forked from CGM_Public/pretix_original
Compare commits
361 Commits
release/4.
...
v4.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8b1c176b | ||
|
|
2598787602 | ||
|
|
003fa62996 | ||
|
|
798c21955e | ||
|
|
fe6185af4b | ||
|
|
7bacefa442 | ||
|
|
04e187c297 | ||
|
|
9f2ffc3276 | ||
|
|
a9a4cf6fca | ||
|
|
a563316e22 | ||
|
|
4b6f55c31d | ||
|
|
21a8fad17a | ||
|
|
7586df9d3f | ||
|
|
1d4afa5d27 | ||
|
|
720d9b924e | ||
|
|
9f56669f2a | ||
|
|
fc541016c6 | ||
|
|
5eefe9ad1e | ||
|
|
1d065a7672 | ||
|
|
101f5f7781 | ||
|
|
af7c6d360f | ||
|
|
8751e6e5ba | ||
|
|
93004a8125 | ||
|
|
adf40e1d56 | ||
|
|
364cfe0131 | ||
|
|
1514527ef3 | ||
|
|
680024234d | ||
|
|
2a3660f2d1 | ||
|
|
2041d1213a | ||
|
|
42a1fe9bd1 | ||
|
|
002469d523 | ||
|
|
5be4af1305 | ||
|
|
0b241438e1 | ||
|
|
61649ab2b8 | ||
|
|
848ea999c5 | ||
|
|
dfa82870fb | ||
|
|
e05ac7ef34 | ||
|
|
ad2334bffc | ||
|
|
17adde99fa | ||
|
|
4789d82c4e | ||
|
|
0567e2d22b | ||
|
|
2e0592b0a6 | ||
|
|
7f6d234b4c | ||
|
|
0436de316b | ||
|
|
e16d643d2a | ||
|
|
bdec22cf3b | ||
|
|
b38df27dce | ||
|
|
b95f556d8f | ||
|
|
851a4c977c | ||
|
|
7bffd461d1 | ||
|
|
9a3b4f7863 | ||
|
|
673a38ddc8 | ||
|
|
a27b8bf213 | ||
|
|
36e6f10b37 | ||
|
|
fde10d7f55 | ||
|
|
6b44b2f429 | ||
|
|
5e9018e0fd | ||
|
|
185f8066ae | ||
|
|
6388f7b29c | ||
|
|
4aa2c9d51d | ||
|
|
ef9256f0b0 | ||
|
|
28d78e40f9 | ||
|
|
89554a82eb | ||
|
|
ae99e82ad1 | ||
|
|
5ea3d01b8d | ||
|
|
aa2bd79b99 | ||
|
|
44ee35b885 | ||
|
|
22b79a8c22 | ||
|
|
65bbd537e6 | ||
|
|
34387d7bc0 | ||
|
|
ca38204313 | ||
|
|
b7083eca2e | ||
|
|
6bb8b428dc | ||
|
|
677142d0c9 | ||
|
|
d1b66e365a | ||
|
|
50154c02ce | ||
|
|
04375d4fcf | ||
|
|
9c1ff296bb | ||
|
|
0b3acb06b5 | ||
|
|
b2cdccedd6 | ||
|
|
7ebefa7b85 | ||
|
|
c7b5baa185 | ||
|
|
6d08e7a8b0 | ||
|
|
0da2b12646 | ||
|
|
a0693483dc | ||
|
|
29826a9f08 | ||
|
|
36a045020f | ||
|
|
40c2b774aa | ||
|
|
8422b2b4aa | ||
|
|
ae334c4860 | ||
|
|
722f36121d | ||
|
|
529092a4ed | ||
|
|
e7068020d5 | ||
|
|
08acecf37b | ||
|
|
b200ca5ad5 | ||
|
|
e564952148 | ||
|
|
f4ad2a2293 | ||
|
|
854bbf26c2 | ||
|
|
ec5a670ea6 | ||
|
|
276add9163 | ||
|
|
de977f4818 | ||
|
|
a4827fc992 | ||
|
|
9a002bf172 | ||
|
|
9a7f3e2d8a | ||
|
|
e7546a7575 | ||
|
|
434719285b | ||
|
|
5bc9ba4641 | ||
|
|
74dd13abd5 | ||
|
|
ead755aa86 | ||
|
|
1f46a8b91b | ||
|
|
eb77c2f6f6 | ||
|
|
c5fe615be5 | ||
|
|
f5504e11ac | ||
|
|
e88a1a52f9 | ||
|
|
b86d54ea9f | ||
|
|
b6e2ed14db | ||
|
|
c513868afa | ||
|
|
3f7664f743 | ||
|
|
e654b951ed | ||
|
|
b5c7556abe | ||
|
|
53e3619140 | ||
|
|
e191988b81 | ||
|
|
bb7fd9423b | ||
|
|
c10c6ee28d | ||
|
|
3c64733e93 | ||
|
|
08cb045f2e | ||
|
|
7bf854fe0b | ||
|
|
f2a1e11b85 | ||
|
|
9b07912b7f | ||
|
|
e1cec9882a | ||
|
|
1ff9c1a84b | ||
|
|
0035825f33 | ||
|
|
1ec73b1b33 | ||
|
|
ed83f4558e | ||
|
|
b18ec7605a | ||
|
|
f96bc0776d | ||
|
|
629bdcd55d | ||
|
|
829fd907a1 | ||
|
|
d7fe321f36 | ||
|
|
517432319e | ||
|
|
edef9f1b23 | ||
|
|
617730ab76 | ||
|
|
7c17d041f4 | ||
|
|
9295abb80e | ||
|
|
4c3192f116 | ||
|
|
9c6a2eb85a | ||
|
|
bcbc8a542f | ||
|
|
a915442efc | ||
|
|
103631a14b | ||
|
|
e9d7a24cbf | ||
|
|
cc977e441a | ||
|
|
add9bae018 | ||
|
|
77d157ab8e | ||
|
|
e42bc94329 | ||
|
|
b4bf5f998e | ||
|
|
dc785e9dac | ||
|
|
8f5f95b04e | ||
|
|
c86839ed41 | ||
|
|
8b6e0f0de7 | ||
|
|
a65243e4bb | ||
|
|
ac028be84e | ||
|
|
efd5b5b1da | ||
|
|
4be618bc93 | ||
|
|
7b6d5a0cc9 | ||
|
|
f367d5e675 | ||
|
|
f9b7894c4d | ||
|
|
354bbb485b | ||
|
|
8dc5dbd547 | ||
|
|
e04793d2eb | ||
|
|
db65c14733 | ||
|
|
f10c8b229f | ||
|
|
4655d8237f | ||
|
|
78f4f35ca3 | ||
|
|
3a01a05a08 | ||
|
|
1738c710cb | ||
|
|
d07783a453 | ||
|
|
1ce331f163 | ||
|
|
586f95bc6d | ||
|
|
5620aec5f2 | ||
|
|
c1dfec20f6 | ||
|
|
7fef81bdef | ||
|
|
0f2e905672 | ||
|
|
a57a4e7350 | ||
|
|
b57a6e982a | ||
|
|
39736ef0d4 | ||
|
|
f7e5f0b567 | ||
|
|
b6078d5272 | ||
|
|
1ed1cd33e8 | ||
|
|
a4a2500725 | ||
|
|
3fb44ec9dd | ||
|
|
2a96575b4d | ||
|
|
dcf29ec63e | ||
|
|
a743605bd3 | ||
|
|
75dc80eb09 | ||
|
|
ac16d9d900 | ||
|
|
736d26c232 | ||
|
|
8985dfc5eb | ||
|
|
bb80ef067a | ||
|
|
bdd9751f0e | ||
|
|
965aac6ad5 | ||
|
|
e3858373d1 | ||
|
|
fcdfae88d7 | ||
|
|
7d5a85e26f | ||
|
|
b8b2c2eba3 | ||
|
|
c6a3280d69 | ||
|
|
7f9368c415 | ||
|
|
add764e3f0 | ||
|
|
a3431cd51e | ||
|
|
9772d43235 | ||
|
|
2e29e369f5 | ||
|
|
9f6ce81229 | ||
|
|
d67954de3f | ||
|
|
d04f93d45c | ||
|
|
ef70209ba8 | ||
|
|
f127cfc46a | ||
|
|
ec444e5bf3 | ||
|
|
32f690e9d0 | ||
|
|
9089b630ed | ||
|
|
0c6971ff5f | ||
|
|
59e92245de | ||
|
|
9894954233 | ||
|
|
6e7505abd5 | ||
|
|
9df381ec4c | ||
|
|
be726183cb | ||
|
|
be5e2d8c33 | ||
|
|
9da7321a19 | ||
|
|
cc7d95b805 | ||
|
|
5376dbffc0 | ||
|
|
cddefd98d3 | ||
|
|
5ae0e55f7e | ||
|
|
856c36b85a | ||
|
|
a3bc717a5b | ||
|
|
6fa198a175 | ||
|
|
9596f48fed | ||
|
|
11b1c81633 | ||
|
|
212f33afee | ||
|
|
3fab15d086 | ||
|
|
2f1dd79162 | ||
|
|
e00ab01235 | ||
|
|
60f0e297e3 | ||
|
|
93c791e16f | ||
|
|
6da8caaa2b | ||
|
|
38ffd7d6ba | ||
|
|
ff4f56392d | ||
|
|
618b67ca2f | ||
|
|
a856a3ef6f | ||
|
|
573284c480 | ||
|
|
d4712266ff | ||
|
|
e4f542b060 | ||
|
|
1b68e8bf0e | ||
|
|
c8d464ded7 | ||
|
|
12ab5ace9c | ||
|
|
a2126c7b15 | ||
|
|
cba2ad5333 | ||
|
|
8700c41f5e | ||
|
|
a88fed283a | ||
|
|
130ffddf48 | ||
|
|
f84b612d7b | ||
|
|
e1ac22067a | ||
|
|
60c3b76ee9 | ||
|
|
fa8552e86f | ||
|
|
ecf1a40a5e | ||
|
|
ecfeae6ad9 | ||
|
|
3544c3f5b8 | ||
|
|
d8f3a3f5be | ||
|
|
d6849c45fe | ||
|
|
eaf663794e | ||
|
|
dbbd4fe47f | ||
|
|
abab7dc874 | ||
|
|
11ddfc511b | ||
|
|
3e50f3dd33 | ||
|
|
bf5becad82 | ||
|
|
f191ce823a | ||
|
|
b03fed979f | ||
|
|
91de41b782 | ||
|
|
ff1cfe269f | ||
|
|
2641a40142 | ||
|
|
584d869729 | ||
|
|
8b9b86a68d | ||
|
|
b7f5631ad0 | ||
|
|
038413be88 | ||
|
|
4508745feb | ||
|
|
f9fa1733b0 | ||
|
|
d50dff4a6e | ||
|
|
2852722b50 | ||
|
|
1ef076bb9b | ||
|
|
8ad53256c2 | ||
|
|
f51155a5df | ||
|
|
75f9824095 | ||
|
|
9678ef3dd4 | ||
|
|
4d945cf1e3 | ||
|
|
8f05de7004 | ||
|
|
72388abd57 | ||
|
|
5801c8602e | ||
|
|
eb77f67d28 | ||
|
|
ba895270fa | ||
|
|
cd88659351 | ||
|
|
eabead4768 | ||
|
|
9cb0cf210a | ||
|
|
d181241a63 | ||
|
|
b3edb82ffd | ||
|
|
eb5ed2bdf9 | ||
|
|
c132ccd141 | ||
|
|
fb2827e9ab | ||
|
|
bb89bf68ef | ||
|
|
97d67d58d5 | ||
|
|
3235f90876 | ||
|
|
227b2513b4 | ||
|
|
5952fdccb8 | ||
|
|
4874748aa2 | ||
|
|
030ea269b0 | ||
|
|
d187a497f9 | ||
|
|
efc2efac84 | ||
|
|
3378744a5c | ||
|
|
71ac461929 | ||
|
|
039da531c4 | ||
|
|
91e080d962 | ||
|
|
b78cf9f2c5 | ||
|
|
af68053195 | ||
|
|
2dd1e567cf | ||
|
|
33400ed7cc | ||
|
|
fe2e01938a | ||
|
|
fccd119a1f | ||
|
|
b1dee5ae7c | ||
|
|
3e178a7293 | ||
|
|
193407d819 | ||
|
|
25419dc8e8 | ||
|
|
cec27d7a44 | ||
|
|
881f0e04a0 | ||
|
|
5ee51c8f9a | ||
|
|
050f3990c3 | ||
|
|
b11ae9e5dd | ||
|
|
c7ef79be90 | ||
|
|
9c3fc69176 | ||
|
|
18df9d66bb | ||
|
|
a43625c5e8 | ||
|
|
a4d9d7041c | ||
|
|
96eabebc15 | ||
|
|
3819df57d8 | ||
|
|
0fee7b0613 | ||
|
|
1a17f54354 | ||
|
|
750231eb3c | ||
|
|
1bb84b7296 | ||
|
|
6d9ef397ee | ||
|
|
64d07a2811 | ||
|
|
e4949b6491 | ||
|
|
71e7df3038 | ||
|
|
47df6fe2bc | ||
|
|
ec08faf205 | ||
|
|
76e86cbdd1 | ||
|
|
f1b072b9a4 | ||
|
|
e792e8fd1e | ||
|
|
bc8b3f504c | ||
|
|
0bcbfda276 | ||
|
|
db029882ec | ||
|
|
a1cc17094d | ||
|
|
1c763ccce3 | ||
|
|
36fd5e7d01 | ||
|
|
66cf9c1ac7 | ||
|
|
0b9b67d603 | ||
|
|
520fb62088 |
3
.github/workflows/docs.yml
vendored
3
.github/workflows/docs.yml
vendored
@@ -33,7 +33,8 @@ jobs:
|
||||
- name: Install system packages
|
||||
run: sudo apt update && sudo apt install enchant hunspell aspell-en
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -Ur doc/requirements.txt
|
||||
run: pip3 install -Ur requirements.txt
|
||||
working-directory: ./doc
|
||||
- name: Spellcheck docs
|
||||
run: make spelling
|
||||
working-directory: ./doc
|
||||
|
||||
6
.github/workflows/strings.yml
vendored
6
.github/workflows/strings.yml
vendored
@@ -31,7 +31,8 @@ jobs:
|
||||
- name: Install system packages
|
||||
run: sudo apt update && sudo apt install gettext
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -Ur src/requirements.txt
|
||||
run: pip3 install -e ".[dev]"
|
||||
working-directory: ./src
|
||||
- name: Compile messages
|
||||
run: python manage.py compilemessages
|
||||
working-directory: ./src
|
||||
@@ -56,7 +57,8 @@ jobs:
|
||||
- name: Install system packages
|
||||
run: sudo apt update && sudo apt install enchant hunspell hunspell-de-de aspell-en aspell-de
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -Ur src/requirements/dev.txt
|
||||
run: pip3 install -e ".[dev]"
|
||||
working-directory: ./src
|
||||
- name: Spellcheck translations
|
||||
run: potypo
|
||||
working-directory: ./src
|
||||
|
||||
6
.github/workflows/style.yml
vendored
6
.github/workflows/style.yml
vendored
@@ -29,7 +29,8 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -Ur src/requirements/dev.txt
|
||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||
working-directory: ./src
|
||||
- name: Run isort
|
||||
run: isort -c .
|
||||
working-directory: ./src
|
||||
@@ -49,7 +50,8 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||
working-directory: ./src
|
||||
- name: Run flake8
|
||||
run: flake8 .
|
||||
working-directory: ./src
|
||||
|
||||
3
.github/workflows/tests.yml
vendored
3
.github/workflows/tests.yml
vendored
@@ -57,7 +57,8 @@ jobs:
|
||||
- name: Install system dependencies
|
||||
run: sudo apt update && sudo apt install gettext mysql-client
|
||||
- name: Install Python dependencies
|
||||
run: pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt mysqlclient psycopg2-binary
|
||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||
working-directory: ./src
|
||||
- name: Run checks
|
||||
run: python manage.py check
|
||||
working-directory: ./src
|
||||
|
||||
@@ -5,8 +5,8 @@ tests:
|
||||
- virtualenv env
|
||||
- source env/bin/activate
|
||||
- pip install -U pip wheel setuptools
|
||||
- XDG_CACHE_HOME=/cache pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt
|
||||
- cd src
|
||||
- XDG_CACHE_HOME=/cache pip3 install -e ".[dev]"
|
||||
- python manage.py check
|
||||
- make all compress
|
||||
- py.test --reruns 3 -n 3 tests
|
||||
@@ -21,8 +21,8 @@ pypi:
|
||||
- virtualenv env
|
||||
- source env/bin/activate
|
||||
- pip install -U pip wheel setuptools check-manifest twine
|
||||
- XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt
|
||||
- cd src
|
||||
- XDG_CACHE_HOME=/cache pip3 install -e ".[dev]"
|
||||
- python setup.py sdist
|
||||
- pip install dist/pretix-*.tar.gz
|
||||
- python -m pretix migrate
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -1,9 +1,9 @@
|
||||
FROM python:3.8
|
||||
FROM python:3.9-bullseye
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
default-libmysqlclient-dev \
|
||||
libmariadb-dev \
|
||||
gettext \
|
||||
git \
|
||||
libffi-dev \
|
||||
@@ -15,8 +15,7 @@ RUN apt-get update && \
|
||||
libxslt1-dev \
|
||||
locales \
|
||||
nginx \
|
||||
python-dev \
|
||||
python-virtualenv \
|
||||
python3-virtualenv \
|
||||
python3-dev \
|
||||
sudo \
|
||||
supervisor \
|
||||
@@ -41,17 +40,14 @@ ENV LC_ALL=C.UTF-8 \
|
||||
DJANGO_SETTINGS_MODULE=production_settings
|
||||
|
||||
# To copy only the requirements files needed to install from PIP
|
||||
COPY src/requirements /pretix/src/requirements
|
||||
COPY src/requirements.txt /pretix/src
|
||||
COPY src/setup.py /pretix/src/setup.py
|
||||
RUN pip3 install -U \
|
||||
pip \
|
||||
setuptools \
|
||||
wheel && \
|
||||
cd /pretix/src && \
|
||||
pip3 install \
|
||||
-r requirements.txt \
|
||||
-r requirements/memcached.txt \
|
||||
-r requirements/mysql.txt \
|
||||
PRETIX_DOCKER_BUILD=TRUE pip3 install \
|
||||
-e ".[memcached,mysql]" \
|
||||
gunicorn django-extensions ipython && \
|
||||
rm -rf ~/.cache/pip
|
||||
|
||||
@@ -60,10 +56,11 @@ 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/nginx-max-body-size.conf /etc/nginx/conf.d/nginx-max-body-size.conf
|
||||
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
COPY src /pretix/src
|
||||
|
||||
RUN cd /pretix/src && pip3 install .
|
||||
RUN cd /pretix/src && python setup.py install
|
||||
|
||||
RUN chmod +x /usr/local/bin/pretix && \
|
||||
rm /etc/nginx/sites-enabled/default && \
|
||||
|
||||
1
deployment/docker/nginx-max-body-size.conf
Normal file
1
deployment/docker/nginx-max-body-size.conf
Normal file
@@ -0,0 +1 @@
|
||||
client_max_body_size 100M;
|
||||
@@ -16,7 +16,6 @@ http {
|
||||
charset utf-8;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
client_max_body_size 100M;
|
||||
|
||||
log_format private '[$time_local] $host "$request" $status $body_bytes_sent';
|
||||
|
||||
@@ -66,9 +65,18 @@ http {
|
||||
access_log off;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
gzip on;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://unix:/tmp/pretix.sock:/;
|
||||
# Very important:
|
||||
# proxy_pass http://unix:/tmp/pretix.sock:;
|
||||
# is not the same as
|
||||
# proxy_pass http://unix:/tmp/pretix.sock:/;
|
||||
# In the latter case, nginx will apply its URL parsing, in the former it doesn't.
|
||||
# There are situations in which pretix' API will deal with "file names" containing %2F%2F, which
|
||||
# nginx will normalize to %2F, which can break ticket validation.
|
||||
proxy_pass http://unix:/tmp/pretix.sock:;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
}
|
||||
|
||||
@@ -297,6 +297,12 @@ to speed up various operations::
|
||||
[redis]
|
||||
location=redis://127.0.0.1:6379/1
|
||||
sessions=false
|
||||
sentinels=[
|
||||
["sentinel_host_1", 26379],
|
||||
["sentinel_host_2", 26379],
|
||||
["sentinel_host_3", 26379]
|
||||
]
|
||||
password=password
|
||||
|
||||
``location``
|
||||
The location of redis, as a URL of the form ``redis://[:password]@localhost:6379/0``
|
||||
@@ -305,13 +311,34 @@ to speed up various operations::
|
||||
``session``
|
||||
When this is set to ``True``, redis will be used as the session storage.
|
||||
|
||||
``sentinels``
|
||||
Configures redis sentinels to use.
|
||||
If you don't want to use redis sentinels, you should omit this option.
|
||||
If this is set, redis via sentinels will be used instead of plain redis.
|
||||
In this case the location should be of the form ``redis://my_master/0``.
|
||||
The ``sentinels`` variable should be a json serialized list of sentinels,
|
||||
each being a list with the two elements hostname and port.
|
||||
You cannot provide a password within the location when using sentinels.
|
||||
Note that the configuration format requires you to either place the entire
|
||||
value on one line or make sure all values are indented by at least one space.
|
||||
|
||||
``password``
|
||||
If your redis setup doesn't require a password or you already specified it in the location you can omit this option.
|
||||
If this is set it will be passed to redis as the connection option PASSWORD.
|
||||
|
||||
If redis is not configured, pretix will store sessions and locks in the database. If memcached
|
||||
is configured, memcached will be used for caching instead of redis.
|
||||
|
||||
Translations
|
||||
------------
|
||||
|
||||
pretix comes with a number of translations. Some of them are marked as "incubating", which means
|
||||
pretix comes with a number of translations. All languages are enabled by default. If you want to limit
|
||||
the languages available in your installation, you can enable a set of languages like this::
|
||||
|
||||
[languages]
|
||||
enabled=en,de
|
||||
|
||||
Some of the languages them are marked as "incubating", which means
|
||||
they can usually only be selected in development mode. If you want to use them nevertheless, you
|
||||
can activate them like this::
|
||||
|
||||
@@ -337,11 +364,22 @@ an AMQP server (e.g. RabbitMQ) as a broker and redis or your database as a resul
|
||||
[celery]
|
||||
broker=amqp://guest:guest@localhost:5672//
|
||||
backend=redis://localhost/0
|
||||
broker_transport_options="{}"
|
||||
backend_transport_options="{}"
|
||||
|
||||
RabbitMQ might be the better choice if you have a complex, multi-server, high-performance setup,
|
||||
but as you already should have a redis instance ready for session and lock storage, we recommend
|
||||
redis for convenience. See the `Celery documentation`_ for more details.
|
||||
|
||||
The two ``transport_options`` entries can be omitted in most cases.
|
||||
If they are present they need to be a valid JSON dictionary.
|
||||
For possible entries in that dictionary see the `Celery documentation`_.
|
||||
|
||||
To use redis with sentinels set the broker or backend to ``sentinel://sentinel_host_1:26379;sentinal_host_2:26379/0``
|
||||
and the respective transport_options to ``{"master_name":"mymaster"}``.
|
||||
If your redis instances behind the sentinel have a password use ``sentinel://:my_password@sentinel_host_1:26379;sentinal_host_2:26379/0``.
|
||||
If your redis sentinels themselves have a password set the transport_options to ``{"master_name":"mymaster","sentinel_kwargs":{"password":"my_password"}}``.
|
||||
|
||||
Sentry
|
||||
------
|
||||
|
||||
@@ -396,3 +434,19 @@ pretix can make use of some external tools if they are installed. Currently, the
|
||||
|
||||
.. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure
|
||||
.. _Celery documentation: http://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||
|
||||
Maximum upload file sizes
|
||||
-------------------------
|
||||
|
||||
You can configure the maximum file size for uploading various files::
|
||||
|
||||
[pretix_file_upload]
|
||||
; Max upload size for images in MiB, defaults to 10 MiB
|
||||
max_size_image = 12
|
||||
; Max upload size for favicons in MiB, defaults to 1 MiB
|
||||
max_size_favicon = 2
|
||||
; Max upload size for email attachments in MiB, defaults to 10 MiB
|
||||
max_size_email_attachment = 15
|
||||
; Max upload size for other files in MiB, defaults to 10 MiB
|
||||
; This includes all file upload type order questions
|
||||
max_size_other = 100
|
||||
|
||||
@@ -39,6 +39,10 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
.. warning:: By default, using `ufw` in conjunction will not have any effect. Please make sure to either bind the exposed
|
||||
ports of your docker container explicitly to 127.0.0.1 or configure docker to respect any set up firewall
|
||||
rules.
|
||||
|
||||
On this guide
|
||||
-------------
|
||||
|
||||
@@ -58,7 +62,12 @@ Database
|
||||
--------
|
||||
|
||||
Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on
|
||||
our database's shell. For PostgreSQL, we would do::
|
||||
our database's shell. Please make sure that UTF8 is used as encoding for the best compatibility. You can check this with
|
||||
the following command::
|
||||
|
||||
# sudo -u postgres psql -c 'SHOW SERVER_ENCODING'
|
||||
|
||||
For PostgreSQL database creation, we would do::
|
||||
|
||||
# sudo -u postgres createuser -P pretix
|
||||
# sudo -u postgres createdb -O pretix pretix
|
||||
@@ -178,7 +187,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
|
||||
TimeoutStartSec=0
|
||||
ExecStartPre=-/usr/bin/docker kill %n
|
||||
ExecStartPre=-/usr/bin/docker rm %n
|
||||
ExecStart=/usr/bin/docker run --name %n -p 8345:80 \
|
||||
ExecStart=/usr/bin/docker run --name %n -p 127.0.0.1:8345:80 \
|
||||
-v /var/pretix-data:/data \
|
||||
-v /etc/pretix:/etc/pretix \
|
||||
-v /var/run/redis:/var/run/redis \
|
||||
@@ -228,7 +237,7 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8345/;
|
||||
proxy_pass http://localhost:8345;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
@@ -25,7 +25,7 @@ installation guides):
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `PostgreSQL`_ 9.6+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `redis`_ server
|
||||
* A `nodejs_` installation
|
||||
* A `nodejs`_ installation
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
Linux and firewalls, we recommend that you start with `ufw`_.
|
||||
@@ -51,7 +51,12 @@ Database
|
||||
--------
|
||||
|
||||
Having the database server installed, we still need a database and a database user. We can create these with any kind
|
||||
of database managing tool or directly on our database's shell. For PostgreSQL, we would do::
|
||||
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
|
||||
best compatibility. You can check this with the following command::
|
||||
|
||||
# sudo -u postgres psql -c 'SHOW SERVER_ENCODING'
|
||||
|
||||
For PostgreSQL database creation, we would do::
|
||||
|
||||
# sudo -u postgres createuser pretix
|
||||
# sudo -u postgres createdb -O pretix pretix
|
||||
@@ -67,7 +72,7 @@ To build and run pretix, you will need the following debian packages::
|
||||
|
||||
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \
|
||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||
gettext libpq-dev libmariadbclient-dev libjpeg-dev libopenjp2-7-dev
|
||||
gettext libpq-dev libmariadb-dev libjpeg-dev libopenjp2-7-dev
|
||||
|
||||
Config file
|
||||
-----------
|
||||
@@ -129,10 +134,13 @@ python installation::
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U pip setuptools wheel
|
||||
|
||||
We now install pretix, its direct dependencies and gunicorn. Replace ``postgres`` with ``mysql`` in the following
|
||||
command if you're running MySQL::
|
||||
We now install pretix, its direct dependencies and gunicorn::
|
||||
|
||||
(venv)$ pip3 install "pretix[postgres]" gunicorn
|
||||
(venv)$ pip3 install pretix gunicorn
|
||||
|
||||
If you're running MySQL, also install the client library::
|
||||
|
||||
(venv)$ pip3 install mysqlclient
|
||||
|
||||
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
|
||||
|
||||
@@ -229,7 +237,7 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8345/;
|
||||
proxy_pass http://localhost:8345;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Host $http_host;
|
||||
@@ -251,14 +259,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /var/pretix/venv/lib/python3.5/site-packages/pretix/static.dist/;
|
||||
alias /var/pretix/venv/lib/python3.7/site-packages/pretix/static.dist/;
|
||||
access_log off;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
}
|
||||
|
||||
.. note:: Remember to replace the ``python3.5`` in the ``/static/`` path in the config
|
||||
.. note:: Remember to replace the ``python3.7`` in the ``/static/`` path in the config
|
||||
above with your python version.
|
||||
|
||||
We recommend reading about setting `strong encryption settings`_ for your web server.
|
||||
@@ -277,11 +285,10 @@ Updates
|
||||
|
||||
.. warning:: While we try hard not to break things, **please perform a backup before every upgrade**.
|
||||
|
||||
To upgrade to a new pretix release, pull the latest code changes and run the following commands (again, replace
|
||||
``postgres`` with ``mysql`` if necessary)::
|
||||
To upgrade to a new pretix release, pull the latest code changes and run the following commands::
|
||||
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U pretix[postgres] gunicorn
|
||||
(venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
(venv)$ python -m pretix updatestyles
|
||||
|
||||
@@ -87,7 +87,8 @@ respectively, or ``null`` if there is no such page. You can use those URLs to re
|
||||
respective page.
|
||||
|
||||
The field ``results`` contains a list of objects representing the first results. For most
|
||||
objects, every page contains 50 results.
|
||||
objects, every page contains 50 results. You can specify a lower pagination size using the
|
||||
``page_size`` query parameter, but no more than 50.
|
||||
|
||||
Conditional fetching
|
||||
--------------------
|
||||
|
||||
@@ -243,6 +243,99 @@ Cart position endpoints
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
|
||||
order.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/bulk_create/
|
||||
|
||||
Creates multiple new cart position. This operation is deliberately not atomic, so each cart position can succeed
|
||||
or fail individually, so the response code of the response is not the only thing to look at!
|
||||
|
||||
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
|
||||
|
||||
.. warning:: The same limitations as with the regular creation endpoint apply.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/bulk_create/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name_parts": {
|
||||
"given_name": "Peter",
|
||||
"family_name": "Miller"
|
||||
},
|
||||
"attendee_email": null,
|
||||
"answers": [
|
||||
{
|
||||
"question": 1,
|
||||
"answer": "23",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"subevent": null
|
||||
},
|
||||
{
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name_parts": {
|
||||
"given_name": "Maria",
|
||||
"family_name": "Miller"
|
||||
},
|
||||
"attendee_email": null,
|
||||
"answers": [
|
||||
{
|
||||
"question": 1,
|
||||
"answer": "23",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"subevent": null
|
||||
}
|
||||
]
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"success": true,
|
||||
"errors": null,
|
||||
"data": {
|
||||
"id": 1,
|
||||
...
|
||||
},
|
||||
},
|
||||
{
|
||||
"success": "false",
|
||||
"errors": {
|
||||
"non_field_errors": ["There is not enough quota available on quota \"Tickets\" to perform the operation."]
|
||||
},
|
||||
"data": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create positions for
|
||||
:param event: The ``slug`` field of the event to create positions for
|
||||
:statuscode 200: See response for success
|
||||
:statuscode 400: Your input could not be parsed
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
|
||||
order.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/
|
||||
|
||||
Deletes a cart position, identified by its internal ID.
|
||||
|
||||
@@ -362,6 +362,42 @@ Endpoints
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/failed_checkins/
|
||||
|
||||
Stores a failed check-in. Only necessary for statistical purposes if you perform scan validation offline.
|
||||
|
||||
:<json boolean error_reason: One of ``canceled``, ``invalid``, ``unpaid``, ``product``, ``rules``, ``revoked``,
|
||||
``incomplete``, ``already_redeemed``, or ``error``. Required.
|
||||
:<json raw_barcode: The raw barcode you scanned. Required.
|
||||
:<json datetime: Date and time of the scan. Optional.
|
||||
:<json type: Type of scan, defaults to ``"entry"``.
|
||||
:<json position: Internal ID of an order position you matched. Optional.
|
||||
:<json raw_item: Internal ID of an item you matched. Optional.
|
||||
:<json raw_variation: Internal ID of an item variation you matched. Optional.
|
||||
:<json raw_subevent: Internal ID of an event series date you matched. Optional.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/failed_checkins/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"raw_barcode": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
|
||||
"error_reason": "canceled"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param list: The ID of the check-in list to save for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: Invalid request
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order position or check-in list does not exist.
|
||||
|
||||
|
||||
Order position endpoints
|
||||
------------------------
|
||||
@@ -424,6 +460,9 @@ Order position endpoints
|
||||
"checkins": [
|
||||
{
|
||||
"list": 1,
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": true
|
||||
}
|
||||
@@ -535,6 +574,9 @@ Order position endpoints
|
||||
{
|
||||
"list": 1,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"auto_checked_in": true
|
||||
}
|
||||
],
|
||||
@@ -562,6 +604,8 @@ Order position endpoints
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order position or check-in list does not exist.
|
||||
|
||||
.. _`rest-checkin-redeem`:
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/redeem/
|
||||
|
||||
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
||||
@@ -576,8 +620,9 @@ Order position endpoints
|
||||
:<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
|
||||
returned. Otherwise, canceled orders will return ``unpaid``.
|
||||
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
|
||||
:<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required
|
||||
questions that have not been filled. Defaults to ``false``.
|
||||
:<json boolean force: Specifies that the check-in should succeed regardless of revoked barcode, previous check-ins or required
|
||||
questions that have not been filled. This is usually used to upload offline scans that already happened,
|
||||
because there's no point in validating them since they happened whether they are valid or not. Defaults to ``false``.
|
||||
:<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
|
||||
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
|
||||
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in
|
||||
|
||||
@@ -84,6 +84,12 @@ Endpoints
|
||||
|
||||
The ``clone_from`` parameter has been added to the event creation endpoint.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``with_availability_for`` parameter has been added.
|
||||
|
||||
The ``search`` query parameter has been added to filter events by their slug, name, or location in any language.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/
|
||||
|
||||
Returns a list of all events within a given organizer the authenticated user/token has access to.
|
||||
@@ -162,6 +168,11 @@ Endpoints
|
||||
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.
|
||||
:query with_availability_for: If set to a sales channel identifier, the response will contain a special ``best_availability_state``
|
||||
attribute with values of 100 for "tickets available", values less than 100 for "tickets sold out or reserved",
|
||||
and ``null`` for "status unknown". These values might be served from a cache. This parameter can make the response
|
||||
slow.
|
||||
:query search: Only return events matching a given search query.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
@@ -58,6 +58,15 @@ lines list of objects The actual invo
|
||||
created before this field was introduced as well as for
|
||||
all lines not created by a product (e.g. a shipping or
|
||||
cancellation fee).
|
||||
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
|
||||
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
|
||||
all invoice lines
|
||||
created before this field was introduced as well as for
|
||||
all lines not created by a fee (e.g. a product).
|
||||
├ fee_internal_type string Additional fee type, e.g. type of payment provider. Can be ``null``
|
||||
for all invoice lines
|
||||
created before this field was introduced as well as for
|
||||
all lines not created by a fee (e.g. a product).
|
||||
├ event_date_from datetime Start date of the (sub)event this line was created for as it
|
||||
was set during invoice creation. Can be ``null`` for all invoice
|
||||
lines created before this was introduced as well as for lines in
|
||||
@@ -97,6 +106,10 @@ internal_reference string Customer's refe
|
||||
``lines.event_date_to``, and ``lines.attendee_name`` have been added.
|
||||
``refers`` now returns an invoice number including the prefix.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The attributes ``fee_type`` and ``fee_internal_type`` have been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -162,6 +175,8 @@ Endpoints
|
||||
"description": "Budget Ticket",
|
||||
"item": 1234,
|
||||
"variation": 245,
|
||||
"fee_type": null,
|
||||
"fee_internal_type": null,
|
||||
"event_date_from": "2017-12-27T10:00:00Z",
|
||||
"event_date_to": null,
|
||||
"attendee_name": null,
|
||||
@@ -248,6 +263,8 @@ Endpoints
|
||||
"description": "Budget Ticket",
|
||||
"item": 1234,
|
||||
"variation": 245,
|
||||
"fee_type": null,
|
||||
"fee_internal_type": null,
|
||||
"event_date_from": "2017-12-27T10:00:00Z",
|
||||
"event_date_to": null,
|
||||
"attendee_name": null,
|
||||
|
||||
@@ -26,6 +26,18 @@ description multi-lingual string A public descri
|
||||
position integer An integer, used for sorting
|
||||
require_membership boolean If ``true``, booking this variation requires an active membership.
|
||||
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
||||
sales_channels list of strings Sales channels this variation is available on, such as
|
||||
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
|
||||
The item-level list takes precedence, i.e. a sales
|
||||
channel needs to be on both lists for the item to be
|
||||
available.
|
||||
available_from datetime The first date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
available_until datetime The last date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
|
||||
redemption process, but not in the normal shop
|
||||
frontend.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
@@ -64,6 +76,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": {
|
||||
"en": "Test2"
|
||||
},
|
||||
@@ -129,6 +145,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
@@ -160,6 +180,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
@@ -181,6 +205,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
@@ -233,6 +261,10 @@ Endpoints
|
||||
"active": false,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
@@ -107,6 +107,18 @@ variations list of objects A list with one
|
||||
├ require_membership boolean If ``true``, booking this variation requires an active membership.
|
||||
├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
||||
Markdown syntax or can be ``null``.
|
||||
├ sales_channels list of strings Sales channels this variation is available on, such as
|
||||
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
|
||||
The item-level list takes precedence, i.e. a sales
|
||||
channel needs to be on both lists for the item to be
|
||||
available.
|
||||
├ available_from datetime The first date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
├ available_until datetime The last date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
|
||||
redemption process, but not in the normal shop
|
||||
frontend.
|
||||
└ position integer An integer, used for sorting
|
||||
addons list of objects Definition of add-ons that can be chosen for this item.
|
||||
Only writable during creation,
|
||||
@@ -230,6 +242,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
@@ -241,6 +257,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
@@ -337,6 +357,10 @@ Endpoints
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"description": null,
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
@@ -347,6 +371,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
@@ -422,6 +450,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
@@ -433,6 +465,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
@@ -497,6 +533,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
@@ -508,6 +548,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
@@ -603,6 +647,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
@@ -614,6 +662,10 @@ Endpoints
|
||||
"active": true,
|
||||
"require_membership": false,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ payment_date date **DEPRECATED AN
|
||||
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
|
||||
total money (string) Total value of this order
|
||||
comment string Internal comment on this order
|
||||
custom_followup_at date Internal date for a custom follow-up action
|
||||
checkin_attention boolean If ``true``, the check-in app should show a warning
|
||||
that this ticket requires special attention if a ticket
|
||||
of this order is scanned.
|
||||
@@ -123,6 +124,10 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``customer`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``custom_followup_at`` attribute has been added.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -160,11 +165,13 @@ secret string Secret code pri
|
||||
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
|
||||
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
|
||||
pseudonymization_id string A random ID, e.g. for use in lead scanning apps
|
||||
checkins list of objects List of check-ins with this ticket
|
||||
checkins list of objects List of **successful** check-ins with this ticket
|
||||
├ id integer Internal ID of the check-in event
|
||||
├ list integer Internal ID of the check-in list
|
||||
├ datetime datetime Time of check-in
|
||||
├ type string Type of scan (defaults to ``entry``)
|
||||
├ gate integer Internal ID of the gate. Can be ``null``.
|
||||
├ device integer Internal ID of the device. Can be ``null``.
|
||||
└ auto_checked_in boolean Indicates if this check-in been performed automatically by the system
|
||||
downloads list of objects List of ticket download options
|
||||
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
|
||||
@@ -305,6 +312,7 @@ List of all orders
|
||||
"fees": [],
|
||||
"total": "23.00",
|
||||
"comment": "",
|
||||
"custom_followup_at": null,
|
||||
"checkin_attention": false,
|
||||
"require_approval": false,
|
||||
"invoice_address": {
|
||||
@@ -355,6 +363,8 @@ List of all orders
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
@@ -474,6 +484,7 @@ Fetching individual orders
|
||||
"fees": [],
|
||||
"total": "23.00",
|
||||
"comment": "",
|
||||
"custom_followup_at": null,
|
||||
"checkin_attention": false,
|
||||
"require_approval": false,
|
||||
"invoice_address": {
|
||||
@@ -524,6 +535,8 @@ Fetching individual orders
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
@@ -638,6 +651,8 @@ Updating order fields
|
||||
|
||||
* ``comment``
|
||||
|
||||
* ``custom_followup_at``
|
||||
|
||||
* ``invoice_address`` (you always need to supply the full object, or ``null`` to delete the current address)
|
||||
|
||||
**Example request**:
|
||||
@@ -811,6 +826,7 @@ Creating orders
|
||||
charge will be created), this is just informative in case you *handled the payment already*.
|
||||
* ``payment_date`` (optional) – Date and time of the completion of the payment.
|
||||
* ``comment`` (optional)
|
||||
* ``custom_followup_at`` (optional)
|
||||
* ``checkin_attention`` (optional)
|
||||
* ``invoice_address`` (optional)
|
||||
|
||||
@@ -1421,6 +1437,8 @@ List of all order positions
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
@@ -1527,6 +1545,8 @@ Fetching individual positions
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
|
||||
@@ -28,12 +28,22 @@ closed boolean Whether the quo
|
||||
field).
|
||||
release_after_exit boolean Whether the quota regains capacity as soon as some tickets
|
||||
have been scanned at an exit.
|
||||
available boolean Whether this quota is available. Only returned if ``with_availability=true``
|
||||
is set on the request. Do not rely on this value for critical operations, it may be
|
||||
slightly out of date.
|
||||
available_number integer Number of available tickets. Only returned if ``with_availability=true``
|
||||
is set on the request. Do not rely on this value for critical operations, it may be
|
||||
slightly out of date. ``null`` means unlimited.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The attribute ``release_after_exit`` has been added.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``with_availability`` query parameter has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -80,6 +90,7 @@ Endpoints
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
|
||||
Default: ``position``
|
||||
:query integer subevent: Only return quotas of the sub-event with the given ID
|
||||
:query string with_availability: Set to ``true`` to get availability information. Can lead to increased answer times.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
@@ -120,6 +131,7 @@ Endpoints
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the quota to fetch
|
||||
:query string with_availability: Set to ``true`` to get availability information. Can lead to increased answer times.
|
||||
: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.
|
||||
|
||||
@@ -82,6 +82,10 @@ Endpoints
|
||||
|
||||
The sub-events resource can now be filtered by meta data attributes.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``with_availability_for`` parameter has been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/
|
||||
|
||||
Returns a list of all sub-events of an event.
|
||||
@@ -152,6 +156,10 @@ Endpoints
|
||||
only those sub-events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that
|
||||
have no value set. Please note that this filter will respect default values set on
|
||||
organizer or event level.
|
||||
:query with_availability_for: If set to a sales channel identifier, the response will contain a special ``best_availability_state``
|
||||
attribute with values of 100 for "tickets available", values less than 100 for "tickets sold out or reserved",
|
||||
and ``null`` for "status unknown". These values might be served from a cache. This parameter can make the response
|
||||
slow.
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
28
doc/development/algorithms/checkin.rst
Normal file
28
doc/development/algorithms/checkin.rst
Normal file
@@ -0,0 +1,28 @@
|
||||
.. spelling: libpretixsync
|
||||
|
||||
Check-in algorithms
|
||||
===================
|
||||
|
||||
When a ticket is scanned at the entrance or exit of an event, we follow a series of steps to determine whether
|
||||
the check-in is allowed or not. To understand some of the terms in the following diagrams, you should also check
|
||||
out the documentation of the :ref:`ticket redemption API endpoint <rest-checkin-redeem>`.
|
||||
|
||||
Server-side
|
||||
-----------
|
||||
|
||||
The following diagram shows the series of checks executed on the server when a ticket is redeemed through the API.
|
||||
Some simplifications have been made, for example the deduplication mechanism based on the ``nonce`` parameter
|
||||
to prevent re-uploads of the same scan is not shown.
|
||||
|
||||
.. image:: /images/checkin_online.png
|
||||
|
||||
Client-side
|
||||
-----------
|
||||
|
||||
The process of verifying tickets offline is a little different. There are two different approaches,
|
||||
depending on whether we have information about all tickets in the local database. The following diagram shows
|
||||
the algorithm as currently implemented in recent versions of `libpretixsync`_.
|
||||
|
||||
.. image:: /images/checkin_offline.png
|
||||
|
||||
.. _libpretixsync: https://github.com/pretix/libpretixsync
|
||||
13
doc/development/algorithms/index.rst
Normal file
13
doc/development/algorithms/index.rst
Normal file
@@ -0,0 +1,13 @@
|
||||
Algorithms
|
||||
==========
|
||||
|
||||
The business logic inside pretix is full of complex algorithms making decisions based on all the hundreds of settings
|
||||
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very
|
||||
when working on features close to them, or because they also need to be re-implemented by client-side components like our
|
||||
ticket scanning apps and we want to ensure the implementations are as similar as possible to avoid confusion.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
checkin
|
||||
layouts
|
||||
15
doc/development/algorithms/layouts.rst
Normal file
15
doc/development/algorithms/layouts.rst
Normal file
@@ -0,0 +1,15 @@
|
||||
.. spelling: pretixPOS
|
||||
|
||||
Ticket layout
|
||||
=============
|
||||
|
||||
When a ticket is exported to PDF, the system needs to decide which of multiple PDF layouts to use. The
|
||||
following diagram shows the steps of the decision, showing both the implementation in pretix itself as
|
||||
well as the implementation in `pretixPOS`_.
|
||||
|
||||
The process can be influenced by plugins, which is demonstrated with the example of the shipping plugin.
|
||||
|
||||
.. image:: /images/ticket_layouts.png
|
||||
|
||||
|
||||
.. _pretixPOS: https://pretix.eu/about/en/pos
|
||||
@@ -8,6 +8,7 @@ Developer documentation
|
||||
setup
|
||||
contribution/index
|
||||
implementation/index
|
||||
translation/index
|
||||
algorithms/index
|
||||
api/index
|
||||
structure
|
||||
translation/index
|
||||
|
||||
@@ -54,7 +54,7 @@ Working with the code
|
||||
The first thing you need are all the main application's dependencies::
|
||||
|
||||
cd src/
|
||||
pip3 install -r requirements.txt -r requirements/dev.txt
|
||||
pip3 install -e ".[dev]"
|
||||
|
||||
Next, you need to copy the SCSS files from the source folder to the STATIC_ROOT directory::
|
||||
|
||||
|
||||
BIN
doc/images/checkin_offline.png
Normal file
BIN
doc/images/checkin_offline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 236 KiB |
146
doc/images/checkin_offline.puml
Normal file
146
doc/images/checkin_offline.puml
Normal file
@@ -0,0 +1,146 @@
|
||||
@startuml
|
||||
|
||||
|
||||
partition "data-based check" {
|
||||
"Check based on local database" --> "Is the order in status PAID or PENDING\nand is the position not canceled?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error CANCELED"
|
||||
else
|
||||
-down->[yes] "Is the product part of the check-in list?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error PRODUCT"
|
||||
else
|
||||
-down->[yes] "Is the subevent part of the check-in list?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error INVALID"
|
||||
note bottom: TODO\ninconsistent\nwith online\ncheck
|
||||
else
|
||||
-down->[yes] "Is the order in status PAID?"
|
||||
--> if "" then
|
||||
-right->[no] "Does the check-in list include pending orders?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error UNPAID "
|
||||
else
|
||||
-down->[yes] "Is ignore_unpaid set?\n(Has the operator confirmed\nthe checkin?)"
|
||||
--> if "" then
|
||||
-right->[no] "Return error UNPAID "
|
||||
else
|
||||
-down->[yes] "Is this an entry or exit?"
|
||||
endif
|
||||
endif
|
||||
else
|
||||
-down->[yes] "Is this an entry or exit?"
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
"Is this an entry or exit?" --> if "" then
|
||||
-right->[entry] Evaluate custom logic (rules)
|
||||
--> if "" then
|
||||
-right->[error] "Return error RULES"
|
||||
else
|
||||
-down->[ok] "Are all required questions answered?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error INCOMPLETE"
|
||||
else
|
||||
-down->[yes] "Does the check-in list allow multi-entry?"
|
||||
endif
|
||||
endif
|
||||
else
|
||||
-->[exit] "Return OK "
|
||||
endif
|
||||
|
||||
"Does the check-in list allow multi-entry?" --> if "" then
|
||||
-right->[yes] "Return OK"
|
||||
else
|
||||
-down->[no] "Is this the first checkin\nfor this ticket on this list?"
|
||||
--> if "" then
|
||||
-right->[yes] "Return OK"
|
||||
else
|
||||
-down->[no] "Are all previous checkins\nfor this ticket on this list exits?"
|
||||
--> if "" then
|
||||
-right->[yes] "Return OK"
|
||||
else
|
||||
-down->[no] "Does the check-in list\n allow entry after exit\nand is the last checkin\nan exit?"
|
||||
--> if "" then
|
||||
-right->[yes] "Return OK"
|
||||
else
|
||||
-down->[no] "Return error ALREADY_REDEEMED"
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
}
|
||||
|
||||
partition "dataless check" {
|
||||
"Check based on secret content" --> "Does the secret decode with\nany supported scheme\nand has a valid signature?"
|
||||
|
||||
--> if "" then
|
||||
-down->[yes] "Is the ticket secret on the revocation list?"
|
||||
--> if "" then
|
||||
-right->[yes] "Return error REVOKED"
|
||||
else
|
||||
-down->[no] "Is the product part of the check-in list? "
|
||||
--> if "" then
|
||||
-right->[no] "Return error PRODUCT "
|
||||
else
|
||||
-down->[yes] "Is the subevent part of the check-in list? "
|
||||
--> if "" then
|
||||
-right->[no] "Return error INVALID "
|
||||
note bottom: TODO\ninconsistent\nwith online\ncheck
|
||||
else
|
||||
--> "Is this an entry or exit? "
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
else
|
||||
-right>[no] "Return error INVALID "
|
||||
endif
|
||||
|
||||
"Is this an entry or exit? " --> if "" then
|
||||
-right->[entry] "Evaluate custom logic (rules) "
|
||||
--> if "" then
|
||||
-right->[error] "Return error RULES "
|
||||
else
|
||||
-down->[ok] "Are all required questions answered? "
|
||||
--> if "" then
|
||||
-right->[no] "Return error INCOMPLETE "
|
||||
else
|
||||
-down->[yes] "Does the check-in list allow multi-entry? "
|
||||
endif
|
||||
endif
|
||||
else
|
||||
-->[exit] " Return OK "
|
||||
endif
|
||||
|
||||
"Does the check-in list allow multi-entry? " --> if "" then
|
||||
-right->[yes] " Return OK "
|
||||
else
|
||||
-down->[no] "Are any locally queued checkins for\nthis ticket of this list known?"
|
||||
--> if "" then
|
||||
-right->[no] " Return OK "
|
||||
else
|
||||
-down->[yes] "Are all locally queued checkins\nfor this ticket on this list exits? "
|
||||
--> if "" then
|
||||
-right->[yes] " Return OK "
|
||||
else
|
||||
-down->[no] "Does the check-in list\n allow entry after exit\nand is the last locally\nqueued checkin\nan exit? "
|
||||
--> if "" then
|
||||
-right->[yes] " Return OK "
|
||||
else
|
||||
-down->[no] "Return error ALREADY_REDEEMED "
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
}
|
||||
|
||||
(*) --> "Check if order position with\nscanned ticket secret exists"
|
||||
--> if "" then
|
||||
-down->[yes] "Check based on local database"
|
||||
else
|
||||
-->[no] "Check based on secret content"
|
||||
endif
|
||||
|
||||
@enduml
|
||||
BIN
doc/images/checkin_online.png
Normal file
BIN
doc/images/checkin_online.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
92
doc/images/checkin_online.puml
Normal file
92
doc/images/checkin_online.puml
Normal file
@@ -0,0 +1,92 @@
|
||||
@startuml
|
||||
|
||||
(*) --> "Check if order position with\nscanned ticket secret exists"
|
||||
--> if "" then
|
||||
-down->[yes] ===CHECK===
|
||||
else
|
||||
-->[no] "Check if secret exists\nin revocation list"
|
||||
--> if "" then
|
||||
--> "Is this a forced upload?"
|
||||
--> if "" then
|
||||
-->[yes] ===CHECK===
|
||||
else
|
||||
-right->[no] "Return error REVOKED"
|
||||
endif
|
||||
else
|
||||
-right->[no] "Return error INVALID"
|
||||
endif
|
||||
|
||||
endif
|
||||
|
||||
|
||||
===CHECK=== -down-> "Is the order in status PAID or PENDING\nand is the position not canceled?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error CANCELED"
|
||||
else
|
||||
-down->[yes] "Is the product part of the check-in list?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error PRODUCT"
|
||||
else
|
||||
-down->[yes] "Is the subevent part of the check-in list?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error PRODUCT "
|
||||
else
|
||||
-down->[yes] "Is the order in status PAID\nor is this a forced upload?"
|
||||
--> if "" then
|
||||
-right->[no] "Does the check-in list include pending orders?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error UNPAID "
|
||||
else
|
||||
-down->[yes] "Is ignore_unpaid set?\n(Has the operator confirmed\nthe checkin?)"
|
||||
--> if "" then
|
||||
-right->[no] "Return error UNPAID "
|
||||
else
|
||||
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
|
||||
endif
|
||||
endif
|
||||
else
|
||||
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
"Is this an entry or exit?\nIs the upload forced?" --> if "" then
|
||||
-right->[entry && not force] Evaluate custom logic (rules)
|
||||
--> if "" then
|
||||
-right->[error] "Return error RULES"
|
||||
else
|
||||
-down->[ok] "Are all required questions answered?"
|
||||
--> if "" then
|
||||
-right->[no && questions_supported] "Return error INCOMPLETE"
|
||||
else
|
||||
-down->[yes || not questions_supported] "Does the check-in list allow multi-entry?"
|
||||
endif
|
||||
endif
|
||||
else
|
||||
-->[exit || force=true] "Return OK "
|
||||
endif
|
||||
|
||||
"Does the check-in list allow multi-entry?" --> if "" then
|
||||
-right->[yes] "Return OK"
|
||||
else
|
||||
-down->[no] "Is this the first checkin\nfor this ticket on this list?"
|
||||
--> if "" then
|
||||
-right->[yes] "Return OK"
|
||||
else
|
||||
-down->[no] "Are all previous checkins\nfor this ticket on this list exits?"
|
||||
--> if "" then
|
||||
-right->[yes] "Return OK"
|
||||
else
|
||||
-down->[no] "Does the check-in list\n allow entry after exit\nand is the last checkin\nan exit?"
|
||||
--> if "" then
|
||||
-right->[yes] "Return OK"
|
||||
else
|
||||
-down->[no] "Return error ALREADY_REDEEMED"
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
|
||||
@enduml
|
||||
BIN
doc/images/ticket_layouts.png
Normal file
BIN
doc/images/ticket_layouts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
52
doc/images/ticket_layouts.puml
Normal file
52
doc/images/ticket_layouts.puml
Normal file
@@ -0,0 +1,52 @@
|
||||
@startuml
|
||||
|
||||
(*) --> "Which implementation?"
|
||||
--> if "" then
|
||||
-down->[pretixPOS] "Check for TicketLayoutItem with\nsales_channel=pretixpos [libpretixsync]"
|
||||
--> if "" then
|
||||
--> (*)
|
||||
else
|
||||
-->[not found] "Check for TicketLayoutItem with\nsales_channel=web [libpretixsync]"
|
||||
--> if "" then
|
||||
--> (*)
|
||||
else
|
||||
-->[not found] "Use event default [libpretixsync]"
|
||||
--> (*)
|
||||
endif
|
||||
endif
|
||||
|
||||
else
|
||||
-right->[pretix] "Check for TicketLayoutItem with\nsales_channel=order.sales_channel"
|
||||
--> if "" then
|
||||
-right-> "Run override_layout plugin signal on result"
|
||||
else
|
||||
-down->[not found] "Check for TicketLayoutItem with\nsales_channel=web"
|
||||
--> if "" then
|
||||
--> "Run override_layout plugin signal on result"
|
||||
else
|
||||
-->[not found] "Use event default"
|
||||
--> "Run override_layout plugin signal on result"
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
|
||||
"Run override_layout plugin signal on result" -> (*)
|
||||
|
||||
|
||||
partition pretix_shipping {
|
||||
"Run override_layout plugin signal on result" --> "Check for ShippingLayoutItem with\nmethod=order.shipping_method"
|
||||
--> if "" then
|
||||
--> (*)
|
||||
else
|
||||
-down->[not found] "Check for ShippingMethod.layout"
|
||||
--> if "" then
|
||||
--> (*)
|
||||
else
|
||||
-down->[not found] "Keep original layout"
|
||||
--> (*)
|
||||
endif
|
||||
endif
|
||||
}
|
||||
|
||||
@enduml
|
||||
64
doc/plugins/certificates.rst
Normal file
64
doc/plugins/certificates.rst
Normal file
@@ -0,0 +1,64 @@
|
||||
Certificates of attendance
|
||||
==========================
|
||||
|
||||
The certificates plugin provides a HTTP API that allows you to download the certificate for a specific attendee.
|
||||
|
||||
|
||||
Certificate download
|
||||
--------------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/certificate/
|
||||
|
||||
Downloads the certificate for one order position, identified by its internal ID. Download is a two-step
|
||||
process. You will always get a :http:statuscode:`303` response with a ``Location`` header to a different
|
||||
URL. In the background, our server starts preparing the PDF file.
|
||||
|
||||
If you then do a ``GET`` to the URL you were given, you will either receive a :http:statuscode:`409` response
|
||||
indicating to retry after a few seconds, or a :http:statuscode:`200` response with the PDF file.
|
||||
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 303 See Other
|
||||
Location: /api/v1/organizers/democon/events/3vjrh/orderpositions/426/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/certificate/?result=1f550651-ae7b-4911-a76c-2be8f348aaa5 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/pdf
|
||||
|
||||
...
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the order position to fetch
|
||||
:statuscode 200: File ready for download
|
||||
:statuscode 303: Processing started
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource
|
||||
**or** downloads are not available for this order position at this time. The response content will
|
||||
contain more details.
|
||||
:statuscode 404: The requested order position or download provider does not exist.
|
||||
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||
seconds.
|
||||
@@ -15,5 +15,6 @@ If you want to **create** a plugin, please go to the
|
||||
ticketoutputpdf
|
||||
badges
|
||||
campaigns
|
||||
certificates
|
||||
digital
|
||||
webinar
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-r ../src/requirements.txt
|
||||
-e ../src/
|
||||
sphinx==2.3.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
|
||||
@@ -21,6 +21,7 @@ cancelled
|
||||
casted
|
||||
Ceph
|
||||
checkbox
|
||||
checkins
|
||||
checksum
|
||||
config
|
||||
contenttypes
|
||||
|
||||
@@ -4,8 +4,7 @@ Embeddable Widget
|
||||
=================
|
||||
|
||||
If you want to show your ticket shop on your event website or blog, you can use our JavaScript widget. This way,
|
||||
users will not need to leave your site to buy their ticket in most cases. The widget will still open a new tab
|
||||
for the checkout if the user is on a mobile device.
|
||||
users will not need to leave your site to buy their ticket in most cases.
|
||||
|
||||
To obtain the correct HTML code for embedding your event into your website, we recommend that you go to the "Widget"
|
||||
tab of your event's settings. You can specify some optional settings there (for example the language of the widget)
|
||||
|
||||
2
src/.gitignore
vendored
2
src/.gitignore
vendored
@@ -9,4 +9,4 @@ dist/
|
||||
*.bak
|
||||
pretix/static/jsi18n/
|
||||
node_modules/
|
||||
|
||||
.eggs/
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "4.0.0"
|
||||
__version__ = "4.3.0"
|
||||
|
||||
@@ -59,6 +59,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:badgeitem-list'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
('GET', 'api-v1:checkinlist-status'),
|
||||
('POST', 'api-v1:checkinlist-failed_checkins'),
|
||||
('GET', 'api-v1:checkinlistpos-list'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
@@ -69,9 +70,9 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
)
|
||||
|
||||
|
||||
class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
|
||||
identifier = 'pretixscan_online_kiosk'
|
||||
verbose_name = _('pretixSCAN (kiosk mode, online only)')
|
||||
verbose_name = _('pretixSCAN (kiosk mode, no order sync, no search)')
|
||||
allowlist = (
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
@@ -89,6 +90,37 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:badgeitem-list'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
('GET', 'api-v1:checkinlist-status'),
|
||||
('POST', 'api-v1:checkinlist-failed_checkins'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
)
|
||||
|
||||
|
||||
class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
identifier = 'pretixscan_online_noorders'
|
||||
verbose_name = _('pretixSCAN (online only, no order sync)')
|
||||
allowlist = (
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
('GET', 'api-v1:event-list'),
|
||||
('GET', 'api-v1:event-detail'),
|
||||
('GET', 'api-v1:subevent-list'),
|
||||
('GET', 'api-v1:subevent-detail'),
|
||||
('GET', 'api-v1:itemcategory-list'),
|
||||
('GET', 'api-v1:item-list'),
|
||||
('GET', 'api-v1:question-list'),
|
||||
('GET', 'api-v1:badgelayout-list'),
|
||||
('GET', 'api-v1:badgeitem-list'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
('GET', 'api-v1:checkinlist-status'),
|
||||
('POST', 'api-v1:checkinlist-failed_checkins'),
|
||||
('GET', 'api-v1:checkinlistpos-list'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
@@ -131,6 +163,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'api-v1:orderrefund-list'),
|
||||
('POST', 'api-v1:orderrefund-done'),
|
||||
('POST', 'api-v1:cartposition-list'),
|
||||
('POST', 'api-v1:cartposition-bulk-create'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('POST', 'plugins:pretix_posbackend:order.posprintlog'),
|
||||
@@ -158,6 +191,7 @@ DEVICE_SECURITY_PROFILES = {
|
||||
FullAccessSecurityProfile,
|
||||
PretixScanSecurityProfile,
|
||||
PretixScanNoSyncSecurityProfile,
|
||||
PretixScanNoSyncNoSearchSecurityProfile,
|
||||
PretixPosSecurityProfile,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ class EventPermission(BasePermission):
|
||||
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
|
||||
return False
|
||||
|
||||
if request.method not in SAFE_METHODS and hasattr(view, 'write_permission'):
|
||||
if hasattr(view, '_get_permission_name'):
|
||||
required_permission = getattr(view, '_get_permission_name')(request)
|
||||
elif request.method not in SAFE_METHODS and hasattr(view, 'write_permission'):
|
||||
required_permission = getattr(view, 'write_permission')
|
||||
elif hasattr(view, 'permission'):
|
||||
required_permission = getattr(view, 'permission')
|
||||
|
||||
18
src/pretix/api/migrations/0006_alter_webhook_target_url.py
Normal file
18
src/pretix/api/migrations/0006_alter_webhook_target_url.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.2 on 2021-07-05 07:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixapi', '0005_auto_20191028_1541'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='target_url',
|
||||
field=models.URLField(max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.4 on 2021-09-15 11:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixapi', '0006_alter_webhook_target_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='webhookcall',
|
||||
name='target_url',
|
||||
field=models.URLField(max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -95,7 +95,7 @@ class OAuthRefreshToken(AbstractRefreshToken):
|
||||
class WebHook(models.Model):
|
||||
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
|
||||
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
|
||||
target_url = models.URLField(verbose_name=_("Target URL"))
|
||||
target_url = models.URLField(verbose_name=_("Target URL"), max_length=255)
|
||||
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
|
||||
|
||||
@@ -120,7 +120,7 @@ class WebHookEventListener(models.Model):
|
||||
class WebHookCall(models.Model):
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
||||
datetime = models.DateTimeField(auto_now_add=True)
|
||||
target_url = models.URLField()
|
||||
target_url = models.URLField(max_length=255)
|
||||
action_type = models.CharField(max_length=255)
|
||||
is_retry = models.BooleanField(default=False)
|
||||
execution_time = models.FloatField(null=True)
|
||||
|
||||
27
src/pretix/api/pagination.py
Normal file
27
src/pretix/api/pagination.py
Normal file
@@ -0,0 +1,27 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
class Pagination(PageNumberPagination):
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 50
|
||||
@@ -73,53 +73,61 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
|
||||
)
|
||||
|
||||
with self.context['event'].lock():
|
||||
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
|
||||
if validated_data.get('variation')
|
||||
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
|
||||
if len(new_quotas) == 0:
|
||||
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
|
||||
if validated_data.get('variation')
|
||||
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
|
||||
if len(new_quotas) == 0:
|
||||
raise ValidationError(
|
||||
gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(validated_data.get('item'))
|
||||
)
|
||||
)
|
||||
for quota in new_quotas:
|
||||
avail = quota.availability(_cache=self.context['quota_cache'])
|
||||
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
|
||||
raise ValidationError(
|
||||
gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(validated_data.get('item'))
|
||||
gettext_lazy('There is not enough quota available on quota "{}" to perform '
|
||||
'the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
)
|
||||
for quota in new_quotas:
|
||||
avail = quota.availability()
|
||||
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
|
||||
raise ValidationError(
|
||||
gettext_lazy('There is not enough quota available on quota "{}" to perform '
|
||||
'the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
)
|
||||
attendee_name = validated_data.pop('attendee_name', '')
|
||||
if attendee_name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
|
||||
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
|
||||
if validated_data.get('seat'):
|
||||
if not seated:
|
||||
raise ValidationError('The specified product does not allow to choose a seat.')
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError('The specified seat does not exist.')
|
||||
except Seat.MultipleObjectsReturned:
|
||||
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'),
|
||||
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.')
|
||||
for quota in new_quotas:
|
||||
oldsize = self.context['quota_cache'][quota.pk][1]
|
||||
newsize = oldsize - 1 if oldsize is not None else None
|
||||
self.context['quota_cache'][quota.pk] = (
|
||||
Quota.AVAILABILITY_OK if newsize is None or newsize > 0 else Quota.AVAILABILITY_GONE,
|
||||
newsize
|
||||
)
|
||||
|
||||
validated_data.pop('sales_channel')
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
attendee_name = validated_data.pop('attendee_name', '')
|
||||
if attendee_name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
|
||||
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
|
||||
if validated_data.get('seat'):
|
||||
if not seated:
|
||||
raise ValidationError('The specified product does not allow to choose a seat.')
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError('The specified seat does not exist.')
|
||||
except Seat.MultipleObjectsReturned:
|
||||
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'),
|
||||
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.')
|
||||
|
||||
validated_data.pop('sales_channel')
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options')
|
||||
|
||||
@@ -42,6 +42,7 @@ from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _
|
||||
from django_countries.serializers import CountryFieldMixin
|
||||
from pytz import common_timezones
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import ChoiceField, Field
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
|
||||
@@ -53,7 +54,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 validate_event_settings
|
||||
from pretix.base.settings import LazyI18nStringList, validate_event_settings
|
||||
from pretix.base.signals import api_event_settings_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -93,9 +94,12 @@ class MetaPropertyField(Field):
|
||||
class SeatCategoryMappingField(Field):
|
||||
|
||||
def to_representation(self, value):
|
||||
qs = value.seat_category_mappings.all()
|
||||
if isinstance(value, Event):
|
||||
qs = qs.filter(subevent=None)
|
||||
if hasattr(value, '_seat_category_mappings'):
|
||||
qs = value._seat_category_mappings
|
||||
else:
|
||||
qs = value.seat_category_mappings.all()
|
||||
if isinstance(value, Event):
|
||||
qs = qs.filter(subevent=None)
|
||||
return {
|
||||
v.layout_category: v.product_id for v in qs
|
||||
}
|
||||
@@ -156,6 +160,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
|
||||
timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones])
|
||||
valid_keys = ValidKeysField(source='*', read_only=True)
|
||||
best_availability_state = serializers.IntegerField(allow_null=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
@@ -163,12 +168,14 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
'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',
|
||||
'sales_channels')
|
||||
'sales_channels', 'best_availability_state')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not hasattr(self.context['request'], 'event'):
|
||||
self.fields.pop('valid_keys')
|
||||
if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET:
|
||||
self.fields.pop('best_availability_state')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -441,13 +448,19 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
|
||||
event = SlugRelatedField(slug_field='slug', read_only=True)
|
||||
meta_data = MetaDataField(source='*')
|
||||
best_availability_state = serializers.IntegerField(allow_null=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
|
||||
'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public',
|
||||
'frontpage_text', 'seating_plan', 'item_price_overrides', 'variation_price_overrides',
|
||||
'meta_data', 'seat_category_mapping', 'last_modified')
|
||||
'meta_data', 'seat_category_mapping', 'last_modified', 'best_availability_state')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET:
|
||||
self.fields.pop('best_availability_state')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -776,6 +789,10 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
data = super().validate(data)
|
||||
settings_dict = self.instance.freeze()
|
||||
settings_dict.update(data)
|
||||
|
||||
if data.get('confirm_texts') is not None:
|
||||
data['confirm_texts'] = LazyI18nStringList(data['confirm_texts'])
|
||||
|
||||
validate_event_settings(self.event, settings_dict)
|
||||
return data
|
||||
|
||||
|
||||
@@ -31,9 +31,10 @@
|
||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os.path
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import QuerySet
|
||||
@@ -58,7 +59,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price',
|
||||
'require_membership', 'require_membership_types',)
|
||||
'require_membership', 'require_membership_types', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -73,7 +75,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price',
|
||||
'require_membership', 'require_membership_types',)
|
||||
'require_membership', 'require_membership_types', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -161,7 +164,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
), max_size=10 * 1024 * 1024)
|
||||
), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
@@ -245,10 +248,13 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
|
||||
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
picture = validated_data.pop('picture', None)
|
||||
item = Item.objects.create(**validated_data)
|
||||
if picture:
|
||||
item.picture.save(os.path.basename(picture.name), picture)
|
||||
|
||||
for variation_data in variations_data:
|
||||
require_membership_types = variation_data.pop('require_membership_types')
|
||||
require_membership_types = variation_data.pop('require_membership_types', [])
|
||||
v = ItemVariation.objects.create(item=item, **variation_data)
|
||||
if require_membership_types:
|
||||
v.require_membership_types.add(*require_membership_types)
|
||||
@@ -269,7 +275,10 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
picture = validated_data.pop('picture', None)
|
||||
item = super().update(instance, validated_data)
|
||||
if picture:
|
||||
item.picture.save(os.path.basename(picture.name), picture)
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
@@ -408,10 +417,19 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
|
||||
class QuotaSerializer(I18nAwareModelSerializer):
|
||||
available = serializers.BooleanField(read_only=True)
|
||||
available_number = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out', 'release_after_exit')
|
||||
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out',
|
||||
'release_after_exit', 'available', 'available_number')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if 'request' not in self.context or self.context['request'].GET.get('with_availability') != 'true':
|
||||
del self.fields['available']
|
||||
del self.fields['available_number']
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -26,6 +26,7 @@ from collections import Counter, defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
import pycountry
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
@@ -191,7 +192,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > 10 * 1024 * 1024:
|
||||
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
data['options'] = []
|
||||
@@ -199,7 +200,9 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
return data
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('question').type == Question.TYPE_FILE:
|
||||
if not data.get('question'):
|
||||
raise ValidationError('Question not specified.')
|
||||
elif data.get('question').type == Question.TYPE_FILE:
|
||||
return self._handle_file_upload(data)
|
||||
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
if not data.get('options'):
|
||||
@@ -250,7 +253,30 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'type')
|
||||
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'type')
|
||||
|
||||
|
||||
class FailedCheckinSerializer(I18nAwareModelSerializer):
|
||||
error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False)
|
||||
raw_barcode = serializers.CharField(required=True, allow_null=False)
|
||||
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none(), required=False, allow_null=True)
|
||||
raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
|
||||
raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
|
||||
raw_subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation',
|
||||
'raw_subevent', 'datetime', 'type', 'position')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
event = self.context['event']
|
||||
self.fields['raw_item'].queryset = event.items.all()
|
||||
self.fields['raw_variation'].queryset = ItemVariation.objects.filter(item__event=event)
|
||||
self.fields['position'].queryset = OrderPosition.all.filter(order__event=event)
|
||||
if event.has_subevents:
|
||||
self.fields['raw_subevent'].queryset = event.subevents.all()
|
||||
|
||||
|
||||
class OrderDownloadsField(serializers.Field):
|
||||
@@ -631,7 +657,7 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
model = Order
|
||||
fields = (
|
||||
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
|
||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
||||
'url', 'customer'
|
||||
)
|
||||
@@ -662,7 +688,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', 'phone']
|
||||
update_fields = ['comment', 'custom_followup_at', 'checkin_attention', 'email', 'locale', 'phone']
|
||||
|
||||
if 'invoice_address' in validated_data:
|
||||
iadata = validated_data.pop('invoice_address')
|
||||
@@ -902,6 +928,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
min_length=5
|
||||
)
|
||||
comment = serializers.CharField(required=False, allow_blank=True)
|
||||
custom_followup_at = serializers.DateField(required=False, allow_null=True)
|
||||
payment_provider = serializers.CharField(required=False, allow_null=True)
|
||||
payment_info = CompatibleJSONField(required=False)
|
||||
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
@@ -920,7 +947,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
model = Order
|
||||
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_email', 'simulate', 'customer')
|
||||
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp is None:
|
||||
@@ -1269,7 +1296,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if pos.voucher:
|
||||
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
|
||||
pos.save()
|
||||
seen_answers = set()
|
||||
for answ_data in answers_data:
|
||||
# Workaround for a pretixPOS bug :-(
|
||||
if answ_data.get('question') in seen_answers:
|
||||
continue
|
||||
seen_answers.add(answ_data.get('question'))
|
||||
|
||||
options = answ_data.pop('options', [])
|
||||
|
||||
if isinstance(answ_data['answer'], File):
|
||||
@@ -1393,7 +1426,8 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = InvoiceLine
|
||||
fields = ('position', 'description', 'item', 'variation', 'attendee_name', 'event_date_from',
|
||||
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name')
|
||||
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
|
||||
'fee_internal_type')
|
||||
|
||||
|
||||
class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -275,6 +275,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
default_fields = [
|
||||
'customer_accounts',
|
||||
'customer_accounts_link_by_email',
|
||||
'invoice_regenerate_allowed',
|
||||
'contact_mail',
|
||||
'imprint_url',
|
||||
'organizer_info_text',
|
||||
@@ -294,6 +295,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font',
|
||||
'organizer_logo_image_inherit',
|
||||
'organizer_logo_image'
|
||||
]
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ class SettingsSerializer(serializers.Serializer):
|
||||
f = DEFAULTS[fname]['serializer_class'](
|
||||
**kwargs
|
||||
)
|
||||
f._label = form_kwargs.get('label', fname)
|
||||
f._help_text = form_kwargs.get('help_text')
|
||||
f._label = str(form_kwargs.get('label', fname))
|
||||
f._help_text = str(form_kwargs.get('help_text'))
|
||||
f.parent = self
|
||||
self.fields[fname] = f
|
||||
|
||||
|
||||
@@ -21,14 +21,18 @@
|
||||
#
|
||||
from django.db import transaction
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from pretix.api.serializers.cart import (
|
||||
CartPositionCreateSerializer, CartPositionSerializer,
|
||||
)
|
||||
from pretix.base.models import CartPosition
|
||||
from pretix.base.services.locking import NoLockManager
|
||||
|
||||
|
||||
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
@@ -50,18 +54,61 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['quota_cache'] = {}
|
||||
return ctx
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(), self.request.event.lock():
|
||||
self.perform_create(serializer)
|
||||
cp = serializer.instance
|
||||
serializer = CartPositionSerializer(cp, context=serializer.context)
|
||||
|
||||
cp = serializer.instance
|
||||
serializer = CartPositionSerializer(cp, context=serializer.context)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@action(detail=False, methods=['POST'])
|
||||
def bulk_create(self, request, *args, **kwargs):
|
||||
if not isinstance(request.data, list): # noqa
|
||||
return Response({"error": "Please supply a list"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ctx = self.get_serializer_context()
|
||||
with transaction.atomic():
|
||||
serializers = [
|
||||
CartPositionCreateSerializer(data=d, context=ctx)
|
||||
for d in request.data
|
||||
]
|
||||
|
||||
lockfn = self.request.event.lock
|
||||
if not any(s.is_valid(raise_exception=False) for s in serializers):
|
||||
lockfn = NoLockManager
|
||||
|
||||
results = []
|
||||
with lockfn():
|
||||
for s in serializers:
|
||||
if s.is_valid(raise_exception=False):
|
||||
try:
|
||||
cp = s.save()
|
||||
except ValidationError as e:
|
||||
results.append({
|
||||
'success': False,
|
||||
'data': None,
|
||||
'errors': {api_settings.NON_FIELD_ERRORS_KEY: e.detail},
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
'success': True,
|
||||
'data': CartPositionSerializer(cp, context=ctx).data,
|
||||
'errors': None,
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
'success': False,
|
||||
'data': None,
|
||||
'errors': s.errors,
|
||||
})
|
||||
|
||||
return Response({'results': results}, status=status.HTTP_200_OK)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery,
|
||||
)
|
||||
@@ -31,19 +33,24 @@ from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from packaging.version import parse
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import DateTimeField
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.checkin import CheckinListSerializer
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import CheckinListOrderPositionSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
CheckinListOrderPositionSerializer, FailedCheckinSerializer,
|
||||
)
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, CheckinList, Event, Order, OrderPosition, Question,
|
||||
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Question,
|
||||
)
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
||||
@@ -79,8 +86,14 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
queryset = CheckinList.objects.none()
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = CheckinListFilter
|
||||
permission = ('can_view_orders', 'can_checkin_orders',)
|
||||
write_permission = 'can_change_event_settings'
|
||||
|
||||
def _get_permission_name(self, request):
|
||||
if request.path.endswith('/failed_checkins/'):
|
||||
return 'can_checkin_orders', 'can_change_orders'
|
||||
elif request.method in SAFE_METHODS:
|
||||
return 'can_view_orders', 'can_checkin_orders',
|
||||
else:
|
||||
return 'can_change_event_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.event.checkin_lists.prefetch_related(
|
||||
@@ -125,6 +138,49 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@action(detail=True, methods=['POST'], url_name='failed_checkins')
|
||||
@transaction.atomic()
|
||||
def failed_checkins(self, *args, **kwargs):
|
||||
serializer = FailedCheckinSerializer(
|
||||
data=self.request.data,
|
||||
context={'event': self.request.event}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
kwargs = {}
|
||||
|
||||
if not serializer.validated_data.get('position'):
|
||||
kwargs['position'] = OrderPosition.all.filter(
|
||||
secret=serializer.validated_data['raw_barcode']
|
||||
).first()
|
||||
|
||||
c = serializer.save(
|
||||
list=self.get_object(),
|
||||
successful=False,
|
||||
forced=True,
|
||||
device=self.request.auth if isinstance(self.request.auth, Device) else None,
|
||||
gate=self.request.auth.gate if isinstance(self.request.auth, Device) else None,
|
||||
**kwargs,
|
||||
)
|
||||
if c.position:
|
||||
c.position.order.log_action('pretix.event.checkin.denied', data={
|
||||
'position': c.position.id,
|
||||
'positionid': c.position.positionid,
|
||||
'errorcode': c.error_reason,
|
||||
'reason_explanation': c.error_explanation,
|
||||
'datetime': c.datetime,
|
||||
'type': c.type,
|
||||
'list': c.list.pk
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
else:
|
||||
self.request.event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': c.datetime,
|
||||
'type': c.type,
|
||||
'list': c.list.pk,
|
||||
'barcode': c.raw_barcode
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
|
||||
return Response(serializer.data, status=201)
|
||||
|
||||
@action(detail=True, methods=['GET'])
|
||||
def status(self, *args, **kwargs):
|
||||
with language(self.request.event.settings.locale):
|
||||
@@ -294,7 +350,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
@@ -304,7 +360,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
@@ -356,6 +413,18 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
else:
|
||||
dt = now()
|
||||
|
||||
common_checkin_args = dict(
|
||||
raw_barcode=self.kwargs['pk'],
|
||||
type=type,
|
||||
list=self.checkinlist,
|
||||
datetime=dt,
|
||||
device=self.request.auth if isinstance(self.request.auth, Device) else None,
|
||||
gate=self.request.auth.gate if isinstance(self.request.auth, Device) else None,
|
||||
nonce=nonce,
|
||||
forced=force,
|
||||
)
|
||||
raw_barcode_for_checkin = None
|
||||
|
||||
try:
|
||||
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
|
||||
if self.kwargs['pk'].isnumeric():
|
||||
@@ -364,22 +433,87 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
op = queryset.get(secret=self.kwargs['pk'])
|
||||
except OrderPosition.DoesNotExist:
|
||||
revoked_matches = list(self.request.event.revoked_secrets.filter(secret=self.kwargs['pk']))
|
||||
if len(revoked_matches) == 0 or not force:
|
||||
if len(revoked_matches) == 0:
|
||||
self.request.event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk,
|
||||
'barcode': self.kwargs['pk']
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
raise Http404()
|
||||
|
||||
op = revoked_matches[0].position
|
||||
op.order.log_action('pretix.event.checkin.revoked', data={
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk,
|
||||
'barcode': self.kwargs['pk']
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
for k, s in self.request.event.ticket_secret_generators.items():
|
||||
try:
|
||||
parsed = s.parse_secret(self.kwargs['pk'])
|
||||
common_checkin_args.update({
|
||||
'raw_item': parsed.item,
|
||||
'raw_variation': parsed.variation,
|
||||
'raw_subevent': parsed.subevent,
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
Checkin.objects.create(
|
||||
position=None,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_INVALID,
|
||||
**common_checkin_args,
|
||||
)
|
||||
|
||||
if force and isinstance(self.request.auth, Device):
|
||||
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
|
||||
# valid at the time but no longer exists at time of upload, the device would retry to
|
||||
# upload the same scan over and over again. Since we can't update all devices quickly,
|
||||
# here's a dirty workaround to make it stop.
|
||||
try:
|
||||
brand = self.request.auth.software_brand
|
||||
ver = parse(self.request.auth.software_version)
|
||||
legacy_mode = (
|
||||
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
|
||||
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
|
||||
(brand == 'pretixSCAN' and ver < parse('1.11.2'))
|
||||
)
|
||||
if legacy_mode:
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_ALREADY_REDEEMED,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
|
||||
}, status=400)
|
||||
except: # we don't care e.g. about invalid version numbers
|
||||
pass
|
||||
|
||||
return Response({
|
||||
'detail': 'Not found.', # for backwards compatibility
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_INVALID,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
}, status=404)
|
||||
elif revoked_matches and force:
|
||||
op = revoked_matches[0].position
|
||||
raw_barcode_for_checkin = self.kwargs['pk']
|
||||
else:
|
||||
op = revoked_matches[0].position
|
||||
op.order.log_action('pretix.event.checkin.revoked', data={
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk,
|
||||
'barcode': self.kwargs['pk']
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_REVOKED,
|
||||
**common_checkin_args
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_REVOKED,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=400)
|
||||
|
||||
given_answers = {}
|
||||
if 'answers' in self.request.data:
|
||||
@@ -409,6 +543,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
type=type,
|
||||
raw_barcode=raw_barcode_for_checkin,
|
||||
from_revoked_secret=True,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
@@ -424,11 +560,19 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': e.code,
|
||||
'reason_explanation': e.reason,
|
||||
'force': force,
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=e.code,
|
||||
error_explanation=e.reason,
|
||||
**common_checkin_args,
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code,
|
||||
@@ -460,7 +604,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > 10 * 1024 * 1024:
|
||||
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
return cf.file
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
import django_filters
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError, Q
|
||||
from django.db.models import Prefetch, ProtectedError, Q
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
@@ -49,20 +49,24 @@ from pretix.api.serializers.event import (
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, TaxRule, TeamAPIToken,
|
||||
CartPosition, Device, Event, SeatCategoryMapping, TaxRule, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
from pretix.helpers.i18n import i18ncomp
|
||||
from pretix.presale.style import regenerate_css
|
||||
from pretix.presale.views.organizer import filter_qs_by_attr
|
||||
|
||||
with scopes_disabled():
|
||||
class EventFilter(FilterSet):
|
||||
|
||||
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')
|
||||
search = django_filters.rest_framework.CharFilter(method='search_qs')
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
@@ -107,6 +111,13 @@ with scopes_disabled():
|
||||
def sales_channel_qs(self, queryset, name, value):
|
||||
return queryset.filter(sales_channels__contains=value)
|
||||
|
||||
def search_qs(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(name__icontains=i18ncomp(value))
|
||||
| Q(slug__icontains=value)
|
||||
| Q(location__icontains=i18ncomp(value))
|
||||
)
|
||||
|
||||
|
||||
class EventViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = EventSerializer
|
||||
@@ -136,10 +147,43 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
qs = filter_qs_by_attr(qs, self.request)
|
||||
|
||||
if 'with_availability_for' in self.request.GET:
|
||||
qs = Event.annotated(qs, channel=self.request.GET.get('with_availability_for'))
|
||||
|
||||
return qs.prefetch_related(
|
||||
'meta_values', 'meta_values__property', 'seat_category_mappings'
|
||||
'organizer',
|
||||
'meta_values',
|
||||
'meta_values__property',
|
||||
'item_meta_properties',
|
||||
Prefetch(
|
||||
'seat_category_mappings',
|
||||
to_attr='_seat_category_mappings',
|
||||
queryset=SeatCategoryMapping.objects.filter(subevent=None)
|
||||
),
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
if 'with_availability_for' in self.request.GET:
|
||||
quotas_to_compute = []
|
||||
qcache = {}
|
||||
for se in page:
|
||||
se._quota_cache = qcache
|
||||
quotas_to_compute += se.active_quotas
|
||||
|
||||
if quotas_to_compute:
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(*quotas_to_compute)
|
||||
qa.compute(allow_cache=True)
|
||||
qcache.update(qa.results)
|
||||
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
current_live_value = serializer.instance.live
|
||||
updated_live_value = serializer.validated_data.get('live', None)
|
||||
@@ -197,7 +241,6 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
except Event.DoesNotExist:
|
||||
raise ValidationError('Event to copy from was not found')
|
||||
|
||||
print(copy_from, self.request.GET)
|
||||
new_event = serializer.save(organizer=self.request.organizer)
|
||||
|
||||
if copy_from:
|
||||
@@ -336,8 +379,18 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
|
||||
qs = filter_qs_by_attr(qs, self.request)
|
||||
|
||||
if 'with_availability_for' in self.request.GET:
|
||||
qs = SubEvent.annotated(qs, channel=self.request.GET.get('with_availability_for'))
|
||||
|
||||
return qs.prefetch_related(
|
||||
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings', 'meta_values'
|
||||
'event',
|
||||
'subeventitem_set',
|
||||
'subeventitemvariation_set',
|
||||
'meta_values',
|
||||
Prefetch(
|
||||
'seat_category_mappings',
|
||||
to_attr='_seat_category_mappings',
|
||||
),
|
||||
)
|
||||
|
||||
def list(self, request, **kwargs):
|
||||
@@ -345,14 +398,24 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
resp = self.get_paginated_response(serializer.data)
|
||||
resp['X-Page-Generated'] = date
|
||||
return resp
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data, headers={'X-Page-Generated': date})
|
||||
if 'with_availability_for' in self.request.GET:
|
||||
quotas_to_compute = []
|
||||
qcache = {}
|
||||
for se in page:
|
||||
se._quota_cache = qcache
|
||||
quotas_to_compute += se.active_quotas
|
||||
|
||||
if quotas_to_compute:
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(*quotas_to_compute)
|
||||
qa.compute(allow_cache=True)
|
||||
qcache.update(qa.results)
|
||||
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
resp = self.get_paginated_response(serializer.data)
|
||||
resp['X-Page-Generated'] = date
|
||||
return resp
|
||||
|
||||
def perform_update(self, serializer):
|
||||
original_data = self.get_serializer(instance=serializer.instance).data
|
||||
|
||||
@@ -132,7 +132,7 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
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)):
|
||||
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex)
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
@@ -151,7 +151,7 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
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)):
|
||||
for ex in sorted([response(events, self.request.organizer) 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
|
||||
|
||||
@@ -477,6 +477,23 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
return self.request.event.quotas.all()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
if self.request.GET.get('with_availability') == 'true':
|
||||
if page:
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(*page)
|
||||
qa.compute(allow_cache=False)
|
||||
for q in page:
|
||||
q.available = qa.results[q][0] == Quota.AVAILABILITY_OK
|
||||
q.available_number = qa.results[q][1]
|
||||
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -496,6 +513,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['request'] = self.request
|
||||
return ctx
|
||||
|
||||
def perform_update(self, serializer):
|
||||
|
||||
@@ -55,9 +55,9 @@ from pretix.api.serializers.order import (
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
|
||||
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
|
||||
TaxRule, TeamAPIToken, generate_secret,
|
||||
CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice,
|
||||
InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
|
||||
Quota, SubEvent, TaxRule, TeamAPIToken, generate_secret,
|
||||
)
|
||||
from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
|
||||
from pretix.base.payment import PaymentException
|
||||
@@ -201,7 +201,8 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
Prefetch(
|
||||
'positions',
|
||||
opq.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item__category', 'addon_to', 'seat',
|
||||
Prefetch('addons', opq.select_related('item', 'variation', 'seat'))
|
||||
)
|
||||
@@ -212,7 +213,8 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
Prefetch(
|
||||
'positions',
|
||||
opq.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -690,6 +692,16 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
}
|
||||
)
|
||||
|
||||
if 'custom_followup_at' in self.request.data and serializer.instance.custom_followup_at != self.request.data.get('custom_followup_at'):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.order.custom_followup_at',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'new_custom_followup_at': self.request.data.get('custom_followup_at')
|
||||
}
|
||||
)
|
||||
|
||||
if 'checkin_attention' in self.request.data and serializer.instance.checkin_attention != self.request.data.get('checkin_attention'):
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.order.checkin_attention',
|
||||
@@ -781,7 +793,7 @@ with scopes_disabled():
|
||||
)
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(checkins__isnull=not value)
|
||||
return queryset.alias(ce=Exists(Checkin.objects.filter(position=OuterRef('pk')))).filter(ce=value)
|
||||
|
||||
def attendee_name_qs(self, queryset, name, value):
|
||||
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
|
||||
@@ -835,7 +847,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
|
||||
qs = qs.filter(order__event=self.request.event)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', qs.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
@@ -845,7 +858,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
|
||||
Prefetch(
|
||||
'positions',
|
||||
qs.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
)
|
||||
)
|
||||
))
|
||||
@@ -854,7 +868,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
|
||||
)
|
||||
@@ -1436,8 +1451,14 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
inv = self.get_object()
|
||||
if inv.canceled:
|
||||
raise ValidationError('The invoice has already been canceled.')
|
||||
if not inv.event.settings.invoice_regenerate_allowed:
|
||||
raise PermissionDenied('Invoices may not be changed after they are created.')
|
||||
elif inv.shredded:
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
elif inv.sent_to_organizer:
|
||||
raise PermissionDenied('The invoice file has already been exported.')
|
||||
elif now().astimezone(self.request.event.timezone).date() - inv.date > datetime.timedelta(days=1):
|
||||
raise PermissionDenied('The invoice file is too old to be regenerated.')
|
||||
else:
|
||||
inv = regenerate_invoice(inv)
|
||||
inv.order.log_action(
|
||||
|
||||
@@ -261,7 +261,7 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
),
|
||||
ParametrizedEventWebhookEvent(
|
||||
'pretix.event.deleted',
|
||||
_('Event details changed'),
|
||||
_('Event deleted'),
|
||||
),
|
||||
ParametrizedSubEventWebhookEvent(
|
||||
'pretix.subevent.added',
|
||||
|
||||
@@ -82,6 +82,13 @@ class SalesChannel:
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def customer_accounts_supported(self) -> bool:
|
||||
"""
|
||||
If this property is ``True``, checkout will show the customer login step.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
def get_all_sales_channels():
|
||||
global _ALL_CHANNELS
|
||||
|
||||
@@ -30,7 +30,7 @@ from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.base.templatetags.safelink import safelink as sl
|
||||
|
||||
|
||||
def get_powered_by(safelink=True):
|
||||
def get_powered_by(request, safelink=True):
|
||||
gs = GlobalSettingsObject()
|
||||
d = gs.settings.license_check_input
|
||||
if d.get('poweredby_name'):
|
||||
@@ -57,7 +57,7 @@ def get_powered_by(safelink=True):
|
||||
|
||||
if d.get('base_license') == 'agpl':
|
||||
msg += ' (<a href="{}" target="_blank" rel="noopener">{}</a>)'.format(
|
||||
reverse('source'),
|
||||
request.build_absolute_uri(reverse('source')),
|
||||
gettext('source code')
|
||||
)
|
||||
|
||||
@@ -69,7 +69,7 @@ def contextprocessor(request):
|
||||
'rtl': getattr(request, 'LANGUAGE_CODE', 'en') in settings.LANGUAGES_RTL,
|
||||
}
|
||||
try:
|
||||
ctx['poweredby'] = get_powered_by(safelink=True)
|
||||
ctx['poweredby'] = get_powered_by(request, safelink=True)
|
||||
except Exception:
|
||||
ctx['poweredby'] = 'powered by <a href="https://pretix.eu/" target="_blank" rel="noopener">pretix</a>'
|
||||
if settings.DEBUG and 'runserver' not in sys.argv:
|
||||
|
||||
@@ -26,6 +26,7 @@ from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from smtplib import SMTPResponseException
|
||||
|
||||
import css_inline
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
from django.db.models import Count
|
||||
@@ -35,7 +36,6 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import (
|
||||
get_language, gettext_lazy as _, pgettext_lazy,
|
||||
)
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.i18n import (
|
||||
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
|
||||
@@ -174,7 +174,11 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
htmlctx['ev'] = position.subevent or self.event
|
||||
|
||||
tpl = get_template(self.template_name)
|
||||
body_html = inline_css(tpl.render(htmlctx))
|
||||
body_html = tpl.render(htmlctx)
|
||||
|
||||
inliner = css_inline.CSSInliner(remove_style_tags=True)
|
||||
body_html = inliner.inline(body_html)
|
||||
|
||||
return body_html
|
||||
|
||||
|
||||
@@ -448,6 +452,26 @@ def base_placeholders(sender, **kwargs):
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'subevent', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
|
||||
lambda event: str(event if not event.has_subevents or not event.subevents.exists() else event.subevents.first())
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'subevent_date_from', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(),
|
||||
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_remove', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: build_absolute_uri(
|
||||
event, 'presale:event.waitinglist.remove'
|
||||
) + '?voucher=' + waiting_list_entry.voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.waitinglist.remove',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: build_absolute_uri(
|
||||
|
||||
@@ -47,12 +47,15 @@ from django.db.models import QuerySet
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES
|
||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES, Cell
|
||||
|
||||
from pretix.base.models import Event
|
||||
|
||||
|
||||
def excel_safe(val):
|
||||
if isinstance(val, Cell):
|
||||
return val
|
||||
|
||||
if not isinstance(val, KNOWN_TYPES):
|
||||
val = str(val)
|
||||
|
||||
@@ -70,8 +73,9 @@ class BaseExporter:
|
||||
This is the base class for all data exporters
|
||||
"""
|
||||
|
||||
def __init__(self, event, progress_callback=lambda v: None):
|
||||
def __init__(self, event, organizer, progress_callback=lambda v: None):
|
||||
self.event = event
|
||||
self.organizer = organizer
|
||||
self.progress_callback = progress_callback
|
||||
self.is_multievent = isinstance(event, QuerySet)
|
||||
if isinstance(event, QuerySet):
|
||||
@@ -220,9 +224,13 @@ class ListExporter(BaseExporter):
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
def prepare_xlsx_sheet(self, ws):
|
||||
pass
|
||||
|
||||
def _render_xlsx(self, form_data, output_file=None):
|
||||
wb = Workbook(write_only=True)
|
||||
ws = wb.create_sheet()
|
||||
self.prepare_xlsx_sheet(ws)
|
||||
try:
|
||||
ws.title = str(self.verbose_name)
|
||||
except:
|
||||
|
||||
@@ -129,7 +129,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
label=_('End event date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=_('Only include orders including at least one ticket for a date on or after this date. '
|
||||
help_text=_('Only include orders including at least one ticket for a date on or before this date. '
|
||||
'Will also include other dates in case of mixed orders!')
|
||||
)),
|
||||
]
|
||||
@@ -288,6 +288,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Sales channel'))
|
||||
headers.append(_('Requires special attention'))
|
||||
headers.append(_('Comment'))
|
||||
headers.append(_('Follow-up date'))
|
||||
headers.append(_('Positions'))
|
||||
headers.append(_('E-mail address verified'))
|
||||
headers.append(_('Payment providers'))
|
||||
@@ -393,6 +394,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
row.append(order.sales_channel)
|
||||
row.append(_('Yes') if order.checkin_attention else _('No'))
|
||||
row.append(order.comment or "")
|
||||
row.append(order.custom_followup_at.strftime("%Y-%m-%d") if order.custom_followup_at else "")
|
||||
row.append(order.pcnt)
|
||||
row.append(_('Yes') if order.email_known_to_work else _('No'))
|
||||
row.append(', '.join([
|
||||
@@ -574,6 +576,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Seat row'),
|
||||
_('Seat number'),
|
||||
_('Order comment'),
|
||||
_('Follow-up date'),
|
||||
]
|
||||
|
||||
questions = list(Question.objects.filter(event__in=self.events))
|
||||
@@ -677,6 +680,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
row += ['', '', '', '', '']
|
||||
|
||||
row.append(order.comment)
|
||||
row.append(order.custom_followup_at.strftime("%Y-%m-%d") if order.custom_followup_at else "")
|
||||
acache = {}
|
||||
for a in op.answers.all():
|
||||
# We do not want to localize Date, Time and Datetime question answers, as those can lead
|
||||
@@ -721,7 +725,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
row += [
|
||||
order.sales_channel,
|
||||
order.locale,
|
||||
row.append(_('Yes') if order.email_known_to_work else _('No'))
|
||||
_('Yes') if order.email_known_to_work else _('No')
|
||||
]
|
||||
row.append(', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
|
||||
@@ -780,7 +784,7 @@ class PaymentListExporter(ListExporter):
|
||||
|
||||
headers = [
|
||||
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Status code'), _('Amount'), _('Payment method'), _('Comment')
|
||||
_('Status code'), _('Amount'), _('Payment method'), _('Comment'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import vat_moss.errors
|
||||
import vat_moss.id
|
||||
from babel import Locale
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
@@ -507,7 +508,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault('ext_whitelist', (".png", ".jpg", ".jpeg", ".jfif", ".tif", ".tiff", ".bmp"))
|
||||
kwargs.setdefault('max_size', 10 * 1024 * 1024)
|
||||
kwargs.setdefault('max_size', settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -739,7 +740,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
|
||||
".bmp", ".tif", ".tiff"
|
||||
),
|
||||
max_size=10 * 1024 * 1024,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
attrs = {}
|
||||
@@ -976,7 +977,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
scheme=event.settings.name_scheme,
|
||||
titles=event.settings.name_scheme_titles,
|
||||
label=_('Name'),
|
||||
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
|
||||
initial=self.instance.name_parts,
|
||||
)
|
||||
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
|
||||
if not event.settings.invoice_name_required:
|
||||
|
||||
@@ -184,7 +184,7 @@ class BusinessBooleanRadio(forms.RadioSelect):
|
||||
self.require_business = require_business
|
||||
if self.require_business:
|
||||
choices = (
|
||||
('business', _('Business customer')),
|
||||
('business', _('Business or institutional customer')),
|
||||
)
|
||||
else:
|
||||
choices = (
|
||||
|
||||
@@ -769,44 +769,55 @@ class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
]
|
||||
|
||||
def _draw_metadata(self, canvas):
|
||||
# Draws the "invoice number -- date" line. This has gotten a little more complicated since we
|
||||
# encountered some events with very long invoice numbers. In this case, we automatically reduce
|
||||
# the font size until it fits.
|
||||
begin_top = 100 * mm
|
||||
|
||||
textobject = canvas.beginText(self.left_margin, self.pagesize[1] - begin_top)
|
||||
textobject.setFont(self.font_regular, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Order code'))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.order.full_code)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
if self.invoice.is_cancellation:
|
||||
textobject = canvas.beginText(self.left_margin + 50 * mm, self.pagesize[1] - begin_top)
|
||||
def _draw(label, value, value_size, x, width):
|
||||
if canvas.stringWidth(value, self.font_regular, value_size) > width and value_size > 6:
|
||||
return False
|
||||
textobject = canvas.beginText(x, self.pagesize[1] - begin_top)
|
||||
textobject.setFont(self.font_regular, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation number'))
|
||||
textobject.textLine(label)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
canvas.drawText(textobject)
|
||||
textobject.setFont(self.font_regular, value_size)
|
||||
textobject.textLine(value)
|
||||
return textobject
|
||||
|
||||
textobject = canvas.beginText(self.left_margin + 100 * mm, self.pagesize[1] - begin_top)
|
||||
textobject.setFont(self.font_regular, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice'))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.refers.number)
|
||||
canvas.drawText(textobject)
|
||||
else:
|
||||
textobject = canvas.beginText(self.left_margin + 70 * mm, self.pagesize[1] - begin_top)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice number'))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
canvas.drawText(textobject)
|
||||
value_size = 10
|
||||
while value_size >= 5:
|
||||
objects = [
|
||||
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm)
|
||||
]
|
||||
|
||||
p = Paragraph(
|
||||
date_format(self.invoice.date, "DATE_FORMAT"),
|
||||
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
|
||||
)
|
||||
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
|
||||
p.wrapOn(canvas, w, 15 * mm)
|
||||
date_x = self.pagesize[0] - w - self.right_margin
|
||||
|
||||
if self.invoice.is_cancellation:
|
||||
objects += [
|
||||
_draw(pgettext('invoice', 'Cancellation number'), self.invoice.number,
|
||||
value_size, self.left_margin + 50 * mm, 45 * mm),
|
||||
_draw(pgettext('invoice', 'Original invoice'), self.invoice.refers.number,
|
||||
value_size, self.left_margin + 100 * mm, date_x - self.left_margin - 100 * mm - 5 * mm),
|
||||
]
|
||||
else:
|
||||
objects += [
|
||||
_draw(pgettext('invoice', 'Invoice number'), self.invoice.number,
|
||||
value_size, self.left_margin + 70 * mm, date_x - self.left_margin - 70 * mm - 5 * mm),
|
||||
]
|
||||
|
||||
if all(objects):
|
||||
for o in objects:
|
||||
canvas.drawText(o)
|
||||
break
|
||||
value_size -= 1
|
||||
|
||||
p = Paragraph(date_format(self.invoice.date, "DATE_FORMAT"), style=self.stylesheet['Normal'])
|
||||
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
|
||||
p.wrapOn(canvas, w, 15 * mm)
|
||||
date_x = self.pagesize[0] - w - self.right_margin
|
||||
p.drawOn(canvas, date_x, self.pagesize[1] - begin_top - 10 - 6)
|
||||
|
||||
textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top)
|
||||
|
||||
67
src/pretix/base/management/commands/_migrations.py
Normal file
67
src/pretix/base/management/commands/_migrations.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
"""
|
||||
Django, for theoretically very valid reasons, creates migrations for *every single thing*
|
||||
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
|
||||
database backend unknown to us might actually use this information for its database schema.
|
||||
|
||||
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
|
||||
certain that some changes to models will never require a change to the database. In this case,
|
||||
not creating a migration for certain changes will save us some performance while applying them
|
||||
*and* allow for a cleaner git history. Win-win!
|
||||
|
||||
Only caveat is that we need to do some dirty monkeypatching to achieve it...
|
||||
"""
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import models as modelops
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
|
||||
def monkeypatch_migrations():
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
|
||||
IGNORED_ATTRS = [
|
||||
# (field type, attribute name, banlist of field sub-types)
|
||||
(models.Field, 'verbose_name', []),
|
||||
(models.Field, 'help_text', []),
|
||||
(models.Field, 'validators', []),
|
||||
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
|
||||
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
|
||||
models.TimeField]),
|
||||
(models.CharField, 'choices', [CountryField])
|
||||
]
|
||||
|
||||
original_deconstruct = models.Field.deconstruct
|
||||
|
||||
def new_deconstruct(self):
|
||||
name, path, args, kwargs = original_deconstruct(self)
|
||||
for ftype, attr, banlist in IGNORED_ATTRS:
|
||||
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in banlist):
|
||||
kwargs.pop(attr, None)
|
||||
return name, path, args, kwargs
|
||||
|
||||
models.Field.deconstruct = new_deconstruct
|
||||
@@ -103,7 +103,7 @@ class Command(BaseCommand):
|
||||
|
||||
with language(locale), override(timezone):
|
||||
for receiver, response in signal_result:
|
||||
ex = response(e, report_status)
|
||||
ex = response(e, o, report_status)
|
||||
if ex.identifier == options['export_provider']:
|
||||
params = json.loads(options.get('parameters') or '{}')
|
||||
with open(options['output_file'], 'wb') as f:
|
||||
|
||||
@@ -32,53 +32,11 @@
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
"""
|
||||
Django, for theoretically very valid reasons, creates migrations for *every single thing*
|
||||
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
|
||||
database backend unknown to us might actually use this information for its database schema.
|
||||
|
||||
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
|
||||
certain that some changes to models will never require a change to the database. In this case,
|
||||
not creating a migration for certain changes will save us some performance while applying them
|
||||
*and* allow for a cleaner git history. Win-win!
|
||||
|
||||
Only caveat is that we need to do some dirty monkeypatching to achieve it...
|
||||
"""
|
||||
from django.core.management.commands.makemigrations import Command as Parent
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import models as modelops
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
|
||||
IGNORED_ATTRS = [
|
||||
# (field type, attribute name, banlist of field sub-types)
|
||||
(models.Field, 'verbose_name', []),
|
||||
(models.Field, 'help_text', []),
|
||||
(models.Field, 'validators', []),
|
||||
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
|
||||
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
|
||||
models.TimeField]),
|
||||
(models.CharField, 'choices', [CountryField])
|
||||
]
|
||||
from ._migrations import monkeypatch_migrations
|
||||
|
||||
original_deconstruct = models.Field.deconstruct
|
||||
|
||||
|
||||
def new_deconstruct(self):
|
||||
name, path, args, kwargs = original_deconstruct(self)
|
||||
for ftype, attr, banlist in IGNORED_ATTRS:
|
||||
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in banlist):
|
||||
kwargs.pop(attr, None)
|
||||
return name, path, args, kwargs
|
||||
|
||||
|
||||
models.Field.deconstruct = new_deconstruct
|
||||
monkeypatch_migrations()
|
||||
|
||||
|
||||
class Command(Parent):
|
||||
|
||||
@@ -32,12 +32,6 @@
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
"""
|
||||
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
|
||||
run when there are things we have no migrations for. Usually, this is intended, and running
|
||||
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
|
||||
users from doing that by going really dirty and filtering it from the output.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from django.core.management.base import OutputWrapper
|
||||
@@ -45,9 +39,15 @@ from django.core.management.commands.migrate import Command as Parent
|
||||
|
||||
|
||||
class OutputFilter(OutputWrapper):
|
||||
"""
|
||||
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
|
||||
run when there are things we have no migrations for. Usually, this is intended, and running
|
||||
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
|
||||
users from doing that by going really dirty and filtering it from the output.
|
||||
"""
|
||||
banlist = (
|
||||
"Your models have changes that are not yet reflected",
|
||||
"Run 'manage.py makemigrations' to make new "
|
||||
"have changes that are not yet reflected",
|
||||
"re-run 'manage.py migrate'"
|
||||
)
|
||||
|
||||
def write(self, msg, style_func=None, ending=None):
|
||||
|
||||
@@ -214,7 +214,7 @@ def _render_csp(h):
|
||||
def _merge_csp(a, b):
|
||||
for k, v in a.items():
|
||||
if k in b:
|
||||
a[k] += b[k]
|
||||
a[k] += [i for i in b[k] if i not in a[k]]
|
||||
|
||||
for k, v in b.items():
|
||||
if k not in a:
|
||||
|
||||
18
src/pretix/base/migrations/0191_event_last_modified.py
Normal file
18
src/pretix/base/migrations/0191_event_last_modified.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.2 on 2021-05-24 12:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0190_quota_ignore_for_event_availability'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='last_modified',
|
||||
field=models.DateTimeField(auto_now=True, db_index=True),
|
||||
),
|
||||
]
|
||||
60
src/pretix/base/migrations/0192_checkin_more_fields.py
Normal file
60
src/pretix/base/migrations/0192_checkin_more_fields.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 3.2.2 on 2021-05-11 16:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0191_event_last_modified'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='error_explanation',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='error_reason',
|
||||
field=models.CharField(max_length=100, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='raw_barcode',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='raw_item',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checkins', to='pretixbase.item'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='raw_subevent',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checkins', to='pretixbase.subevent'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='raw_variation',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checkins', to='pretixbase.itemvariation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
|
||||
name='successful',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='checkin',
|
||||
name='position',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='all_checkins', to='pretixbase.orderposition'),
|
||||
),
|
||||
]
|
||||
25
src/pretix/base/migrations/0193_auto_20210611_1355.py
Normal file
25
src/pretix/base/migrations/0193_auto_20210611_1355.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 3.2.3 on 2021-06-11 13:55
|
||||
|
||||
import django.db.models.manager
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0192_checkin_more_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='checkin',
|
||||
managers=[
|
||||
('all', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='custom_followup_at',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0194_membership_canceled.py
Normal file
18
src/pretix/base/migrations/0194_membership_canceled.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.3 on 2021-06-17 10:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0193_auto_20210611_1355'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='membership',
|
||||
name='canceled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
23
src/pretix/base/migrations/0195_auto_20210622_1457.py
Normal file
23
src/pretix/base/migrations/0195_auto_20210622_1457.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.3 on 2021-06-22 14:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0194_membership_canceled'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceline',
|
||||
name='fee_internal_type',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceline',
|
||||
name='fee_type',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
]
|
||||
38
src/pretix/base/migrations/0196_auto_20210523_1322.py
Normal file
38
src/pretix/base/migrations/0196_auto_20210523_1322.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 3.2.2 on 2021-05-23 13:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0195_auto_20210622_1457'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='customer',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invoice_addresses', to='pretixbase.customer'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AttendeeProfile',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('attendee_name_cached', models.CharField(max_length=255, null=True)),
|
||||
('attendee_name_parts', models.JSONField(default=dict)),
|
||||
('attendee_email', models.EmailField(max_length=254, null=True)),
|
||||
('company', models.CharField(max_length=255, null=True)),
|
||||
('street', models.TextField(null=True)),
|
||||
('zipcode', models.CharField(max_length=30, null=True)),
|
||||
('city', models.CharField(max_length=255, null=True)),
|
||||
('country', pretix.helpers.countries.FastCountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2, null=True)),
|
||||
('state', models.CharField(max_length=255, null=True)),
|
||||
('answers', models.JSONField(default=list)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attendee_profiles', to='pretixbase.customer')),
|
||||
],
|
||||
),
|
||||
]
|
||||
36
src/pretix/base/migrations/0197_auto_20210914_0814.py
Normal file
36
src/pretix/base/migrations/0197_auto_20210914_0814.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 3.2.4 on 2021-09-14 08:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
import pretix.base.models.items
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0196_auto_20210523_1322'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='available_from',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='available_until',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='hide_without_voucher',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='sales_channels',
|
||||
field=pretix.base.models.fields.MultiStringField(default=pretix.base.models.items._all_sales_channels_identifiers),
|
||||
),
|
||||
]
|
||||
@@ -31,6 +31,7 @@
|
||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -231,9 +232,14 @@ class CheckinList(LoggedModel):
|
||||
return rules
|
||||
|
||||
|
||||
class SuccessfulCheckinManager(ScopedManager(organizer='list__event__organizer').__class__):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(successful=True)
|
||||
|
||||
|
||||
class Checkin(models.Model):
|
||||
"""
|
||||
A check-in object is created when a person enters or exits the event.
|
||||
A check-in object is created when a ticket is scanned with our scanning apps.
|
||||
"""
|
||||
TYPE_ENTRY = 'entry'
|
||||
TYPE_EXIT = 'exit'
|
||||
@@ -241,13 +247,83 @@ class Checkin(models.Model):
|
||||
(TYPE_ENTRY, _('Entry')),
|
||||
(TYPE_EXIT, _('Exit')),
|
||||
)
|
||||
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins', on_delete=models.CASCADE)
|
||||
|
||||
REASON_CANCELED = 'canceled'
|
||||
REASON_INVALID = 'invalid'
|
||||
REASON_UNPAID = 'unpaid'
|
||||
REASON_PRODUCT = 'product'
|
||||
REASON_RULES = 'rules'
|
||||
REASON_REVOKED = 'revoked'
|
||||
REASON_INCOMPLETE = 'incomplete'
|
||||
REASON_ALREADY_REDEEMED = 'already_redeemed'
|
||||
REASON_ERROR = 'error'
|
||||
REASONS = (
|
||||
(REASON_CANCELED, _('Order canceled')),
|
||||
(REASON_INVALID, _('Unknown ticket')),
|
||||
(REASON_UNPAID, _('Ticket not paid')),
|
||||
(REASON_RULES, _('Forbidden by custom rule')),
|
||||
(REASON_REVOKED, _('Ticket code revoked/changed')),
|
||||
(REASON_INCOMPLETE, _('Information required')),
|
||||
(REASON_ALREADY_REDEEMED, _('Ticket already used')),
|
||||
(REASON_PRODUCT, _('Ticket type not allowed here')),
|
||||
(REASON_ERROR, _('Server error')),
|
||||
)
|
||||
|
||||
successful = models.BooleanField(
|
||||
default=True,
|
||||
)
|
||||
error_reason = models.CharField(
|
||||
max_length=100,
|
||||
choices=REASONS,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
error_explanation = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
position = models.ForeignKey(
|
||||
'pretixbase.OrderPosition',
|
||||
related_name='all_checkins',
|
||||
on_delete=models.CASCADE,
|
||||
null=True, blank=True,
|
||||
)
|
||||
|
||||
# For "raw" scans where we do not know which position they belong to (e.g. scan of signed
|
||||
# barcode that is not in database).
|
||||
raw_barcode = models.TextField(null=True, blank=True)
|
||||
raw_item = models.ForeignKey(
|
||||
'pretixbase.Item',
|
||||
related_name='checkins',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
)
|
||||
raw_variation = models.ForeignKey(
|
||||
'pretixbase.ItemVariation',
|
||||
related_name='checkins',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
)
|
||||
raw_subevent = models.ForeignKey(
|
||||
'pretixbase.SubEvent',
|
||||
related_name='checkins',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
)
|
||||
|
||||
# Datetime of checkin, might be different from created if past scans are uploaded
|
||||
datetime = models.DateTimeField(default=now)
|
||||
nonce = models.CharField(max_length=190, null=True, blank=True)
|
||||
|
||||
# Datetime of creation on server
|
||||
created = models.DateTimeField(auto_now_add=True, null=True, blank=True)
|
||||
|
||||
list = models.ForeignKey(
|
||||
'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT,
|
||||
)
|
||||
type = models.CharField(max_length=100, choices=CHECKIN_TYPES, default=TYPE_ENTRY)
|
||||
|
||||
nonce = models.CharField(max_length=190, null=True, blank=True)
|
||||
forced = models.BooleanField(default=False)
|
||||
device = models.ForeignKey(
|
||||
'pretixbase.Device', related_name='checkins', on_delete=models.PROTECT, null=True, blank=True
|
||||
@@ -257,7 +333,8 @@ class Checkin(models.Model):
|
||||
)
|
||||
auto_checked_in = models.BooleanField(default=False)
|
||||
|
||||
objects = ScopedManager(organizer='position__order__event__organizer')
|
||||
all = ScopedManager(organizer='list__event__organizer')
|
||||
objects = SuccessfulCheckinManager()
|
||||
|
||||
class Meta:
|
||||
ordering = (('-datetime'),)
|
||||
@@ -269,7 +346,8 @@ class Checkin(models.Model):
|
||||
|
||||
def save(self, **kwargs):
|
||||
super().save(**kwargs)
|
||||
self.position.order.touch()
|
||||
if self.position:
|
||||
self.position.order.touch()
|
||||
self.list.event.cache.delete('checkin_count')
|
||||
self.list.touch()
|
||||
|
||||
@@ -277,3 +355,7 @@ class Checkin(models.Model):
|
||||
super().delete(**kwargs)
|
||||
self.position.order.touch()
|
||||
self.list.touch()
|
||||
|
||||
@property
|
||||
def is_late_upload(self):
|
||||
return self.created and abs(self.created - self.datetime) > timedelta(minutes=2)
|
||||
|
||||
@@ -19,19 +19,21 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import pycountry
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import (
|
||||
check_password, is_password_usable, make_password,
|
||||
)
|
||||
from django.db import models
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.organizer import Organizer
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
|
||||
|
||||
class Customer(LoggedModel):
|
||||
@@ -88,6 +90,8 @@ class Customer(LoggedModel):
|
||||
self.all_logentries().update(data={}, shredded=True)
|
||||
self.orders.all().update(customer=None)
|
||||
self.memberships.all().update(attendee_name_parts=None)
|
||||
self.attendee_profiles.all().delete()
|
||||
self.invoice_addresses.all().delete()
|
||||
|
||||
@scopes_disabled()
|
||||
def assign_identifier(self):
|
||||
@@ -174,3 +178,88 @@ class Customer(LoggedModel):
|
||||
continue
|
||||
ctx['name_%s' % f] = self.name_parts.get(f, '')
|
||||
return ctx
|
||||
|
||||
@property
|
||||
def stored_addresses(self):
|
||||
return self.invoice_addresses(manager='profiles')
|
||||
|
||||
|
||||
class AttendeeProfile(models.Model):
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
related_name='attendee_profiles',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
attendee_name_cached = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Attendee name"),
|
||||
blank=True, null=True,
|
||||
)
|
||||
attendee_name_parts = models.JSONField(
|
||||
blank=True, default=dict
|
||||
)
|
||||
attendee_email = models.EmailField(
|
||||
verbose_name=_("Attendee email"),
|
||||
blank=True, null=True,
|
||||
)
|
||||
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
|
||||
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True, null=True)
|
||||
country = FastCountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
|
||||
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True, null=True)
|
||||
answers = models.JSONField(default=list)
|
||||
|
||||
objects = ScopedManager(organizer='customer__organizer')
|
||||
|
||||
@property
|
||||
def attendee_name(self):
|
||||
if not self.attendee_name_parts:
|
||||
return None
|
||||
if '_legacy' in self.attendee_name_parts:
|
||||
return self.attendee_name_parts['_legacy']
|
||||
if '_scheme' in self.attendee_name_parts:
|
||||
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
|
||||
else:
|
||||
scheme = PERSON_NAME_SCHEMES[self.customer.organizer.settings.name_scheme]
|
||||
return scheme['concatenation'](self.attendee_name_parts).strip()
|
||||
|
||||
@property
|
||||
def state_name(self):
|
||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||
if sd:
|
||||
return sd.name
|
||||
return self.state
|
||||
|
||||
@property
|
||||
def state_for_address(self):
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
if not self.state or str(self.country) not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
return ""
|
||||
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.country)][1] == 'long':
|
||||
return self.state_name
|
||||
return self.state
|
||||
|
||||
def describe(self):
|
||||
from .items import Question
|
||||
from .orders import QuestionAnswer
|
||||
|
||||
parts = [
|
||||
self.attendee_name,
|
||||
self.attendee_email,
|
||||
self.company,
|
||||
self.street,
|
||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||
self.country.name,
|
||||
]
|
||||
for a in self.answers:
|
||||
value = a.get('value')
|
||||
try:
|
||||
value = ", ".join(value.values())
|
||||
except AttributeError:
|
||||
value = str(value)
|
||||
answer = QuestionAnswer(question=Question(type=a.get('question_type')), answer=value)
|
||||
val = str(answer)
|
||||
parts.append(f'{a["field_label"]}: {val}')
|
||||
|
||||
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import logging
|
||||
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
||||
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
#
|
||||
@@ -73,9 +74,10 @@ from pretix.helpers.thumb import get_thumbnail
|
||||
from ..settings import settings_hierarkey
|
||||
from .organizer import Organizer, Team
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventMixin:
|
||||
|
||||
def clean(self):
|
||||
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
|
||||
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
|
||||
@@ -154,6 +156,29 @@ class EventMixin:
|
||||
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
|
||||
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
|
||||
|
||||
def get_time_range_display(self, tz=None, force_show_end=False) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start time and sometimes the end time
|
||||
of the event with respect to the current locale and to the ``show_date_to``
|
||||
setting. Dates are not shown. This is usually used in combination with get_date_range_display
|
||||
"""
|
||||
tz = tz or self.timezone
|
||||
|
||||
show_date_to = self.date_to and (self.settings.show_date_to or force_show_end) and (
|
||||
# Show date to if start and end are on the same day ("08:00-10:00")
|
||||
self.date_to.astimezone(tz).date() == self.date_from.astimezone(tz).date() or
|
||||
# Show date to if start and end are on consecutive days and less than 24h ("23:00-03:00")
|
||||
(self.date_to.astimezone(tz).date() == self.date_from.astimezone(tz).date() + timedelta(days=1) and
|
||||
self.date_to.astimezone(tz).time() < self.date_from.astimezone(tz).time())
|
||||
# Do not show end time if this is a 5-day event because there's no way to make it understandable
|
||||
)
|
||||
if show_date_to:
|
||||
return '{} – {}'.format(
|
||||
_date(self.date_from.astimezone(tz), "TIME_FORMAT"),
|
||||
_date(self.date_to.astimezone(tz), "TIME_FORMAT"),
|
||||
)
|
||||
return _date(self.date_from.astimezone(tz), "TIME_FORMAT")
|
||||
|
||||
@property
|
||||
def timezone(self):
|
||||
return pytz.timezone(self.settings.timezone)
|
||||
@@ -243,6 +268,10 @@ class EventMixin:
|
||||
).values('items')
|
||||
sq_active_variation = ItemVariation.objects.filter(
|
||||
Q(active=True)
|
||||
& Q(sales_channels__contains=channel)
|
||||
& Q(hide_without_voucher=False)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(item__active=True)
|
||||
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
||||
@@ -493,12 +522,18 @@ class Event(EventMixin, LoggedModel):
|
||||
default=False
|
||||
)
|
||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||
related_name='events')
|
||||
related_name='events', verbose_name=_('Seating plan'))
|
||||
|
||||
last_modified = models.DateTimeField(
|
||||
auto_now=True, db_index=True
|
||||
)
|
||||
|
||||
sales_channels = MultiStringField(
|
||||
verbose_name=_('Restrict to specific sales channels'),
|
||||
help_text=_('Only sell tickets for this event on the following sales channels.'),
|
||||
default=default_sales_channels,
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='organizer')
|
||||
|
||||
class Meta:
|
||||
@@ -529,9 +564,17 @@ class Event(EventMixin, LoggedModel):
|
||||
logo_file = self.settings.get('logo_image', as_type=str, default='')[7:]
|
||||
og_file = self.settings.get('og_image', as_type=str, default='')[7:]
|
||||
if og_file:
|
||||
img = get_thumbnail(og_file, '1200').thumb.url
|
||||
try:
|
||||
img = get_thumbnail(og_file, '1200').thumb.url
|
||||
except:
|
||||
logger.exception(f'Failed to create thumbnail of {og_file}')
|
||||
img = default_storage.url(og_file)
|
||||
elif logo_file:
|
||||
img = get_thumbnail(logo_file, '5000x120').thumb.url
|
||||
try:
|
||||
img = get_thumbnail(logo_file, '5000x1200').thumb.url
|
||||
except:
|
||||
logger.exception(f'Failed to create thumbnail of {logo_file}')
|
||||
img = default_storage.url(logo_file)
|
||||
if img:
|
||||
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
||||
|
||||
@@ -736,7 +779,9 @@ class Event(EventMixin, LoggedModel):
|
||||
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
|
||||
ia.save()
|
||||
|
||||
quota_map = {}
|
||||
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
|
||||
quota_map[q.pk] = q
|
||||
items = list(q.items.all())
|
||||
vars = list(q.variations.all())
|
||||
oldid = q.pk
|
||||
@@ -860,7 +905,7 @@ class Event(EventMixin, LoggedModel):
|
||||
event_copy_data.send(
|
||||
sender=self, other=other,
|
||||
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
|
||||
question_map=question_map, checkin_list_map=checkin_list_map
|
||||
question_map=question_map, checkin_list_map=checkin_list_map, quota_map=quota_map,
|
||||
)
|
||||
|
||||
if has_custom_style:
|
||||
@@ -999,6 +1044,11 @@ class Event(EventMixin, LoggedModel):
|
||||
| Q(date_to__gte=now() - timedelta(hours=24))
|
||||
)
|
||||
) # order_by doesn't make sense with I18nField
|
||||
if ordering in ("date_ascending", "date_descending"):
|
||||
# if primary order is by date, then order in database
|
||||
# this allows to limit/slice results
|
||||
return subevs.order_by(*orderfields)
|
||||
|
||||
for f in reversed(orderfields):
|
||||
if f.startswith('-'):
|
||||
subevs = sorted(subevs, key=attrgetter(f[1:]), reverse=True)
|
||||
@@ -1108,15 +1158,18 @@ class Event(EventMixin, LoggedModel):
|
||||
self.items.all().delete()
|
||||
self.subevents.all().delete()
|
||||
|
||||
def set_active_plugins(self, modules, allow_restricted=False):
|
||||
def get_available_plugins(self):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
plugins_active = self.get_plugins()
|
||||
plugins_available = {
|
||||
return {
|
||||
p.module: p for p in get_all_plugins(self)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
|
||||
def set_active_plugins(self, modules, allow_restricted=False):
|
||||
plugins_active = self.get_plugins()
|
||||
plugins_available = self.get_available_plugins()
|
||||
|
||||
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
|
||||
|
||||
for module in enable:
|
||||
@@ -1145,6 +1198,10 @@ class Event(EventMixin, LoggedModel):
|
||||
plugins_active.remove(module)
|
||||
self.set_active_plugins(plugins_active)
|
||||
|
||||
plugins_available = self.get_available_plugins()
|
||||
if hasattr(plugins_available[module].app, 'uninstalled'):
|
||||
getattr(plugins_available[module].app, 'uninstalled')(self)
|
||||
|
||||
regenerate_css.apply_async(args=(self.pk,))
|
||||
|
||||
@staticmethod
|
||||
@@ -1254,14 +1311,15 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
verbose_name=_("Frontpage text")
|
||||
)
|
||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||
related_name='subevents')
|
||||
last_modified = models.DateTimeField(
|
||||
auto_now=True, db_index=True
|
||||
)
|
||||
related_name='subevents', verbose_name=_('Seating plan'))
|
||||
|
||||
items = models.ManyToManyField('Item', through='SubEventItem')
|
||||
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
|
||||
|
||||
last_modified = models.DateTimeField(
|
||||
auto_now=True, db_index=True
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -343,6 +343,8 @@ class InvoiceLine(models.Model):
|
||||
item = models.ForeignKey('Item', null=True, blank=True, on_delete=models.PROTECT)
|
||||
variation = models.ForeignKey('ItemVariation', null=True, blank=True, on_delete=models.PROTECT)
|
||||
attendee_name = models.TextField(null=True, blank=True)
|
||||
fee_type = models.CharField(max_length=190, null=True, blank=True)
|
||||
fee_internal_type = models.CharField(max_length=190, null=True, blank=True)
|
||||
|
||||
@property
|
||||
def net_value(self):
|
||||
|
||||
@@ -736,6 +736,11 @@ class Item(LoggedModel):
|
||||
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
|
||||
|
||||
|
||||
def _all_sales_channels_identifiers():
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
return list(get_all_sales_channels().keys())
|
||||
|
||||
|
||||
class ItemVariation(models.Model):
|
||||
"""
|
||||
A variation of a product. For example, if your item is 'T-Shirt'
|
||||
@@ -761,7 +766,7 @@ class ItemVariation(models.Model):
|
||||
)
|
||||
value = I18nCharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Description')
|
||||
verbose_name=_('Variation')
|
||||
)
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
@@ -797,6 +802,29 @@ class ItemVariation(models.Model):
|
||||
verbose_name=_('Membership types'),
|
||||
blank=True,
|
||||
)
|
||||
available_from = models.DateTimeField(
|
||||
verbose_name=_("Available from"),
|
||||
null=True, blank=True,
|
||||
help_text=_('This variation will not be sold before the given date.')
|
||||
)
|
||||
available_until = models.DateTimeField(
|
||||
verbose_name=_("Available until"),
|
||||
null=True, blank=True,
|
||||
help_text=_('This variation will not be sold after the given date.')
|
||||
)
|
||||
sales_channels = fields.MultiStringField(
|
||||
verbose_name=_('Sales channels'),
|
||||
default=_all_sales_channels_identifiers,
|
||||
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
|
||||
'selected here but not on product level, the variation will not be available.'),
|
||||
blank=True,
|
||||
)
|
||||
hide_without_voucher = models.BooleanField(
|
||||
verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'),
|
||||
default=False,
|
||||
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
|
||||
'that unlocks this variation.')
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='item__event__organizer')
|
||||
|
||||
@@ -928,6 +956,24 @@ class ItemVariation(models.Model):
|
||||
def is_only_variation(self):
|
||||
return ItemVariation.objects.filter(item=self.item).count() == 1
|
||||
|
||||
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
||||
now_dt = now_dt or now()
|
||||
if self.available_from and self.available_from > now_dt:
|
||||
return False
|
||||
if self.available_until and self.available_until < now_dt:
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_available(self, now_dt: datetime=None) -> bool:
|
||||
"""
|
||||
Returns whether this item is available according to its ``active`` flag
|
||||
and its ``available_from`` and ``available_until`` fields
|
||||
"""
|
||||
now_dt = now_dt or now()
|
||||
if not self.active or not self.is_available_by_time(now_dt):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ItemAddOn(models.Model):
|
||||
"""
|
||||
@@ -1051,7 +1097,7 @@ class ItemBundle(models.Model):
|
||||
)
|
||||
count = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name=_('Number')
|
||||
verbose_name=_('Quantity')
|
||||
)
|
||||
designated_price = models.DecimalField(
|
||||
default=Decimal('0.00'), blank=True,
|
||||
@@ -1620,6 +1666,8 @@ class Quota(LoggedModel):
|
||||
|
||||
@staticmethod
|
||||
def clean_items(event, items, variations):
|
||||
if not items:
|
||||
return
|
||||
for item in items:
|
||||
if event != item.event:
|
||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||
|
||||
@@ -118,6 +118,10 @@ class Membership(models.Model):
|
||||
verbose_name=_('Test mode'),
|
||||
default=False
|
||||
)
|
||||
canceled = models.BooleanField(
|
||||
verbose_name=_('Canceled'),
|
||||
default=False
|
||||
)
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
related_name='memberships',
|
||||
|
||||
@@ -60,7 +60,7 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries.fields import Country
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
@@ -217,6 +217,11 @@ class Order(LockModel, LoggedModel):
|
||||
help_text=_("The text entered in this field will not be visible to the user and is available for your "
|
||||
"convenience.")
|
||||
)
|
||||
custom_followup_at = models.DateField(
|
||||
verbose_name=_("Follow-up date"),
|
||||
help_text=_('We\'ll show you this order to be due for a follow-up on this day.'),
|
||||
null=True, blank=True
|
||||
)
|
||||
checkin_attention = models.BooleanField(
|
||||
verbose_name=_('Requires special attention'),
|
||||
default=False,
|
||||
@@ -300,6 +305,10 @@ class Order(LockModel, LoggedModel):
|
||||
"""
|
||||
return self.all_fees(manager='objects')
|
||||
|
||||
@property
|
||||
def custom_followup_due(self):
|
||||
return self.custom_followup_at and self.custom_followup_at <= now().astimezone(get_current_timezone()).date()
|
||||
|
||||
@cached_property
|
||||
@scopes_disabled()
|
||||
def count_positions(self):
|
||||
@@ -410,6 +419,8 @@ class Order(LockModel, LoggedModel):
|
||||
is_underpaid=Case(
|
||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
|
||||
then=Value(1)),
|
||||
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__gt=1e-8),
|
||||
then=Value(1)),
|
||||
default=Value(0),
|
||||
output_field=models.IntegerField()
|
||||
)
|
||||
@@ -532,7 +543,7 @@ class Order(LockModel, LoggedModel):
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
return round_decimal(fee, self.event.currency)
|
||||
return round_decimal(min(fee, self.total), self.event.currency)
|
||||
|
||||
@property
|
||||
@scopes_disabled()
|
||||
@@ -590,6 +601,8 @@ class Order(LockModel, LoggedModel):
|
||||
for gc in op.issued_gift_cards.all():
|
||||
if gc.value != op.price:
|
||||
return False
|
||||
if op.granted_memberships.with_usages().filter(usages__gt=0):
|
||||
return False
|
||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||
return False
|
||||
if self.status == Order.STATUS_PENDING:
|
||||
@@ -772,6 +785,7 @@ class Order(LockModel, LoggedModel):
|
||||
def ticket_download_available(self):
|
||||
return self.event.settings.ticket_download and (
|
||||
self.event.settings.ticket_download_date is None
|
||||
or self.ticket_download_date is None
|
||||
or now() > self.ticket_download_date
|
||||
) and (
|
||||
self.status == Order.STATUS_PAID
|
||||
@@ -899,7 +913,7 @@ class Order(LockModel, LoggedModel):
|
||||
return str(e)
|
||||
return True
|
||||
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
|
||||
@@ -942,7 +956,7 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
subject = subject.format_map(TolerantDict(context))
|
||||
subject = str(subject).format_map(TolerantDict(context))
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers=headers, sender=sender,
|
||||
@@ -1689,7 +1703,7 @@ class OrderPayment(models.Model):
|
||||
})
|
||||
|
||||
if self.order.pending_sum + r.amount == Decimal('0.00'):
|
||||
self.refund.done()
|
||||
r.done()
|
||||
|
||||
return r
|
||||
|
||||
@@ -1894,6 +1908,7 @@ class OrderFee(models.Model):
|
||||
FEE_TYPE_SHIPPING = "shipping"
|
||||
FEE_TYPE_SERVICE = "service"
|
||||
FEE_TYPE_CANCELLATION = "cancellation"
|
||||
FEE_TYPE_INSURANCE = "insurance"
|
||||
FEE_TYPE_OTHER = "other"
|
||||
FEE_TYPE_GIFTCARD = "giftcard"
|
||||
FEE_TYPES = (
|
||||
@@ -1901,6 +1916,7 @@ class OrderFee(models.Model):
|
||||
(FEE_TYPE_SHIPPING, _("Shipping fee")),
|
||||
(FEE_TYPE_SERVICE, _("Service fee")),
|
||||
(FEE_TYPE_CANCELLATION, _("Cancellation fee")),
|
||||
(FEE_TYPE_INSURANCE, _("Insurance fee")),
|
||||
(FEE_TYPE_OTHER, _("Other fees")),
|
||||
(FEE_TYPE_GIFTCARD, _("Gift card")),
|
||||
)
|
||||
@@ -2054,6 +2070,14 @@ class OrderPosition(AbstractPosition):
|
||||
def sort_key(self):
|
||||
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
|
||||
|
||||
@property
|
||||
def checkins(self):
|
||||
"""
|
||||
Related manager for all successful checkins. Use ``all_checkins`` instead if you want
|
||||
canceled positions as well.
|
||||
"""
|
||||
return self.all_checkins(manager='objects')
|
||||
|
||||
@property
|
||||
def generate_ticket(self):
|
||||
if self.item.generate_tickets is not None:
|
||||
@@ -2168,7 +2192,7 @@ class OrderPosition(AbstractPosition):
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None, attach_tickets=False):
|
||||
auth=None, attach_tickets=False, attach_ical=False):
|
||||
"""
|
||||
Sends an email to the attendee. Basically, this method does two things:
|
||||
|
||||
@@ -2185,6 +2209,7 @@ class OrderPosition(AbstractPosition):
|
||||
:param headers: Dictionary with additional mail headers
|
||||
:param sender: Custom email sender.
|
||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||
:param attach_ical: Attach relevant ICS files
|
||||
"""
|
||||
from pretix.base.services.mail import (
|
||||
SendMailException, mail, render_mail,
|
||||
@@ -2204,7 +2229,9 @@ class OrderPosition(AbstractPosition):
|
||||
recipient, subject, template, context,
|
||||
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
||||
position=self,
|
||||
invoices=invoices, attach_tickets=attach_tickets
|
||||
invoices=invoices,
|
||||
attach_tickets=attach_tickets,
|
||||
attach_ical=attach_ical,
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
@@ -2219,6 +2246,7 @@ class OrderPosition(AbstractPosition):
|
||||
'recipient': recipient,
|
||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||
'attach_tickets': attach_tickets,
|
||||
'attach_ical': attach_ical,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2306,6 +2334,12 @@ class CartPosition(AbstractPosition):
|
||||
class InvoiceAddress(models.Model):
|
||||
last_modified = models.DateTimeField(auto_now=True)
|
||||
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
related_name='invoice_addresses',
|
||||
null=True, blank=True,
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
|
||||
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
|
||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
@@ -2332,6 +2366,7 @@ class InvoiceAddress(models.Model):
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='order__event__organizer')
|
||||
profiles = ScopedManager(organizer='customer__organizer')
|
||||
|
||||
def save(self, **kwargs):
|
||||
if self.order:
|
||||
@@ -2344,6 +2379,20 @@ class InvoiceAddress(models.Model):
|
||||
self.name_parts = {}
|
||||
super().save(**kwargs)
|
||||
|
||||
def describe(self):
|
||||
parts = [
|
||||
self.company,
|
||||
self.name,
|
||||
self.street,
|
||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||
self.country.name,
|
||||
self.vat_id,
|
||||
self.custom_field,
|
||||
self.internal_reference,
|
||||
(_('Beneficiary') + ': ' + self.beneficiary) if self.beneficiary else '',
|
||||
]
|
||||
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
|
||||
|
||||
@property
|
||||
def is_empty(self):
|
||||
return (
|
||||
@@ -2379,6 +2428,30 @@ class InvoiceAddress(models.Model):
|
||||
raise TypeError("Invalid name given.")
|
||||
return scheme['concatenation'](self.name_parts).strip()
|
||||
|
||||
def for_js(self):
|
||||
d = {}
|
||||
|
||||
if self.name_parts:
|
||||
if '_scheme' in self.name_parts:
|
||||
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
|
||||
for i, (k, l, w) in enumerate(scheme['fields']):
|
||||
d[f'name_parts_{i}'] = self.name_parts.get(k) or ''
|
||||
|
||||
d.update({
|
||||
'company': self.company,
|
||||
'is_business': self.is_business,
|
||||
'street': self.street,
|
||||
'zipcode': self.zipcode,
|
||||
'city': self.city,
|
||||
'country': str(self.country) if self.country else None,
|
||||
'state': str(self.state) if self.state else None,
|
||||
'vat_id': self.vat_id,
|
||||
'custom_field': self.custom_field,
|
||||
'internal_reference': self.internal_reference,
|
||||
'beneficiary': self.beneficiary,
|
||||
})
|
||||
return d
|
||||
|
||||
|
||||
def cachedticket_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
||||
|
||||
@@ -38,7 +38,7 @@ from decimal import ROUND_HALF_UP, Decimal
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db import connection, models
|
||||
from django.db.models import F, OuterRef, Q, Subquery, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.crypto import get_random_string
|
||||
@@ -74,6 +74,55 @@ def generate_code(prefix=None):
|
||||
return code
|
||||
|
||||
|
||||
def generate_codes(organizer, num=1, prefix=None):
|
||||
codes = set()
|
||||
batch_size = 500
|
||||
if 'postgres' in settings.DATABASES['default']['ENGINE']:
|
||||
batch_size = 5_000
|
||||
|
||||
"""
|
||||
We're trying to check if any of the requested codes already exists in the database. Generally, this is a
|
||||
|
||||
SELECT code FROM voucher WHERE code IN (…)
|
||||
|
||||
query. However, it turns out that this query get's rather slow if an organizer has lots of vouchers, even
|
||||
with a organizer with just over 50_000 vouchers, we've seen that creating 20_000 new voucher codes took
|
||||
just over 30 seconds. There's another way of doing this query on PostgreSQL, which is joining with a
|
||||
temporary table
|
||||
|
||||
SELECT code FROM voucher INNER JOIN (VALUES …) vals(v) ON (code = v)
|
||||
|
||||
This is significantly faster, inserting 20_000 vouchers now takes 2-3s instead of 31s on the same dataset.
|
||||
It's still slow, and removing the JOIN to the event table doesn't significantly speed it up. We might need
|
||||
an entirely different approach at some point.
|
||||
"""
|
||||
|
||||
while len(codes) < num:
|
||||
new_codes = set()
|
||||
for i in range(min(num - len(codes), batch_size)): # Work around SQLite's SQLITE_MAX_VARIABLE_NUMBER
|
||||
new_codes.add(_generate_random_code(prefix=prefix))
|
||||
|
||||
if 'postgres' in settings.DATABASES['default']['ENGINE']:
|
||||
with connection.cursor() as cursor:
|
||||
args = list(new_codes) + [organizer.pk]
|
||||
tmptable = "VALUES " + (", ".join(['(%s)'] * len(new_codes)))
|
||||
cursor.execute(
|
||||
f'SELECT code '
|
||||
f'FROM "{Voucher._meta.db_table}" '
|
||||
f'INNER JOIN ({tmptable}) vals(v) ON ("{Voucher._meta.db_table}"."code" = "v")'
|
||||
f'INNER JOIN "{Event._meta.db_table}" ON ("{Voucher._meta.db_table}"."event_id" = "{Event._meta.db_table}"."id") '
|
||||
f'WHERE "{Event._meta.db_table}"."organizer_id" = %s',
|
||||
args
|
||||
)
|
||||
for row in cursor.fetchall():
|
||||
new_codes.remove(row[0])
|
||||
else:
|
||||
new_codes -= set([v['code'] for v in Voucher.objects.filter(code__in=new_codes).values('code')])
|
||||
|
||||
codes |= new_codes
|
||||
return list(codes)
|
||||
|
||||
|
||||
class Voucher(LoggedModel):
|
||||
"""
|
||||
A Voucher can reserve ticket quota or allow special prices.
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
#
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.db.models import F, Q, Sum
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager
|
||||
@@ -114,9 +115,12 @@ class WaitingListEntry(LoggedModel):
|
||||
return '%s waits for %s' % (str(self.email), str(self.item))
|
||||
|
||||
def clean(self):
|
||||
WaitingListEntry.clean_duplicate(self.email, self.item, self.variation, self.subevent, self.pk)
|
||||
WaitingListEntry.clean_itemvar(self.event, self.item, self.variation)
|
||||
WaitingListEntry.clean_subevent(self.event, self.subevent)
|
||||
try:
|
||||
WaitingListEntry.clean_duplicate(self.email, self.item, self.variation, self.subevent, self.pk)
|
||||
WaitingListEntry.clean_itemvar(self.event, self.item, self.variation)
|
||||
WaitingListEntry.clean_subevent(self.event, self.subevent)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError('Invalid input')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
@@ -147,6 +151,34 @@ class WaitingListEntry(LoggedModel):
|
||||
)
|
||||
if availability[1] is None or availability[1] < 1:
|
||||
raise WaitingListException(_('This product is currently not available.'))
|
||||
|
||||
ev = self.subevent or self.event
|
||||
if ev.seat_category_mappings.filter(product=self.item).exists():
|
||||
# Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous
|
||||
# to use in combination with seating plans. If your event has 50 seats and a quota of 50 and
|
||||
# default settings, everything is fine and the waiting list will work as usual. However, as soon
|
||||
# as those two numbers diverge, either due to misconfiguration or due to intentional features such
|
||||
# as our COVID-19 minimum distance feature, things get ugly. Theoretically, there could be
|
||||
# significant quota available but not a single seat! The waiting list would happily send out vouchers
|
||||
# which do not work at all. Generally, we consider this a "known bug" and not fixable with the current
|
||||
# design of the waiting list and seating features.
|
||||
# However, we've put in a simple safeguard that makes sure the waiting list on its own does not screw
|
||||
# everything up. Specifically, we will not send out vouchers if the number of available seats is less
|
||||
# than the number of valid vouchers *issued through the waiting list*. Things can still go wrong due to
|
||||
# manually created vouchers, manually blocked seats or the minimum distance feature, but this reduces
|
||||
# the possible damage a bit.
|
||||
num_free_seats_for_product = ev.free_seats().filter(product=self.item).count()
|
||||
num_valid_vouchers_for_product = self.event.vouchers.filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
|
||||
block_quota=True,
|
||||
item_id=self.item_id,
|
||||
subevent_id=self.subevent_id,
|
||||
waitinglistentries__isnull=False
|
||||
).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0
|
||||
free_seats = num_free_seats_for_product - num_valid_vouchers_for_product
|
||||
if not free_seats:
|
||||
raise WaitingListException(_('No seat with this product is currently available.'))
|
||||
|
||||
if self.voucher:
|
||||
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
||||
if '@' not in self.email:
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import datetime
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal, DecimalException
|
||||
@@ -35,12 +36,14 @@ from django.utils.translation import (
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from phonenumber_field.phonenumber import to_python
|
||||
from phonenumbers import SUPPORTED_REGIONS
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms.questions import guess_country
|
||||
from pretix.base.models import (
|
||||
ItemVariation, OrderPosition, Question, QuestionAnswer, QuestionOption,
|
||||
Seat,
|
||||
Seat, SubEvent,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.settings import (
|
||||
@@ -155,11 +158,38 @@ class EmailColumn(ImportColumn):
|
||||
order.email = value
|
||||
|
||||
|
||||
class PhoneColumn(ImportColumn):
|
||||
identifier = 'phone'
|
||||
verbose_name = gettext_lazy('Phone number')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
if self.event.settings.region in SUPPORTED_REGIONS:
|
||||
region = self.event.settings.region
|
||||
elif self.event.settings.locale[:2].upper() in SUPPORTED_REGIONS:
|
||||
region = self.event.settings.locale[:2].upper()
|
||||
else:
|
||||
region = None
|
||||
|
||||
phone_number = to_python(value, region)
|
||||
if not phone_number or not phone_number.is_valid():
|
||||
raise ValidationError(_('Enter a valid phone number.'))
|
||||
return phone_number
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
order.phone = value
|
||||
|
||||
|
||||
class SubeventColumn(ImportColumn):
|
||||
identifier = 'subevent'
|
||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
||||
default_value = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._subevent_cache = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def subevents(self):
|
||||
return list(self.event.subevents.filter(active=True).order_by('date_from'))
|
||||
@@ -172,6 +202,30 @@ class SubeventColumn(ImportColumn):
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
raise ValidationError(pgettext("subevent", "You need to select a date."))
|
||||
|
||||
if value in self._subevent_cache:
|
||||
return self._subevent_cache[value]
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = self.event.timezone.localize(d)
|
||||
try:
|
||||
se = self.event.subevents.get(
|
||||
active=True,
|
||||
date_from__gt=d - datetime.timedelta(seconds=1),
|
||||
date_from__lt=d + datetime.timedelta(seconds=1),
|
||||
)
|
||||
self._subevent_cache[value] = se
|
||||
return se
|
||||
except SubEvent.DoesNotExist:
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
except SubEvent.MultipleObjectsReturned:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
matches = [
|
||||
p for p in self.subevents
|
||||
if str(p.pk) == value or any(
|
||||
@@ -181,6 +235,8 @@ class SubeventColumn(ImportColumn):
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
|
||||
self._subevent_cache[value] = matches[0]
|
||||
return matches[0]
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
@@ -622,9 +678,12 @@ class SeatColumn(ImportColumn):
|
||||
if value:
|
||||
try:
|
||||
value = Seat.objects.get(
|
||||
event=self.event,
|
||||
seat_guid=value,
|
||||
subevent=previous_values.get('subevent')
|
||||
)
|
||||
except Seat.MultipleObjectsReturned:
|
||||
raise ValidationError(_('Multiple matching seats were found.'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError(_('No matching seat was found.'))
|
||||
if not value.is_available() or value in self._cached:
|
||||
@@ -725,6 +784,7 @@ def get_all_columns(event):
|
||||
default.append(SubeventColumn(event))
|
||||
default += [
|
||||
EmailColumn(event),
|
||||
PhoneColumn(event),
|
||||
ItemColumn(event),
|
||||
Variation(event),
|
||||
InvoiceAddressCompany(event),
|
||||
|
||||
@@ -703,7 +703,7 @@ class BasePaymentProvider:
|
||||
try:
|
||||
ia = order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
return True
|
||||
pass
|
||||
else:
|
||||
if str(ia.country) not in restricted_countries:
|
||||
return False
|
||||
|
||||
@@ -50,6 +50,7 @@ from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -98,6 +99,11 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": "1",
|
||||
"evaluate": lambda orderposition, order, event: str(orderposition.positionid)
|
||||
}),
|
||||
("order_positionid", {
|
||||
"label": _("Order code and position number"),
|
||||
"editor_sample": "A1B2C-1",
|
||||
"evaluate": lambda orderposition, order, event: f"{orderposition.order.code}-{orderposition.positionid}"
|
||||
}),
|
||||
("item", {
|
||||
"label": _("Product name"),
|
||||
"editor_sample": _("Sample product"),
|
||||
@@ -428,7 +434,7 @@ def images_from_questions(sender, *args, **kwargs):
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = op.answers.filter(question_id=question_id).first()
|
||||
a = op.answers.filter(question_id=question_id).first() or a
|
||||
|
||||
if not a or not a.file or not any(a.file.name.lower().endswith(e) for e in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff")):
|
||||
return None
|
||||
@@ -468,7 +474,7 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = op.answers.filter(question_id=question_id).first()
|
||||
a = op.answers.filter(question_id=question_id).first() or a
|
||||
|
||||
if not a:
|
||||
return ""
|
||||
@@ -551,6 +557,12 @@ def get_first_scan(op: OrderPosition):
|
||||
return ""
|
||||
|
||||
|
||||
reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
|
||||
'delete_harakat': True,
|
||||
'support_ligatures': False,
|
||||
}))
|
||||
|
||||
|
||||
class Renderer:
|
||||
|
||||
def __init__(self, event, layout, background_file):
|
||||
@@ -568,6 +580,8 @@ class Renderer:
|
||||
|
||||
@classmethod
|
||||
def _register_fonts(cls):
|
||||
if hasattr(cls, '_fonts_registered'):
|
||||
return
|
||||
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
|
||||
@@ -582,6 +596,8 @@ class Renderer:
|
||||
if 'bolditalic' in styles:
|
||||
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
|
||||
|
||||
cls._fonts_registered = True
|
||||
|
||||
def _draw_poweredby(self, canvas: Canvas, op: OrderPosition, o: dict):
|
||||
content = o.get('content', 'dark')
|
||||
if content not in ('dark', 'white'):
|
||||
@@ -716,11 +732,6 @@ class Renderer:
|
||||
|
||||
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
|
||||
# to resolve all ligatures and python-bidi to switch RTL texts.
|
||||
configuration = {
|
||||
'delete_harakat': True,
|
||||
'support_ligatures': False,
|
||||
}
|
||||
reshaper = ArabicReshaper(configuration=configuration)
|
||||
try:
|
||||
text = "<br/>".join(get_display(reshaper.reshape(l)) for l in text.split("<br/>"))
|
||||
except:
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
import base64
|
||||
import inspect
|
||||
import struct
|
||||
from collections import namedtuple
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.hazmat.backends.openssl.backend import Backend
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
@@ -37,6 +39,8 @@ from pretix.base.models import Item, ItemVariation, SubEvent
|
||||
from pretix.base.secretgenerators import pretix_sig1_pb2
|
||||
from pretix.base.signals import register_ticket_secret_generators
|
||||
|
||||
ParsedSecret = namedtuple('AnalyzedSecret', 'item variation subevent attendee_name opaque_id')
|
||||
|
||||
|
||||
class BaseTicketSecretGenerator:
|
||||
"""
|
||||
@@ -72,6 +76,14 @@ class BaseTicketSecretGenerator:
|
||||
"""
|
||||
return False
|
||||
|
||||
def parse_secret(self, secret: str) -> Optional[ParsedSecret]:
|
||||
"""
|
||||
Given a ``secret``, return an ``ParsedSecret`` with the information decoded from the secret, if possible.
|
||||
Any value of ``ParsedSecret`` may be ``None``, and if parsing is not possible at all, you can ``None`` (as
|
||||
the default implementation does).
|
||||
"""
|
||||
return None
|
||||
|
||||
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
|
||||
attendee_name: str = None, current_secret: str = None, force_invalidate=False) -> str:
|
||||
"""
|
||||
@@ -181,6 +193,15 @@ class Sig1TicketSecretGenerator(BaseTicketSecretGenerator):
|
||||
except:
|
||||
return None
|
||||
|
||||
def parse_secret(self, secret: str) -> Optional[ParsedSecret]:
|
||||
ticket = self._parse(secret)
|
||||
if ticket:
|
||||
item = self.event.items.filter(pk=ticket.item).first() if ticket.item else None
|
||||
subevent = self.event.subevents.filter(pk=ticket.subevent).first() if ticket.subevent else None
|
||||
variation = item.variations.filter(pk=ticket.variation).first() if item and ticket.subevent else None
|
||||
opaque_id = ticket.seed
|
||||
return self.ParsedSecret(item=item, subevent=subevent, variation=variation, opaque_id=opaque_id, attendee_name=None)
|
||||
|
||||
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
|
||||
current_secret: str = None, force_invalidate=False):
|
||||
if current_secret and not force_invalidate:
|
||||
|
||||
@@ -227,7 +227,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
if not self.request.called_directly and counter % max(10, total // 100) == 0:
|
||||
self.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'value': round(counter / total * 100, 2)}
|
||||
meta={'value': round(counter / total * 100 if total else 0, 2)}
|
||||
)
|
||||
except LockTimeoutException:
|
||||
logger.exception("Could not cancel order")
|
||||
@@ -285,7 +285,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
if not self.request.called_directly and counter % max(10, total // 100) == 0:
|
||||
self.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'value': round(counter / total * 100, 2)}
|
||||
meta={'value': round(counter / total * 100 if total else 0, 2)}
|
||||
)
|
||||
|
||||
if send_waitinglist:
|
||||
@@ -296,6 +296,6 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
if not self.request.called_directly and counter % max(10, total // 100) == 0:
|
||||
self.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'value': round(counter / total * 100, 2)}
|
||||
meta={'value': round(counter / total * 100 if total else 0, 2)}
|
||||
)
|
||||
return failed
|
||||
|
||||
@@ -283,13 +283,16 @@ class CartManager:
|
||||
if op.item.require_voucher and op.voucher is None:
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if op.item.hide_without_voucher and (op.voucher is None or not op.voucher.show_hidden_items):
|
||||
if (
|
||||
(op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher)) and
|
||||
(op.voucher is None or not op.voucher.show_hidden_items)
|
||||
):
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if not op.item.is_available() or (op.variation and not op.variation.active):
|
||||
if not op.item.is_available() or (op.variation and not op.variation.is_available()):
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
if self._sales_channel not in op.item.sales_channels:
|
||||
if self._sales_channel not in op.item.sales_channels or (op.variation and self._sales_channel not in op.variation.sales_channels):
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
|
||||
|
||||
@@ -39,7 +39,7 @@ import dateutil
|
||||
import dateutil.parser
|
||||
import pytz
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import (
|
||||
BooleanField, Count, ExpressionWrapper, F, IntegerField, OuterRef, Q,
|
||||
Subquery, Value,
|
||||
@@ -512,6 +512,21 @@ class RequiredQuestionsError(Exception):
|
||||
|
||||
|
||||
def _save_answers(op, answers, given_answers):
|
||||
def _create_answer(question, answer):
|
||||
try:
|
||||
return op.answers.create(question=question, answer=answer)
|
||||
except IntegrityError:
|
||||
# Since we prefill ``field.answer`` at form creation time, there's a possible race condition
|
||||
# here if the user submits their scan a second time while the first one is still running,
|
||||
# thus leading to duplicate QuestionAnswer objects. Since Django doesn't support UPSERT, the "proper"
|
||||
# fix would be a transaction with select_for_update(), or at least fetching using get_or_create here
|
||||
# again. However, both of these approaches have a significant performance overhead for *all* requests,
|
||||
# while the issue happens very very rarely. So we opt for just catching the error and retrying properly.
|
||||
qa = op.answers.get(question=question)
|
||||
qa.answer = answer
|
||||
qa.save(update_fields=['answer'])
|
||||
qa.options.clear()
|
||||
|
||||
written = False
|
||||
for q, a in given_answers.items():
|
||||
if not a:
|
||||
@@ -528,7 +543,7 @@ def _save_answers(op, answers, given_answers):
|
||||
written = True
|
||||
qa.options.clear()
|
||||
else:
|
||||
qa = op.answers.create(question=q, answer=str(a.answer))
|
||||
qa = _create_answer(question=q, answer=str(a.answer))
|
||||
qa.options.add(a)
|
||||
elif isinstance(a, list):
|
||||
if q in answers:
|
||||
@@ -538,13 +553,13 @@ def _save_answers(op, answers, given_answers):
|
||||
written = True
|
||||
qa.options.clear()
|
||||
else:
|
||||
qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a]))
|
||||
qa = _create_answer(question=q, answer=", ".join([str(o) for o in a]))
|
||||
qa.options.add(*a)
|
||||
elif isinstance(a, File):
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
else:
|
||||
qa = op.answers.create(question=q, answer=str(a))
|
||||
qa = _create_answer(question=q, answer=str(a))
|
||||
qa.file.save(os.path.basename(a.name), a, save=False)
|
||||
qa.answer = 'file://' + qa.file.name
|
||||
qa.save()
|
||||
@@ -555,7 +570,7 @@ def _save_answers(op, answers, given_answers):
|
||||
qa.answer = str(a)
|
||||
qa.save()
|
||||
else:
|
||||
op.answers.create(question=q, answer=str(a))
|
||||
_create_answer(question=q, answer=str(a))
|
||||
written = True
|
||||
|
||||
if written:
|
||||
@@ -566,7 +581,8 @@ def _save_answers(op, answers, given_answers):
|
||||
|
||||
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
|
||||
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
|
||||
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY):
|
||||
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
|
||||
raw_barcode=None, from_revoked_secret=False):
|
||||
"""
|
||||
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
|
||||
not valid at this time.
|
||||
@@ -581,6 +597,11 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
:param nonce: A random nonce to prevent race conditions.
|
||||
:param datetime: The datetime of the checkin, defaults to now.
|
||||
"""
|
||||
|
||||
# !!!!!!!!!
|
||||
# Update doc/images/checkin_online.puml if you make substantial changes here!
|
||||
# !!!!!!!!!
|
||||
|
||||
dt = datetime or now()
|
||||
|
||||
if op.canceled or op.order.status not in (Order.STATUS_PAID, Order.STATUS_PENDING):
|
||||
@@ -623,12 +644,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
_('This order is not marked as paid.'),
|
||||
'unpaid'
|
||||
)
|
||||
elif require_answers and not force and questions_supported:
|
||||
raise RequiredQuestionsError(
|
||||
_('You need to answer questions to complete this check-in.'),
|
||||
'incomplete',
|
||||
require_answers
|
||||
)
|
||||
|
||||
if type == Checkin.TYPE_ENTRY and clist.rules and not force:
|
||||
rule_data = LazyRuleVars(op, clist, dt)
|
||||
@@ -643,19 +658,27 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
reason=reason
|
||||
)
|
||||
|
||||
if require_answers and not force and questions_supported:
|
||||
raise RequiredQuestionsError(
|
||||
_('You need to answer questions to complete this check-in.'),
|
||||
'incomplete',
|
||||
require_answers
|
||||
)
|
||||
|
||||
device = None
|
||||
if isinstance(auth, Device):
|
||||
device = auth
|
||||
|
||||
last_ci = op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce').first()
|
||||
last_cis = list(op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce'))
|
||||
entry_allowed = (
|
||||
type == Checkin.TYPE_EXIT or
|
||||
clist.allow_multiple_entries or
|
||||
last_ci is None or
|
||||
(clist.allow_entry_after_exit and last_ci.type == Checkin.TYPE_EXIT)
|
||||
not last_cis or
|
||||
all(c.type == Checkin.TYPE_EXIT for c in last_cis) or
|
||||
(clist.allow_entry_after_exit and last_cis[0].type == Checkin.TYPE_EXIT)
|
||||
)
|
||||
|
||||
if nonce and ((last_ci and last_ci.nonce == nonce) or op.checkins.filter(type=type, list=clist, device=device, nonce=nonce).exists()):
|
||||
if nonce and ((last_cis and last_cis[0].nonce == nonce) or op.checkins.filter(type=type, list=clist, device=device, nonce=nonce).exists()):
|
||||
return
|
||||
|
||||
if entry_allowed or force:
|
||||
@@ -667,7 +690,8 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
device=device,
|
||||
gate=device.gate if device else None,
|
||||
nonce=nonce,
|
||||
forced=force and not entry_allowed,
|
||||
forced=force and (not entry_allowed or from_revoked_secret),
|
||||
raw_barcode=raw_barcode,
|
||||
)
|
||||
op.order.log_action('pretix.event.checkin', data={
|
||||
'position': op.id,
|
||||
@@ -676,6 +700,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
'forced': force or op.order.status != Order.STATUS_PAID,
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'answers': {k.pk: str(v) for k, v in given_answers.items()},
|
||||
'list': clist.pk
|
||||
}, user=user, auth=auth)
|
||||
checkin_created.send(op.order.event, checkin=ci)
|
||||
|
||||
@@ -40,7 +40,7 @@ def clean_cart_positions(sender, **kwargs):
|
||||
cp.delete()
|
||||
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
|
||||
cp.delete()
|
||||
for ia in InvoiceAddress.objects.filter(order__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
||||
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
||||
ia.delete()
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
|
||||
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)
|
||||
ex = response(event, event.organizer, set_progress)
|
||||
if ex.identifier == provider:
|
||||
d = ex.render(form_data)
|
||||
if d is None:
|
||||
@@ -70,12 +70,15 @@ 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, device: int, token: int, 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], staff_session=False) -> 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')
|
||||
if user and staff_session:
|
||||
allowed_events = organizer.events.all()
|
||||
|
||||
def set_progress(val):
|
||||
if not self.request.called_directly:
|
||||
@@ -100,16 +103,19 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
|
||||
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)
|
||||
if form_data.get('events') is not None:
|
||||
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'))
|
||||
else:
|
||||
events = allowed_events.filter(pk__in=form_data.get('events'))
|
||||
events = allowed_events
|
||||
responses = register_multievent_data_exporters.send(organizer)
|
||||
|
||||
for receiver, response in responses:
|
||||
if not response:
|
||||
continue
|
||||
ex = response(events, set_progress)
|
||||
ex = response(events, organizer, set_progress)
|
||||
if ex.identifier == provider:
|
||||
d = ex.render(form_data)
|
||||
if d is None:
|
||||
|
||||
@@ -244,7 +244,9 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
event_date_to=None if invoice.event.has_subevents else invoice.event.date_to,
|
||||
tax_value=fee.tax_value,
|
||||
tax_rate=fee.tax_rate,
|
||||
tax_name=fee.tax_rule.name if fee.tax_rule else ''
|
||||
tax_name=fee.tax_rule.name if fee.tax_rule else '',
|
||||
fee_type=fee.fee_type,
|
||||
fee_internal_type=fee.internal_type or None,
|
||||
)
|
||||
|
||||
if fee.tax_rule and fee.tax_rule.is_reverse_charge(ia) and fee.value and not fee.tax_value:
|
||||
@@ -321,7 +323,7 @@ def generate_invoice(order: Order, trigger_pdf=True):
|
||||
order=order,
|
||||
event=order.event,
|
||||
organizer=order.event.organizer,
|
||||
date=timezone.now().date(),
|
||||
date=timezone.now().astimezone(order.event.timezone).date(),
|
||||
)
|
||||
invoice = build_invoice(invoice)
|
||||
if trigger_pdf:
|
||||
|
||||
@@ -45,7 +45,6 @@ from email.utils import formataddr
|
||||
from typing import Any, Dict, List, Sequence, Union
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import cssutils
|
||||
import pytz
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -79,7 +78,6 @@ from pretix.presale.ical import get_ical
|
||||
|
||||
logger = logging.getLogger('pretix.base.mail')
|
||||
INVALID_ADDRESS = 'invalid-pretix-mail-address'
|
||||
cssutils.log.setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
class TolerantDict(dict):
|
||||
@@ -176,8 +174,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
subject = str(subject).format_map(TolerantDict(context))
|
||||
sender = (
|
||||
sender or
|
||||
(event.settings.get('mail_from') if event else settings.MAIL_FROM) or
|
||||
(organizer.settings.get('mail_from') if organizer else settings.MAIL_FROM) or
|
||||
(event.settings.get('mail_from') if event else None) or
|
||||
(organizer.settings.get('mail_from') if organizer else None) or
|
||||
settings.MAIL_FROM
|
||||
)
|
||||
if event:
|
||||
@@ -406,7 +404,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
logger.exception('Could not attach invoice to email')
|
||||
pass
|
||||
|
||||
if attach_size < 4 * 1024 * 1024:
|
||||
if attach_size < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT:
|
||||
# Do not attach more than 4MB, it will bounce way to often.
|
||||
for a in args:
|
||||
try:
|
||||
@@ -628,6 +626,7 @@ def encoder_linelength(msg):
|
||||
|
||||
|
||||
def convert_image_to_cid(image_src, cid_id, verify_ssl=True):
|
||||
image_src = image_src.strip()
|
||||
try:
|
||||
if image_src.startswith('data:image/'):
|
||||
image_type, image_content = image_src.split(',', 1)
|
||||
@@ -641,13 +640,14 @@ def convert_image_to_cid(image_src, cid_id, verify_ssl=True):
|
||||
image_src = normalize_image_url(image_src)
|
||||
|
||||
path = urlparse(image_src).path
|
||||
guess_subtype = os.path.splitext(path)[1][1:]
|
||||
image_type = os.path.splitext(path)[1][1:]
|
||||
|
||||
response = requests.get(image_src, verify=verify_ssl)
|
||||
mime_image = MIMEImage(
|
||||
response.content, _subtype=guess_subtype)
|
||||
response.content, _subtype=image_type)
|
||||
|
||||
mime_image.add_header('Content-ID', '<%s>' % cid_id)
|
||||
mime_image.add_header('Content-Disposition', 'inline;\n filename="{}.{}"'.format(cid_id, image_type))
|
||||
|
||||
return mime_image
|
||||
except:
|
||||
|
||||
@@ -141,6 +141,11 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
||||
_('You selected a membership that is connected to a different customer account.')
|
||||
)
|
||||
|
||||
if m.canceled:
|
||||
raise ValidationError(
|
||||
_('You selected membership that has been canceled.')
|
||||
)
|
||||
|
||||
if m.testmode != testmode:
|
||||
raise ValidationError(
|
||||
_('You can only use a test mode membership for test mode tickets.')
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import css_inline
|
||||
from django.conf import settings
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import override
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import LogEntry, NotificationSetting, User
|
||||
@@ -131,7 +131,11 @@ def send_notification_mail(notification: Notification, user: User):
|
||||
}
|
||||
|
||||
tpl_html = get_template('pretixbase/email/notification.html')
|
||||
body_html = inline_css(tpl_html.render(ctx))
|
||||
|
||||
body_html = tpl_html.render(ctx)
|
||||
inliner = css_inline.CSSInliner(remove_style_tags=True)
|
||||
body_html = inliner.inline(body_html)
|
||||
|
||||
tpl_plain = get_template('pretixbase/email/notification.txt')
|
||||
body_plain = tpl_plain.render(ctx)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user