mirror of
https://github.com/pretix/pretix.git
synced 2025-12-20 16:32:26 +00:00
Compare commits
261 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d745bcf2c4 | ||
|
|
a1bfe05879 | ||
|
|
f156299cb3 | ||
|
|
023b1535d4 | ||
|
|
ec97dae695 | ||
|
|
f184ca1918 | ||
|
|
7f71ae6e4b | ||
|
|
84bafd94d5 | ||
|
|
ac7502b0a2 | ||
|
|
3c85591568 | ||
|
|
2787935fc6 | ||
|
|
6d432cf824 | ||
|
|
e09853c6c6 | ||
|
|
418c9196ba | ||
|
|
a949fd7fdc | ||
|
|
f9b834b798 | ||
|
|
0747f5b8b8 | ||
|
|
33b34f31d1 | ||
|
|
f93c780e6a | ||
|
|
9722e76e5f | ||
|
|
e33d15429e | ||
|
|
41c69aaa2a | ||
|
|
07ed7526c0 | ||
|
|
1043824853 | ||
|
|
a99a254f5c | ||
|
|
0429a0f811 | ||
|
|
c2ba312bad | ||
|
|
a3ff3cda12 | ||
|
|
aeba2a1e26 | ||
|
|
e57291914c | ||
|
|
7165cc4c3b | ||
|
|
fa5f33d3c6 | ||
|
|
c8df9c187e | ||
|
|
35270e7032 | ||
|
|
898ae3e2bc | ||
|
|
76d0c7be3a | ||
|
|
793832402c | ||
|
|
f6a500cd75 | ||
|
|
7a8f90478a | ||
|
|
6ea4315beb | ||
|
|
f3de5d5c96 | ||
|
|
fdc555f74f | ||
|
|
2505389e61 | ||
|
|
da38396191 | ||
|
|
2abe744bdd | ||
|
|
ce79bfb242 | ||
|
|
748cfa3487 | ||
|
|
eb80cf248e | ||
|
|
65e3efa5a3 | ||
|
|
3388c3ab09 | ||
|
|
65ff065f02 | ||
|
|
0f30958937 | ||
|
|
5cef80d58c | ||
|
|
19c328b6e7 | ||
|
|
fc6b644587 | ||
|
|
190ffe8d24 | ||
|
|
18eedd8a5f | ||
|
|
00667aff11 | ||
|
|
f1cd46f6dc | ||
|
|
674d7673ce | ||
|
|
71800074ca | ||
|
|
a7b331a9b0 | ||
|
|
1d541df381 | ||
|
|
32d32d68d9 | ||
|
|
5375f6aec1 | ||
|
|
99f3360c44 | ||
|
|
d391312aab | ||
|
|
70bf422537 | ||
|
|
86932e8a19 | ||
|
|
2d9bf5ecb9 | ||
|
|
c4e8da8ea4 | ||
|
|
715fdadf95 | ||
|
|
1b53d74aa9 | ||
|
|
66621aee6e | ||
|
|
18333041bb | ||
|
|
b4badaa472 | ||
|
|
a856f29426 | ||
|
|
1dab5149d4 | ||
|
|
4e870b7366 | ||
|
|
a8cbb06bb0 | ||
|
|
0be2043ded | ||
|
|
2554c7f5fc | ||
|
|
3912ceb79d | ||
|
|
593fc69d0c | ||
|
|
cf3c4d26cb | ||
|
|
bc8358cd97 | ||
|
|
e2461ab475 | ||
|
|
f97c97e661 | ||
|
|
1325cf1e7c | ||
|
|
ba8ea0e4d4 | ||
|
|
1c769f2876 | ||
|
|
2dee222482 | ||
|
|
d132cd27f3 | ||
|
|
9a2a4bedeb | ||
|
|
779cefeaad | ||
|
|
b36feb229f | ||
|
|
2e5861958d | ||
|
|
01c3b08583 | ||
|
|
5b81507600 | ||
|
|
75e100f108 | ||
|
|
8b08b43e77 | ||
|
|
9d70fd675c | ||
|
|
72504cd53a | ||
|
|
9056826b68 | ||
|
|
ecf05b2392 | ||
|
|
4aa9f073b3 | ||
|
|
19c2b8d89d | ||
|
|
5e355b4005 | ||
|
|
746c140cdb | ||
|
|
be413693ce | ||
|
|
6cf1074b8d | ||
|
|
504067f325 | ||
|
|
b1cffe9f72 | ||
|
|
c0dd631774 | ||
|
|
66cd63036c | ||
|
|
29a45d3ee4 | ||
|
|
23aba9b5ef | ||
|
|
454f0f6fc8 | ||
|
|
002ff38fba | ||
|
|
dc8bd59715 | ||
|
|
56a2da08df | ||
|
|
4762d6818f | ||
|
|
e99e91d20f | ||
|
|
9fee2d0fbc | ||
|
|
3f30ddc9ab | ||
|
|
641a848f30 | ||
|
|
a582322847 | ||
|
|
a7ec7491ec | ||
|
|
90ae8860dd | ||
|
|
00ca75e119 | ||
|
|
455fb2e560 | ||
|
|
1ec4c524f8 | ||
|
|
75b9b04c65 | ||
|
|
bf0a9675f4 | ||
|
|
853877f2da | ||
|
|
2e44900c43 | ||
|
|
c5085bb46e | ||
|
|
da859b9980 | ||
|
|
b6f30f6996 | ||
|
|
9fde378eac | ||
|
|
52e9525f64 | ||
|
|
80aeeed855 | ||
|
|
d207514c9a | ||
|
|
1286e53b85 | ||
|
|
7c0df5b755 | ||
|
|
8889d8441e | ||
|
|
c60a25f2bc | ||
|
|
a3dd015c23 | ||
|
|
736ecbd7b6 | ||
|
|
8ed41a1276 | ||
|
|
06643232cf | ||
|
|
90399d2567 | ||
|
|
609203196b | ||
|
|
070b871254 | ||
|
|
cbadb2c395 | ||
|
|
0e9951f964 | ||
|
|
6afb954b93 | ||
|
|
bdf1fc2c23 | ||
|
|
9c0c8a95fa | ||
|
|
356a2dc9c5 | ||
|
|
4f5a9284ca | ||
|
|
130b06d26b | ||
|
|
ab4dd9b8de | ||
|
|
bb6b8bd8bb | ||
|
|
2aeceeed08 | ||
|
|
39223f0f65 | ||
|
|
33ba4daadb | ||
|
|
1f9adcce6e | ||
|
|
4d36676cf8 | ||
|
|
821cb54ad0 | ||
|
|
a40951060f | ||
|
|
c6a98fad5a | ||
|
|
d3a0405faa | ||
|
|
664bb9a65b | ||
|
|
06d8464998 | ||
|
|
c9b20d2cf5 | ||
|
|
a198635865 | ||
|
|
4e26df5752 | ||
|
|
5caa874263 | ||
|
|
05939537dd | ||
|
|
0d29f8624f | ||
|
|
0d8db8266d | ||
|
|
09be2c1199 | ||
|
|
da8ecb6e6e | ||
|
|
4240ad43d0 | ||
|
|
c47e41ac8a | ||
|
|
04bfa63a5e | ||
|
|
e311341d01 | ||
|
|
1f21d1420c | ||
|
|
5c1d637637 | ||
|
|
ecc72d54ad | ||
|
|
ff8a3ea1c3 | ||
|
|
924bad3484 | ||
|
|
808df7a982 | ||
|
|
7f196ef6fe | ||
|
|
44ef9b608a | ||
|
|
62b1aec3b0 | ||
|
|
571fef4ed8 | ||
|
|
5308099d84 | ||
|
|
a5e41aae50 | ||
|
|
54e4ad1a1c | ||
|
|
b6e4163c2b | ||
|
|
1aa1583eae | ||
|
|
fc210cf06d | ||
|
|
3459f3e4c4 | ||
|
|
903a7f122d | ||
|
|
246d150511 | ||
|
|
2cd5094393 | ||
|
|
a665836a60 | ||
|
|
e7d2d0ddab | ||
|
|
1d722da5af | ||
|
|
90475e4159 | ||
|
|
3690dba73b | ||
|
|
0a55fdbc49 | ||
|
|
eac32c25ba | ||
|
|
c2345d200a | ||
|
|
663fd8a57a | ||
|
|
a204302910 | ||
|
|
13e464bcf1 | ||
|
|
8b2b98c128 | ||
|
|
a5f806d975 | ||
|
|
b51bd2118e | ||
|
|
089938c3ee | ||
|
|
574fe9094c | ||
|
|
6fdd32de6a | ||
|
|
b3e95f54dd | ||
|
|
55d8639ecc | ||
|
|
978130551a | ||
|
|
a452bf816c | ||
|
|
99c3981e2d | ||
|
|
87a514ca8b | ||
|
|
937b967259 | ||
|
|
242bfc0023 | ||
|
|
eed309636f | ||
|
|
0944929818 | ||
|
|
2592b8b221 | ||
|
|
fcdd852860 | ||
|
|
f43585bf36 | ||
|
|
5a034f1339 | ||
|
|
0eb5b73502 | ||
|
|
41e878fabb | ||
|
|
93a7c5df09 | ||
|
|
c71c78cf69 | ||
|
|
66af5973ec | ||
|
|
921b28f8d4 | ||
|
|
0aa5df8a17 | ||
|
|
65f6da8d9e | ||
|
|
827afd6d39 | ||
|
|
97561819e2 | ||
|
|
d02e8b1dcf | ||
|
|
7ad46addee | ||
|
|
956b6f43e4 | ||
|
|
cc493968a1 | ||
|
|
fd6fb52a11 | ||
|
|
ef11084613 | ||
|
|
2a85f327fd | ||
|
|
bd9d8ce0ad | ||
|
|
d71db5a8ad | ||
|
|
755d1b5692 | ||
|
|
0fad2ab728 | ||
|
|
2b9461e847 |
@@ -1,3 +1,10 @@
|
|||||||
doc/
|
doc/
|
||||||
env/
|
env/
|
||||||
res/
|
res/
|
||||||
|
local/
|
||||||
|
.git/
|
||||||
|
pretixeu/
|
||||||
|
src/data/
|
||||||
|
src/pretix/static.dist/
|
||||||
|
src/dist/
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
user www-data www-data;
|
user www-data www-data;
|
||||||
worker_processes 1;
|
worker_processes auto;
|
||||||
pid /var/run/nginx.pid;
|
pid /var/run/nginx.pid;
|
||||||
daemon off;
|
daemon off;
|
||||||
|
worker_rlimit_nofile 262144;
|
||||||
|
|
||||||
events {
|
events {
|
||||||
worker_connections 4096;
|
worker_connections 16384;
|
||||||
|
multi_accept on;
|
||||||
|
use epoll;
|
||||||
}
|
}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ cd /pretix/src
|
|||||||
export DJANGO_SETTINGS_MODULE=production_settings
|
export DJANGO_SETTINGS_MODULE=production_settings
|
||||||
export DATA_DIR=/data/
|
export DATA_DIR=/data/
|
||||||
export HOME=/pretix
|
export HOME=/pretix
|
||||||
export NUM_WORKERS=$((2 * $(nproc --all)))
|
|
||||||
|
|
||||||
AUTOMIGRATE=${AUTOMIGRATE:-yes}
|
AUTOMIGRATE=${AUTOMIGRATE:-yes}
|
||||||
|
NUM_WORKERS_DEFAULT=$((2 * $(nproc --all)))
|
||||||
|
export NUM_WORKERS=${NUM_WORKERS:-$NUM_WORKERS_DEFAULT}
|
||||||
|
|
||||||
if [ ! -d /data/logs ]; then
|
if [ ! -d /data/logs ]; then
|
||||||
mkdir /data/logs;
|
mkdir /data/logs;
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ Example::
|
|||||||
``csp_log``
|
``csp_log``
|
||||||
Log violations of the Content Security Policy (CSP). Defaults to ``on``.
|
Log violations of the Content Security Policy (CSP). Defaults to ``on``.
|
||||||
|
|
||||||
|
``csp_additional_header``
|
||||||
|
Specifies a CSP header that will be **merged** with pretix's default header. For example, if you set this
|
||||||
|
to ``script-src https://mycdn.com``, pretix will add ``https://mycdn.com`` as an **additional** allowed source
|
||||||
|
to all CSP headers. Empty by default.
|
||||||
|
|
||||||
``loglevel``
|
``loglevel``
|
||||||
Set console and file log level (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``.
|
Set console and file log level (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``.
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
|||||||
user=pretix
|
user=pretix
|
||||||
; Replace with the password you chose above
|
; Replace with the password you chose above
|
||||||
password=*********
|
password=*********
|
||||||
; In most docker setups, 172.17.0.1 is the address of the docker host. Adjuts
|
; In most docker setups, 172.17.0.1 is the address of the docker host. Adjust
|
||||||
; this to wherever your database is running, e.g. the name of a linked container
|
; this to wherever your database is running, e.g. the name of a linked container
|
||||||
; or of a mounted MySQL socket.
|
; or of a mounted MySQL socket.
|
||||||
host=172.17.0.1
|
host=172.17.0.1
|
||||||
@@ -295,7 +295,9 @@ on one machine after each upgrade manually, otherwise multiple containers might
|
|||||||
database schema at the same time.
|
database schema at the same time.
|
||||||
|
|
||||||
To run only the ``pretix-web`` component of pretix as well as a nginx server serving static files, you
|
To run only the ``pretix-web`` component of pretix as well as a nginx server serving static files, you
|
||||||
can invoke the container with ``docker run … pretix/standalone:stable web`` (instead of ``all``).
|
can invoke the container with ``docker run … pretix/standalone:stable web`` (instead of ``all``). You
|
||||||
|
can adjust the number of ``gunicorn`` processes with the ``NUM_WORKERS`` environment variable (defaults to
|
||||||
|
two times the number of CPUs detected).
|
||||||
|
|
||||||
To run only ``pretix-worker``, you can run ``docker run … pretix/standalone:stable taskworker``. You can
|
To run only ``pretix-worker``, you can run ``docker run … pretix/standalone:stable taskworker``. You can
|
||||||
also pass arguments to limit the worker to specific queues or to change the number of concurrent task
|
also pass arguments to limit the worker to specific queues or to change the number of concurrent task
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ pretix_model_instances
|
|||||||
the ``model`` name. Starting with pretix 3.11, these numbers might only be approximate for
|
the ``model`` name. Starting with pretix 3.11, these numbers might only be approximate for
|
||||||
most tables when running on PostgreSQL to mitigate performance impact.
|
most tables when running on PostgreSQL to mitigate performance impact.
|
||||||
|
|
||||||
|
pretix_celery_tasks_queued_count
|
||||||
|
The number of background tasks in the worker queue, labeled with ``queue``.
|
||||||
|
|
||||||
|
pretix_celery_tasks_queued_age_seconds
|
||||||
|
The age of the longest-waiting in the worker queue in seconds, labeled with ``queue``.
|
||||||
|
|
||||||
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
||||||
.. _Prometheus: https://prometheus.io/
|
.. _Prometheus: https://prometheus.io/
|
||||||
.. _cProfile: https://docs.python.org/3/library/profile.html
|
.. _cProfile: https://docs.python.org/3/library/profile.html
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
|
|||||||
constructed from a number of
|
constructed from a number of
|
||||||
days before the base point
|
days before the base point
|
||||||
and the base point.
|
and the base point.
|
||||||
|
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
|
||||||
|
specifiers in requests
|
||||||
|
(see below).
|
||||||
===================== ============================ ===================================
|
===================== ============================ ===================================
|
||||||
|
|
||||||
Query parameters
|
Query parameters
|
||||||
@@ -227,4 +230,48 @@ We store idempotency keys for 24 hours, so you should never retry a request afte
|
|||||||
All ``POST``, ``PUT``, ``PATCH``, or ``DELETE`` api calls support idempotency keys. Adding an idempotency key to a
|
All ``POST``, ``PUT``, ``PATCH``, or ``DELETE`` api calls support idempotency keys. Adding an idempotency key to a
|
||||||
``GET``, ``HEAD``, or ``OPTIONS`` request has no effect.
|
``GET``, ``HEAD``, or ``OPTIONS`` request has no effect.
|
||||||
|
|
||||||
|
|
||||||
|
File upload
|
||||||
|
-----------
|
||||||
|
|
||||||
|
In some places, the API supports working with files, for example when setting the picture of a product. In this case,
|
||||||
|
you will first need to make a separate request to our file upload endpoint:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/upload HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k
|
||||||
|
Content-Type: image/png
|
||||||
|
Content-Disposition: attachment; filename="logo.png"
|
||||||
|
Content-Length: 1234
|
||||||
|
|
||||||
|
<raw file content>
|
||||||
|
|
||||||
|
Note that the ``Content-Type`` and ``Content-Disposition`` headers are required. If the upload was successful, you will
|
||||||
|
receive a JSON response with the ID of the file:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 201 Created
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1"
|
||||||
|
}
|
||||||
|
|
||||||
|
You can then use this file ID in the request you want to use it in. File IDs are currently valid for 24 hours and can only
|
||||||
|
be used using the same authorization method and user that was used to upload them.
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
PATCH /api/v1/organizers/test/events/test/items/3/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"picture": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
|
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ item_meta_properties object Item-specific m
|
|||||||
valid_keys object Cryptographic keys for non-default signature schemes.
|
valid_keys object Cryptographic keys for non-default signature schemes.
|
||||||
For performance reason, value is omitted in lists and
|
For performance reason, value is omitted in lists and
|
||||||
only contained in detail views. Value can be cached.
|
only contained in detail views. Value can be cached.
|
||||||
|
sales_channels list A list of sales channels this event is available for
|
||||||
|
sale on.
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +93,11 @@ valid_keys object Cryptographic k
|
|||||||
|
|
||||||
The attribute ``valid_keys`` has been added.
|
The attribute ``valid_keys`` has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 3.14
|
||||||
|
|
||||||
|
The attribute ``sales_channels`` has been added.
|
||||||
|
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
|
|
||||||
@@ -147,11 +154,16 @@ Endpoints
|
|||||||
"timezone": "Europe/Berlin",
|
"timezone": "Europe/Berlin",
|
||||||
"item_meta_properties": {},
|
"item_meta_properties": {},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"pretix.plugins.banktransfer"
|
"pretix.plugins.banktransfer",
|
||||||
"pretix.plugins.stripe"
|
"pretix.plugins.stripe",
|
||||||
"pretix.plugins.paypal"
|
"pretix.plugins.paypal",
|
||||||
"pretix.plugins.ticketoutputpdf"
|
"pretix.plugins.ticketoutputpdf"
|
||||||
],
|
],
|
||||||
|
"sales_channels": [
|
||||||
|
"web",
|
||||||
|
"pretixpos",
|
||||||
|
"resellers"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -170,6 +182,7 @@ Endpoints
|
|||||||
only contain the events matching the set criteria. Providing ``?attr[Format]=Seminar`` would return only those
|
only contain the events matching the set criteria. Providing ``?attr[Format]=Seminar`` would return only those
|
||||||
events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that have no value
|
events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that have no value
|
||||||
set. Please note that this filter will respect default values set on organizer level.
|
set. Please note that this filter will respect default values set on organizer level.
|
||||||
|
:query sales_channel: If set to a sales channel identifier, only events allowed to be sold on the specified sales channel are returned.
|
||||||
:param organizer: The ``slug`` field of a valid organizer
|
:param organizer: The ``slug`` field of a valid organizer
|
||||||
:statuscode 200: no error
|
:statuscode 200: no error
|
||||||
:statuscode 401: Authentication failure
|
:statuscode 401: Authentication failure
|
||||||
@@ -219,16 +232,21 @@ Endpoints
|
|||||||
"timezone": "Europe/Berlin",
|
"timezone": "Europe/Berlin",
|
||||||
"item_meta_properties": {},
|
"item_meta_properties": {},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"pretix.plugins.banktransfer"
|
"pretix.plugins.banktransfer",
|
||||||
"pretix.plugins.stripe"
|
"pretix.plugins.stripe",
|
||||||
"pretix.plugins.paypal"
|
"pretix.plugins.paypal",
|
||||||
"pretix.plugins.ticketoutputpdf"
|
"pretix.plugins.ticketoutputpdf"
|
||||||
],
|
],
|
||||||
"valid_keys": {
|
"valid_keys": {
|
||||||
"pretix_sig1": [
|
"pretix_sig1": [
|
||||||
"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo="
|
"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo="
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"sales_channels": [
|
||||||
|
"web",
|
||||||
|
"pretixpos",
|
||||||
|
"resellers"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to fetch
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
@@ -279,6 +297,11 @@ Endpoints
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"pretix.plugins.stripe",
|
"pretix.plugins.stripe",
|
||||||
"pretix.plugins.paypal"
|
"pretix.plugins.paypal"
|
||||||
|
],
|
||||||
|
"sales_channels": [
|
||||||
|
"web",
|
||||||
|
"pretixpos",
|
||||||
|
"resellers"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +337,11 @@ Endpoints
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"pretix.plugins.stripe",
|
"pretix.plugins.stripe",
|
||||||
"pretix.plugins.paypal"
|
"pretix.plugins.paypal"
|
||||||
|
],
|
||||||
|
"sales_channels": [
|
||||||
|
"web",
|
||||||
|
"pretixpos",
|
||||||
|
"resellers"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +397,11 @@ Endpoints
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"pretix.plugins.stripe",
|
"pretix.plugins.stripe",
|
||||||
"pretix.plugins.paypal"
|
"pretix.plugins.paypal"
|
||||||
|
],
|
||||||
|
"sales_channels": [
|
||||||
|
"web",
|
||||||
|
"pretixpos",
|
||||||
|
"resellers"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +437,11 @@ Endpoints
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"pretix.plugins.stripe",
|
"pretix.plugins.stripe",
|
||||||
"pretix.plugins.paypal"
|
"pretix.plugins.paypal"
|
||||||
|
],
|
||||||
|
"sales_channels": [
|
||||||
|
"web",
|
||||||
|
"pretixpos",
|
||||||
|
"resellers"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +511,11 @@ Endpoints
|
|||||||
"pretix.plugins.stripe",
|
"pretix.plugins.stripe",
|
||||||
"pretix.plugins.paypal",
|
"pretix.plugins.paypal",
|
||||||
"pretix.plugins.pretixdroid"
|
"pretix.plugins.pretixdroid"
|
||||||
|
],
|
||||||
|
"sales_channels": [
|
||||||
|
"web",
|
||||||
|
"pretixpos",
|
||||||
|
"resellers"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,28 @@ expires datetime Expiry date (or
|
|||||||
conditions string Special terms and conditions for this card (or ``null``)
|
conditions string Special terms and conditions for this card (or ``null``)
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
The gift card transaction resource contains the following public fields:
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
id integer Internal ID of the gift card transaction
|
||||||
|
datetime datetime Creation date of the transaction
|
||||||
|
value money (string) Transaction amount
|
||||||
|
event string Event slug, if the gift card was used in the web shop (or ``null``)
|
||||||
|
order string Order code, if the gift card was used in the web shop (or ``null``)
|
||||||
|
text string Custom text of the transaction (or ``null``)
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
|
|
||||||
|
.. versionadded:: 3.14
|
||||||
|
|
||||||
|
The transaction list endpoint was added.
|
||||||
|
|
||||||
.. http:get:: /api/v1/organizers/(organizer)/giftcards/
|
.. http:get:: /api/v1/organizers/(organizer)/giftcards/
|
||||||
|
|
||||||
Returns a list of all gift cards issued by a given organizer.
|
Returns a list of all gift cards issued by a given organizer.
|
||||||
@@ -250,3 +269,45 @@ Endpoints
|
|||||||
:statuscode 401: Authentication failure
|
:statuscode 401: Authentication failure
|
||||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
|
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
|
||||||
:statuscode 409: There is not sufficient credit on the gift card.
|
:statuscode 409: There is not sufficient credit on the gift card.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/giftcards/(id)/transactions/
|
||||||
|
|
||||||
|
List all transactions of a gift card.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/giftcards/1/transactions/ 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": 82,
|
||||||
|
"datetime": "2020-06-22T15:41:42.800534Z",
|
||||||
|
"value": "50.00",
|
||||||
|
"event": "democon",
|
||||||
|
"order": "FXQYW",
|
||||||
|
"text": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to view
|
||||||
|
:param id: The ``id`` field of the gift card to view
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ admission boolean ``true`` for it
|
|||||||
(such as primary tickets) and ``false`` for others
|
(such as primary tickets) and ``false`` for others
|
||||||
(such as add-ons or merchandise).
|
(such as add-ons or merchandise).
|
||||||
position integer An integer, used for sorting
|
position integer An integer, used for sorting
|
||||||
picture string A product picture to be displayed in the shop
|
picture file A product picture to be displayed in the shop
|
||||||
(read-only, can be ``null``).
|
(can be ``null``).
|
||||||
sales_channels list of strings Sales channels this product is available on, such as
|
sales_channels list of strings Sales channels this product is available on, such as
|
||||||
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
|
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
|
||||||
available_from datetime The first date time at which this item can be bought
|
available_from datetime The first date time at which this item can be bought
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ testmode boolean If ``true``, th
|
|||||||
test mode. Only orders in test mode can be deleted.
|
test mode. Only orders in test mode can be deleted.
|
||||||
secret string The secret contained in the link sent to the customer
|
secret string The secret contained in the link sent to the customer
|
||||||
email string The customer email address
|
email string The customer email address
|
||||||
|
phone string The customer phone number
|
||||||
locale string The locale used for communication with this customer
|
locale string The locale used for communication with this customer
|
||||||
sales_channel string Channel this sale was created through, such as
|
sales_channel string Channel this sale was created through, such as
|
||||||
``"web"``.
|
``"web"``.
|
||||||
@@ -167,6 +168,10 @@ last_modified datetime Last modificati
|
|||||||
|
|
||||||
The ``subevent_before`` query parameter has been added.
|
The ``subevent_before`` query parameter has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 3.14
|
||||||
|
|
||||||
|
The ``phone`` attribute has been added.
|
||||||
|
|
||||||
|
|
||||||
.. _order-position-resource:
|
.. _order-position-resource:
|
||||||
|
|
||||||
@@ -320,7 +325,8 @@ state string Payment state,
|
|||||||
source string How this refund has been created, one of ``buyer``, ``admin``, or ``external``
|
source string How this refund has been created, one of ``buyer``, ``admin``, or ``external``
|
||||||
amount money (string) Payment amount
|
amount money (string) Payment amount
|
||||||
created datetime Date and time of creation of this payment
|
created datetime Date and time of creation of this payment
|
||||||
payment_date datetime Date and time of completion of this payment (or ``null``)
|
comment string Reason for refund (shown to the customer in some cases, can be ``null``).
|
||||||
|
execution_date datetime Date and time of completion of this refund (or ``null``)
|
||||||
provider string Identification string of the payment provider
|
provider string Identification string of the payment provider
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
@@ -372,6 +378,7 @@ List of all orders
|
|||||||
"secret": "k24fiuwvu8kxz3y1",
|
"secret": "k24fiuwvu8kxz3y1",
|
||||||
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
||||||
"email": "tester@example.org",
|
"email": "tester@example.org",
|
||||||
|
"phone": "+491234567",
|
||||||
"locale": "en",
|
"locale": "en",
|
||||||
"sales_channel": "web",
|
"sales_channel": "web",
|
||||||
"datetime": "2017-12-01T10:00:00Z",
|
"datetime": "2017-12-01T10:00:00Z",
|
||||||
@@ -539,6 +546,7 @@ Fetching individual orders
|
|||||||
"secret": "k24fiuwvu8kxz3y1",
|
"secret": "k24fiuwvu8kxz3y1",
|
||||||
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
||||||
"email": "tester@example.org",
|
"email": "tester@example.org",
|
||||||
|
"phone": "+491234567",
|
||||||
"locale": "en",
|
"locale": "en",
|
||||||
"sales_channel": "web",
|
"sales_channel": "web",
|
||||||
"datetime": "2017-12-01T10:00:00Z",
|
"datetime": "2017-12-01T10:00:00Z",
|
||||||
@@ -705,6 +713,8 @@ Updating order fields
|
|||||||
|
|
||||||
* ``email``
|
* ``email``
|
||||||
|
|
||||||
|
* ``phone``
|
||||||
|
|
||||||
* ``checkin_attention``
|
* ``checkin_attention``
|
||||||
|
|
||||||
* ``locale``
|
* ``locale``
|
||||||
@@ -940,9 +950,9 @@ Creating orders
|
|||||||
during order generation and is not respected automatically when the order changes later.)
|
during order generation and is not respected automatically when the order changes later.)
|
||||||
|
|
||||||
* ``force`` (optional). If set to ``true``, quotas will be ignored.
|
* ``force`` (optional). If set to ``true``, quotas will be ignored.
|
||||||
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of
|
* ``send_email`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of
|
||||||
whether these emails are enabled for certain sales channels. Defaults to
|
whether these emails are enabled for certain sales channels. Defaults to
|
||||||
``false``.
|
``false``. Used to be ``send_mail`` before pretix 3.14.
|
||||||
|
|
||||||
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
||||||
to incrementing integers starting with ``1``. Then, you can reference one of these
|
to incrementing integers starting with ``1``. Then, you can reference one of these
|
||||||
@@ -1697,6 +1707,67 @@ Order position ticket download
|
|||||||
Manipulating individual positions
|
Manipulating individual positions
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
|
.. versionchanged:: 3.15
|
||||||
|
|
||||||
|
The ``PATCH`` method has been added for individual positions.
|
||||||
|
|
||||||
|
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
||||||
|
|
||||||
|
Updates specific fields on an order position. Currently, only the following fields are supported:
|
||||||
|
|
||||||
|
* ``attendee_email``
|
||||||
|
|
||||||
|
* ``attendee_name_parts`` or ``attendee_name``
|
||||||
|
|
||||||
|
* ``company``
|
||||||
|
|
||||||
|
* ``street``
|
||||||
|
|
||||||
|
* ``zipcode``
|
||||||
|
|
||||||
|
* ``city``
|
||||||
|
|
||||||
|
* ``country``
|
||||||
|
|
||||||
|
* ``state``
|
||||||
|
|
||||||
|
* ``answers``: If specified, you will need to provide **all** answers for this order position.
|
||||||
|
Validation is handled the same way as when creating orders through the API. You are therefore
|
||||||
|
expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier``
|
||||||
|
and ``option_identifiers`` will be ignored.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
PATCH /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"attendee_email": "other@example.org"
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
(Full order resource, see above.)
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer of the event
|
||||||
|
:param event: The ``slug`` field of the event
|
||||||
|
:param id: The ``id`` field of the order position to update
|
||||||
|
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: The order could not be updated due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
|
||||||
|
|
||||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
||||||
|
|
||||||
Deletes an order position, identified by its internal ID.
|
Deletes an order position, identified by its internal ID.
|
||||||
@@ -1976,6 +2047,7 @@ Order payment endpoints
|
|||||||
"amount": "23.00",
|
"amount": "23.00",
|
||||||
"payment_date": "2017-12-04T12:13:12Z",
|
"payment_date": "2017-12-04T12:13:12Z",
|
||||||
"info": {},
|
"info": {},
|
||||||
|
"send_email": false,
|
||||||
"provider": "banktransfer"
|
"provider": "banktransfer"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2048,6 +2120,7 @@ Order refund endpoints
|
|||||||
"payment": 1,
|
"payment": 1,
|
||||||
"created": "2017-12-01T10:00:00Z",
|
"created": "2017-12-01T10:00:00Z",
|
||||||
"execution_date": "2017-12-04T12:13:12Z",
|
"execution_date": "2017-12-04T12:13:12Z",
|
||||||
|
"comment": "Cancellation",
|
||||||
"provider": "banktransfer"
|
"provider": "banktransfer"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2090,6 +2163,7 @@ Order refund endpoints
|
|||||||
"payment": 1,
|
"payment": 1,
|
||||||
"created": "2017-12-01T10:00:00Z",
|
"created": "2017-12-01T10:00:00Z",
|
||||||
"execution_date": "2017-12-04T12:13:12Z",
|
"execution_date": "2017-12-04T12:13:12Z",
|
||||||
|
"comment": "Cancellation",
|
||||||
"provider": "banktransfer"
|
"provider": "banktransfer"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2124,6 +2198,7 @@ Order refund endpoints
|
|||||||
"amount": "23.00",
|
"amount": "23.00",
|
||||||
"payment": 1,
|
"payment": 1,
|
||||||
"execution_date": null,
|
"execution_date": null,
|
||||||
|
"comment": "Cancellation",
|
||||||
"provider": "manual",
|
"provider": "manual",
|
||||||
"mark_canceled": false,
|
"mark_canceled": false,
|
||||||
"mark_pending": true
|
"mark_pending": true
|
||||||
@@ -2145,6 +2220,7 @@ Order refund endpoints
|
|||||||
"payment": 1,
|
"payment": 1,
|
||||||
"created": "2017-12-01T10:00:00Z",
|
"created": "2017-12-01T10:00:00Z",
|
||||||
"execution_date": null,
|
"execution_date": null,
|
||||||
|
"comment": "Cancellation",
|
||||||
"provider": "manual"
|
"provider": "manual"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,3 +90,120 @@ Endpoints
|
|||||||
:statuscode 200: no error
|
:statuscode 200: no error
|
||||||
:statuscode 401: Authentication failure
|
:statuscode 401: Authentication failure
|
||||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||||
|
|
||||||
|
Organizer settings
|
||||||
|
------------------
|
||||||
|
|
||||||
|
pretix organizers and events have lots and lots of parameters of different types that are stored in a key-value store on our system.
|
||||||
|
Since many of these settings depend on each other in complex ways, we can not give direct access to all of these
|
||||||
|
settings through the API. However, we do expose many of the simple and useful flags through the API.
|
||||||
|
|
||||||
|
Please note that the available settings flags change between pretix versions, and we do not give a guarantee on backwards-compatibility like with other parts of the API.
|
||||||
|
Therefore, we're also not including a list of the options here, but instead recommend to look at the endpoint output
|
||||||
|
to see available options. The ``explain=true`` flag enables a verbose mode that provides you with human-readable
|
||||||
|
information about the properties.
|
||||||
|
|
||||||
|
.. note:: Please note that this is not a complete representation of all organizer settings. You will find more settings
|
||||||
|
in the web interface.
|
||||||
|
|
||||||
|
.. warning:: This API is intended for advanced users. Even though we take care to validate your input, you will be
|
||||||
|
able to break your shops using this API by creating situations of conflicting settings. Please take care.
|
||||||
|
|
||||||
|
.. versionchanged:: 3.14
|
||||||
|
|
||||||
|
Initial support for settings has been added to the API.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/settings/
|
||||||
|
|
||||||
|
Get current values of organizer settings.
|
||||||
|
|
||||||
|
Permission required: "Can change organizer settings"
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/settings/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example standard response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"event_list_type": "calendar",
|
||||||
|
…
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example verbose response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"event_list_type":
|
||||||
|
{
|
||||||
|
"value": "calendar",
|
||||||
|
"label": "Default overview style",
|
||||||
|
"help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
…
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to access
|
||||||
|
:query explain: Set to ``true`` to enable verbose response mode
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||||
|
|
||||||
|
.. http:patch:: /api/v1/organizers/(organizer)/settings/
|
||||||
|
|
||||||
|
Updates organizer settings. Note that ``PUT`` is not allowed here, only ``PATCH``.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
Settings can be stored at different levels in pretix. If a value is not set on organizer level, a default setting
|
||||||
|
from a higher level (global) will be returned. If you explicitly set a setting on organizer level, it
|
||||||
|
will no longer be inherited from the higher levels. Therefore, we recommend you to send only settings that you
|
||||||
|
explicitly want to set on organizer level. To unset a settings, pass ``null``.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
PATCH /api/v1/organizers/bigevents/settings/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"event_list_type": "calendar"
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"event_list_type": "calendar",
|
||||||
|
…
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to update
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 400: The organizer could not be updated due to invalid submitted data.
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
.. spelling:: checkin
|
.. spelling::
|
||||||
|
|
||||||
|
checkin
|
||||||
|
datetime
|
||||||
|
|
||||||
.. _rest-questions:
|
.. _rest-questions:
|
||||||
|
|
||||||
@@ -53,6 +56,12 @@ options list of objects In case of ques
|
|||||||
├ identifier string An arbitrary string that can be used for matching with
|
├ identifier string An arbitrary string that can be used for matching with
|
||||||
other sources.
|
other sources.
|
||||||
└ answer multi-lingual string The displayed value of this option
|
└ answer multi-lingual string The displayed value of this option
|
||||||
|
valid_number_min string Minimum value for number questions (optional)
|
||||||
|
valid_number_max string Maximum value for number questions (optional)
|
||||||
|
valid_date_min date Minimum value for date questions (optional)
|
||||||
|
valid_date_max date Maximum value for date questions (optional)
|
||||||
|
valid_datetime_min datetime Minimum value for date and time questions (optional)
|
||||||
|
valid_datetime_max datetime Maximum value for date and time questions (optional)
|
||||||
dependency_question integer Internal ID of a different question. The current
|
dependency_question integer Internal ID of a different question. The current
|
||||||
question will only be shown if the question given in
|
question will only be shown if the question given in
|
||||||
this attribute is set to the value given in
|
this attribute is set to the value given in
|
||||||
@@ -92,6 +101,10 @@ dependency_value string An old version
|
|||||||
|
|
||||||
The attribute ``help_text`` has been added.
|
The attribute ``help_text`` has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 3.14
|
||||||
|
|
||||||
|
The attributes ``valid_*`` have been added.
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
|
|
||||||
@@ -137,6 +150,12 @@ Endpoints
|
|||||||
"ask_during_checkin": false,
|
"ask_during_checkin": false,
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"print_on_invoice": false,
|
"print_on_invoice": false,
|
||||||
|
"valid_number_min": null,
|
||||||
|
"valid_number_max": null,
|
||||||
|
"valid_date_min": null,
|
||||||
|
"valid_date_max": null,
|
||||||
|
"valid_datetime_min": null,
|
||||||
|
"valid_datetime_max": null,
|
||||||
"dependency_question": null,
|
"dependency_question": null,
|
||||||
"dependency_value": null,
|
"dependency_value": null,
|
||||||
"dependency_values": [],
|
"dependency_values": [],
|
||||||
@@ -208,6 +227,12 @@ Endpoints
|
|||||||
"ask_during_checkin": false,
|
"ask_during_checkin": false,
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"print_on_invoice": false,
|
"print_on_invoice": false,
|
||||||
|
"valid_number_min": null,
|
||||||
|
"valid_number_max": null,
|
||||||
|
"valid_date_min": null,
|
||||||
|
"valid_date_max": null,
|
||||||
|
"valid_datetime_min": null,
|
||||||
|
"valid_datetime_max": null,
|
||||||
"dependency_question": null,
|
"dependency_question": null,
|
||||||
"dependency_value": null,
|
"dependency_value": null,
|
||||||
"dependency_values": [],
|
"dependency_values": [],
|
||||||
@@ -302,6 +327,12 @@ Endpoints
|
|||||||
"dependency_question": null,
|
"dependency_question": null,
|
||||||
"dependency_value": null,
|
"dependency_value": null,
|
||||||
"dependency_values": [],
|
"dependency_values": [],
|
||||||
|
"valid_number_min": null,
|
||||||
|
"valid_number_max": null,
|
||||||
|
"valid_date_min": null,
|
||||||
|
"valid_date_max": null,
|
||||||
|
"valid_datetime_min": null,
|
||||||
|
"valid_datetime_max": null,
|
||||||
"options": [
|
"options": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -377,6 +408,12 @@ Endpoints
|
|||||||
"dependency_question": null,
|
"dependency_question": null,
|
||||||
"dependency_value": null,
|
"dependency_value": null,
|
||||||
"dependency_values": [],
|
"dependency_values": [],
|
||||||
|
"valid_number_min": null,
|
||||||
|
"valid_number_max": null,
|
||||||
|
"valid_date_min": null,
|
||||||
|
"valid_date_max": null,
|
||||||
|
"valid_datetime_min": null,
|
||||||
|
"valid_datetime_max": null,
|
||||||
"options": [
|
"options": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ Control panel views
|
|||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
If you want to add a custom view to the control area of an event, just register an URL in your
|
If you want to add a custom view to the control area of an event, just register an URL in your
|
||||||
``urls.py`` that lives in the ``/control/`` subpath::
|
``urls.py`` that lives in the ``/control/`` subpath:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from django.conf.urls import url
|
from django.conf.urls import url
|
||||||
|
|
||||||
@@ -44,7 +46,9 @@ If only the ``organizer`` parameter is present, it will be ensured that:
|
|||||||
* The user has permission to access view the current organizer
|
* The user has permission to access view the current organizer
|
||||||
|
|
||||||
If you want to require specific permission types, we provide you with a decorator or a mixin for
|
If you want to require specific permission types, we provide you with a decorator or a mixin for
|
||||||
your views::
|
your views:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from pretix.control.permissions import (
|
from pretix.control.permissions import (
|
||||||
event_permission_required, EventPermissionRequiredMixin
|
event_permission_required, EventPermissionRequiredMixin
|
||||||
@@ -61,8 +65,9 @@ your views::
|
|||||||
...
|
...
|
||||||
|
|
||||||
Similarly, there is ``organizer_permission_required`` and ``OrganizerPermissionRequiredMixin``. In case of
|
Similarly, there is ``organizer_permission_required`` and ``OrganizerPermissionRequiredMixin``. In case of
|
||||||
event-related views, there is also a signal that allows you to add the view to the event navigation like this::
|
event-related views, there is also a signal that allows you to add the view to the event navigation like this:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from django.urls import resolve, reverse
|
from django.urls import resolve, reverse
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -90,7 +95,9 @@ Event settings view
|
|||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
A special case of a control panel view is a view hooked into the event settings page. For this case, there is a
|
A special case of a control panel view is a view hooked into the event settings page. For this case, there is a
|
||||||
special navigation signal::
|
special navigation signal:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
@receiver(nav_event_settings, dispatch_uid='friends_tickets_nav_settings')
|
@receiver(nav_event_settings, dispatch_uid='friends_tickets_nav_settings')
|
||||||
def navbar_settings(sender, request, **kwargs):
|
def navbar_settings(sender, request, **kwargs):
|
||||||
@@ -105,7 +112,9 @@ special navigation signal::
|
|||||||
}]
|
}]
|
||||||
|
|
||||||
Also, your view should inherit from ``EventSettingsViewMixin`` and your template from ``pretixcontrol/event/settings_base.html``
|
Also, your view should inherit from ``EventSettingsViewMixin`` and your template from ``pretixcontrol/event/settings_base.html``
|
||||||
for good integration. If you just want to display a form, you could do it like the following::
|
for good integration. If you just want to display a form, you could do it like the following:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
class MySettingsView(EventSettingsViewMixin, EventSettingsFormView):
|
class MySettingsView(EventSettingsViewMixin, EventSettingsFormView):
|
||||||
model = Event
|
model = Event
|
||||||
@@ -147,7 +156,9 @@ Including a custom view into the participant-facing frontend is a little bit dif
|
|||||||
no path prefix like ``control/``.
|
no path prefix like ``control/``.
|
||||||
|
|
||||||
First, define your URL in your ``urls.py``, but this time in the ``event_patterns`` section and wrapped by
|
First, define your URL in your ``urls.py``, but this time in the ``event_patterns`` section and wrapped by
|
||||||
``event_url``::
|
``event_url``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from pretix.multidomain import event_url
|
from pretix.multidomain import event_url
|
||||||
|
|
||||||
@@ -182,8 +193,9 @@ standard Django request handling: There are `ViewSets`_ to group related views i
|
|||||||
automatically build URL configurations from them.
|
automatically build URL configurations from them.
|
||||||
|
|
||||||
To integrate a custom viewset with pretix' REST API, you can just register with one of our routers within the
|
To integrate a custom viewset with pretix' REST API, you can just register with one of our routers within the
|
||||||
``urls.py`` module of your plugin::
|
``urls.py`` module of your plugin:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from pretix.api.urls import event_router, router, orga_router
|
from pretix.api.urls import event_router, router, orga_router
|
||||||
|
|
||||||
@@ -200,7 +212,9 @@ in the control panel. However, you need to make sure on your own only to return
|
|||||||
.event`` and ``request.organizer`` are available as usual.
|
.event`` and ``request.organizer`` are available as usual.
|
||||||
|
|
||||||
To require a special permission like ``can_view_orders``, you do not need to inherit from a special ViewSet base
|
To require a special permission like ``can_view_orders``, you do not need to inherit from a special ViewSet base
|
||||||
class, you can just set the ``permission`` attribute on your viewset::
|
class, you can just set the ``permission`` attribute on your viewset:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
class MyViewSet(ModelViewSet):
|
class MyViewSet(ModelViewSet):
|
||||||
permission = 'can_view_orders'
|
permission = 'can_view_orders'
|
||||||
@@ -208,8 +222,9 @@ class, you can just set the ``permission`` attribute on your viewset::
|
|||||||
|
|
||||||
If you want to check the permission only for some methods of your viewset, you have to do it yourself. Note here that
|
If you want to check the permission only for some methods of your viewset, you have to do it yourself. Note here that
|
||||||
API authentications can be done via user sessions or API tokens and you should therefore check something like the
|
API authentications can be done via user sessions or API tokens and you should therefore check something like the
|
||||||
following::
|
following:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user)
|
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user)
|
||||||
if perm_holder.has_event_permission(request.event.organizer, request.event, 'can_view_orders'):
|
if perm_holder.has_event_permission(request.event.organizer, request.event, 'can_view_orders'):
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ Output registration
|
|||||||
The email HTML renderer API does not make a lot of usage from signals, however, it
|
The email HTML renderer API does not make a lot of usage from signals, however, it
|
||||||
does use a signal to get a list of all available email renderers. Your plugin
|
does use a signal to get a list of all available email renderers. Your plugin
|
||||||
should listen for this signal and return the subclass of ``pretix.base.email.BaseHTMLMailRenderer``
|
should listen for this signal and return the subclass of ``pretix.base.email.BaseHTMLMailRenderer``
|
||||||
that we'll provide in this plugin::
|
that we'll provide in this plugin:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
@@ -72,7 +74,9 @@ class ``TemplateBasedMailRenderer`` that you can re-use to perform the following
|
|||||||
* Call `inlinestyler`_ to convert all ``<style>`` style sheets to inline ``style=""``
|
* Call `inlinestyler`_ to convert all ``<style>`` style sheets to inline ``style=""``
|
||||||
attributes for better compatibility
|
attributes for better compatibility
|
||||||
|
|
||||||
To use it, you just need to implement some variables::
|
To use it, you just need to implement some variables:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
class ClassicMailRenderer(TemplateBasedMailRenderer):
|
class ClassicMailRenderer(TemplateBasedMailRenderer):
|
||||||
verbose_name = _('pretix default')
|
verbose_name = _('pretix default')
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ Exporter registration
|
|||||||
The exporter API does not make a lot of usage from signals, however, it does use a signal to get a list of
|
The exporter API does not make a lot of usage from signals, however, it does use a signal to get a list of
|
||||||
all available exporters. Your plugin should listen for this signal and return the subclass of
|
all available exporters. Your plugin should listen for this signal and return the subclass of
|
||||||
``pretix.base.exporter.BaseExporter``
|
``pretix.base.exporter.BaseExporter``
|
||||||
that we'll provide in this plugin::
|
that we'll provide in this plugin:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
@@ -31,7 +33,9 @@ that we'll provide in this plugin::
|
|||||||
|
|
||||||
Some exporters might also prove to be useful, when provided on an organizer-level. In order to declare your
|
Some exporters might also prove to be useful, when provided on an organizer-level. In order to declare your
|
||||||
exporter as capable of providing exports spanning multiple events, your plugin should listen for this signal
|
exporter as capable of providing exports spanning multiple events, your plugin should listen for this signal
|
||||||
and return the subclass of ``pretix.base.exporter.BaseExporter`` that we'll provide in this plugin::
|
and return the subclass of ``pretix.base.exporter.BaseExporter`` that we'll provide in this plugin:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ Output registration
|
|||||||
The invoice renderer API does not make a lot of usage from signals, however, it
|
The invoice renderer API does not make a lot of usage from signals, however, it
|
||||||
does use a signal to get a list of all available invoice renderers. Your plugin
|
does use a signal to get a list of all available invoice renderers. Your plugin
|
||||||
should listen for this signal and return the subclass of ``pretix.base.invoice.BaseInvoiceRenderer``
|
should listen for this signal and return the subclass of ``pretix.base.invoice.BaseInvoiceRenderer``
|
||||||
that we'll provide in this plugin::
|
that we'll provide in this plugin:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ Provider registration
|
|||||||
The payment provider API does not make a lot of usage from signals, however, it
|
The payment provider API does not make a lot of usage from signals, however, it
|
||||||
does use a signal to get a list of all available payment providers. Your plugin
|
does use a signal to get a list of all available payment providers. Your plugin
|
||||||
should listen for this signal and return the subclass of ``pretix.base.payment.BasePaymentProvider``
|
should listen for this signal and return the subclass of ``pretix.base.payment.BasePaymentProvider``
|
||||||
that the plugin will provide::
|
that the plugin will provide:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
@@ -104,14 +106,22 @@ The provider class
|
|||||||
|
|
||||||
.. automethod:: payment_control_render
|
.. automethod:: payment_control_render
|
||||||
|
|
||||||
|
.. automethod:: payment_control_render_short
|
||||||
|
|
||||||
.. automethod:: payment_refund_supported
|
.. automethod:: payment_refund_supported
|
||||||
|
|
||||||
.. automethod:: payment_partial_refund_supported
|
.. automethod:: payment_partial_refund_supported
|
||||||
|
|
||||||
|
.. automethod:: payment_presale_render
|
||||||
|
|
||||||
.. automethod:: execute_refund
|
.. automethod:: execute_refund
|
||||||
|
|
||||||
.. automethod:: refund_control_render
|
.. automethod:: refund_control_render
|
||||||
|
|
||||||
|
.. automethod:: new_refund_control_form_render
|
||||||
|
|
||||||
|
.. automethod:: new_refund_control_form_process
|
||||||
|
|
||||||
.. automethod:: api_payment_details
|
.. automethod:: api_payment_details
|
||||||
|
|
||||||
.. automethod:: matching_id
|
.. automethod:: matching_id
|
||||||
@@ -140,7 +150,9 @@ it is necessary to introduce additional views. One example is the PayPal
|
|||||||
provider. It redirects the user to a PayPal website in the
|
provider. It redirects the user to a PayPal website in the
|
||||||
:py:meth:`BasePaymentProvider.checkout_prepare` step of the checkout process
|
:py:meth:`BasePaymentProvider.checkout_prepare` step of the checkout process
|
||||||
and provides PayPal with a URL to redirect back to. This URL points to a
|
and provides PayPal with a URL to redirect back to. This URL points to a
|
||||||
view which looks roughly like this::
|
view which looks roughly like this:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def success(request):
|
def success(request):
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ Placeholder registration
|
|||||||
|
|
||||||
The placeholder API does not make a lot of usage from signals, however, it
|
The placeholder API does not make a lot of usage from signals, however, it
|
||||||
does use a signal to get a list of all available email placeholders. Your plugin
|
does use a signal to get a list of all available email placeholders. Your plugin
|
||||||
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``::
|
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
@@ -71,7 +73,9 @@ Helper class for simple placeholders
|
|||||||
------------------------------------
|
------------------------------------
|
||||||
|
|
||||||
pretix ships with a helper class that makes it easy to provide placeholders based on simple
|
pretix ships with a helper class that makes it easy to provide placeholders based on simple
|
||||||
functions::
|
functions:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
placeholder = SimpleFunctionalMailTextPlaceholder(
|
placeholder = SimpleFunctionalMailTextPlaceholder(
|
||||||
'code', ['order'], lambda order: order.code, sample='F8VVL'
|
'code', ['order'], lambda order: order.code, sample='F8VVL'
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ restricted boolean (optional) ``False`` by default, restricts a plugin
|
|||||||
compatibility string Specifier for compatible pretix versions.
|
compatibility string Specifier for compatible pretix versions.
|
||||||
================== ==================== ===========================================================
|
================== ==================== ===========================================================
|
||||||
|
|
||||||
A working example would be::
|
A working example would be:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from pretix.base.plugins import PluginConfig
|
from pretix.base.plugins import PluginConfig
|
||||||
@@ -81,7 +83,7 @@ A working example would be::
|
|||||||
|
|
||||||
default_app_config = 'pretix_paypal.PaypalApp'
|
default_app_config = 'pretix_paypal.PaypalApp'
|
||||||
|
|
||||||
The ``AppConfig`` class may implement a property ``compatiblity_errors``, that checks
|
The ``AppConfig`` class may implement a property ``compatibility_errors``, that checks
|
||||||
whether the pretix installation meets all requirements of the plugin. If so,
|
whether the pretix installation meets all requirements of the plugin. If so,
|
||||||
it should contain ``None`` or an empty list, otherwise a list of strings containing
|
it should contain ``None`` or an empty list, otherwise a list of strings containing
|
||||||
human-readable error messages. We recommend using the ``django.utils.functional.cached_property``
|
human-readable error messages. We recommend using the ``django.utils.functional.cached_property``
|
||||||
@@ -96,7 +98,9 @@ Plugin registration
|
|||||||
|
|
||||||
Somehow, pretix needs to know that your plugin exists at all. For this purpose, we
|
Somehow, pretix needs to know that your plugin exists at all. For this purpose, we
|
||||||
make use of the `entry point`_ feature of setuptools. To register a plugin that lives
|
make use of the `entry point`_ feature of setuptools. To register a plugin that lives
|
||||||
in a separate python package, your ``setup.py`` should contain something like this::
|
in a separate python package, your ``setup.py`` should contain something like this:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
args...,
|
args...,
|
||||||
@@ -118,7 +122,9 @@ The various components of pretix define a number of signals which your plugin ca
|
|||||||
listen for. We will go into the details of the different signals in the following
|
listen for. We will go into the details of the different signals in the following
|
||||||
pages. We suggest that you put your signal receivers into a ``signals`` submodule
|
pages. We suggest that you put your signal receivers into a ``signals`` submodule
|
||||||
of your plugin. You should extend your ``AppConfig`` (see above) by the following
|
of your plugin. You should extend your ``AppConfig`` (see above) by the following
|
||||||
method to make your receivers available::
|
method to make your receivers available:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
class PaypalApp(AppConfig):
|
class PaypalApp(AppConfig):
|
||||||
…
|
…
|
||||||
@@ -127,7 +133,9 @@ method to make your receivers available::
|
|||||||
from . import signals # NOQA
|
from . import signals # NOQA
|
||||||
|
|
||||||
You can optionally specify code that is executed when your plugin is activated for an event
|
You can optionally specify code that is executed when your plugin is activated for an event
|
||||||
in the ``installed`` method::
|
in the ``installed`` method:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
class PaypalApp(AppConfig):
|
class PaypalApp(AppConfig):
|
||||||
…
|
…
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ looks like this:
|
|||||||
|
|
||||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||||
yield 'invoice-addresses.json', 'application/json', json.dumps({
|
yield 'invoice-addresses.json', 'application/json', json.dumps({
|
||||||
ia.order.code: InvoiceAdddressSerializer(ia).data
|
ia.order.code: InvoiceAddressSerializer(ia).data
|
||||||
for ia in InvoiceAddress.objects.filter(order__event=self.event)
|
for ia in InvoiceAddress.objects.filter(order__event=self.event)
|
||||||
}, indent=4)
|
}, indent=4)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ Output registration
|
|||||||
The ticket output API does not make a lot of usage from signals, however, it
|
The ticket output API does not make a lot of usage from signals, however, it
|
||||||
does use a signal to get a list of all available ticket outputs. Your plugin
|
does use a signal to get a list of all available ticket outputs. Your plugin
|
||||||
should listen for this signal and return the subclass of ``pretix.base.ticketoutput.BaseTicketOutput``
|
should listen for this signal and return the subclass of ``pretix.base.ticketoutput.BaseTicketOutput``
|
||||||
that we'll provide in this plugin::
|
that we'll provide in this plugin:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ Implementing a task
|
|||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
A common pattern for implementing asynchronous tasks can be seen a lot in ``pretix.base.services``
|
A common pattern for implementing asynchronous tasks can be seen a lot in ``pretix.base.services``
|
||||||
and looks like this::
|
and looks like this:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
|
|
||||||
@@ -34,13 +36,15 @@ If your user needs to wait for the response of the asynchronous task, there are
|
|||||||
that will probably move to ``pretix.base`` at some point. They consist of the view mixin ``AsyncAction`` that allows
|
that will probably move to ``pretix.base`` at some point. They consist of the view mixin ``AsyncAction`` that allows
|
||||||
you to easily write a view that kicks off and waits for an asynchronous task. ``AsyncAction`` will determine whether
|
you to easily write a view that kicks off and waits for an asynchronous task. ``AsyncAction`` will determine whether
|
||||||
to run the task asynchronously or not and will do some magic to look nice for users with and without JavaScript support.
|
to run the task asynchronously or not and will do some magic to look nice for users with and without JavaScript support.
|
||||||
A usage example taken directly from the code is::
|
A usage example taken directly from the code is:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View):
|
class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View):
|
||||||
"""
|
"""
|
||||||
A view that executes a task asynchronously. A POST request will kick off the
|
A view that executes a task asynchronously. A POST request will kick off the
|
||||||
task into the background or run it in the foreground if celery is not installed.
|
task into the background or run it in the foreground if celery is not installed.
|
||||||
In the former case, subsequent GET calls can be used to determinine the current
|
In the former case, subsequent GET calls can be used to determine the current
|
||||||
status of the task.
|
status of the task.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -79,7 +83,9 @@ A usage example taken directly from the code is::
|
|||||||
return super().get_error_message(exception)
|
return super().get_error_message(exception)
|
||||||
|
|
||||||
On the client side, this can be used by simply adding a ``data-asynctask`` attribute to an HTML form. This will enable
|
On the client side, this can be used by simply adding a ``data-asynctask`` attribute to an HTML form. This will enable
|
||||||
AJAX sending of the form and display a loading indicator::
|
AJAX sending of the form and display a loading indicator:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
<form method="post" data-asynctask
|
<form method="post" data-asynctask
|
||||||
action="{% eventurl request.event "presale:event.order.cancel.do" … %}">
|
action="{% eventurl request.event "presale:event.order.cancel.do" … %}">
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ numbers and dates, ``LazyDate`` and ``LazyNumber``. There also is a ``LazyLocale
|
|||||||
exceptions with gettext-localized exception messages.
|
exceptions with gettext-localized exception messages.
|
||||||
|
|
||||||
Last, but definitely not least, we have the ``language`` context manager (``pretix.base.i18n.language``) that allows
|
Last, but definitely not least, we have the ``language`` context manager (``pretix.base.i18n.language``) that allows
|
||||||
you to execute a piece of code with a different locale::
|
you to execute a piece of code with a different locale:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
with language('de'):
|
with language('de'):
|
||||||
render_mail_template()
|
render_mail_template()
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ We recommend all relevant models to inherit from ``LoggedModel`` as it simplifie
|
|||||||
.. autoclass:: pretix.base.models.LoggedModel
|
.. autoclass:: pretix.base.models.LoggedModel
|
||||||
:members: log_action, all_logentries
|
:members: log_action, all_logentries
|
||||||
|
|
||||||
To actually log an action, you can just call the ``log_action`` method on your object::
|
To actually log an action, you can just call the ``log_action`` method on your object:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
order.log_action('pretix.event.order.canceled', user=user, data={})
|
order.log_action('pretix.event.order.canceled', user=user, data={})
|
||||||
|
|
||||||
@@ -29,7 +31,9 @@ Logging form actions
|
|||||||
""""""""""""""""""""
|
""""""""""""""""""""
|
||||||
|
|
||||||
A very common use case is to log the changes to a model that have been done in a ``ModelForm``. In this case,
|
A very common use case is to log the changes to a model that have been done in a ``ModelForm``. In this case,
|
||||||
we generally use a custom ``form_valid`` method on our ``FormView`` that looks like this::
|
we generally use a custom ``form_valid`` method on our ``FormView`` that looks like this:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
@@ -40,7 +44,9 @@ we generally use a custom ``form_valid`` method on our ``FormView`` that looks l
|
|||||||
messages.success(self.request, _('Your changes have been saved.'))
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
It gets a little bit more complicated if your form allows file uploads::
|
It gets a little bit more complicated if your form allows file uploads:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
@@ -67,7 +73,9 @@ following ready-to-include template::
|
|||||||
|
|
||||||
We now need a way to translate the action codes like ``pretix.event.changed`` into human-readable
|
We now need a way to translate the action codes like ``pretix.event.changed`` into human-readable
|
||||||
strings. The :py:attr:`pretix.base.signals.logentry_display` signals allows you to do so. A simple
|
strings. The :py:attr:`pretix.base.signals.logentry_display` signals allows you to do so. A simple
|
||||||
implementation could look like::
|
implementation could look like:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from pretix.base.signals import logentry_display
|
from pretix.base.signals import logentry_display
|
||||||
@@ -88,7 +96,9 @@ Sending notifications
|
|||||||
|
|
||||||
If you think that the logged information might be important or urgent enough to send out a notification to interested
|
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
|
organizers. In this case, you should listen for the :py:attr:`pretix.base.signals.register_notification_types` signal
|
||||||
to register a notification type::
|
to register a notification type:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
@receiver(register_notification_types)
|
@receiver(register_notification_types)
|
||||||
def register_my_notification_types(sender, **kwargs):
|
def register_my_notification_types(sender, **kwargs):
|
||||||
@@ -103,7 +113,9 @@ You should subclass the base ``NotificationType`` class and implement all its me
|
|||||||
.. autoclass:: pretix.base.notifications.NotificationType
|
.. autoclass:: pretix.base.notifications.NotificationType
|
||||||
:members: action_type, verbose_name, required_permission, build_notification
|
:members: action_type, verbose_name, required_permission, build_notification
|
||||||
|
|
||||||
A simple implementation could look like this::
|
A simple implementation could look like this:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
class MyNotificationType(NotificationType):
|
class MyNotificationType(NotificationType):
|
||||||
required_permission = "can_view_orders"
|
required_permission = "can_view_orders"
|
||||||
@@ -143,7 +155,9 @@ Logging technical information
|
|||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
If you just want to log technical information to a log file on disk that does not need to be parsed
|
If you just want to log technical information to a log file on disk that does not need to be parsed
|
||||||
and displayed later, you can just use Python's ``logging`` module::
|
and displayed later, you can just use Python's ``logging`` module:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -151,7 +165,9 @@ and displayed later, you can just use Python's ``logging`` module::
|
|||||||
|
|
||||||
logger.info('Startup complete.')
|
logger.info('Startup complete.')
|
||||||
|
|
||||||
This is also very useful to provide debugging information when an exception occurs::
|
This is also very useful to provide debugging information when an exception occurs:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
try:
|
try:
|
||||||
foo()
|
foo()
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ Requiring permissions for a view
|
|||||||
--------------------------------
|
--------------------------------
|
||||||
|
|
||||||
pretix provides a number of useful mixins and decorators that allow you to specify that a user needs a certain
|
pretix provides a number of useful mixins and decorators that allow you to specify that a user needs a certain
|
||||||
permission level to access a view::
|
permission level to access a view:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from pretix.control.permissions import (
|
from pretix.control.permissions import (
|
||||||
OrganizerPermissionRequiredMixin, organizer_permission_required
|
OrganizerPermissionRequiredMixin, organizer_permission_required
|
||||||
@@ -44,7 +46,9 @@ permission level to access a view::
|
|||||||
# Only users with *any* permission on this organizer can access this
|
# Only users with *any* permission on this organizer can access this
|
||||||
|
|
||||||
|
|
||||||
Of course, the same is available on event level::
|
Of course, the same is available on event level:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from pretix.control.permissions import (
|
from pretix.control.permissions import (
|
||||||
EventPermissionRequiredMixin, event_permission_required
|
EventPermissionRequiredMixin, event_permission_required
|
||||||
@@ -73,7 +77,9 @@ Of course, the same is available on event level::
|
|||||||
# Only users with *any* permission on this event can access this
|
# Only users with *any* permission on this event can access this
|
||||||
|
|
||||||
You can also require that this view is only accessible by system administrators with an active "admin session"
|
You can also require that this view is only accessible by system administrators with an active "admin session"
|
||||||
(see below for what this means)::
|
(see below for what this means):
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from pretix.control.permissions import (
|
from pretix.control.permissions import (
|
||||||
AdministratorPermissionRequiredMixin, administrator_permission_required
|
AdministratorPermissionRequiredMixin, administrator_permission_required
|
||||||
@@ -89,7 +95,9 @@ You can also require that this view is only accessible by system administrators
|
|||||||
# ...
|
# ...
|
||||||
|
|
||||||
In rare cases it might also be useful to expose a feature only to people who have a staff account but do not
|
In rare cases it might also be useful to expose a feature only to people who have a staff account but do not
|
||||||
necessarily have an active admin session::
|
necessarily have an active admin session:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from pretix.control.permissions import (
|
from pretix.control.permissions import (
|
||||||
StaffMemberRequiredMixin, staff_member_required
|
StaffMemberRequiredMixin, staff_member_required
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ subclass that also adds support for internationalized fields:
|
|||||||
|
|
||||||
.. autoclass:: pretix.base.forms.SettingsForm
|
.. autoclass:: pretix.base.forms.SettingsForm
|
||||||
|
|
||||||
You can simply use it like this::
|
You can simply use it like this:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
class EventSettingsForm(SettingsForm):
|
class EventSettingsForm(SettingsForm):
|
||||||
show_date_to = forms.BooleanField(
|
show_date_to = forms.BooleanField(
|
||||||
@@ -56,7 +58,9 @@ You can simply use it like this::
|
|||||||
Defaults in plugins
|
Defaults in plugins
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
Plugins can add custom hardcoded defaults in the following way::
|
Plugins can add custom hardcoded defaults in the following way:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from pretix.base.settings import settings_hierarkey
|
from pretix.base.settings import settings_hierarkey
|
||||||
|
|
||||||
|
|||||||
@@ -64,20 +64,35 @@ is valid in every text):
|
|||||||
Placeholder Description
|
Placeholder Description
|
||||||
============================== ===============================================================================
|
============================== ===============================================================================
|
||||||
event The event name
|
event The event name
|
||||||
|
event_slug The event's short form
|
||||||
|
code In case of the waiting list, the voucher code to redeem
|
||||||
|
currency The currency used for the event (three-letter code)
|
||||||
total The order's total value
|
total The order's total value
|
||||||
total_with_currency The order's total value with a localized currency sign
|
total_with_currency The order's total value with a localized currency sign
|
||||||
currency The currency used for the event (three-letter code)
|
refund_amount (For cancellation emails) The amount of money that will be refunded, including
|
||||||
|
the currency
|
||||||
payment_info Information text specific to the payment method (e.g. banking details)
|
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
|
url An URL pointing to the download/status page of the order
|
||||||
invoice_name The name field of the invoice address
|
url_info_change An URL pointing to the page of the order that can be used to change ticket
|
||||||
|
information
|
||||||
|
url_products_change An URL pointing to the page of the order that can be used to change the products
|
||||||
|
in the order
|
||||||
|
url_cancel An URL pointing to the page of the order that can be used to cancel the order
|
||||||
|
name, name_* Any name that can be used to address the recipient (e.g. name from invoice address,
|
||||||
|
name from first ticket, …)
|
||||||
|
invoice_name, invoice_name_* The name field of the invoice address
|
||||||
invoice_company The company field of the invoice address
|
invoice_company The company field of the invoice address
|
||||||
|
attendee_name, attendee_name_* The name of the attendee represented by the ticket
|
||||||
expire_date The order's expiration date
|
expire_date The order's expiration date
|
||||||
|
comment When rejecting an order, this will contain the reason for the rejection
|
||||||
date The same as ``expire_date``, but in a different e-mail (for backwards
|
date The same as ``expire_date``, but in a different e-mail (for backwards
|
||||||
compatibility)
|
compatibility)
|
||||||
orders A list of orders including links to their status pages, specific to the "resend
|
orders A list of orders including links to their status pages, specific to the "resend
|
||||||
link (requested by user)" e-mail
|
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
|
hours In case of the waiting list, the number of hours the voucher code is valid
|
||||||
|
product In case of the waiting list, the product that has become available
|
||||||
|
voucher_list When sending out vouchers in bulk, this will be replaced with the list of
|
||||||
|
vouchers
|
||||||
============================== ===============================================================================
|
============================== ===============================================================================
|
||||||
|
|
||||||
The different e-mails are explained in the following:
|
The different e-mails are explained in the following:
|
||||||
|
|||||||
@@ -304,8 +304,92 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
|||||||
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
|
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
|
||||||
made through this widget will be counted towards this campaign.
|
made through this widget will be counted towards this campaign.
|
||||||
|
|
||||||
* If you use the tracking plugin, you can pass a Google Analytics User ID to enable cross-domain tracking. This will
|
* If you use the tracking plugin, you can enable cross-domain tracking. To do so, you need to initialize the
|
||||||
require you to dynamically load the widget, like this::
|
pretix-widget manually. Use the html code to embed the widget and add one the following code snippets. Make sure to
|
||||||
|
replace all occurrences of <MEASUREMENT_ID> with your Google Analytics MEASUREMENT_ID (UA-XXXXXXX-X or G-XXXXXXXX)
|
||||||
|
|
||||||
|
Please also make sure to add the embedding website to your `Referral exclusions
|
||||||
|
<https://support.google.com/analytics/answer/2795830>`_ in your Google Analytics settings.
|
||||||
|
|
||||||
|
If you use Google Analytics 4 (GA4 – G-XXXXXXXX)::
|
||||||
|
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '<MEASUREMENT_ID>');
|
||||||
|
|
||||||
|
window.pretixWidgetCallback = function () {
|
||||||
|
window.PretixWidget.build_widgets = false;
|
||||||
|
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||||
|
if (!window['google_tag_manager']) {
|
||||||
|
window.PretixWidget.buildWidgets();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientId;
|
||||||
|
var sessionId;
|
||||||
|
var loadingTimeout;
|
||||||
|
function build() {
|
||||||
|
// use loadingTimeout to make sure build() is only called once
|
||||||
|
if (!loadingTimeout) return;
|
||||||
|
window.clearTimeout(loadingTimeout);
|
||||||
|
loadingTimeout = null;
|
||||||
|
if (clientId) window.PretixWidget.widget_data["tracking-ga-id"] = clientId;
|
||||||
|
if (sessionId) window.PretixWidget.widget_data["tracking-ga-sessid"] = sessionId;
|
||||||
|
window.PretixWidget.buildWidgets();
|
||||||
|
};
|
||||||
|
// make sure to build pretix-widgets if gtag fails to load either client_id or session_id
|
||||||
|
loadingTimeout = window.setTimeout(build, 2000);
|
||||||
|
|
||||||
|
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
|
||||||
|
clientId = id;
|
||||||
|
if (sessionId !== undefined) build();
|
||||||
|
});
|
||||||
|
gtag('get', '<MEASUREMENT_ID>', 'session_id', function(id) {
|
||||||
|
sessionId = id;
|
||||||
|
if (clientId !== undefined) build();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
If you use Universal Analytics with ``gtag.js`` (UA-XXXXXXX-X)::
|
||||||
|
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '<MEASUREMENT_ID>');
|
||||||
|
|
||||||
|
window.pretixWidgetCallback = function () {
|
||||||
|
window.PretixWidget.build_widgets = false;
|
||||||
|
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||||
|
if (!window['google_tag_manager']) {
|
||||||
|
window.PretixWidget.buildWidgets();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure to build pretix-widgets if gtag fails to load client_id
|
||||||
|
var loadingTimeout = window.setTimeout(function() {
|
||||||
|
loadingTimeout = null;
|
||||||
|
window.PretixWidget.buildWidgets();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
|
||||||
|
if (loadingTimeout) {
|
||||||
|
window.clearTimeout(loadingTimeout);
|
||||||
|
window.PretixWidget.widget_data["tracking-ga-id"] = id;
|
||||||
|
window.PretixWidget.buildWidgets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
If you use ```analytics.js` (Universal Analytics)::
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||||
@@ -313,27 +397,33 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
|||||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||||
|
|
||||||
ga('create', 'UA-XXXXXX-1', 'auto');
|
ga('create', '<MEASUREMENT_ID>', 'auto');
|
||||||
ga('send', 'pageview');
|
ga('send', 'pageview');
|
||||||
|
|
||||||
window.pretixWidgetCallback = function () {
|
window.pretixWidgetCallback = function () {
|
||||||
window.PretixWidget.build_widgets = false;
|
window.PretixWidget.build_widgets = false;
|
||||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||||
if(window.ga && ga.create) {
|
if (!window['ga'] || !ga.create) {
|
||||||
ga(function(tracker) {
|
// Tracking is probably blocked
|
||||||
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
|
|
||||||
window.PretixWidget.buildWidgets()
|
|
||||||
});
|
|
||||||
} else { // Tracking is probably blocked
|
|
||||||
window.PretixWidget.buildWidgets()
|
window.PretixWidget.buildWidgets()
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var loadingTimeout = window.setTimeout(function() {
|
||||||
|
loadingTimeout = null;
|
||||||
|
window.PretixWidget.buildWidgets();
|
||||||
|
}, 1000);
|
||||||
|
ga(function(tracker) {
|
||||||
|
if (loadingTimeout) {
|
||||||
|
window.clearTimeout(loadingTimeout);
|
||||||
|
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
|
||||||
|
window.PretixWidget.buildWidgets();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
In some combinations with Google Tag Manager, the widget does not load this way. In this case, try replacing
|
|
||||||
``tracker.get('clientId')`` with ``ga.getAll()[0].get('clientId')``.
|
|
||||||
|
|
||||||
|
|
||||||
.. versionchanged:: 2.3
|
.. versionchanged:: 2.3
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ localecompile:
|
|||||||
|
|
||||||
localegen:
|
localegen:
|
||||||
./manage.py makemessages --keep-pot --ignore "pretix/helpers/*" $(LNGS)
|
./manage.py makemessages --keep-pot --ignore "pretix/helpers/*" $(LNGS)
|
||||||
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "build/*" $(LNGS)
|
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
|
||||||
|
|
||||||
staticfiles: jsi18n
|
staticfiles: jsi18n
|
||||||
./manage.py collectstatic --noinput
|
./manage.py collectstatic --noinput
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "3.13.0"
|
__version__ = "3.15.0"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
|||||||
('GET', 'api-v1:revokedsecrets-list'),
|
('GET', 'api-v1:revokedsecrets-list'),
|
||||||
('GET', 'api-v1:order-list'),
|
('GET', 'api-v1:order-list'),
|
||||||
('GET', 'api-v1:event.settings'),
|
('GET', 'api-v1:event.settings'),
|
||||||
|
('POST', 'api-v1:upload'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
|||||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||||
('GET', 'api-v1:revokedsecrets-list'),
|
('GET', 'api-v1:revokedsecrets-list'),
|
||||||
('GET', 'api-v1:event.settings'),
|
('GET', 'api-v1:event.settings'),
|
||||||
|
('POST', 'api-v1:upload'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
|||||||
('DELETE', 'api-v1:cartposition-detail'),
|
('DELETE', 'api-v1:cartposition-detail'),
|
||||||
('GET', 'api-v1:giftcard-list'),
|
('GET', 'api-v1:giftcard-list'),
|
||||||
('POST', 'api-v1:giftcard-transact'),
|
('POST', 'api-v1:giftcard-transact'),
|
||||||
|
('GET', 'plugins:pretix_posbackend:posclosing-list'),
|
||||||
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
|
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
|
||||||
('POST', 'plugins:pretix_posbackend:posclosing-list'),
|
('POST', 'plugins:pretix_posbackend:posclosing-list'),
|
||||||
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
|
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
|
||||||
@@ -112,6 +115,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
|||||||
('GET', 'plugins:pretix_seating:event.event.subevent'),
|
('GET', 'plugins:pretix_seating:event.event.subevent'),
|
||||||
('GET', 'plugins:pretix_seating:event.plan'),
|
('GET', 'plugins:pretix_seating:event.plan'),
|
||||||
('GET', 'plugins:pretix_seating:selection.simple'),
|
('GET', 'plugins:pretix_seating:selection.simple'),
|
||||||
|
('POST', 'api-v1:upload'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,10 +89,38 @@ class EventCRUDPermission(EventPermission):
|
|||||||
class ProfilePermission(BasePermission):
|
class ProfilePermission(BasePermission):
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
try:
|
||||||
|
# If this logic is updated, make sure to also update the logic in pretix/control/middleware.py
|
||||||
|
assert_session_valid(request)
|
||||||
|
except SessionInvalid:
|
||||||
|
return False
|
||||||
|
except SessionReauthRequired:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if isinstance(request.auth, OAuthAccessToken):
|
if isinstance(request.auth, OAuthAccessToken):
|
||||||
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class AnyAuthenticatedClientPermission(BasePermission):
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
try:
|
||||||
|
# If this logic is updated, make sure to also update the logic in pretix/control/middleware.py
|
||||||
|
assert_session_valid(request)
|
||||||
|
except SessionInvalid:
|
||||||
|
return False
|
||||||
|
except SessionReauthRequired:
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.files import File
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy
|
from django.utils.translation import gettext_lazy
|
||||||
@@ -87,7 +88,10 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
|||||||
raise ValidationError('The specified seat ID is not unique.')
|
raise ValidationError('The specified seat ID is not unique.')
|
||||||
else:
|
else:
|
||||||
validated_data['seat'] = seat
|
validated_data['seat'] = seat
|
||||||
if not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web')):
|
if not seat.is_available(
|
||||||
|
sales_channel=validated_data.get('sales_channel', 'web'),
|
||||||
|
distance_ignore_cart_id=validated_data['cart_id'],
|
||||||
|
):
|
||||||
raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
|
raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
|
||||||
elif seated:
|
elif seated:
|
||||||
raise ValidationError('The specified product requires to choose a seat.')
|
raise ValidationError('The specified product requires to choose a seat.')
|
||||||
@@ -97,6 +101,13 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
for answ_data in answers_data:
|
for answ_data in answers_data:
|
||||||
options = answ_data.pop('options')
|
options = answ_data.pop('options')
|
||||||
|
if isinstance(answ_data['answer'], File):
|
||||||
|
an = answ_data.pop('answer')
|
||||||
|
answ = cp.answers.create(**answ_data, answer='')
|
||||||
|
answ.file.save(an.name, an, save=False)
|
||||||
|
answ.answer = 'file://' + answ.file.name
|
||||||
|
answ.save()
|
||||||
|
else:
|
||||||
answ = cp.answers.create(**answ_data)
|
answ = cp.answers.create(**answ_data)
|
||||||
answ.options.add(*options)
|
answ.options.add(*options)
|
||||||
return cp
|
return cp
|
||||||
@@ -104,6 +115,7 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
|||||||
def validate_cart_id(self, cid):
|
def validate_cart_id(self, cid):
|
||||||
if cid and not cid.endswith('@api'):
|
if cid and not cid.endswith('@api'):
|
||||||
raise ValidationError('Cart ID should end in @api or be empty.')
|
raise ValidationError('Cart ID should end in @api or be empty.')
|
||||||
|
return cid
|
||||||
|
|
||||||
def validate_item(self, item):
|
def validate_item(self, item):
|
||||||
if item.event != self.context['event']:
|
if item.event != self.context['event']:
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django_countries.serializers import CountryFieldMixin
|
from django_countries.serializers import CountryFieldMixin
|
||||||
from hierarkey.proxy import HierarkeyProxy
|
|
||||||
from pytz import common_timezones
|
from pytz import common_timezones
|
||||||
from rest_framework import serializers
|
|
||||||
from rest_framework.fields import ChoiceField, Field
|
from rest_framework.fields import ChoiceField, Field
|
||||||
from rest_framework.relations import SlugRelatedField
|
from rest_framework.relations import SlugRelatedField
|
||||||
|
|
||||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||||
|
from pretix.api.serializers.settings import SettingsSerializer
|
||||||
from pretix.base.models import Event, TaxRule
|
from pretix.base.models import Event, TaxRule
|
||||||
from pretix.base.models.event import SubEvent
|
from pretix.base.models.event import SubEvent
|
||||||
from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
||||||
from pretix.base.services.seating import (
|
from pretix.base.services.seating import (
|
||||||
SeatProtected, generate_seats, validate_plan_change,
|
SeatProtected, generate_seats, validate_plan_change,
|
||||||
)
|
)
|
||||||
from pretix.base.settings import DEFAULTS, validate_settings
|
from pretix.base.settings import validate_event_settings
|
||||||
from pretix.base.signals import api_event_settings_fields
|
from pretix.base.signals import api_event_settings_fields
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MetaDataField(Field):
|
class MetaDataField(Field):
|
||||||
|
|
||||||
@@ -124,7 +128,8 @@ class EventSerializer(I18nAwareModelSerializer):
|
|||||||
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
|
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
|
||||||
'date_to', 'date_admission', 'is_public', 'presale_start',
|
'date_to', 'date_admission', 'is_public', 'presale_start',
|
||||||
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan',
|
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan',
|
||||||
'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys')
|
'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys',
|
||||||
|
'sales_channels')
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -557,7 +562,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
|||||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
|
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
|
||||||
|
|
||||||
|
|
||||||
class EventSettingsSerializer(serializers.Serializer):
|
class EventSettingsSerializer(SettingsSerializer):
|
||||||
default_fields = [
|
default_fields = [
|
||||||
'imprint_url',
|
'imprint_url',
|
||||||
'checkout_email_helptext',
|
'checkout_email_helptext',
|
||||||
@@ -573,6 +578,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
|||||||
'presale_start_show_date',
|
'presale_start_show_date',
|
||||||
'locales',
|
'locales',
|
||||||
'locale',
|
'locale',
|
||||||
|
'region',
|
||||||
'last_order_modification_date',
|
'last_order_modification_date',
|
||||||
'show_quota_left',
|
'show_quota_left',
|
||||||
'waiting_list_enabled',
|
'waiting_list_enabled',
|
||||||
@@ -596,8 +602,12 @@ class EventSettingsSerializer(serializers.Serializer):
|
|||||||
'attendee_addresses_required',
|
'attendee_addresses_required',
|
||||||
'attendee_company_asked',
|
'attendee_company_asked',
|
||||||
'attendee_company_required',
|
'attendee_company_required',
|
||||||
|
'attendee_data_explanation_text',
|
||||||
'confirm_texts',
|
'confirm_texts',
|
||||||
'order_email_asked_twice',
|
'order_email_asked_twice',
|
||||||
|
'order_phone_asked',
|
||||||
|
'order_phone_required',
|
||||||
|
'checkout_phone_helptext',
|
||||||
'payment_term_mode',
|
'payment_term_mode',
|
||||||
'payment_term_days',
|
'payment_term_days',
|
||||||
'payment_term_weekdays',
|
'payment_term_weekdays',
|
||||||
@@ -606,6 +616,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
|||||||
'payment_term_expire_automatically',
|
'payment_term_expire_automatically',
|
||||||
'payment_term_accept_late',
|
'payment_term_accept_late',
|
||||||
'payment_explanation',
|
'payment_explanation',
|
||||||
|
'payment_pending_hidden',
|
||||||
'ticket_download',
|
'ticket_download',
|
||||||
'ticket_download_date',
|
'ticket_download_date',
|
||||||
'ticket_download_addons',
|
'ticket_download_addons',
|
||||||
@@ -647,6 +658,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
|||||||
'invoice_additional_text',
|
'invoice_additional_text',
|
||||||
'invoice_footer_text',
|
'invoice_footer_text',
|
||||||
'invoice_eu_currencies',
|
'invoice_eu_currencies',
|
||||||
|
'invoice_logo_image',
|
||||||
'cancel_allow_user',
|
'cancel_allow_user',
|
||||||
'cancel_allow_user_until',
|
'cancel_allow_user_until',
|
||||||
'cancel_allow_user_paid',
|
'cancel_allow_user_paid',
|
||||||
@@ -656,54 +668,48 @@ class EventSettingsSerializer(serializers.Serializer):
|
|||||||
'cancel_allow_user_paid_keep_percentage',
|
'cancel_allow_user_paid_keep_percentage',
|
||||||
'cancel_allow_user_paid_adjust_fees',
|
'cancel_allow_user_paid_adjust_fees',
|
||||||
'cancel_allow_user_paid_adjust_fees_explanation',
|
'cancel_allow_user_paid_adjust_fees_explanation',
|
||||||
|
'cancel_allow_user_paid_adjust_fees_step',
|
||||||
'cancel_allow_user_paid_refund_as_giftcard',
|
'cancel_allow_user_paid_refund_as_giftcard',
|
||||||
'cancel_allow_user_paid_require_approval',
|
'cancel_allow_user_paid_require_approval',
|
||||||
'change_allow_user_variation',
|
'change_allow_user_variation',
|
||||||
'change_allow_user_until',
|
'change_allow_user_until',
|
||||||
'change_allow_user_price',
|
'change_allow_user_price',
|
||||||
|
'primary_color',
|
||||||
|
'theme_color_success',
|
||||||
|
'theme_color_danger',
|
||||||
|
'theme_color_background',
|
||||||
|
'theme_round_borders',
|
||||||
|
'primary_font',
|
||||||
|
'logo_image',
|
||||||
|
'logo_image_large',
|
||||||
|
'logo_show_title',
|
||||||
|
'og_image',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.event = kwargs.pop('event')
|
self.event = kwargs.pop('event')
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
for fname in self.default_fields:
|
|
||||||
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
|
||||||
if callable(kwargs):
|
|
||||||
kwargs = kwargs()
|
|
||||||
kwargs.setdefault('required', False)
|
|
||||||
kwargs.setdefault('allow_null', True)
|
|
||||||
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
|
||||||
if callable(form_kwargs):
|
|
||||||
form_kwargs = form_kwargs()
|
|
||||||
if 'serializer_class' not in DEFAULTS[fname]:
|
|
||||||
raise ValidationError('{} has no serializer class'.format(fname))
|
|
||||||
f = DEFAULTS[fname]['serializer_class'](
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
f._label = form_kwargs.get('label', fname)
|
|
||||||
f._help_text = form_kwargs.get('help_text')
|
|
||||||
self.fields[fname] = f
|
|
||||||
|
|
||||||
for recv, resp in api_event_settings_fields.send(sender=self.event):
|
for recv, resp in api_event_settings_fields.send(sender=self.event):
|
||||||
for fname, field in resp.items():
|
for fname, field in resp.items():
|
||||||
field.required = False
|
field.required = False
|
||||||
self.fields[fname] = field
|
self.fields[fname] = field
|
||||||
|
|
||||||
def update(self, instance: HierarkeyProxy, validated_data):
|
|
||||||
for attr, value in validated_data.items():
|
|
||||||
if value is None:
|
|
||||||
instance.delete(attr)
|
|
||||||
elif instance.get(attr, as_type=type(value)) != value:
|
|
||||||
instance.set(attr, value)
|
|
||||||
return instance
|
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
settings_dict = self.instance.freeze()
|
settings_dict = self.instance.freeze()
|
||||||
settings_dict.update(data)
|
settings_dict.update(data)
|
||||||
validate_settings(self.event, settings_dict)
|
validate_event_settings(self.event, settings_dict)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def get_new_filename(self, name: str) -> str:
|
||||||
|
nonce = get_random_string(length=8)
|
||||||
|
fname = '%s/%s/%s.%s.%s' % (
|
||||||
|
self.event.organizer.slug, self.event.slug, name.split('/')[-1], nonce, name.split('.')[-1]
|
||||||
|
)
|
||||||
|
# TODO: make sure pub is always correct
|
||||||
|
return 'pub/' + fname
|
||||||
|
|
||||||
|
|
||||||
class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||||
default_fields = [
|
default_fields = [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
@@ -27,3 +28,50 @@ class ListMultipleChoiceField(serializers.MultipleChoiceField):
|
|||||||
]
|
]
|
||||||
|
|
||||||
return remove_duplicates_from_list(representation_data)
|
return remove_duplicates_from_list(representation_data)
|
||||||
|
|
||||||
|
|
||||||
|
class UploadedFileField(serializers.Field):
|
||||||
|
default_error_messages = {
|
||||||
|
'required': 'No file was submitted.',
|
||||||
|
'not_found': 'The submitted file ID was not found.',
|
||||||
|
'invalid_type': 'The submitted file has a file type that is not allowed in this field.',
|
||||||
|
'size': 'The submitted file is too large to be used in this field.',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.allowed_types = kwargs.pop('allowed_types', None)
|
||||||
|
self.max_size = kwargs.pop('max_size', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
from pretix.base.models import CachedFile
|
||||||
|
|
||||||
|
request = self.context.get('request', None)
|
||||||
|
try:
|
||||||
|
cf = CachedFile.objects.get(
|
||||||
|
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}',
|
||||||
|
file__isnull=False,
|
||||||
|
pk=data[len("file:"):],
|
||||||
|
)
|
||||||
|
except (ValidationError, IndexError): # invalid uuid
|
||||||
|
self.fail('not_found')
|
||||||
|
except CachedFile.DoesNotExist:
|
||||||
|
self.fail('not_found')
|
||||||
|
|
||||||
|
if self.allowed_types and cf.type not in self.allowed_types:
|
||||||
|
self.fail('invalid_type')
|
||||||
|
if self.max_size and cf.file.size > self.max_size:
|
||||||
|
self.fail('size')
|
||||||
|
|
||||||
|
return cf.file
|
||||||
|
|
||||||
|
def to_representation(self, value):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = value.url
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
request = self.context['request']
|
||||||
|
return request.build_absolute_uri(url)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from pretix.api.serializers.event import MetaDataField
|
from pretix.api.serializers.event import MetaDataField
|
||||||
|
from pretix.api.serializers.fields import UploadedFileField
|
||||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
|
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
|
||||||
@@ -113,6 +114,9 @@ class ItemSerializer(I18nAwareModelSerializer):
|
|||||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||||
meta_data = MetaDataField(required=False, source='*')
|
meta_data = MetaDataField(required=False, source='*')
|
||||||
|
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||||
|
'image/png', 'image/jpeg', 'image/gif'
|
||||||
|
), max_size=10 * 1024 * 1024)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Item
|
model = Item
|
||||||
@@ -123,7 +127,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
|||||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data')
|
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data')
|
||||||
read_only_fields = ('has_variations', 'picture')
|
read_only_fields = ('has_variations',)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
@@ -277,7 +281,9 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
|||||||
model = Question
|
model = Question
|
||||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||||
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||||
'hidden', 'dependency_value', 'print_on_invoice', 'help_text')
|
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
|
||||||
|
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max'
|
||||||
|
)
|
||||||
|
|
||||||
def validate_identifier(self, value):
|
def validate_identifier(self, value):
|
||||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from collections import Counter, defaultdict
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pycountry
|
import pycountry
|
||||||
|
from django.core.files import File
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy
|
from django.utils.translation import gettext_lazy
|
||||||
@@ -17,8 +18,9 @@ from pretix.base.channels import get_all_sales_channels
|
|||||||
from pretix.base.decimal import round_decimal
|
from pretix.base.decimal import round_decimal
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
|
CachedFile, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item,
|
||||||
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher,
|
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat,
|
||||||
|
SubEvent, TaxRule, Voucher,
|
||||||
)
|
)
|
||||||
from pretix.base.models.orders import (
|
from pretix.base.models.orders import (
|
||||||
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
|
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
|
||||||
@@ -94,12 +96,9 @@ class AnswerQuestionIdentifierField(serializers.Field):
|
|||||||
|
|
||||||
class AnswerQuestionOptionsIdentifierField(serializers.Field):
|
class AnswerQuestionOptionsIdentifierField(serializers.Field):
|
||||||
def to_representation(self, instance: QuestionAnswer):
|
def to_representation(self, instance: QuestionAnswer):
|
||||||
|
if isinstance(instance, WrappedModel) or instance.pk:
|
||||||
return [o.identifier for o in instance.options.all()]
|
return [o.identifier for o in instance.options.all()]
|
||||||
|
return []
|
||||||
|
|
||||||
class AnswerQuestionOptionsField(serializers.Field):
|
|
||||||
def to_representation(self, instance: QuestionAnswer):
|
|
||||||
return [o.pk for o in instance.options.all()]
|
|
||||||
|
|
||||||
|
|
||||||
class InlineSeatSerializer(I18nAwareModelSerializer):
|
class InlineSeatSerializer(I18nAwareModelSerializer):
|
||||||
@@ -112,12 +111,91 @@ class InlineSeatSerializer(I18nAwareModelSerializer):
|
|||||||
class AnswerSerializer(I18nAwareModelSerializer):
|
class AnswerSerializer(I18nAwareModelSerializer):
|
||||||
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
|
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
|
||||||
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
|
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
|
||||||
options = AnswerQuestionOptionsField(source='*', read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = QuestionAnswer
|
model = QuestionAnswer
|
||||||
fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers')
|
fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers')
|
||||||
|
|
||||||
|
def validate_question(self, q):
|
||||||
|
if q.event != self.context['event']:
|
||||||
|
raise ValidationError(
|
||||||
|
'The specified question does not belong to this event.'
|
||||||
|
)
|
||||||
|
return q
|
||||||
|
|
||||||
|
def _handle_file_upload(self, data):
|
||||||
|
try:
|
||||||
|
ao = self.context["request"].user or self.context["request"].auth
|
||||||
|
cf = CachedFile.objects.get(
|
||||||
|
session_key=f'api-upload-{str(type(ao))}-{ao.pk}',
|
||||||
|
file__isnull=False,
|
||||||
|
pk=data['answer'][len("file:"):],
|
||||||
|
)
|
||||||
|
except (ValidationError, IndexError): # invalid uuid
|
||||||
|
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||||
|
except CachedFile.DoesNotExist:
|
||||||
|
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||||
|
|
||||||
|
allowed_types = (
|
||||||
|
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
|
||||||
|
)
|
||||||
|
if cf.type not in allowed_types:
|
||||||
|
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||||
|
if cf.file.size > 10 * 1024 * 1024:
|
||||||
|
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||||
|
|
||||||
|
data['options'] = []
|
||||||
|
data['answer'] = cf.file
|
||||||
|
return data
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
if data.get('question').type == Question.TYPE_FILE:
|
||||||
|
return self._handle_file_upload(data)
|
||||||
|
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||||
|
if not data.get('options'):
|
||||||
|
raise ValidationError(
|
||||||
|
'You need to specify options if the question is of a choice type.'
|
||||||
|
)
|
||||||
|
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1:
|
||||||
|
raise ValidationError(
|
||||||
|
'You can specify at most one option for this question.'
|
||||||
|
)
|
||||||
|
for o in data.get('options'):
|
||||||
|
if o.question_id != data.get('question').pk:
|
||||||
|
raise ValidationError(
|
||||||
|
'The specified option does not belong to this question.'
|
||||||
|
)
|
||||||
|
|
||||||
|
data['answer'] = ", ".join([str(o) for o in data.get('options')])
|
||||||
|
|
||||||
|
else:
|
||||||
|
if data.get('options'):
|
||||||
|
raise ValidationError(
|
||||||
|
'You should not specify options if the question is not of a choice type.'
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('question').type == Question.TYPE_BOOLEAN:
|
||||||
|
if data.get('answer') in ['true', 'True', '1', 'TRUE']:
|
||||||
|
data['answer'] = 'True'
|
||||||
|
elif data.get('answer') in ['false', 'False', '0', 'FALSE']:
|
||||||
|
data['answer'] = 'False'
|
||||||
|
else:
|
||||||
|
raise ValidationError(
|
||||||
|
'Please specify "true" or "false" for boolean questions.'
|
||||||
|
)
|
||||||
|
elif data.get('question').type == Question.TYPE_NUMBER:
|
||||||
|
serializers.DecimalField(
|
||||||
|
max_digits=50,
|
||||||
|
decimal_places=25
|
||||||
|
).to_internal_value(data.get('answer'))
|
||||||
|
elif data.get('question').type == Question.TYPE_DATE:
|
||||||
|
data['answer'] = serializers.DateField().to_internal_value(data.get('answer'))
|
||||||
|
elif data.get('question').type == Question.TYPE_TIME:
|
||||||
|
data['answer'] = serializers.TimeField().to_internal_value(data.get('answer'))
|
||||||
|
elif data.get('question').type == Question.TYPE_DATETIME:
|
||||||
|
data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer'))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class CheckinSerializer(I18nAwareModelSerializer):
|
class CheckinSerializer(I18nAwareModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -180,7 +258,7 @@ class PdfDataSerializer(serializers.Field):
|
|||||||
res = {}
|
res = {}
|
||||||
|
|
||||||
ev = instance.subevent or instance.order.event
|
ev = instance.subevent or instance.order.event
|
||||||
with language(instance.order.locale):
|
with language(instance.order.locale, instance.order.event.settings.region):
|
||||||
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
|
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
|
||||||
# we serialize a list.
|
# we serialize a list.
|
||||||
|
|
||||||
@@ -205,13 +283,14 @@ class PdfDataSerializer(serializers.Field):
|
|||||||
|
|
||||||
|
|
||||||
class OrderPositionSerializer(I18nAwareModelSerializer):
|
class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||||
checkins = CheckinSerializer(many=True)
|
checkins = CheckinSerializer(many=True, read_only=True)
|
||||||
answers = AnswerSerializer(many=True)
|
answers = AnswerSerializer(many=True)
|
||||||
downloads = PositionDownloadsField(source='*')
|
downloads = PositionDownloadsField(source='*', read_only=True)
|
||||||
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
||||||
pdf_data = PdfDataSerializer(source='*')
|
pdf_data = PdfDataSerializer(source='*', read_only=True)
|
||||||
seat = InlineSeatSerializer(read_only=True)
|
seat = InlineSeatSerializer(read_only=True)
|
||||||
country = CompatibleCountryField(source='*')
|
country = CompatibleCountryField(source='*')
|
||||||
|
attendee_name = serializers.CharField(required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = OrderPosition
|
model = OrderPosition
|
||||||
@@ -219,12 +298,99 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
|||||||
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
||||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
|
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
|
||||||
|
read_only_fields = (
|
||||||
|
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
|
||||||
|
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
|
||||||
|
'seat', 'canceled'
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
|
if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
|
||||||
self.fields.pop('pdf_data')
|
self.fields.pop('pdf_data')
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
||||||
|
raise ValidationError(
|
||||||
|
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||||
|
)
|
||||||
|
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
|
||||||
|
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||||
|
|
||||||
|
if data.get('country'):
|
||||||
|
if not pycountry.countries.get(alpha_2=data.get('country').code):
|
||||||
|
raise ValidationError(
|
||||||
|
{'country': ['Invalid country code.']}
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('state'):
|
||||||
|
cc = str(data.get('country') or self.instance.country or '')
|
||||||
|
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||||
|
raise ValidationError(
|
||||||
|
{'state': ['States are not supported in country "{}".'.format(cc)]}
|
||||||
|
)
|
||||||
|
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
|
||||||
|
raise ValidationError(
|
||||||
|
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
|
||||||
|
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
|
||||||
|
update_fields = [
|
||||||
|
'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
|
||||||
|
'state', 'attendee_email',
|
||||||
|
]
|
||||||
|
answers_data = validated_data.pop('answers', None)
|
||||||
|
|
||||||
|
name = validated_data.pop('attendee_name', '')
|
||||||
|
if name and not validated_data.get('attendee_name_parts'):
|
||||||
|
validated_data['attendee_name_parts'] = {
|
||||||
|
'_legacy': name
|
||||||
|
}
|
||||||
|
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
if attr in update_fields:
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
|
||||||
|
instance.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
if answers_data is not None:
|
||||||
|
qs_seen = set()
|
||||||
|
answercache = {
|
||||||
|
a.question_id: a for a in instance.answers.all()
|
||||||
|
}
|
||||||
|
for answ_data in answers_data:
|
||||||
|
options = answ_data.pop('options', [])
|
||||||
|
if answ_data['question'].pk in qs_seen:
|
||||||
|
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
|
||||||
|
if answ_data['question'].pk in answercache:
|
||||||
|
a = answercache[answ_data['question'].pk]
|
||||||
|
if isinstance(answ_data['answer'], File):
|
||||||
|
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
|
||||||
|
a.answer = 'file://' + a.file.name
|
||||||
|
else:
|
||||||
|
for attr, value in answ_data.items():
|
||||||
|
setattr(a, attr, value)
|
||||||
|
a.save()
|
||||||
|
else:
|
||||||
|
if isinstance(answ_data['answer'], File):
|
||||||
|
an = answ_data.pop('answer')
|
||||||
|
a = instance.answers.create(**answ_data, answer='')
|
||||||
|
a.file.save(an.name, an, save=False)
|
||||||
|
a.answer = 'file://' + a.file.name
|
||||||
|
a.save()
|
||||||
|
else:
|
||||||
|
a = instance.answers.create(**answ_data)
|
||||||
|
a.options.set(options)
|
||||||
|
qs_seen.add(a.question_id)
|
||||||
|
for qid, a in answercache.items():
|
||||||
|
if qid not in qs_seen:
|
||||||
|
a.delete()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class RequireAttentionField(serializers.Field):
|
class RequireAttentionField(serializers.Field):
|
||||||
def to_representation(self, instance: OrderPosition):
|
def to_representation(self, instance: OrderPosition):
|
||||||
@@ -336,7 +502,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = OrderRefund
|
model = OrderRefund
|
||||||
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
|
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'comment', 'provider')
|
||||||
|
|
||||||
|
|
||||||
class OrderURLField(serializers.URLField):
|
class OrderURLField(serializers.URLField):
|
||||||
@@ -361,7 +527,7 @@ class OrderSerializer(I18nAwareModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = (
|
fields = (
|
||||||
'code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
||||||
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
||||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
||||||
'url'
|
'url'
|
||||||
@@ -393,7 +559,7 @@ class OrderSerializer(I18nAwareModelSerializer):
|
|||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
|
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
|
||||||
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
|
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
|
||||||
update_fields = ['comment', 'checkin_attention', 'email', 'locale']
|
update_fields = ['comment', 'checkin_attention', 'email', 'locale', 'phone']
|
||||||
|
|
||||||
if 'invoice_address' in validated_data:
|
if 'invoice_address' in validated_data:
|
||||||
iadata = validated_data.pop('invoice_address')
|
iadata = validated_data.pop('invoice_address')
|
||||||
@@ -425,7 +591,17 @@ class OrderSerializer(I18nAwareModelSerializer):
|
|||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class AnswerQuestionOptionsField(serializers.Field):
|
||||||
|
def to_representation(self, instance: QuestionAnswer):
|
||||||
|
return [o.pk for o in instance.options.all()]
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatedAnswerSerializer(AnswerSerializer):
|
||||||
|
options = AnswerQuestionOptionsField(read_only=True, source='*')
|
||||||
|
|
||||||
|
|
||||||
class SimulatedOrderPositionSerializer(OrderPositionSerializer):
|
class SimulatedOrderPositionSerializer(OrderPositionSerializer):
|
||||||
|
answers = SimulatedAnswerSerializer(many=True)
|
||||||
addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid')
|
addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid')
|
||||||
|
|
||||||
|
|
||||||
@@ -452,62 +628,8 @@ class PriceCalcSerializer(serializers.Serializer):
|
|||||||
del self.fields['subevent']
|
del self.fields['subevent']
|
||||||
|
|
||||||
|
|
||||||
class AnswerCreateSerializer(I18nAwareModelSerializer):
|
class AnswerCreateSerializer(AnswerSerializer):
|
||||||
|
pass
|
||||||
class Meta:
|
|
||||||
model = QuestionAnswer
|
|
||||||
fields = ('question', 'answer', 'options')
|
|
||||||
|
|
||||||
def validate_question(self, q):
|
|
||||||
if q.event != self.context['event']:
|
|
||||||
raise ValidationError(
|
|
||||||
'The specified question does not belong to this event.'
|
|
||||||
)
|
|
||||||
return q
|
|
||||||
|
|
||||||
def validate(self, data):
|
|
||||||
if data.get('question').type == Question.TYPE_FILE:
|
|
||||||
raise ValidationError(
|
|
||||||
'File uploads are currently not supported via the API.'
|
|
||||||
)
|
|
||||||
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
|
||||||
if not data.get('options'):
|
|
||||||
raise ValidationError(
|
|
||||||
'You need to specify options if the question is of a choice type.'
|
|
||||||
)
|
|
||||||
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1:
|
|
||||||
raise ValidationError(
|
|
||||||
'You can specify at most one option for this question.'
|
|
||||||
)
|
|
||||||
data['answer'] = ", ".join([str(o) for o in data.get('options')])
|
|
||||||
|
|
||||||
else:
|
|
||||||
if data.get('options'):
|
|
||||||
raise ValidationError(
|
|
||||||
'You should not specify options if the question is not of a choice type.'
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.get('question').type == Question.TYPE_BOOLEAN:
|
|
||||||
if data.get('answer') in ['true', 'True', '1', 'TRUE']:
|
|
||||||
data['answer'] = 'True'
|
|
||||||
elif data.get('answer') in ['false', 'False', '0', 'FALSE']:
|
|
||||||
data['answer'] = 'False'
|
|
||||||
else:
|
|
||||||
raise ValidationError(
|
|
||||||
'Please specify "true" or "false" for boolean questions.'
|
|
||||||
)
|
|
||||||
elif data.get('question').type == Question.TYPE_NUMBER:
|
|
||||||
serializers.DecimalField(
|
|
||||||
max_digits=50,
|
|
||||||
decimal_places=25
|
|
||||||
).to_internal_value(data.get('answer'))
|
|
||||||
elif data.get('question').type == Question.TYPE_DATE:
|
|
||||||
data['answer'] = serializers.DateField().to_internal_value(data.get('answer'))
|
|
||||||
elif data.get('question').type == Question.TYPE_TIME:
|
|
||||||
data['answer'] = serializers.TimeField().to_internal_value(data.get('answer'))
|
|
||||||
elif data.get('question').type == Question.TYPE_DATETIME:
|
|
||||||
data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer'))
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class OrderFeeCreateSerializer(I18nAwareModelSerializer):
|
class OrderFeeCreateSerializer(I18nAwareModelSerializer):
|
||||||
@@ -682,7 +804,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
||||||
force = serializers.BooleanField(default=False, required=False)
|
force = serializers.BooleanField(default=False, required=False)
|
||||||
payment_date = serializers.DateTimeField(required=False, allow_null=True)
|
payment_date = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
send_mail = serializers.BooleanField(default=False, required=False)
|
send_email = serializers.BooleanField(default=False, required=False)
|
||||||
simulate = serializers.BooleanField(default=False, required=False)
|
simulate = serializers.BooleanField(default=False, required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -691,9 +813,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
|
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
|
||||||
'force', 'send_mail', 'simulate')
|
'force', 'send_email', 'simulate')
|
||||||
|
|
||||||
def validate_payment_provider(self, pp):
|
def validate_payment_provider(self, pp):
|
||||||
if pp is None:
|
if pp is None:
|
||||||
@@ -786,7 +908,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
payment_date = validated_data.pop('payment_date', now())
|
payment_date = validated_data.pop('payment_date', now())
|
||||||
force = validated_data.pop('force', False)
|
force = validated_data.pop('force', False)
|
||||||
simulate = validated_data.pop('simulate', False)
|
simulate = validated_data.pop('simulate', False)
|
||||||
self._send_mail = validated_data.pop('send_mail', False)
|
self._send_mail = validated_data.pop('send_email', False)
|
||||||
|
|
||||||
if 'invoice_address' in validated_data:
|
if 'invoice_address' in validated_data:
|
||||||
iadata = validated_data.pop('invoice_address')
|
iadata = validated_data.pop('invoice_address')
|
||||||
@@ -1044,6 +1166,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
pos.save()
|
pos.save()
|
||||||
for answ_data in answers_data:
|
for answ_data in answers_data:
|
||||||
options = answ_data.pop('options', [])
|
options = answ_data.pop('options', [])
|
||||||
|
|
||||||
|
if isinstance(answ_data['answer'], File):
|
||||||
|
an = answ_data.pop('answer')
|
||||||
|
answ = pos.answers.create(**answ_data, answer='')
|
||||||
|
answ.file.save(an.name, an, save=False)
|
||||||
|
answ.answer = 'file://' + answ.file.name
|
||||||
|
answ.save()
|
||||||
|
else:
|
||||||
answ = pos.answers.create(**answ_data)
|
answ = pos.answers.create(**answ_data)
|
||||||
answ.options.add(*options)
|
answ.options.add(*options)
|
||||||
pos_map[pos.positionid] = pos
|
pos_map[pos.positionid] = pos
|
||||||
@@ -1194,7 +1324,7 @@ class OrderRefundCreateSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = OrderRefund
|
model = OrderRefund
|
||||||
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info')
|
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info', 'comment')
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
pid = validated_data.pop('payment', None)
|
pid = validated_data.pop('payment', None)
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import get_language, gettext_lazy as _
|
from django.utils.crypto import get_random_string
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||||
from pretix.api.serializers.order import CompatibleJSONField
|
from pretix.api.serializers.order import CompatibleJSONField
|
||||||
|
from pretix.api.serializers.settings import SettingsSerializer
|
||||||
from pretix.base.auth import get_auth_backends
|
from pretix.base.auth import get_auth_backends
|
||||||
|
from pretix.base.i18n import get_language_without_region
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Device, GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
|
Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team,
|
||||||
User,
|
TeamAPIToken, TeamInvite, User,
|
||||||
)
|
)
|
||||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import SendMailException, mail
|
||||||
|
from pretix.base.settings import validate_organizer_settings
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import build_absolute_uri
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OrganizerSerializer(I18nAwareModelSerializer):
|
class OrganizerSerializer(I18nAwareModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -59,6 +66,21 @@ class GiftCardSerializer(I18nAwareModelSerializer):
|
|||||||
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions')
|
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions')
|
||||||
|
|
||||||
|
|
||||||
|
class OrderEventSlugField(serializers.RelatedField):
|
||||||
|
|
||||||
|
def to_representation(self, obj):
|
||||||
|
return obj.event.slug
|
||||||
|
|
||||||
|
|
||||||
|
class GiftCardTransactionSerializer(I18nAwareModelSerializer):
|
||||||
|
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
||||||
|
event = OrderEventSlugField(source='order', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = GiftCardTransaction
|
||||||
|
fields = ('id', 'datetime', 'value', 'event', 'order', 'text')
|
||||||
|
|
||||||
|
|
||||||
class EventSlugField(serializers.SlugRelatedField):
|
class EventSlugField(serializers.SlugRelatedField):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.context['organizer'].events.all()
|
return self.context['organizer'].events.all()
|
||||||
@@ -128,7 +150,7 @@ class TeamInviteSerializer(serializers.ModelSerializer):
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
event=None,
|
event=None,
|
||||||
locale=get_language() # TODO: expose?
|
locale=get_language_without_region() # TODO: expose?
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
pass # Already logged
|
pass # Already logged
|
||||||
@@ -187,3 +209,45 @@ class TeamMemberSerializer(serializers.ModelSerializer):
|
|||||||
fields = (
|
fields = (
|
||||||
'id', 'email', 'fullname', 'require_2fa'
|
'id', 'email', 'fullname', 'require_2fa'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizerSettingsSerializer(SettingsSerializer):
|
||||||
|
default_fields = [
|
||||||
|
'organizer_info_text',
|
||||||
|
'event_list_type',
|
||||||
|
'event_list_availability',
|
||||||
|
'organizer_homepage_text',
|
||||||
|
'organizer_link_back',
|
||||||
|
'organizer_logo_image_large',
|
||||||
|
'giftcard_length',
|
||||||
|
'giftcard_expiry_years',
|
||||||
|
'locales',
|
||||||
|
'region',
|
||||||
|
'event_team_provisioning',
|
||||||
|
'primary_color',
|
||||||
|
'theme_color_success',
|
||||||
|
'theme_color_danger',
|
||||||
|
'theme_color_background',
|
||||||
|
'theme_round_borders',
|
||||||
|
'primary_font',
|
||||||
|
'organizer_logo_image'
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.organizer = kwargs.pop('organizer')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
data = super().validate(data)
|
||||||
|
settings_dict = self.instance.freeze()
|
||||||
|
settings_dict.update(data)
|
||||||
|
validate_organizer_settings(self.organizer, settings_dict)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_new_filename(self, name: str) -> str:
|
||||||
|
nonce = get_random_string(length=8)
|
||||||
|
fname = '%s/%s.%s.%s' % (
|
||||||
|
self.organizer.slug, name.split('/')[-1], nonce, name.split('.')[-1]
|
||||||
|
)
|
||||||
|
# TODO: make sure pub is always correct
|
||||||
|
return 'pub/' + fname
|
||||||
|
|||||||
77
src/pretix/api/serializers/settings.py
Normal file
77
src/pretix/api/serializers/settings.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.files import File
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.db.models.fields.files import FieldFile
|
||||||
|
from hierarkey.proxy import HierarkeyProxy
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
from pretix.api.serializers.fields import UploadedFileField
|
||||||
|
from pretix.base.settings import DEFAULTS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsSerializer(serializers.Serializer):
|
||||||
|
default_fields = []
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.changed_data = []
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for fname in self.default_fields:
|
||||||
|
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
||||||
|
if callable(kwargs):
|
||||||
|
kwargs = kwargs()
|
||||||
|
kwargs.setdefault('required', False)
|
||||||
|
kwargs.setdefault('allow_null', True)
|
||||||
|
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||||
|
if callable(form_kwargs):
|
||||||
|
form_kwargs = form_kwargs()
|
||||||
|
if 'serializer_class' not in DEFAULTS[fname]:
|
||||||
|
raise ValidationError('{} has no serializer class'.format(fname))
|
||||||
|
f = DEFAULTS[fname]['serializer_class'](
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
f._label = form_kwargs.get('label', fname)
|
||||||
|
f._help_text = form_kwargs.get('help_text')
|
||||||
|
f.parent = self
|
||||||
|
self.fields[fname] = f
|
||||||
|
|
||||||
|
def update(self, instance: HierarkeyProxy, validated_data):
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
if isinstance(value, FieldFile):
|
||||||
|
# Delete old file
|
||||||
|
fname = instance.get(attr, as_type=File)
|
||||||
|
if fname:
|
||||||
|
try:
|
||||||
|
default_storage.delete(fname.name)
|
||||||
|
except OSError: # pragma: no cover
|
||||||
|
logger.error('Deleting file %s failed.' % fname.name)
|
||||||
|
|
||||||
|
# Create new file
|
||||||
|
newname = default_storage.save(self.get_new_filename(value.name), value)
|
||||||
|
instance.set(attr, File(file=value, name=newname))
|
||||||
|
self.changed_data.append(attr)
|
||||||
|
elif isinstance(self.fields[attr], UploadedFileField):
|
||||||
|
if value is None:
|
||||||
|
fname = instance.get(attr, as_type=File)
|
||||||
|
if fname:
|
||||||
|
try:
|
||||||
|
default_storage.delete(fname.name)
|
||||||
|
except OSError: # pragma: no cover
|
||||||
|
logger.error('Deleting file %s failed.' % fname.name)
|
||||||
|
instance.delete(attr)
|
||||||
|
else:
|
||||||
|
# file is unchanged
|
||||||
|
continue
|
||||||
|
elif value is None:
|
||||||
|
instance.delete(attr)
|
||||||
|
self.changed_data.append(attr)
|
||||||
|
elif instance.get(attr, as_type=type(value)) != value:
|
||||||
|
instance.set(attr, value)
|
||||||
|
self.changed_data.append(attr)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def get_new_filename(self, name: str) -> str:
|
||||||
|
raise NotImplementedError()
|
||||||
@@ -7,8 +7,8 @@ from rest_framework import routers
|
|||||||
from pretix.api.views import cart
|
from pretix.api.views import cart
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
checkin, device, event, exporters, item, oauth, order, organizer, user,
|
checkin, device, event, exporters, item, oauth, order, organizer, upload,
|
||||||
version, voucher, waitinglist, webhooks,
|
user, version, voucher, waitinglist, webhooks,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
@@ -62,6 +62,9 @@ order_router = routers.DefaultRouter()
|
|||||||
order_router.register(r'payments', order.PaymentViewSet)
|
order_router.register(r'payments', order.PaymentViewSet)
|
||||||
order_router.register(r'refunds', order.RefundViewSet)
|
order_router.register(r'refunds', order.RefundViewSet)
|
||||||
|
|
||||||
|
giftcard_router = routers.DefaultRouter()
|
||||||
|
giftcard_router.register(r'transactions', organizer.GiftCardTransactionViewSet)
|
||||||
|
|
||||||
# Force import of all plugins to give them a chance to register URLs with the router
|
# Force import of all plugins to give them a chance to register URLs with the router
|
||||||
for app in apps.get_app_configs():
|
for app in apps.get_app_configs():
|
||||||
if hasattr(app, 'PretixPluginMeta'):
|
if hasattr(app, 'PretixPluginMeta'):
|
||||||
@@ -71,6 +74,9 @@ for app in apps.get_app_configs():
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^', include(router.urls)),
|
url(r'^', include(router.urls)),
|
||||||
url(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)),
|
url(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)),
|
||||||
|
url(r'^organizers/(?P<organizer>[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(),
|
||||||
|
name="organizer.settings"),
|
||||||
|
url(r'^organizers/(?P<organizer>[^/]+)/giftcards/(?P<giftcard>[^/]+)/', include(giftcard_router.urls)),
|
||||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/settings/$', event.EventSettingsView.as_view(),
|
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/settings/$', event.EventSettingsView.as_view(),
|
||||||
name="event.settings"),
|
name="event.settings"),
|
||||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
|
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
|
||||||
@@ -89,6 +95,7 @@ urlpatterns = [
|
|||||||
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
|
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
|
||||||
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
|
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
|
||||||
url(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
|
url(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
|
||||||
|
url(r"^upload$", upload.UploadView.as_view(), name="upload"),
|
||||||
url(r"^me$", user.MeView.as_view(), name="user.me"),
|
url(r"^me$", user.MeView.as_view(), name="user.me"),
|
||||||
url(r"^version$", version.VersionView.as_view(), name="version"),
|
url(r"^version$", version.VersionView.as_view(), name="version"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from pretix.api.views import RichOrderingFilter
|
|||||||
from pretix.api.views.order import OrderPositionFilter
|
from pretix.api.views.order import OrderPositionFilter
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Checkin, CheckinList, Event, Order, OrderPosition,
|
CachedFile, Checkin, CheckinList, Event, Order, OrderPosition, Question,
|
||||||
)
|
)
|
||||||
from pretix.base.services.checkin import (
|
from pretix.base.services.checkin import (
|
||||||
CheckInError, RequiredQuestionsError, perform_checkin,
|
CheckInError, RequiredQuestionsError, perform_checkin,
|
||||||
@@ -302,6 +302,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
for q in op.item.questions.filter(ask_during_checkin=True):
|
for q in op.item.questions.filter(ask_during_checkin=True):
|
||||||
if str(q.pk) in aws:
|
if str(q.pk) in aws:
|
||||||
try:
|
try:
|
||||||
|
if q.type == Question.TYPE_FILE:
|
||||||
|
given_answers[q] = self._handle_file_upload(aws[str(q.pk)])
|
||||||
|
else:
|
||||||
given_answers[q] = q.clean_answer(aws[str(q.pk)])
|
given_answers[q] = q.clean_answer(aws[str(q.pk)])
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
pass
|
pass
|
||||||
@@ -352,3 +355,25 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
|
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||||
}, status=201)
|
}, status=201)
|
||||||
|
|
||||||
|
def _handle_file_upload(self, data):
|
||||||
|
try:
|
||||||
|
cf = CachedFile.objects.get(
|
||||||
|
session_key=f'api-upload-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}',
|
||||||
|
file__isnull=False,
|
||||||
|
pk=data[len("file:"):],
|
||||||
|
)
|
||||||
|
except (ValidationError, IndexError): # invalid uuid
|
||||||
|
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||||
|
except CachedFile.DoesNotExist:
|
||||||
|
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||||
|
|
||||||
|
allowed_types = (
|
||||||
|
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
|
||||||
|
)
|
||||||
|
if cf.type not in allowed_types:
|
||||||
|
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||||
|
if cf.file.size > 10 * 1024 * 1024:
|
||||||
|
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||||
|
|
||||||
|
return cf.file
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class EventSelectionView(APIView):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def base_event_qs(self):
|
def base_event_qs(self):
|
||||||
qs = self.request.auth.organizer.events.annotate(
|
qs = self.request.auth.get_events_with_any_permission().annotate(
|
||||||
first_date=Coalesce('date_admission', 'date_from'),
|
first_date=Coalesce('date_admission', 'date_from'),
|
||||||
last_date=Coalesce('date_to', 'date_from'),
|
last_date=Coalesce('date_to', 'date_from'),
|
||||||
).filter(
|
).filter(
|
||||||
@@ -154,6 +154,7 @@ class EventSelectionView(APIView):
|
|||||||
).filter(
|
).filter(
|
||||||
event__organizer=self.request.auth.organizer,
|
event__organizer=self.request.auth.organizer,
|
||||||
event__live=True,
|
event__live=True,
|
||||||
|
event__in=self.request.auth.get_events_with_any_permission(),
|
||||||
active=True,
|
active=True,
|
||||||
).select_related('event').order_by('first_date')
|
).select_related('event').order_by('first_date')
|
||||||
if self.request.auth.gate:
|
if self.request.auth.gate:
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ from pretix.base.models import (
|
|||||||
CartPosition, Device, Event, TaxRule, TeamAPIToken,
|
CartPosition, Device, Event, TaxRule, TeamAPIToken,
|
||||||
)
|
)
|
||||||
from pretix.base.models.event import SubEvent
|
from pretix.base.models.event import SubEvent
|
||||||
|
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
||||||
from pretix.helpers.dicts import merge_dicts
|
from pretix.helpers.dicts import merge_dicts
|
||||||
|
from pretix.presale.style import regenerate_css
|
||||||
from pretix.presale.views.organizer import filter_qs_by_attr
|
from pretix.presale.views.organizer import filter_qs_by_attr
|
||||||
|
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
@@ -26,6 +28,7 @@ with scopes_disabled():
|
|||||||
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
|
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
|
||||||
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
|
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
|
||||||
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
||||||
|
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Event
|
model = Event
|
||||||
@@ -67,6 +70,9 @@ with scopes_disabled():
|
|||||||
else:
|
else:
|
||||||
return queryset.exclude(expr)
|
return queryset.exclude(expr)
|
||||||
|
|
||||||
|
def sales_channel_qs(self, queryset, name, value):
|
||||||
|
return queryset.filter(sales_channels__contains=value)
|
||||||
|
|
||||||
|
|
||||||
class EventViewSet(viewsets.ModelViewSet):
|
class EventViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = EventSerializer
|
serializer_class = EventSerializer
|
||||||
@@ -359,9 +365,13 @@ class EventSettingsView(views.APIView):
|
|||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
if isinstance(request.auth, Device):
|
if isinstance(request.auth, Device):
|
||||||
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event)
|
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||||
|
'request': request
|
||||||
|
})
|
||||||
elif 'can_change_event_settings' in request.eventpermset:
|
elif 'can_change_event_settings' in request.eventpermset:
|
||||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||||
|
'request': request
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
if 'explain' in request.GET:
|
if 'explain' in request.GET:
|
||||||
@@ -376,7 +386,7 @@ class EventSettingsView(views.APIView):
|
|||||||
|
|
||||||
def patch(self, request, *wargs, **kwargs):
|
def patch(self, request, *wargs, **kwargs):
|
||||||
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
|
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
|
||||||
event=request.event)
|
event=request.event, context={'request': request})
|
||||||
s.is_valid(raise_exception=True)
|
s.is_valid(raise_exception=True)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
s.save()
|
s.save()
|
||||||
@@ -385,5 +395,10 @@ class EventSettingsView(views.APIView):
|
|||||||
k: v for k, v in s.validated_data.items()
|
k: v for k, v in s.validated_data.items()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
|
||||||
|
regenerate_css.apply_async(args=(request.event.pk,))
|
||||||
|
s = EventSettingsSerializer(
|
||||||
|
instance=request.event.settings, event=request.event, context={
|
||||||
|
'request': request
|
||||||
|
})
|
||||||
return Response(s.data)
|
return Response(s.data)
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ class ExportersMixin:
|
|||||||
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
cf = CachedFile()
|
cf = CachedFile(web_download=False)
|
||||||
cf.date = now()
|
cf.date = now()
|
||||||
cf.expires = now() + timedelta(days=3)
|
cf.expires = now() + timedelta(hours=24)
|
||||||
cf.save()
|
cf.save()
|
||||||
d = serializer.data
|
d = serializer.data
|
||||||
for k, v in d.items():
|
for k, v in d.items():
|
||||||
|
|||||||
@@ -558,6 +558,8 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
|
if 'send_mail' in request.data and 'send_email' not in request.data:
|
||||||
|
request.data['send_email'] = request.data['send_mail']
|
||||||
serializer = OrderCreateSerializer(data=request.data, context=self.get_serializer_context())
|
serializer = OrderCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -580,7 +582,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
auth=request.auth,
|
auth=request.auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
with language(order.locale):
|
with language(order.locale, self.request.event.settings.region):
|
||||||
order_placed.send(self.request.event, order=order)
|
order_placed.send(self.request.event, order=order)
|
||||||
if order.status == Order.STATUS_PAID:
|
if order.status == Order.STATUS_PAID:
|
||||||
order_paid.send(self.request.event, order=order)
|
order_paid.send(self.request.event, order=order)
|
||||||
@@ -672,6 +674,17 @@ class OrderViewSet(viewsets.ModelViewSet):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if 'phone' in self.request.data and serializer.instance.phone != self.request.data.get('phone'):
|
||||||
|
serializer.instance.log_action(
|
||||||
|
'pretix.event.order.phone.changed',
|
||||||
|
user=self.request.user,
|
||||||
|
auth=self.request.auth,
|
||||||
|
data={
|
||||||
|
'old_phone': serializer.instance.phone,
|
||||||
|
'new_phone': self.request.data.get('phone'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if 'locale' in self.request.data and serializer.instance.locale != self.request.data.get('locale'):
|
if 'locale' in self.request.data and serializer.instance.locale != self.request.data.get('locale'):
|
||||||
serializer.instance.log_action(
|
serializer.instance.log_action(
|
||||||
'pretix.event.order.locale.changed',
|
'pretix.event.order.locale.changed',
|
||||||
@@ -750,7 +763,7 @@ with scopes_disabled():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
serializer_class = OrderPositionSerializer
|
serializer_class = OrderPositionSerializer
|
||||||
queryset = OrderPosition.all.none()
|
queryset = OrderPosition.all.none()
|
||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
@@ -770,6 +783,11 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
ctx['event'] = self.request.event
|
||||||
|
return ctx
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
|
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
|
||||||
qs = OrderPosition.all
|
qs = OrderPosition.all
|
||||||
@@ -884,7 +902,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
|||||||
|
|
||||||
price = get_price(**kwargs)
|
price = get_price(**kwargs)
|
||||||
tr = kwargs.get('tax_rule', kwargs.get('item').tax_rule)
|
tr = kwargs.get('tax_rule', kwargs.get('item').tax_rule)
|
||||||
with language(data.get('locale') or self.request.event.settings.locale):
|
with language(data.get('locale') or self.request.event.settings.locale, self.request.event.settings.region):
|
||||||
return Response({
|
return Response({
|
||||||
'gross': price.gross,
|
'gross': price.gross,
|
||||||
'gross_formatted': money_filter(price.gross, self.request.event.currency, hide_currency=True),
|
'gross_formatted': money_filter(price.gross, self.request.event.currency, hide_currency=True),
|
||||||
@@ -938,6 +956,44 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
|||||||
except Quota.QuotaExceededException as e:
|
except Quota.QuotaExceededException as e:
|
||||||
raise ValidationError(str(e))
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
partial = kwargs.get('partial', False)
|
||||||
|
if not partial:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Method \"PUT\" not allowed."},
|
||||||
|
status=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||||
|
)
|
||||||
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
with transaction.atomic():
|
||||||
|
old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data
|
||||||
|
serializer.save()
|
||||||
|
new_data = serializer.data
|
||||||
|
|
||||||
|
if old_data != new_data:
|
||||||
|
log_data = self.request.data
|
||||||
|
if 'answers' in log_data:
|
||||||
|
for a in new_data['answers']:
|
||||||
|
log_data[f'question_{a["question"]}'] = a["answer"]
|
||||||
|
log_data.pop('answers', None)
|
||||||
|
serializer.instance.order.log_action(
|
||||||
|
'pretix.event.order.modified',
|
||||||
|
user=self.request.user,
|
||||||
|
auth=self.request.auth,
|
||||||
|
data={
|
||||||
|
'data': [
|
||||||
|
dict(
|
||||||
|
position=serializer.instance.pk,
|
||||||
|
**log_data
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
|
||||||
|
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
|
||||||
|
|
||||||
|
|
||||||
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
serializer_class = OrderPaymentSerializer
|
serializer_class = OrderPaymentSerializer
|
||||||
@@ -949,6 +1005,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['order'] = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
ctx['order'] = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||||
|
ctx['event'] = self.request.event
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@@ -956,6 +1013,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
return order.payments.all()
|
return order.payments.all()
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
|
send_mail = request.data.get('send_email', True)
|
||||||
serializer = OrderPaymentCreateSerializer(data=request.data, context=self.get_serializer_context())
|
serializer = OrderPaymentCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -971,7 +1029,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
user=self.request.user if self.request.user.is_authenticated else None,
|
user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
count_waitinglist=False,
|
count_waitinglist=False,
|
||||||
force=request.data.get('force', False)
|
force=request.data.get('force', False),
|
||||||
|
send_mail=send_mail,
|
||||||
)
|
)
|
||||||
except Quota.QuotaExceededException:
|
except Quota.QuotaExceededException:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from django.shortcuts import get_object_or_404
|
|||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
from rest_framework import filters, mixins, serializers, status, viewsets
|
from rest_framework import (
|
||||||
|
filters, mixins, serializers, status, views, viewsets,
|
||||||
|
)
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
|
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
|
||||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||||
@@ -15,15 +17,18 @@ from rest_framework.viewsets import GenericViewSet
|
|||||||
|
|
||||||
from pretix.api.models import OAuthAccessToken
|
from pretix.api.models import OAuthAccessToken
|
||||||
from pretix.api.serializers.organizer import (
|
from pretix.api.serializers.organizer import (
|
||||||
DeviceSerializer, GiftCardSerializer, OrganizerSerializer,
|
DeviceSerializer, GiftCardSerializer, GiftCardTransactionSerializer,
|
||||||
SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer,
|
OrganizerSerializer, OrganizerSettingsSerializer, SeatingPlanSerializer,
|
||||||
TeamMemberSerializer, TeamSerializer,
|
TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer,
|
||||||
|
TeamSerializer,
|
||||||
)
|
)
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Device, GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
|
Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team,
|
||||||
User,
|
TeamAPIToken, TeamInvite, User,
|
||||||
)
|
)
|
||||||
|
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
||||||
from pretix.helpers.dicts import merge_dicts
|
from pretix.helpers.dicts import merge_dicts
|
||||||
|
from pretix.presale.style import regenerate_organizer_css
|
||||||
|
|
||||||
|
|
||||||
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
@@ -191,6 +196,24 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
|||||||
raise MethodNotAllowed("Gift cards cannot be deleted.")
|
raise MethodNotAllowed("Gift cards cannot be deleted.")
|
||||||
|
|
||||||
|
|
||||||
|
class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = GiftCardTransactionSerializer
|
||||||
|
queryset = GiftCardTransaction.objects.none()
|
||||||
|
permission = 'can_manage_gift_cards'
|
||||||
|
write_permission = 'can_manage_gift_cards'
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def giftcard(self):
|
||||||
|
if self.request.GET.get('include_accepted') == 'true':
|
||||||
|
qs = self.request.organizer.accepted_gift_cards
|
||||||
|
else:
|
||||||
|
qs = self.request.organizer.issued_gift_cards.all()
|
||||||
|
return get_object_or_404(qs, pk=self.kwargs.get('giftcard'))
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.giftcard.transactions.select_related('order', 'order__event')
|
||||||
|
|
||||||
|
|
||||||
class TeamViewSet(viewsets.ModelViewSet):
|
class TeamViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = TeamSerializer
|
serializer_class = TeamSerializer
|
||||||
queryset = Team.objects.none()
|
queryset = Team.objects.none()
|
||||||
@@ -396,3 +419,43 @@ class DeviceViewSet(mixins.CreateModelMixin,
|
|||||||
data=self.request.data
|
data=self.request.data
|
||||||
)
|
)
|
||||||
return inst
|
return inst
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizerSettingsView(views.APIView):
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||||
|
'request': request
|
||||||
|
})
|
||||||
|
if 'explain' in request.GET:
|
||||||
|
return Response({
|
||||||
|
fname: {
|
||||||
|
'value': s.data[fname],
|
||||||
|
'label': getattr(field, '_label', fname),
|
||||||
|
'help_text': getattr(field, '_help_text', None)
|
||||||
|
} for fname, field in s.fields.items()
|
||||||
|
})
|
||||||
|
return Response(s.data)
|
||||||
|
|
||||||
|
def patch(self, request, *wargs, **kwargs):
|
||||||
|
s = OrganizerSettingsSerializer(
|
||||||
|
instance=request.organizer.settings, data=request.data, partial=True,
|
||||||
|
organizer=request.organizer, context={
|
||||||
|
'request': request
|
||||||
|
}
|
||||||
|
)
|
||||||
|
s.is_valid(raise_exception=True)
|
||||||
|
with transaction.atomic():
|
||||||
|
s.save()
|
||||||
|
self.request.organizer.log_action(
|
||||||
|
'pretix.organizer.settings', user=self.request.user, auth=self.request.auth, data={
|
||||||
|
k: v for k, v in s.validated_data.items()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
|
||||||
|
regenerate_organizer_css.apply_async(args=(request.organizer.pk,))
|
||||||
|
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||||
|
'request': request
|
||||||
|
})
|
||||||
|
return Response(s.data)
|
||||||
|
|||||||
55
src/pretix/api/views/upload.py
Normal file
55
src/pretix/api/views/upload.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||||
|
from rest_framework.authentication import SessionAuthentication
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
from rest_framework.parsers import FileUploadParser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from pretix.api.auth.device import DeviceTokenAuthentication
|
||||||
|
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
|
||||||
|
from pretix.api.auth.token import TeamTokenAuthentication
|
||||||
|
from pretix.base.models import CachedFile
|
||||||
|
|
||||||
|
ALLOWED_TYPES = {
|
||||||
|
'image/gif': {'.gif'},
|
||||||
|
'image/jpeg': {'.jpg', '.jpeg'},
|
||||||
|
'image/png': {'.png'},
|
||||||
|
'application/pdf': {'.pdf'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UploadView(APIView):
|
||||||
|
authentication_classes = (
|
||||||
|
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
|
||||||
|
)
|
||||||
|
parser_classes = [FileUploadParser]
|
||||||
|
permission_classes = [AnyAuthenticatedClientPermission]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
if 'file' not in request.data:
|
||||||
|
raise ValidationError('No file has been submitted.')
|
||||||
|
file_obj = request.data['file']
|
||||||
|
content_type = file_obj.content_type.split(";")[0] # ignore e.g. "; charset=…"
|
||||||
|
if content_type not in ALLOWED_TYPES:
|
||||||
|
raise ValidationError('Content type "{type}" is not allowed'.format(type=content_type))
|
||||||
|
if not any(file_obj.name.endswith(ext) for ext in ALLOWED_TYPES[content_type]):
|
||||||
|
raise ValidationError('File name "{name}" has an invalid extension for type "{type}"'.format(
|
||||||
|
name=file_obj.name,
|
||||||
|
type=content_type
|
||||||
|
))
|
||||||
|
cf = CachedFile.objects.create(
|
||||||
|
expires=now() + datetime.timedelta(days=1),
|
||||||
|
date=now(),
|
||||||
|
web_download=False,
|
||||||
|
filename=file_obj.name,
|
||||||
|
type=content_type,
|
||||||
|
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}'
|
||||||
|
)
|
||||||
|
cf.file.save(file_obj.name, file_obj)
|
||||||
|
cf.save()
|
||||||
|
return Response({
|
||||||
|
'id': f'file:{cf.pk}'
|
||||||
|
}, status=201)
|
||||||
@@ -6,6 +6,7 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from pretix import __version__
|
from pretix import __version__
|
||||||
from pretix.api.auth.device import DeviceTokenAuthentication
|
from pretix.api.auth.device import DeviceTokenAuthentication
|
||||||
|
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
|
||||||
from pretix.api.auth.token import TeamTokenAuthentication
|
from pretix.api.auth.token import TeamTokenAuthentication
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ class VersionView(APIView):
|
|||||||
authentication_classes = (
|
authentication_classes = (
|
||||||
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
|
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
|
||||||
)
|
)
|
||||||
|
permission_classes = [AnyAuthenticatedClientPermission]
|
||||||
|
|
||||||
def get(self, request, format=None):
|
def get(self, request, format=None):
|
||||||
return Response({
|
return Response({
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
|||||||
'body': body_md,
|
'body': body_md,
|
||||||
'subject': str(subject),
|
'subject': str(subject),
|
||||||
'color': settings.PRETIX_PRIMARY_COLOR,
|
'color': settings.PRETIX_PRIMARY_COLOR,
|
||||||
'rtl': get_language() in settings.LANGUAGES_RTL
|
'rtl': get_language() in settings.LANGUAGES_RTL or get_language().split('-')[0] in settings.LANGUAGES_RTL,
|
||||||
}
|
}
|
||||||
if self.event:
|
if self.event:
|
||||||
htmlctx['event'] = self.event
|
htmlctx['event'] = self.event
|
||||||
@@ -427,28 +427,30 @@ def base_placeholders(sender, **kwargs):
|
|||||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||||
'* {} - {}'.format(
|
'* {} - {}'.format(
|
||||||
order.full_code,
|
order.full_code,
|
||||||
build_absolute_uri(event, 'presale:event.order', kwargs={
|
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||||
'event': event.slug,
|
'event': event.slug,
|
||||||
'organizer': event.organizer.slug,
|
'organizer': event.organizer.slug,
|
||||||
'order': order.code,
|
'order': order.code,
|
||||||
'secret': order.secret
|
'secret': order.secret,
|
||||||
|
'hash': order.email_confirm_hash(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
for order in orders
|
for order in orders
|
||||||
), lambda event: '\n' + '\n\n'.join(
|
), lambda event: '\n' + '\n\n'.join(
|
||||||
'* {} - {}'.format(
|
'* {} - {}'.format(
|
||||||
'{}-{}'.format(event.slug.upper(), order['code']),
|
'{}-{}'.format(event.slug.upper(), order['code']),
|
||||||
build_absolute_uri(event, 'presale:event.order', kwargs={
|
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||||
'event': event.slug,
|
'event': event.slug,
|
||||||
'organizer': event.organizer.slug,
|
'organizer': event.organizer.slug,
|
||||||
'order': order['code'],
|
'order': order['code'],
|
||||||
'secret': order['secret']
|
'secret': order['secret'],
|
||||||
|
'hash': order['hash'],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
for order in [
|
for order in [
|
||||||
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy'},
|
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': 'abcdefghi'},
|
||||||
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd'},
|
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd', 'hash': 'jklmnopqr'},
|
||||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd'}
|
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ from .invoices import * # noqa
|
|||||||
from .json import * # noqa
|
from .json import * # noqa
|
||||||
from .mail import * # noqa
|
from .mail import * # noqa
|
||||||
from .orderlist import * # noqa
|
from .orderlist import * # noqa
|
||||||
|
from .waitinglist import * # noqa
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
initial=False,
|
initial=False,
|
||||||
required=False
|
required=False
|
||||||
)),
|
)),
|
||||||
|
('group_multiple_choice',
|
||||||
|
forms.BooleanField(
|
||||||
|
label=_('Show multiple choice answers grouped in one column'),
|
||||||
|
initial=False,
|
||||||
|
required=False
|
||||||
|
)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,7 +145,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
tax_rates = self._get_all_tax_rates(qs)
|
tax_rates = self._get_all_tax_rates(qs)
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Phone number'), _('Order date'),
|
||||||
_('Order time'), _('Company'), _('Name'),
|
_('Order time'), _('Company'), _('Name'),
|
||||||
]
|
]
|
||||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
||||||
@@ -147,8 +153,8 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
for k, label, w in name_scheme['fields']:
|
for k, label, w in name_scheme['fields']:
|
||||||
headers.append(label)
|
headers.append(label)
|
||||||
headers += [
|
headers += [
|
||||||
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
|
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'),
|
||||||
_('Date of last payment'), _('Fees'), _('Order locale')
|
_('Custom address field'), _('VAT ID'), _('Date of last payment'), _('Fees'), _('Order locale')
|
||||||
]
|
]
|
||||||
|
|
||||||
for tr in tax_rates:
|
for tr in tax_rates:
|
||||||
@@ -215,6 +221,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
order.total,
|
order.total,
|
||||||
order.get_status_display(),
|
order.get_status_display(),
|
||||||
order.email,
|
order.email,
|
||||||
|
str(order.phone) if order.phone else '',
|
||||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||||
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
||||||
]
|
]
|
||||||
@@ -235,10 +242,11 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
order.invoice_address.country if order.invoice_address.country else
|
order.invoice_address.country if order.invoice_address.country else
|
||||||
order.invoice_address.country_old,
|
order.invoice_address.country_old,
|
||||||
order.invoice_address.state,
|
order.invoice_address.state,
|
||||||
|
order.invoice_address.custom_field,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
|
row += [''] * (9 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
|
||||||
|
|
||||||
row += [
|
row += [
|
||||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||||
@@ -302,6 +310,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
_('Order code'),
|
_('Order code'),
|
||||||
_('Status'),
|
_('Status'),
|
||||||
_('Email'),
|
_('Email'),
|
||||||
|
_('Phone number'),
|
||||||
_('Order date'),
|
_('Order date'),
|
||||||
_('Order time'),
|
_('Order time'),
|
||||||
_('Fee type'),
|
_('Fee type'),
|
||||||
@@ -333,6 +342,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
order.code,
|
order.code,
|
||||||
order.get_status_display(),
|
order.get_status_display(),
|
||||||
order.email,
|
order.email,
|
||||||
|
str(order.phone) if order.phone else '',
|
||||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||||
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
||||||
op.get_fee_type_display(),
|
op.get_fee_type_display(),
|
||||||
@@ -401,6 +411,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
_('Position ID'),
|
_('Position ID'),
|
||||||
_('Status'),
|
_('Status'),
|
||||||
_('Email'),
|
_('Email'),
|
||||||
|
_('Phone number'),
|
||||||
_('Order date'),
|
_('Order date'),
|
||||||
_('Order time'),
|
_('Order time'),
|
||||||
]
|
]
|
||||||
@@ -444,6 +455,11 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
for q in questions:
|
for q in questions:
|
||||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||||
options[q.pk] = []
|
options[q.pk] = []
|
||||||
|
if form_data['group_multiple_choice']:
|
||||||
|
for o in q.options.all():
|
||||||
|
options[q.pk].append(o)
|
||||||
|
headers.append(str(q.question))
|
||||||
|
else:
|
||||||
for o in q.options.all():
|
for o in q.options.all():
|
||||||
headers.append(str(q.question) + ' – ' + str(o.answer))
|
headers.append(str(q.question) + ' – ' + str(o.answer))
|
||||||
options[q.pk].append(o)
|
options[q.pk].append(o)
|
||||||
@@ -480,6 +496,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
op.positionid,
|
op.positionid,
|
||||||
order.get_status_display(),
|
order.get_status_display(),
|
||||||
order.email,
|
order.email,
|
||||||
|
str(order.phone) if order.phone else '',
|
||||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||||
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
||||||
]
|
]
|
||||||
@@ -545,6 +562,9 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
acache[a.question_id] = str(a)
|
acache[a.question_id] = str(a)
|
||||||
for q in questions:
|
for q in questions:
|
||||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||||
|
if form_data['group_multiple_choice']:
|
||||||
|
row.append(", ".join(str(o.answer) for o in options[q.pk] if o.pk in acache.get(q.pk, set())))
|
||||||
|
else:
|
||||||
for o in options[q.pk]:
|
for o in options[q.pk]:
|
||||||
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
|
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
|
||||||
else:
|
else:
|
||||||
@@ -632,7 +652,7 @@ class PaymentListExporter(ListExporter):
|
|||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||||
_('Status code'), _('Amount'), _('Payment method')
|
_('Status code'), _('Amount'), _('Payment method'), _('Comment')
|
||||||
]
|
]
|
||||||
yield headers
|
yield headers
|
||||||
|
|
||||||
@@ -654,7 +674,8 @@ class PaymentListExporter(ListExporter):
|
|||||||
obj.get_state_display(),
|
obj.get_state_display(),
|
||||||
obj.state,
|
obj.state,
|
||||||
obj.amount * (-1 if isinstance(obj, OrderRefund) else 1),
|
obj.amount * (-1 if isinstance(obj, OrderRefund) else 1),
|
||||||
provider_names.get(obj.provider, obj.provider)
|
provider_names.get(obj.provider, obj.provider),
|
||||||
|
obj.comment if isinstance(obj, OrderRefund) else "",
|
||||||
]
|
]
|
||||||
yield row
|
yield row
|
||||||
|
|
||||||
|
|||||||
165
src/pretix/base/exporters/waitinglist.py
Normal file
165
src/pretix/base/exporters/waitinglist.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from django import forms
|
||||||
|
from django.db.models import F, Q
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
|
|
||||||
|
from pretix.base.models.waitinglist import WaitingListEntry
|
||||||
|
|
||||||
|
from ..exporter import ListExporter
|
||||||
|
from ..signals import (
|
||||||
|
register_data_exporters, register_multievent_data_exporters,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WaitingListExporter(ListExporter):
|
||||||
|
identifier = 'waitinglist'
|
||||||
|
verbose_name = _('Waiting list')
|
||||||
|
|
||||||
|
# map selected status to label and queryset-filter
|
||||||
|
status_filters = [
|
||||||
|
(
|
||||||
|
'',
|
||||||
|
_('All entries'),
|
||||||
|
lambda qs: qs
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'awaiting-voucher',
|
||||||
|
_('Waiting for a voucher'),
|
||||||
|
lambda qs: qs.filter(voucher__isnull=True)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'voucher-assigned',
|
||||||
|
_('Voucher assigned'),
|
||||||
|
lambda qs: qs.filter(voucher__isnull=False)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'awaiting-redemption',
|
||||||
|
_('Waiting for redemption'),
|
||||||
|
lambda qs: qs.filter(
|
||||||
|
voucher__isnull=False,
|
||||||
|
voucher__redeemed__lt=F('voucher__max_usages'),
|
||||||
|
).filter(Q(voucher__valid_until__isnull=True) | Q(voucher__valid_until__gt=now()))
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'voucher-redeemed',
|
||||||
|
_('Voucher redeemed'),
|
||||||
|
lambda qs: qs.filter(
|
||||||
|
voucher__isnull=False,
|
||||||
|
voucher__redeemed__gte=F('voucher__max_usages'),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'voucher-expired',
|
||||||
|
_('Voucher expired'),
|
||||||
|
lambda qs: qs.filter(
|
||||||
|
voucher__isnull=False,
|
||||||
|
voucher__redeemed__lt=F('voucher__max_usages'),
|
||||||
|
voucher__valid_until__isnull=False,
|
||||||
|
voucher__valid_until__lte=now()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def iterate_list(self, form_data):
|
||||||
|
# create dicts for easier access by key, which is passed by form_data[status]
|
||||||
|
status_labels = {k: v for k, v, c in self.status_filters}
|
||||||
|
queryset_mutators = {k: c for k, v, c in self.status_filters}
|
||||||
|
|
||||||
|
entries = WaitingListEntry.objects.filter(
|
||||||
|
event__in=self.events,
|
||||||
|
).select_related(
|
||||||
|
'item', 'variation', 'voucher', 'subevent'
|
||||||
|
).order_by('created')
|
||||||
|
|
||||||
|
# apply filter to queryset/entries according to status
|
||||||
|
# if unknown status-filter is given, django will handle the error
|
||||||
|
status_filter = form_data.get("status", "")
|
||||||
|
entries = queryset_mutators[status_filter](entries)
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
_('Date'),
|
||||||
|
_('Email'),
|
||||||
|
_('Product name'),
|
||||||
|
_('Variation'),
|
||||||
|
_('Event slug'),
|
||||||
|
_('Event name'),
|
||||||
|
pgettext_lazy('subevents', 'Date'), # Name of subevent
|
||||||
|
_('Start date'), # Start date of subevent or event
|
||||||
|
_('End date'), # End date of subevent or event
|
||||||
|
_('Language'),
|
||||||
|
_('Priority'),
|
||||||
|
_('Status'),
|
||||||
|
_('Voucher code'),
|
||||||
|
]
|
||||||
|
|
||||||
|
yield headers
|
||||||
|
yield self.ProgressSetTotal(total=len(entries))
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if entry.voucher:
|
||||||
|
if entry.voucher.redeemed >= entry.voucher.max_usages:
|
||||||
|
status_label = status_labels['voucher-redeemed']
|
||||||
|
elif not entry.voucher.is_active():
|
||||||
|
status_label = status_labels['voucher-expired']
|
||||||
|
else:
|
||||||
|
status_label = status_labels['voucher-assigned']
|
||||||
|
else:
|
||||||
|
status_label = status_labels['awaiting-voucher']
|
||||||
|
|
||||||
|
# which event should be used to output dates in columns "Start date" and "End date"
|
||||||
|
event_for_date_columns = entry.subevent if entry.subevent else entry.event
|
||||||
|
tz = pytz.timezone(entry.event.settings.timezone)
|
||||||
|
datetime_format = '%Y-%m-%d %H:%M:%S'
|
||||||
|
|
||||||
|
row = [
|
||||||
|
entry.created.astimezone(tz).strftime(datetime_format), # alternative: .isoformat(),
|
||||||
|
entry.email,
|
||||||
|
str(entry.item) if entry.item else "",
|
||||||
|
str(entry.variation) if entry.variation else "",
|
||||||
|
entry.event.slug,
|
||||||
|
entry.event.name,
|
||||||
|
entry.subevent.name if entry.subevent else "",
|
||||||
|
event_for_date_columns.date_from.astimezone(tz).strftime(datetime_format),
|
||||||
|
event_for_date_columns.date_to.astimezone(tz).strftime(datetime_format) if event_for_date_columns.date_to else "",
|
||||||
|
entry.locale,
|
||||||
|
str(entry.priority),
|
||||||
|
status_label,
|
||||||
|
entry.voucher.code if entry.voucher else '',
|
||||||
|
]
|
||||||
|
yield row
|
||||||
|
|
||||||
|
@property
|
||||||
|
def additional_form_fields(self):
|
||||||
|
return OrderedDict(
|
||||||
|
[
|
||||||
|
('status',
|
||||||
|
forms.ChoiceField(
|
||||||
|
label=_('Status'),
|
||||||
|
initial=['awaiting-voucher'],
|
||||||
|
required=False,
|
||||||
|
choices=[(k, v) for (k, v, c) in self.status_filters]
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_filename(self):
|
||||||
|
if self.is_multievent:
|
||||||
|
event = self.events.first()
|
||||||
|
slug = event.organizer.slug if len(self.events) > 1 else event.slug
|
||||||
|
else:
|
||||||
|
slug = self.event.slug
|
||||||
|
return '{}_waitinglist'.format(slug)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(register_data_exporters, dispatch_uid="exporter_waitinglist")
|
||||||
|
def register_waitinglist_exporter(sender, **kwargs):
|
||||||
|
return WaitingListExporter
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_waitinglist")
|
||||||
|
def register_multievent_i_waitinglist_exporter(sender, **kwargs):
|
||||||
|
return WaitingListExporter
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.password_validation import (
|
from django.contrib.auth.password_validation import (
|
||||||
password_validators_help_texts, validate_password,
|
password_validators_help_texts, validate_password,
|
||||||
)
|
)
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from pretix.base.models import User
|
from pretix.base.models import User
|
||||||
from pretix.helpers.dicts import move_to_end
|
from pretix.helpers.dicts import move_to_end
|
||||||
|
from pretix.helpers.http import get_client_ip
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(forms.Form):
|
class LoginForm(forms.Form):
|
||||||
@@ -18,6 +23,7 @@ class LoginForm(forms.Form):
|
|||||||
|
|
||||||
error_messages = {
|
error_messages = {
|
||||||
'invalid_login': _("This combination of credentials is not known to our system."),
|
'invalid_login': _("This combination of credentials is not known to our system."),
|
||||||
|
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||||
'inactive': _("This account is inactive.")
|
'inactive': _("This account is inactive.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,10 +45,36 @@ class LoginForm(forms.Form):
|
|||||||
else:
|
else:
|
||||||
move_to_end(self.fields, 'keep_logged_in')
|
move_to_end(self.fields, 'keep_logged_in')
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def ratelimit_key(self):
|
||||||
|
if not settings.HAS_REDIS:
|
||||||
|
return None
|
||||||
|
client_ip = get_client_ip(self.request)
|
||||||
|
if not client_ip:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
client_ip = ipaddress.ip_address(client_ip)
|
||||||
|
except ValueError:
|
||||||
|
# Web server not set up correctly
|
||||||
|
return None
|
||||||
|
if client_ip.is_private:
|
||||||
|
# This is the private IP of the server, web server not set up correctly
|
||||||
|
return None
|
||||||
|
return 'pretix_login_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
|
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
|
||||||
|
if self.ratelimit_key:
|
||||||
|
from django_redis import get_redis_connection
|
||||||
|
rc = get_redis_connection("redis")
|
||||||
|
cnt = rc.get(self.ratelimit_key)
|
||||||
|
if cnt and int(cnt) > 10:
|
||||||
|
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
|
||||||
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
||||||
if self.user_cache is None:
|
if self.user_cache is None:
|
||||||
|
if self.ratelimit_key:
|
||||||
|
rc.incr(self.ratelimit_key)
|
||||||
|
rc.expire(self.ratelimit_key, 300)
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['invalid_login'],
|
self.error_messages['invalid_login'],
|
||||||
code='invalid_login'
|
code='invalid_login'
|
||||||
|
|||||||
@@ -9,30 +9,34 @@ import pycountry
|
|||||||
import pytz
|
import pytz
|
||||||
import vat_moss.errors
|
import vat_moss.errors
|
||||||
import vat_moss.id
|
import vat_moss.id
|
||||||
from babel import localedata
|
from babel import Locale
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.forms import Select
|
from django.forms import Select
|
||||||
|
from django.utils import translation
|
||||||
|
from django.utils.formats import date_format
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import (
|
from django.utils.timezone import get_current_timezone
|
||||||
get_language, gettext_lazy as _, pgettext_lazy,
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
)
|
|
||||||
from django_countries import countries
|
from django_countries import countries
|
||||||
from django_countries.fields import Country, CountryField
|
from django_countries.fields import Country, CountryField
|
||||||
from phonenumber_field.formfields import PhoneNumberField
|
from phonenumber_field.formfields import PhoneNumberField
|
||||||
from phonenumber_field.phonenumber import PhoneNumber
|
from phonenumber_field.phonenumber import PhoneNumber
|
||||||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||||||
from phonenumbers import NumberParseException
|
from phonenumbers import NumberParseException, national_significant_number
|
||||||
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
||||||
|
|
||||||
from pretix.base.forms.widgets import (
|
from pretix.base.forms.widgets import (
|
||||||
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
|
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
|
||||||
TimePickerWidget, UploadedFileWidget,
|
TimePickerWidget, UploadedFileWidget,
|
||||||
)
|
)
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import (
|
||||||
|
get_babel_locale, get_language_without_region, language,
|
||||||
|
)
|
||||||
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
||||||
from pretix.base.models.tax import (
|
from pretix.base.models.tax import (
|
||||||
EU_COUNTRIES, cc_to_vat_prefix, is_eu_country,
|
EU_COUNTRIES, cc_to_vat_prefix, is_eu_country,
|
||||||
@@ -201,7 +205,47 @@ class NamePartsFormField(forms.MultiValueField):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class WrappedPhonePrefixSelect(Select):
|
||||||
|
initial = None
|
||||||
|
|
||||||
|
def __init__(self, initial=None):
|
||||||
|
choices = [("", "---------")]
|
||||||
|
language = get_babel_locale() # changed from default implementation that used the django locale
|
||||||
|
locale = Locale(translation.to_locale(language))
|
||||||
|
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||||
|
prefix = "+%d" % prefix
|
||||||
|
if initial and initial in values:
|
||||||
|
self.initial = prefix
|
||||||
|
for country_code in values:
|
||||||
|
country_name = locale.territories.get(country_code)
|
||||||
|
if country_name:
|
||||||
|
choices.append((prefix, "{} {}".format(country_name, prefix)))
|
||||||
|
super().__init__(choices=sorted(choices, key=lambda item: item[1]))
|
||||||
|
|
||||||
|
def render(self, name, value, *args, **kwargs):
|
||||||
|
return super().render(name, value or self.initial, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context(self, name, value, attrs):
|
||||||
|
if value and self.choices[1][0] != value:
|
||||||
|
matching_choices = len([1 for p, c in self.choices if p == value])
|
||||||
|
if matching_choices > 1:
|
||||||
|
# Some countries share a phone prefix, for example +1 is used all over the Americas.
|
||||||
|
# This causes a UX problem: If the default value or the existing data is +12125552368,
|
||||||
|
# the widget will just show the first <option> entry with value="+1" as selected,
|
||||||
|
# which alphabetically is America Samoa, although most numbers statistically are from
|
||||||
|
# the US. As a workaround, we detect this case and add an aditional choice value with
|
||||||
|
# just <option value="+1">+1</option> without an explicit country.
|
||||||
|
self.choices.insert(1, (value, value))
|
||||||
|
context = super().get_context(name, value, attrs)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||||
|
|
||||||
|
def __init__(self, attrs=None, initial=None):
|
||||||
|
widgets = (WrappedPhonePrefixSelect(initial), forms.TextInput())
|
||||||
|
super(PhoneNumberPrefixWidget, self).__init__(widgets, attrs)
|
||||||
|
|
||||||
def render(self, name, value, attrs=None, renderer=None):
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
output = super().render(name, value, attrs, renderer)
|
output = super().render(name, value, attrs, renderer)
|
||||||
return mark_safe(self.format_output(output))
|
return mark_safe(self.format_output(output))
|
||||||
@@ -209,12 +253,44 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
|||||||
def format_output(self, rendered_widgets) -> str:
|
def format_output(self, rendered_widgets) -> str:
|
||||||
return '<div class="nameparts-form-group">%s</div>' % ''.join(rendered_widgets)
|
return '<div class="nameparts-form-group">%s</div>' % ''.join(rendered_widgets)
|
||||||
|
|
||||||
|
def decompress(self, value):
|
||||||
|
"""
|
||||||
|
If an incomplete phone number (e.g. without country prefix) is currently entered,
|
||||||
|
the default implementation just discards the value and shows nothing at all.
|
||||||
|
Let's rather show something invalid, so the user is prompted to fix it, instead of
|
||||||
|
silently deleting data.
|
||||||
|
"""
|
||||||
|
if value:
|
||||||
|
if type(value) == PhoneNumber:
|
||||||
|
if value.country_code and value.national_number:
|
||||||
|
return [
|
||||||
|
"+%d" % value.country_code,
|
||||||
|
national_significant_number(value),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
None,
|
||||||
|
str(value)
|
||||||
|
]
|
||||||
|
elif "." in value:
|
||||||
|
return value.split(".")
|
||||||
|
else:
|
||||||
|
return [None, value]
|
||||||
|
return [None, ""]
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
# In contrast to defualt implementation, do not silently fail if a number without
|
||||||
|
# country prefix is entered
|
||||||
|
values = super(PhoneNumberPrefixWidget, self).value_from_datadict(data, files, name)
|
||||||
|
if values[1]:
|
||||||
|
return "%s.%s" % tuple(values)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def guess_country(event):
|
def guess_country(event):
|
||||||
# Try to guess the initial country from either the country of the merchant
|
# Try to guess the initial country from either the country of the merchant
|
||||||
# or the locale. This will hopefully save at least some users some scrolling :)
|
# or the locale. This will hopefully save at least some users some scrolling :)
|
||||||
locale = get_language()
|
locale = get_language_without_region()
|
||||||
country = event.settings.invoice_address_from_country
|
country = event.settings.region or event.settings.invoice_address_from_country
|
||||||
if not country:
|
if not country:
|
||||||
valid_countries = countries.countries
|
valid_countries = countries.countries
|
||||||
if '-' in locale:
|
if '-' in locale:
|
||||||
@@ -234,6 +310,43 @@ class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
|
|||||||
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
||||||
|
|
||||||
|
|
||||||
|
class MinDateValidator(MinValueValidator):
|
||||||
|
def __call__(self, value):
|
||||||
|
try:
|
||||||
|
return super().__call__(value)
|
||||||
|
except ValidationError as e:
|
||||||
|
e.params['limit_value'] = date_format(e.params['limit_value'], 'SHORT_DATE_FORMAT')
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
class MinDateTimeValidator(MinValueValidator):
|
||||||
|
def __call__(self, value):
|
||||||
|
try:
|
||||||
|
return super().__call__(value)
|
||||||
|
except ValidationError as e:
|
||||||
|
e.params['limit_value'] = date_format(e.params['limit_value'].astimezone(get_current_timezone()), 'SHORT_DATETIME_FORMAT')
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
class MaxDateValidator(MaxValueValidator):
|
||||||
|
|
||||||
|
def __call__(self, value):
|
||||||
|
try:
|
||||||
|
return super().__call__(value)
|
||||||
|
except ValidationError as e:
|
||||||
|
e.params['limit_value'] = date_format(e.params['limit_value'], 'SHORT_DATE_FORMAT')
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
class MaxDateTimeValidator(MaxValueValidator):
|
||||||
|
def __call__(self, value):
|
||||||
|
try:
|
||||||
|
return super().__call__(value)
|
||||||
|
except ValidationError as e:
|
||||||
|
e.params['limit_value'] = date_format(e.params['limit_value'].astimezone(get_current_timezone()), 'SHORT_DATETIME_FORMAT')
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
class BaseQuestionsForm(forms.Form):
|
class BaseQuestionsForm(forms.Form):
|
||||||
"""
|
"""
|
||||||
This form class is responsible for asking order-related questions. This includes
|
This form class is responsible for asking order-related questions. This includes
|
||||||
@@ -392,9 +505,10 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
elif q.type == Question.TYPE_NUMBER:
|
elif q.type == Question.TYPE_NUMBER:
|
||||||
field = forms.DecimalField(
|
field = forms.DecimalField(
|
||||||
label=label, required=required,
|
label=label, required=required,
|
||||||
|
min_value=q.valid_number_min or Decimal('0.00'),
|
||||||
|
max_value=q.valid_number_max,
|
||||||
help_text=q.help_text,
|
help_text=q.help_text,
|
||||||
initial=initial.answer if initial else None,
|
initial=initial.answer if initial else None,
|
||||||
min_value=Decimal('0.00'),
|
|
||||||
)
|
)
|
||||||
elif q.type == Question.TYPE_STRING:
|
elif q.type == Question.TYPE_STRING:
|
||||||
field = forms.CharField(
|
field = forms.CharField(
|
||||||
@@ -453,12 +567,21 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
max_size=10 * 1024 * 1024,
|
max_size=10 * 1024 * 1024,
|
||||||
)
|
)
|
||||||
elif q.type == Question.TYPE_DATE:
|
elif q.type == Question.TYPE_DATE:
|
||||||
|
attrs = {}
|
||||||
|
if q.valid_date_min:
|
||||||
|
attrs['data-min'] = q.valid_date_min.isoformat()
|
||||||
|
if q.valid_date_max:
|
||||||
|
attrs['data-max'] = q.valid_date_max.isoformat()
|
||||||
field = forms.DateField(
|
field = forms.DateField(
|
||||||
label=label, required=required,
|
label=label, required=required,
|
||||||
help_text=help_text,
|
help_text=help_text,
|
||||||
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
|
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
|
||||||
widget=DatePickerWidget(),
|
widget=DatePickerWidget(attrs),
|
||||||
)
|
)
|
||||||
|
if q.valid_date_min:
|
||||||
|
field.validators.append(MinDateValidator(q.valid_date_min))
|
||||||
|
if q.valid_date_max:
|
||||||
|
field.validators.append(MaxDateValidator(q.valid_date_max))
|
||||||
elif q.type == Question.TYPE_TIME:
|
elif q.type == Question.TYPE_TIME:
|
||||||
field = forms.TimeField(
|
field = forms.TimeField(
|
||||||
label=label, required=required,
|
label=label, required=required,
|
||||||
@@ -471,16 +594,18 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
label=label, required=required,
|
label=label, required=required,
|
||||||
help_text=help_text,
|
help_text=help_text,
|
||||||
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
|
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')),
|
widget=SplitDateTimePickerWidget(
|
||||||
|
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||||
|
min_date=q.valid_datetime_min,
|
||||||
|
max_date=q.valid_datetime_max
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
if q.valid_datetime_min:
|
||||||
|
field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
|
||||||
|
if q.valid_datetime_max:
|
||||||
|
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||||
elif q.type == Question.TYPE_PHONENUMBER:
|
elif q.type == Question.TYPE_PHONENUMBER:
|
||||||
babel_locale = 'en'
|
with language(get_babel_locale()):
|
||||||
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
|
|
||||||
if localedata.exists(get_language()):
|
|
||||||
babel_locale = get_language()
|
|
||||||
elif localedata.exists(get_language()[:2]):
|
|
||||||
babel_locale = get_language()[:2]
|
|
||||||
with language(babel_locale):
|
|
||||||
default_country = guess_country(event)
|
default_country = guess_country(event)
|
||||||
default_prefix = None
|
default_prefix = None
|
||||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||||
@@ -565,8 +690,9 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if not self.all_optional:
|
if not self.all_optional:
|
||||||
for q in question_cache.values():
|
for q in question_cache.values():
|
||||||
answer = d.get('question_%d' % q.pk)
|
answer = d.get('question_%d' % q.pk)
|
||||||
if question_is_required(q) and not answer and answer != 0:
|
field = self['question_%d' % q.pk]
|
||||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required')]})
|
if question_is_required(q) and not answer and answer != 0 and not field.errors:
|
||||||
|
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
|
||||||
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password
|
from django.contrib.auth.hashers import check_password
|
||||||
from django.contrib.auth.password_validation import (
|
from django.contrib.auth.password_validation import (
|
||||||
password_validators_help_texts, validate_password,
|
password_validators_help_texts, validate_password,
|
||||||
@@ -19,6 +20,7 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
"address or password."),
|
"address or password."),
|
||||||
'pw_current_wrong': _("The current password you entered was not correct."),
|
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||||
'pw_mismatch': _("Please enter the same password twice"),
|
'pw_mismatch': _("Please enter the same password twice"),
|
||||||
|
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||||
}
|
}
|
||||||
|
|
||||||
old_pw = forms.CharField(max_length=255,
|
old_pw = forms.CharField(max_length=255,
|
||||||
@@ -64,6 +66,18 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
|
|
||||||
def clean_old_pw(self):
|
def clean_old_pw(self):
|
||||||
old_pw = self.cleaned_data.get('old_pw')
|
old_pw = self.cleaned_data.get('old_pw')
|
||||||
|
|
||||||
|
if old_pw and settings.HAS_REDIS:
|
||||||
|
from django_redis import get_redis_connection
|
||||||
|
rc = get_redis_connection("redis")
|
||||||
|
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
|
||||||
|
rc.expire('pretix_pwchange_%s' % self.user.pk, 300)
|
||||||
|
if cnt > 10:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['rate_limit'],
|
||||||
|
code='rate_limit',
|
||||||
|
)
|
||||||
|
|
||||||
if old_pw and not check_password(old_pw, self.user.password):
|
if old_pw and not check_password(old_pw, self.user.password):
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['pw_current_wrong'],
|
self.error_messages['pw_current_wrong'],
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.formats import get_format
|
from django.utils.formats import get_format
|
||||||
from django.utils.functional import lazy
|
from django.utils.functional import lazy
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import get_current_timezone, now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
@@ -17,11 +18,14 @@ class DatePickerWidget(forms.DateInput):
|
|||||||
date_attrs['class'] += ' datepickerfield'
|
date_attrs['class'] += ' datepickerfield'
|
||||||
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
|
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
|
||||||
|
|
||||||
|
def placeholder():
|
||||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||||
date_attrs['placeholder'] = now().replace(
|
return now().replace(
|
||||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||||
).strftime(df)
|
).strftime(df)
|
||||||
|
|
||||||
|
date_attrs['placeholder'] = lazy(placeholder, str)
|
||||||
|
|
||||||
forms.DateInput.__init__(self, date_attrs, date_format)
|
forms.DateInput.__init__(self, date_attrs, date_format)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,11 +39,14 @@ class TimePickerWidget(forms.TimeInput):
|
|||||||
time_attrs['class'] += ' timepickerfield'
|
time_attrs['class'] += ' timepickerfield'
|
||||||
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
|
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
|
||||||
|
|
||||||
|
def placeholder():
|
||||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||||
time_attrs['placeholder'] = now().replace(
|
return now().replace(
|
||||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||||
).strftime(tf)
|
).strftime(tf)
|
||||||
|
|
||||||
|
time_attrs['placeholder'] = lazy(placeholder, str)
|
||||||
|
|
||||||
forms.TimeInput.__init__(self, time_attrs, time_format)
|
forms.TimeInput.__init__(self, time_attrs, time_format)
|
||||||
|
|
||||||
|
|
||||||
@@ -92,7 +99,7 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
|||||||
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||||
template_name = 'pretixbase/forms/widgets/splitdatetime.html'
|
template_name = 'pretixbase/forms/widgets/splitdatetime.html'
|
||||||
|
|
||||||
def __init__(self, attrs=None, date_format=None, time_format=None):
|
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None):
|
||||||
attrs = attrs or {}
|
attrs = attrs or {}
|
||||||
if 'placeholder' in attrs:
|
if 'placeholder' in attrs:
|
||||||
del attrs['placeholder']
|
del attrs['placeholder']
|
||||||
@@ -106,6 +113,14 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
|||||||
time_attrs['class'] += ' timepickerfield'
|
time_attrs['class'] += ' timepickerfield'
|
||||||
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
|
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
|
||||||
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
|
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
|
||||||
|
if min_date:
|
||||||
|
date_attrs['data-min'] = (
|
||||||
|
min_date if isinstance(min_date, date) else min_date.astimezone(get_current_timezone()).date()
|
||||||
|
).isoformat()
|
||||||
|
if max_date:
|
||||||
|
date_attrs['data-max'] = (
|
||||||
|
max_date if isinstance(max_date, date) else max_date.astimezone(get_current_timezone()).date()
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
def date_placeholder():
|
def date_placeholder():
|
||||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from babel import localedata
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.formats import date_format, number_format
|
from django.utils.formats import date_format, number_format
|
||||||
@@ -66,10 +67,52 @@ class LazyNumber:
|
|||||||
return number_format(self.value, decimal_pos=self.decimal_pos)
|
return number_format(self.value, decimal_pos=self.decimal_pos)
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
|
||||||
|
|
||||||
|
|
||||||
|
def get_babel_locale():
|
||||||
|
babel_locale = 'en'
|
||||||
|
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||||
|
if localedata.exists(translation.get_language()):
|
||||||
|
babel_locale = translation.get_language()
|
||||||
|
elif localedata.exists(translation.get_language()[:2]):
|
||||||
|
babel_locale = translation.get_language()[:2]
|
||||||
|
return babel_locale
|
||||||
|
|
||||||
|
|
||||||
|
def get_language_without_region(lng=None):
|
||||||
|
"""
|
||||||
|
Returns the currently active language, but strips what pretix calls a ``region``. For example,
|
||||||
|
if the currently active language is ``en-us``, you will be returned ``en`` since pretix does not
|
||||||
|
ship with separate language files for ``en-us``. If the currently active language is ``pt-br``,
|
||||||
|
you will be returned ``pt-br`` since there are separate language files for ``pt-br``.
|
||||||
|
|
||||||
|
tl;dr: You will be always passed a language that is defined in settings.LANGUAGES.
|
||||||
|
"""
|
||||||
|
lng = lng or translation.get_language() or settings.LANGUAGE_CODE
|
||||||
|
if lng not in ALLOWED_LANGUAGES:
|
||||||
|
lng = lng.split('-')[0]
|
||||||
|
if lng not in ALLOWED_LANGUAGES:
|
||||||
|
lng = settings.LANGUAGE_CODE
|
||||||
|
return lng
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def language(lng):
|
def language(lng, region=None):
|
||||||
|
"""
|
||||||
|
Temporarily change the active language to ``lng``. Will automatically be rolled back when the
|
||||||
|
context manager returns.
|
||||||
|
|
||||||
|
You can optionally pass a "region". For example, if you pass ``en`` as ``lng`` and ``US`` as
|
||||||
|
``region``, the active language will be ``en-us``, which will mostly affect date/time
|
||||||
|
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
|
||||||
|
attribute will be ignored.
|
||||||
|
"""
|
||||||
_lng = translation.get_language()
|
_lng = translation.get_language()
|
||||||
translation.activate(lng or settings.LANGUAGE_CODE)
|
lng = lng or settings.LANGUAGE_CODE
|
||||||
|
if '-' not in lng and region:
|
||||||
|
lng += '-' + region.lower()
|
||||||
|
translation.activate(lng)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
|||||||
|
|
||||||
def _upper(self, val):
|
def _upper(self, val):
|
||||||
# We uppercase labels, but not in every language
|
# We uppercase labels, but not in every language
|
||||||
if get_language() == 'el':
|
if get_language().startswith('el'):
|
||||||
return val
|
return val
|
||||||
return val.upper()
|
return val.upper()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import json
|
||||||
import math
|
import math
|
||||||
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
@@ -6,6 +8,7 @@ from django.conf import settings
|
|||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|
||||||
from pretix.base.models import Event, Invoice, Order, OrderPosition, Organizer
|
from pretix.base.models import Event, Invoice, Order, OrderPosition, Organizer
|
||||||
|
from pretix.celery_app import app
|
||||||
|
|
||||||
if settings.HAS_REDIS:
|
if settings.HAS_REDIS:
|
||||||
import django_redis
|
import django_redis
|
||||||
@@ -248,6 +251,19 @@ def metric_values():
|
|||||||
else:
|
else:
|
||||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
|
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
|
||||||
|
|
||||||
|
if settings.HAS_CELERY:
|
||||||
|
client = app.broker_connection().channel().client
|
||||||
|
for q in settings.CELERY_TASK_QUEUES:
|
||||||
|
llen = client.llen(q.name)
|
||||||
|
lfirst = client.lindex(q.name, -1)
|
||||||
|
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen
|
||||||
|
if lfirst:
|
||||||
|
ldata = json.loads(lfirst)
|
||||||
|
dt = time.time() - ldata.get('created', 0)
|
||||||
|
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt
|
||||||
|
else:
|
||||||
|
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
|
||||||
|
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ from django.utils.translation.trans_real import (
|
|||||||
parse_accept_lang_header,
|
parse_accept_lang_header,
|
||||||
)
|
)
|
||||||
|
|
||||||
from pretix.base.settings import GlobalSettingsObject
|
from pretix.base.i18n import get_language_without_region
|
||||||
|
from pretix.base.settings import global_settings_object
|
||||||
from pretix.multidomain.urlreverse import (
|
from pretix.multidomain.urlreverse import (
|
||||||
get_event_domain, get_organizer_domain,
|
get_event_domain, get_organizer_domain,
|
||||||
)
|
)
|
||||||
@@ -35,7 +36,8 @@ class LocaleMiddleware(MiddlewareMixin):
|
|||||||
# Normally, this middleware runs *before* the event is set. However, on event frontend pages it
|
# Normally, this middleware runs *before* the event is set. However, on event frontend pages it
|
||||||
# might be run a second time by pretix.presale.EventMiddleware and in this case the event is already
|
# might be run a second time by pretix.presale.EventMiddleware and in this case the event is already
|
||||||
# set and can be taken into account for the decision.
|
# set and can be taken into account for the decision.
|
||||||
if hasattr(request, 'event') and not request.path.startswith(get_script_prefix() + 'control'):
|
if not request.path.startswith(get_script_prefix() + 'control'):
|
||||||
|
if hasattr(request, 'event'):
|
||||||
if language not in request.event.settings.locales:
|
if language not in request.event.settings.locales:
|
||||||
firstpart = language.split('-')[0]
|
firstpart = language.split('-')[0]
|
||||||
if firstpart in request.event.settings.locales:
|
if firstpart in request.event.settings.locales:
|
||||||
@@ -46,8 +48,18 @@ class LocaleMiddleware(MiddlewareMixin):
|
|||||||
if lang.startswith(firstpart + '-'):
|
if lang.startswith(firstpart + '-'):
|
||||||
language = lang
|
language = lang
|
||||||
break
|
break
|
||||||
|
if '-' not in language and request.event.settings.region:
|
||||||
|
language += '-' + request.event.settings.region
|
||||||
|
elif hasattr(request, 'organizer'):
|
||||||
|
if '-' not in language and request.organizer.settings.region:
|
||||||
|
language += '-' + request.organizer.settings.region
|
||||||
|
else:
|
||||||
|
gs = global_settings_object(request)
|
||||||
|
if '-' not in language and gs.settings.region:
|
||||||
|
language += '-' + gs.settings.region
|
||||||
|
|
||||||
translation.activate(language)
|
translation.activate(language)
|
||||||
request.LANGUAGE_CODE = translation.get_language()
|
request.LANGUAGE_CODE = get_language_without_region()
|
||||||
|
|
||||||
tzname = None
|
tzname = None
|
||||||
if hasattr(request, 'event'):
|
if hasattr(request, 'event'):
|
||||||
@@ -192,7 +204,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
|
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
|
||||||
|
|
||||||
img_src = []
|
img_src = []
|
||||||
gs = GlobalSettingsObject()
|
gs = global_settings_object(request)
|
||||||
if gs.settings.leaflet_tiles:
|
if gs.settings.leaflet_tiles:
|
||||||
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
||||||
|
|
||||||
@@ -216,6 +228,8 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
h['report-uri'] = ["/csp_report/"]
|
h['report-uri'] = ["/csp_report/"]
|
||||||
if 'Content-Security-Policy' in resp:
|
if 'Content-Security-Policy' in resp:
|
||||||
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
||||||
|
if settings.CSP_ADDITIONAL_HEADER:
|
||||||
|
_merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER))
|
||||||
|
|
||||||
staticdomain = "'self'"
|
staticdomain = "'self'"
|
||||||
dynamicdomain = "'self'"
|
dynamicdomain = "'self'"
|
||||||
|
|||||||
23
src/pretix/base/migrations/0162b_auto_20201218_1810.py
Normal file
23
src/pretix/base/migrations/0162b_auto_20201218_1810.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.0.11 on 2020-12-18 18:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0162_remove_seat_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cachedfile',
|
||||||
|
name='session_key',
|
||||||
|
field=models.TextField(null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cachedfile',
|
||||||
|
name='web_download',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
49
src/pretix/base/migrations/0171_auto_20201126_1635.py
Normal file
49
src/pretix/base/migrations/0171_auto_20201126_1635.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Generated by Django 3.0.11 on 2020-11-26 16:35
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0170_remove_hidden_urls'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='question',
|
||||||
|
name='valid_date_max',
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='question',
|
||||||
|
name='valid_date_min',
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='question',
|
||||||
|
name='valid_datetime_max',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='question',
|
||||||
|
name='valid_datetime_min',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='question',
|
||||||
|
name='valid_number_max',
|
||||||
|
field=models.DecimalField(decimal_places=6, max_digits=16, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='question',
|
||||||
|
name='valid_number_min',
|
||||||
|
field=models.DecimalField(decimal_places=6, max_digits=16, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='seat',
|
||||||
|
name='product',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='seats', to='pretixbase.Item'),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
src/pretix/base/migrations/0172_event_sales_channels.py
Normal file
20
src/pretix/base/migrations/0172_event_sales_channels.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 3.0.9 on 2020-12-02 12:37
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
import pretix.base.models.fields
|
||||||
|
from pretix.base.channels import get_all_sales_channels
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0171_auto_20201126_1635'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='sales_channels',
|
||||||
|
field=pretix.base.models.fields.MultiStringField(default=list(get_all_sales_channels().keys())),
|
||||||
|
),
|
||||||
|
]
|
||||||
51
src/pretix/base/migrations/0173_auto_20201211_1648.py
Normal file
51
src/pretix/base/migrations/0173_auto_20201211_1648.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 3.0.11 on 2020-12-11 16:48
|
||||||
|
import json
|
||||||
|
|
||||||
|
import phonenumber_field.modelfields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
import pretix.base.models.fields
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_settings(apps, schema_editor):
|
||||||
|
Order = apps.get_model('pretixbase', 'Order')
|
||||||
|
Event = apps.get_model('pretixbase', 'Event')
|
||||||
|
Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
|
||||||
|
Event_SettingsStore.objects.filter(key='telephone_field_required').update(key='order_phone_required')
|
||||||
|
Event_SettingsStore.objects.filter(key='telephone_field_help_text').update(key='checkout_phone_helptext')
|
||||||
|
for e in Event.objects.filter(plugins__icontains="pretix_telephone"):
|
||||||
|
plugins = e.plugins.split(",")
|
||||||
|
plugins.remove("pretix_telephone")
|
||||||
|
e.plugins = ",".join(plugins)
|
||||||
|
e.save()
|
||||||
|
Event_SettingsStore.objects.create(object=e, key='order_phone_asked', value='True')
|
||||||
|
for o in Order.objects.filter(meta_info__icontains='"telephone"'):
|
||||||
|
mi = json.loads(o.meta_info)
|
||||||
|
if 'telephone' in mi.get('contact_form_data', {}):
|
||||||
|
mi['phone'] = mi['contact_form_data'].pop('telephone')
|
||||||
|
o.phone = mi['phone']
|
||||||
|
o.meta_info = json.dumps(mi)
|
||||||
|
o.save(update_fields=['meta_info', 'phone'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0172_event_sales_channels'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='phone',
|
||||||
|
field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='event',
|
||||||
|
name='sales_channels',
|
||||||
|
field=pretix.base.models.fields.MultiStringField(default=['web']),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_settings, migrations.RunPython.noop,
|
||||||
|
)
|
||||||
|
]
|
||||||
14
src/pretix/base/migrations/0174_merge_20201222_1031.py
Normal file
14
src/pretix/base/migrations/0174_merge_20201222_1031.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Generated by Django 3.0.11 on 2020-12-22 10:31
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0173_auto_20201211_1648'),
|
||||||
|
('pretixbase', '0162b_auto_20201218_1810'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
]
|
||||||
18
src/pretix/base/migrations/0175_orderrefund_comment.py
Normal file
18
src/pretix/base/migrations/0175_orderrefund_comment.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.11 on 2021-01-15 09:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0174_merge_20201222_1031'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderrefund',
|
||||||
|
name='comment',
|
||||||
|
field=models.TextField(null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -28,6 +28,8 @@ class CachedFile(models.Model):
|
|||||||
filename = models.CharField(max_length=255)
|
filename = models.CharField(max_length=255)
|
||||||
type = models.CharField(max_length=255)
|
type = models.CharField(max_length=255)
|
||||||
file = models.FileField(null=True, blank=True, upload_to=cachedfile_name, max_length=255)
|
file = models.FileField(null=True, blank=True, upload_to=cachedfile_name, max_length=255)
|
||||||
|
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
|
||||||
|
session_key = models.TextField(null=True, blank=True) # only allow download in this session
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=CachedFile)
|
@receiver(post_delete, sender=CachedFile)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class CheckinList(LoggedModel):
|
|||||||
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
|
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_('With this option, people will be able to check in even if the '
|
help_text=_('With this option, people will be able to check in even if the '
|
||||||
'order have not been paid.'))
|
'order has not been paid.'))
|
||||||
gates = models.ManyToManyField(
|
gates = models.ManyToManyField(
|
||||||
'Gate', verbose_name=_("Gates"), blank=True,
|
'Gate', verbose_name=_("Gates"), blank=True,
|
||||||
help_text=_("Does not have any effect for the validation of tickets, only for the automatic configuration of "
|
help_text=_("Does not have any effect for the validation of tickets, only for the automatic configuration of "
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ from django.conf import settings
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.core.mail import get_connection
|
from django.core.mail import get_connection
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import (
|
||||||
|
MaxValueValidator, MinLengthValidator, MinValueValidator, RegexValidator,
|
||||||
|
)
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery, Value
|
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery, Value
|
||||||
from django.template.defaultfilters import date as _date
|
from django.template.defaultfilters import date as _date
|
||||||
@@ -23,6 +25,7 @@ from django_scopes import ScopedManager, scopes_disabled
|
|||||||
from i18nfield.fields import I18nCharField, I18nTextField
|
from i18nfield.fields import I18nCharField, I18nTextField
|
||||||
|
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
|
from pretix.base.models.fields import MultiStringField
|
||||||
from pretix.base.reldate import RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateWrapper
|
||||||
from pretix.base.validators import EventSlugBanlistValidator
|
from pretix.base.validators import EventSlugBanlistValidator
|
||||||
from pretix.helpers.database import GroupConcat
|
from pretix.helpers.database import GroupConcat
|
||||||
@@ -331,6 +334,8 @@ class Event(EventMixin, LoggedModel):
|
|||||||
:type plugins: str
|
:type plugins: str
|
||||||
:param has_subevents: Enable event series functionality
|
:param has_subevents: Enable event series functionality
|
||||||
:type has_subevents: bool
|
:type has_subevents: bool
|
||||||
|
:param sales_channels: A list of sales channel identifiers, that this event is available for sale on
|
||||||
|
:type sales_channels: list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
settings_namespace = 'event'
|
settings_namespace = 'event'
|
||||||
@@ -350,8 +355,11 @@ class Event(EventMixin, LoggedModel):
|
|||||||
"remembered, but you can also choose to use a random value. "
|
"remembered, but you can also choose to use a random value. "
|
||||||
"This will be used in URLs, order codes, invoice numbers, and bank transfer references."),
|
"This will be used in URLs, order codes, invoice numbers, and bank transfer references."),
|
||||||
validators=[
|
validators=[
|
||||||
|
MinLengthValidator(
|
||||||
|
limit_value=2,
|
||||||
|
),
|
||||||
RegexValidator(
|
RegexValidator(
|
||||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*$",
|
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$",
|
||||||
message=_("The slug may only contain letters, numbers, dots and dashes."),
|
message=_("The slug may only contain letters, numbers, dots and dashes."),
|
||||||
),
|
),
|
||||||
EventSlugBanlistValidator()
|
EventSlugBanlistValidator()
|
||||||
@@ -390,10 +398,18 @@ class Event(EventMixin, LoggedModel):
|
|||||||
geo_lat = models.FloatField(
|
geo_lat = models.FloatField(
|
||||||
verbose_name=_("Latitude"),
|
verbose_name=_("Latitude"),
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
|
validators=[
|
||||||
|
MinValueValidator(-90),
|
||||||
|
MaxValueValidator(90),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
geo_lon = models.FloatField(
|
geo_lon = models.FloatField(
|
||||||
verbose_name=_("Longitude"),
|
verbose_name=_("Longitude"),
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
|
validators=[
|
||||||
|
MinValueValidator(-180),
|
||||||
|
MaxValueValidator(180),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
plugins = models.TextField(
|
plugins = models.TextField(
|
||||||
null=False, blank=True,
|
null=False, blank=True,
|
||||||
@@ -409,7 +425,11 @@ class Event(EventMixin, LoggedModel):
|
|||||||
)
|
)
|
||||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||||
related_name='events')
|
related_name='events')
|
||||||
|
sales_channels = MultiStringField(
|
||||||
|
verbose_name=_('Restrict to specific sales channels'),
|
||||||
|
help_text=_('Only sell tickets for this event on the following sales channels.'),
|
||||||
|
default=['web'],
|
||||||
|
)
|
||||||
objects = ScopedManager(organizer='organizer')
|
objects = ScopedManager(organizer='organizer')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -521,7 +541,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
|
|
||||||
return locking.LockManager(self)
|
return locking.LockManager(self)
|
||||||
|
|
||||||
def get_mail_backend(self, force_custom=False):
|
def get_mail_backend(self, timeout=None, force_custom=False):
|
||||||
"""
|
"""
|
||||||
Returns an email server connection, either by using the system-wide connection
|
Returns an email server connection, either by using the system-wide connection
|
||||||
or by returning a custom one based on the event's settings.
|
or by returning a custom one based on the event's settings.
|
||||||
@@ -535,7 +555,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
password=self.settings.smtp_password,
|
password=self.settings.smtp_password,
|
||||||
use_tls=self.settings.smtp_use_tls,
|
use_tls=self.settings.smtp_use_tls,
|
||||||
use_ssl=self.settings.smtp_use_ssl,
|
use_ssl=self.settings.smtp_use_ssl,
|
||||||
fail_silently=False)
|
fail_silently=False, timeout=timeout)
|
||||||
else:
|
else:
|
||||||
return get_connection(fail_silently=False)
|
return get_connection(fail_silently=False)
|
||||||
|
|
||||||
@@ -1114,10 +1134,18 @@ class SubEvent(EventMixin, LoggedModel):
|
|||||||
geo_lat = models.FloatField(
|
geo_lat = models.FloatField(
|
||||||
verbose_name=_("Latitude"),
|
verbose_name=_("Latitude"),
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
|
validators=[
|
||||||
|
MinValueValidator(-90),
|
||||||
|
MaxValueValidator(90),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
geo_lon = models.FloatField(
|
geo_lon = models.FloatField(
|
||||||
verbose_name=_("Longitude"),
|
verbose_name=_("Longitude"),
|
||||||
null=True, blank=True
|
null=True, blank=True,
|
||||||
|
validators=[
|
||||||
|
MinValueValidator(-180),
|
||||||
|
MaxValueValidator(180),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
frontpage_text = I18nTextField(
|
frontpage_text = I18nTextField(
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
|
|||||||
@@ -1026,7 +1026,7 @@ class Question(LoggedModel):
|
|||||||
(TYPE_PHONENUMBER, _("Phone number")),
|
(TYPE_PHONENUMBER, _("Phone number")),
|
||||||
)
|
)
|
||||||
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
|
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
|
||||||
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE, TYPE_PHONENUMBER]
|
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_PHONENUMBER]
|
||||||
|
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
Event,
|
Event,
|
||||||
@@ -1069,6 +1069,7 @@ class Question(LoggedModel):
|
|||||||
)
|
)
|
||||||
ask_during_checkin = models.BooleanField(
|
ask_during_checkin = models.BooleanField(
|
||||||
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
||||||
|
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||||
default=False
|
default=False
|
||||||
)
|
)
|
||||||
hidden = models.BooleanField(
|
hidden = models.BooleanField(
|
||||||
@@ -1084,6 +1085,18 @@ class Question(LoggedModel):
|
|||||||
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||||
)
|
)
|
||||||
dependency_values = MultiStringField(default=[])
|
dependency_values = MultiStringField(default=[])
|
||||||
|
valid_number_min = models.DecimalField(decimal_places=6, max_digits=16, null=True, blank=True,
|
||||||
|
verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps'))
|
||||||
|
valid_number_max = models.DecimalField(decimal_places=6, max_digits=16, null=True, blank=True,
|
||||||
|
verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps'))
|
||||||
|
valid_date_min = models.DateField(null=True, blank=True,
|
||||||
|
verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps'))
|
||||||
|
valid_date_max = models.DateField(null=True, blank=True,
|
||||||
|
verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps'))
|
||||||
|
valid_datetime_min = models.DateTimeField(null=True, blank=True,
|
||||||
|
verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps'))
|
||||||
|
valid_datetime_max = models.DateTimeField(null=True, blank=True,
|
||||||
|
verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps'))
|
||||||
|
|
||||||
objects = ScopedManager(organizer='event__organizer')
|
objects = ScopedManager(organizer='event__organizer')
|
||||||
|
|
||||||
@@ -1173,14 +1186,24 @@ class Question(LoggedModel):
|
|||||||
answer = formats.sanitize_separators(answer)
|
answer = formats.sanitize_separators(answer)
|
||||||
answer = str(answer).strip()
|
answer = str(answer).strip()
|
||||||
try:
|
try:
|
||||||
return Decimal(answer)
|
v = Decimal(answer)
|
||||||
|
if self.valid_number_min is not None and v < self.valid_number_min:
|
||||||
|
raise ValidationError(_('The number is to low.'))
|
||||||
|
if self.valid_number_max is not None and v > self.valid_number_max:
|
||||||
|
raise ValidationError(_('The number is to high.'))
|
||||||
|
return v
|
||||||
except DecimalException:
|
except DecimalException:
|
||||||
raise ValidationError(_('Invalid number input.'))
|
raise ValidationError(_('Invalid number input.'))
|
||||||
elif self.type == Question.TYPE_DATE:
|
elif self.type == Question.TYPE_DATE:
|
||||||
if isinstance(answer, date):
|
if isinstance(answer, date):
|
||||||
return answer
|
return answer
|
||||||
try:
|
try:
|
||||||
return dateutil.parser.parse(answer).date()
|
dt = dateutil.parser.parse(answer).date()
|
||||||
|
if self.valid_date_min is not None and dt < self.valid_date_min:
|
||||||
|
raise ValidationError(_('Please choose a later date.'))
|
||||||
|
if self.valid_date_max is not None and dt > self.valid_date_max:
|
||||||
|
raise ValidationError(_('Please choose an earlier date.'))
|
||||||
|
return dt
|
||||||
except:
|
except:
|
||||||
raise ValidationError(_('Invalid date input.'))
|
raise ValidationError(_('Invalid date input.'))
|
||||||
elif self.type == Question.TYPE_TIME:
|
elif self.type == Question.TYPE_TIME:
|
||||||
@@ -1197,9 +1220,14 @@ class Question(LoggedModel):
|
|||||||
dt = dateutil.parser.parse(answer)
|
dt = dateutil.parser.parse(answer)
|
||||||
if is_naive(dt):
|
if is_naive(dt):
|
||||||
dt = make_aware(dt, pytz.timezone(self.event.settings.timezone))
|
dt = make_aware(dt, pytz.timezone(self.event.settings.timezone))
|
||||||
return dt
|
|
||||||
except:
|
except:
|
||||||
raise ValidationError(_('Invalid datetime input.'))
|
raise ValidationError(_('Invalid datetime input.'))
|
||||||
|
else:
|
||||||
|
if self.valid_datetime_min is not None and dt < self.valid_datetime_min:
|
||||||
|
raise ValidationError(_('Please choose a later date.'))
|
||||||
|
if self.valid_datetime_max is not None and dt > self.valid_datetime_max:
|
||||||
|
raise ValidationError(_('Please choose an earlier date.'))
|
||||||
|
return dt
|
||||||
elif self.type == Question.TYPE_COUNTRYCODE and answer:
|
elif self.type == Question.TYPE_COUNTRYCODE and answer:
|
||||||
c = Country(answer.upper())
|
c = Country(answer.upper())
|
||||||
if c.name:
|
if c.name:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from django_countries.fields import Country
|
|||||||
from django_scopes import ScopedManager, scopes_disabled
|
from django_scopes import ScopedManager, scopes_disabled
|
||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
from jsonfallback.fields import FallbackJSONField
|
from jsonfallback.fields import FallbackJSONField
|
||||||
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
from phonenumber_field.phonenumber import PhoneNumber
|
from phonenumber_field.phonenumber import PhoneNumber
|
||||||
from phonenumbers import NumberParseException
|
from phonenumbers import NumberParseException
|
||||||
|
|
||||||
@@ -86,6 +87,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
:type event: Event
|
:type event: Event
|
||||||
:param email: The email of the person who ordered this
|
:param email: The email of the person who ordered this
|
||||||
:type email: str
|
:type email: str
|
||||||
|
:param phone: The phone number of the person who ordered this
|
||||||
|
:type phone: str
|
||||||
:param testmode: Whether this is a test mode order
|
:param testmode: Whether this is a test mode order
|
||||||
:type testmode: bool
|
:type testmode: bool
|
||||||
:param locale: The locale of this order
|
:param locale: The locale of this order
|
||||||
@@ -144,6 +147,10 @@ class Order(LockModel, LoggedModel):
|
|||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
verbose_name=_('E-mail')
|
verbose_name=_('E-mail')
|
||||||
)
|
)
|
||||||
|
phone = PhoneNumberField(
|
||||||
|
null=True, blank=True,
|
||||||
|
verbose_name=_('Phone number'),
|
||||||
|
)
|
||||||
locale = models.CharField(
|
locale = models.CharField(
|
||||||
null=True, blank=True, max_length=32,
|
null=True, blank=True, max_length=32,
|
||||||
verbose_name=_('Locale')
|
verbose_name=_('Locale')
|
||||||
@@ -326,6 +333,9 @@ class Order(LockModel, LoggedModel):
|
|||||||
payment_sum=payment_sum_sq,
|
payment_sum=payment_sum_sq,
|
||||||
refund_sum=refund_sum_sq,
|
refund_sum=refund_sum_sq,
|
||||||
)
|
)
|
||||||
|
qs = qs.annotate(
|
||||||
|
computed_payment_refund_sum=Coalesce(payment_sum_sq, 0) - Coalesce(refund_sum_sq, 0),
|
||||||
|
)
|
||||||
|
|
||||||
qs = qs.annotate(
|
qs = qs.annotate(
|
||||||
pending_sum_t=F('total') - Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
pending_sum_t=F('total') - Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
||||||
@@ -605,21 +615,26 @@ class Order(LockModel, LoggedModel):
|
|||||||
return proposals
|
return proposals
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_code(code):
|
def normalize_code(code, is_fallback=False):
|
||||||
tr = str.maketrans({
|
d = {
|
||||||
'2': 'Z',
|
'2': 'Z',
|
||||||
'4': 'A',
|
'4': 'A',
|
||||||
'5': 'S',
|
'5': 'S',
|
||||||
'6': 'G',
|
'6': 'G',
|
||||||
})
|
}
|
||||||
|
if is_fallback:
|
||||||
|
d['8'] = 'B'
|
||||||
|
# 8 has been removed from the character set only in 2021, which means there are a lot of order codes
|
||||||
|
# with an 8 in it around. We only want to replace this when this is used in a fallback.
|
||||||
|
tr = str.maketrans(d)
|
||||||
return code.upper().translate(tr)
|
return code.upper().translate(tr)
|
||||||
|
|
||||||
def assign_code(self):
|
def assign_code(self):
|
||||||
# This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
|
# This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
|
||||||
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
|
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
|
||||||
# handwriting (2/Z, 4/A, 5/S, 6/G). This allows for better detection e.g. in incoming wire transfers that
|
# handwriting (2/Z, 4/A, 5/S, 6/G, 8/B). This allows for better detection e.g. in incoming wire transfers that
|
||||||
# might include OCR'd handwritten text
|
# might include OCR'd handwritten text
|
||||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ379')
|
||||||
iteration = 0
|
iteration = 0
|
||||||
length = settings.ENTROPY['order_code']
|
length = settings.ENTROPY['order_code']
|
||||||
while True:
|
while True:
|
||||||
@@ -639,7 +654,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if iteration > 20:
|
if iteration > 20:
|
||||||
# Safeguard: If we don't find an unused and non-blacklisted code within 20 iterations, we increase
|
# Safeguard: If we don't find an unused and non-banlisted code within 20 iterations, we increase
|
||||||
# the length.
|
# the length.
|
||||||
length += 1
|
length += 1
|
||||||
iteration = 0
|
iteration = 0
|
||||||
@@ -857,7 +872,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
for k, v in self.event.meta_data.items():
|
for k, v in self.event.meta_data.items():
|
||||||
context['meta_' + k] = v
|
context['meta_' + k] = v
|
||||||
|
|
||||||
with language(self.locale):
|
with language(self.locale, self.event.settings.region):
|
||||||
recipient = self.email
|
recipient = self.email
|
||||||
if position and position.attendee_email:
|
if position and position.attendee_email:
|
||||||
recipient = position.attendee_email
|
recipient = position.attendee_email
|
||||||
@@ -890,7 +905,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def resend_link(self, user=None, auth=None):
|
def resend_link(self, user=None, auth=None):
|
||||||
with language(self.locale):
|
with language(self.locale, self.event.settings.region):
|
||||||
email_template = self.event.settings.mail_text_resend_link
|
email_template = self.event.settings.mail_text_resend_link
|
||||||
email_context = get_email_context(event=self.event, order=self)
|
email_context = get_email_context(event=self.event, order=self)
|
||||||
email_subject = _('Your order: %(code)s') % {'code': self.code}
|
email_subject = _('Your order: %(code)s') % {'code': self.code}
|
||||||
@@ -902,7 +917,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def positions_with_tickets(self):
|
def positions_with_tickets(self):
|
||||||
for op in self.positions.all():
|
for op in self.positions.select_related('item'):
|
||||||
if not op.generate_ticket:
|
if not op.generate_ticket:
|
||||||
continue
|
continue
|
||||||
yield op
|
yield op
|
||||||
@@ -1155,7 +1170,7 @@ class AbstractPosition(models.Model):
|
|||||||
(2) questions: a list of Question objects, extended by an 'answer' property
|
(2) questions: a list of Question objects, extended by an 'answer' property
|
||||||
"""
|
"""
|
||||||
self.answ = {}
|
self.answ = {}
|
||||||
for a in self.answers.all():
|
for a in getattr(self, 'answerlist', self.answers.all()): # use prefetch_related cache from get_cart
|
||||||
self.answ[a.question_id] = a
|
self.answ[a.question_id] = a
|
||||||
|
|
||||||
# We need to clone our question objects, otherwise we will override the cached
|
# We need to clone our question objects, otherwise we will override the cached
|
||||||
@@ -1209,6 +1224,9 @@ class AbstractPosition(models.Model):
|
|||||||
else self.variation.quotas.filter(subevent=self.subevent))
|
else self.variation.quotas.filter(subevent=self.subevent))
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
update_fields = kwargs.get('update_fields', [])
|
||||||
|
if 'attendee_name_parts' in update_fields:
|
||||||
|
update_fields.append('attendee_name_cached')
|
||||||
self.attendee_name_cached = self.attendee_name
|
self.attendee_name_cached = self.attendee_name
|
||||||
if self.attendee_name_parts is None:
|
if self.attendee_name_parts is None:
|
||||||
self.attendee_name_parts = {}
|
self.attendee_name_parts = {}
|
||||||
@@ -1514,7 +1532,7 @@ class OrderPayment(models.Model):
|
|||||||
def _send_paid_mail_attendee(self, position, user):
|
def _send_paid_mail_attendee(self, position, user):
|
||||||
from pretix.base.services.mail import SendMailException
|
from pretix.base.services.mail import SendMailException
|
||||||
|
|
||||||
with language(self.order.locale):
|
with language(self.order.locale, self.order.event.settings.region):
|
||||||
email_template = self.order.event.settings.mail_text_order_paid_attendee
|
email_template = self.order.event.settings.mail_text_order_paid_attendee
|
||||||
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
||||||
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
|
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
|
||||||
@@ -1532,7 +1550,7 @@ class OrderPayment(models.Model):
|
|||||||
def _send_paid_mail(self, invoice, user, mail_text):
|
def _send_paid_mail(self, invoice, user, mail_text):
|
||||||
from pretix.base.services.mail import SendMailException
|
from pretix.base.services.mail import SendMailException
|
||||||
|
|
||||||
with language(self.order.locale):
|
with language(self.order.locale, self.order.event.settings.region):
|
||||||
email_template = self.order.event.settings.mail_text_order_paid
|
email_template = self.order.event.settings.mail_text_order_paid
|
||||||
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
|
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
|
||||||
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
|
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
|
||||||
@@ -1698,6 +1716,11 @@ class OrderRefund(models.Model):
|
|||||||
max_length=255,
|
max_length=255,
|
||||||
verbose_name=_("Payment provider")
|
verbose_name=_("Payment provider")
|
||||||
)
|
)
|
||||||
|
comment = models.TextField(
|
||||||
|
verbose_name=_("Refund reason"),
|
||||||
|
help_text=_('May be shown to the end user or used e.g. as part of a payment reference.'),
|
||||||
|
null=True, blank=True
|
||||||
|
)
|
||||||
info = models.TextField(
|
info = models.TextField(
|
||||||
verbose_name=_("Payment information"),
|
verbose_name=_("Payment information"),
|
||||||
null=True, blank=True
|
null=True, blank=True
|
||||||
@@ -1736,7 +1759,7 @@ class OrderRefund(models.Model):
|
|||||||
Marks the refund as complete. This does not modify the state of the order.
|
Marks the refund as complete. This does not modify the state of the order.
|
||||||
|
|
||||||
:param user: The user who performed the change
|
:param user: The user who performed the change
|
||||||
:param user: The API auth token that performed the change
|
:param auth: The API auth token that performed the change
|
||||||
"""
|
"""
|
||||||
self.state = self.REFUND_STATE_DONE
|
self.state = self.REFUND_STATE_DONE
|
||||||
self.execution_date = self.execution_date or now()
|
self.execution_date = self.execution_date or now()
|
||||||
@@ -1874,7 +1897,7 @@ class OrderFee(models.Model):
|
|||||||
self.tax_rule = self.order.event.settings.tax_rate_default
|
self.tax_rule = self.order.event.settings.tax_rate_default
|
||||||
|
|
||||||
if self.tax_rule:
|
if self.tax_rule:
|
||||||
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia)
|
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
|
||||||
self.tax_rate = tax.rate
|
self.tax_rate = tax.rate
|
||||||
self.tax_value = tax.tax
|
self.tax_value = tax.tax
|
||||||
else:
|
else:
|
||||||
@@ -2026,9 +2049,11 @@ class OrderPosition(AbstractPosition):
|
|||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
ia = None
|
ia = None
|
||||||
if self.tax_rule:
|
if self.tax_rule:
|
||||||
tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross')
|
tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross', force_fixed_gross_price=True)
|
||||||
self.tax_rate = tax.rate
|
self.tax_rate = tax.rate
|
||||||
self.tax_value = tax.tax
|
self.tax_value = tax.tax
|
||||||
|
if tax.gross != self.price:
|
||||||
|
raise ValueError('Invalid tax calculation')
|
||||||
else:
|
else:
|
||||||
self.tax_value = Decimal('0.00')
|
self.tax_value = Decimal('0.00')
|
||||||
self.tax_rate = Decimal('0.00')
|
self.tax_rate = Decimal('0.00')
|
||||||
@@ -2038,6 +2063,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
|
|
||||||
if self.tax_rate is None:
|
if self.tax_rate is None:
|
||||||
self._calculate_tax()
|
self._calculate_tax()
|
||||||
|
|
||||||
self.order.touch()
|
self.order.touch()
|
||||||
if not self.pk:
|
if not self.pk:
|
||||||
while not self.secret or OrderPosition.all.filter(
|
while not self.secret or OrderPosition.all.filter(
|
||||||
@@ -2101,7 +2127,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
for k, v in self.event.meta_data.items():
|
for k, v in self.event.meta_data.items():
|
||||||
context['meta_' + k] = v
|
context['meta_' + k] = v
|
||||||
|
|
||||||
with language(self.order.locale):
|
with language(self.order.locale, self.order.event.settings.region):
|
||||||
recipient = self.attendee_email
|
recipient = self.attendee_email
|
||||||
try:
|
try:
|
||||||
email_content = render_mail(template, context)
|
email_content = render_mail(template, context)
|
||||||
@@ -2129,7 +2155,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
|
|
||||||
def resend_link(self, user=None, auth=None):
|
def resend_link(self, user=None, auth=None):
|
||||||
|
|
||||||
with language(self.order.locale):
|
with language(self.order.locale, self.order.event.settings.region):
|
||||||
email_template = self.event.settings.mail_text_resend_link
|
email_template = self.event.settings.mail_text_resend_link
|
||||||
email_context = get_email_context(event=self.order.event, order=self.order, position=self)
|
email_context = get_email_context(event=self.order.event, order=self.order, position=self)
|
||||||
email_subject = _('Your event registration: %(code)s') % {'code': self.order.code}
|
email_subject = _('Your event registration: %(code)s') % {'code': self.order.code}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import string
|
import string
|
||||||
from datetime import date, datetime, time
|
from datetime import date, datetime, time
|
||||||
|
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import MinLengthValidator, RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
@@ -38,8 +38,11 @@ class Organizer(LoggedModel):
|
|||||||
"Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used "
|
"Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used "
|
||||||
"once. This is being used in URLs to refer to your organizer accounts and your events."),
|
"once. This is being used in URLs to refer to your organizer accounts and your events."),
|
||||||
validators=[
|
validators=[
|
||||||
|
MinLengthValidator(
|
||||||
|
limit_value=2,
|
||||||
|
),
|
||||||
RegexValidator(
|
RegexValidator(
|
||||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]+$",
|
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$",
|
||||||
message=_("The slug may only contain letters, numbers, dots and dashes.")
|
message=_("The slug may only contain letters, numbers, dots and dashes.")
|
||||||
),
|
),
|
||||||
OrganizerSlugBanlistValidator()
|
OrganizerSlugBanlistValidator()
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.formats import localize
|
from django.utils.formats import localize
|
||||||
from django.utils.timezone import get_current_timezone, now
|
from django.utils.timezone import get_current_timezone, now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _, pgettext
|
||||||
from i18nfield.fields import I18nCharField
|
from i18nfield.fields import I18nCharField
|
||||||
|
from i18nfield.strings import LazyI18nString
|
||||||
|
|
||||||
from pretix.base.decimal import round_decimal
|
from pretix.base.decimal import round_decimal
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
@@ -188,7 +189,7 @@ class TaxRule(LoggedModel):
|
|||||||
return Decimal(self.rate)
|
return Decimal(self.rate)
|
||||||
|
|
||||||
def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, invoice_address=None,
|
def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, invoice_address=None,
|
||||||
subtract_from_gross=Decimal('0.00'), gross_price_is_tax_rate: Decimal = None):
|
subtract_from_gross=Decimal('0.00'), gross_price_is_tax_rate: Decimal = None, force_fixed_gross_price=False):
|
||||||
from .event import Event
|
from .event import Event
|
||||||
try:
|
try:
|
||||||
currency = currency or self.event.currency
|
currency = currency or self.event.currency
|
||||||
@@ -200,7 +201,7 @@ class TaxRule(LoggedModel):
|
|||||||
rate = override_tax_rate
|
rate = override_tax_rate
|
||||||
elif invoice_address:
|
elif invoice_address:
|
||||||
adjust_rate = self.tax_rate_for(invoice_address)
|
adjust_rate = self.tax_rate_for(invoice_address)
|
||||||
if adjust_rate == gross_price_is_tax_rate and base_price_is == 'gross':
|
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price) and base_price_is == 'gross':
|
||||||
rate = adjust_rate
|
rate = adjust_rate
|
||||||
elif adjust_rate != rate:
|
elif adjust_rate != rate:
|
||||||
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||||
@@ -268,6 +269,25 @@ class TaxRule(LoggedModel):
|
|||||||
return r
|
return r
|
||||||
return {'action': 'vat'}
|
return {'action': 'vat'}
|
||||||
|
|
||||||
|
def invoice_text(self, invoice_address):
|
||||||
|
if self._custom_rules:
|
||||||
|
rule = self.get_matching_rule(invoice_address)
|
||||||
|
t = rule.get('invoice_text', {})
|
||||||
|
if t and any(l for l in t.values()):
|
||||||
|
return str(LazyI18nString(t))
|
||||||
|
if self.is_reverse_charge(invoice_address):
|
||||||
|
if is_eu_country(invoice_address.country):
|
||||||
|
return pgettext(
|
||||||
|
"invoice",
|
||||||
|
"Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability "
|
||||||
|
"rests with the service recipient."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return pgettext(
|
||||||
|
"invoice",
|
||||||
|
"VAT liability rests with the service recipient."
|
||||||
|
)
|
||||||
|
|
||||||
def is_reverse_charge(self, invoice_address):
|
def is_reverse_charge(self, invoice_address):
|
||||||
if self._custom_rules:
|
if self._custom_rules:
|
||||||
rule = self.get_matching_rule(invoice_address)
|
rule = self.get_matching_rule(invoice_address)
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class WaitingListEntry(LoggedModel):
|
|||||||
self.voucher = v
|
self.voucher = v
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
with language(self.locale):
|
with language(self.locale, self.event.settings.region):
|
||||||
mail(
|
mail(
|
||||||
self.email,
|
self.email,
|
||||||
_('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
|
_('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import pytz
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.forms import Form
|
from django.forms import Form
|
||||||
@@ -706,12 +706,24 @@ class BasePaymentProvider:
|
|||||||
It should return HTML code containing information regarding the current payment
|
It should return HTML code containing information regarding the current payment
|
||||||
status and, if applicable, next steps.
|
status and, if applicable, next steps.
|
||||||
|
|
||||||
The default implementation returns the verbose name of the payment provider.
|
The default implementation returns an empty string.
|
||||||
|
|
||||||
:param order: The order object
|
:param order: The order object
|
||||||
"""
|
"""
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def payment_control_render_short(self, payment: OrderPayment) -> str:
|
||||||
|
"""
|
||||||
|
Will be called if the *event administrator* performs an action on the payment. Should
|
||||||
|
return a very short version of the payment method. Usually, this should return e.g.
|
||||||
|
a transaction ID or account identifier, but no information on status, dates, etc.
|
||||||
|
|
||||||
|
The default implementation falls back to payment_presa_elrender.
|
||||||
|
|
||||||
|
:param order: The order object
|
||||||
|
"""
|
||||||
|
return self.payment_presale_render(payment)
|
||||||
|
|
||||||
def refund_control_render(self, request: HttpRequest, refund: OrderRefund) -> str:
|
def refund_control_render(self, request: HttpRequest, refund: OrderRefund) -> str:
|
||||||
"""
|
"""
|
||||||
Will be called if the *event administrator* views the details of a refund.
|
Will be called if the *event administrator* views the details of a refund.
|
||||||
@@ -725,6 +737,19 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def payment_presale_render(self, payment: OrderPayment) -> str:
|
||||||
|
"""
|
||||||
|
Will be called if the *ticket customer* views the details of a payment. This is
|
||||||
|
currently used e.g. when the customer requests a refund to show which payment
|
||||||
|
method is used for the refund. This should only include very basic information
|
||||||
|
about the payment, such as "VISA card ****9999", and never raw payment information.
|
||||||
|
|
||||||
|
The default implementation returns the public name of the payment provider.
|
||||||
|
|
||||||
|
:param order: The order object
|
||||||
|
"""
|
||||||
|
return self.public_name
|
||||||
|
|
||||||
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
||||||
"""
|
"""
|
||||||
Will be called to check if the provider supports automatic refunding for this
|
Will be called to check if the provider supports automatic refunding for this
|
||||||
@@ -760,6 +785,32 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
raise PaymentException(_('Automatic refunds are not supported by this payment provider.'))
|
raise PaymentException(_('Automatic refunds are not supported by this payment provider.'))
|
||||||
|
|
||||||
|
def new_refund_control_form_render(self, request: HttpRequest, order: Order) -> str:
|
||||||
|
"""
|
||||||
|
Render a form that will be shown to backend users when trying to create a new refund.
|
||||||
|
|
||||||
|
Usually, refunds are created from an existing payment object, e.g. if there is a credit card
|
||||||
|
payment and the credit card provider returns ``True`` from ``payment_refund_supported``, the system
|
||||||
|
will automatically create an ``OrderRefund`` and call ``execute_refund`` on that payment. This method
|
||||||
|
can and should not be used in that situation! Instead, by implementing this method you can add a refund
|
||||||
|
flow for this payment provider that starts without an existing payment. For example, even though an order
|
||||||
|
was paid by credit card, it could easily be refunded by SEPA bank transfer. In that case, the SEPA bank
|
||||||
|
transfer provider would implement this method and return a form that asks for the IBAN.
|
||||||
|
|
||||||
|
This method should return HTML or ``None``. All form fields should have a globally unique name.
|
||||||
|
"""
|
||||||
|
return
|
||||||
|
|
||||||
|
def new_refund_control_form_process(self, request: HttpRequest, amount: Decimal, order: Order) -> OrderRefund:
|
||||||
|
"""
|
||||||
|
Process a backend user's request to initiate a new refund with an amount of ``amount`` for ``order``.
|
||||||
|
|
||||||
|
This method should parse the input provided to the form created and either raise ``ValidationError``
|
||||||
|
or return an ``OrderRefund`` object in ``created`` state that has not yet been saved to the database.
|
||||||
|
The system will then call ``execute_refund`` on that object.
|
||||||
|
"""
|
||||||
|
raise ValidationError('Not implemented')
|
||||||
|
|
||||||
def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]):
|
def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]):
|
||||||
"""
|
"""
|
||||||
When personal data is removed from an event, this method is called to scrub payment-related data
|
When personal data is removed from an event, this method is called to scrub payment-related data
|
||||||
@@ -899,7 +950,7 @@ class ManualPayment(BasePaymentProvider):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def public_name(self):
|
def public_name(self):
|
||||||
return str(self.settings.get('public_name', as_type=LazyI18nString))
|
return str(self.settings.get('public_name', as_type=LazyI18nString) or _('Manual payment'))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def settings_form_fields(self):
|
def settings_form_fields(self):
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from pretix.base.models import Order, OrderPosition
|
|||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
from pretix.base.signals import layout_text_variables
|
from pretix.base.signals import layout_text_variables
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
|
from pretix.base.templatetags.phone_format import phone_format
|
||||||
from pretix.presale.style import get_fonts
|
from pretix.presale.style import get_fonts
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -121,6 +122,26 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
'editor_sample': _('John Doe\nSample company\nSesame Street 42\n12345 Any City\nAtlantis'),
|
'editor_sample': _('John Doe\nSample company\nSesame Street 42\n12345 Any City\nAtlantis'),
|
||||||
'evaluate': lambda op, order, event: op.address_format()
|
'evaluate': lambda op, order, event: op.address_format()
|
||||||
}),
|
}),
|
||||||
|
("attendee_street", {
|
||||||
|
"label": _("Attendee street"),
|
||||||
|
"editor_sample": 'Sesame Street 42',
|
||||||
|
"evaluate": lambda op, order, ev: op.street or (op.addon_to.street if op.addon_to else '')
|
||||||
|
}),
|
||||||
|
("attendee_zipcode", {
|
||||||
|
"label": _("Attendee ZIP code"),
|
||||||
|
"editor_sample": '12345',
|
||||||
|
"evaluate": lambda op, order, ev: op.zipcode or (op.addon_to.zipcode if op.addon_to else '')
|
||||||
|
}),
|
||||||
|
("attendee_city", {
|
||||||
|
"label": _("Attendee city"),
|
||||||
|
"editor_sample": 'Any City',
|
||||||
|
"evaluate": lambda op, order, ev: op.city or (op.addon_to.city if op.addon_to else '')
|
||||||
|
}),
|
||||||
|
("attendee_state", {
|
||||||
|
"label": _("Attendee state"),
|
||||||
|
"editor_sample": 'Sample State',
|
||||||
|
"evaluate": lambda op, order, ev: op.state or (op.addon_to.state if op.addon_to else '')
|
||||||
|
}),
|
||||||
("attendee_country", {
|
("attendee_country", {
|
||||||
"label": _("Attendee country"),
|
"label": _("Attendee country"),
|
||||||
"editor_sample": 'Atlantis',
|
"editor_sample": 'Atlantis',
|
||||||
@@ -209,6 +230,11 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
"editor_sample": _("Random City"),
|
"editor_sample": _("Random City"),
|
||||||
"evaluate": lambda op, order, ev: str(ev.location)
|
"evaluate": lambda op, order, ev: str(ev.location)
|
||||||
}),
|
}),
|
||||||
|
("telephone", {
|
||||||
|
"label": _("Phone number"),
|
||||||
|
"editor_sample": "+01 1234 567890",
|
||||||
|
"evaluate": lambda op, order, ev: phone_format(order.phone)
|
||||||
|
}),
|
||||||
("invoice_name", {
|
("invoice_name", {
|
||||||
"label": _("Invoice address name"),
|
"label": _("Invoice address name"),
|
||||||
"editor_sample": _("John Doe"),
|
"editor_sample": _("John Doe"),
|
||||||
@@ -219,11 +245,31 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
"editor_sample": _("Sample company"),
|
"editor_sample": _("Sample company"),
|
||||||
"evaluate": lambda op, order, ev: order.invoice_address.company if getattr(order, 'invoice_address', None) else ''
|
"evaluate": lambda op, order, ev: order.invoice_address.company if getattr(order, 'invoice_address', None) else ''
|
||||||
}),
|
}),
|
||||||
|
("invoice_street", {
|
||||||
|
"label": _("Invoice address street"),
|
||||||
|
"editor_sample": _("Sesame Street 42"),
|
||||||
|
"evaluate": lambda op, order, ev: order.invoice_address.street if getattr(order, 'invoice_address', None) else ''
|
||||||
|
}),
|
||||||
|
("invoice_zipcode", {
|
||||||
|
"label": _("Invoice address ZIP code"),
|
||||||
|
"editor_sample": _("12345"),
|
||||||
|
"evaluate": lambda op, order, ev: order.invoice_address.zipcode if getattr(order, 'invoice_address', None) else ''
|
||||||
|
}),
|
||||||
("invoice_city", {
|
("invoice_city", {
|
||||||
"label": _("Invoice address city"),
|
"label": _("Invoice address city"),
|
||||||
"editor_sample": _("Sample city"),
|
"editor_sample": _("Sample city"),
|
||||||
"evaluate": lambda op, order, ev: order.invoice_address.city if getattr(order, 'invoice_address', None) else ''
|
"evaluate": lambda op, order, ev: order.invoice_address.city if getattr(order, 'invoice_address', None) else ''
|
||||||
}),
|
}),
|
||||||
|
("invoice_state", {
|
||||||
|
"label": _("Invoice address state"),
|
||||||
|
"editor_sample": _("Sample State"),
|
||||||
|
"evaluate": lambda op, order, ev: order.invoice_address.state if getattr(order, 'invoice_address', None) else ''
|
||||||
|
}),
|
||||||
|
("invoice_country", {
|
||||||
|
"label": _("Invoice address country"),
|
||||||
|
"editor_sample": _("Atlantis"),
|
||||||
|
"evaluate": lambda op, order, ev: str(getattr(order.invoice_address.country, 'name', '')) if getattr(order, 'invoice_address', None) else ''
|
||||||
|
}),
|
||||||
("addons", {
|
("addons", {
|
||||||
"label": _("List of Add-Ons"),
|
"label": _("List of Add-Ons"),
|
||||||
"editor_sample": _("Add-on 1\nAdd-on 2"),
|
"editor_sample": _("Add-on 1\nAdd-on 2"),
|
||||||
@@ -381,6 +427,7 @@ class Renderer:
|
|||||||
self.layout = layout
|
self.layout = layout
|
||||||
self.background_file = background_file
|
self.background_file = background_file
|
||||||
self.variables = get_variables(event)
|
self.variables = get_variables(event)
|
||||||
|
self.event = event
|
||||||
if self.background_file:
|
if self.background_file:
|
||||||
self.bg_bytes = self.background_file.read()
|
self.bg_bytes = self.background_file.read()
|
||||||
self.bg_pdf = PdfFileReader(BytesIO(self.bg_bytes), strict=False)
|
self.bg_pdf = PdfFileReader(BytesIO(self.bg_bytes), strict=False)
|
||||||
@@ -447,7 +494,7 @@ class Renderer:
|
|||||||
|
|
||||||
def _get_text_content(self, op: OrderPosition, order: Order, o: dict, inner=False):
|
def _get_text_content(self, op: OrderPosition, order: Order, o: dict, inner=False):
|
||||||
if o.get('locale', None) and not inner:
|
if o.get('locale', None) and not inner:
|
||||||
with language(o['locale']):
|
with language(o['locale'], self.event.settings.region):
|
||||||
return self._get_text_content(op, order, o, True)
|
return self._get_text_content(op, order, o, True)
|
||||||
|
|
||||||
ev = self._get_ev(op, order)
|
ev = self._get_ev(op, order)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Subquery
|
from django.db.models import Count, Exists, IntegerField, OuterRef, Subquery
|
||||||
|
from django.utils.translation import gettext
|
||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
|
|
||||||
from pretix.base.decimal import round_decimal
|
from pretix.base.decimal import round_decimal
|
||||||
@@ -24,7 +25,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
|
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
|
||||||
with language(wle.locale):
|
with language(wle.locale, wle.event.settings.region):
|
||||||
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
|
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
|
||||||
try:
|
try:
|
||||||
mail(
|
mail(
|
||||||
@@ -41,7 +42,7 @@ def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: Lazy
|
|||||||
|
|
||||||
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
|
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
|
||||||
refund_amount: Decimal, user: User, positions: list):
|
refund_amount: Decimal, user: User, positions: list):
|
||||||
with language(order.locale):
|
with language(order.locale, order.event.settings.region):
|
||||||
try:
|
try:
|
||||||
ia = order.invoice_address
|
ia = order.invoice_address
|
||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
@@ -195,7 +196,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
|||||||
if auto_refund:
|
if auto_refund:
|
||||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
||||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions)
|
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||||
|
comment=gettext('Event canceled'))
|
||||||
finally:
|
finally:
|
||||||
if send:
|
if send:
|
||||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
|
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
|
||||||
@@ -252,7 +254,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
|||||||
if auto_refund:
|
if auto_refund:
|
||||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
||||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions)
|
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||||
|
comment=gettext('Event canceled'))
|
||||||
|
|
||||||
if send:
|
if send:
|
||||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
|
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import dateutil
|
import dateutil
|
||||||
|
from django.core.files import File
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models.functions import TruncDate
|
from django.db.models.functions import TruncDate
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -23,7 +24,7 @@ def get_logic_environment(ev):
|
|||||||
elif t == 'date_from':
|
elif t == 'date_from':
|
||||||
return ev.date_from
|
return ev.date_from
|
||||||
elif t == 'date_to':
|
elif t == 'date_to':
|
||||||
return ev.date_to
|
return ev.date_to or ev.date_from
|
||||||
elif t == 'date_admission':
|
elif t == 'date_admission':
|
||||||
return ev.date_admission or ev.date_from
|
return ev.date_admission or ev.date_from
|
||||||
|
|
||||||
@@ -125,6 +126,14 @@ def _save_answers(op, answers, given_answers):
|
|||||||
else:
|
else:
|
||||||
qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a]))
|
qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a]))
|
||||||
qa.options.add(*a)
|
qa.options.add(*a)
|
||||||
|
elif isinstance(a, File):
|
||||||
|
if q in answers:
|
||||||
|
qa = answers[q]
|
||||||
|
else:
|
||||||
|
qa = op.answers.create(question=q, answer=str(a))
|
||||||
|
qa.file.save(a.name, a, save=False)
|
||||||
|
qa.answer = 'file://' + qa.file.name
|
||||||
|
qa.save()
|
||||||
else:
|
else:
|
||||||
if q in answers:
|
if q in answers:
|
||||||
qa = answers[q]
|
qa = answers[q]
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
|
|||||||
)
|
)
|
||||||
|
|
||||||
file = CachedFile.objects.get(id=fileid)
|
file = CachedFile.objects.get(id=fileid)
|
||||||
with language(event.settings.locale), override(event.settings.timezone):
|
with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
|
||||||
responses = register_data_exporters.send(event)
|
responses = register_data_exporters.send(event)
|
||||||
for receiver, response in responses:
|
for receiver, response in responses:
|
||||||
ex = response(event, set_progress)
|
ex = response(event, set_progress)
|
||||||
@@ -67,15 +67,18 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
|
|||||||
if user:
|
if user:
|
||||||
locale = user.locale
|
locale = user.locale
|
||||||
timezone = user.timezone
|
timezone = user.timezone
|
||||||
|
region = None # todo: add to user?
|
||||||
else:
|
else:
|
||||||
e = allowed_events.first()
|
e = allowed_events.first()
|
||||||
if e:
|
if e:
|
||||||
locale = e.settings.locale
|
locale = e.settings.locale
|
||||||
timezone = e.settings.timezone
|
timezone = e.settings.timezone
|
||||||
|
region = e.settings.region
|
||||||
else:
|
else:
|
||||||
locale = settings.LANGUAGE_CODE
|
locale = settings.LANGUAGE_CODE
|
||||||
timezone = settings.TIME_ZONE
|
timezone = settings.TIME_ZONE
|
||||||
with language(locale), override(timezone):
|
region = None
|
||||||
|
with language(locale, region), override(timezone):
|
||||||
if isinstance(form_data['events'][0], str):
|
if isinstance(form_data['events'][0], str):
|
||||||
events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer)
|
events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from pretix.base.i18n import language
|
|||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
|
Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
|
||||||
)
|
)
|
||||||
from pretix.base.models.tax import EU_CURRENCIES, is_eu_country
|
from pretix.base.models.tax import EU_CURRENCIES
|
||||||
from pretix.base.services.tasks import TransactionAwareTask
|
from pretix.base.services.tasks import TransactionAwareTask
|
||||||
from pretix.base.settings import GlobalSettingsObject
|
from pretix.base.settings import GlobalSettingsObject
|
||||||
from pretix.base.signals import invoice_line_text, periodic_task
|
from pretix.base.signals import invoice_line_text, periodic_task
|
||||||
@@ -43,7 +43,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
|
|
||||||
lp = invoice.order.payments.last()
|
lp = invoice.order.payments.last()
|
||||||
|
|
||||||
with language(invoice.locale):
|
with language(invoice.locale, invoice.event.settings.region):
|
||||||
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
|
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
|
||||||
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
||||||
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||||
@@ -142,6 +142,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
reverse_charge = False
|
reverse_charge = False
|
||||||
|
|
||||||
positions.sort(key=lambda p: p.sort_key)
|
positions.sort(key=lambda p: p.sort_key)
|
||||||
|
|
||||||
|
tax_texts = []
|
||||||
for i, p in enumerate(positions):
|
for i, p in enumerate(positions):
|
||||||
if not invoice.event.settings.invoice_include_free and p.price == Decimal('0.00') and not p.addon_c:
|
if not invoice.event.settings.invoice_include_free and p.price == Decimal('0.00') and not p.addon_c:
|
||||||
continue
|
continue
|
||||||
@@ -178,22 +180,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
if p.tax_rule and p.tax_rule.is_reverse_charge(ia) and p.price and not p.tax_value:
|
if p.tax_rule and p.tax_rule.is_reverse_charge(ia) and p.price and not p.tax_value:
|
||||||
reverse_charge = True
|
reverse_charge = True
|
||||||
|
|
||||||
if reverse_charge:
|
if p.tax_rule:
|
||||||
if invoice.additional_text:
|
tax_text = p.tax_rule.invoice_text(ia)
|
||||||
invoice.additional_text += "<br /><br />"
|
if tax_text and tax_text not in tax_texts:
|
||||||
if is_eu_country(invoice.invoice_to_country):
|
tax_texts.append(tax_text)
|
||||||
invoice.additional_text += pgettext(
|
|
||||||
"invoice",
|
|
||||||
"Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability "
|
|
||||||
"rests with the service recipient."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
invoice.additional_text += pgettext(
|
|
||||||
"invoice",
|
|
||||||
"VAT liability rests with the service recipient."
|
|
||||||
)
|
|
||||||
invoice.reverse_charge = True
|
|
||||||
invoice.save()
|
|
||||||
|
|
||||||
offset = len(positions)
|
offset = len(positions)
|
||||||
for i, fee in enumerate(invoice.order.fees.all()):
|
for i, fee in enumerate(invoice.order.fees.all()):
|
||||||
@@ -213,6 +203,20 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
tax_name=fee.tax_rule.name if fee.tax_rule else ''
|
tax_name=fee.tax_rule.name if fee.tax_rule else ''
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if fee.tax_rule and fee.tax_rule.is_reverse_charge(ia) and fee.value and not fee.tax_value:
|
||||||
|
reverse_charge = True
|
||||||
|
|
||||||
|
if fee.tax_rule:
|
||||||
|
tax_text = fee.tax_rule.invoice_text(ia)
|
||||||
|
if tax_text and tax_text not in tax_texts:
|
||||||
|
tax_texts.append(tax_text)
|
||||||
|
|
||||||
|
if tax_texts:
|
||||||
|
invoice.additional_text += "<br /><br />"
|
||||||
|
invoice.additional_text += "<br />".join(tax_texts)
|
||||||
|
invoice.reverse_charge = reverse_charge
|
||||||
|
invoice.save()
|
||||||
|
|
||||||
return invoice
|
return invoice
|
||||||
|
|
||||||
|
|
||||||
@@ -240,7 +244,7 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
|
|||||||
cancellation.date = timezone.now().date()
|
cancellation.date = timezone.now().date()
|
||||||
cancellation.payment_provider_text = ''
|
cancellation.payment_provider_text = ''
|
||||||
cancellation.file = None
|
cancellation.file = None
|
||||||
with language(invoice.locale):
|
with language(invoice.locale, invoice.event.settings.region):
|
||||||
cancellation.invoice_from = invoice.event.settings.get('invoice_address_from')
|
cancellation.invoice_from = invoice.event.settings.get('invoice_address_from')
|
||||||
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
||||||
cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||||
@@ -293,7 +297,7 @@ def invoice_pdf_task(invoice: int):
|
|||||||
return None
|
return None
|
||||||
if i.file:
|
if i.file:
|
||||||
i.file.delete()
|
i.file.delete()
|
||||||
with language(i.locale):
|
with language(i.locale, i.event.settings.region):
|
||||||
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
|
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
|
||||||
i.file.save(fname, ContentFile(fcontent))
|
i.file.save(fname, ContentFile(fcontent))
|
||||||
i.save()
|
i.save()
|
||||||
@@ -324,7 +328,7 @@ def build_preview_invoice_pdf(event):
|
|||||||
if not locale or locale == '__user__':
|
if not locale or locale == '__user__':
|
||||||
locale = event.settings.locale
|
locale = event.settings.locale
|
||||||
|
|
||||||
with rolledback_transaction(), language(locale):
|
with rolledback_transaction(), language(locale, event.settings.region):
|
||||||
order = event.orders.create(status=Order.STATUS_PENDING, datetime=timezone.now(),
|
order = event.orders.create(status=Order.STATUS_PENDING, datetime=timezone.now(),
|
||||||
expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count())
|
expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count())
|
||||||
invoice = Invoice(
|
invoice = Invoice(
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
|||||||
except Order.DoesNotExist:
|
except Order.DoesNotExist:
|
||||||
order = None
|
order = None
|
||||||
else:
|
else:
|
||||||
with language(order.locale):
|
with language(order.locale, event.settings.region):
|
||||||
if position:
|
if position:
|
||||||
try:
|
try:
|
||||||
position = order.positions.get(pk=position)
|
position = order.positions.get(pk=position)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
|
|||||||
# TODO: quotacheck?
|
# TODO: quotacheck?
|
||||||
cf = CachedFile.objects.get(id=fileid)
|
cf = CachedFile.objects.get(id=fileid)
|
||||||
user = User.objects.get(pk=user)
|
user = User.objects.get(pk=user)
|
||||||
with language(locale):
|
with language(locale, event.settings.region):
|
||||||
cols = get_all_columns(event)
|
cols = get_all_columns(event)
|
||||||
parsed = parse_csv(cf.file)
|
parsed = parse_csv(cf.file)
|
||||||
orders = []
|
orders = []
|
||||||
@@ -163,7 +163,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
|
|||||||
)
|
)
|
||||||
|
|
||||||
for o in orders:
|
for o in orders:
|
||||||
with language(o.locale):
|
with language(o.locale, event.settings.region):
|
||||||
order_placed.send(event, order=o)
|
order_placed.send(event, order=o)
|
||||||
if o.status == Order.STATUS_PAID:
|
if o.status == Order.STATUS_PAID:
|
||||||
order_paid.send(event, order=o)
|
order_paid.send(event, order=o)
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ from django_scopes import scopes_disabled
|
|||||||
from pretix.api.models import OAuthApplication
|
from pretix.api.models import OAuthApplication
|
||||||
from pretix.base.channels import get_all_sales_channels
|
from pretix.base.channels import get_all_sales_channels
|
||||||
from pretix.base.email import get_email_context
|
from pretix.base.email import get_email_context
|
||||||
from pretix.base.i18n import LazyLocaleException, language
|
from pretix.base.i18n import (
|
||||||
|
LazyLocaleException, get_language_without_region, language,
|
||||||
|
)
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order,
|
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order,
|
||||||
OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
||||||
@@ -260,7 +262,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
|||||||
# send_mail will trigger PDF generation later
|
# send_mail will trigger PDF generation later
|
||||||
|
|
||||||
if send_mail:
|
if send_mail:
|
||||||
with language(order.locale):
|
with language(order.locale, order.event.settings.region):
|
||||||
if order.total == Decimal('0.00'):
|
if order.total == Decimal('0.00'):
|
||||||
email_template = order.event.settings.mail_text_order_approved_free
|
email_template = order.event.settings.mail_text_order_approved_free
|
||||||
email_subject = _('Order approved and confirmed: %(code)s') % {'code': order.code}
|
email_subject = _('Order approved and confirmed: %(code)s') % {'code': order.code}
|
||||||
@@ -311,7 +313,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
|||||||
if send_mail:
|
if send_mail:
|
||||||
email_template = order.event.settings.mail_text_order_denied
|
email_template = order.event.settings.mail_text_order_denied
|
||||||
email_context = get_email_context(event=order.event, order=order, comment=comment)
|
email_context = get_email_context(event=order.event, order=order, comment=comment)
|
||||||
with language(order.locale):
|
with language(order.locale, order.event.settings.region):
|
||||||
email_subject = _('Order denied: %(code)s') % {'code': order.code}
|
email_subject = _('Order denied: %(code)s') % {'code': order.code}
|
||||||
try:
|
try:
|
||||||
order.send_mail(
|
order.send_mail(
|
||||||
@@ -422,7 +424,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
|||||||
|
|
||||||
if send_mail:
|
if send_mail:
|
||||||
email_template = order.event.settings.mail_text_order_canceled
|
email_template = order.event.settings.mail_text_order_canceled
|
||||||
with language(order.locale):
|
with language(order.locale, order.event.settings.region):
|
||||||
email_context = get_email_context(event=order.event, order=order)
|
email_context = get_email_context(event=order.event, order=order)
|
||||||
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
|
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
|
||||||
try:
|
try:
|
||||||
@@ -776,8 +778,9 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
|||||||
status=Order.STATUS_PENDING,
|
status=Order.STATUS_PENDING,
|
||||||
event=event,
|
event=event,
|
||||||
email=email,
|
email=email,
|
||||||
|
phone=(meta_info or {}).get('contact_form_data', {}).get('phone'),
|
||||||
datetime=now_dt,
|
datetime=now_dt,
|
||||||
locale=locale,
|
locale=get_language_without_region(locale),
|
||||||
total=total,
|
total=total,
|
||||||
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
||||||
meta_info=json.dumps(meta_info or {}),
|
meta_info=json.dumps(meta_info or {}),
|
||||||
@@ -1033,7 +1036,7 @@ def send_expiry_warnings(sender, **kwargs):
|
|||||||
# Race condition
|
# Race condition
|
||||||
continue
|
continue
|
||||||
|
|
||||||
with language(o.locale):
|
with language(o.locale, settings.region):
|
||||||
o.expiry_reminder_sent = True
|
o.expiry_reminder_sent = True
|
||||||
o.save(update_fields=['expiry_reminder_sent'])
|
o.save(update_fields=['expiry_reminder_sent'])
|
||||||
email_template = settings.mail_text_order_expire_warning
|
email_template = settings.mail_text_order_expire_warning
|
||||||
@@ -1110,7 +1113,7 @@ def send_download_reminders(sender, **kwargs):
|
|||||||
if not send:
|
if not send:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
with language(o.locale):
|
with language(o.locale, o.event.settings.region):
|
||||||
o.download_reminder_sent = True
|
o.download_reminder_sent = True
|
||||||
o.save(update_fields=['download_reminder_sent'])
|
o.save(update_fields=['download_reminder_sent'])
|
||||||
email_template = event.settings.mail_text_download_reminder
|
email_template = event.settings.mail_text_download_reminder
|
||||||
@@ -1150,7 +1153,7 @@ def send_download_reminders(sender, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
|
def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
|
||||||
with language(order.locale):
|
with language(order.locale, order.event.settings.region):
|
||||||
email_template = order.event.settings.mail_text_order_changed
|
email_template = order.event.settings.mail_text_order_changed
|
||||||
email_context = get_email_context(event=order.event, order=order)
|
email_context = get_email_context(event=order.event, order=order)
|
||||||
email_subject = _('Your order has been changed: %(code)s') % {'code': order.code}
|
email_subject = _('Your order has been changed: %(code)s') % {'code': order.code}
|
||||||
@@ -2031,7 +2034,7 @@ _unset = object()
|
|||||||
|
|
||||||
|
|
||||||
def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER,
|
def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||||
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None):
|
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None, comment=None):
|
||||||
notify_admin = False
|
notify_admin = False
|
||||||
error = False
|
error = False
|
||||||
if isinstance(order, int):
|
if isinstance(order, int):
|
||||||
@@ -2056,6 +2059,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
|||||||
order=order,
|
order=order,
|
||||||
payment=None,
|
payment=None,
|
||||||
source=source,
|
source=source,
|
||||||
|
comment=comment,
|
||||||
state=OrderRefund.REFUND_STATE_CREATED,
|
state=OrderRefund.REFUND_STATE_CREATED,
|
||||||
execution_date=now(),
|
execution_date=now(),
|
||||||
amount=can_auto_refund_sum,
|
amount=can_auto_refund_sum,
|
||||||
@@ -2093,6 +2097,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
|||||||
source=source,
|
source=source,
|
||||||
state=OrderRefund.REFUND_STATE_CREATED,
|
state=OrderRefund.REFUND_STATE_CREATED,
|
||||||
amount=value,
|
amount=value,
|
||||||
|
comment=comment,
|
||||||
provider=p.provider
|
provider=p.provider
|
||||||
)
|
)
|
||||||
order.log_action('pretix.event.order.refund.created', {
|
order.log_action('pretix.event.order.refund.created', {
|
||||||
@@ -2122,6 +2127,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
r = order.refunds.create(
|
r = order.refunds.create(
|
||||||
source=source,
|
source=source,
|
||||||
|
comment=comment,
|
||||||
state=OrderRefund.REFUND_STATE_CREATED,
|
state=OrderRefund.REFUND_STATE_CREATED,
|
||||||
amount=refund_amount - can_auto_refund_sum,
|
amount=refund_amount - can_auto_refund_sum,
|
||||||
provider='manual'
|
provider='manual'
|
||||||
@@ -2146,13 +2152,14 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
|||||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||||
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False):
|
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False, comment=None):
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||||
cancellation_fee)
|
cancellation_fee)
|
||||||
if try_auto_refund:
|
if try_auto_refund:
|
||||||
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard)
|
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
|
||||||
|
comment=comment)
|
||||||
return ret
|
return ret
|
||||||
except LockTimeoutException:
|
except LockTimeoutException:
|
||||||
self.retry()
|
self.retry()
|
||||||
|
|||||||
@@ -113,10 +113,11 @@ class QuotaAvailability:
|
|||||||
raise e
|
raise e
|
||||||
|
|
||||||
def _write_cache(self, quotas, now_dt):
|
def _write_cache(self, quotas, now_dt):
|
||||||
events = {q.event for q in quotas}
|
# We used to also delete item_quota_cache:* from the event cache here, but as the cache
|
||||||
|
# gets more complex, this does not seem worth it. The cache is only present for up to
|
||||||
|
# 5 seconds to prevent high peaks, and a 5-second delay in availability is usually
|
||||||
|
# tolerable
|
||||||
update = []
|
update = []
|
||||||
for e in events:
|
|
||||||
e.cache.delete('item_quota_cache')
|
|
||||||
for q in quotas:
|
for q in quotas:
|
||||||
rewrite_cache = self._count_waitinglist and (
|
rewrite_cache = self._count_waitinglist and (
|
||||||
not q.cache_is_hot(now_dt) or self.results[q][0] > q.cached_availability_state
|
not q.cache_is_hot(now_dt) or self.results[q][0] > q.cached_availability_state
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def validate_plan_change(event, subevent, plan):
|
|||||||
'already sold.'), leftovers[0])
|
'already sold.'), leftovers[0])
|
||||||
|
|
||||||
|
|
||||||
def generate_seats(event, subevent, plan, mapping):
|
def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
|
||||||
current_seats = {}
|
current_seats = {}
|
||||||
for s in event.seats.select_related('product').annotate(
|
for s in event.seats.select_related('product').annotate(
|
||||||
has_op=Count('orderposition'), has_v=Count('vouchers')
|
has_op=Count('orderposition'), has_v=Count('vouchers')
|
||||||
@@ -68,7 +68,10 @@ def generate_seats(event, subevent, plan, mapping):
|
|||||||
update(seat, 'seat_label', ss.seat_label),
|
update(seat, 'seat_label', ss.seat_label),
|
||||||
update(seat, 'x', ss.x),
|
update(seat, 'x', ss.x),
|
||||||
update(seat, 'y', ss.y),
|
update(seat, 'y', ss.y),
|
||||||
])
|
] + (
|
||||||
|
[update(seat, 'blocked', ss.guid in blocked_guids)]
|
||||||
|
if blocked_guids else []
|
||||||
|
))
|
||||||
if updated:
|
if updated:
|
||||||
seat.save()
|
seat.save()
|
||||||
else:
|
else:
|
||||||
@@ -84,6 +87,7 @@ def generate_seats(event, subevent, plan, mapping):
|
|||||||
seat_label=ss.seat_label,
|
seat_label=ss.seat_label,
|
||||||
x=ss.x,
|
x=ss.x,
|
||||||
y=ss.y,
|
y=ss.y,
|
||||||
|
blocked=bool(blocked_guids and ss.guid in blocked_guids),
|
||||||
product=p,
|
product=p,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from pretix.celery_app import app
|
|||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledEventTask)
|
@app.task(base=ProfiledEventTask)
|
||||||
def export(event: Event, shredders: List[str]) -> None:
|
def export(event: Event, shredders: List[str], session_key=None) -> None:
|
||||||
known_shredders = event.get_data_shredders()
|
known_shredders = event.get_data_shredders()
|
||||||
|
|
||||||
with NamedTemporaryFile() as rawfile:
|
with NamedTemporaryFile() as rawfile:
|
||||||
@@ -55,6 +55,8 @@ def export(event: Event, shredders: List[str]) -> None:
|
|||||||
cf.date = now()
|
cf.date = now()
|
||||||
cf.filename = event.slug + '.zip'
|
cf.filename = event.slug + '.zip'
|
||||||
cf.type = 'application/zip'
|
cf.type = 'application/zip'
|
||||||
|
cf.session_key = session_key
|
||||||
|
cf.web_download = True
|
||||||
cf.expires = now() + timedelta(hours=1)
|
cf.expires = now() + timedelta(hours=1)
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.file.save(cachedfile_name(cf, cf.filename), rawfile)
|
cf.file.save(cachedfile_name(cf, cf.filename), rawfile)
|
||||||
@@ -73,16 +75,19 @@ def shred(event: Event, fileid: str, confirm_code: str) -> None:
|
|||||||
indexdata = json.loads(zipfile.read('index.json').decode())
|
indexdata = json.loads(zipfile.read('index.json').decode())
|
||||||
if indexdata['organizer'] != event.organizer.slug or indexdata['event'] != event.slug:
|
if indexdata['organizer'] != event.organizer.slug or indexdata['event'] != event.slug:
|
||||||
raise ShredError(_("This file is from a different event."))
|
raise ShredError(_("This file is from a different event."))
|
||||||
|
shredders = []
|
||||||
|
for s in indexdata['shredders']:
|
||||||
|
shredder = known_shredders.get(s)
|
||||||
|
if not shredder:
|
||||||
|
continue
|
||||||
|
shredders.append(shredder)
|
||||||
|
if any(shredder.require_download_confirmation for shredder in shredders):
|
||||||
if indexdata['confirm_code'] != confirm_code:
|
if indexdata['confirm_code'] != confirm_code:
|
||||||
raise ShredError(_("The confirm code you entered was incorrect."))
|
raise ShredError(_("The confirm code you entered was incorrect."))
|
||||||
if event.logentry_set.filter(datetime__gte=parse(indexdata['time'])):
|
if event.logentry_set.filter(datetime__gte=parse(indexdata['time'])):
|
||||||
raise ShredError(_("Something happened in your event after the export, please try again."))
|
raise ShredError(_("Something happened in your event after the export, please try again."))
|
||||||
|
|
||||||
for s in indexdata['shredders']:
|
for shredder in shredders:
|
||||||
shredder = known_shredders.get(s)
|
|
||||||
if not shredder:
|
|
||||||
continue
|
|
||||||
|
|
||||||
shredder.shred_data()
|
shredder.shred_data()
|
||||||
|
|
||||||
cf.file.delete(save=False)
|
cf.file.delete(save=False)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def generate_orderposition(order_position: int, provider: str):
|
def generate_orderposition(order_position: int, provider: str):
|
||||||
order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position)
|
order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position)
|
||||||
|
|
||||||
with language(order_position.order.locale):
|
with language(order_position.order.locale, order_position.order.event.settings.region):
|
||||||
responses = register_ticket_outputs.send(order_position.order.event)
|
responses = register_ticket_outputs.send(order_position.order.event)
|
||||||
for receiver, response in responses:
|
for receiver, response in responses:
|
||||||
prov = response(order_position.order.event)
|
prov = response(order_position.order.event)
|
||||||
@@ -41,7 +41,7 @@ def generate_orderposition(order_position: int, provider: str):
|
|||||||
def generate_order(order: int, provider: str):
|
def generate_order(order: int, provider: str):
|
||||||
order = Order.objects.select_related('event').get(id=order)
|
order = Order.objects.select_related('event').get(id=order)
|
||||||
|
|
||||||
with language(order.locale):
|
with language(order.locale, order.event.settings.region):
|
||||||
responses = register_ticket_outputs.send(order.event)
|
responses = register_ticket_outputs.send(order.event)
|
||||||
for receiver, response in responses:
|
for receiver, response in responses:
|
||||||
prov = response(order.event)
|
prov = response(order.event)
|
||||||
@@ -75,7 +75,7 @@ class DummyRollbackException(Exception):
|
|||||||
def preview(event: int, provider: str):
|
def preview(event: int, provider: str):
|
||||||
event = Event.objects.get(id=event)
|
event = Event.objects.get(id=event)
|
||||||
|
|
||||||
with rolledback_transaction(), language(event.settings.locale):
|
with rolledback_transaction(), language(event.settings.locale, event.settings.region):
|
||||||
item = event.items.create(name=_("Sample product"), default_price=42.23,
|
item = event.items.create(name=_("Sample product"), default_price=42.23,
|
||||||
description=_("Sample product description"))
|
description=_("Sample product description"))
|
||||||
item2 = event.items.create(name=_("Sample workshop"), default_price=23.40)
|
item2 = event.items.create(name=_("Sample workshop"), default_price=23.40)
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
|||||||
continue
|
continue
|
||||||
if wle.subevent and not wle.subevent.presale_is_running:
|
if wle.subevent and not wle.subevent.presale_is_running:
|
||||||
continue
|
continue
|
||||||
if not wle.item.active or (wle.variation and not wle.variation.active):
|
if not wle.item.is_available():
|
||||||
|
gone.add((wle.item, wle.variation, wle.subevent))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
|
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ from django import forms
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import (
|
||||||
|
MaxValueValidator, MinValueValidator, RegexValidator,
|
||||||
|
)
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.utils.translation import (
|
from django.utils.translation import (
|
||||||
gettext_lazy as _, gettext_noop, pgettext, pgettext_lazy,
|
gettext_lazy as _, gettext_noop, pgettext, pgettext_lazy,
|
||||||
@@ -19,14 +21,18 @@ from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
|||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from pretix.api.serializers.fields import ListMultipleChoiceField
|
from pretix.api.serializers.fields import (
|
||||||
|
ListMultipleChoiceField, UploadedFileField,
|
||||||
|
)
|
||||||
from pretix.api.serializers.i18n import I18nField
|
from pretix.api.serializers.i18n import I18nField
|
||||||
from pretix.base.models.tax import TaxRule
|
from pretix.base.models.tax import TaxRule
|
||||||
from pretix.base.reldate import (
|
from pretix.base.reldate import (
|
||||||
RelativeDateField, RelativeDateTimeField, RelativeDateWrapper,
|
RelativeDateField, RelativeDateTimeField, RelativeDateWrapper,
|
||||||
SerializerRelativeDateField, SerializerRelativeDateTimeField,
|
SerializerRelativeDateField, SerializerRelativeDateTimeField,
|
||||||
)
|
)
|
||||||
from pretix.control.forms import MultipleLanguagesWidget, SingleLanguageWidget
|
from pretix.control.forms import (
|
||||||
|
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
||||||
|
)
|
||||||
from pretix.helpers.countries import CachedCountries
|
from pretix.helpers.countries import CachedCountries
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +44,18 @@ def country_choice_kwargs():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def primary_font_kwargs():
|
||||||
|
from pretix.presale.style import get_fonts
|
||||||
|
|
||||||
|
choices = [('Open Sans', 'Open Sans')]
|
||||||
|
choices += [
|
||||||
|
(a, {"title": a, "data": v}) for a, v in get_fonts().items()
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
'choices': choices,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class LazyI18nStringList(UserList):
|
class LazyI18nStringList(UserList):
|
||||||
def __init__(self, init_list=None):
|
def __init__(self, init_list=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -177,6 +195,25 @@ DEFAULTS = {
|
|||||||
help_text=_("Require customers to fill in the primary email address twice to avoid errors."),
|
help_text=_("Require customers to fill in the primary email address twice to avoid errors."),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'order_phone_asked': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Ask for a phone number per order"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
'order_phone_required': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Require a phone number per order"),
|
||||||
|
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
|
||||||
|
)
|
||||||
|
},
|
||||||
'invoice_address_asked': {
|
'invoice_address_asked': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -252,7 +289,6 @@ DEFAULTS = {
|
|||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Ask for beneficiary"),
|
label=_("Ask for beneficiary"),
|
||||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||||
required=False
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'invoice_address_custom_field': {
|
'invoice_address_custom_field': {
|
||||||
@@ -421,7 +457,6 @@ DEFAULTS = {
|
|||||||
widget_kwargs={'attrs': {
|
widget_kwargs={'attrs': {
|
||||||
'rows': 3,
|
'rows': 3,
|
||||||
}},
|
}},
|
||||||
required=False,
|
|
||||||
label=_("Guidance text"),
|
label=_("Guidance text"),
|
||||||
help_text=_("This text will be shown above the payment options. You can explain the choices to the user here, "
|
help_text=_("This text will be shown above the payment options. You can explain the choices to the user here, "
|
||||||
"if you want.")
|
"if you want.")
|
||||||
@@ -441,7 +476,6 @@ DEFAULTS = {
|
|||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Set payment term"),
|
label=_("Set payment term"),
|
||||||
widget=forms.RadioSelect,
|
widget=forms.RadioSelect,
|
||||||
required=True,
|
|
||||||
choices=(
|
choices=(
|
||||||
('days', _("in days")),
|
('days', _("in days")),
|
||||||
('minutes', _("in minutes"))
|
('minutes', _("in minutes"))
|
||||||
@@ -488,7 +522,6 @@ DEFAULTS = {
|
|||||||
widget=forms.CheckboxInput(
|
widget=forms.CheckboxInput(
|
||||||
attrs={
|
attrs={
|
||||||
'data-display-dependency': '#id_payment_term_mode_0',
|
'data-display-dependency': '#id_payment_term_mode_0',
|
||||||
'data-required-if': '#id_payment_term_mode_0'
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -541,6 +574,18 @@ DEFAULTS = {
|
|||||||
"the pool and can be ordered by other people."),
|
"the pool and can be ordered by other people."),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'payment_pending_hidden': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Hide "payment pending" state on customer-facing pages'),
|
||||||
|
help_text=_("The payment instructions panel will still be shown to the primary customer, but no indication "
|
||||||
|
"of missing payment will be visible on the ticket pages of attendees who did not buy the ticket "
|
||||||
|
"themselves.")
|
||||||
|
)
|
||||||
|
},
|
||||||
'payment_giftcard__enabled': {
|
'payment_giftcard__enabled': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool
|
'type': bool
|
||||||
@@ -808,6 +853,20 @@ DEFAULTS = {
|
|||||||
label=_("Default language"),
|
label=_("Default language"),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'region': {
|
||||||
|
'default': None,
|
||||||
|
'type': str,
|
||||||
|
'form_class': forms.ChoiceField,
|
||||||
|
'serializer_class': serializers.ChoiceField,
|
||||||
|
'serializer_kwargs': lambda: dict(**country_choice_kwargs()),
|
||||||
|
'form_kwargs': lambda: dict(
|
||||||
|
label=_('Region'),
|
||||||
|
help_text=_('Will be used to determine date and time formatting as well as default country for customer '
|
||||||
|
'addresses and phone numbers. For formatting, this takes less priority than the language and '
|
||||||
|
'is therefore mostly relevant for languages used in different regions globally (like English).'),
|
||||||
|
**country_choice_kwargs()
|
||||||
|
),
|
||||||
|
},
|
||||||
'show_dates_on_frontpage': {
|
'show_dates_on_frontpage': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -990,7 +1049,16 @@ DEFAULTS = {
|
|||||||
},
|
},
|
||||||
'event_list_availability': {
|
'event_list_availability': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool
|
'type': bool,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Show availability in event overviews'),
|
||||||
|
help_text=_('If checked, the list of events will show if events are sold out. This might '
|
||||||
|
'make for longer page loading times if you have lots of events and the shown status might be out '
|
||||||
|
'of date for up to two minutes.'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'event_list_type': {
|
'event_list_type': {
|
||||||
'default': 'list',
|
'default': 'list',
|
||||||
@@ -1157,6 +1225,21 @@ DEFAULTS = {
|
|||||||
"e.g. to explain choosing a lower refund will help your organization.")
|
"e.g. to explain choosing a lower refund will help your organization.")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'cancel_allow_user_paid_adjust_fees_step': {
|
||||||
|
'default': None,
|
||||||
|
'type': Decimal,
|
||||||
|
'form_class': forms.DecimalField,
|
||||||
|
'serializer_class': serializers.DecimalField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
max_digits=10, decimal_places=2
|
||||||
|
),
|
||||||
|
'form_kwargs': dict(
|
||||||
|
max_digits=10, decimal_places=2,
|
||||||
|
label=_("Step size for reduction amount"),
|
||||||
|
help_text=_('By default, customers can choose an arbitrary amount for you to keep. If you set this to e.g. '
|
||||||
|
'10, they will only be able to choose values in increments of 10.')
|
||||||
|
)
|
||||||
|
},
|
||||||
'cancel_allow_user_paid_require_approval': {
|
'cancel_allow_user_paid_require_approval': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -1599,26 +1682,106 @@ Your {event} team"""))
|
|||||||
'primary_color': {
|
'primary_color': {
|
||||||
'default': settings.PRETIX_PRIMARY_COLOR,
|
'default': settings.PRETIX_PRIMARY_COLOR,
|
||||||
'type': str,
|
'type': str,
|
||||||
|
'form_class': forms.CharField,
|
||||||
|
'serializer_class': serializers.CharField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Primary color"),
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||||
|
),
|
||||||
},
|
},
|
||||||
'theme_color_success': {
|
'theme_color_success': {
|
||||||
'default': '#50A167',
|
'default': '#50A167',
|
||||||
'type': str
|
'type': str,
|
||||||
|
'form_class': forms.CharField,
|
||||||
|
'serializer_class': serializers.CharField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Accent color for success"),
|
||||||
|
help_text=_("We strongly suggest to use a shade of green."),
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||||
|
),
|
||||||
},
|
},
|
||||||
'theme_color_danger': {
|
'theme_color_danger': {
|
||||||
'default': '#D36060',
|
'default': '#D36060',
|
||||||
'type': str
|
'type': str,
|
||||||
|
'form_class': forms.CharField,
|
||||||
|
'serializer_class': serializers.CharField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Accent color for errors"),
|
||||||
|
help_text=_("We strongly suggest to use a shade of red."),
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||||
|
),
|
||||||
},
|
},
|
||||||
'theme_color_background': {
|
'theme_color_background': {
|
||||||
'default': '#FFFFFF',
|
'default': '#FFFFFF',
|
||||||
'type': str
|
'type': str,
|
||||||
|
'form_class': forms.CharField,
|
||||||
|
'serializer_class': serializers.CharField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Page background color"),
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
widget=forms.TextInput(attrs={'class': 'colorpickerfield no-contrast'})
|
||||||
|
),
|
||||||
},
|
},
|
||||||
'theme_round_borders': {
|
'theme_round_borders': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Use round edges"),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'primary_font': {
|
'primary_font': {
|
||||||
'default': 'Open Sans',
|
'default': 'Open Sans',
|
||||||
'type': str
|
'type': str,
|
||||||
|
'form_class': forms.ChoiceField,
|
||||||
|
'serializer_class': serializers.ChoiceField,
|
||||||
|
'serializer_kwargs': lambda: dict(**primary_font_kwargs()),
|
||||||
|
'form_kwargs': lambda: dict(
|
||||||
|
label=_('Font'),
|
||||||
|
help_text=_('Only respected by modern browsers.'),
|
||||||
|
widget=FontSelect,
|
||||||
|
**primary_font_kwargs()
|
||||||
|
),
|
||||||
},
|
},
|
||||||
'presale_css_file': {
|
'presale_css_file': {
|
||||||
'default': None,
|
'default': None,
|
||||||
@@ -1638,31 +1801,116 @@ Your {event} team"""))
|
|||||||
},
|
},
|
||||||
'logo_image': {
|
'logo_image': {
|
||||||
'default': None,
|
'default': None,
|
||||||
'type': File
|
'type': File,
|
||||||
|
'form_class': ExtFileField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Header image'),
|
||||||
|
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||||
|
max_size=10 * 1024 * 1024,
|
||||||
|
help_text=_('If you provide a logo image, we will by default not show your event name and date '
|
||||||
|
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||||
|
'can increase the size with the setting below. We recommend not using small details on the picture '
|
||||||
|
'as it will be resized on smaller screens.')
|
||||||
|
),
|
||||||
|
'serializer_class': UploadedFileField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
allowed_types=[
|
||||||
|
'image/png', 'image/jpeg', 'image/gif'
|
||||||
|
],
|
||||||
|
max_size=10 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
|
||||||
},
|
},
|
||||||
'logo_image_large': {
|
'logo_image_large': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Use header image in its full size'),
|
||||||
|
help_text=_('We recommend to upload a picture at least 1170 pixels wide.'),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'logo_show_title': {
|
'logo_show_title': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Show event title even if a header image is present'),
|
||||||
|
help_text=_('The title will only be shown on the event front page.'),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'organizer_logo_image': {
|
'organizer_logo_image': {
|
||||||
'default': None,
|
'default': None,
|
||||||
'type': File
|
'type': File,
|
||||||
|
'form_class': ExtFileField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Header image'),
|
||||||
|
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||||
|
max_size=10 * 1024 * 1024,
|
||||||
|
help_text=_('If you provide a logo image, we will by default not show your organization name '
|
||||||
|
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||||
|
'can increase the size with the setting below. We recommend not using small details on the picture '
|
||||||
|
'as it will be resized on smaller screens.')
|
||||||
|
),
|
||||||
|
'serializer_class': UploadedFileField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
allowed_types=[
|
||||||
|
'image/png', 'image/jpeg', 'image/gif'
|
||||||
|
],
|
||||||
|
max_size=10 * 1024 * 1024,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'organizer_logo_image_large': {
|
'organizer_logo_image_large': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Use header image in its full size'),
|
||||||
|
help_text=_('We recommend to upload a picture at least 1170 pixels wide.'),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'og_image': {
|
'og_image': {
|
||||||
'default': None,
|
'default': None,
|
||||||
'type': File
|
'type': File,
|
||||||
|
'form_class': ExtFileField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Social media image'),
|
||||||
|
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||||
|
max_size=10 * 1024 * 1024,
|
||||||
|
help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. '
|
||||||
|
'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like '
|
||||||
|
'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good '
|
||||||
|
'only the center square is shown. If you do not fill this, we will use the logo given above.')
|
||||||
|
),
|
||||||
|
'serializer_class': UploadedFileField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
allowed_types=[
|
||||||
|
'image/png', 'image/jpeg', 'image/gif'
|
||||||
|
],
|
||||||
|
max_size=10 * 1024 * 1024,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'invoice_logo_image': {
|
'invoice_logo_image': {
|
||||||
'default': None,
|
'default': None,
|
||||||
'type': File
|
'type': File,
|
||||||
|
'form_class': ExtFileField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Logo image'),
|
||||||
|
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||||
|
required=False,
|
||||||
|
max_size=10 * 1024 * 1024,
|
||||||
|
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
|
||||||
|
),
|
||||||
|
'serializer_class': UploadedFileField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
allowed_types=[
|
||||||
|
'image/png', 'image/jpeg', 'image/gif'
|
||||||
|
],
|
||||||
|
max_size=10 * 1024 * 1024,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'frontpage_text': {
|
'frontpage_text': {
|
||||||
'default': '',
|
'default': '',
|
||||||
@@ -1713,6 +1961,30 @@ Your {event} team"""))
|
|||||||
"how to obtain a voucher code.")
|
"how to obtain a voucher code.")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'attendee_data_explanation_text': {
|
||||||
|
'default': '',
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'serializer_class': I18nField,
|
||||||
|
'form_class': I18nFormField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Attendee data explanation"),
|
||||||
|
widget=I18nTextarea,
|
||||||
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
|
help_text=_("This text will be shown above the questions asked for every admission product. You can use it e.g. to explain "
|
||||||
|
"why you need information from them.")
|
||||||
|
)
|
||||||
|
},
|
||||||
|
'checkout_phone_helptext': {
|
||||||
|
'default': '',
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'serializer_class': I18nField,
|
||||||
|
'form_class': I18nFormField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Help text of the phone number field"),
|
||||||
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
|
widget=I18nTextarea
|
||||||
|
)
|
||||||
|
},
|
||||||
'checkout_email_helptext': {
|
'checkout_email_helptext': {
|
||||||
'default': LazyI18nString.from_gettext(gettext_noop(
|
'default': LazyI18nString.from_gettext(gettext_noop(
|
||||||
'Make sure to enter a valid email address. We will send you an order '
|
'Make sure to enter a valid email address. We will send you an order '
|
||||||
@@ -1733,11 +2005,26 @@ Your {event} team"""))
|
|||||||
},
|
},
|
||||||
'organizer_info_text': {
|
'organizer_info_text': {
|
||||||
'default': '',
|
'default': '',
|
||||||
'type': LazyI18nString
|
'type': LazyI18nString,
|
||||||
|
'serializer_class': I18nField,
|
||||||
|
'form_class': I18nFormField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Info text'),
|
||||||
|
widget=I18nTextarea,
|
||||||
|
help_text=_('Not displayed anywhere by default, but if you want to, you can use this e.g. in ticket templates.')
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'event_team_provisioning': {
|
'event_team_provisioning': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Allow creating a new team during event creation'),
|
||||||
|
help_text=_('Users that do not have access to all events under this organizer, must select one of their teams '
|
||||||
|
'to have access to the created event. This setting allows users to create an event-specified team'
|
||||||
|
' on-the-fly, even when they do not have \"Can change teams and permissions\" permission.'),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'update_check_ack': {
|
'update_check_ack': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
@@ -1779,6 +2066,10 @@ Your {event} team"""))
|
|||||||
'default': None,
|
'default': None,
|
||||||
'type': str
|
'type': str
|
||||||
},
|
},
|
||||||
|
'mapquest_apikey': {
|
||||||
|
'default': None,
|
||||||
|
'type': str
|
||||||
|
},
|
||||||
'leaflet_tiles': {
|
'leaflet_tiles': {
|
||||||
'default': None,
|
'default': None,
|
||||||
'type': str
|
'type': str
|
||||||
@@ -1811,13 +2102,51 @@ Your {event} team"""))
|
|||||||
# When adding a new ordering, remember to also define it in the event model
|
# When adding a new ordering, remember to also define it in the event model
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'organizer_link_back': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Link back to organizer overview on all event pages'),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
'organizer_homepage_text': {
|
||||||
|
'default': '',
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'serializer_class': I18nField,
|
||||||
|
'form_class': I18nFormField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Homepage text'),
|
||||||
|
widget=I18nTextarea,
|
||||||
|
help_text=_('This will be displayed on the organizer homepage.')
|
||||||
|
)
|
||||||
|
},
|
||||||
'name_scheme': {
|
'name_scheme': {
|
||||||
'default': 'full',
|
'default': 'full',
|
||||||
'type': str
|
'type': str
|
||||||
},
|
},
|
||||||
'giftcard_length': {
|
'giftcard_length': {
|
||||||
'default': settings.ENTROPY['giftcard_secret'],
|
'default': settings.ENTROPY['giftcard_secret'],
|
||||||
'type': int
|
'type': int,
|
||||||
|
'form_class': forms.IntegerField,
|
||||||
|
'serializer_class': serializers.IntegerField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Length of gift card codes'),
|
||||||
|
help_text=_('The system generates by default {}-character long gift card codes. However, if a different length '
|
||||||
|
'is required, it can be set here.'.format(settings.ENTROPY['giftcard_secret'])),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
'giftcard_expiry_years': {
|
||||||
|
'default': None,
|
||||||
|
'type': int,
|
||||||
|
'form_class': forms.IntegerField,
|
||||||
|
'serializer_class': serializers.IntegerField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Validity of gift card codes in years'),
|
||||||
|
help_text=_('If you set a number here, gift cards will by default expire at the end of the year after this '
|
||||||
|
'many years. If you keep it empty, gift cards do not have an explicit expiry date.'),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
'seating_choice': {
|
'seating_choice': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
@@ -1853,6 +2182,10 @@ Your {event} team"""))
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SETTINGS_AFFECTING_CSS = {
|
||||||
|
'primary_color', 'theme_color_success', 'theme_color_danger', 'primary_font',
|
||||||
|
'theme_color_background', 'theme_round_borders'
|
||||||
|
}
|
||||||
PERSON_NAME_TITLE_GROUPS = OrderedDict([
|
PERSON_NAME_TITLE_GROUPS = OrderedDict([
|
||||||
('english_common', (_('Most common English titles'), (
|
('english_common', (_('Most common English titles'), (
|
||||||
'Mr',
|
'Mr',
|
||||||
@@ -2052,6 +2385,30 @@ PERSON_NAME_SCHEMES = OrderedDict([
|
|||||||
'_scheme': 'salutation_title_given_family',
|
'_scheme': 'salutation_title_given_family',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
('salutation_title_given_family_degree', {
|
||||||
|
'fields': (
|
||||||
|
('salutation', pgettext_lazy('person_name', 'Salutation'), 1),
|
||||||
|
('title', pgettext_lazy('person_name', 'Title'), 1),
|
||||||
|
('given_name', _('Given name'), 2),
|
||||||
|
('family_name', _('Family name'), 2),
|
||||||
|
('degree', pgettext_lazy('person_name', 'Degree (after name)'), 2),
|
||||||
|
),
|
||||||
|
'concatenation': lambda d: (
|
||||||
|
' '.join(
|
||||||
|
str(p) for p in (d.get(key, '') for key in ["title", "given_name", "family_name"]) if p
|
||||||
|
) +
|
||||||
|
str((', ' if d.get('degree') else '')) +
|
||||||
|
str(d.get('degree', ''))
|
||||||
|
),
|
||||||
|
'sample': {
|
||||||
|
'salutation': pgettext_lazy('person_name_sample', 'Mr'),
|
||||||
|
'title': pgettext_lazy('person_name_sample', 'Dr'),
|
||||||
|
'given_name': pgettext_lazy('person_name_sample', 'John'),
|
||||||
|
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
|
||||||
|
'degree': pgettext_lazy('person_name_sample', 'MA'),
|
||||||
|
'_scheme': 'salutation_title_given_family_degree',
|
||||||
|
},
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
COUNTRIES_WITH_STATE_IN_ADDRESS = {
|
COUNTRIES_WITH_STATE_IN_ADDRESS = {
|
||||||
# Source: http://www.bitboost.com/ref/international-address-formats.html
|
# Source: http://www.bitboost.com/ref/international-address-formats.html
|
||||||
@@ -2143,7 +2500,8 @@ class SettingsSandbox:
|
|||||||
self._event.settings.set(self._convert_key(key), value)
|
self._event.settings.set(self._convert_key(key), value)
|
||||||
|
|
||||||
|
|
||||||
def validate_settings(event, settings_dict):
|
def validate_event_settings(event, settings_dict):
|
||||||
|
from pretix.base.models import Event
|
||||||
from pretix.base.signals import validate_event_settings
|
from pretix.base.signals import validate_event_settings
|
||||||
|
|
||||||
if 'locales' in settings_dict and settings_dict['locale'] not in settings_dict['locales']:
|
if 'locales' in settings_dict and settings_dict['locale'] not in settings_dict['locales']:
|
||||||
@@ -2174,4 +2532,20 @@ def validate_settings(event, settings_dict):
|
|||||||
'payment_term_last': _('The last payment date cannot be before the end of presale.')
|
'payment_term_last': _('The last payment date cannot be before the end of presale.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if isinstance(event, Event):
|
||||||
validate_event_settings.send(sender=event, settings_dict=settings_dict)
|
validate_event_settings.send(sender=event, settings_dict=settings_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_organizer_settings(organizer, settings_dict):
|
||||||
|
# This is not doing anything for the time being.
|
||||||
|
# But earlier we called validate_event_settings for the organizer, too - and that didn't do anything for
|
||||||
|
# organizer-settings either.
|
||||||
|
#
|
||||||
|
# N.B.: When actually fleshing out this stub, adding it to the OrganizerUpdateForm should be considered.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def global_settings_object(holder):
|
||||||
|
if not hasattr(holder, '_global_settings_object'):
|
||||||
|
holder._global_settings_object = GlobalSettingsObject()
|
||||||
|
return holder._global_settings_object
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from pretix.base.models import (
|
|||||||
)
|
)
|
||||||
from pretix.base.services.invoices import invoice_pdf_task
|
from pretix.base.services.invoices import invoice_pdf_task
|
||||||
from pretix.base.signals import register_data_shredders
|
from pretix.base.signals import register_data_shredders
|
||||||
|
from pretix.helpers.json import CustomJSONEncoder
|
||||||
|
|
||||||
|
|
||||||
class ShredError(LazyLocaleException):
|
class ShredError(LazyLocaleException):
|
||||||
@@ -81,6 +82,14 @@ class BaseDataShredder:
|
|||||||
"""
|
"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def require_download_confirmation(self):
|
||||||
|
"""
|
||||||
|
Indicates whether the data of this shredder needs to be downloaded, before it is actually shredded. By default
|
||||||
|
this value is equal to the tax relevant flag.
|
||||||
|
"""
|
||||||
|
return self.tax_relevant
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def verbose_name(self) -> str:
|
def verbose_name(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -121,6 +130,31 @@ def shred_log_fields(logentry, banlist=None, whitelist=None):
|
|||||||
logentry.save(update_fields=['data', 'shredded'])
|
logentry.save(update_fields=['data', 'shredded'])
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneNumberShredder(BaseDataShredder):
|
||||||
|
verbose_name = _('Phone numbers')
|
||||||
|
identifier = 'phone_numbers'
|
||||||
|
description = _('This will remove all phone numbers from orders.')
|
||||||
|
|
||||||
|
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||||
|
yield 'phone-by-order.json', 'application/json', json.dumps({
|
||||||
|
o.code: o.phone for o in self.event.orders.filter(phone__isnull=False)
|
||||||
|
}, cls=CustomJSONEncoder, indent=4)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def shred_data(self):
|
||||||
|
for o in self.event.orders.all():
|
||||||
|
o.phone = None
|
||||||
|
d = o.meta_info_data
|
||||||
|
if d:
|
||||||
|
if 'contact_form_data' in d and 'phone' in d['contact_form_data']:
|
||||||
|
del d['contact_form_data']['phone']
|
||||||
|
o.meta_info = json.dumps(d)
|
||||||
|
o.save(update_fields=['meta_info', 'phone'])
|
||||||
|
|
||||||
|
for le in self.event.logentry_set.filter(action_type="pretix.event.order.phone.changed"):
|
||||||
|
shred_log_fields(le, banlist=['old_phone', 'new_phone'])
|
||||||
|
|
||||||
|
|
||||||
class EmailAddressShredder(BaseDataShredder):
|
class EmailAddressShredder(BaseDataShredder):
|
||||||
verbose_name = _('E-mails')
|
verbose_name = _('E-mails')
|
||||||
identifier = 'order_emails'
|
identifier = 'order_emails'
|
||||||
@@ -372,9 +406,10 @@ class PaymentInfoShredder(BaseDataShredder):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(register_data_shredders, dispatch_uid="shredders_builtin")
|
@receiver(register_data_shredders, dispatch_uid="shredders_builtin")
|
||||||
def register_payment_provider(sender, **kwargs):
|
def register_core_shredders(sender, **kwargs):
|
||||||
return [
|
return [
|
||||||
EmailAddressShredder,
|
EmailAddressShredder,
|
||||||
|
PhoneNumberShredder,
|
||||||
AttendeeInfoShredder,
|
AttendeeInfoShredder,
|
||||||
InvoiceAddressShredder,
|
InvoiceAddressShredder,
|
||||||
QuestionAnswerShredder,
|
QuestionAnswerShredder,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||||
</p>
|
</p>
|
||||||
{% if request.user.is_staff and not staff_session %}
|
{% if request.user.is_staff and not staff_session %}
|
||||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
||||||
<p>
|
<p>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||||
</p>
|
</p>
|
||||||
{% if request.user.is_staff and not staff_session %}
|
{% if request.user.is_staff and not staff_session %}
|
||||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
||||||
<p>
|
<p>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||||
|
|||||||
22
src/pretix/base/templatetags/phone_format.py
Normal file
22
src/pretix/base/templatetags/phone_format.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from django import template
|
||||||
|
from phonenumber_field.phonenumber import PhoneNumber
|
||||||
|
from phonenumbers import NumberParseException
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter("phone_format")
|
||||||
|
def phone_format(value: str):
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return PhoneNumber.from_string(value).as_international
|
||||||
|
except NumberParseException:
|
||||||
|
return value
|
||||||
|
|
||||||
|
if isinstance(value, PhoneNumber) and value.national_number:
|
||||||
|
return value.as_international
|
||||||
|
|
||||||
|
return str(value)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import bleach
|
import bleach
|
||||||
@@ -71,6 +72,10 @@ EMAIL_RE = build_email_re(tlds=sorted(tld_set, key=len, reverse=True))
|
|||||||
|
|
||||||
|
|
||||||
def safelink_callback(attrs, new=False):
|
def safelink_callback(attrs, new=False):
|
||||||
|
"""
|
||||||
|
Makes sure that all links to a different domain are passed through a redirection handler
|
||||||
|
to ensure there's no passing of referers with secrets inside them.
|
||||||
|
"""
|
||||||
url = attrs.get((None, 'href'), '/')
|
url = attrs.get((None, 'href'), '/')
|
||||||
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||||
signer = signing.Signer(salt='safe-redirect')
|
signer = signing.Signer(salt='safe-redirect')
|
||||||
@@ -80,7 +85,42 @@ def safelink_callback(attrs, new=False):
|
|||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
def truelink_callback(attrs, new=False):
|
||||||
|
"""
|
||||||
|
Tries to prevent "phishing" attacks in which a link looks like it points to a safe place but instead
|
||||||
|
points somewhere else, e.g.
|
||||||
|
|
||||||
|
<a href="https://evilsite.com">https://google.com</a>
|
||||||
|
|
||||||
|
At the same time, custom texts are still allowed:
|
||||||
|
|
||||||
|
<a href="https://maps.google.com">Get to the event</a>
|
||||||
|
|
||||||
|
Suffixes are also allowed:
|
||||||
|
|
||||||
|
<a href="https://maps.google.com/location/foo">https://maps.google.com</a>
|
||||||
|
"""
|
||||||
|
text = re.sub(r'[^a-zA-Z0-9.\-/_]', '', attrs.get('_text')) # clean up link text
|
||||||
|
href_url = urllib.parse.urlparse(attrs[None, 'href'])
|
||||||
|
if URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
||||||
|
# link text looks like a url
|
||||||
|
if text.startswith('//'):
|
||||||
|
text = 'https:' + text
|
||||||
|
elif not text.startswith('http'):
|
||||||
|
text = 'https://' + text
|
||||||
|
|
||||||
|
text_url = urllib.parse.urlparse(text)
|
||||||
|
if text_url.netloc != href_url.netloc or not href_url.path.startswith(href_url.path):
|
||||||
|
# link text contains an URL that has a different base than the actual URL
|
||||||
|
attrs['_text'] = attrs[None, 'href']
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
def abslink_callback(attrs, new=False):
|
def abslink_callback(attrs, new=False):
|
||||||
|
"""
|
||||||
|
Makes sure that all links will be absolute links and will be opened in a new page with no
|
||||||
|
window.opener attribute.
|
||||||
|
"""
|
||||||
url = attrs.get((None, 'href'), '/')
|
url = attrs.get((None, 'href'), '/')
|
||||||
if not url.startswith('mailto:') and not url.startswith('tel:'):
|
if not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||||
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)
|
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)
|
||||||
@@ -93,6 +133,7 @@ def markdown_compile_email(source):
|
|||||||
linker = bleach.Linker(
|
linker = bleach.Linker(
|
||||||
url_re=URL_RE,
|
url_re=URL_RE,
|
||||||
email_re=EMAIL_RE,
|
email_re=EMAIL_RE,
|
||||||
|
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||||
parse_email=True
|
parse_email=True
|
||||||
)
|
)
|
||||||
return linker.linkify(bleach.clean(
|
return linker.linkify(bleach.clean(
|
||||||
@@ -145,7 +186,7 @@ def rich_text(text: str, **kwargs):
|
|||||||
linker = bleach.Linker(
|
linker = bleach.Linker(
|
||||||
url_re=URL_RE,
|
url_re=URL_RE,
|
||||||
email_re=EMAIL_RE,
|
email_re=EMAIL_RE,
|
||||||
callbacks=DEFAULT_CALLBACKS + ([safelink_callback] if kwargs.get('safelinks', True) else [abslink_callback]),
|
callbacks=DEFAULT_CALLBACKS + ([truelink_callback, safelink_callback] if kwargs.get('safelinks', True) else [truelink_callback, abslink_callback]),
|
||||||
parse_email=True
|
parse_email=True
|
||||||
)
|
)
|
||||||
body_md = linker.linkify(markdown_compile(text))
|
body_md = linker.linkify(markdown_compile(text))
|
||||||
@@ -161,7 +202,7 @@ def rich_text_snippet(text: str, **kwargs):
|
|||||||
linker = bleach.Linker(
|
linker = bleach.Linker(
|
||||||
url_re=URL_RE,
|
url_re=URL_RE,
|
||||||
email_re=EMAIL_RE,
|
email_re=EMAIL_RE,
|
||||||
callbacks=DEFAULT_CALLBACKS + ([safelink_callback] if kwargs.get('safelinks', True) else [abslink_callback]),
|
callbacks=DEFAULT_CALLBACKS + ([truelink_callback, safelink_callback] if kwargs.get('safelinks', True) else [truelink_callback, abslink_callback]),
|
||||||
parse_email=True
|
parse_email=True
|
||||||
)
|
)
|
||||||
body_md = linker.linkify(markdown_compile(text, snippet=True))
|
body_md = linker.linkify(markdown_compile(text, snippet=True))
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ class DownloadView(TemplateView):
|
|||||||
@cached_property
|
@cached_property
|
||||||
def object(self) -> CachedFile:
|
def object(self) -> CachedFile:
|
||||||
try:
|
try:
|
||||||
return get_object_or_404(CachedFile, id=self.kwargs['id'])
|
o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
|
||||||
|
if o.session_key:
|
||||||
|
if o.session_key != self.request.session.session_key:
|
||||||
|
raise Http404()
|
||||||
|
return o
|
||||||
except ValueError: # Invalid URLs
|
except ValueError: # Invalid URLs
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ class CachedFileField(ExtFileField):
|
|||||||
cf = CachedFile.objects.create(
|
cf = CachedFile.objects.create(
|
||||||
expires=now() + datetime.timedelta(days=1),
|
expires=now() + datetime.timedelta(days=1),
|
||||||
date=now(),
|
date=now(),
|
||||||
|
web_download=True,
|
||||||
filename=data.name,
|
filename=data.name,
|
||||||
type=data.content_type,
|
type=data.content_type,
|
||||||
)
|
)
|
||||||
@@ -218,6 +219,7 @@ class CachedFileField(ExtFileField):
|
|||||||
if isinstance(data, File):
|
if isinstance(data, File):
|
||||||
cf = CachedFile.objects.create(
|
cf = CachedFile.objects.create(
|
||||||
expires=now() + datetime.timedelta(days=1),
|
expires=now() + datetime.timedelta(days=1),
|
||||||
|
web_download=True,
|
||||||
date=now(),
|
date=now(),
|
||||||
filename=data.name,
|
filename=data.name,
|
||||||
type=data.content_type,
|
type=data.content_type,
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ from urllib.parse import urlencode, urlparse
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import RegexValidator, validate_email
|
from django.core.validators import validate_email
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.forms import formset_factory
|
from django.forms import CheckboxSelectMultiple, formset_factory
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.timezone import get_current_timezone_name
|
from django.utils.timezone import get_current_timezone_name
|
||||||
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
|
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
|
||||||
from django_countries import Countries
|
|
||||||
from django_countries.fields import LazyTypedChoiceField
|
from django_countries.fields import LazyTypedChoiceField
|
||||||
from i18nfield.forms import (
|
from i18nfield.forms import (
|
||||||
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
|
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
|
||||||
@@ -25,17 +24,17 @@ from pretix.base.models import Event, Organizer, TaxRule, Team
|
|||||||
from pretix.base.models.event import EventMetaValue, SubEvent
|
from pretix.base.models.event import EventMetaValue, SubEvent
|
||||||
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||||
from pretix.base.settings import (
|
from pretix.base.settings import (
|
||||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_settings,
|
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||||
)
|
)
|
||||||
from pretix.control.forms import (
|
from pretix.control.forms import (
|
||||||
ExtFileField, FontSelect, MultipleLanguagesWidget, SlugWidget,
|
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||||
SplitDateTimeField, SplitDateTimePickerWidget,
|
SplitDateTimePickerWidget,
|
||||||
)
|
)
|
||||||
from pretix.control.forms.widgets import Select2
|
from pretix.control.forms.widgets import Select2
|
||||||
|
from pretix.helpers.countries import CachedCountries
|
||||||
from pretix.multidomain.models import KnownDomain
|
from pretix.multidomain.models import KnownDomain
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
from pretix.plugins.banktransfer.payment import BankTransfer
|
from pretix.plugins.banktransfer.payment import BankTransfer
|
||||||
from pretix.presale.style import get_fonts
|
|
||||||
|
|
||||||
|
|
||||||
class EventWizardFoundationForm(forms.Form):
|
class EventWizardFoundationForm(forms.Form):
|
||||||
@@ -311,6 +310,16 @@ class EventUpdateForm(I18nModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text=_('You need to configure the custom domain in the webserver beforehand.')
|
help_text=_('You need to configure the custom domain in the webserver beforehand.')
|
||||||
)
|
)
|
||||||
|
self.fields['sales_channels'] = forms.MultipleChoiceField(
|
||||||
|
label=self.fields['sales_channels'].label,
|
||||||
|
help_text=self.fields['sales_channels'].help_text,
|
||||||
|
required=self.fields['sales_channels'].required,
|
||||||
|
initial=self.fields['sales_channels'].initial,
|
||||||
|
choices=(
|
||||||
|
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
|
||||||
|
),
|
||||||
|
widget=forms.CheckboxSelectMultiple
|
||||||
|
)
|
||||||
|
|
||||||
def clean_domain(self):
|
def clean_domain(self):
|
||||||
d = self.cleaned_data['domain']
|
d = self.cleaned_data['domain']
|
||||||
@@ -367,6 +376,7 @@ class EventUpdateForm(I18nModelForm):
|
|||||||
'location',
|
'location',
|
||||||
'geo_lat',
|
'geo_lat',
|
||||||
'geo_lon',
|
'geo_lon',
|
||||||
|
'sales_channels'
|
||||||
]
|
]
|
||||||
field_classes = {
|
field_classes = {
|
||||||
'date_from': SplitDateTimeField,
|
'date_from': SplitDateTimeField,
|
||||||
@@ -381,6 +391,7 @@ class EventUpdateForm(I18nModelForm):
|
|||||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
|
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
|
||||||
'presale_start': SplitDateTimePickerWidget(),
|
'presale_start': SplitDateTimePickerWidget(),
|
||||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
|
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
|
||||||
|
'sales_channels': CheckboxSelectMultiple(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -401,87 +412,6 @@ class EventSettingsForm(SettingsForm):
|
|||||||
"restrict the set of selectable titles."),
|
"restrict the set of selectable titles."),
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
logo_image = ExtFileField(
|
|
||||||
label=_('Header image'),
|
|
||||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
|
||||||
required=False,
|
|
||||||
max_size=10 * 1024 * 1024,
|
|
||||||
help_text=_('If you provide a logo image, we will by default not show your event name and date '
|
|
||||||
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
|
||||||
'can increase the size with the setting below. We recommend not using small details on the picture '
|
|
||||||
'as it will be resized on smaller screens.')
|
|
||||||
)
|
|
||||||
logo_image_large = forms.BooleanField(
|
|
||||||
label=_('Use header image in its full size'),
|
|
||||||
help_text=_('We recommend to upload a picture at least 1170 pixels wide.'),
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
logo_show_title = forms.BooleanField(
|
|
||||||
label=_('Show event title even if a header image is present'),
|
|
||||||
help_text=_('The title will only be shown on the event front page.'),
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
og_image = ExtFileField(
|
|
||||||
label=_('Social media image'),
|
|
||||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
|
||||||
required=False,
|
|
||||||
max_size=10 * 1024 * 1024,
|
|
||||||
help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. '
|
|
||||||
'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like '
|
|
||||||
'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good '
|
|
||||||
'only the center square is shown. If you do not fill this, we will use the logo given above.')
|
|
||||||
)
|
|
||||||
primary_color = forms.CharField(
|
|
||||||
label=_("Primary color"),
|
|
||||||
required=False,
|
|
||||||
validators=[
|
|
||||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
|
||||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
|
||||||
],
|
|
||||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
|
||||||
)
|
|
||||||
theme_color_success = forms.CharField(
|
|
||||||
label=_("Accent color for success"),
|
|
||||||
help_text=_("We strongly suggest to use a shade of green."),
|
|
||||||
required=False,
|
|
||||||
validators=[
|
|
||||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
|
||||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
|
||||||
],
|
|
||||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
|
||||||
)
|
|
||||||
theme_color_danger = forms.CharField(
|
|
||||||
label=_("Accent color for errors"),
|
|
||||||
help_text=_("We strongly suggest to use a dark shade of red."),
|
|
||||||
required=False,
|
|
||||||
validators=[
|
|
||||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
|
||||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
|
||||||
],
|
|
||||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
|
||||||
)
|
|
||||||
theme_color_background = forms.CharField(
|
|
||||||
label=_("Page background color"),
|
|
||||||
required=False,
|
|
||||||
validators=[
|
|
||||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
|
||||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
|
||||||
|
|
||||||
],
|
|
||||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield no-contrast'})
|
|
||||||
)
|
|
||||||
theme_round_borders = forms.BooleanField(
|
|
||||||
label=_("Use round edges"),
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
primary_font = forms.ChoiceField(
|
|
||||||
label=_('Font'),
|
|
||||||
choices=[
|
|
||||||
('Open Sans', 'Open Sans')
|
|
||||||
],
|
|
||||||
widget=FontSelect,
|
|
||||||
help_text=_('Only respected by modern browsers.')
|
|
||||||
)
|
|
||||||
|
|
||||||
auto_fields = [
|
auto_fields = [
|
||||||
'imprint_url',
|
'imprint_url',
|
||||||
@@ -496,6 +426,7 @@ class EventSettingsForm(SettingsForm):
|
|||||||
'presale_start_show_date',
|
'presale_start_show_date',
|
||||||
'locales',
|
'locales',
|
||||||
'locale',
|
'locale',
|
||||||
|
'region',
|
||||||
'show_quota_left',
|
'show_quota_left',
|
||||||
'waiting_list_enabled',
|
'waiting_list_enabled',
|
||||||
'waiting_list_hours',
|
'waiting_list_hours',
|
||||||
@@ -518,18 +449,57 @@ class EventSettingsForm(SettingsForm):
|
|||||||
'attendee_company_required',
|
'attendee_company_required',
|
||||||
'attendee_addresses_asked',
|
'attendee_addresses_asked',
|
||||||
'attendee_addresses_required',
|
'attendee_addresses_required',
|
||||||
|
'attendee_data_explanation_text',
|
||||||
|
'order_phone_asked',
|
||||||
|
'order_phone_required',
|
||||||
|
'checkout_phone_helptext',
|
||||||
'banner_text',
|
'banner_text',
|
||||||
'banner_text_bottom',
|
'banner_text_bottom',
|
||||||
'order_email_asked_twice',
|
'order_email_asked_twice',
|
||||||
'last_order_modification_date',
|
'last_order_modification_date',
|
||||||
'checkout_show_copy_answers_button',
|
'checkout_show_copy_answers_button',
|
||||||
|
'primary_color',
|
||||||
|
'theme_color_success',
|
||||||
|
'theme_color_danger',
|
||||||
|
'theme_color_background',
|
||||||
|
'theme_round_borders',
|
||||||
|
'primary_font',
|
||||||
|
'logo_image',
|
||||||
|
'logo_image_large',
|
||||||
|
'logo_show_title',
|
||||||
|
'og_image',
|
||||||
]
|
]
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
data = super().clean()
|
data = super().clean()
|
||||||
settings_dict = self.event.settings.freeze()
|
settings_dict = self.event.settings.freeze()
|
||||||
settings_dict.update(data)
|
settings_dict.update(data)
|
||||||
validate_settings(self.event, data)
|
|
||||||
|
# set all dependants of virtual_keys and
|
||||||
|
# delete all virtual_fields to prevent them from being saved
|
||||||
|
for virtual_key in self.virtual_keys:
|
||||||
|
if virtual_key not in data:
|
||||||
|
continue
|
||||||
|
base_key = virtual_key.rsplit('_', 2)[0]
|
||||||
|
asked_key = base_key + '_asked'
|
||||||
|
required_key = base_key + '_required'
|
||||||
|
|
||||||
|
if data[virtual_key] == 'optional':
|
||||||
|
data[asked_key] = True
|
||||||
|
data[required_key] = False
|
||||||
|
elif data[virtual_key] == 'required':
|
||||||
|
data[asked_key] = True
|
||||||
|
data[required_key] = True
|
||||||
|
# Explicitly check for 'do_not_ask'.
|
||||||
|
# Do not overwrite as default-behaviour when no value for virtual field is transmitted!
|
||||||
|
elif data[virtual_key] == 'do_not_ask':
|
||||||
|
data[asked_key] = False
|
||||||
|
data[required_key] = False
|
||||||
|
|
||||||
|
# hierarkey.forms cannot handle non-existent keys in cleaned_data => do not delete, but set to None
|
||||||
|
data[virtual_key] = None
|
||||||
|
|
||||||
|
validate_event_settings(self.event, data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -552,9 +522,39 @@ class EventSettingsForm(SettingsForm):
|
|||||||
if not self.event.has_subevents:
|
if not self.event.has_subevents:
|
||||||
del self.fields['frontpage_subevent_ordering']
|
del self.fields['frontpage_subevent_ordering']
|
||||||
del self.fields['event_list_type']
|
del self.fields['event_list_type']
|
||||||
self.fields['primary_font'].choices += [
|
|
||||||
(a, {"title": a, "data": v}) for a, v in get_fonts().items()
|
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
|
||||||
|
self.virtual_keys = []
|
||||||
|
for asked_key in [key for key in self.fields.keys() if key.endswith('_asked')]:
|
||||||
|
required_key = asked_key.rsplit('_', 1)[0] + '_required'
|
||||||
|
virtual_key = asked_key + '_required'
|
||||||
|
if required_key not in self.fields or virtual_key in self.fields:
|
||||||
|
# either no matching required key or
|
||||||
|
# there already is a field with virtual_key defined manually, so do not overwrite
|
||||||
|
continue
|
||||||
|
|
||||||
|
asked_field = self.fields[asked_key]
|
||||||
|
|
||||||
|
self.fields[virtual_key] = forms.ChoiceField(
|
||||||
|
label=asked_field.label,
|
||||||
|
help_text=asked_field.help_text,
|
||||||
|
required=True,
|
||||||
|
widget=forms.RadioSelect,
|
||||||
|
choices=[
|
||||||
|
# default key needs a value other than '' because with '' it would also overwrite even if combi-field is not transmitted
|
||||||
|
('do_not_ask', _('Do not ask')),
|
||||||
|
('optional', _('Ask, but do not require input')),
|
||||||
|
('required', _('Ask and require input'))
|
||||||
]
|
]
|
||||||
|
)
|
||||||
|
self.virtual_keys.append(virtual_key)
|
||||||
|
|
||||||
|
if self.initial[required_key]:
|
||||||
|
self.initial[virtual_key] = 'required'
|
||||||
|
elif self.initial[asked_key]:
|
||||||
|
self.initial[virtual_key] = 'optional'
|
||||||
|
else:
|
||||||
|
self.initial[virtual_key] = 'do_not_ask'
|
||||||
|
|
||||||
|
|
||||||
class CancelSettingsForm(SettingsForm):
|
class CancelSettingsForm(SettingsForm):
|
||||||
@@ -568,6 +568,7 @@ class CancelSettingsForm(SettingsForm):
|
|||||||
'cancel_allow_user_paid_keep_percentage',
|
'cancel_allow_user_paid_keep_percentage',
|
||||||
'cancel_allow_user_paid_adjust_fees',
|
'cancel_allow_user_paid_adjust_fees',
|
||||||
'cancel_allow_user_paid_adjust_fees_explanation',
|
'cancel_allow_user_paid_adjust_fees_explanation',
|
||||||
|
'cancel_allow_user_paid_adjust_fees_step',
|
||||||
'cancel_allow_user_paid_refund_as_giftcard',
|
'cancel_allow_user_paid_refund_as_giftcard',
|
||||||
'cancel_allow_user_paid_require_approval',
|
'cancel_allow_user_paid_require_approval',
|
||||||
'change_allow_user_variation',
|
'change_allow_user_variation',
|
||||||
@@ -592,6 +593,7 @@ class PaymentSettingsForm(SettingsForm):
|
|||||||
'payment_term_last',
|
'payment_term_last',
|
||||||
'payment_term_expire_automatically',
|
'payment_term_expire_automatically',
|
||||||
'payment_term_accept_late',
|
'payment_term_accept_late',
|
||||||
|
'payment_pending_hidden',
|
||||||
'payment_explanation',
|
'payment_explanation',
|
||||||
]
|
]
|
||||||
tax_rate_default = forms.ModelChoiceField(
|
tax_rate_default = forms.ModelChoiceField(
|
||||||
@@ -618,7 +620,7 @@ class PaymentSettingsForm(SettingsForm):
|
|||||||
data = super().clean()
|
data = super().clean()
|
||||||
settings_dict = self.obj.settings.freeze()
|
settings_dict = self.obj.settings.freeze()
|
||||||
settings_dict.update(data)
|
settings_dict.update(data)
|
||||||
validate_settings(self.obj, data)
|
validate_event_settings(self.obj, data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -702,6 +704,7 @@ class InvoiceSettingsForm(SettingsForm):
|
|||||||
'invoice_additional_text',
|
'invoice_additional_text',
|
||||||
'invoice_footer_text',
|
'invoice_footer_text',
|
||||||
'invoice_eu_currencies',
|
'invoice_eu_currencies',
|
||||||
|
'invoice_logo_image',
|
||||||
]
|
]
|
||||||
|
|
||||||
invoice_generate_sales_channels = forms.MultipleChoiceField(
|
invoice_generate_sales_channels = forms.MultipleChoiceField(
|
||||||
@@ -721,13 +724,6 @@ class InvoiceSettingsForm(SettingsForm):
|
|||||||
label=_("Invoice language"),
|
label=_("Invoice language"),
|
||||||
choices=[('__user__', _('The user\'s language'))] + settings.LANGUAGES,
|
choices=[('__user__', _('The user\'s language'))] + settings.LANGUAGES,
|
||||||
)
|
)
|
||||||
invoice_logo_image = ExtFileField(
|
|
||||||
label=_('Logo image'),
|
|
||||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
|
||||||
required=False,
|
|
||||||
max_size=10 * 1024 * 1024,
|
|
||||||
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
event = kwargs.get('obj')
|
event = kwargs.get('obj')
|
||||||
@@ -750,7 +746,7 @@ class InvoiceSettingsForm(SettingsForm):
|
|||||||
data = super().clean()
|
data = super().clean()
|
||||||
settings_dict = self.obj.settings.freeze()
|
settings_dict = self.obj.settings.freeze()
|
||||||
settings_dict.update(data)
|
settings_dict.update(data)
|
||||||
validate_settings(self.obj, data)
|
validate_event_settings(self.obj, data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -1121,15 +1117,16 @@ class CommentForm(I18nModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class CountriesAndEU(Countries):
|
class CountriesAndEU(CachedCountries):
|
||||||
override = {
|
override = {
|
||||||
'ZZ': _('Any country'),
|
'ZZ': _('Any country'),
|
||||||
'EU': _('European Union')
|
'EU': _('European Union')
|
||||||
}
|
}
|
||||||
first = ['ZZ', 'EU']
|
first = ['ZZ', 'EU']
|
||||||
|
cache_subkey = 'with_any_or_eu'
|
||||||
|
|
||||||
|
|
||||||
class TaxRuleLineForm(forms.Form):
|
class TaxRuleLineForm(I18nForm):
|
||||||
country = LazyTypedChoiceField(
|
country = LazyTypedChoiceField(
|
||||||
choices=CountriesAndEU(),
|
choices=CountriesAndEU(),
|
||||||
required=False
|
required=False
|
||||||
@@ -1156,11 +1153,26 @@ class TaxRuleLineForm(forms.Form):
|
|||||||
max_digits=10, decimal_places=2,
|
max_digits=10, decimal_places=2,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
invoice_text = I18nFormField(
|
||||||
|
label=_('Text on invoice'),
|
||||||
|
required=False,
|
||||||
|
widget=I18nTextInput
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet):
|
||||||
|
# compatibility shim for django-i18nfield library
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.event = kwargs.pop('event', None)
|
||||||
|
if self.event:
|
||||||
|
kwargs['locales'] = self.event.settings.get('locales')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
TaxRuleLineFormSet = formset_factory(
|
TaxRuleLineFormSet = formset_factory(
|
||||||
TaxRuleLineForm,
|
TaxRuleLineForm, formset=I18nBaseFormSet,
|
||||||
can_order=False, can_delete=True, extra=0
|
can_order=True, can_delete=True, extra=0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from urllib.parse import urlencode
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Exists, F, Model, OuterRef, Q, QuerySet
|
from django.db.models import Exists, F, Max, Model, OuterRef, Q, QuerySet
|
||||||
from django.db.models.functions import Coalesce, ExtractWeekDay
|
from django.db.models.functions import Coalesce, ExtractWeekDay
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils.formats import date_format, localize
|
from django.utils.formats import date_format, localize
|
||||||
@@ -150,8 +150,8 @@ class OrderFilterForm(FilterForm):
|
|||||||
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||||
)),
|
)),
|
||||||
(_('Cancellations'), (
|
(_('Cancellations'), (
|
||||||
(Order.STATUS_CANCELED, _('Canceled')),
|
(Order.STATUS_CANCELED, _('Canceled (fully)')),
|
||||||
('cp', _('Canceled (or with paid fee)')),
|
('cp', _('Canceled (fully or with paid fee)')),
|
||||||
('rc', _('Cancellation requested')),
|
('rc', _('Cancellation requested')),
|
||||||
)),
|
)),
|
||||||
(_('Payment process'), (
|
(_('Payment process'), (
|
||||||
@@ -159,7 +159,8 @@ class OrderFilterForm(FilterForm):
|
|||||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||||
('o', _('Pending (overdue)')),
|
('o', _('Pending (overdue)')),
|
||||||
('overpaid', _('Overpaid')),
|
('overpaid', _('Overpaid')),
|
||||||
('underpaid', _('Underpaid')),
|
('partially_paid', _('Partially paid')),
|
||||||
|
('underpaid', _('Underpaid (but confirmed)')),
|
||||||
('pendingpaid', _('Pending (but fully paid)')),
|
('pendingpaid', _('Pending (but fully paid)')),
|
||||||
)),
|
)),
|
||||||
(_('Approval process'), (
|
(_('Approval process'), (
|
||||||
@@ -238,6 +239,10 @@ class OrderFilterForm(FilterForm):
|
|||||||
elif s == 'rc':
|
elif s == 'rc':
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
cancellation_requests__isnull=False
|
cancellation_requests__isnull=False
|
||||||
|
).annotate(
|
||||||
|
cancellation_request_time=Max('cancellation_requests__created')
|
||||||
|
).order_by(
|
||||||
|
'-cancellation_request_time'
|
||||||
)
|
)
|
||||||
elif s == 'pendingpaid':
|
elif s == 'pendingpaid':
|
||||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||||
@@ -245,6 +250,14 @@ class OrderFilterForm(FilterForm):
|
|||||||
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
||||||
& Q(require_approval=False)
|
& Q(require_approval=False)
|
||||||
)
|
)
|
||||||
|
elif s == 'partially_paid':
|
||||||
|
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||||
|
qs = qs.filter(
|
||||||
|
computed_payment_refund_sum__lt=F('total'),
|
||||||
|
computed_payment_refund_sum__gt=Decimal('0.00')
|
||||||
|
).exclude(
|
||||||
|
status=Order.STATUS_CANCELED
|
||||||
|
)
|
||||||
elif s == 'underpaid':
|
elif s == 'underpaid':
|
||||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
@@ -449,6 +462,16 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Total amount'),
|
label=_('Total amount'),
|
||||||
)
|
)
|
||||||
|
payment_sum_min = forms.DecimalField(
|
||||||
|
localize=True,
|
||||||
|
required=False,
|
||||||
|
label=_('Minimal sum of payments and refunds'),
|
||||||
|
)
|
||||||
|
payment_sum_max = forms.DecimalField(
|
||||||
|
localize=True,
|
||||||
|
required=False,
|
||||||
|
label=_('Maximal sum of payments and refunds'),
|
||||||
|
)
|
||||||
sales_channel = forms.ChoiceField(
|
sales_channel = forms.ChoiceField(
|
||||||
label=_('Sales channel'),
|
label=_('Sales channel'),
|
||||||
required=False,
|
required=False,
|
||||||
@@ -564,7 +587,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
|||||||
if fdata.get('created_from'):
|
if fdata.get('created_from'):
|
||||||
qs = qs.filter(datetime__gte=fdata.get('created_from'))
|
qs = qs.filter(datetime__gte=fdata.get('created_from'))
|
||||||
if fdata.get('created_to'):
|
if fdata.get('created_to'):
|
||||||
qs = qs.filter(datetime__gte=fdata.get('created_to'))
|
qs = qs.filter(datetime__lte=fdata.get('created_to'))
|
||||||
if fdata.get('comment'):
|
if fdata.get('comment'):
|
||||||
qs = qs.filter(comment__icontains=fdata.get('comment'))
|
qs = qs.filter(comment__icontains=fdata.get('comment'))
|
||||||
if fdata.get('sales_channel'):
|
if fdata.get('sales_channel'):
|
||||||
@@ -575,6 +598,16 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
|||||||
qs = qs.filter(email_known_to_work=fdata.get('email_known_to_work'))
|
qs = qs.filter(email_known_to_work=fdata.get('email_known_to_work'))
|
||||||
if fdata.get('locale'):
|
if fdata.get('locale'):
|
||||||
qs = qs.filter(locale=fdata.get('locale'))
|
qs = qs.filter(locale=fdata.get('locale'))
|
||||||
|
if fdata.get('payment_sum_min') is not None:
|
||||||
|
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||||
|
qs = qs.filter(
|
||||||
|
computed_payment_refund_sum__gte=fdata['payment_sum_min'],
|
||||||
|
)
|
||||||
|
if fdata.get('payment_sum_max') is not None:
|
||||||
|
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||||
|
qs = qs.filter(
|
||||||
|
computed_payment_refund_sum__lte=fdata['payment_sum_max'],
|
||||||
|
)
|
||||||
if fdata.get('invoice_address_company'):
|
if fdata.get('invoice_address_company'):
|
||||||
qs = qs.filter(invoice_address__company__icontains=fdata.get('invoice_address_company'))
|
qs = qs.filter(invoice_address__company__icontains=fdata.get('invoice_address_company'))
|
||||||
if fdata.get('invoice_address_name'):
|
if fdata.get('invoice_address_name'):
|
||||||
@@ -1113,8 +1146,8 @@ class CheckInFilterForm(FilterForm):
|
|||||||
'-item': ('-item__name', '-variation__value', '-order__code'),
|
'-item': ('-item__name', '-variation__value', '-order__code'),
|
||||||
'seat': ('seat__sorting_rank', 'seat__guid'),
|
'seat': ('seat__sorting_rank', 'seat__guid'),
|
||||||
'-seat': ('-seat__sorting_rank', '-seat__guid'),
|
'-seat': ('-seat__sorting_rank', '-seat__guid'),
|
||||||
'date': ('subevent__date_from', 'order__code'),
|
'date': ('subevent__date_from', 'subevent__id', 'order__code'),
|
||||||
'-date': ('-subevent__date_from', '-order__code'),
|
'-date': ('-subevent__date_from', 'subevent__id', '-order__code'),
|
||||||
'name': {'_order': F('display_name').asc(nulls_first=True),
|
'name': {'_order': F('display_name').asc(nulls_first=True),
|
||||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')},
|
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')},
|
||||||
'-name': {'_order': F('display_name').desc(nulls_last=True),
|
'-name': {'_order': F('display_name').desc(nulls_last=True),
|
||||||
@@ -1477,6 +1510,9 @@ class VoucherTagFilterForm(FilterForm):
|
|||||||
|
|
||||||
|
|
||||||
class RefundFilterForm(FilterForm):
|
class RefundFilterForm(FilterForm):
|
||||||
|
orders = {'provider': 'provider', 'state': 'state', 'order': 'order__code',
|
||||||
|
'source': 'source', 'amount': 'amount', 'created': 'created'}
|
||||||
|
|
||||||
provider = forms.ChoiceField(
|
provider = forms.ChoiceField(
|
||||||
label=_('Payment provider'),
|
label=_('Payment provider'),
|
||||||
choices=[
|
choices=[
|
||||||
@@ -1513,6 +1549,10 @@ class RefundFilterForm(FilterForm):
|
|||||||
qs = qs.filter(state__in=[OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT,
|
qs = qs.filter(state__in=[OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT,
|
||||||
OrderRefund.REFUND_STATE_EXTERNAL])
|
OrderRefund.REFUND_STATE_EXTERNAL])
|
||||||
|
|
||||||
|
if fdata.get('ordering'):
|
||||||
|
qs = qs.order_by(self.get_order_by())
|
||||||
|
else:
|
||||||
|
qs = qs.order_by('-created')
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ from pretix.base.signals import register_global_settings
|
|||||||
|
|
||||||
|
|
||||||
class GlobalSettingsForm(SettingsForm):
|
class GlobalSettingsForm(SettingsForm):
|
||||||
|
auto_fields = [
|
||||||
|
'region'
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.obj = GlobalSettingsObject()
|
self.obj = GlobalSettingsObject()
|
||||||
super().__init__(*args, obj=self.obj, **kwargs)
|
super().__init__(*args, obj=self.obj, **kwargs)
|
||||||
|
|
||||||
self.fields = OrderedDict([
|
self.fields = OrderedDict(list(self.fields.items()) + [
|
||||||
('footer_text', I18nFormField(
|
('footer_text', I18nFormField(
|
||||||
widget=I18nTextInput,
|
widget=I18nTextInput,
|
||||||
required=False,
|
required=False,
|
||||||
@@ -41,6 +45,10 @@ class GlobalSettingsForm(SettingsForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_("OpenCage API key for geocoding"),
|
label=_("OpenCage API key for geocoding"),
|
||||||
)),
|
)),
|
||||||
|
('mapquest_apikey', SecretKeySettingsField(
|
||||||
|
required=False,
|
||||||
|
label=_("MapQuest API key for geocoding"),
|
||||||
|
)),
|
||||||
('leaflet_tiles', forms.CharField(
|
('leaflet_tiles', forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_("Leaflet tiles URL pattern"),
|
label=_("Leaflet tiles URL pattern"),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user