forked from CGM_Public/pretix_original
Compare commits
280 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5094e5e4ec | ||
|
|
222cab115e | ||
|
|
7809f8ac1a | ||
|
|
839c76769c | ||
|
|
8b4cf9db70 | ||
|
|
099ab079f9 | ||
|
|
56c861036c | ||
|
|
211fddf308 | ||
|
|
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 | ||
|
|
19e5843d99 | ||
|
|
4ede99c04b | ||
|
|
0fad2ab728 | ||
|
|
2b9461e847 | ||
|
|
987802335b | ||
|
|
eb7e272273 | ||
|
|
2761419952 | ||
|
|
4b422571ad | ||
|
|
c340fd9d97 | ||
|
|
e5d554a7b3 | ||
|
|
076aa097f6 | ||
|
|
97b9c1029a | ||
|
|
2ebd040a7c | ||
|
|
14a66ff80c | ||
|
|
76c6bbc321 | ||
|
|
0272e44edd | ||
|
|
99d2c40935 | ||
|
|
2720cf5ae1 | ||
|
|
3e415c2654 | ||
|
|
6d1ad45908 | ||
|
|
5514279868 | ||
|
|
868aae0054 | ||
|
|
55f89b2125 | ||
|
|
10e0e9e618 | ||
|
|
1119f90c02 | ||
|
|
35108c0e47 | ||
|
|
86b722015f | ||
|
|
54e9a03b9a | ||
|
|
c90365e908 | ||
|
|
5c85c69b3d | ||
|
|
6d9e1be844 | ||
|
|
168a6bae98 | ||
|
|
6c1fa8cf2d | ||
|
|
88be280445 | ||
|
|
6aa3532ee6 | ||
|
|
b8db58b978 | ||
|
|
5a95550075 | ||
|
|
627f601bdb | ||
|
|
6c03e49090 | ||
|
|
0d0294a292 | ||
|
|
d389a2aaa1 | ||
|
|
f51ec04e05 | ||
|
|
023f9eb6e7 | ||
|
|
0bd1c3f3af | ||
|
|
821599dc1a | ||
|
|
9a65ad0abe | ||
|
|
12cb555917 | ||
|
|
87656cef4c | ||
|
|
3a67203a0d | ||
|
|
695a800811 | ||
|
|
e3c820b760 | ||
|
|
c52bf0be8c | ||
|
|
b287f870b1 | ||
|
|
48f3a157bc | ||
|
|
62a0dd2541 | ||
|
|
8c63f2159c | ||
|
|
e5a77dc482 | ||
|
|
bd81d7dced | ||
|
|
23c38a3742 | ||
|
|
6c29fc0117 | ||
|
|
eae1fc9a81 | ||
|
|
2c1195eaa1 | ||
|
|
f94e8e5bdc | ||
|
|
20ec388b03 | ||
|
|
02278660bc | ||
|
|
01b90ded36 | ||
|
|
10b592a1c4 | ||
|
|
cfffcf2d1a | ||
|
|
df83682d55 | ||
|
|
eeb3c1a960 | ||
|
|
a7565342c0 | ||
|
|
d03c5ce30c | ||
|
|
b51108ab22 | ||
|
|
d08c811f3a | ||
|
|
c757f3e4c7 | ||
|
|
5962e4d4ab | ||
|
|
6fd2662956 | ||
|
|
259d2cdb27 | ||
|
|
04e9c8a226 | ||
|
|
78798ff382 | ||
|
|
be1926ff21 | ||
|
|
6af5b3fd5e | ||
|
|
8989723145 | ||
|
|
e980b2c255 | ||
|
|
cb0023dc3c | ||
|
|
b4c18c6ea6 | ||
|
|
e07cca9148 | ||
|
|
031ee647ab | ||
|
|
6ca6f9437f | ||
|
|
07ff523ea3 | ||
|
|
92df47d0c7 | ||
|
|
717c905d16 | ||
|
|
e922bd7376 | ||
|
|
a48d844456 | ||
|
|
48119038b4 | ||
|
|
598f0b316e | ||
|
|
7df503fb4f | ||
|
|
4c84cf7b37 | ||
|
|
f969db69cb | ||
|
|
fb92676aee | ||
|
|
6052895ada | ||
|
|
7a98f3fa89 | ||
|
|
da149682aa | ||
|
|
ba4eff5545 | ||
|
|
32c08d431f | ||
|
|
ecd914f44d | ||
|
|
f6dc90fb28 | ||
|
|
4093c1d909 | ||
|
|
9da14dfebe | ||
|
|
a941378b80 | ||
|
|
9202aca26a | ||
|
|
b841878dcb | ||
|
|
2cf6a4a6ab | ||
|
|
8759155357 | ||
|
|
1fe4d1a8ca | ||
|
|
73e0937d80 | ||
|
|
151d5c4f2b | ||
|
|
8486f66e69 | ||
|
|
9bb8f7b429 | ||
|
|
53ce1a53c6 | ||
|
|
ce61c8a23a | ||
|
|
13f825ec1b | ||
|
|
4ff4402a5f | ||
|
|
b4964b1460 | ||
|
|
710aaa5f1c | ||
|
|
ed12fd3cd5 | ||
|
|
ec7be3bd07 | ||
|
|
95aa7b7619 | ||
|
|
f9d1dc7181 | ||
|
|
ad094bcfc0 | ||
|
|
2b1d9bc039 | ||
|
|
762d815cf5 | ||
|
|
6a71b9bf19 | ||
|
|
d2617ca104 | ||
|
|
a3573125df | ||
|
|
565a65f780 | ||
|
|
9543d89014 | ||
|
|
e61288ba67 | ||
|
|
58af025fd8 |
@@ -1,3 +1,10 @@
|
||||
doc/
|
||||
env/
|
||||
res/
|
||||
local/
|
||||
.git/
|
||||
pretixeu/
|
||||
src/data/
|
||||
src/pretix/static.dist/
|
||||
src/dist/
|
||||
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM python:3.6
|
||||
FROM python:3.8
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
@@ -30,7 +30,8 @@ RUN apt-get update && \
|
||||
mkdir /data && \
|
||||
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
|
||||
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
|
||||
mkdir /static
|
||||
mkdir /static && \
|
||||
mkdir /etc/supervisord
|
||||
|
||||
ENV LC_ALL=C.UTF-8 \
|
||||
DJANGO_SETTINGS_MODULE=production_settings
|
||||
@@ -47,12 +48,13 @@ RUN pip3 install -U \
|
||||
-r requirements.txt \
|
||||
-r requirements/memcached.txt \
|
||||
-r requirements/mysql.txt \
|
||||
-r requirements/redis.txt \
|
||||
gunicorn && \
|
||||
gunicorn django-extensions ipython && \
|
||||
rm -rf ~/.cache/pip
|
||||
|
||||
COPY deployment/docker/pretix.bash /usr/local/bin/pretix
|
||||
COPY deployment/docker/supervisord.conf /etc/supervisord.conf
|
||||
COPY deployment/docker/supervisord /etc/supervisord
|
||||
COPY deployment/docker/supervisord.all.conf /etc/supervisord.all.conf
|
||||
COPY deployment/docker/supervisord.web.conf /etc/supervisord.web.conf
|
||||
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
COPY src /pretix/src
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
user www-data www-data;
|
||||
worker_processes 1;
|
||||
worker_processes auto;
|
||||
pid /var/run/nginx.pid;
|
||||
daemon off;
|
||||
worker_rlimit_nofile 262144;
|
||||
|
||||
events {
|
||||
worker_connections 4096;
|
||||
worker_connections 16384;
|
||||
multi_accept on;
|
||||
use epoll;
|
||||
}
|
||||
|
||||
http {
|
||||
|
||||
@@ -3,7 +3,10 @@ cd /pretix/src
|
||||
export DJANGO_SETTINGS_MODULE=production_settings
|
||||
export DATA_DIR=/data/
|
||||
export HOME=/pretix
|
||||
export NUM_WORKERS=$((2 * $(nproc --all)))
|
||||
|
||||
AUTOMIGRATE=${AUTOMIGRATE:-yes}
|
||||
NUM_WORKERS_DEFAULT=$((2 * $(nproc --all)))
|
||||
export NUM_WORKERS=${NUM_WORKERS:-$NUM_WORKERS_DEFAULT}
|
||||
|
||||
if [ ! -d /data/logs ]; then
|
||||
mkdir /data/logs;
|
||||
@@ -16,10 +19,16 @@ if [ "$1" == "cron" ]; then
|
||||
exec python3 -m pretix runperiodic
|
||||
fi
|
||||
|
||||
python3 -m pretix migrate --noinput
|
||||
if [ "$AUTOMIGRATE" != "skip" ]; then
|
||||
python3 -m pretix migrate --noinput
|
||||
fi
|
||||
|
||||
if [ "$1" == "all" ]; then
|
||||
exec sudo -E /usr/bin/supervisord -n -c /etc/supervisord.conf
|
||||
exec sudo -E /usr/bin/supervisord -n -c /etc/supervisord.all.conf
|
||||
fi
|
||||
|
||||
if [ "$1" == "web" ]; then
|
||||
exec sudo -E /usr/bin/supervisord -n -c /etc/supervisord.web.conf
|
||||
fi
|
||||
|
||||
if [ "$1" == "webworker" ]; then
|
||||
@@ -37,10 +46,6 @@ if [ "$1" == "taskworker" ]; then
|
||||
exec celery -A pretix.celery_app worker -l info "$@"
|
||||
fi
|
||||
|
||||
if [ "$1" == "shell" ]; then
|
||||
exec python3 -m pretix shell
|
||||
fi
|
||||
|
||||
if [ "$1" == "upgrade" ]; then
|
||||
exec python3 -m pretix updatestyles
|
||||
fi
|
||||
|
||||
2
deployment/docker/supervisord.all.conf
Normal file
2
deployment/docker/supervisord.all.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
[include]
|
||||
files = /etc/supervisord/*.conf
|
||||
@@ -1,44 +0,0 @@
|
||||
[unix_http_server]
|
||||
file=/tmp/supervisor.sock
|
||||
|
||||
[supervisord]
|
||||
logfile=/tmp/supervisord.log
|
||||
logfile_maxbytes=50MB
|
||||
logfile_backups=10
|
||||
loglevel=info
|
||||
pidfile=/tmp/supervisord.pid
|
||||
nodaemon=false
|
||||
minfds=1024
|
||||
minprocs=200
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///tmp/supervisor.sock
|
||||
|
||||
[program:pretixweb]
|
||||
command=/usr/local/bin/pretix webworker
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=5
|
||||
user=pretixuser
|
||||
environment=HOME=/pretix
|
||||
|
||||
[program:pretixtask]
|
||||
command=/usr/local/bin/pretix taskworker
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=5
|
||||
user=pretixuser
|
||||
|
||||
[program:nginx]
|
||||
command=/usr/sbin/nginx
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=10
|
||||
stdout_events_enabled=true
|
||||
stderr_events_enabled=true
|
||||
|
||||
[include]
|
||||
files = /etc/supervisord-*.conf
|
||||
2
deployment/docker/supervisord.web.conf
Normal file
2
deployment/docker/supervisord.web.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
[include]
|
||||
files = /etc/supervisord/base.conf /etc/supervisord/nginx.conf /etc/supervisord/pretixweb.conf
|
||||
18
deployment/docker/supervisord/base.conf
Normal file
18
deployment/docker/supervisord/base.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
[unix_http_server]
|
||||
file=/tmp/supervisor.sock
|
||||
|
||||
[supervisord]
|
||||
logfile=/tmp/supervisord.log
|
||||
logfile_maxbytes=50MB
|
||||
logfile_backups=10
|
||||
loglevel=info
|
||||
pidfile=/tmp/supervisord.pid
|
||||
nodaemon=false
|
||||
minfds=1024
|
||||
minprocs=200
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///tmp/supervisor.sock
|
||||
7
deployment/docker/supervisord/nginx.conf
Normal file
7
deployment/docker/supervisord/nginx.conf
Normal file
@@ -0,0 +1,7 @@
|
||||
[program:nginx]
|
||||
command=/usr/sbin/nginx
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=10
|
||||
stdout_events_enabled=true
|
||||
stderr_events_enabled=true
|
||||
6
deployment/docker/supervisord/pretixtask.conf
Normal file
6
deployment/docker/supervisord/pretixtask.conf
Normal file
@@ -0,0 +1,6 @@
|
||||
[program:pretixtask]
|
||||
command=/usr/local/bin/pretix taskworker
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=5
|
||||
user=pretixuser
|
||||
7
deployment/docker/supervisord/pretixweb.conf
Normal file
7
deployment/docker/supervisord/pretixweb.conf
Normal file
@@ -0,0 +1,7 @@
|
||||
[program:pretixweb]
|
||||
command=/usr/local/bin/pretix webworker
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=5
|
||||
user=pretixuser
|
||||
environment=HOME=/pretix
|
||||
@@ -23,6 +23,14 @@ The config file may contain the following sections (all settings are optional an
|
||||
default values). We suggest that you start from the examples given in one of the
|
||||
installation tutorials.
|
||||
|
||||
.. note::
|
||||
|
||||
The configuration file is the recommended way to configure pretix. However, you can
|
||||
also set them through environment variables. In this case, the syntax is
|
||||
``PRETIX_SECTION_CONFIG``. For example, to configure the setting ``password_reset``
|
||||
from the ``[pretix]`` section, set ``PRETIX_PRETIX_PASSWORD_RESET=off`` in your
|
||||
environment.
|
||||
|
||||
pretix settings
|
||||
---------------
|
||||
|
||||
@@ -97,7 +105,12 @@ Example::
|
||||
|
||||
``csp_log``
|
||||
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``
|
||||
Set console and file log level (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``.
|
||||
|
||||
|
||||
@@ -284,6 +284,26 @@ Then, go to that directory and build the image::
|
||||
You can now use that image ``mypretix`` instead of ``pretix/standalone`` in your service file (see above). Be sure
|
||||
to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an update.
|
||||
|
||||
Scaling up
|
||||
----------
|
||||
|
||||
If you need to scale to multiple machines, please first read our :ref:`scaling guide <scaling>`.
|
||||
|
||||
If you run the official docker container on multiple machines, it is recommended to set the environment
|
||||
variable ``AUTOMIGRATE=skip`` on all containers and run ``docker exec -it pretix.service pretix migrate``
|
||||
on one machine after each upgrade manually, otherwise multiple containers might try to upgrade the
|
||||
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
|
||||
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
|
||||
also pass arguments to limit the worker to specific queues or to change the number of concurrent task
|
||||
workers, e.g. ``docker run … taskworker -Q notifications --concurrency 32``.
|
||||
|
||||
|
||||
.. _Docker: https://docs.docker.com/engine/installation/linux/debian/
|
||||
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
|
||||
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
|
||||
|
||||
@@ -47,6 +47,8 @@ item_meta_properties object Item-specific m
|
||||
valid_keys object Cryptographic keys for non-default signature schemes.
|
||||
For performance reason, value is omitted in lists and
|
||||
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.
|
||||
|
||||
.. versionchanged:: 3.14
|
||||
|
||||
The attribute ``sales_channels`` has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -147,11 +154,16 @@ Endpoints
|
||||
"timezone": "Europe/Berlin",
|
||||
"item_meta_properties": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer"
|
||||
"pretix.plugins.stripe"
|
||||
"pretix.plugins.paypal"
|
||||
"pretix.plugins.banktransfer",
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal",
|
||||
"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
|
||||
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.
|
||||
: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
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
@@ -219,16 +232,21 @@ Endpoints
|
||||
"timezone": "Europe/Berlin",
|
||||
"item_meta_properties": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer"
|
||||
"pretix.plugins.stripe"
|
||||
"pretix.plugins.paypal"
|
||||
"pretix.plugins.banktransfer",
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal",
|
||||
"pretix.plugins.ticketoutputpdf"
|
||||
],
|
||||
"valid_keys": {
|
||||
"pretix_sig1": [
|
||||
"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo="
|
||||
]
|
||||
}
|
||||
},
|
||||
"sales_channels": [
|
||||
"web",
|
||||
"pretixpos",
|
||||
"resellers"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -279,6 +297,11 @@ Endpoints
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
],
|
||||
"sales_channels": [
|
||||
"web",
|
||||
"pretixpos",
|
||||
"resellers"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -314,6 +337,11 @@ Endpoints
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
],
|
||||
"sales_channels": [
|
||||
"web",
|
||||
"pretixpos",
|
||||
"resellers"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -369,6 +397,11 @@ Endpoints
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
],
|
||||
"sales_channels": [
|
||||
"web",
|
||||
"pretixpos",
|
||||
"resellers"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -404,6 +437,11 @@ Endpoints
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
],
|
||||
"sales_channels": [
|
||||
"web",
|
||||
"pretixpos",
|
||||
"resellers"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -473,6 +511,11 @@ Endpoints
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal",
|
||||
"pretix.plugins.pretixdroid"
|
||||
],
|
||||
"sales_channels": [
|
||||
"web",
|
||||
"pretixpos",
|
||||
"resellers"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
215
doc/api/resources/exporters.rst
Normal file
215
doc/api/resources/exporters.rst
Normal file
@@ -0,0 +1,215 @@
|
||||
.. spelling:: checkin
|
||||
|
||||
Data exporters
|
||||
==============
|
||||
|
||||
pretix and it's plugins include a number of data exporters that allow you to bulk download various data from pretix in
|
||||
different formats. This page shows you how to use these exporters through the API.
|
||||
|
||||
.. versionchanged:: 3.13
|
||||
|
||||
This feature has been added to the API.
|
||||
|
||||
.. warning::
|
||||
|
||||
While we consider the methods listed on this page to be a stable API, the availability and specific input field
|
||||
requirements of individual exporters is **not considered a stable API**. Specific exporters and their input parameters
|
||||
may change at any time without warning.
|
||||
|
||||
Listing available exporters
|
||||
---------------------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exporters/
|
||||
|
||||
Returns a list of all exporters available for a given event. You will receive a list of export methods as well as their
|
||||
supported input fields. Note that the exact type and validation requirements of the input fields are not given in the
|
||||
response, and you might need to look into the pretix web interface to figure out the exact input required.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/exporters/ 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": [
|
||||
{
|
||||
"identifier": "orderlist",
|
||||
"verbose_name": "Order data",
|
||||
"input_parameters": [
|
||||
{
|
||||
"name": "_format",
|
||||
"required": true,
|
||||
"choices": [
|
||||
"xlsx",
|
||||
"orders:default",
|
||||
"orders:excel",
|
||||
"orders:semicolon",
|
||||
"positions:default",
|
||||
"positions:excel",
|
||||
"positions:semicolon",
|
||||
"fees:default",
|
||||
"fees:excel",
|
||||
"fees:semicolon"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "paid_only",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/exporters/
|
||||
|
||||
Returns a list of all cross-event exporters available for a given organizer. You will receive a list of export methods as well as their
|
||||
supported input fields. Note that the exact type and validation requirements of the input fields are not given in the
|
||||
response, and you might need to look into the pretix web interface to figure out the exact input required.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/exporters/ 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": [
|
||||
{
|
||||
"identifier": "orderlist",
|
||||
"verbose_name": "Order data",
|
||||
"input_parameters": [
|
||||
{
|
||||
"name": "events",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "_format",
|
||||
"required": true,
|
||||
"choices": [
|
||||
"xlsx",
|
||||
"orders:default",
|
||||
"orders:excel",
|
||||
"orders:semicolon",
|
||||
"positions:default",
|
||||
"positions:excel",
|
||||
"positions:semicolon",
|
||||
"fees:default",
|
||||
"fees:excel",
|
||||
"fees:semicolon"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "paid_only",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
Running an export
|
||||
-----------------
|
||||
|
||||
Since exports often include large data sets, they might take longer than the duration of an HTTP request. Therefore,
|
||||
creating an export is a two-step process. First you need to start an export task with one of the following to API
|
||||
endpoints:
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exporters/(identifier)/run/
|
||||
|
||||
Starts an export task. If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
|
||||
The body points you to the download URL of the result.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/exporters/orderlist/run/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"_format": "xlsx"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderlist/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param identifier: The ``identifier`` field of the exporter to run
|
||||
:statuscode 202: no error
|
||||
:statuscode 400: Invalid input options
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/exporters/(identifier)/run/
|
||||
|
||||
The endpoint for organizer-level exports works just like event-level exports (see above).
|
||||
|
||||
|
||||
Downloading the result
|
||||
----------------------
|
||||
|
||||
When starting an export, you receive a ``url`` for downloading the result. Running a ``GET`` request on that result will
|
||||
yield one of the following status codes:
|
||||
|
||||
* ``200 OK`` – The export succeeded. The body will be your resulting file. Might be large!
|
||||
* ``409 Conflict`` – Your export is still running. The body will be JSON with the structure ``{"status": "running", "percentage": 40}``. ``percentage`` can be ``null`` if it is not known and ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
|
||||
* ``410 Gone`` – Running the export has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
|
||||
* ``404 Not Found`` – The export does not exist / is expired.
|
||||
|
||||
.. warning::
|
||||
|
||||
Running exports puts a lot of stress on the system, we kindly ask you not to run more than two exports at the same time.
|
||||
|
||||
@@ -22,9 +22,28 @@ expires datetime Expiry date (or
|
||||
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
|
||||
---------
|
||||
|
||||
.. versionadded:: 3.14
|
||||
|
||||
The transaction list endpoint was added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/giftcards/
|
||||
|
||||
Returns a list of all gift cards issued by a given organizer.
|
||||
@@ -250,3 +269,45 @@ Endpoints
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
|
||||
:statuscode 409: There is not sufficient credit on the gift card.
|
||||
|
||||
.. 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.
|
||||
|
||||
@@ -27,5 +27,6 @@ Resources and endpoints
|
||||
devices
|
||||
webhooks
|
||||
seatingplans
|
||||
exporters
|
||||
billing_invoices
|
||||
billing_var
|
||||
|
||||
@@ -30,6 +30,7 @@ testmode boolean If ``true``, th
|
||||
test mode. Only orders in test mode can be deleted.
|
||||
secret string The secret contained in the link sent to the customer
|
||||
email string The customer email address
|
||||
phone string The customer phone number
|
||||
locale string The locale used for communication with this customer
|
||||
sales_channel string Channel this sale was created through, such as
|
||||
``"web"``.
|
||||
@@ -163,6 +164,14 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``exclude`` and ``subevent_after`` query parameter has been added.
|
||||
|
||||
.. versionchanged:: 3.13
|
||||
|
||||
The ``subevent_before`` query parameter has been added.
|
||||
|
||||
.. versionchanged:: 3.14
|
||||
|
||||
The ``phone`` attribute has been added.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -368,6 +377,7 @@ List of all orders
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
||||
"email": "tester@example.org",
|
||||
"phone": "+491234567",
|
||||
"locale": "en",
|
||||
"sales_channel": "web",
|
||||
"datetime": "2017-12-01T10:00:00Z",
|
||||
@@ -490,7 +500,8 @@ List of all orders
|
||||
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
||||
you will not notice it using this method.
|
||||
:query datetime created_since: Only return orders that have been created since the given date.
|
||||
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date.
|
||||
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
|
||||
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
|
||||
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
@@ -534,6 +545,7 @@ Fetching individual orders
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
||||
"email": "tester@example.org",
|
||||
"phone": "+491234567",
|
||||
"locale": "en",
|
||||
"sales_channel": "web",
|
||||
"datetime": "2017-12-01T10:00:00Z",
|
||||
@@ -700,6 +712,8 @@ Updating order fields
|
||||
|
||||
* ``email``
|
||||
|
||||
* ``phone``
|
||||
|
||||
* ``checkin_attention``
|
||||
|
||||
* ``locale``
|
||||
@@ -935,9 +949,9 @@ Creating orders
|
||||
during order generation and is not respected automatically when the order changes later.)
|
||||
|
||||
* ``force`` (optional). If set to ``true``, quotas will be ignored.
|
||||
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order, 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
|
||||
``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
|
||||
to incrementing integers starting with ``1``. Then, you can reference one of these
|
||||
@@ -1971,6 +1985,7 @@ Order payment endpoints
|
||||
"amount": "23.00",
|
||||
"payment_date": "2017-12-04T12:13:12Z",
|
||||
"info": {},
|
||||
"send_email": false,
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
|
||||
|
||||
@@ -90,3 +90,120 @@ Endpoints
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
: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:
|
||||
|
||||
@@ -53,6 +56,12 @@ options list of objects In case of ques
|
||||
├ identifier string An arbitrary string that can be used for matching with
|
||||
other sources.
|
||||
└ 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
|
||||
question will only be shown if the question 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.
|
||||
|
||||
.. versionchanged:: 3.14
|
||||
|
||||
The attributes ``valid_*`` have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -137,6 +150,12 @@ Endpoints
|
||||
"ask_during_checkin": false,
|
||||
"hidden": 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_value": null,
|
||||
"dependency_values": [],
|
||||
@@ -208,6 +227,12 @@ Endpoints
|
||||
"ask_during_checkin": false,
|
||||
"hidden": 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_value": null,
|
||||
"dependency_values": [],
|
||||
@@ -302,6 +327,12 @@ Endpoints
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"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": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -377,6 +408,12 @@ Endpoints
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"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": [
|
||||
{
|
||||
"id": 1,
|
||||
|
||||
@@ -31,8 +31,10 @@ action_types list of strings A list of actio
|
||||
The following values for ``action_types`` are valid with pretix core:
|
||||
|
||||
* ``pretix.event.order.placed``
|
||||
* ``pretix.event.order.placed.require_approval``
|
||||
* ``pretix.event.order.paid``
|
||||
* ``pretix.event.order.canceled``
|
||||
* ``pretix.event.order.reactivated``
|
||||
* ``pretix.event.order.expired``
|
||||
* ``pretix.event.order.modified``
|
||||
* ``pretix.event.order.contact.changed``
|
||||
@@ -42,6 +44,12 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.event.order.denied``
|
||||
* ``pretix.event.checkin``
|
||||
* ``pretix.event.checkin.reverted``
|
||||
* ``pretix.event.added``
|
||||
* ``pretix.event.changed``
|
||||
* ``pretix.event.deleted``
|
||||
* ``pretix.subevent.added``
|
||||
* ``pretix.subevent.changed``
|
||||
* ``pretix.subevent.deleted``
|
||||
|
||||
Installed plugins might register more valid values.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Backend
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
|
||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms,
|
||||
item_formsets, order_search_filter_q
|
||||
item_formsets, order_search_filter_q, order_search_forms
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
include LICENSE
|
||||
include README.rst
|
||||
global-include *.proto
|
||||
recursive-include pretix/static *
|
||||
recursive-include pretix/static.dist *
|
||||
recursive-include pretix/locale *
|
||||
|
||||
@@ -7,7 +7,7 @@ localecompile:
|
||||
|
||||
localegen:
|
||||
./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
|
||||
./manage.py collectstatic --noinput
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.12.0"
|
||||
__version__ = "3.14.2"
|
||||
|
||||
@@ -102,12 +102,17 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('DELETE', 'api-v1:cartposition-detail'),
|
||||
('GET', 'api-v1:giftcard-list'),
|
||||
('POST', 'api-v1:giftcard-transact'),
|
||||
('GET', 'plugins:pretix_posbackend:posclosing-list'),
|
||||
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
|
||||
('POST', 'plugins:pretix_posbackend:posclosing-list'),
|
||||
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
|
||||
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('GET', 'plugins:pretix_seating:event.event'),
|
||||
('GET', 'plugins:pretix_seating:event.event.subevent'),
|
||||
('GET', 'plugins:pretix_seating:event.plan'),
|
||||
('GET', 'plugins:pretix_seating:selection.simple'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError('The specified seat ID is not unique.')
|
||||
else:
|
||||
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))
|
||||
elif seated:
|
||||
raise ValidationError('The specified product requires to choose a seat.')
|
||||
@@ -104,6 +107,7 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
def validate_cart_id(self, cid):
|
||||
if cid and not cid.endswith('@api'):
|
||||
raise ValidationError('Cart ID should end in @api or be empty.')
|
||||
return cid
|
||||
|
||||
def validate_item(self, item):
|
||||
if item.event != self.context['event']:
|
||||
|
||||
@@ -17,7 +17,7 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
||||
from pretix.base.services.seating import (
|
||||
SeatProtected, generate_seats, validate_plan_change,
|
||||
)
|
||||
from pretix.base.settings import DEFAULTS, validate_settings
|
||||
from pretix.base.settings import DEFAULTS, validate_event_settings
|
||||
from pretix.base.signals import api_event_settings_fields
|
||||
|
||||
|
||||
@@ -124,7 +124,8 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
|
||||
'date_to', 'date_admission', 'is_public', 'presale_start',
|
||||
'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):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -573,6 +574,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'presale_start_show_date',
|
||||
'locales',
|
||||
'locale',
|
||||
'region',
|
||||
'last_order_modification_date',
|
||||
'show_quota_left',
|
||||
'waiting_list_enabled',
|
||||
@@ -596,8 +598,12 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'attendee_addresses_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'attendee_data_explanation_text',
|
||||
'confirm_texts',
|
||||
'order_email_asked_twice',
|
||||
'order_phone_asked',
|
||||
'order_phone_required',
|
||||
'checkout_phone_helptext',
|
||||
'payment_term_mode',
|
||||
'payment_term_days',
|
||||
'payment_term_weekdays',
|
||||
@@ -606,6 +612,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'payment_term_expire_automatically',
|
||||
'payment_term_accept_late',
|
||||
'payment_explanation',
|
||||
'payment_pending_hidden',
|
||||
'ticket_download',
|
||||
'ticket_download_date',
|
||||
'ticket_download_addons',
|
||||
@@ -661,10 +668,17 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'change_allow_user_variation',
|
||||
'change_allow_user_until',
|
||||
'change_allow_user_price',
|
||||
'primary_color',
|
||||
'theme_color_success',
|
||||
'theme_color_danger',
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
self.changed_data = []
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.default_fields:
|
||||
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
||||
@@ -693,15 +707,17 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
for attr, value in validated_data.items():
|
||||
if 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 validate(self, data):
|
||||
data = super().validate(data)
|
||||
settings_dict = self.instance.freeze()
|
||||
settings_dict.update(data)
|
||||
validate_settings(self.event, settings_dict)
|
||||
validate_event_settings(self.event, settings_dict)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
127
src/pretix/api/serializers/exporters.py
Normal file
127
src/pretix/api/serializers/exporters.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from django import forms
|
||||
from django.http import QueryDict
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class FormFieldWrapperField(serializers.Field):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.form_field = kwargs.pop('form_field')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_representation(self, value):
|
||||
return self.form_field.widget.format_value(value)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name')
|
||||
d = self.form_field.clean(d)
|
||||
return d
|
||||
|
||||
|
||||
simple_mappings = (
|
||||
(forms.DateField, serializers.DateField, tuple()),
|
||||
(forms.TimeField, serializers.TimeField, tuple()),
|
||||
(forms.SplitDateTimeField, serializers.DateTimeField, tuple()),
|
||||
(forms.DateTimeField, serializers.DateTimeField, tuple()),
|
||||
(forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')),
|
||||
(forms.FloatField, serializers.FloatField, tuple()),
|
||||
(forms.IntegerField, serializers.IntegerField, tuple()),
|
||||
(forms.EmailField, serializers.EmailField, tuple()),
|
||||
(forms.UUIDField, serializers.UUIDField, tuple()),
|
||||
(forms.URLField, serializers.URLField, tuple()),
|
||||
(forms.NullBooleanField, serializers.NullBooleanField, tuple()),
|
||||
(forms.BooleanField, serializers.BooleanField, tuple()),
|
||||
)
|
||||
|
||||
|
||||
class SerializerDescriptionField(serializers.Field):
|
||||
def to_representation(self, value):
|
||||
fields = []
|
||||
for k, v in value.fields.items():
|
||||
d = {
|
||||
'name': k,
|
||||
'required': v.required,
|
||||
}
|
||||
if isinstance(v, serializers.ChoiceField):
|
||||
d['choices'] = list(v.choices.keys())
|
||||
fields.append(d)
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
class ExporterSerializer(serializers.Serializer):
|
||||
identifier = serializers.CharField()
|
||||
verbose_name = serializers.CharField()
|
||||
input_parameters = SerializerDescriptionField(source='_serializer')
|
||||
|
||||
|
||||
class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
|
||||
def to_representation(self, value):
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
return super().to_representation(value)
|
||||
|
||||
|
||||
class JobRunSerializer(serializers.Serializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
ex = kwargs.pop('exporter')
|
||||
events = kwargs.pop('events', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
if events is not None:
|
||||
self.fields["events"] = serializers.SlugRelatedField(
|
||||
queryset=events,
|
||||
required=True,
|
||||
allow_empty=False,
|
||||
slug_field='slug',
|
||||
many=True
|
||||
)
|
||||
for k, v in ex.export_form_fields.items():
|
||||
for m_from, m_to, m_kwargs in simple_mappings:
|
||||
if isinstance(v, m_from):
|
||||
self.fields[k] = m_to(
|
||||
required=v.required,
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
**{kwarg: getattr(v, kwargs, None) for kwarg in m_kwargs}
|
||||
)
|
||||
break
|
||||
|
||||
if isinstance(v, forms.ModelMultipleChoiceField):
|
||||
self.fields[k] = PrimaryKeyRelatedField(
|
||||
queryset=v.queryset,
|
||||
required=v.required,
|
||||
allow_empty=not v.required,
|
||||
validators=v.validators,
|
||||
many=True
|
||||
)
|
||||
elif isinstance(v, forms.ModelChoiceField):
|
||||
self.fields[k] = PrimaryKeyRelatedField(
|
||||
queryset=v.queryset,
|
||||
required=v.required,
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
elif isinstance(v, forms.MultipleChoiceField):
|
||||
self.fields[k] = serializers.MultipleChoiceField(
|
||||
choices=v.choices,
|
||||
required=v.required,
|
||||
allow_empty=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
elif isinstance(v, forms.ChoiceField):
|
||||
self.fields[k] = serializers.ChoiceField(
|
||||
choices=v.choices,
|
||||
required=v.required,
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
else:
|
||||
self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, QueryDict):
|
||||
data = data.copy()
|
||||
for k, v in self.fields.items():
|
||||
if isinstance(v, serializers.ManyRelatedField) and k not in data:
|
||||
data[k] = []
|
||||
data = super().to_internal_value(data)
|
||||
return data
|
||||
@@ -277,7 +277,9 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
model = Question
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value', 'print_on_invoice', '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):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
|
||||
@@ -180,7 +180,7 @@ class PdfDataSerializer(serializers.Field):
|
||||
res = {}
|
||||
|
||||
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
|
||||
# we serialize a list.
|
||||
|
||||
@@ -361,7 +361,7 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Order
|
||||
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',
|
||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
||||
'url'
|
||||
@@ -393,7 +393,7 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
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 = ['comment', 'checkin_attention', 'email', 'locale']
|
||||
update_fields = ['comment', 'checkin_attention', 'email', 'locale', 'phone']
|
||||
|
||||
if 'invoice_address' in validated_data:
|
||||
iadata = validated_data.pop('invoice_address')
|
||||
@@ -682,7 +682,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
force = serializers.BooleanField(default=False, required=False)
|
||||
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)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -691,9 +691,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
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',
|
||||
'force', 'send_mail', 'simulate')
|
||||
'force', 'send_email', 'simulate')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp is None:
|
||||
@@ -786,7 +786,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
payment_date = validated_data.pop('payment_date', now())
|
||||
force = validated_data.pop('force', 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:
|
||||
iadata = validated_data.pop('invoice_address')
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import get_language, gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from hierarkey.proxy import HierarkeyProxy
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import CompatibleJSONField
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.i18n import get_language_without_region
|
||||
from pretix.base.models import (
|
||||
Device, GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
|
||||
User,
|
||||
Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team,
|
||||
TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.base.settings import DEFAULTS, validate_organizer_settings
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
@@ -59,6 +62,21 @@ class GiftCardSerializer(I18nAwareModelSerializer):
|
||||
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):
|
||||
def get_queryset(self):
|
||||
return self.context['organizer'].events.all()
|
||||
@@ -128,7 +146,7 @@ class TeamInviteSerializer(serializers.ModelSerializer):
|
||||
})
|
||||
},
|
||||
event=None,
|
||||
locale=get_language() # TODO: expose?
|
||||
locale=get_language_without_region() # TODO: expose?
|
||||
)
|
||||
except SendMailException:
|
||||
pass # Already logged
|
||||
@@ -187,3 +205,64 @@ class TeamMemberSerializer(serializers.ModelSerializer):
|
||||
fields = (
|
||||
'id', 'email', 'fullname', 'require_2fa'
|
||||
)
|
||||
|
||||
|
||||
class OrganizerSettingsSerializer(serializers.Serializer):
|
||||
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'
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.organizer = kwargs.pop('organizer')
|
||||
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')
|
||||
self.fields[fname] = f
|
||||
|
||||
def update(self, instance: HierarkeyProxy, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
if 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 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
|
||||
|
||||
@@ -7,8 +7,8 @@ from rest_framework import routers
|
||||
from pretix.api.views import cart
|
||||
|
||||
from .views import (
|
||||
checkin, device, event, item, oauth, order, organizer, user, version,
|
||||
voucher, waitinglist, webhooks,
|
||||
checkin, device, event, exporters, item, oauth, order, organizer, user,
|
||||
version, voucher, waitinglist, webhooks,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
@@ -22,6 +22,7 @@ orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
|
||||
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
|
||||
orga_router.register(r'teams', organizer.TeamViewSet)
|
||||
orga_router.register(r'devices', organizer.DeviceViewSet)
|
||||
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
|
||||
|
||||
team_router = routers.DefaultRouter()
|
||||
team_router.register(r'members', organizer.TeamMemberViewSet)
|
||||
@@ -44,6 +45,7 @@ event_router.register(r'taxrules', event.TaxRuleViewSet)
|
||||
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
||||
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
|
||||
|
||||
checkinlist_router = routers.DefaultRouter()
|
||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
|
||||
@@ -60,6 +62,9 @@ order_router = routers.DefaultRouter()
|
||||
order_router.register(r'payments', order.PaymentViewSet)
|
||||
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
|
||||
for app in apps.get_app_configs():
|
||||
if hasattr(app, 'PretixPluginMeta'):
|
||||
@@ -69,6 +74,9 @@ for app in apps.get_app_configs():
|
||||
urlpatterns = [
|
||||
url(r'^', include(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(),
|
||||
name="event.settings"),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
|
||||
|
||||
@@ -131,7 +131,7 @@ class EventSelectionView(APIView):
|
||||
|
||||
@property
|
||||
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'),
|
||||
last_date=Coalesce('date_to', 'date_from'),
|
||||
).filter(
|
||||
@@ -154,6 +154,7 @@ class EventSelectionView(APIView):
|
||||
).filter(
|
||||
event__organizer=self.request.auth.organizer,
|
||||
event__live=True,
|
||||
event__in=self.request.auth.get_events_with_any_permission(),
|
||||
active=True,
|
||||
).select_related('event').order_by('first_date')
|
||||
if self.request.auth.gate:
|
||||
|
||||
@@ -18,7 +18,9 @@ from pretix.base.models import (
|
||||
CartPosition, Device, Event, TaxRule, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
from pretix.presale.style import regenerate_css
|
||||
from pretix.presale.views.organizer import filter_qs_by_attr
|
||||
|
||||
with scopes_disabled():
|
||||
@@ -26,6 +28,7 @@ with scopes_disabled():
|
||||
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
|
||||
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_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:
|
||||
model = Event
|
||||
@@ -67,6 +70,9 @@ with scopes_disabled():
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
def sales_channel_qs(self, queryset, name, value):
|
||||
return queryset.filter(sales_channels__contains=value)
|
||||
|
||||
|
||||
class EventViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = EventSerializer
|
||||
@@ -385,5 +391,7 @@ class EventSettingsView(views.APIView):
|
||||
k: v for k, v in s.validated_data.items()
|
||||
}
|
||||
)
|
||||
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)
|
||||
return Response(s.data)
|
||||
|
||||
154
src/pretix/api/views/exporters.py
Normal file
154
src/pretix/api/views/exporters.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from pretix.api.serializers.exporters import (
|
||||
ExporterSerializer, JobRunSerializer,
|
||||
)
|
||||
from pretix.base.models import CachedFile, Device, TeamAPIToken
|
||||
from pretix.base.services.export import export, multiexport
|
||||
from pretix.base.signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from pretix.helpers.http import ChunkBasedFileResponse
|
||||
|
||||
|
||||
class ExportersMixin:
|
||||
def list(self, request, *args, **kwargs):
|
||||
res = ExporterSerializer(self.exporters, many=True)
|
||||
return Response({
|
||||
"count": len(self.exporters),
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": res.data
|
||||
})
|
||||
|
||||
def get_object(self):
|
||||
instances = [e for e in self.exporters if e.identifier == self.kwargs.get('pk')]
|
||||
if not instances:
|
||||
raise Http404()
|
||||
return instances[0]
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
serializer = ExporterSerializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||
def download(self, *args, **kwargs):
|
||||
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
||||
if cf.file:
|
||||
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
|
||||
return resp
|
||||
elif not settings.HAS_CELERY:
|
||||
return Response(
|
||||
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
|
||||
res = AsyncResult(kwargs['asyncid'])
|
||||
if res.failed():
|
||||
if isinstance(res.info, dict) and res.info['exc_type'] == 'ExportError':
|
||||
msg = res.info['exc_message']
|
||||
else:
|
||||
msg = 'Internal error'
|
||||
return Response(
|
||||
{'status': 'failed', 'message': msg},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'status': 'running' if res.state in ('PROGRESS', 'STARTED', 'SUCCESS') else 'waiting',
|
||||
'percentage': res.result.get('value', None) if res.result else None,
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def run(self, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
cf = CachedFile(web_download=False)
|
||||
cf.date = now()
|
||||
cf.expires = now() + timedelta(hours=24)
|
||||
cf.save()
|
||||
d = serializer.data
|
||||
for k, v in d.items():
|
||||
if isinstance(v, set):
|
||||
d[k] = list(v)
|
||||
async_result = self.do_export(cf, instance, d)
|
||||
|
||||
url_kwargs = {
|
||||
'asyncid': str(async_result.id),
|
||||
'cfid': str(cf.id),
|
||||
}
|
||||
url_kwargs.update(self.kwargs)
|
||||
return Response({
|
||||
'download': reverse('api-v1:exporters-download', kwargs=url_kwargs, request=self.request)
|
||||
}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_serializer_kwargs(self):
|
||||
return {}
|
||||
|
||||
@cached_property
|
||||
def exporters(self):
|
||||
exporters = []
|
||||
responses = register_data_exporters.send(self.request.event)
|
||||
for ex in sorted([response(self.request.event) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex)
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
|
||||
def do_export(self, cf, instance, data):
|
||||
return export.apply_async(args=(self.request.event.id, str(cf.id), instance.identifier, data))
|
||||
|
||||
|
||||
class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
permission = None
|
||||
|
||||
@cached_property
|
||||
def exporters(self):
|
||||
exporters = []
|
||||
events = (self.request.auth or self.request.user).get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
||||
for ex in sorted([response(events) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex, events=events)
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
|
||||
def get_serializer_kwargs(self):
|
||||
return {
|
||||
'events': self.request.auth.get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
}
|
||||
|
||||
def do_export(self, cf, instance, data):
|
||||
return multiexport.apply_async(kwargs={
|
||||
'organizer': self.request.organizer.id,
|
||||
'user': self.request.user.id if self.request.user.is_authenticated else None,
|
||||
'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) else None,
|
||||
'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None,
|
||||
'fileid': str(cf.id),
|
||||
'provider': instance.identifier,
|
||||
'form_data': data
|
||||
})
|
||||
@@ -33,7 +33,7 @@ from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
|
||||
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
|
||||
TeamAPIToken, generate_secret,
|
||||
TaxRule, TeamAPIToken, generate_secret,
|
||||
)
|
||||
from pretix.base.models.orders import RevokedTicketSecret
|
||||
from pretix.base.payment import PaymentException
|
||||
@@ -65,6 +65,7 @@ with scopes_disabled():
|
||||
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
|
||||
subevent_after = django_filters.IsoDateTimeFilter(method='subevent_after_qs')
|
||||
subevent_before = django_filters.IsoDateTimeFilter(method='subevent_before_qs')
|
||||
search = django_filters.CharFilter(method='search_qs')
|
||||
|
||||
class Meta:
|
||||
@@ -84,6 +85,19 @@ with scopes_disabled():
|
||||
).filter(has_se_after=True)
|
||||
return qs
|
||||
|
||||
def subevent_before_qs(self, qs, name, value):
|
||||
qs = qs.annotate(
|
||||
has_se_before=Exists(
|
||||
OrderPosition.all.filter(
|
||||
subevent_id__in=SubEvent.objects.filter(
|
||||
Q(date_from__lt=value), event=OuterRef(OuterRef('event_id'))
|
||||
).values_list('id'),
|
||||
order_id=OuterRef('pk'),
|
||||
)
|
||||
)
|
||||
).filter(has_se_before=True)
|
||||
return qs
|
||||
|
||||
def search_qs(self, qs, name, value):
|
||||
u = value
|
||||
if "-" in value:
|
||||
@@ -544,10 +558,15 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
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.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
self.perform_create(serializer)
|
||||
try:
|
||||
self.perform_create(serializer)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise ValidationError(_('One of the selected products is not available in the selected country.'))
|
||||
send_mail = serializer._send_mail
|
||||
order = serializer.instance
|
||||
if not order.pk:
|
||||
@@ -563,7 +582,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
auth=request.auth,
|
||||
)
|
||||
|
||||
with language(order.locale):
|
||||
with language(order.locale, self.request.event.settings.region):
|
||||
order_placed.send(self.request.event, order=order)
|
||||
if order.status == Order.STATUS_PAID:
|
||||
order_paid.send(self.request.event, order=order)
|
||||
@@ -655,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'):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.order.locale.changed',
|
||||
@@ -867,7 +897,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
|
||||
price = get_price(**kwargs)
|
||||
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({
|
||||
'gross': price.gross,
|
||||
'gross_formatted': money_filter(price.gross, self.request.event.currency, hide_currency=True),
|
||||
@@ -932,6 +962,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['order'] = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -939,6 +970,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return order.payments.all()
|
||||
|
||||
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.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
@@ -954,7 +986,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth,
|
||||
count_waitinglist=False,
|
||||
force=request.data.get('force', False)
|
||||
force=request.data.get('force', False),
|
||||
send_mail=send_mail,
|
||||
)
|
||||
except Quota.QuotaExceededException:
|
||||
pass
|
||||
|
||||
@@ -6,7 +6,9 @@ from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
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.exceptions import MethodNotAllowed, PermissionDenied
|
||||
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.serializers.organizer import (
|
||||
DeviceSerializer, GiftCardSerializer, OrganizerSerializer,
|
||||
SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer,
|
||||
TeamMemberSerializer, TeamSerializer,
|
||||
DeviceSerializer, GiftCardSerializer, GiftCardTransactionSerializer,
|
||||
OrganizerSerializer, OrganizerSettingsSerializer, SeatingPlanSerializer,
|
||||
TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer,
|
||||
TeamSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Device, GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
|
||||
User,
|
||||
Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team,
|
||||
TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
from pretix.presale.style import regenerate_organizer_css
|
||||
|
||||
|
||||
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@@ -191,6 +196,24 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
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):
|
||||
serializer_class = TeamSerializer
|
||||
queryset = Team.objects.none()
|
||||
@@ -396,3 +419,37 @@ class DeviceViewSet(mixins.CreateModelMixin,
|
||||
data=self.request.data
|
||||
)
|
||||
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)
|
||||
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
|
||||
)
|
||||
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)
|
||||
return Response(s.data)
|
||||
|
||||
@@ -7,7 +7,7 @@ import requests
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from requests import RequestException
|
||||
|
||||
@@ -97,6 +97,67 @@ class ParametrizedOrderWebhookEvent(WebhookEvent):
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedEventWebhookEvent(WebhookEvent):
|
||||
def __init__(self, action_type, verbose_name):
|
||||
self._action_type = action_type
|
||||
self._verbose_name = verbose_name
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return self._action_type
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return self._verbose_name
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
if logentry.action_type == 'pretix.event.deleted':
|
||||
organizer = logentry.content_object
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'organizer': organizer.slug,
|
||||
'event': logentry.parsed_data.get('slug'),
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
event = logentry.content_object
|
||||
if not event:
|
||||
return None
|
||||
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'organizer': event.organizer.slug,
|
||||
'event': event.slug,
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedSubEventWebhookEvent(WebhookEvent):
|
||||
def __init__(self, action_type, verbose_name):
|
||||
self._action_type = action_type
|
||||
self._verbose_name = verbose_name
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return self._action_type
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return self._verbose_name
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
# do not use content_object, this is also called in deletion
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'organizer': logentry.event.organizer.slug,
|
||||
'event': logentry.event.slug,
|
||||
'subevent': logentry.object_id,
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
@@ -169,44 +230,69 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.event.checkin.reverted',
|
||||
_('Ticket check-in reverted'),
|
||||
),
|
||||
ParametrizedEventWebhookEvent(
|
||||
'pretix.event.added',
|
||||
_('Event created'),
|
||||
),
|
||||
ParametrizedEventWebhookEvent(
|
||||
'pretix.event.changed',
|
||||
_('Event details changed'),
|
||||
),
|
||||
ParametrizedEventWebhookEvent(
|
||||
'pretix.event.deleted',
|
||||
_('Event details changed'),
|
||||
),
|
||||
ParametrizedSubEventWebhookEvent(
|
||||
'pretix.subevent.added',
|
||||
pgettext_lazy('subevent', 'Event series date added'),
|
||||
),
|
||||
ParametrizedSubEventWebhookEvent(
|
||||
'pretix.subevent.changed',
|
||||
pgettext_lazy('subevent', 'Event series date changed'),
|
||||
),
|
||||
ParametrizedSubEventWebhookEvent(
|
||||
'pretix.subevent.deleted',
|
||||
pgettext_lazy('subevent', 'Event series date deleted'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=TransactionAwareTask, acks_late=True)
|
||||
def notify_webhooks(logentry_id: int):
|
||||
logentry = LogEntry.all.select_related('event', 'event__organizer').get(id=logentry_id)
|
||||
def notify_webhooks(logentry_ids: list):
|
||||
if not isinstance(logentry_ids, list):
|
||||
logentry_ids = [logentry_ids]
|
||||
qs = LogEntry.all.select_related('event', 'event__organizer').filter(id__in=logentry_ids)
|
||||
_org, _at, webhooks = None, None, None
|
||||
for logentry in qs:
|
||||
if not logentry.organizer:
|
||||
break # We need to know the organizer
|
||||
|
||||
if not logentry.organizer:
|
||||
return # We need to know the organizer
|
||||
notification_type = logentry.webhook_type
|
||||
|
||||
types = get_all_webhook_events()
|
||||
notification_type = None
|
||||
typepath = logentry.action_type
|
||||
while not notification_type and '.' in typepath:
|
||||
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
if not notification_type:
|
||||
break # Ignore, no webhooks for this event type
|
||||
|
||||
if not notification_type:
|
||||
return # Ignore, no webhooks for this event type
|
||||
if _org != logentry.organizer or _at != logentry.action_type or webhooks is None:
|
||||
_org = logentry.organizer
|
||||
_at = logentry.action_type
|
||||
|
||||
# All webhooks that registered for this notification
|
||||
event_listener = WebHookEventListener.objects.filter(
|
||||
webhook=OuterRef('pk'),
|
||||
action_type=notification_type.action_type
|
||||
)
|
||||
# All webhooks that registered for this notification
|
||||
event_listener = WebHookEventListener.objects.filter(
|
||||
webhook=OuterRef('pk'),
|
||||
action_type=notification_type.action_type
|
||||
)
|
||||
webhooks = WebHook.objects.annotate(has_el=Exists(event_listener)).filter(
|
||||
organizer=logentry.organizer,
|
||||
has_el=True,
|
||||
enabled=True
|
||||
)
|
||||
if logentry.event_id:
|
||||
webhooks = webhooks.filter(
|
||||
Q(all_events=True) | Q(limit_events__pk=logentry.event_id)
|
||||
)
|
||||
|
||||
webhooks = WebHook.objects.annotate(has_el=Exists(event_listener)).filter(
|
||||
organizer=logentry.organizer,
|
||||
has_el=True,
|
||||
enabled=True
|
||||
)
|
||||
if logentry.event_id:
|
||||
webhooks = webhooks.filter(
|
||||
Q(all_events=True) | Q(limit_events__pk=logentry.event_id)
|
||||
)
|
||||
|
||||
for wh in webhooks:
|
||||
send_webhook.apply_async(args=(logentry_id, notification_type.action_type, wh.pk))
|
||||
for wh in webhooks:
|
||||
send_webhook.apply_async(args=(logentry.id, notification_type.action_type, wh.pk))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=9, acks_late=True)
|
||||
@@ -250,7 +336,7 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
|
||||
webhook.enabled = False
|
||||
webhook.save()
|
||||
elif resp.status_code > 299:
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2))
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2)) # max is 2 ** (8*2) = 65536 seconds = ~18 hours
|
||||
except RequestException as e:
|
||||
WebHookCall.objects.create(
|
||||
webhook=webhook,
|
||||
@@ -262,6 +348,6 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
|
||||
payload=json.dumps(payload),
|
||||
response_body=str(e)[:1024 * 1024]
|
||||
)
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2))
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2)) # max is 2 ** (8*2) = 65536 seconds = ~18 hours
|
||||
except MaxRetriesExceededError:
|
||||
pass
|
||||
|
||||
@@ -73,8 +73,8 @@ banlist = [
|
||||
"wtf"
|
||||
]
|
||||
|
||||
blacklist_regex = re.compile('(' + '|'.join(banlist) + ')')
|
||||
banlist_regex = re.compile('(' + '|'.join(banlist) + ')')
|
||||
|
||||
|
||||
def banned(string):
|
||||
return bool(blacklist_regex.search(string.lower()))
|
||||
return bool(banlist_regex.search(string.lower()))
|
||||
|
||||
@@ -115,7 +115,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
'body': body_md,
|
||||
'subject': str(subject),
|
||||
'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:
|
||||
htmlctx['event'] = self.event
|
||||
|
||||
@@ -4,3 +4,4 @@ from .invoices import * # noqa
|
||||
from .json import * # noqa
|
||||
from .mail import * # noqa
|
||||
from .orderlist import * # noqa
|
||||
from .waitinglist import * # noqa
|
||||
|
||||
@@ -41,7 +41,7 @@ class MailExporter(BaseExporter):
|
||||
initial=[Order.STATUS_PENDING, Order.STATUS_PAID],
|
||||
choices=Order.STATUS_CHOICE,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False
|
||||
required=True
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -53,9 +53,23 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
initial=True,
|
||||
required=False
|
||||
)),
|
||||
('include_payment_amounts',
|
||||
forms.BooleanField(
|
||||
label=_('Include payment amounts'),
|
||||
initial=False,
|
||||
required=False
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
def _get_all_payment_methods(self, qs):
|
||||
pps = dict(get_all_payment_providers())
|
||||
return sorted([(pp, pps[pp]) for pp in set(
|
||||
OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list(
|
||||
'provider', flat=True
|
||||
).distinct()
|
||||
)], key=lambda pp: pp[0])
|
||||
|
||||
def _get_all_tax_rates(self, qs):
|
||||
tax_rates = set(
|
||||
a for a
|
||||
@@ -125,7 +139,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
tax_rates = self._get_all_tax_rates(qs)
|
||||
|
||||
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'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
||||
@@ -133,8 +147,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(label)
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
|
||||
_('Date of last payment'), _('Fees'), _('Order locale')
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'),
|
||||
_('Custom address field'), _('VAT ID'), _('Date of last payment'), _('Fees'), _('Order locale')
|
||||
]
|
||||
|
||||
for tr in tax_rates:
|
||||
@@ -150,6 +164,10 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Comment'))
|
||||
headers.append(_('Positions'))
|
||||
headers.append(_('Payment providers'))
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
for id, vn in payment_methods:
|
||||
headers.append(_('Paid by {method}').format(method=vn))
|
||||
|
||||
yield headers
|
||||
|
||||
@@ -163,6 +181,23 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
taxsum=Sum('tax_value'), grosssum=Sum('value')
|
||||
)
|
||||
}
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_sum_cache = {
|
||||
(o['order__id'], o['provider']): o['grosssum'] for o in
|
||||
OrderPayment.objects.values('provider', 'order__id').order_by().filter(
|
||||
state__in=[OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED]
|
||||
).annotate(
|
||||
grosssum=Sum('amount')
|
||||
)
|
||||
}
|
||||
refund_sum_cache = {
|
||||
(o['order__id'], o['provider']): o['grosssum'] for o in
|
||||
OrderRefund.objects.values('provider', 'order__id').order_by().filter(
|
||||
state__in=[OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT]
|
||||
).annotate(
|
||||
grosssum=Sum('amount')
|
||||
)
|
||||
}
|
||||
sum_cache = {
|
||||
(o['order__id'], o['tax_rate']): o for o in
|
||||
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
|
||||
@@ -180,6 +215,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.total,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
str(order.phone) if order.phone else '',
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
||||
]
|
||||
@@ -200,10 +236,11 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.state,
|
||||
order.invoice_address.custom_field,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
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 += [
|
||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||
@@ -234,6 +271,14 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
str(self.providers.get(p, p)) for p in sorted(set((order.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]))
|
||||
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
for id, vn in payment_methods:
|
||||
row.append(
|
||||
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
|
||||
refund_sum_cache.get((order.id, id), Decimal('0.00'))
|
||||
)
|
||||
yield row
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
@@ -259,6 +304,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Order code'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Phone number'),
|
||||
_('Order date'),
|
||||
_('Order time'),
|
||||
_('Fee type'),
|
||||
@@ -290,6 +336,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.code,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
str(order.phone) if order.phone else '',
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
||||
op.get_fee_type_display(),
|
||||
@@ -358,6 +405,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Position ID'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Phone number'),
|
||||
_('Order date'),
|
||||
_('Order time'),
|
||||
]
|
||||
@@ -437,6 +485,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
op.positionid,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
str(order.phone) if order.phone else '',
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
order.datetime.astimezone(tz).strftime('%H:%M:%S'),
|
||||
]
|
||||
|
||||
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.conf import settings
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import User
|
||||
from pretix.helpers.dicts import move_to_end
|
||||
from pretix.helpers.http import get_client_ip
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
@@ -18,6 +23,7 @@ class LoginForm(forms.Form):
|
||||
|
||||
error_messages = {
|
||||
'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.")
|
||||
}
|
||||
|
||||
@@ -39,10 +45,36 @@ class LoginForm(forms.Form):
|
||||
else:
|
||||
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):
|
||||
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)
|
||||
if self.user_cache is None:
|
||||
if self.ratelimit_key:
|
||||
rc.incr(self.ratelimit_key)
|
||||
rc.expire(self.ratelimit_key, 300)
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['invalid_login'],
|
||||
code='invalid_login'
|
||||
|
||||
@@ -9,32 +9,38 @@ import pycountry
|
||||
import pytz
|
||||
import vat_moss.errors
|
||||
import vat_moss.id
|
||||
from babel import localedata
|
||||
from babel import Locale
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db.models import QuerySet
|
||||
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.safestring import mark_safe
|
||||
from django.utils.translation import (
|
||||
get_language, gettext_lazy as _, pgettext_lazy,
|
||||
)
|
||||
from django.utils.timezone import get_current_timezone
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||||
from phonenumbers import NumberParseException
|
||||
from phonenumbers import NumberParseException, national_significant_number
|
||||
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
||||
|
||||
from pretix.base.forms.widgets import (
|
||||
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
|
||||
TimePickerWidget, UploadedFileWidget,
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.i18n import (
|
||||
get_babel_locale, get_language_without_region, language,
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
||||
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
|
||||
from pretix.base.models.tax import (
|
||||
EU_COUNTRIES, cc_to_vat_prefix, is_eu_country,
|
||||
)
|
||||
from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
|
||||
@@ -199,7 +205,47 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
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 pretix, 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):
|
||||
|
||||
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):
|
||||
output = super().render(name, value, attrs, renderer)
|
||||
return mark_safe(self.format_output(output))
|
||||
@@ -207,12 +253,44 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
def format_output(self, rendered_widgets) -> str:
|
||||
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):
|
||||
# Try to guess the initial country from either the country of the merchant
|
||||
# or the locale. This will hopefully save at least some users some scrolling :)
|
||||
locale = get_language()
|
||||
country = event.settings.invoice_address_from_country
|
||||
locale = get_language_without_region()
|
||||
country = event.settings.region or event.settings.invoice_address_from_country
|
||||
if not country:
|
||||
valid_countries = countries.countries
|
||||
if '-' in locale:
|
||||
@@ -232,6 +310,43 @@ class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
|
||||
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):
|
||||
"""
|
||||
This form class is responsible for asking order-related questions. This includes
|
||||
@@ -390,9 +505,10 @@ class BaseQuestionsForm(forms.Form):
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
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,
|
||||
initial=initial.answer if initial else None,
|
||||
min_value=Decimal('0.00'),
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
@@ -451,12 +567,21 @@ class BaseQuestionsForm(forms.Form):
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
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(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
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:
|
||||
field = forms.TimeField(
|
||||
label=label, required=required,
|
||||
@@ -469,16 +594,18 @@ class BaseQuestionsForm(forms.Form):
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
|
||||
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
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:
|
||||
babel_locale = 'en'
|
||||
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||
if localedata.exists(get_language()):
|
||||
babel_locale = get_language()
|
||||
elif localedata.exists(get_language()[:2]):
|
||||
babel_locale = get_language()[:2]
|
||||
with language(babel_locale):
|
||||
with language(get_babel_locale()):
|
||||
default_country = guess_country(event)
|
||||
default_prefix = None
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
@@ -648,7 +775,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.fields['state'].widget.is_required = True
|
||||
|
||||
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
|
||||
if cc and cc not in EU_COUNTRIES and fprefix + 'vat_id' in self.data:
|
||||
if cc and not is_eu_country(cc) and fprefix + 'vat_id' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'vat_id']
|
||||
|
||||
@@ -698,7 +825,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if not data.get('is_business'):
|
||||
data['company'] = ''
|
||||
data['vat_id'] = ''
|
||||
if data.get('is_business') and not data.get('country') in EU_COUNTRIES:
|
||||
if data.get('is_business') and not is_eu_country(data.get('country')):
|
||||
data['vat_id'] = ''
|
||||
if self.event.settings.invoice_address_required:
|
||||
if data.get('is_business') and not data.get('company'):
|
||||
@@ -722,7 +849,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.cleaned_data['country'] = ''
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
|
||||
elif self.validate_vat_id and data.get('is_business') and is_eu_country(data.get('country')) and data.get('vat_id'):
|
||||
if data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
|
||||
raise ValidationError(_('Your VAT ID does not match the selected country.'))
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
@@ -19,6 +20,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
"address or password."),
|
||||
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||
'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,
|
||||
@@ -64,6 +66,18 @@ class UserSettingsForm(forms.ModelForm):
|
||||
|
||||
def clean_old_pw(self):
|
||||
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):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_current_wrong'],
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
from django import forms
|
||||
from django.utils.formats import get_format
|
||||
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 _
|
||||
|
||||
|
||||
@@ -92,7 +93,7 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
||||
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
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 {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
@@ -106,6 +107,14 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
date_attrs['autocomplete'] = 'date-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():
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
from babel import localedata
|
||||
from django.conf import settings
|
||||
from django.utils import translation
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
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()
|
||||
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:
|
||||
yield
|
||||
finally:
|
||||
|
||||
@@ -144,7 +144,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
|
||||
def _upper(self, val):
|
||||
# We uppercase labels, but not in every language
|
||||
if get_language() == 'el':
|
||||
if get_language().startswith('el'):
|
||||
return val
|
||||
return val.upper()
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ from urllib.parse import urlsplit
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.middleware.common import CommonMiddleware
|
||||
from django.urls import get_script_prefix
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.cache import patch_vary_headers
|
||||
@@ -14,7 +15,8 @@ from django.utils.translation.trans_real import (
|
||||
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 (
|
||||
get_event_domain, get_organizer_domain,
|
||||
)
|
||||
@@ -34,19 +36,30 @@ class LocaleMiddleware(MiddlewareMixin):
|
||||
# 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
|
||||
# set and can be taken into account for the decision.
|
||||
if hasattr(request, 'event') and not request.path.startswith(get_script_prefix() + 'control'):
|
||||
if language not in request.event.settings.locales:
|
||||
firstpart = language.split('-')[0]
|
||||
if firstpart in request.event.settings.locales:
|
||||
language = firstpart
|
||||
else:
|
||||
language = request.event.settings.locale
|
||||
for lang in request.event.settings.locales:
|
||||
if lang.startswith(firstpart + '-'):
|
||||
language = lang
|
||||
break
|
||||
if not request.path.startswith(get_script_prefix() + 'control'):
|
||||
if hasattr(request, 'event'):
|
||||
if language not in request.event.settings.locales:
|
||||
firstpart = language.split('-')[0]
|
||||
if firstpart in request.event.settings.locales:
|
||||
language = firstpart
|
||||
else:
|
||||
language = request.event.settings.locale
|
||||
for lang in request.event.settings.locales:
|
||||
if lang.startswith(firstpart + '-'):
|
||||
language = lang
|
||||
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)
|
||||
request.LANGUAGE_CODE = translation.get_language()
|
||||
request.LANGUAGE_CODE = get_language_without_region()
|
||||
|
||||
tzname = None
|
||||
if hasattr(request, 'event'):
|
||||
@@ -191,7 +204,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
|
||||
|
||||
img_src = []
|
||||
gs = GlobalSettingsObject()
|
||||
gs = global_settings_object(request)
|
||||
if gs.settings.leaflet_tiles:
|
||||
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
||||
|
||||
@@ -215,6 +228,8 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
h['report-uri'] = ["/csp_report/"]
|
||||
if 'Content-Security-Policy' in resp:
|
||||
_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'"
|
||||
dynamicdomain = "'self'"
|
||||
@@ -252,3 +267,15 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
del resp['Content-Security-Policy']
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
class CustomCommonMiddleware(CommonMiddleware):
|
||||
|
||||
def get_full_path_with_slash(self, request):
|
||||
"""
|
||||
Raise an error regardless of DEBUG mode when in POST, PUT, or PATCH.
|
||||
"""
|
||||
new_path = super().get_full_path_with_slash(request)
|
||||
if request.method in ('POST', 'PUT', 'PATCH'):
|
||||
raise Http404('Please append a / at the end of the URL')
|
||||
return new_path
|
||||
|
||||
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),
|
||||
),
|
||||
]
|
||||
20
src/pretix/base/migrations/0170_remove_hidden_urls.py
Normal file
20
src/pretix/base/migrations/0170_remove_hidden_urls.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.0.9 on 2020-11-23 15:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def remove_old_settings(app, schema_editor):
|
||||
EventSettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
|
||||
|
||||
EventSettingsStore.objects.filter(key__startswith='payment_', key__endswith='__hidden_url').delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0169_checkinlist_gates'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(remove_old_settings, migrations.RunPython.noop)
|
||||
]
|
||||
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 = [
|
||||
]
|
||||
@@ -28,6 +28,8 @@ class CachedFile(models.Model):
|
||||
filename = 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)
|
||||
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)
|
||||
@@ -49,9 +51,8 @@ class LoggingMixin:
|
||||
:param user: The user performing the action (optional)
|
||||
"""
|
||||
from pretix.api.models import OAuthAccessToken, OAuthApplication
|
||||
from pretix.api.webhooks import get_all_webhook_events, notify_webhooks
|
||||
from pretix.api.webhooks import notify_webhooks
|
||||
|
||||
from ..notifications import get_all_notification_types
|
||||
from ..services.notifications import notify
|
||||
from .devices import Device
|
||||
from .event import Event
|
||||
@@ -93,21 +94,11 @@ class LoggingMixin:
|
||||
if save:
|
||||
logentry.save()
|
||||
|
||||
no_types = get_all_notification_types()
|
||||
wh_types = get_all_webhook_events()
|
||||
|
||||
no_type = None
|
||||
wh_type = None
|
||||
typepath = logentry.action_type
|
||||
while (not no_type or not wh_types) and '.' in typepath:
|
||||
wh_type = wh_type or wh_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
no_type = no_type or no_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
|
||||
if no_type:
|
||||
if logentry.notification_type:
|
||||
notify.apply_async(args=(logentry.pk,))
|
||||
if wh_type:
|
||||
if logentry.webhook_type:
|
||||
notify_webhooks.apply_async(args=(logentry.pk,))
|
||||
|
||||
return logentry
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class CheckinList(LoggedModel):
|
||||
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
|
||||
default=False,
|
||||
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(
|
||||
'Gate', verbose_name=_("Gates"), blank=True,
|
||||
help_text=_("Does not have any effect for the validation of tickets, only for the automatic configuration of "
|
||||
|
||||
@@ -222,3 +222,15 @@ class Device(LoggedModel):
|
||||
return self.organizer.events.all()
|
||||
else:
|
||||
return self.limit_events.all()
|
||||
|
||||
def get_events_with_permission(self, permission, request=None):
|
||||
"""
|
||||
Returns a queryset of events the device has a specific permissions to.
|
||||
|
||||
:param request: Ignored, for compatibility with User model
|
||||
:return: Iterable of Events
|
||||
"""
|
||||
if permission in self.permission_set():
|
||||
return self.get_events_with_any_permission()
|
||||
else:
|
||||
return self.organizer.events.none()
|
||||
|
||||
@@ -23,6 +23,7 @@ from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.validators import EventSlugBanlistValidator
|
||||
from pretix.helpers.database import GroupConcat
|
||||
@@ -118,25 +119,49 @@ class EventMixin:
|
||||
def timezone(self):
|
||||
return pytz.timezone(self.settings.timezone)
|
||||
|
||||
@property
|
||||
def effective_presale_end(self):
|
||||
"""
|
||||
Returns the effective presale end date, taking for subevents into consideration if the presale end
|
||||
date might have been further limited by the event-level presale end date
|
||||
"""
|
||||
if isinstance(self, SubEvent):
|
||||
presale_ends = [self.presale_end, self.event.presale_end]
|
||||
return min(filter(lambda x: x is not None, presale_ends)) if any(presale_ends) else None
|
||||
else:
|
||||
return self.presale_end
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
"""
|
||||
Is true, when ``presale_end`` is set and in the past.
|
||||
"""
|
||||
if self.presale_end:
|
||||
return now() > self.presale_end
|
||||
if self.effective_presale_end:
|
||||
return now() > self.effective_presale_end
|
||||
elif self.date_to:
|
||||
return now() > self.date_to
|
||||
else:
|
||||
return now().astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date()
|
||||
|
||||
@property
|
||||
def effective_presale_start(self):
|
||||
"""
|
||||
Returns the effective presale start date, taking for subevents into consideration if the presale start
|
||||
date might have been further limited by the event-level presale start date
|
||||
"""
|
||||
if isinstance(self, SubEvent):
|
||||
presale_starts = [self.presale_start, self.event.presale_start]
|
||||
return max(filter(lambda x: x is not None, presale_starts)) if any(presale_starts) else None
|
||||
else:
|
||||
return self.presale_start
|
||||
|
||||
@property
|
||||
def presale_is_running(self):
|
||||
"""
|
||||
Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not
|
||||
set or in the past.
|
||||
"""
|
||||
if self.presale_start and now() < self.presale_start:
|
||||
if self.effective_presale_start and now() < self.effective_presale_start:
|
||||
return False
|
||||
return not self.presale_has_ended
|
||||
|
||||
@@ -244,6 +269,34 @@ class EventMixin:
|
||||
return Quota.AVAILABILITY_RESERVED
|
||||
return Quota.AVAILABILITY_GONE
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
qs_annotated = self._seats(ignore_voucher=ignore_voucher)
|
||||
|
||||
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if self.settings.seating_minimal_distance > 0:
|
||||
qs = qs.filter(has_closeby_taken=False)
|
||||
|
||||
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
|
||||
def total_seats(self, ignore_voucher=None):
|
||||
return self._seats(ignore_voucher=ignore_voucher)
|
||||
|
||||
def taken_seats(self, ignore_voucher=None):
|
||||
return self._seats(ignore_voucher=ignore_voucher).filter(has_order=True)
|
||||
|
||||
def blocked_seats(self, ignore_voucher=None):
|
||||
qs = self._seats(ignore_voucher=ignore_voucher)
|
||||
q = (
|
||||
Q(has_cart=True)
|
||||
| Q(has_voucher=True)
|
||||
| Q(blocked=True)
|
||||
)
|
||||
if self.settings.seating_minimal_distance > 0:
|
||||
q |= Q(has_closeby_taken=True, has_order=False)
|
||||
return qs.filter(q)
|
||||
|
||||
|
||||
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
||||
class Event(EventMixin, LoggedModel):
|
||||
@@ -279,6 +332,8 @@ class Event(EventMixin, LoggedModel):
|
||||
:type plugins: str
|
||||
:param has_subevents: Enable event series functionality
|
||||
: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'
|
||||
@@ -357,7 +412,11 @@ class Event(EventMixin, LoggedModel):
|
||||
)
|
||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||
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')
|
||||
|
||||
class Meta:
|
||||
@@ -394,7 +453,7 @@ class Event(EventMixin, LoggedModel):
|
||||
if img:
|
||||
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
def _seats(self, ignore_voucher=None):
|
||||
from .seating import Seat
|
||||
|
||||
qs_annotated = Seat.annotated(self.seats, self.pk, None,
|
||||
@@ -402,13 +461,7 @@ class Event(EventMixin, LoggedModel):
|
||||
minimal_distance=self.settings.seating_minimal_distance,
|
||||
distance_only_within_row=self.settings.seating_distance_within_row)
|
||||
|
||||
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if self.settings.seating_minimal_distance > 0:
|
||||
qs = qs.filter(has_closeby_taken=False)
|
||||
|
||||
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
return qs_annotated
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
@@ -475,7 +528,7 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
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
|
||||
or by returning a custom one based on the event's settings.
|
||||
@@ -489,7 +542,7 @@ class Event(EventMixin, LoggedModel):
|
||||
password=self.settings.smtp_password,
|
||||
use_tls=self.settings.smtp_use_tls,
|
||||
use_ssl=self.settings.smtp_use_ssl,
|
||||
fail_silently=False)
|
||||
fail_silently=False, timeout=timeout)
|
||||
else:
|
||||
return get_connection(fail_silently=False)
|
||||
|
||||
@@ -507,11 +560,14 @@ class Event(EventMixin, LoggedModel):
|
||||
def copy_data_from(self, other):
|
||||
from ..signals import event_copy_data
|
||||
from . import (
|
||||
Item, ItemAddOn, ItemCategory, ItemMetaValue, Question, Quota,
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, Question,
|
||||
Quota,
|
||||
)
|
||||
|
||||
self.plugins = other.plugins
|
||||
self.is_public = other.is_public
|
||||
if other.date_admission:
|
||||
self.date_admission = self.date_from + (other.date_admission - other.date_from)
|
||||
self.testmode = other.testmode
|
||||
self.save()
|
||||
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
|
||||
@@ -573,6 +629,14 @@ class Event(EventMixin, LoggedModel):
|
||||
ia.addon_category = category_map[ia.addon_category.pk]
|
||||
ia.save()
|
||||
|
||||
for ia in ItemBundle.objects.filter(base_item__event=other).prefetch_related('base_item', 'bundled_item', 'bundled_variation'):
|
||||
ia.pk = None
|
||||
ia.base_item = item_map[ia.base_item.pk]
|
||||
ia.bundled_item = item_map[ia.bundled_item.pk]
|
||||
if ia.bundled_variation:
|
||||
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
|
||||
ia.save()
|
||||
|
||||
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
|
||||
items = list(q.items.all())
|
||||
vars = list(q.variations.all())
|
||||
@@ -1089,19 +1153,13 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
date_format(self.date_from.astimezone(self.timezone), "TIME_FORMAT") if self.settings.show_times else ""
|
||||
).strip()
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
def _seats(self, ignore_voucher=None):
|
||||
from .seating import Seat
|
||||
qs_annotated = Seat.annotated(self.seats, self.event_id, self,
|
||||
ignore_voucher_id=ignore_voucher.pk if ignore_voucher else None,
|
||||
minimal_distance=self.settings.seating_minimal_distance,
|
||||
distance_only_within_row=self.settings.seating_distance_within_row)
|
||||
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if self.settings.seating_minimal_distance > 0:
|
||||
qs = qs.filter(has_closeby_taken=False)
|
||||
|
||||
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
return qs_annotated
|
||||
|
||||
@cached_property
|
||||
def settings(self):
|
||||
|
||||
@@ -314,7 +314,7 @@ class Item(LoggedModel):
|
||||
)
|
||||
allow_waitinglist = models.BooleanField(
|
||||
verbose_name=_("Show a waiting list for this ticket"),
|
||||
help_text=_("This will only work of waiting lists are enabled for this event."),
|
||||
help_text=_("This will only work if waiting lists are enabled for this event."),
|
||||
default=True
|
||||
)
|
||||
show_quota_left = models.NullBooleanField(
|
||||
@@ -1084,6 +1084,18 @@ class Question(LoggedModel):
|
||||
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -1173,14 +1185,24 @@ class Question(LoggedModel):
|
||||
answer = formats.sanitize_separators(answer)
|
||||
answer = str(answer).strip()
|
||||
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:
|
||||
raise ValidationError(_('Invalid number input.'))
|
||||
elif self.type == Question.TYPE_DATE:
|
||||
if isinstance(answer, date):
|
||||
return answer
|
||||
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:
|
||||
raise ValidationError(_('Invalid date input.'))
|
||||
elif self.type == Question.TYPE_TIME:
|
||||
@@ -1197,9 +1219,14 @@ class Question(LoggedModel):
|
||||
dt = dateutil.parser.parse(answer)
|
||||
if is_naive(dt):
|
||||
dt = make_aware(dt, pytz.timezone(self.event.settings.timezone))
|
||||
return dt
|
||||
except:
|
||||
raise ValidationError(_('Invalid datetime input.'))
|
||||
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:
|
||||
c = Country(answer.upper())
|
||||
if c.name:
|
||||
|
||||
@@ -63,14 +63,42 @@ class LogEntry(models.Model):
|
||||
return response
|
||||
return self.action_type
|
||||
|
||||
@property
|
||||
def webhook_type(self):
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
|
||||
wh_types = get_all_webhook_events()
|
||||
wh_type = None
|
||||
typepath = self.action_type
|
||||
while not wh_type and '.' in typepath:
|
||||
wh_type = wh_type or wh_types.get(typepath + ('.*' if typepath != self.action_type else ''))
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
return wh_type
|
||||
|
||||
@property
|
||||
def notification_type(self):
|
||||
from pretix.base.notifications import get_all_notification_types
|
||||
|
||||
no_type = None
|
||||
no_types = get_all_notification_types()
|
||||
typepath = self.action_type
|
||||
while not no_type and '.' in typepath:
|
||||
no_type = no_type or no_types.get(typepath + ('.*' if typepath != self.action_type else ''))
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
return no_type
|
||||
|
||||
@cached_property
|
||||
def organizer(self):
|
||||
from .organizer import Organizer
|
||||
|
||||
if self.event:
|
||||
return self.event.organizer
|
||||
elif hasattr(self.content_object, 'event'):
|
||||
return self.content_object.event.organizer
|
||||
elif hasattr(self.content_object, 'organizer'):
|
||||
return self.content_object.organizer
|
||||
elif isinstance(self.content_object, Organizer):
|
||||
return self.content_object
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
@@ -188,3 +216,16 @@ class LogEntry(models.Model):
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
raise TypeError("Logs cannot be deleted.")
|
||||
|
||||
@classmethod
|
||||
def bulk_postprocess(cls, objects):
|
||||
from pretix.api.webhooks import notify_webhooks
|
||||
|
||||
from ..services.notifications import notify
|
||||
|
||||
to_notify = [o.id for o in objects if o.notification_type]
|
||||
if to_notify:
|
||||
notify.apply_async(args=(to_notify,))
|
||||
to_wh = [o.id for o in objects if o.webhook_type]
|
||||
if to_wh:
|
||||
notify_webhooks.apply_async(args=(to_wh,))
|
||||
|
||||
@@ -31,6 +31,7 @@ from django_countries.fields import Country
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
from phonenumbers import NumberParseException
|
||||
|
||||
@@ -86,6 +87,8 @@ class Order(LockModel, LoggedModel):
|
||||
:type event: Event
|
||||
:param email: The email of the person who ordered this
|
||||
: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
|
||||
:type testmode: bool
|
||||
:param locale: The locale of this order
|
||||
@@ -144,6 +147,10 @@ class Order(LockModel, LoggedModel):
|
||||
null=True, blank=True,
|
||||
verbose_name=_('E-mail')
|
||||
)
|
||||
phone = PhoneNumberField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Phone number'),
|
||||
)
|
||||
locale = models.CharField(
|
||||
null=True, blank=True, max_length=32,
|
||||
verbose_name=_('Locale')
|
||||
@@ -326,6 +333,9 @@ class Order(LockModel, LoggedModel):
|
||||
payment_sum=payment_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(
|
||||
pending_sum_t=F('total') - Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
||||
@@ -639,7 +649,7 @@ class Order(LockModel, LoggedModel):
|
||||
return
|
||||
|
||||
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.
|
||||
length += 1
|
||||
iteration = 0
|
||||
@@ -857,7 +867,7 @@ class Order(LockModel, LoggedModel):
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale):
|
||||
with language(self.locale, self.event.settings.region):
|
||||
recipient = self.email
|
||||
if position and position.attendee_email:
|
||||
recipient = position.attendee_email
|
||||
@@ -890,7 +900,7 @@ class Order(LockModel, LoggedModel):
|
||||
)
|
||||
|
||||
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_context = get_email_context(event=self.event, order=self)
|
||||
email_subject = _('Your order: %(code)s') % {'code': self.code}
|
||||
@@ -902,7 +912,7 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
@property
|
||||
def positions_with_tickets(self):
|
||||
for op in self.positions.all():
|
||||
for op in self.positions.select_related('item'):
|
||||
if not op.generate_ticket:
|
||||
continue
|
||||
yield op
|
||||
@@ -1155,7 +1165,7 @@ class AbstractPosition(models.Model):
|
||||
(2) questions: a list of Question objects, extended by an 'answer' property
|
||||
"""
|
||||
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
|
||||
|
||||
# We need to clone our question objects, otherwise we will override the cached
|
||||
@@ -1514,7 +1524,7 @@ class OrderPayment(models.Model):
|
||||
def _send_paid_mail_attendee(self, position, user):
|
||||
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_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
||||
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
|
||||
@@ -1532,7 +1542,7 @@ class OrderPayment(models.Model):
|
||||
def _send_paid_mail(self, invoice, user, mail_text):
|
||||
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_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}
|
||||
@@ -1600,6 +1610,10 @@ class OrderPayment(models.Model):
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
})
|
||||
|
||||
if self.order.pending_sum + r.amount == Decimal('0.00'):
|
||||
self.refund.done()
|
||||
|
||||
return r
|
||||
|
||||
|
||||
@@ -1870,7 +1884,7 @@ class OrderFee(models.Model):
|
||||
self.tax_rule = self.order.event.settings.tax_rate_default
|
||||
|
||||
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_value = tax.tax
|
||||
else:
|
||||
@@ -2022,9 +2036,11 @@ class OrderPosition(AbstractPosition):
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
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_value = tax.tax
|
||||
if tax.gross != self.price:
|
||||
raise ValueError('Invalid tax calculation')
|
||||
else:
|
||||
self.tax_value = Decimal('0.00')
|
||||
self.tax_rate = Decimal('0.00')
|
||||
@@ -2034,6 +2050,7 @@ class OrderPosition(AbstractPosition):
|
||||
|
||||
if self.tax_rate is None:
|
||||
self._calculate_tax()
|
||||
|
||||
self.order.touch()
|
||||
if not self.pk:
|
||||
while not self.secret or OrderPosition.all.filter(
|
||||
@@ -2097,7 +2114,7 @@ class OrderPosition(AbstractPosition):
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.order.locale):
|
||||
with language(self.order.locale, self.order.event.settings.region):
|
||||
recipient = self.attendee_email
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
@@ -2125,7 +2142,7 @@ class OrderPosition(AbstractPosition):
|
||||
|
||||
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_context = get_email_context(event=self.order.event, order=self.order, position=self)
|
||||
email_subject = _('Your event registration: %(code)s') % {'code': self.order.code}
|
||||
|
||||
@@ -357,3 +357,15 @@ class TeamAPIToken(models.Model):
|
||||
return self.team.organizer.events.all()
|
||||
else:
|
||||
return self.team.limit_events.all()
|
||||
|
||||
def get_events_with_permission(self, permission, request=None):
|
||||
"""
|
||||
Returns a queryset of events the token has a specific permissions to.
|
||||
|
||||
:param request: Ignored, for compatibility with User model
|
||||
:return: Iterable of Events
|
||||
"""
|
||||
if getattr(self.team, permission, False):
|
||||
return self.get_events_with_any_permission()
|
||||
else:
|
||||
return self.team.organizer.events.none()
|
||||
|
||||
@@ -130,7 +130,7 @@ class Seat(models.Model):
|
||||
seat_number = models.CharField(max_length=190, blank=True, default="")
|
||||
seat_label = models.CharField(max_length=190, null=True)
|
||||
seat_guid = models.CharField(max_length=190, db_index=True)
|
||||
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
|
||||
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.SET_NULL)
|
||||
blocked = models.BooleanField(default=False)
|
||||
sorting_rank = models.BigIntegerField(default=0)
|
||||
x = models.FloatField(null=True)
|
||||
|
||||
@@ -4,8 +4,10 @@ from decimal import Decimal
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
from i18nfield.fields import I18nCharField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
@@ -85,6 +87,14 @@ EU_CURRENCIES = {
|
||||
}
|
||||
|
||||
|
||||
def is_eu_country(cc):
|
||||
cc = str(cc)
|
||||
if cc == 'GB':
|
||||
return now().astimezone(get_current_timezone()).year <= 2020
|
||||
else:
|
||||
return cc in EU_COUNTRIES
|
||||
|
||||
|
||||
def cc_to_vat_prefix(country_code):
|
||||
if country_code == 'GR':
|
||||
return 'EL'
|
||||
@@ -127,6 +137,9 @@ class TaxRule(LoggedModel):
|
||||
class Meta:
|
||||
ordering = ('event', 'rate', 'id')
|
||||
|
||||
class SaleNotAllowed(Exception):
|
||||
pass
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import OrderFee, OrderPosition
|
||||
|
||||
@@ -169,12 +182,14 @@ class TaxRule(LoggedModel):
|
||||
return Decimal('0.00')
|
||||
if self.has_custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
if rule.get('action', 'vat') == 'block':
|
||||
raise self.SaleNotAllowed()
|
||||
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
|
||||
return Decimal(rule.get('rate'))
|
||||
return Decimal(self.rate)
|
||||
|
||||
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
|
||||
try:
|
||||
currency = currency or self.event.currency
|
||||
@@ -186,7 +201,7 @@ class TaxRule(LoggedModel):
|
||||
rate = override_tax_rate
|
||||
elif 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
|
||||
elif adjust_rate != rate:
|
||||
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||
@@ -241,7 +256,7 @@ class TaxRule(LoggedModel):
|
||||
rules = self._custom_rules
|
||||
if invoice_address:
|
||||
for r in rules:
|
||||
if r['country'] == 'EU' and str(invoice_address.country) not in EU_COUNTRIES:
|
||||
if r['country'] == 'EU' and not is_eu_country(invoice_address.country):
|
||||
continue
|
||||
if r['country'] not in ('ZZ', 'EU') and r['country'] != str(invoice_address.country):
|
||||
continue
|
||||
@@ -254,6 +269,25 @@ class TaxRule(LoggedModel):
|
||||
return r
|
||||
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):
|
||||
if self._custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
@@ -265,7 +299,7 @@ class TaxRule(LoggedModel):
|
||||
if not invoice_address or not invoice_address.country:
|
||||
return False
|
||||
|
||||
if str(invoice_address.country) not in EU_COUNTRIES:
|
||||
if not is_eu_country(invoice_address.country):
|
||||
return False
|
||||
|
||||
if invoice_address.country == self.home_country:
|
||||
@@ -279,6 +313,8 @@ class TaxRule(LoggedModel):
|
||||
def _tax_applicable(self, invoice_address):
|
||||
if self._custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
if rule.get('action', 'vat') == 'block':
|
||||
raise self.SaleNotAllowed()
|
||||
return rule.get('action', 'vat') == 'vat'
|
||||
|
||||
if not self.eu_reverse_charge:
|
||||
@@ -289,7 +325,7 @@ class TaxRule(LoggedModel):
|
||||
# No country specified? Always apply VAT!
|
||||
return True
|
||||
|
||||
if str(invoice_address.country) not in EU_COUNTRIES:
|
||||
if not is_eu_country(invoice_address.country):
|
||||
# Non-EU country? Never apply VAT!
|
||||
return False
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ class WaitingListEntry(LoggedModel):
|
||||
self.voucher = v
|
||||
self.save()
|
||||
|
||||
with language(self.locale):
|
||||
with language(self.locale, self.event.settings.region):
|
||||
mail(
|
||||
self.email,
|
||||
_('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
|
||||
|
||||
@@ -513,7 +513,7 @@ class BasePaymentProvider:
|
||||
|
||||
return timing and pricing
|
||||
|
||||
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
|
||||
def payment_form_render(self, request: HttpRequest, total: Decimal, order: Order=None) -> str:
|
||||
"""
|
||||
When the user selects this provider as their preferred payment method,
|
||||
they will be shown the HTML you return from this method.
|
||||
@@ -522,13 +522,15 @@ class BasePaymentProvider:
|
||||
and render the returned form. If your payment method doesn't require
|
||||
the user to fill out form fields, you should just return a paragraph
|
||||
of explanatory text.
|
||||
|
||||
:param order: Only set when this is a change to a new payment method for an existing order.
|
||||
"""
|
||||
form = self.payment_form(request)
|
||||
template = get_template('pretixpresale/event/checkout_payment_form_default.html')
|
||||
ctx = {'request': request, 'form': form}
|
||||
return template.render(ctx)
|
||||
|
||||
def checkout_confirm_render(self, request) -> str:
|
||||
def checkout_confirm_render(self, request, order: Order=None) -> str:
|
||||
"""
|
||||
If the user has successfully filled in their payment data, they will be redirected
|
||||
to a confirmation page which lists all details of their order for a final review.
|
||||
@@ -537,6 +539,8 @@ class BasePaymentProvider:
|
||||
|
||||
In most cases, this should include a short summary of the user's input and
|
||||
a short explanation on how the payment process will continue.
|
||||
|
||||
:param order: Only set when this is a change to a new payment method for an existing order.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ from pretix.base.models import Order, OrderPosition
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import layout_text_variables
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.phone_format import phone_format
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -121,6 +122,26 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
'editor_sample': _('John Doe\nSample company\nSesame Street 42\n12345 Any City\nAtlantis'),
|
||||
'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", {
|
||||
"label": _("Attendee country"),
|
||||
"editor_sample": 'Atlantis',
|
||||
@@ -209,6 +230,11 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("Random City"),
|
||||
"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", {
|
||||
"label": _("Invoice address name"),
|
||||
"editor_sample": _("John Doe"),
|
||||
@@ -219,11 +245,31 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("Sample company"),
|
||||
"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", {
|
||||
"label": _("Invoice address city"),
|
||||
"editor_sample": _("Sample city"),
|
||||
"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", {
|
||||
"label": _("List of Add-Ons"),
|
||||
"editor_sample": _("Add-on 1\nAdd-on 2"),
|
||||
@@ -381,6 +427,7 @@ class Renderer:
|
||||
self.layout = layout
|
||||
self.background_file = background_file
|
||||
self.variables = get_variables(event)
|
||||
self.event = event
|
||||
if self.background_file:
|
||||
self.bg_bytes = self.background_file.read()
|
||||
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):
|
||||
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)
|
||||
|
||||
ev = self._get_ev(op, order)
|
||||
|
||||
0
src/pretix/base/secretgenerators/__init__.py
Normal file
0
src/pretix/base/secretgenerators/__init__.py
Normal file
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import inspect
|
||||
import struct
|
||||
|
||||
from cryptography.hazmat.backends.openssl.backend import Backend
|
||||
@@ -52,10 +53,10 @@ class BaseTicketSecretGenerator:
|
||||
return False
|
||||
|
||||
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
|
||||
current_secret: str = None, force_invalidate=False) -> str:
|
||||
attendee_name: str = None, current_secret: str = None, force_invalidate=False) -> str:
|
||||
"""
|
||||
Generate a new secret for a ticket with product ``item``, variation ``variation``, subevent ``subevent``,
|
||||
and the current secret ``current_secret`` (if any).
|
||||
attendee name ``attendee_name`` (can be ``None``) and the current secret ``current_secret`` (if any).
|
||||
|
||||
The result must be a string that should only contain the characters ``A-Za-z0-9+/=``.
|
||||
|
||||
@@ -70,6 +71,11 @@ class BaseTicketSecretGenerator:
|
||||
If ``force_invalidate`` is set to ``False`` and ``item``, ``variation`` and ``subevent`` have a different value
|
||||
as when ``current_secret`` was generated, then this method MAY OR MAY NOT return ``current_secret`` unchanged,
|
||||
depending on the semantics of the method.
|
||||
|
||||
.. note:: While it is guaranteed that ``generate_secret`` and the revocation list process are called every
|
||||
time the ``item``, ``variation``, or ``subevent`` parameters change, it is currently **NOT**
|
||||
guaranteed that this process is triggered if the ``attendee_name`` parameter changes. You should
|
||||
therefore not rely on this value for more than informational or debugging purposes.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -80,7 +86,7 @@ class RandomTicketSecretGenerator(BaseTicketSecretGenerator):
|
||||
use_revocation_list = False
|
||||
|
||||
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
|
||||
current_secret: str = None, force_invalidate=False):
|
||||
attendee_name: str = None, current_secret: str = None, force_invalidate=False):
|
||||
if current_secret and not force_invalidate:
|
||||
return current_secret
|
||||
return get_random_string(
|
||||
@@ -187,12 +193,17 @@ def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_us
|
||||
gen = event.ticket_secret_generator
|
||||
if gen.use_revocation_list and force_invalidate_if_revokation_list_used:
|
||||
force_invalidate = True
|
||||
|
||||
kwargs = {}
|
||||
if 'attendee_name' in inspect.signature(gen.generate_secret).parameters:
|
||||
kwargs['attendee_name'] = position.attendee_name
|
||||
secret = gen.generate_secret(
|
||||
item=position.item,
|
||||
variation=position.variation,
|
||||
subevent=position.subevent,
|
||||
current_secret=position.secret,
|
||||
force_invalidate=force_invalidate
|
||||
force_invalidate=force_invalidate,
|
||||
**kwargs
|
||||
)
|
||||
changed = position.secret != secret
|
||||
if position.secret and changed and gen.use_revocation_list:
|
||||
|
||||
@@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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)
|
||||
try:
|
||||
mail(
|
||||
@@ -41,7 +41,7 @@ def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: Lazy
|
||||
|
||||
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
|
||||
refund_amount: Decimal, user: User, positions: list):
|
||||
with language(order.locale):
|
||||
with language(order.locale, order.event.settings.region):
|
||||
try:
|
||||
ia = order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
@@ -65,7 +65,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
|
||||
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
real_subject = str(subject).format_map(TolerantDict(email_context))
|
||||
email_context = get_email_context(event_or_subevent=subevent or order.event,
|
||||
email_context = get_email_context(event_or_subevent=p.subevent or order.event,
|
||||
event=order.event,
|
||||
refund_amount=refund_amount,
|
||||
position_or_address=p,
|
||||
@@ -82,11 +82,12 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_fixed: str,
|
||||
keep_fee_percentage: str, keep_fees: list=None, manual_refund: bool=False,
|
||||
send: bool=False, send_subject: dict=None, send_message: dict=None,
|
||||
def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
keep_fee_fixed: str, keep_fee_per_ticket: str, keep_fee_percentage: str, keep_fees: list=None,
|
||||
manual_refund: bool=False, send: bool=False, send_subject: dict=None, send_message: dict=None,
|
||||
send_waitinglist: bool=False, send_waitinglist_subject: dict={}, send_waitinglist_message: dict={},
|
||||
user: int=None, refund_as_giftcard: bool=False, giftcard_expires=None, giftcard_conditions=None):
|
||||
user: int=None, refund_as_giftcard: bool=False, giftcard_expires=None, giftcard_conditions=None,
|
||||
subevents_from: str=None, subevents_to: str=None):
|
||||
send_subject = LazyI18nString(send_subject)
|
||||
send_message = LazyI18nString(send_message)
|
||||
send_waitinglist_subject = LazyI18nString(send_waitinglist_subject)
|
||||
@@ -102,14 +103,20 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
|
||||
pcnt__gt=0
|
||||
).all()
|
||||
|
||||
if subevent:
|
||||
subevent = event.subevents.get(pk=subevent)
|
||||
if subevent or subevents_from:
|
||||
if subevent:
|
||||
subevents = event.subevents.filter(pk=subevent)
|
||||
subevent = subevents.first()
|
||||
subevent_ids = {subevent.pk}
|
||||
else:
|
||||
subevents = event.subevents.filter(date_from__gte=subevents_from, date_from__lt=subevents_to)
|
||||
subevent_ids = set(subevents.values_list('id', flat=True))
|
||||
|
||||
has_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).filter(
|
||||
subevent=subevent
|
||||
subevent__in=subevents
|
||||
)
|
||||
has_other_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).exclude(
|
||||
subevent=subevent
|
||||
subevent__in=subevents
|
||||
)
|
||||
orders_to_change = orders_to_cancel.annotate(
|
||||
has_subevent=Exists(has_subevent),
|
||||
@@ -124,15 +131,18 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
|
||||
has_subevent=True, has_other_subevent=False
|
||||
)
|
||||
|
||||
subevent.log_action(
|
||||
'pretix.subevent.canceled', user=user,
|
||||
)
|
||||
subevent.active = False
|
||||
subevent.save(update_fields=['active'])
|
||||
subevent.log_action(
|
||||
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
for se in subevents:
|
||||
se.log_action(
|
||||
'pretix.subevent.canceled', user=user,
|
||||
)
|
||||
se.active = False
|
||||
se.save(update_fields=['active'])
|
||||
se.log_action(
|
||||
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
else:
|
||||
subevents = None
|
||||
subevent_ids = set()
|
||||
orders_to_change = event.orders.none()
|
||||
event.log_action(
|
||||
'pretix.event.canceled', user=user,
|
||||
@@ -146,7 +156,9 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
|
||||
)
|
||||
failed = 0
|
||||
total = orders_to_cancel.count() + orders_to_change.count()
|
||||
qs_wl = event.waitinglistentries.filter(subevent=subevent, voucher__isnull=True)
|
||||
qs_wl = event.waitinglistentries.filter(voucher__isnull=True).select_related('subevent')
|
||||
if subevents:
|
||||
qs_wl = qs_wl.filter(subevent__in=subevents)
|
||||
if send_waitinglist:
|
||||
total += qs_wl.count()
|
||||
counter = 0
|
||||
@@ -170,6 +182,10 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
|
||||
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * (o.total - fee_sum)
|
||||
if keep_fee_fixed:
|
||||
fee += Decimal(keep_fee_fixed)
|
||||
if keep_fee_per_ticket:
|
||||
for p in o.positions.all():
|
||||
if p.addon_to_id is None:
|
||||
fee += min(p.price, Decimal(keep_fee_per_ticket))
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
|
||||
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
|
||||
@@ -201,16 +217,20 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
|
||||
with transaction.atomic():
|
||||
o = event.orders.select_for_update().get(pk=o)
|
||||
total = Decimal('0.00')
|
||||
fee = Decimal('0.00')
|
||||
positions = []
|
||||
|
||||
ocm = OrderChangeManager(o, user=user, notify=False)
|
||||
for p in o.positions.all():
|
||||
if p.subevent == subevent:
|
||||
if p.subevent_id in subevent_ids:
|
||||
total += p.price
|
||||
ocm.cancel(p)
|
||||
positions.append(p)
|
||||
|
||||
fee = Decimal('0.00')
|
||||
if keep_fee_per_ticket:
|
||||
if p.addon_to_id is None:
|
||||
fee += min(p.price, Decimal(keep_fee_per_ticket))
|
||||
|
||||
if keep_fee_fixed:
|
||||
fee += Decimal(keep_fee_fixed)
|
||||
if keep_fee_percentage:
|
||||
@@ -246,7 +266,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_
|
||||
|
||||
if send_waitinglist:
|
||||
for wle in qs_wl:
|
||||
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, subevent)
|
||||
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, wle.subevent)
|
||||
|
||||
counter += 1
|
||||
if not self.request.called_directly and counter % max(10, total // 100) == 0:
|
||||
|
||||
@@ -106,6 +106,7 @@ error_messages = {
|
||||
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
|
||||
'seat_multiple': _('You can not select the same seat multiple times.'),
|
||||
'gift_card': _("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
|
||||
'country_blocked': _('One of the selected products is not available in the selected country.'),
|
||||
}
|
||||
|
||||
|
||||
@@ -324,6 +325,8 @@ class CartManager:
|
||||
custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices,
|
||||
invoice_address=self.invoice_address, force_custom_price=force_custom_price, bundled_sum=bundled_sum
|
||||
)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise CartError(error_messages['country_blocked'])
|
||||
except ValueError as e:
|
||||
if str(e) == 'price_too_high':
|
||||
raise CartError(error_messages['price_too_high'])
|
||||
@@ -1063,6 +1066,7 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
|
||||
if pos.tax_rate != rate:
|
||||
current_net = pos.price - pos.tax_value
|
||||
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
||||
totaldiff += new_gross - pos.price
|
||||
pos.price = new_gross
|
||||
pos.includes_tax = rate != Decimal('0.00')
|
||||
pos.override_tax_rate = rate
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import override
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Event, Organizer, User, cachedfile_name,
|
||||
CachedFile, Device, Event, Organizer, TeamAPIToken, User, cachedfile_name,
|
||||
)
|
||||
from pretix.base.services.tasks import (
|
||||
ProfiledEventTask, ProfiledOrganizerUserTask,
|
||||
@@ -31,7 +32,7 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
|
||||
)
|
||||
|
||||
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)
|
||||
for receiver, response in responses:
|
||||
ex = response(event, set_progress)
|
||||
@@ -48,7 +49,13 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
|
||||
|
||||
|
||||
@app.task(base=ProfiledOrganizerUserTask, throws=(ExportError,), bind=True)
|
||||
def multiexport(self, organizer: Organizer, user: User, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
|
||||
def multiexport(self, organizer: Organizer, user: User, device: int, token: int, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
|
||||
if device:
|
||||
device = Device.objects.get(pk=device)
|
||||
if token:
|
||||
device = TeamAPIToken.objects.get(pk=token)
|
||||
allowed_events = (device or token or user).get_events_with_permission('can_view_orders')
|
||||
|
||||
def set_progress(val):
|
||||
if not self.request.called_directly:
|
||||
self.update_state(
|
||||
@@ -57,10 +64,25 @@ def multiexport(self, organizer: Organizer, user: User, fileid: str, provider: s
|
||||
)
|
||||
|
||||
file = CachedFile.objects.get(id=fileid)
|
||||
with language(user.locale), override(user.timezone):
|
||||
allowed_events = user.get_events_with_permission('can_view_orders')
|
||||
|
||||
events = allowed_events.filter(pk__in=form_data.get('events'))
|
||||
if user:
|
||||
locale = user.locale
|
||||
timezone = user.timezone
|
||||
region = None # todo: add to user?
|
||||
else:
|
||||
e = allowed_events.first()
|
||||
if e:
|
||||
locale = e.settings.locale
|
||||
timezone = e.settings.timezone
|
||||
region = e.settings.region
|
||||
else:
|
||||
locale = settings.LANGUAGE_CODE
|
||||
timezone = settings.TIME_ZONE
|
||||
region = None
|
||||
with language(locale, region), override(timezone):
|
||||
if isinstance(form_data['events'][0], str):
|
||||
events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer)
|
||||
else:
|
||||
events = allowed_events.filter(pk__in=form_data.get('events'))
|
||||
responses = register_multievent_data_exporters.send(organizer)
|
||||
|
||||
for receiver, response in responses:
|
||||
|
||||
@@ -24,7 +24,7 @@ from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
|
||||
)
|
||||
from pretix.base.models.tax import EU_COUNTRIES, EU_CURRENCIES
|
||||
from pretix.base.models.tax import EU_CURRENCIES
|
||||
from pretix.base.services.tasks import TransactionAwareTask
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.base.signals import invoice_line_text, periodic_task
|
||||
@@ -43,7 +43,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
|
||||
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_name = invoice.event.settings.get('invoice_address_from_name')
|
||||
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
|
||||
|
||||
positions.sort(key=lambda p: p.sort_key)
|
||||
|
||||
tax_texts = []
|
||||
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:
|
||||
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:
|
||||
reverse_charge = True
|
||||
|
||||
if reverse_charge:
|
||||
if invoice.additional_text:
|
||||
invoice.additional_text += "<br /><br />"
|
||||
if str(invoice.invoice_to_country) in EU_COUNTRIES:
|
||||
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()
|
||||
if p.tax_rule:
|
||||
tax_text = p.tax_rule.invoice_text(ia)
|
||||
if tax_text and tax_text not in tax_texts:
|
||||
tax_texts.append(tax_text)
|
||||
|
||||
offset = len(positions)
|
||||
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 ''
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -240,7 +244,7 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
|
||||
cancellation.date = timezone.now().date()
|
||||
cancellation.payment_provider_text = ''
|
||||
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_name = invoice.event.settings.get('invoice_address_from_name')
|
||||
cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||
@@ -293,7 +297,7 @@ def invoice_pdf_task(invoice: int):
|
||||
return None
|
||||
if i.file:
|
||||
i.file.delete()
|
||||
with language(i.locale):
|
||||
with language(i.locale, i.event.settings.region):
|
||||
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
|
||||
i.file.save(fname, ContentFile(fcontent))
|
||||
i.save()
|
||||
@@ -324,7 +328,7 @@ def build_preview_invoice_pdf(event):
|
||||
if not locale or locale == '__user__':
|
||||
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(),
|
||||
expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count())
|
||||
invoice = Invoice(
|
||||
|
||||
@@ -290,7 +290,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
except Order.DoesNotExist:
|
||||
order = None
|
||||
else:
|
||||
with language(order.locale):
|
||||
with language(order.locale, event.settings.region):
|
||||
if position:
|
||||
try:
|
||||
position = order.positions.get(pk=position)
|
||||
@@ -372,7 +372,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
backend.send_messages([email])
|
||||
except smtplib.SMTPResponseException as e:
|
||||
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
|
||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
|
||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
|
||||
logger.exception('Error sending email')
|
||||
|
||||
if order:
|
||||
@@ -389,7 +389,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||
except Exception as e:
|
||||
if isinstance(e, (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, ssl.SSLError, OSError)):
|
||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
|
||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
|
||||
if order:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.error',
|
||||
|
||||
@@ -15,55 +15,59 @@ from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
@app.task(base=TransactionAwareTask, acks_late=True)
|
||||
@scopes_disabled()
|
||||
def notify(logentry_id: int):
|
||||
logentry = LogEntry.all.select_related('event', 'event__organizer').get(id=logentry_id)
|
||||
if not logentry.event:
|
||||
return # Ignore, we only have event-related notifications right now
|
||||
types = get_all_notification_types(logentry.event)
|
||||
def notify(logentry_ids: list):
|
||||
if not isinstance(logentry_ids, list):
|
||||
logentry_ids = [logentry_ids]
|
||||
|
||||
notification_type = None
|
||||
typepath = logentry.action_type
|
||||
while not notification_type and '.' in typepath:
|
||||
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
qs = LogEntry.all.select_related('event', 'event__organizer').filter(id__in=logentry_ids)
|
||||
|
||||
if not notification_type:
|
||||
return # No suitable plugin
|
||||
_event, _at, notify_specific, notify_global = None, None, None, None
|
||||
for logentry in qs:
|
||||
if not logentry.event:
|
||||
break # Ignore, we only have event-related notifications right now
|
||||
|
||||
# All users that have the permission to get the notification
|
||||
users = logentry.event.get_users_with_permission(
|
||||
notification_type.required_permission
|
||||
).filter(notifications_send=True, is_active=True)
|
||||
if logentry.user:
|
||||
users = users.exclude(pk=logentry.user.pk)
|
||||
notification_type = logentry.notification_type
|
||||
|
||||
# Get all notification settings, both specific to this event as well as global
|
||||
notify_specific = {
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
event=logentry.event,
|
||||
action_type=notification_type.action_type,
|
||||
user__pk__in=users.values_list('pk', flat=True)
|
||||
)
|
||||
}
|
||||
notify_global = {
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
event__isnull=True,
|
||||
action_type=notification_type.action_type,
|
||||
user__pk__in=users.values_list('pk', flat=True)
|
||||
)
|
||||
}
|
||||
if not notification_type:
|
||||
break # No suitable plugin
|
||||
|
||||
for um, enabled in notify_specific.items():
|
||||
user, method = um
|
||||
if enabled:
|
||||
send_notification.apply_async(args=(logentry_id, notification_type.action_type, user.pk, method))
|
||||
if _event != logentry.event or _at != logentry.action_type or notify_global is None:
|
||||
_event = logentry.event
|
||||
_at = logentry.action_type
|
||||
# All users that have the permission to get the notification
|
||||
users = logentry.event.get_users_with_permission(
|
||||
notification_type.required_permission
|
||||
).filter(notifications_send=True, is_active=True)
|
||||
if logentry.user:
|
||||
users = users.exclude(pk=logentry.user.pk)
|
||||
|
||||
for um, enabled in notify_global.items():
|
||||
user, method = um
|
||||
if enabled and um not in notify_specific:
|
||||
send_notification.apply_async(args=(logentry_id, notification_type.action_type, user.pk, method))
|
||||
# Get all notification settings, both specific to this event as well as global
|
||||
notify_specific = {
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
event=logentry.event,
|
||||
action_type=notification_type.action_type,
|
||||
user__pk__in=users.values_list('pk', flat=True)
|
||||
)
|
||||
}
|
||||
notify_global = {
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
event__isnull=True,
|
||||
action_type=notification_type.action_type,
|
||||
user__pk__in=users.values_list('pk', flat=True)
|
||||
)
|
||||
}
|
||||
|
||||
for um, enabled in notify_specific.items():
|
||||
user, method = um
|
||||
if enabled:
|
||||
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
|
||||
|
||||
for um, enabled in notify_global.items():
|
||||
user, method = um
|
||||
if enabled and um not in notify_specific:
|
||||
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, acks_late=True)
|
||||
|
||||
@@ -65,7 +65,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
|
||||
# TODO: quotacheck?
|
||||
cf = CachedFile.objects.get(id=fileid)
|
||||
user = User.objects.get(pk=user)
|
||||
with language(locale):
|
||||
with language(locale, event.settings.region):
|
||||
cols = get_all_columns(event)
|
||||
parsed = parse_csv(cf.file)
|
||||
orders = []
|
||||
@@ -163,7 +163,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
|
||||
)
|
||||
|
||||
for o in orders:
|
||||
with language(o.locale):
|
||||
with language(o.locale, event.settings.region):
|
||||
order_placed.send(event, order=o)
|
||||
if o.status == Order.STATUS_PAID:
|
||||
order_paid.send(event, order=o)
|
||||
|
||||
@@ -23,7 +23,9 @@ from django_scopes import scopes_disabled
|
||||
from pretix.api.models import OAuthApplication
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.i18n import (
|
||||
LazyLocaleException, get_language_without_region, language,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order,
|
||||
OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
||||
@@ -89,6 +91,7 @@ error_messages = {
|
||||
'positions have been removed from your cart.'),
|
||||
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
|
||||
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
||||
'country_blocked': _('One of the selected products is not available in the selected country.'),
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -259,7 +262,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
# send_mail will trigger PDF generation later
|
||||
|
||||
if send_mail:
|
||||
with language(order.locale):
|
||||
with language(order.locale, order.event.settings.region):
|
||||
if order.total == Decimal('0.00'):
|
||||
email_template = order.event.settings.mail_text_order_approved_free
|
||||
email_subject = _('Order approved and confirmed: %(code)s') % {'code': order.code}
|
||||
@@ -310,7 +313,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
if send_mail:
|
||||
email_template = order.event.settings.mail_text_order_denied
|
||||
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}
|
||||
try:
|
||||
order.send_mail(
|
||||
@@ -421,7 +424,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
|
||||
if send_mail:
|
||||
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_subject = _('Order canceled: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
@@ -615,34 +618,39 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
current_discount = cp.price_before_voucher - cp.price
|
||||
max_discount = max(v_budget[cp.voucher] + current_discount, 0)
|
||||
|
||||
if cp.is_bundled:
|
||||
try:
|
||||
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
|
||||
bprice = bundle.designated_price or 0
|
||||
except ItemBundle.DoesNotExist:
|
||||
bprice = cp.price
|
||||
except ItemBundle.MultipleObjectsReturned:
|
||||
raise OrderError("Invalid product configuration (duplicate bundle)")
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
|
||||
custom_price_is_tax_rate=cp.override_tax_rate,
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
|
||||
custom_price_is_tax_rate=cp.override_tax_rate,
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
changed_prices[cp.pk] = bprice
|
||||
else:
|
||||
bundled_sum = 0
|
||||
if not cp.addon_to_id:
|
||||
for bundledp in cp.addons.all():
|
||||
if bundledp.is_bundled:
|
||||
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
|
||||
try:
|
||||
if cp.is_bundled:
|
||||
try:
|
||||
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
|
||||
bprice = bundle.designated_price or 0
|
||||
except ItemBundle.DoesNotExist:
|
||||
bprice = cp.price
|
||||
except ItemBundle.MultipleObjectsReturned:
|
||||
raise OrderError("Invalid product configuration (duplicate bundle)")
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
|
||||
custom_price_is_tax_rate=cp.override_tax_rate,
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
|
||||
custom_price_is_tax_rate=cp.override_tax_rate,
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
changed_prices[cp.pk] = bprice
|
||||
else:
|
||||
bundled_sum = 0
|
||||
if not cp.addon_to_id:
|
||||
for bundledp in cp.addons.all():
|
||||
if bundledp.is_bundled:
|
||||
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
|
||||
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
|
||||
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
|
||||
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
|
||||
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
|
||||
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
|
||||
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
|
||||
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
|
||||
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
|
||||
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
|
||||
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
|
||||
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
err = err or error_messages['country_blocked']
|
||||
cp.delete()
|
||||
continue
|
||||
|
||||
if max_discount is not None:
|
||||
v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross)
|
||||
@@ -770,8 +778,9 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
status=Order.STATUS_PENDING,
|
||||
event=event,
|
||||
email=email,
|
||||
phone=(meta_info or {}).get('contact_form_data', {}).get('phone'),
|
||||
datetime=now_dt,
|
||||
locale=locale,
|
||||
locale=get_language_without_region(locale),
|
||||
total=total,
|
||||
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
||||
meta_info=json.dumps(meta_info or {}),
|
||||
@@ -1027,7 +1036,7 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
# Race condition
|
||||
continue
|
||||
|
||||
with language(o.locale):
|
||||
with language(o.locale, settings.region):
|
||||
o.expiry_reminder_sent = True
|
||||
o.save(update_fields=['expiry_reminder_sent'])
|
||||
email_template = settings.mail_text_order_expire_warning
|
||||
@@ -1104,7 +1113,7 @@ def send_download_reminders(sender, **kwargs):
|
||||
if not send:
|
||||
continue
|
||||
|
||||
with language(o.locale):
|
||||
with language(o.locale, o.event.settings.region):
|
||||
o.download_reminder_sent = True
|
||||
o.save(update_fields=['download_reminder_sent'])
|
||||
email_template = event.settings.mail_text_download_reminder
|
||||
@@ -1144,7 +1153,7 @@ def send_download_reminders(sender, **kwargs):
|
||||
|
||||
|
||||
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_context = get_email_context(event=order.event, order=order)
|
||||
email_subject = _('Your order has been changed: %(code)s') % {'code': order.code}
|
||||
@@ -1174,6 +1183,7 @@ class OrderChangeManager:
|
||||
'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'),
|
||||
'seat_required': _('The selected product requires you to select a seat.'),
|
||||
'seat_forbidden': _('The selected product does not allow to select a seat.'),
|
||||
'tax_rule_country_blocked': _('The selected country is blocked by your tax rule.'),
|
||||
'gift_card_change': _('You cannot change the price of a position that has been used to issue a gift card.'),
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
|
||||
@@ -1241,8 +1251,11 @@ class OrderChangeManager:
|
||||
self._operations.append(self.SeatOperation(position, seat))
|
||||
|
||||
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
|
||||
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
|
||||
invoice_address=self._invoice_address)
|
||||
try:
|
||||
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
|
||||
invoice_address=self._invoice_address)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||
|
||||
if price is None: # NOQA
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
@@ -1262,8 +1275,11 @@ class OrderChangeManager:
|
||||
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
|
||||
raise OrderError(self.error_messages['product_without_variation'])
|
||||
|
||||
price = get_price(item, variation, voucher=position.voucher, subevent=subevent,
|
||||
invoice_address=self._invoice_address)
|
||||
try:
|
||||
price = get_price(item, variation, voucher=position.voucher, subevent=subevent,
|
||||
invoice_address=self._invoice_address)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||
|
||||
if price is None: # NOQA
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
@@ -1321,7 +1337,10 @@ class OrderChangeManager:
|
||||
if not pos.price:
|
||||
continue
|
||||
|
||||
new_rate = tax_rule.tax_rate_for(ia)
|
||||
try:
|
||||
new_rate = tax_rule.tax_rate_for(ia)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(error_messages['tax_rule_country_blocked'])
|
||||
# We use override_tax_rate to make sure .tax() doesn't get clever and re-adjusts the pricing itself
|
||||
if new_rate != pos.tax_rate:
|
||||
if keep == 'net':
|
||||
@@ -1374,10 +1393,13 @@ class OrderChangeManager:
|
||||
except Seat.DoesNotExist:
|
||||
raise OrderError(error_messages['seat_invalid'])
|
||||
|
||||
if price is None:
|
||||
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
|
||||
else:
|
||||
price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address)
|
||||
try:
|
||||
if price is None:
|
||||
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
|
||||
else:
|
||||
price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||
|
||||
if price is None:
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
@@ -1952,7 +1974,10 @@ class OrderChangeManager:
|
||||
self._check_quotas()
|
||||
self._check_seats()
|
||||
self._check_complete_cancel()
|
||||
self._perform_operations()
|
||||
try:
|
||||
self._perform_operations()
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||
self._recalculate_total_and_payment_fee()
|
||||
self._reissue_invoice()
|
||||
self._clear_tickets_cache()
|
||||
|
||||
@@ -113,10 +113,11 @@ class QuotaAvailability:
|
||||
raise e
|
||||
|
||||
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 = []
|
||||
for e in events:
|
||||
e.cache.delete('item_quota_cache')
|
||||
for q in quotas:
|
||||
rewrite_cache = self._count_waitinglist and (
|
||||
not q.cache_is_hot(now_dt) or self.results[q][0] > q.cached_availability_state
|
||||
|
||||
@@ -17,7 +17,7 @@ from pretix.celery_app import app
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
with NamedTemporaryFile() as rawfile:
|
||||
@@ -55,6 +55,8 @@ def export(event: Event, shredders: List[str]) -> None:
|
||||
cf.date = now()
|
||||
cf.filename = event.slug + '.zip'
|
||||
cf.type = 'application/zip'
|
||||
cf.session_key = session_key
|
||||
cf.web_download = True
|
||||
cf.expires = now() + timedelta(hours=1)
|
||||
cf.save()
|
||||
cf.file.save(cachedfile_name(cf, cf.filename), rawfile)
|
||||
|
||||
@@ -127,6 +127,7 @@ def order_overview(
|
||||
order__event=event
|
||||
).annotate(
|
||||
status=Case(
|
||||
When(order__status='n', order__require_approval=True, then=Value('unapproved')),
|
||||
When(canceled=True, then=Value('c')),
|
||||
default=F('order__status')
|
||||
)
|
||||
@@ -135,6 +136,7 @@ def order_overview(
|
||||
).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by()
|
||||
|
||||
states = {
|
||||
'unapproved': 'unapproved',
|
||||
'canceled': Order.STATUS_CANCELED,
|
||||
'paid': Order.STATUS_PAID,
|
||||
'pending': Order.STATUS_PENDING,
|
||||
@@ -198,6 +200,7 @@ def order_overview(
|
||||
order__event=event
|
||||
).annotate(
|
||||
status=Case(
|
||||
When(order__status='n', order__require_approval=True, then=Value('unapproved')),
|
||||
When(canceled=True, then=Value('c')),
|
||||
default=F('order__status')
|
||||
)
|
||||
|
||||
@@ -96,8 +96,9 @@ class OrganizerUserTask(app.Task):
|
||||
kwargs['organizer'] = organizer
|
||||
|
||||
user_id = kwargs['user']
|
||||
user = User.objects.get(pk=user_id)
|
||||
kwargs['user'] = user
|
||||
if user_id is not None:
|
||||
user = User.objects.get(pk=user_id)
|
||||
kwargs['user'] = user
|
||||
|
||||
with scope(organizer=organizer):
|
||||
ret = super().__call__(*args, **kwargs)
|
||||
|
||||
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||
def generate_orderposition(order_position: int, provider: str):
|
||||
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)
|
||||
for receiver, response in responses:
|
||||
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):
|
||||
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)
|
||||
for receiver, response in responses:
|
||||
prov = response(order.event)
|
||||
@@ -75,7 +75,7 @@ class DummyRollbackException(Exception):
|
||||
def preview(event: int, provider: str):
|
||||
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,
|
||||
description=_("Sample product description"))
|
||||
item2 = event.items.create(name=_("Sample workshop"), default_price=23.40)
|
||||
|
||||
@@ -9,7 +9,9 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
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.utils.translation import (
|
||||
gettext_lazy as _, gettext_noop, pgettext, pgettext_lazy,
|
||||
@@ -26,7 +28,9 @@ from pretix.base.reldate import (
|
||||
RelativeDateField, RelativeDateTimeField, RelativeDateWrapper,
|
||||
SerializerRelativeDateField, SerializerRelativeDateTimeField,
|
||||
)
|
||||
from pretix.control.forms import MultipleLanguagesWidget, SingleLanguageWidget
|
||||
from pretix.control.forms import (
|
||||
FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
||||
)
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
|
||||
|
||||
@@ -38,6 +42,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):
|
||||
def __init__(self, init_list=None):
|
||||
super().__init__()
|
||||
@@ -177,6 +193,25 @@ DEFAULTS = {
|
||||
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': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
@@ -252,7 +287,6 @@ DEFAULTS = {
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for beneficiary"),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
required=False
|
||||
)
|
||||
},
|
||||
'invoice_address_custom_field': {
|
||||
@@ -421,7 +455,6 @@ DEFAULTS = {
|
||||
widget_kwargs={'attrs': {
|
||||
'rows': 3,
|
||||
}},
|
||||
required=False,
|
||||
label=_("Guidance text"),
|
||||
help_text=_("This text will be shown above the payment options. You can explain the choices to the user here, "
|
||||
"if you want.")
|
||||
@@ -441,7 +474,6 @@ DEFAULTS = {
|
||||
'form_kwargs': dict(
|
||||
label=_("Set payment term"),
|
||||
widget=forms.RadioSelect,
|
||||
required=True,
|
||||
choices=(
|
||||
('days', _("in days")),
|
||||
('minutes', _("in minutes"))
|
||||
@@ -488,7 +520,6 @@ DEFAULTS = {
|
||||
widget=forms.CheckboxInput(
|
||||
attrs={
|
||||
'data-display-dependency': '#id_payment_term_mode_0',
|
||||
'data-required-if': '#id_payment_term_mode_0'
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -541,6 +572,18 @@ DEFAULTS = {
|
||||
"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': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
@@ -808,6 +851,20 @@ DEFAULTS = {
|
||||
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': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
@@ -990,7 +1047,16 @@ DEFAULTS = {
|
||||
},
|
||||
'event_list_availability': {
|
||||
'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': {
|
||||
'default': 'list',
|
||||
@@ -1599,26 +1665,106 @@ Your {event} team"""))
|
||||
'primary_color': {
|
||||
'default': settings.PRETIX_PRIMARY_COLOR,
|
||||
'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': {
|
||||
'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': {
|
||||
'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': {
|
||||
'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': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Use round edges"),
|
||||
)
|
||||
},
|
||||
'primary_font': {
|
||||
'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': {
|
||||
'default': None,
|
||||
@@ -1654,7 +1800,13 @@ Your {event} team"""))
|
||||
},
|
||||
'organizer_logo_image_large': {
|
||||
'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': {
|
||||
'default': None,
|
||||
@@ -1713,6 +1865,30 @@ Your {event} team"""))
|
||||
"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': {
|
||||
'default': LazyI18nString.from_gettext(gettext_noop(
|
||||
'Make sure to enter a valid email address. We will send you an order '
|
||||
@@ -1733,11 +1909,26 @@ Your {event} team"""))
|
||||
},
|
||||
'organizer_info_text': {
|
||||
'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': {
|
||||
'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': {
|
||||
'default': 'False',
|
||||
@@ -1779,6 +1970,10 @@ Your {event} team"""))
|
||||
'default': None,
|
||||
'type': str
|
||||
},
|
||||
'mapquest_apikey': {
|
||||
'default': None,
|
||||
'type': str
|
||||
},
|
||||
'leaflet_tiles': {
|
||||
'default': None,
|
||||
'type': str
|
||||
@@ -1811,13 +2006,51 @@ Your {event} team"""))
|
||||
# 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': {
|
||||
'default': 'full',
|
||||
'type': str
|
||||
},
|
||||
'giftcard_length': {
|
||||
'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': {
|
||||
'default': 'True',
|
||||
@@ -1853,6 +2086,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([
|
||||
('english_common', (_('Most common English titles'), (
|
||||
'Mr',
|
||||
@@ -2049,7 +2286,31 @@ PERSON_NAME_SCHEMES = OrderedDict([
|
||||
'title': pgettext_lazy('person_name_sample', 'Dr'),
|
||||
'given_name': pgettext_lazy('person_name_sample', 'John'),
|
||||
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
|
||||
'_scheme': 'title_salutation_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',
|
||||
},
|
||||
}),
|
||||
])
|
||||
@@ -2143,7 +2404,8 @@ class SettingsSandbox:
|
||||
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
|
||||
|
||||
if 'locales' in settings_dict and settings_dict['locale'] not in settings_dict['locales']:
|
||||
@@ -2174,4 +2436,20 @@ def validate_settings(event, settings_dict):
|
||||
'payment_term_last': _('The last payment date cannot be before the end of presale.')
|
||||
})
|
||||
|
||||
validate_event_settings.send(sender=event, settings_dict=settings_dict)
|
||||
if isinstance(event, Event):
|
||||
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.signals import register_data_shredders
|
||||
from pretix.helpers.json import CustomJSONEncoder
|
||||
|
||||
|
||||
class ShredError(LazyLocaleException):
|
||||
@@ -121,6 +122,31 @@ def shred_log_fields(logentry, banlist=None, whitelist=None):
|
||||
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):
|
||||
verbose_name = _('E-mails')
|
||||
identifier = 'order_emails'
|
||||
@@ -372,9 +398,10 @@ class PaymentInfoShredder(BaseDataShredder):
|
||||
|
||||
|
||||
@receiver(register_data_shredders, dispatch_uid="shredders_builtin")
|
||||
def register_payment_provider(sender, **kwargs):
|
||||
def register_core_shredders(sender, **kwargs):
|
||||
return [
|
||||
EmailAddressShredder,
|
||||
PhoneNumberShredder,
|
||||
AttendeeInfoShredder,
|
||||
InvoiceAddressShredder,
|
||||
QuestionAnswerShredder,
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}{% if widget.value.is_img %}<br><a href="{{ widget.value.url }}" data-lightbox="{{ widget.value.name }}"><img src="{{ widget.value|thumb:"200x100" }}" /></a>{% endif %}<br>
|
||||
{{ widget.input_text }}:{% endif %}
|
||||
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
|
||||
{% if widget.cachedfile %}<input type="hidden" name="{{ widget.hidden_name }}" value="{{ widget.cachedfile.id }}">{% endif %}
|
||||
|
||||
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 bleach
|
||||
@@ -71,6 +72,10 @@ EMAIL_RE = build_email_re(tlds=sorted(tld_set, key=len, reverse=True))
|
||||
|
||||
|
||||
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'), '/')
|
||||
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')
|
||||
@@ -80,7 +85,42 @@ def safelink_callback(attrs, new=False):
|
||||
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('[^a-zA-Z0-9.-/_]', '', attrs.get('_text')) # clean up link text
|
||||
if URL_RE.match(text):
|
||||
# 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)
|
||||
href_url = urllib.parse.urlparse(attrs[None, 'href'])
|
||||
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):
|
||||
"""
|
||||
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'), '/')
|
||||
if not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)
|
||||
@@ -93,6 +133,7 @@ def markdown_compile_email(source):
|
||||
linker = bleach.Linker(
|
||||
url_re=URL_RE,
|
||||
email_re=EMAIL_RE,
|
||||
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||
parse_email=True
|
||||
)
|
||||
return linker.linkify(bleach.clean(
|
||||
@@ -145,7 +186,7 @@ def rich_text(text: str, **kwargs):
|
||||
linker = bleach.Linker(
|
||||
url_re=URL_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
|
||||
)
|
||||
body_md = linker.linkify(markdown_compile(text))
|
||||
@@ -161,7 +202,7 @@ def rich_text_snippet(text: str, **kwargs):
|
||||
linker = bleach.Linker(
|
||||
url_re=URL_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
|
||||
)
|
||||
body_md = linker.linkify(markdown_compile(text, snippet=True))
|
||||
|
||||
@@ -13,7 +13,11 @@ class DownloadView(TemplateView):
|
||||
@cached_property
|
||||
def object(self) -> CachedFile:
|
||||
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
|
||||
raise Http404()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.files import File
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.forms.utils import from_current_timezone
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from ...base.forms import I18nModelForm
|
||||
@@ -77,6 +78,8 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if hasattr(self.file, 'display_name'):
|
||||
return self.file.display_name
|
||||
return self.file.name
|
||||
|
||||
@property
|
||||
@@ -84,6 +87,8 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
|
||||
return any(self.file.name.lower().endswith(e) for e in ('.jpg', '.jpeg', '.png', '.gif'))
|
||||
|
||||
def __str__(self):
|
||||
if hasattr(self.file, 'display_name'):
|
||||
return self.file.display_name
|
||||
return os.path.basename(self.file.name).split('.', 1)[-1]
|
||||
|
||||
@property
|
||||
@@ -93,6 +98,48 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
|
||||
def get_context(self, name, value, attrs):
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
ctx['widget']['value'] = self.FakeFile(value)
|
||||
ctx['widget']['cachedfile'] = None
|
||||
return ctx
|
||||
|
||||
|
||||
class CachedFileInput(forms.ClearableFileInput):
|
||||
template_name = 'pretixbase/forms/widgets/thumbnailed_file_input.html'
|
||||
|
||||
class FakeFile(File):
|
||||
def __init__(self, file):
|
||||
self.file = file
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.file.filename
|
||||
|
||||
@property
|
||||
def is_img(self):
|
||||
return any(self.file.filename.lower().endswith(e) for e in ('.jpg', '.jpeg', '.png', '.gif'))
|
||||
|
||||
def __str__(self):
|
||||
return self.file.filename
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self.file.file.url
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
from ...base.models import CachedFile
|
||||
v = super().value_from_datadict(data, files, name)
|
||||
if v is None and data.get(name + '-cachedfile'): # An explicit "[x] clear" would be False, not None
|
||||
return CachedFile.objects.filter(id=data[name + '-cachedfile']).first()
|
||||
return v
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
from ...base.models import CachedFile
|
||||
if isinstance(value, CachedFile):
|
||||
value = self.FakeFile(value)
|
||||
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
ctx['widget']['value'] = value
|
||||
ctx['widget']['cachedfile'] = value.file if isinstance(value, self.FakeFile) else None
|
||||
ctx['widget']['hidden_name'] = name + '-cachedfile'
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -129,7 +176,7 @@ class ExtFileField(SizeFileField):
|
||||
|
||||
def clean(self, *args, **kwargs):
|
||||
data = super().clean(*args, **kwargs)
|
||||
if data:
|
||||
if isinstance(data, File):
|
||||
filename = data.name
|
||||
ext = os.path.splitext(filename)[1]
|
||||
ext = ext.lower()
|
||||
@@ -138,6 +185,51 @@ class ExtFileField(SizeFileField):
|
||||
return data
|
||||
|
||||
|
||||
class CachedFileField(ExtFileField):
|
||||
widget = CachedFileInput
|
||||
|
||||
def to_python(self, data):
|
||||
from ...base.models import CachedFile
|
||||
|
||||
if isinstance(data, CachedFile):
|
||||
return data
|
||||
|
||||
return super().to_python(data)
|
||||
|
||||
def bound_data(self, data, initial):
|
||||
from ...base.models import CachedFile
|
||||
|
||||
if isinstance(data, File):
|
||||
cf = CachedFile.objects.create(
|
||||
expires=now() + datetime.timedelta(days=1),
|
||||
date=now(),
|
||||
web_download=True,
|
||||
filename=data.name,
|
||||
type=data.content_type,
|
||||
)
|
||||
cf.file.save(data.name, data.file)
|
||||
cf.save()
|
||||
return cf
|
||||
return super().bound_data(data, initial)
|
||||
|
||||
def clean(self, *args, **kwargs):
|
||||
from ...base.models import CachedFile
|
||||
|
||||
data = super().clean(*args, **kwargs)
|
||||
if isinstance(data, File):
|
||||
cf = CachedFile.objects.create(
|
||||
expires=now() + datetime.timedelta(days=1),
|
||||
web_download=True,
|
||||
date=now(),
|
||||
filename=data.name,
|
||||
type=data.content_type,
|
||||
)
|
||||
cf.file.save(data.name, data.file)
|
||||
cf.save()
|
||||
return cf
|
||||
return data
|
||||
|
||||
|
||||
class SlugWidget(forms.TextInput):
|
||||
template_name = 'pretixcontrol/slug_widget.html'
|
||||
prefix = ''
|
||||
|
||||
@@ -3,15 +3,14 @@ from urllib.parse import urlencode, urlparse
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
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.forms import formset_factory
|
||||
from django.forms import CheckboxSelectMultiple, formset_factory
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import get_current_timezone_name
|
||||
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
|
||||
from django_countries import Countries
|
||||
from django_countries.fields import LazyTypedChoiceField
|
||||
from i18nfield.forms import (
|
||||
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.reldate import RelativeDateField, RelativeDateTimeField
|
||||
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 (
|
||||
ExtFileField, FontSelect, MultipleLanguagesWidget, SlugWidget,
|
||||
SplitDateTimeField, SplitDateTimePickerWidget,
|
||||
ExtFileField, MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||
SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.plugins.banktransfer.payment import BankTransfer
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
|
||||
class EventWizardFoundationForm(forms.Form):
|
||||
@@ -311,6 +310,16 @@ class EventUpdateForm(I18nModelForm):
|
||||
required=False,
|
||||
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):
|
||||
d = self.cleaned_data['domain']
|
||||
@@ -367,6 +376,7 @@ class EventUpdateForm(I18nModelForm):
|
||||
'location',
|
||||
'geo_lat',
|
||||
'geo_lon',
|
||||
'sales_channels'
|
||||
]
|
||||
field_classes = {
|
||||
'date_from': SplitDateTimeField,
|
||||
@@ -381,6 +391,7 @@ class EventUpdateForm(I18nModelForm):
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
|
||||
'presale_start': SplitDateTimePickerWidget(),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
|
||||
'sales_channels': CheckboxSelectMultiple()
|
||||
}
|
||||
|
||||
|
||||
@@ -431,57 +442,6 @@ class EventSettingsForm(SettingsForm):
|
||||
'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 = [
|
||||
'imprint_url',
|
||||
@@ -496,6 +456,7 @@ class EventSettingsForm(SettingsForm):
|
||||
'presale_start_show_date',
|
||||
'locales',
|
||||
'locale',
|
||||
'region',
|
||||
'show_quota_left',
|
||||
'waiting_list_enabled',
|
||||
'waiting_list_hours',
|
||||
@@ -518,18 +479,53 @@ class EventSettingsForm(SettingsForm):
|
||||
'attendee_company_required',
|
||||
'attendee_addresses_asked',
|
||||
'attendee_addresses_required',
|
||||
'attendee_data_explanation_text',
|
||||
'order_phone_asked',
|
||||
'order_phone_required',
|
||||
'checkout_phone_helptext',
|
||||
'banner_text',
|
||||
'banner_text_bottom',
|
||||
'order_email_asked_twice',
|
||||
'last_order_modification_date',
|
||||
'checkout_show_copy_answers_button',
|
||||
'primary_color',
|
||||
'theme_color_success',
|
||||
'theme_color_danger',
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
settings_dict = self.event.settings.freeze()
|
||||
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
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -552,9 +548,39 @@ class EventSettingsForm(SettingsForm):
|
||||
if not self.event.has_subevents:
|
||||
del self.fields['frontpage_subevent_ordering']
|
||||
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):
|
||||
@@ -592,6 +618,7 @@ class PaymentSettingsForm(SettingsForm):
|
||||
'payment_term_last',
|
||||
'payment_term_expire_automatically',
|
||||
'payment_term_accept_late',
|
||||
'payment_pending_hidden',
|
||||
'payment_explanation',
|
||||
]
|
||||
tax_rate_default = forms.ModelChoiceField(
|
||||
@@ -618,7 +645,7 @@ class PaymentSettingsForm(SettingsForm):
|
||||
data = super().clean()
|
||||
settings_dict = self.obj.settings.freeze()
|
||||
settings_dict.update(data)
|
||||
validate_settings(self.obj, data)
|
||||
validate_event_settings(self.obj, data)
|
||||
return data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -657,6 +684,8 @@ class ProviderForm(SettingsForm):
|
||||
enabled = cleaned_data.get(self.settingspref + '_enabled')
|
||||
if not enabled:
|
||||
return
|
||||
if cleaned_data.get(self.settingspref + '_hidden_url', None):
|
||||
cleaned_data[self.settingspref + '_hidden_url'] = None
|
||||
for k, v in self.fields.items():
|
||||
val = cleaned_data.get(k)
|
||||
if v._required and not val:
|
||||
@@ -748,7 +777,7 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
data = super().clean()
|
||||
settings_dict = self.obj.settings.freeze()
|
||||
settings_dict.update(data)
|
||||
validate_settings(self.obj, data)
|
||||
validate_event_settings(self.obj, data)
|
||||
return data
|
||||
|
||||
|
||||
@@ -1119,15 +1148,16 @@ class CommentForm(I18nModelForm):
|
||||
}
|
||||
|
||||
|
||||
class CountriesAndEU(Countries):
|
||||
class CountriesAndEU(CachedCountries):
|
||||
override = {
|
||||
'ZZ': _('Any country'),
|
||||
'EU': _('European Union')
|
||||
}
|
||||
first = ['ZZ', 'EU']
|
||||
cache_subkey = 'with_any_or_eu'
|
||||
|
||||
|
||||
class TaxRuleLineForm(forms.Form):
|
||||
class TaxRuleLineForm(I18nForm):
|
||||
country = LazyTypedChoiceField(
|
||||
choices=CountriesAndEU(),
|
||||
required=False
|
||||
@@ -1146,6 +1176,7 @@ class TaxRuleLineForm(forms.Form):
|
||||
('vat', _('Charge VAT')),
|
||||
('reverse', _('Reverse charge')),
|
||||
('no', _('No VAT')),
|
||||
('block', _('Sale not allowed')),
|
||||
],
|
||||
)
|
||||
rate = forms.DecimalField(
|
||||
@@ -1153,11 +1184,26 @@ class TaxRuleLineForm(forms.Form):
|
||||
max_digits=10, decimal_places=2,
|
||||
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(
|
||||
TaxRuleLineForm,
|
||||
can_order=False, can_delete=True, extra=0
|
||||
TaxRuleLineForm, formset=I18nBaseFormSet,
|
||||
can_order=True, can_delete=True, extra=0
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
from datetime import datetime, time
|
||||
from decimal import Decimal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.conf import settings
|
||||
from django.db.models import Exists, F, Model, OuterRef, Q, QuerySet
|
||||
from django.db.models.functions import Coalesce, ExtractWeekDay
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.forms.widgets import DatePickerWidget
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms.widgets import (
|
||||
DatePickerWidget, SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Checkin, Event, EventMetaProperty, EventMetaValue, Invoice, InvoiceAddress,
|
||||
Item, Order, OrderPayment, OrderPosition, OrderRefund, Organizer, Question,
|
||||
@@ -19,7 +25,9 @@ from pretix.base.models import (
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.control.forms.widgets import Select2
|
||||
from pretix.control.signals import order_search_filter_q
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.database import FixedOrderBy, rolledback_transaction
|
||||
from pretix.helpers.dicts import move_to_end
|
||||
from pretix.helpers.i18n import i18ncomp
|
||||
|
||||
PAYMENT_PROVIDERS = []
|
||||
@@ -83,6 +91,38 @@ class FilterForm(forms.Form):
|
||||
else:
|
||||
return self.orders[o]
|
||||
|
||||
def filter_to_strings(self):
|
||||
string = []
|
||||
for k, f in self.fields.items():
|
||||
v = self.cleaned_data.get(k)
|
||||
if v is None or (isinstance(v, (list, str, QuerySet)) and len(v) == 0):
|
||||
continue
|
||||
if k == "saveas":
|
||||
continue
|
||||
|
||||
if isinstance(v, bool):
|
||||
val = _('Yes') if v else _('No')
|
||||
elif isinstance(v, QuerySet):
|
||||
q = ['"' + str(m) + '"' for m in v]
|
||||
if not q:
|
||||
continue
|
||||
val = ' or '.join(q)
|
||||
elif isinstance(v, Model):
|
||||
val = '"' + str(v) + '"'
|
||||
elif isinstance(f, forms.MultipleChoiceField):
|
||||
valdict = dict(f.choices)
|
||||
val = ' or '.join([str(valdict.get(m)) for m in v])
|
||||
elif isinstance(f, forms.ChoiceField):
|
||||
val = str(dict(f.choices).get(v))
|
||||
elif isinstance(v, datetime):
|
||||
val = date_format(v, 'SHORT_DATETIME_FORMAT')
|
||||
elif isinstance(v, Decimal):
|
||||
val = localize(v)
|
||||
else:
|
||||
val = v
|
||||
string.append('{}: {}'.format(f.label, val))
|
||||
return string
|
||||
|
||||
|
||||
class OrderFilterForm(FilterForm):
|
||||
query = forms.CharField(
|
||||
@@ -104,20 +144,30 @@ class OrderFilterForm(FilterForm):
|
||||
label=_('Order status'),
|
||||
choices=(
|
||||
('', _('All orders')),
|
||||
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||
(Order.STATUS_PENDING, _('Pending')),
|
||||
('o', _('Pending (overdue)')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||
(Order.STATUS_EXPIRED, _('Expired')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||
(Order.STATUS_CANCELED, _('Canceled')),
|
||||
('cp', _('Canceled (or with paid fee)')),
|
||||
('pa', _('Approval pending')),
|
||||
('overpaid', _('Overpaid')),
|
||||
('underpaid', _('Underpaid')),
|
||||
('pendingpaid', _('Pending (but fully paid)')),
|
||||
(_('Valid orders'), (
|
||||
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||
(Order.STATUS_PENDING, _('Pending')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||
)),
|
||||
(_('Cancellations'), (
|
||||
(Order.STATUS_CANCELED, _('Canceled (fully)')),
|
||||
('cp', _('Canceled (fully or with paid fee)')),
|
||||
('rc', _('Cancellation requested')),
|
||||
)),
|
||||
(_('Payment process'), (
|
||||
(Order.STATUS_EXPIRED, _('Expired')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||
('o', _('Pending (overdue)')),
|
||||
('overpaid', _('Overpaid')),
|
||||
('partially_paid', _('Partially paid')),
|
||||
('underpaid', _('Underpaid (but confirmed)')),
|
||||
('pendingpaid', _('Pending (but fully paid)')),
|
||||
)),
|
||||
(_('Approval process'), (
|
||||
('na', _('Approved, payment pending')),
|
||||
('pa', _('Approval pending')),
|
||||
)),
|
||||
('testmode', _('Test mode')),
|
||||
('rc', _('Cancellation requested')),
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
@@ -196,6 +246,14 @@ class OrderFilterForm(FilterForm):
|
||||
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
||||
& 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':
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
@@ -207,6 +265,11 @@ class OrderFilterForm(FilterForm):
|
||||
status=Order.STATUS_PENDING,
|
||||
require_approval=True
|
||||
)
|
||||
elif s == 'na':
|
||||
qs = qs.filter(
|
||||
status=Order.STATUS_PENDING,
|
||||
require_approval=False
|
||||
)
|
||||
elif s == 'testmode':
|
||||
qs = qs.filter(
|
||||
testmode=True
|
||||
@@ -337,6 +400,238 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
return qs
|
||||
|
||||
|
||||
class FilterNullBooleanSelect(forms.NullBooleanSelect):
|
||||
def __init__(self, attrs=None):
|
||||
choices = (
|
||||
('unknown', _('All')),
|
||||
('true', _('Yes')),
|
||||
('false', _('No')),
|
||||
)
|
||||
super(forms.NullBooleanSelect, self).__init__(attrs, choices)
|
||||
|
||||
|
||||
class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
subevents_from = forms.SplitDateTimeField(
|
||||
widget=SplitDateTimePickerWidget(attrs={
|
||||
}),
|
||||
label=pgettext_lazy('subevent', 'All dates starting at or after'),
|
||||
required=False,
|
||||
)
|
||||
subevents_to = forms.SplitDateTimeField(
|
||||
widget=SplitDateTimePickerWidget(attrs={
|
||||
}),
|
||||
label=pgettext_lazy('subevent', 'All dates starting before'),
|
||||
required=False,
|
||||
)
|
||||
created_from = forms.SplitDateTimeField(
|
||||
widget=SplitDateTimePickerWidget(attrs={
|
||||
}),
|
||||
label=_('Order placed at or after'),
|
||||
required=False,
|
||||
)
|
||||
created_to = forms.SplitDateTimeField(
|
||||
widget=SplitDateTimePickerWidget(attrs={
|
||||
}),
|
||||
label=_('Order placed before'),
|
||||
required=False,
|
||||
)
|
||||
email = forms.CharField(
|
||||
required=False,
|
||||
label=_('E-mail address')
|
||||
)
|
||||
comment = forms.CharField(
|
||||
required=False,
|
||||
label=_('Comment')
|
||||
)
|
||||
locale = forms.ChoiceField(
|
||||
required=False,
|
||||
label=_('Locale'),
|
||||
choices=settings.LANGUAGES
|
||||
)
|
||||
email_known_to_work = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=FilterNullBooleanSelect,
|
||||
label=_('E-mail address verified'),
|
||||
)
|
||||
total = forms.DecimalField(
|
||||
localize=True,
|
||||
required=False,
|
||||
label=_('Total amount'),
|
||||
)
|
||||
sales_channel = forms.ChoiceField(
|
||||
label=_('Sales channel'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
del self.fields['query']
|
||||
del self.fields['question']
|
||||
del self.fields['answer']
|
||||
del self.fields['ordering']
|
||||
if not self.event.has_subevents:
|
||||
del self.fields['subevents_from']
|
||||
del self.fields['subevents_to']
|
||||
|
||||
self.fields['sales_channel'].choices = [('', '')] + [
|
||||
(k, v.verbose_name) for k, v in get_all_sales_channels().items()
|
||||
]
|
||||
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
self.fields['locale'].choices = [('', '')] + [(a, locale_names[a]) for a in self.event.settings.locales]
|
||||
|
||||
move_to_end(self.fields, 'item')
|
||||
move_to_end(self.fields, 'provider')
|
||||
|
||||
self.fields['invoice_address_company'] = forms.CharField(
|
||||
required=False,
|
||||
label=gettext('Invoice address') + ': ' + gettext('Company')
|
||||
)
|
||||
self.fields['invoice_address_name'] = forms.CharField(
|
||||
required=False,
|
||||
label=gettext('Invoice address') + ': ' + gettext('Name')
|
||||
)
|
||||
self.fields['invoice_address_street'] = forms.CharField(
|
||||
required=False,
|
||||
label=gettext('Invoice address') + ': ' + gettext('Address')
|
||||
)
|
||||
self.fields['invoice_address_zipcode'] = forms.CharField(
|
||||
required=False,
|
||||
label=gettext('Invoice address') + ': ' + gettext('ZIP code'),
|
||||
help_text=_('Exact matches only')
|
||||
)
|
||||
self.fields['invoice_address_city'] = forms.CharField(
|
||||
required=False,
|
||||
label=gettext('Invoice address') + ': ' + gettext('City'),
|
||||
help_text=_('Exact matches only')
|
||||
)
|
||||
self.fields['invoice_address_country'] = forms.ChoiceField(
|
||||
required=False,
|
||||
label=gettext('Invoice address') + ': ' + gettext('Country'),
|
||||
choices=[('', '')] + list(CachedCountries())
|
||||
)
|
||||
self.fields['attendee_name'] = forms.CharField(
|
||||
required=False,
|
||||
label=_('Attendee name')
|
||||
)
|
||||
self.fields['attendee_email'] = forms.CharField(
|
||||
required=False,
|
||||
label=_('Attendee e-mail address')
|
||||
)
|
||||
self.fields['attendee_address_company'] = forms.CharField(
|
||||
required=False,
|
||||
label=gettext('Attendee address') + ': ' + gettext('Company')
|
||||
)
|
||||
self.fields['attendee_address_street'] = forms.CharField(
|
||||
required=False,
|
||||
label=gettext('Attendee address') + ': ' + gettext('Address')
|
||||
)
|
||||
self.fields['attendee_address_zipcode'] = forms.CharField(
|
||||
required=False,
|
||||
label=gettext('Attendee address') + ': ' + gettext('ZIP code'),
|
||||
help_text=_('Exact matches only')
|
||||
)
|
||||
self.fields['attendee_address_city'] = forms.CharField(
|
||||
required=False,
|
||||
label=gettext('Attendee address') + ': ' + gettext('City'),
|
||||
help_text=_('Exact matches only')
|
||||
)
|
||||
self.fields['attendee_address_country'] = forms.ChoiceField(
|
||||
required=False,
|
||||
label=gettext('Attendee address') + ': ' + gettext('Country'),
|
||||
choices=[('', '')] + list(CachedCountries())
|
||||
)
|
||||
self.fields['ticket_secret'] = forms.CharField(
|
||||
label=_('Ticket secret'),
|
||||
required=False
|
||||
)
|
||||
for q in self.event.questions.all():
|
||||
self.fields['question_{}'.format(q.pk)] = forms.CharField(
|
||||
label=q.question,
|
||||
required=False,
|
||||
help_text=_('Exact matches only')
|
||||
)
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
qs = super().filter_qs(qs)
|
||||
|
||||
if fdata.get('subevents_from'):
|
||||
qs = qs.filter(
|
||||
all_positions__subevent__date_from__gte=fdata.get('subevents_from'),
|
||||
all_positions__canceled=False
|
||||
).distinct()
|
||||
if fdata.get('subevents_to'):
|
||||
qs = qs.filter(
|
||||
all_positions__subevent__date_from__lt=fdata.get('subevents_to'),
|
||||
all_positions__canceled=False
|
||||
).distinct()
|
||||
if fdata.get('email'):
|
||||
qs = qs.filter(
|
||||
email__icontains=fdata.get('email')
|
||||
)
|
||||
if fdata.get('created_from'):
|
||||
qs = qs.filter(datetime__gte=fdata.get('created_from'))
|
||||
if fdata.get('created_to'):
|
||||
qs = qs.filter(datetime__lte=fdata.get('created_to'))
|
||||
if fdata.get('comment'):
|
||||
qs = qs.filter(comment__icontains=fdata.get('comment'))
|
||||
if fdata.get('sales_channel'):
|
||||
qs = qs.filter(sales_channel=fdata.get('sales_channel'))
|
||||
if fdata.get('total'):
|
||||
qs = qs.filter(total=fdata.get('total'))
|
||||
if fdata.get('email_known_to_work') is not None:
|
||||
qs = qs.filter(email_known_to_work=fdata.get('email_known_to_work'))
|
||||
if fdata.get('locale'):
|
||||
qs = qs.filter(locale=fdata.get('locale'))
|
||||
if fdata.get('invoice_address_company'):
|
||||
qs = qs.filter(invoice_address__company__icontains=fdata.get('invoice_address_company'))
|
||||
if fdata.get('invoice_address_name'):
|
||||
qs = qs.filter(invoice_address__name_cached__icontains=fdata.get('invoice_address_name'))
|
||||
if fdata.get('invoice_address_street'):
|
||||
qs = qs.filter(invoice_address__street__icontains=fdata.get('invoice_address_street'))
|
||||
if fdata.get('invoice_address_zipcode'):
|
||||
qs = qs.filter(invoice_address__zipcode__iexact=fdata.get('invoice_address_zipcode'))
|
||||
if fdata.get('invoice_address_city'):
|
||||
qs = qs.filter(invoice_address__city__iexact=fdata.get('invoice_address_city'))
|
||||
if fdata.get('invoice_address_country'):
|
||||
qs = qs.filter(invoice_address__country=fdata.get('invoice_address_country'))
|
||||
if fdata.get('attendee_name'):
|
||||
qs = qs.filter(
|
||||
all_positions__attendee_name_cached__icontains=fdata.get('attendee_name')
|
||||
)
|
||||
if fdata.get('attendee_address_company'):
|
||||
qs = qs.filter(
|
||||
all_positions__company__icontains=fdata.get('attendee_address_company')
|
||||
).distinct()
|
||||
if fdata.get('attendee_address_street'):
|
||||
qs = qs.filter(
|
||||
all_positions__street__icontains=fdata.get('attendee_address_street')
|
||||
).distinct()
|
||||
if fdata.get('attendee_address_city'):
|
||||
qs = qs.filter(
|
||||
all_positions__city__iexact=fdata.get('attendee_address_city')
|
||||
).distinct()
|
||||
if fdata.get('attendee_address_country'):
|
||||
qs = qs.filter(
|
||||
all_positions__country=fdata.get('attendee_address_country')
|
||||
).distinct()
|
||||
if fdata.get('ticket_secret'):
|
||||
qs = qs.filter(
|
||||
all_positions__secret__icontains=fdata.get('ticket_secret')
|
||||
).distinct()
|
||||
for q in self.event.questions.all():
|
||||
if fdata.get(f'question_{q.pk}'):
|
||||
answers = QuestionAnswer.objects.filter(
|
||||
question_id=q.pk,
|
||||
orderposition__order_id=OuterRef('pk'),
|
||||
answer__iexact=fdata.get(f'question_{q.pk}')
|
||||
)
|
||||
qs = qs.annotate(**{f'q_{q.pk}': Exists(answers)}).filter(**{f'q_{q.pk}': True})
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class OrderSearchFilterForm(OrderFilterForm):
|
||||
orders = {'code': 'code', 'email': 'email', 'total': 'total',
|
||||
'datetime': 'datetime', 'status': 'status',
|
||||
@@ -827,8 +1122,8 @@ class CheckInFilterForm(FilterForm):
|
||||
'-item': ('-item__name', '-variation__value', '-order__code'),
|
||||
'seat': ('seat__sorting_rank', 'seat__guid'),
|
||||
'-seat': ('-seat__sorting_rank', '-seat__guid'),
|
||||
'date': ('subevent__date_from', 'order__code'),
|
||||
'-date': ('-subevent__date_from', '-order__code'),
|
||||
'date': ('subevent__date_from', 'subevent__id', 'order__code'),
|
||||
'-date': ('-subevent__date_from', 'subevent__id', '-order__code'),
|
||||
'name': {'_order': F('display_name').asc(nulls_first=True),
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')},
|
||||
'-name': {'_order': F('display_name').desc(nulls_last=True),
|
||||
|
||||
@@ -10,11 +10,15 @@ from pretix.base.signals import register_global_settings
|
||||
|
||||
|
||||
class GlobalSettingsForm(SettingsForm):
|
||||
auto_fields = [
|
||||
'region'
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.obj = GlobalSettingsObject()
|
||||
super().__init__(*args, obj=self.obj, **kwargs)
|
||||
|
||||
self.fields = OrderedDict([
|
||||
self.fields = OrderedDict(list(self.fields.items()) + [
|
||||
('footer_text', I18nFormField(
|
||||
widget=I18nTextInput,
|
||||
required=False,
|
||||
@@ -41,6 +45,10 @@ class GlobalSettingsForm(SettingsForm):
|
||||
required=False,
|
||||
label=_("OpenCage API key for geocoding"),
|
||||
)),
|
||||
('mapquest_apikey', SecretKeySettingsField(
|
||||
required=False,
|
||||
label=_("MapQuest API key for geocoding"),
|
||||
)),
|
||||
('leaflet_tiles', forms.CharField(
|
||||
required=False,
|
||||
label=_("Leaflet tiles URL pattern"),
|
||||
|
||||
@@ -16,6 +16,7 @@ from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms import I18nFormSet, I18nModelForm
|
||||
from pretix.base.forms.widgets import DatePickerWidget
|
||||
from pretix.base.models import (
|
||||
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
||||
)
|
||||
@@ -111,14 +112,26 @@ class QuestionForm(I18nModelForm):
|
||||
'dependency_question',
|
||||
'dependency_values',
|
||||
'print_on_invoice',
|
||||
'valid_number_min',
|
||||
'valid_number_max',
|
||||
'valid_datetime_min',
|
||||
'valid_datetime_max',
|
||||
'valid_date_min',
|
||||
'valid_date_max',
|
||||
]
|
||||
widgets = {
|
||||
'valid_datetime_min': SplitDateTimePickerWidget(),
|
||||
'valid_datetime_max': SplitDateTimePickerWidget(),
|
||||
'valid_date_min': DatePickerWidget(),
|
||||
'valid_date_max': DatePickerWidget(),
|
||||
'items': forms.CheckboxSelectMultiple(
|
||||
attrs={'class': 'scrolling-multiple-choice'}
|
||||
),
|
||||
'dependency_values': forms.SelectMultiple,
|
||||
}
|
||||
field_classes = {
|
||||
'valid_datetime_min': SplitDateTimeField,
|
||||
'valid_datetime_max': SplitDateTimeField,
|
||||
'items': SafeModelMultipleChoiceField,
|
||||
'dependency_question': SafeModelChoiceField,
|
||||
}
|
||||
@@ -226,6 +239,8 @@ class ItemCreateForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
self.user = kwargs.pop('user')
|
||||
kwargs.setdefault('initial', {})
|
||||
kwargs['initial'].setdefault('admission', True)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['category'].queryset = self.instance.event.categories.all()
|
||||
|
||||
@@ -16,6 +16,7 @@ from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.email import get_available_placeholders
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator
|
||||
from pretix.base.forms.questions import WrappedPhoneNumberPrefixWidget
|
||||
from pretix.base.forms.widgets import (
|
||||
DatePickerWidget, SplitDateTimePickerWidget,
|
||||
)
|
||||
@@ -400,7 +401,6 @@ class OrderPositionChangeForm(forms.Form):
|
||||
self.fields['tax_rule'].label_from_instance = self.taxrule_label_from_instance
|
||||
|
||||
if not instance.seat and not (
|
||||
not instance.event.settings.seating_choice and
|
||||
instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists()
|
||||
):
|
||||
del self.fields['seat']
|
||||
@@ -461,7 +461,15 @@ class OrderContactForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['email', 'email_known_to_work']
|
||||
fields = ['email', 'email_known_to_work', 'phone']
|
||||
widgets = {
|
||||
'phone': WrappedPhoneNumberPrefixWidget()
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.instance.event.settings.order_phone_asked and not self.instance.phone:
|
||||
del self.fields['phone']
|
||||
|
||||
|
||||
class OrderLocaleForm(forms.ModelForm):
|
||||
@@ -517,6 +525,20 @@ class OrderMailForm(forms.Form):
|
||||
self._set_field_placeholders('message', ['event', 'order'])
|
||||
|
||||
|
||||
class OrderPositionMailForm(OrderMailForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
position = self.position = kwargs.pop('position')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['sendto'].initial = position.attendee_email
|
||||
self.fields['message'] = forms.CharField(
|
||||
label=_("Message"),
|
||||
required=True,
|
||||
widget=forms.Textarea,
|
||||
initial=self.order.event.settings.mail_text_order_custom_mail.localize(self.order.locale),
|
||||
)
|
||||
self._set_field_placeholders('message', ['event', 'order', 'position'])
|
||||
|
||||
|
||||
class OrderRefundForm(forms.Form):
|
||||
action = forms.ChoiceField(
|
||||
required=False,
|
||||
@@ -572,7 +594,21 @@ class EventCancelForm(forms.Form):
|
||||
all_subevents = forms.BooleanField(
|
||||
label=_('Cancel all dates'),
|
||||
initial=False,
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
subevents_from = forms.SplitDateTimeField(
|
||||
widget=SplitDateTimePickerWidget(attrs={
|
||||
'data-inverse-dependency': '#id_all_subevents',
|
||||
}),
|
||||
label=pgettext_lazy('subevent', 'All dates starting at or after'),
|
||||
required=False,
|
||||
)
|
||||
subevents_to = forms.SplitDateTimeField(
|
||||
widget=SplitDateTimePickerWidget(attrs={
|
||||
'data-inverse-dependency': '#id_all_subevents',
|
||||
}),
|
||||
label=pgettext_lazy('subevent', 'All dates starting before'),
|
||||
required=False,
|
||||
)
|
||||
auto_refund = forms.BooleanField(
|
||||
label=_('Automatically refund money if possible'),
|
||||
@@ -613,6 +649,12 @@ class EventCancelForm(forms.Form):
|
||||
max_digits=10, decimal_places=2,
|
||||
required=False
|
||||
)
|
||||
keep_fee_per_ticket = forms.DecimalField(
|
||||
label=_("Keep a fixed cancellation fee per ticket"),
|
||||
help_text=_("Free tickets and add-on products are not counted"),
|
||||
max_digits=10, decimal_places=2,
|
||||
required=False
|
||||
)
|
||||
keep_fee_percentage = forms.DecimalField(
|
||||
label=_("Keep a percentual cancellation fee"),
|
||||
max_digits=10, decimal_places=2,
|
||||
@@ -717,6 +759,7 @@ class EventCancelForm(forms.Form):
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-inverse-dependency': '#id_all_subevents',
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
@@ -733,6 +776,12 @@ class EventCancelForm(forms.Form):
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if self.event.has_subevents and not d['subevent'] and not d['all_subevents']:
|
||||
if d.get('subevent') and d.get('subevents_from'):
|
||||
raise ValidationError(pgettext_lazy('subevent', 'Please either select a specific date or a date range, not both.'))
|
||||
if d.get('all_subevents') and d.get('subevent_from'):
|
||||
raise ValidationError(pgettext_lazy('subevent', 'Please either select all dates or a date range, not both.'))
|
||||
if bool(d.get('subevents_from')) != bool(d.get('subevents_to')):
|
||||
raise ValidationError(pgettext_lazy('subevent', 'If you set a date range, please set both a start and an end.'))
|
||||
if self.event.has_subevents and not d['subevent'] and not d['all_subevents'] and not d.get('subevents_from'):
|
||||
raise ValidationError(_('Please confirm that you want to cancel ALL dates in this event series.'))
|
||||
return d
|
||||
|
||||
@@ -4,24 +4,19 @@ from urllib.parse import urlparse
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db.models import Q
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
from pretix.base.forms import I18nModelForm, SettingsForm
|
||||
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||
from pretix.base.models import Device, Gate, GiftCard, Organizer, Team
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, FontSelect, MultipleLanguagesWidget, SplitDateTimeField,
|
||||
)
|
||||
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||
from pretix.control.forms.event import SafeEventMultipleChoiceField
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
|
||||
class OrganizerForm(I18nModelForm):
|
||||
@@ -218,72 +213,27 @@ class DeviceForm(forms.ModelForm):
|
||||
|
||||
|
||||
class OrganizerSettingsForm(SettingsForm):
|
||||
auto_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_info_text = I18nFormField(
|
||||
label=_('Info text'),
|
||||
required=False,
|
||||
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 = forms.BooleanField(
|
||||
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.'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
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 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,
|
||||
)
|
||||
organizer_homepage_text = I18nFormField(
|
||||
label=_('Homepage text'),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_('This will be displayed on the organizer homepage.')
|
||||
)
|
||||
organizer_logo_image = ExtFileField(
|
||||
label=_('Header image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
@@ -294,44 +244,6 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
'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.')
|
||||
)
|
||||
organizer_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,
|
||||
)
|
||||
event_list_type = forms.ChoiceField(
|
||||
label=_('Default overview style'),
|
||||
choices=(
|
||||
('list', _('List')),
|
||||
('week', _('Week calendar')),
|
||||
('calendar', _('Month calendar')),
|
||||
)
|
||||
)
|
||||
event_list_availability = forms.BooleanField(
|
||||
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
|
||||
)
|
||||
organizer_link_back = forms.BooleanField(
|
||||
label=_('Link back to organizer overview on all event pages'),
|
||||
required=False
|
||||
)
|
||||
locales = forms.MultipleChoiceField(
|
||||
choices=settings.LANGUAGES,
|
||||
label=_("Use languages"),
|
||||
widget=MultipleLanguagesWidget,
|
||||
help_text=_('Choose all languages that your organizer homepage should be available in.')
|
||||
)
|
||||
primary_font = forms.ChoiceField(
|
||||
label=_('Font'),
|
||||
choices=[
|
||||
('Open Sans', 'Open Sans')
|
||||
],
|
||||
widget=FontSelect,
|
||||
help_text=_('Only respected by modern browsers.')
|
||||
)
|
||||
favicon = ExtFileField(
|
||||
label=_('Favicon'),
|
||||
ext_whitelist=(".ico", ".png", ".jpg", ".gif", ".jpeg"),
|
||||
@@ -340,24 +252,6 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. '
|
||||
'We recommend a size of at least 200x200px to accommodate most devices.')
|
||||
)
|
||||
giftcard_length = forms.IntegerField(
|
||||
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'])),
|
||||
required=False
|
||||
)
|
||||
giftcard_expiry_years = forms.IntegerField(
|
||||
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.'),
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['primary_font'].choices += [
|
||||
(a, {"title": a, "data": v}) for a, v in get_fonts().items()
|
||||
]
|
||||
|
||||
|
||||
class WebHookForm(forms.ModelForm):
|
||||
|
||||
@@ -292,6 +292,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.denied': _('The order has been denied.'),
|
||||
'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
|
||||
'to "{new_email}".'),
|
||||
'pretix.event.order.phone.changed': _('The phone number has been changed from "{old_phone}" '
|
||||
'to "{new_phone}".'),
|
||||
'pretix.event.order.locale.changed': _('The order locale has been changed.'),
|
||||
'pretix.event.order.invoice.generated': _('The invoice has been generated.'),
|
||||
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),
|
||||
@@ -305,6 +307,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.email.attachments.skipped': _('The email has been sent without attachments since they '
|
||||
'would have been too large to be likely to arrive.'),
|
||||
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
|
||||
'pretix.event.order.position.email.custom_sent': _('A custom email has been sent to an attendee.'),
|
||||
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '
|
||||
'is available for download.'),
|
||||
'pretix.event.order.email.expire_warning_sent': _('An email has been sent with a warning that the order is about '
|
||||
@@ -397,7 +400,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.testmode.activated': _('The shop has been taken into test mode.'),
|
||||
'pretix.event.testmode.deactivated': _('The test mode has been disabled.'),
|
||||
'pretix.event.added': _('The event has been created.'),
|
||||
'pretix.event.changed': _('The event settings have been changed.'),
|
||||
'pretix.event.changed': _('The event details have been changed.'),
|
||||
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
|
||||
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
|
||||
'pretix.event.question.option.changed': _('An answer option has been changed.'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user