mirror of
https://github.com/pretix/pretix.git
synced 2026-01-05 21:32:26 +00:00
Compare commits
370 Commits
shorter-lo
...
release/1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe9eeaaee | ||
|
|
77ed3193b9 | ||
|
|
c82b37739e | ||
|
|
dba820028f | ||
|
|
90c5fcd050 | ||
|
|
7509bf69ca | ||
|
|
d9adec88c8 | ||
|
|
938a1bca0d | ||
|
|
ab757c502c | ||
|
|
6b17388bd8 | ||
|
|
48a933b757 | ||
|
|
6c02bf73b5 | ||
|
|
960d0bcdf2 | ||
|
|
d389e4390f | ||
|
|
55ce83a642 | ||
|
|
300f8f666d | ||
|
|
5d6083dce4 | ||
|
|
82f9f5027f | ||
|
|
4f015f1d96 | ||
|
|
bbe272c35c | ||
|
|
39513448f3 | ||
|
|
bee61bf398 | ||
|
|
010c31cf10 | ||
|
|
d1643b4506 | ||
|
|
623307b348 | ||
|
|
09e8fca132 | ||
|
|
2c96a26d91 | ||
|
|
f639d2aa57 | ||
|
|
5a68eb345f | ||
|
|
603a3d78fc | ||
|
|
cafc6a7226 | ||
|
|
0b068f6d79 | ||
|
|
ec73c916b7 | ||
|
|
110ccb5587 | ||
|
|
d224ae3eb0 | ||
|
|
dd9c0b3a01 | ||
|
|
d2d711c1f8 | ||
|
|
3dd2492926 | ||
|
|
bc1520ec35 | ||
|
|
3033a82c92 | ||
|
|
bb75be7e8e | ||
|
|
b52f2f5a9e | ||
|
|
5bcfb958f0 | ||
|
|
5f52963ce0 | ||
|
|
3f76be2287 | ||
|
|
92aa65a839 | ||
|
|
bd5337a2c2 | ||
|
|
990d5815f2 | ||
|
|
c1d51cc196 | ||
|
|
f5b871f8f5 | ||
|
|
bc6b84f900 | ||
|
|
5ee79c8148 | ||
|
|
e4706dd3ba | ||
|
|
3c59a870e7 | ||
|
|
ae6ad8870d | ||
|
|
07fed0acce | ||
|
|
7dd99f3d18 | ||
|
|
03d8cfb401 | ||
|
|
ccb981e6ce | ||
|
|
984d5c716b | ||
|
|
43121a08bd | ||
|
|
54c7f16c4c | ||
|
|
6cd2674f2a | ||
|
|
602947a3d7 | ||
|
|
5048963aa2 | ||
|
|
8d16e2b59b | ||
|
|
4accbef6a9 | ||
|
|
2e9d95b96a | ||
|
|
03dfd1b96f | ||
|
|
ee1ccb7f01 | ||
|
|
a6a3544628 | ||
|
|
ca762083b6 | ||
|
|
550ab7de18 | ||
|
|
4919f8991c | ||
|
|
867a8132aa | ||
|
|
c661122bb6 | ||
|
|
80bd8d2039 | ||
|
|
7267496367 | ||
|
|
9dacea11dd | ||
|
|
91c48c50e5 | ||
|
|
67e5ecb931 | ||
|
|
887152a0e2 | ||
|
|
c1a76c4c18 | ||
|
|
8dacbe0fc6 | ||
|
|
a4ead5bd07 | ||
|
|
2f6e36c504 | ||
|
|
bcdb4fd000 | ||
|
|
99395c722d | ||
|
|
e28030576a | ||
|
|
455b0f2015 | ||
|
|
6da0125b7d | ||
|
|
48912bdf55 | ||
|
|
ba70ddfb76 | ||
|
|
f828fcdcab | ||
|
|
c1403207de | ||
|
|
4514bd7e53 | ||
|
|
f2378168c1 | ||
|
|
e0e3a72268 | ||
|
|
c932892dbd | ||
|
|
f03ad7c68f | ||
|
|
d3a26d8022 | ||
|
|
446698d52f | ||
|
|
69faab01b2 | ||
|
|
36d6b6f9ab | ||
|
|
ea70b5fa46 | ||
|
|
927e21e5d1 | ||
|
|
259c0cca69 | ||
|
|
11ce4c2078 | ||
|
|
76ec402fc5 | ||
|
|
df956816b4 | ||
|
|
5d431b3843 | ||
|
|
91ca4f2184 | ||
|
|
b00a0eccc6 | ||
|
|
d675ad18e0 | ||
|
|
031ed8f3cd | ||
|
|
aed78c2d69 | ||
|
|
af3e811f94 | ||
|
|
811c498080 | ||
|
|
e6d58b3b0d | ||
|
|
b7dc671028 | ||
|
|
8418eb2c6b | ||
|
|
5a882a0fae | ||
|
|
be1cbfeb91 | ||
|
|
96c61a073c | ||
|
|
64ef293ce2 | ||
|
|
55953d5b4e | ||
|
|
c63e69db5f | ||
|
|
f9646d9325 | ||
|
|
6bbdbddfaa | ||
|
|
177d46ab8d | ||
|
|
ecd90da554 | ||
|
|
2302dbade6 | ||
|
|
cbf735487f | ||
|
|
a10090b1fb | ||
|
|
babf76371e | ||
|
|
1baac6bb21 | ||
|
|
b1c60065b2 | ||
|
|
af4a2c7184 | ||
|
|
b6f42ecd6d | ||
|
|
8b7d2314b8 | ||
|
|
81adbb3813 | ||
|
|
684198fc08 | ||
|
|
a86431bb6d | ||
|
|
767e3ac659 | ||
|
|
910d6831bf | ||
|
|
c251a48e31 | ||
|
|
8e4b71eb19 | ||
|
|
a2cb219d9b | ||
|
|
f722d4e83e | ||
|
|
ed04f3124f | ||
|
|
08e7a29623 | ||
|
|
09020143e7 | ||
|
|
33e7a10bea | ||
|
|
5e64f6ac88 | ||
|
|
f16aabc136 | ||
|
|
2d00563088 | ||
|
|
124c3a99e6 | ||
|
|
7e135be012 | ||
|
|
d94c67bc7a | ||
|
|
3636bbbf3f | ||
|
|
7c687ee397 | ||
|
|
c3fb033d33 | ||
|
|
8b2257161f | ||
|
|
c4bf73c8d6 | ||
|
|
0db927407d | ||
|
|
9b7223c0e8 | ||
|
|
7b33fc6633 | ||
|
|
8310597944 | ||
|
|
c03ac624fc | ||
|
|
323beb1ab0 | ||
|
|
73490d2923 | ||
|
|
a8e630d271 | ||
|
|
e3e8a162bd | ||
|
|
824ca54478 | ||
|
|
8661bfe4a4 | ||
|
|
4c2c302bfd | ||
|
|
c83f539bba | ||
|
|
8f5849a90c | ||
|
|
b7df5eff19 | ||
|
|
eb4ba70be8 | ||
|
|
136094caf9 | ||
|
|
1fa0256363 | ||
|
|
6de44aee02 | ||
|
|
43facbecda | ||
|
|
0dfca824e2 | ||
|
|
70ee678fef | ||
|
|
680e7cb0da | ||
|
|
498ac97bdd | ||
|
|
3c75072868 | ||
|
|
8e09aa9b54 | ||
|
|
c4fe2322a1 | ||
|
|
43abe14293 | ||
|
|
7146d984d0 | ||
|
|
5495cd749a | ||
|
|
ef93b8ae38 | ||
|
|
2d370aaf5a | ||
|
|
3037309711 | ||
|
|
01c3200258 | ||
|
|
bf9987e05f | ||
|
|
cd61c0833d | ||
|
|
90bdb30449 | ||
|
|
92c2dcaf25 | ||
|
|
a3cd8d151d | ||
|
|
ba455a3630 | ||
|
|
123f47ab39 | ||
|
|
d6503e3e48 | ||
|
|
4f4e5854f2 | ||
|
|
48461122f8 | ||
|
|
aba7652aee | ||
|
|
78fc58cc93 | ||
|
|
3a2ca8d3d6 | ||
|
|
fecc5ec307 | ||
|
|
0665bd443b | ||
|
|
221526c979 | ||
|
|
04369ff4f1 | ||
|
|
e71691d4a5 | ||
|
|
598e7c5637 | ||
|
|
ad73c0e05b | ||
|
|
d4573e8c25 | ||
|
|
1d0def19b1 | ||
|
|
80a1bcf033 | ||
|
|
2f0cbcc565 | ||
|
|
48a2090e01 | ||
|
|
520b978ab0 | ||
|
|
ec42557305 | ||
|
|
bbb71ef891 | ||
|
|
7300c60e73 | ||
|
|
9f90ac80a0 | ||
|
|
f2260212ee | ||
|
|
b94459c761 | ||
|
|
3e26a4d9cc | ||
|
|
6bcfa4980f | ||
|
|
829b6a7d56 | ||
|
|
56bf3fe459 | ||
|
|
63bdb397e7 | ||
|
|
d4b3bf4370 | ||
|
|
7120e95d2a | ||
|
|
f285390f46 | ||
|
|
559864dd01 | ||
|
|
8021e1f269 | ||
|
|
53713acd9a | ||
|
|
5212f6b035 | ||
|
|
ea807239b1 | ||
|
|
2ec534e32d | ||
|
|
ec90efbf4a | ||
|
|
ffa35a9b9b | ||
|
|
2a6629e075 | ||
|
|
59d440b213 | ||
|
|
e6a6043a7a | ||
|
|
ecb1eedcba | ||
|
|
41c8ed2400 | ||
|
|
b2cd633248 | ||
|
|
0acee0e362 | ||
|
|
33265d05fb | ||
|
|
2182a4e361 | ||
|
|
2336505309 | ||
|
|
15b5e66da9 | ||
|
|
c7676cd17a | ||
|
|
e53562dda2 | ||
|
|
d134dcf6a9 | ||
|
|
981d82b0ee | ||
|
|
e75bce37bc | ||
|
|
ef432252f0 | ||
|
|
e9e743f312 | ||
|
|
0998814e69 | ||
|
|
d3f21353ca | ||
|
|
f6d8b825d5 | ||
|
|
4012658596 | ||
|
|
b2eb159380 | ||
|
|
b16824ec2b | ||
|
|
c639cd96f5 | ||
|
|
dd074a11d4 | ||
|
|
41c0719235 | ||
|
|
210768a14f | ||
|
|
87b7ffcc2a | ||
|
|
67de7150e5 | ||
|
|
b6e42d64da | ||
|
|
89732a3057 | ||
|
|
b23d95b6c3 | ||
|
|
847997ea9b | ||
|
|
30d67cd4ad | ||
|
|
aed9382fd7 | ||
|
|
871011826c | ||
|
|
dd8d28f6b8 | ||
|
|
9d08e23a48 | ||
|
|
954af1de3d | ||
|
|
1cfce1f5e9 | ||
|
|
4dbf5dc054 | ||
|
|
cf334e2b48 | ||
|
|
adbe966d85 | ||
|
|
aab56d3b39 | ||
|
|
aa2f0e0fd0 | ||
|
|
2ee0ff755d | ||
|
|
f4be14eed8 | ||
|
|
dc73018404 | ||
|
|
4fbad2d360 | ||
|
|
b19c470ce5 | ||
|
|
a0350d1444 | ||
|
|
1c54ca7b74 | ||
|
|
e6f731ad77 | ||
|
|
47fb61b762 | ||
|
|
4e6345aaed | ||
|
|
f4672564ce | ||
|
|
a4218fa1b9 | ||
|
|
c5ec918e78 | ||
|
|
62ef5271de | ||
|
|
d698313f1d | ||
|
|
16ab6e44f5 | ||
|
|
dddb1d4a65 | ||
|
|
873c7dc65d | ||
|
|
0082216d75 | ||
|
|
0d19944304 | ||
|
|
70fa7eac6b | ||
|
|
3db4833290 | ||
|
|
5c8c106d5b | ||
|
|
d4aa3e62a5 | ||
|
|
888cce78a5 | ||
|
|
d0a5529080 | ||
|
|
0dc3f30791 | ||
|
|
18c24623d7 | ||
|
|
9bf9cc0f9f | ||
|
|
77e917345c | ||
|
|
ad60dadee4 | ||
|
|
83057e48ec | ||
|
|
7639ef9a42 | ||
|
|
852bc6c128 | ||
|
|
cfda772133 | ||
|
|
5e3087341c | ||
|
|
d1357ed5c0 | ||
|
|
58668010a2 | ||
|
|
e5cb26464e | ||
|
|
b098c9c16a | ||
|
|
759fed7a8b | ||
|
|
24da9b8d85 | ||
|
|
1af09509ff | ||
|
|
7f21c171fd | ||
|
|
47814900dc | ||
|
|
425d6590d0 | ||
|
|
c754966103 | ||
|
|
43ca778796 | ||
|
|
765cb09c6c | ||
|
|
8e4eb52386 | ||
|
|
fb19891473 | ||
|
|
fa0bd5e89e | ||
|
|
3c96e631da | ||
|
|
d27fefe4da | ||
|
|
8cb977e4d6 | ||
|
|
7154d3f510 | ||
|
|
1785178532 | ||
|
|
970734cff7 | ||
|
|
5ca82a922a | ||
|
|
34b937273a | ||
|
|
a0e53b532a | ||
|
|
62edf1e7b8 | ||
|
|
3930fc749a | ||
|
|
bfd87f11dd | ||
|
|
f76d173162 | ||
|
|
f8b38dca82 | ||
|
|
248ab25567 | ||
|
|
982a622e88 | ||
|
|
82b68bf7e0 | ||
|
|
2efde1669d | ||
|
|
eea6a5e9da | ||
|
|
fdbe71ff63 | ||
|
|
a8be2d5f24 | ||
|
|
b15c4e6d6f | ||
|
|
8b00361d1b | ||
|
|
444678ce6b | ||
|
|
0f8d520336 | ||
|
|
4d7b5a0a3b |
35
.codecov.yml
Normal file
35
.codecov.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
codecov:
|
||||
notify:
|
||||
require_ci_to_pass: yes
|
||||
|
||||
coverage:
|
||||
precision: 2
|
||||
round: down
|
||||
range: "60...100"
|
||||
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 2%
|
||||
base: auto
|
||||
patch:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 2%
|
||||
base: auto
|
||||
changes: no
|
||||
|
||||
parsers:
|
||||
gcov:
|
||||
branch_detection:
|
||||
conditional: yes
|
||||
loop: yes
|
||||
method: no
|
||||
macro: no
|
||||
|
||||
comment:
|
||||
require_changes: yes
|
||||
layout: "header, diff, files"
|
||||
behavior: default
|
||||
require_changes: no
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,4 +21,6 @@ pretixeu/
|
||||
local/
|
||||
.project
|
||||
.pydevproject
|
||||
.DS_Store
|
||||
|
||||
|
||||
|
||||
@@ -2,11 +2,32 @@ before_script:
|
||||
tests:
|
||||
stage: test
|
||||
script:
|
||||
- virtualenv-3.4 env
|
||||
- virtualenv env
|
||||
- source env/bin/activate
|
||||
- pip install -U pip wheel setuptools
|
||||
- XDG_CACHE_HOME=/cache bash .travis.sh style
|
||||
- XDG_CACHE_HOME=/cache bash .travis.sh tests
|
||||
- XDG_CACHE_HOME=/cache bash .travis.sh doctests
|
||||
tags:
|
||||
- python3
|
||||
pypi:
|
||||
stage: release
|
||||
script:
|
||||
- cp /keys/.pypirc ~/.pypirc
|
||||
- virtualenv env
|
||||
- source env/bin/activate
|
||||
- pip install -U pip wheel setuptools
|
||||
- XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
- cd src
|
||||
- python setup.py sdist upload
|
||||
- python setup.py bdist_wheel upload
|
||||
tags:
|
||||
- python3
|
||||
only:
|
||||
- release
|
||||
artifacts:
|
||||
paths:
|
||||
- src/dist/
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- release
|
||||
|
||||
29
.travis.sh
29
.travis.sh
@@ -4,6 +4,16 @@ set -x
|
||||
|
||||
echo "Executing job $1"
|
||||
|
||||
if [ "$PRETIX_CONFIG_FILE" == "tests/travis_mysql.cfg" ]; then
|
||||
mysql -u root -e 'CREATE DATABASE pretix DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;'
|
||||
pip3 install -Ur src/requirements/mysql.txt
|
||||
fi
|
||||
|
||||
if [ "$PRETIX_CONFIG_FILE" == "tests/travis_postgres.cfg" ]; then
|
||||
psql -c 'create database travis_ci_test;' -U postgres
|
||||
pip3 install -Ur src/requirements/postgres.txt
|
||||
fi
|
||||
|
||||
if [ "$1" == "style" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
cd src
|
||||
@@ -20,12 +30,27 @@ if [ "$1" == "tests" ]; then
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
coverage run -m py.test --rerun 5 tests && coverage report
|
||||
py.test --rerun 5 tests
|
||||
fi
|
||||
if [ "$1" == "tests-cov" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
coverage run -m py.test --rerun 5 tests && coveralls
|
||||
coverage run -m py.test --rerun 5 tests && codecov
|
||||
fi
|
||||
if [ "$1" == "plugins" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
cd src
|
||||
python setup.py develop
|
||||
make all compress
|
||||
|
||||
pushd ~
|
||||
git clone --depth 1 https://github.com/pretix/pretix-cartshare.git
|
||||
cd pretix-cartshare
|
||||
python setup.py develop
|
||||
make
|
||||
py.test --rerun 5 tests
|
||||
popd
|
||||
|
||||
fi
|
||||
|
||||
45
.travis.yml
45
.travis.yml
@@ -1,15 +1,40 @@
|
||||
language: python
|
||||
sudo: false
|
||||
python:
|
||||
- "3.4"
|
||||
install:
|
||||
- pip install -U pip wheel setuptools==28.6.1
|
||||
- pip install -U pip wheel setuptools==28.6.1
|
||||
script:
|
||||
- bash .travis.sh $JOB
|
||||
- bash .travis.sh $JOB
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
env:
|
||||
- JOB=style
|
||||
- JOB=doctests
|
||||
- JOB=tests-cov
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
services:
|
||||
- mysql
|
||||
- postgresql
|
||||
matrix:
|
||||
include:
|
||||
- python: 3.4
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/sqlite.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/sqlite.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/sqlite.cfg
|
||||
- python: 3.4
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.4
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.4
|
||||
env: JOB=style
|
||||
- python: 3.4
|
||||
env: JOB=plugins
|
||||
- python: 3.4
|
||||
env: JOB=tests-cov
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
|
||||
18
AUTHORS
18
AUTHORS
@@ -3,21 +3,35 @@ people who have submitted patches, reported bugs, added translations, helped
|
||||
answer newbie questions, improved the documentation, and generally made pretix
|
||||
an awesome project. Thank you all!
|
||||
|
||||
Adam K. Sumner <asumner101@gmail.com>
|
||||
Ahrdie <robert.deppe@me.com>
|
||||
Alexander Brock <Brock.Alexander@web.de>
|
||||
Ben Oswald
|
||||
Brandon Pineda
|
||||
Bolutife Lawrence
|
||||
Christian Franke <nobody@nowhere.ws>
|
||||
Christopher Dambamuromo <me@chridam.com>
|
||||
chotee <chotee@openended.eu>
|
||||
Cpt. Foo
|
||||
Daniel Rosenblüh
|
||||
Enrique Saez
|
||||
Flavia Bastos
|
||||
informancer <informancer@web.de>
|
||||
Jason Estibeiro <jasonestibeiro@live.com>
|
||||
Jakob Schnell <github@ezelo.de>
|
||||
Jan Felix Wiebe <git@jfwie.be>
|
||||
Jan Weiß
|
||||
Jason Estibeiro <jasonestibeiro@live.com>
|
||||
jlwt90
|
||||
Jonas Große Sundrup <cherti@letopolis.de>
|
||||
Kevin Nelson
|
||||
Leah Oswald
|
||||
Lukas Martini
|
||||
Nathan Mattes
|
||||
Nicole Klünder
|
||||
Marc-Pascal Clement
|
||||
Martin Gross <martin@pc-coholic.de>
|
||||
Raphael Michel <mail@raphaelmichel.de>
|
||||
Team MRMCD
|
||||
Tobias Kunze <rixx@cutebit.de>
|
||||
Oliver Knapp <github@oliverknapp.de>
|
||||
Vishal Sodani <vishalsodani@rediffmail.com>
|
||||
Jan Felix Wiebe <git@jfwie.be>
|
||||
|
||||
65
Dockerfile
65
Dockerfile
@@ -1,49 +1,46 @@
|
||||
FROM debian:jessie
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 git python3-pip \
|
||||
libxml2-dev libxslt1-dev python-dev python-virtualenv locales libffi-dev \
|
||||
build-essential python3-dev zlib1g-dev libssl-dev gettext \
|
||||
libpq-dev libmysqlclient-dev libmemcached-dev libjpeg-dev \
|
||||
aqbanking-tools supervisor nginx sudo \
|
||||
--no-install-recommends
|
||||
|
||||
WORKDIR /
|
||||
|
||||
RUN dpkg-reconfigure locales && \
|
||||
RUN apt-get update && \
|
||||
apt-get install -y python3 git python3-pip \
|
||||
libxml2-dev libxslt1-dev python-dev python-virtualenv locales libffi-dev \
|
||||
build-essential python3-dev zlib1g-dev libssl-dev gettext \
|
||||
libpq-dev libmysqlclient-dev libmemcached-dev libjpeg-dev \
|
||||
aqbanking-tools supervisor nginx sudo \
|
||||
--no-install-recommends && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
dpkg-reconfigure locales && \
|
||||
locale-gen C.UTF-8 && \
|
||||
/usr/sbin/update-locale LANG=C.UTF-8
|
||||
ENV LC_ALL C.UTF-8
|
||||
/usr/sbin/update-locale LANG=C.UTF-8 && \
|
||||
mkdir /etc/pretix && \
|
||||
mkdir /data && \
|
||||
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
|
||||
echo 'pretixuser ALL=(ALL) NOPASSWD: /usr/bin/supervisord' >> /etc/sudoers && \
|
||||
mkdir /static
|
||||
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -ms /bin/bash -d /pretix -u 15371 pretixuser
|
||||
RUN echo 'pretixuser ALL=(ALL) NOPASSWD: /usr/bin/supervisord' >> /etc/sudoers
|
||||
|
||||
RUN mkdir /etc/pretix
|
||||
RUN mkdir /data
|
||||
VOLUME /etc/pretix
|
||||
ENV LC_ALL=C.UTF-8 \
|
||||
DJANGO_SETTINGS_MODULE=production_settings
|
||||
|
||||
COPY deployment/docker/pretix.bash /usr/local/bin/pretix
|
||||
RUN chmod +x /usr/local/bin/pretix
|
||||
COPY deployment/docker/supervisord.conf /etc/supervisord.conf
|
||||
|
||||
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
|
||||
RUN rm /etc/nginx/sites-enabled/default
|
||||
|
||||
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
COPY src /pretix/src
|
||||
WORKDIR /pretix/src
|
||||
ADD deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
ENV DJANGO_SETTINGS_MODULE production_settings
|
||||
|
||||
RUN pip3 install -r requirements.txt -r requirements/mysql.txt -r requirements/postgres.txt \
|
||||
-r requirements/memcached.txt -r requirements/redis.txt \
|
||||
-r requirements/py34.txt gunicorn
|
||||
RUN chmod +x /usr/local/bin/pretix && \
|
||||
rm /etc/nginx/sites-enabled/default && \
|
||||
pip3 install -U pip wheel setuptools && \
|
||||
cd /pretix/src && \
|
||||
rm -f pretix.cfg && \
|
||||
pip3 install -r requirements.txt -r requirements/mysql.txt -r requirements/postgres.txt \
|
||||
-r requirements/memcached.txt -r requirements/redis.txt \
|
||||
-r requirements/py34.txt gunicorn && \
|
||||
mkdir -p data && \
|
||||
chown -R pretixuser:pretixuser /pretix /data data && \
|
||||
sudo -u pretixuser make production
|
||||
|
||||
RUN mkdir /static && chown -R pretixuser:pretixuser /static /pretix /data
|
||||
USER pretixuser
|
||||
RUN make production
|
||||
|
||||
VOLUME ["/etc/pretix", "/data"]
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["pretix"]
|
||||
CMD ["all"]
|
||||
|
||||
41
README.md
41
README.md
@@ -1,41 +0,0 @@
|
||||
pretix
|
||||
======
|
||||
|
||||
[](http://docs.pretix.eu/en/latest/)
|
||||
[](https://travis-ci.org/pretix/pretix)
|
||||
[](https://coveralls.io/r/pretix/pretix)
|
||||
|
||||
|
||||
Reinventing ticket presales, one bit at a time.
|
||||
|
||||
Project status
|
||||
--------------
|
||||
Most features are present and sufficiently stable. pretix has been in use for multiple event and
|
||||
sold a few thousand tickets so far. There is still a bunch of features to come and there surely is
|
||||
still a bunch of bugs in there, but we consider it stable enough that we use it in production ourselves.
|
||||
|
||||
If you deploy and use pretix, there will be a safe upgrade path for all changes to come. We're planning
|
||||
on an 1.0 release in late 2016 or early 2017. Until then, we take the liberty of changing the code as we
|
||||
like, but we try to keep the changes to documented APIs as small as possible. If you want to use pretix
|
||||
in production or develop a plugin now, I invite you to send me an email so that I can notify you of changes
|
||||
and bugs that require your attention.
|
||||
|
||||
Since very recently we now have an [installation guide](https://docs.pretix.eu/en/latest/admin/installation/index.html)
|
||||
in our documentation.
|
||||
|
||||
Contributing
|
||||
------------
|
||||
If you want to contribute to pretix, please read the [developer documentation](https://docs.pretix.eu/en/latest/development/index.html)
|
||||
in our documentation. If you have any further questions, please do not hesitate to ask!
|
||||
|
||||
License
|
||||
-------
|
||||
The code in this repository is published under the terms of the Apache License.
|
||||
See the LICENSE file for the complete license text.
|
||||
|
||||
This project is maintained by Raphael Michel <mail@raphaelmichel.de>. See the
|
||||
AUTHORS file for a list of all the awesome folks who contributed to this project.
|
||||
|
||||
This project is 100 percent free and open source software. If you are interested in
|
||||
commercial support, hosting services or supporting this project financially, please
|
||||
go to [pretix.eu](https://pretix.eu) or contact Raphael directly.
|
||||
54
README.rst
Normal file
54
README.rst
Normal file
@@ -0,0 +1,54 @@
|
||||
pretix
|
||||
======
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/pretix.svg
|
||||
:target: https://pypi.python.org/pypi/pretix
|
||||
|
||||
.. image:: https://readthedocs.org/projects/pretix/badge/?version=latest
|
||||
:target: https://docs.pretix.eu/en/latest/
|
||||
|
||||
.. image:: https://travis-ci.org/pretix/pretix.svg?branch=master
|
||||
:target: https://travis-ci.org/pretix/pretix
|
||||
|
||||
.. image:: https://codecov.io/gh/pretix/pretix/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/pretix/pretix
|
||||
|
||||
|
||||
|
||||
Reinventing ticket presales, one ticket at a time.
|
||||
|
||||
Project status & release cycle
|
||||
------------------------------
|
||||
|
||||
While there is always a lot to do and improve on, pretix by now has been in use for more than a dozen
|
||||
conferences that sold over ten thousand tickets combined without major problems. We therefore think of
|
||||
pretix as being stable and ready to use.
|
||||
|
||||
If you want to use or extend pretix, we strongly recommend to follow our `blog`_. We will announce all
|
||||
releases there. You can always find the latest stable version on PyPI or in the ``release/X.Y`` branch of
|
||||
this repository. The ``master`` branch contains a development version that we also try to keep stable in
|
||||
the sense that it does not break your data, but its APIs might change without prior notice.
|
||||
|
||||
To get started using pretix on your own server, look at the `installation guide`_ in our documentation.
|
||||
|
||||
This project is 100 percent free and open source software. If you are interested in commercial support,
|
||||
hosting services or supporting this project financially, please go to `pretix.eu`_ or contact us at
|
||||
support@pretix.eu.
|
||||
|
||||
Contributing
|
||||
------------
|
||||
If you want to contribute to pretix, please read the `developer documentation`_
|
||||
in our documentation. If you have any further questions, please do not hesitate to ask!
|
||||
|
||||
License
|
||||
-------
|
||||
The code in this repository is published under the terms of the Apache License.
|
||||
See the LICENSE file for the complete license text.
|
||||
|
||||
This project is maintained by Raphael Michel <mail@raphaelmichel.de>. See the
|
||||
AUTHORS file for a list of all the awesome folks who contributed to this project.
|
||||
|
||||
.. _installation guide: https://docs.pretix.eu/en/latest/admin/installation/index.html
|
||||
.. _developer documentation: https://docs.pretix.eu/en/latest/development/index.html
|
||||
.. _pretix.eu: https://pretix.eu
|
||||
.. _blog: https://pretix.eu/about/en/blog/
|
||||
@@ -22,9 +22,11 @@ http {
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
access_log /var/log/nginx/access.log private;
|
||||
error_log /var/log/nginx/error.log;
|
||||
add_header Referrer-Policy same-origin;
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
@@ -48,8 +50,16 @@ http {
|
||||
expires 7d;
|
||||
access_log off;
|
||||
}
|
||||
location ^~ /media/cachedfiles {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
location ^~ /media/invoices {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
location /static/ {
|
||||
alias /static/;
|
||||
alias /pretix/src/pretix/static.dist/;
|
||||
access_log off;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public";
|
||||
|
||||
@@ -33,7 +33,7 @@ fi
|
||||
|
||||
if [ "$1" == "taskworker" ]; then
|
||||
export C_FORCE_ROOT=True
|
||||
exec celery -A pretix worker -l info
|
||||
exec celery -A pretix.celery_app worker -l info
|
||||
fi
|
||||
|
||||
if [ "$1" == "shell" ]; then
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from pretix.settings import *
|
||||
|
||||
|
||||
LOGGING['handlers']['mail_admins']['include_html'] = True
|
||||
STATIC_ROOT = '/static'
|
||||
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
|
||||
|
||||
@@ -16,7 +16,9 @@ the files found before.
|
||||
|
||||
The file is expected to be in the INI format as specified in the `Python documentation`_.
|
||||
|
||||
The config file may contain the following sections (all settings are optional and have default values).
|
||||
The config file may contain the following sections (all settings are optional and have
|
||||
default values). We suggest that you start from the examples given in one of the
|
||||
installation tutorials.
|
||||
|
||||
pretix settings
|
||||
---------------
|
||||
@@ -100,6 +102,10 @@ Example::
|
||||
``user``, ``password``, ``host``, ``port``
|
||||
Connection details for the database connection. Empty by default.
|
||||
|
||||
``galera``
|
||||
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||
turns on some optimizations/special case handlers. Default: ``False``
|
||||
|
||||
URLs
|
||||
----
|
||||
|
||||
@@ -149,6 +155,8 @@ Example::
|
||||
``admins``
|
||||
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
||||
|
||||
.. _`django-settings`:
|
||||
|
||||
Django settings
|
||||
---------------
|
||||
|
||||
@@ -173,6 +181,11 @@ Example::
|
||||
|
||||
.. WARNING:: Never set this to ``True`` in production!
|
||||
|
||||
``profile``
|
||||
Enable code profiling for a random subset of requests. Disabled by default, see
|
||||
:ref:`perf-monitoring` for details.
|
||||
|
||||
.. _`metrics-settings`:
|
||||
|
||||
Metrics
|
||||
-------
|
||||
@@ -201,6 +214,9 @@ You can use an existing memcached server as pretix's caching backend::
|
||||
|
||||
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
|
||||
|
||||
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
|
||||
shared memcached instance, not multiple ones, because cache invalidations would not be
|
||||
propagated otherwise.
|
||||
|
||||
Redis
|
||||
-----
|
||||
@@ -238,6 +254,19 @@ RabbitMQ might be the better choice if you have a complex, multi-server, high-pe
|
||||
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.
|
||||
|
||||
Sentry
|
||||
------
|
||||
|
||||
pretix has native support for sentry, a tool that you can use to track errors in the
|
||||
application. If you want to use sentry, you need to set a DSN in the configuration file::
|
||||
|
||||
[sentry]
|
||||
dsn=https://<key>:<secret>@sentry.io/<project>
|
||||
|
||||
``dsn``
|
||||
You will be given this value by your sentry installation.
|
||||
|
||||
|
||||
Secret length
|
||||
-------------
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@ Contents:
|
||||
|
||||
installation/index
|
||||
config
|
||||
maintainance
|
||||
|
||||
@@ -74,7 +74,7 @@ redis instance to be running on the same host. To avoid the hassle with network
|
||||
recommend connecting to redis via a unix socket. To enable redis on unix sockets, add the following to your
|
||||
``/etc/redis/redis.conf``::
|
||||
|
||||
unixsocket /tmp/redis.sock
|
||||
unixsocket /var/run/redis/redis.sock
|
||||
unixsocketperm 777
|
||||
|
||||
Now restart redis-server::
|
||||
@@ -111,7 +111,7 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
datadir=/data
|
||||
|
||||
[database]
|
||||
; Replace mysql with psycopg2 for PostgreSQL
|
||||
; Replace mysql with postgresql_psycopg2 for PostgreSQL
|
||||
backend=mysql
|
||||
name=pretix
|
||||
user=pretix
|
||||
@@ -127,23 +127,23 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
host=172.17.0.1
|
||||
|
||||
[redis]
|
||||
location=unix:///tmp/redis.sock?db=0
|
||||
location=unix:///var/run/redis/redis.sock?db=0
|
||||
; Remove the following line if you are unsure about your redis' security
|
||||
; to reduce impact if redis gets compromised.
|
||||
sessions=true
|
||||
|
||||
[celery]
|
||||
backend=redis+socket:///tmp/redis.sock?virtual_host=1
|
||||
broker=redis+socket:///tmp/redis.sock?virtual_host=2
|
||||
backend=redis+socket:///var/run/redis/redis.sock?virtual_host=1
|
||||
broker=redis+socket:///var/run/redis/redis.sock?virtual_host=2
|
||||
|
||||
See :ref:`email configuration <mail-settings>` to learn more about configuring mail features.
|
||||
|
||||
Docker image and service
|
||||
------------------------
|
||||
|
||||
First of all, download the latest pretix image by running::
|
||||
First of all, download the latest stable pretix image by running::
|
||||
|
||||
$ docker pull pretix/standalone:latest
|
||||
$ docker pull pretix/standalone:stable
|
||||
|
||||
We recommend starting the docker container using systemd to make sure it runs correctly after a reboot. Create a file
|
||||
named ``/etc/systemd/system/pretix.service`` with the following content::
|
||||
@@ -160,7 +160,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
|
||||
ExecStart=/usr/bin/docker run --name %n -p 8345:80 \
|
||||
-v /var/pretix-data:/data \
|
||||
-v /etc/pretix:/etc/pretix \
|
||||
-v /tmp/redis.sock:/tmp/redis.sock \
|
||||
-v /var/run/redis:/var/run/redis \
|
||||
-v /var/run/mysqld:/var/run/mysqld \
|
||||
pretix/standalone all
|
||||
ExecStop=/usr/bin/docker stop %n
|
||||
@@ -168,7 +168,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
You can leave the MySQL socket volume out if you're using PostgreSQL. You can now run the following comamnds
|
||||
You can leave the MySQL socket volume out if you're using PostgreSQL. You can now run the following commands
|
||||
to enable and start the service::
|
||||
|
||||
# systemctl daemon-reload
|
||||
@@ -222,6 +222,8 @@ Yay, you are done! You should now be able to reach pretix at https://pretix.your
|
||||
*admin@localhost* with a password of *admin*. Don't forget to change that password! Create an organizer first, then
|
||||
create an event and start selling tickets!
|
||||
|
||||
You should probably read :ref:`maintainance` next.
|
||||
|
||||
Updates
|
||||
-------
|
||||
|
||||
@@ -229,11 +231,33 @@ Updates
|
||||
|
||||
Updates are fairly simple, but require at least a short downtime::
|
||||
|
||||
# docker pull pretix/standalone
|
||||
# docker pull pretix/standalone:stable
|
||||
# systemctl restart pretix.service
|
||||
# docker exec -it pretix.service pretix upgrade
|
||||
|
||||
Restarting the service can take a few seconds, especially if the update requires changes to the database.
|
||||
Replace ``stable`` above with a specific version number like ``1.0`` or with ``latest`` for the development
|
||||
version, if you want to.
|
||||
|
||||
Install a plugin
|
||||
----------------
|
||||
|
||||
To install a plugin, you need to build your own docker image. To do so, create a new directory and place a file
|
||||
named ``Dockerfile`` in it. The Dockerfile could look like this (replace ``pretix-passbook`` with the plugins of your
|
||||
choice)::
|
||||
|
||||
FROM pretix/standalone:stable
|
||||
USER root
|
||||
RUN pip3 install pretix-passbook
|
||||
USER pretixuser
|
||||
RUN cd /pretix/src && make production
|
||||
|
||||
Then, go to that directory and build the image::
|
||||
|
||||
$ docker build -t mypretix
|
||||
|
||||
You can now use that image ``mypretix`` instead of ``pretix/standalone`` in your service file (see above). Be sure
|
||||
to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an update.
|
||||
|
||||
.. _Docker: https://docs.docker.com/engine/installation/linux/debian/
|
||||
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
|
||||
|
||||
@@ -5,51 +5,33 @@ General remarks
|
||||
|
||||
Requirements
|
||||
------------
|
||||
To use pretix, the most minimal setup consists of:
|
||||
To use pretix, you will need the following things:
|
||||
|
||||
* **pretix** and the python packages it depends on
|
||||
|
||||
* An **WSGI application server** (we recommend gunicorn)
|
||||
|
||||
* A periodic task runner, e.g. ``cron``
|
||||
|
||||
To run pretix, you will need **at least Python 3.4**. We only recommend installations on **Linux**, Windows is not
|
||||
officially supported (but might work).
|
||||
* **A database**. This needs to be a SQL-based that is supported by Django. We highly recommend to either
|
||||
go for **PostgreSQL** or **MySQL/MariaDB**. If you do not provide one, pretix will run on SQLite, which is useful
|
||||
for evaluation and development purposes.
|
||||
|
||||
Optional requirements
|
||||
---------------------
|
||||
.. warning:: Do not ever use SQLite in production. It will break.
|
||||
|
||||
pretix is built in a way that makes many of the following requirements optional. However, performance or security might
|
||||
be very low if you skip some of them, therefore they are only partly optional.
|
||||
* A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix
|
||||
is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much
|
||||
faster. Also, you need a proxying web server in front to provide SSL encryption.
|
||||
|
||||
Database
|
||||
A good SQL-based database to run on that is supported by Django. We highly recommend to either go for **PostgreSQL**
|
||||
or **MySQL/MariaDB**.
|
||||
If you do not provide one, pretix will run on SQLite, which is useful for evaluation and development purposes.
|
||||
.. warning:: Do not ever run without SSL in production. Your users deserve encrypted connections and thanks to
|
||||
`Let's Encrypt`_ SSL certificates can be obtained for free these days.
|
||||
|
||||
.. warning:: Do not ever use SQLite in production. It will break.
|
||||
* A **redis** server. This will be used for caching, session storage and task queuing.
|
||||
|
||||
Reverse proxy
|
||||
pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix is capable of
|
||||
doing this, having this handled by a proper web server like **nginx** or **Apache** will be much faster. Also, you
|
||||
need a proxying web server in front to provide SSL encryption.
|
||||
.. warning:: pretix can run without redis, however this is only intended for development and should never be
|
||||
used in production.
|
||||
|
||||
.. warning:: Do not ever run without SSL in production. Your users deserve encrypted connections and thanks to
|
||||
`Let's Encrypt`_ SSL certificates can be obtained for free these days.
|
||||
|
||||
Task worker
|
||||
When pretix has to do heavy stuff, it is better to offload it into a background process instead of having the
|
||||
users connection wait. Therefore pretix provides a background service that can be used to work on those
|
||||
longer-running tasks.
|
||||
|
||||
This requires at least Redis (and optionally RabbitMQ).
|
||||
|
||||
Redis
|
||||
If you provide a redis instance, pretix is able to make use of it in the three following ways:
|
||||
|
||||
* Caching
|
||||
* Fast session storage
|
||||
* Queuing and result storage for the task worker queue
|
||||
|
||||
RabbitMQ
|
||||
RabbitMQ can be used as a more advanced queue manager for the task workers if necessary.
|
||||
* Optionally: RabbitMQ or memcached. Both of them might provide speedups, but if they are not present,
|
||||
redis will take over their job.
|
||||
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
|
||||
@@ -38,10 +38,10 @@ Unix user
|
||||
|
||||
As we do not want to run pretix as root, we first create a new unprivileged user::
|
||||
|
||||
# sudo adduser pretix --disabled-password --home /var/pretix
|
||||
# adduser pretix --disabled-password --home /var/pretix
|
||||
|
||||
In this guide, all code lines prepended with a ``#`` symbol are commands that you need to execute on your server as
|
||||
``root`` user; all lines prepended with a ``$`` symbol should be run by the unprivileged user.
|
||||
``root`` user (e.g. using ``sudo``); all lines prepended with a ``$`` symbol should be run by the unprivileged user.
|
||||
|
||||
Database
|
||||
--------
|
||||
@@ -82,7 +82,7 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
datadir=/var/pretix/data
|
||||
|
||||
[database]
|
||||
; Replace mysql with psycopg2 for PostgreSQL
|
||||
; Replace mysql with postgresql_psycopg2 for PostgreSQL
|
||||
backend=mysql
|
||||
name=pretix
|
||||
user=pretix
|
||||
@@ -100,13 +100,13 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
sessions=true
|
||||
|
||||
[celery]
|
||||
backend=redis://127.0.0.1?virtual_host=1
|
||||
broker=redis://127.0.0.1?virtual_host=2
|
||||
backend=redis://127.0.0.1/1
|
||||
broker=redis://127.0.0.1/2
|
||||
|
||||
See :ref:`email configuration <mail-settings>` to learn more about configuring mail features.
|
||||
|
||||
Install pretix from source
|
||||
--------------------------
|
||||
Install pretix from PyPI
|
||||
------------------------
|
||||
|
||||
Now we will install pretix itself. The following steps are to be executed as the ``pretix`` user. Before we
|
||||
actually install pretix, we will create a virtual environment to isolate the python packages from your global
|
||||
@@ -116,14 +116,13 @@ python installation::
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U pip setuptools wheel
|
||||
|
||||
We now clone pretix and install its Python dependencies (replace ``mysql`` with ``postgres`` if you're running
|
||||
PostgreSQL)::
|
||||
We now install pretix, its direct dependencies and gunicorn. Replace ``mysql`` with ``postgres`` in the following
|
||||
command if you're running PostgreSQL::
|
||||
|
||||
(venv)$ git clone https://github.com/pretix/pretix.git /var/pretix/source
|
||||
(venv)$ cd /var/pretix/source/src
|
||||
(venv)$ pip3 install -r requirements.txt -r requirements/mysql.txt \
|
||||
-r requirements/redis.txt \
|
||||
-r requirements/py34.txt gunicorn
|
||||
(venv)$ pip3 install "pretix[mysql]" gunicorn
|
||||
|
||||
If you are running Python 3.4, you also need to ``pip3 install typing``. This is not required on 3.5 or newer.
|
||||
You can find out your Python version using ``python -V``.
|
||||
|
||||
We also need to create a data directory::
|
||||
|
||||
@@ -131,8 +130,8 @@ We also need to create a data directory::
|
||||
|
||||
Finally, we compile static files and translation data and create the database structure::
|
||||
|
||||
(venv)$ make production
|
||||
(venv)$ python manage.py migrate
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
|
||||
|
||||
Start pretix as a service
|
||||
@@ -154,7 +153,7 @@ named ``/etc/systemd/system/pretix-web.service`` with the following content::
|
||||
--name pretix --workers 5 \
|
||||
--max-requests 1200 --max-requests-jitter 50 \
|
||||
--log-level=info --bind=127.0.0.1:8345
|
||||
WorkingDirectory=/var/pretix/source/src
|
||||
WorkingDirectory=/var/pretix
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
@@ -171,8 +170,8 @@ For background tasks we need a second service ``/etc/systemd/system/pretix-worke
|
||||
Group=pretix
|
||||
Environment="VIRTUAL_ENV=/var/pretix/venv"
|
||||
Environment="PATH=/var/pretix/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
ExecStart=/var/pretix/venv/bin/celery -A pretix worker -l info
|
||||
WorkingDirectory=/var/pretix/source/src
|
||||
ExecStart=/var/pretix/venv/bin/celery -A pretix.celery_app worker -l info
|
||||
WorkingDirectory=/var/pretix
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
@@ -191,7 +190,7 @@ Cronjob
|
||||
You need to set up a cronjob that runs the management command ``runperiodic``. The exact interval is not important
|
||||
but should be something between every minute and every hour. You could for example configure cron like this::
|
||||
|
||||
15,45 * * * * export PATH=/var/pretix/venv/bin:$PATH && cd /var/pretix/source/src && ./manage.py runperiodic
|
||||
15,45 * * * * export PATH=/var/pretix/venv/bin:$PATH && cd /var/pretix && python -m pretix runperiodic
|
||||
|
||||
The cronjob should run as the ``pretix`` user (``crontab -e -u pretix``).
|
||||
|
||||
@@ -214,6 +213,9 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
ssl_certificate /path/to/cert.chain.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
add_header Referrer-Options same-origin;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8345/;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -227,14 +229,25 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location ^~ /media/cachedfiles {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
location ^~ /media/invoices {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /var/pretix/source/src/pretix/static.dist/;
|
||||
alias /var/pretix/venv/lib/python3.5/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
|
||||
above with your python version.
|
||||
|
||||
We recommend reading about setting `strong encryption settings`_ for your web server.
|
||||
|
||||
@@ -245,6 +258,8 @@ Yay, you are done! You should now be able to reach pretix at https://pretix.your
|
||||
*admin@localhost* with a password of *admin*. Don't forget to change that password! Create an organizer first, then
|
||||
create an event and start selling tickets!
|
||||
|
||||
You should probably read :ref:`maintainance` next.
|
||||
|
||||
Updates
|
||||
-------
|
||||
|
||||
@@ -254,16 +269,27 @@ To upgrade to a new pretix release, pull the latest code changes and run the fol
|
||||
``mysql`` with ``postgres`` if necessary)::
|
||||
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ cd /var/pretix/source/src
|
||||
(venv)$ git pull origin master
|
||||
(venv)$ pip3 install -r requirements.txt -r requirements/mysql.txt \
|
||||
-r requirements/redis.txt \
|
||||
-r requirements/py34.txt gunicorn
|
||||
(venv)$ python manage.py migrate
|
||||
(venv)$ make production
|
||||
(venv)$ python manage.py updatestyles
|
||||
(venv)$ pip3 install -U pretix[mysql] gunicorn
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
(venv)$ python -m pretix updatestyles
|
||||
# systemctl restart pretix-web pretix-worker
|
||||
|
||||
|
||||
Install a plugin
|
||||
----------------
|
||||
|
||||
To install a plugin, just use ``pip``! Depending on the plugin, you should probably apply database migrations and
|
||||
rebuild the static files afterwards. Replace ``pretix-passbook`` with the plugin of your choice in the following
|
||||
example::
|
||||
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install pretix-passbook
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
# systemctl restart pretix-web pretix-worker
|
||||
|
||||
|
||||
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
|
||||
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
|
||||
99
doc/admin/maintainance.rst
Normal file
99
doc/admin/maintainance.rst
Normal file
@@ -0,0 +1,99 @@
|
||||
.. highlight:: ini
|
||||
|
||||
.. _`maintainance`:
|
||||
|
||||
Backups and Monitoring
|
||||
======================
|
||||
|
||||
If you host your own pretix instance, you also need to care about the availability
|
||||
of your service and the safety of your data yourself. This page gives you some
|
||||
information that you might need to do so properly.
|
||||
|
||||
Backups
|
||||
-------
|
||||
|
||||
There are essentially two things which you should create backups of:
|
||||
|
||||
Database
|
||||
Your SQL database (MySQL or PostgreSQL). This is critical and you should **absolutely
|
||||
always create automatic backups of your database**. There are tons of tutorials on the
|
||||
internet on how to do this, and the exact process depends on the choice of your database.
|
||||
For MySQL, see ``mysqldump`` and for PostgreSQL, see the ``pg_dump`` tool. You probably
|
||||
want to create a cronjob that does the backups for you on a regular schedule.
|
||||
|
||||
Data directory
|
||||
The data directory of your pretix configuration might contain some things that you should
|
||||
back up. If you did not specify a secret in your config file, back up the ``.secret`` text
|
||||
file in the data directory. If you lose your secret, all currently active user sessions,
|
||||
password reset links and similar things will be rendered invalid. Also, you probably want
|
||||
to backup the ``media`` subdirectory of the data directory which contains all user-uploaded
|
||||
and generated files. This includes files you could in theory regenerate (ticket downloads)
|
||||
but also files that you might be legally required to keep (invoice PDFs) or files that you
|
||||
would need to re-upload (event logos, product pictures, etc.). It is up to you if you
|
||||
create regular backups of this data, but we strongly advise you to do so. You can create
|
||||
backups e.g. using ``rsync``. There is a lot of information on the internet on how to create
|
||||
backups of folders on a Linux machine.
|
||||
|
||||
There is no need to create backups of the redis database, if you use it. We only use it for
|
||||
non-critical, temporary or cached data.
|
||||
|
||||
Uptime monitoring
|
||||
-----------------
|
||||
|
||||
To monitor whether your pretix instance is running, you can issue a GET request to
|
||||
``https://pretix.mydomain.com/healthcheck/``. This endpoint tests if the connection to the
|
||||
database, to the configured cache and to redis (if used) is working correctly. If everything
|
||||
appears to work fine, an empty response with status code ``200`` is returned.
|
||||
If there is a problem, a status code in the ``5xx`` range will be returned.
|
||||
|
||||
.. _`perf-monitoring`:
|
||||
|
||||
Performance monitoring
|
||||
----------------------
|
||||
|
||||
If you to generate detailled performance statistics of your pretix installation, there is an
|
||||
endpoint at ``https://pretix.mydomain.com/metrics`` (no slash at the end) which returns a
|
||||
number of values in the text format understood by monitoring tools like Prometheus_. This data
|
||||
is only collected and exposed if you enable it in the :ref:`metrics-settings` section of your
|
||||
pretix configuration. You can also configure basic auth credentials there to protect your
|
||||
statistics against unauthorized access. The data is temporarily collected in redis, so the
|
||||
performance impact of this feature depends on the connection to your redis database.
|
||||
|
||||
Currently, mostly response times of HTTP requests and background tasks are exposed.
|
||||
|
||||
If you want to go even further, you can set the ``profile`` option in the :ref:`django-settings`
|
||||
section to a value between 0 and 1. If you set it for example to 0.1, then 10% of your requests
|
||||
(randomly selected) will be run with cProfile_ activated. The profiling results will be saved
|
||||
to your data directory. As this might impact performance significantly and writes a lot of data
|
||||
to disk, we recommend to only enable it for a small number of requests -- and only if you are
|
||||
really interested in the results.
|
||||
|
||||
Available metrics
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
The metrics available in pretix follow the standard `metric types`_ from the Prometheus world.
|
||||
Currently, the following metrics are exported:
|
||||
|
||||
pretix_view_requests_total
|
||||
Counter. Counts requests to Django views, labeled with the resolved ``url_name``, the used
|
||||
HTTP ``method`` and the ``status_code`` returned.
|
||||
|
||||
pretix_view_durations_seconds
|
||||
Histogram. Measures duration of requests to Django views, labeled with the resolved
|
||||
``url_name``, the used HTTP ``method`` and the ``status_code`` returned.
|
||||
|
||||
pretix_task_runs_total
|
||||
Counter. Counts executions of background tasks, labeled with the ``task_name`` and the
|
||||
``status``. The latter can be ``success``, ``error`` or ``expected-error``.
|
||||
|
||||
pretix_task_duration_seconds
|
||||
Histogram. Measures duration of successful background task executions, labeled with the
|
||||
``task_name``.
|
||||
|
||||
pretix_model_instances
|
||||
Gauge. Measures number of instances of a certain model within the database, labeled with
|
||||
the ``model`` name.
|
||||
|
||||
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
||||
.. _Prometheus: https://prometheus.io/
|
||||
.. _cProfile: https://docs.python.org/3/library/profile.html
|
||||
@@ -38,6 +38,7 @@ extensions = [
|
||||
'sphinx.ext.doctest',
|
||||
'sphinx.ext.todo',
|
||||
'sphinx.ext.coverage',
|
||||
'sphinxcontrib.httpdomain',
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
|
||||
@@ -11,7 +11,7 @@ Core
|
||||
----
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task
|
||||
:members: periodic_task, event_live_issues, event_copy_data
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
@@ -19,13 +19,13 @@ Order events
|
||||
There are multiple signals that will be sent out in the ordering cycle:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: order_paid, order_placed
|
||||
:members: validate_cart, order_paid, order_placed
|
||||
|
||||
Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, footer_links, front_page_top, front_page_bottom
|
||||
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, checkout_confirm_messages
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
@@ -47,11 +47,11 @@ Backend
|
||||
-------
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: nav_event, html_head, quota_detail_html
|
||||
:members: nav_event, html_head, quota_detail_html, nav_topbar, nav_global, nav_organizer
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: logentry_display
|
||||
:members: logentry_display, requiredaction_display
|
||||
|
||||
Vouchers
|
||||
""""""""
|
||||
|
||||
@@ -70,8 +70,6 @@ The provider class
|
||||
|
||||
.. automethod:: is_allowed
|
||||
|
||||
.. automethod:: is_allowed_for_order
|
||||
|
||||
.. autoattribute:: payment_form_fields
|
||||
|
||||
.. automethod:: checkout_prepare
|
||||
|
||||
@@ -42,6 +42,13 @@ configuration class. The metadata class must define the following attributes:
|
||||
``description`` (``str``):
|
||||
A more verbose description of what your plugin does.
|
||||
|
||||
``visible`` (``bool``):
|
||||
``True`` by default, can hide a plugin so it cannot be normally activated.
|
||||
|
||||
``restricted`` (``bool``):
|
||||
``False`` by default, restricts a plugin such that it can only be enabled for an event
|
||||
by system administrators / superusers.
|
||||
|
||||
A working example would be::
|
||||
|
||||
# file: pretix/plugins/timerestriction/__init__.py
|
||||
@@ -57,6 +64,8 @@ A working example would be::
|
||||
name = _("PayPal")
|
||||
author = _("the pretix team")
|
||||
version = '1.0.0'
|
||||
visible = True
|
||||
restricted = False
|
||||
description = _("This plugin allows you to receive payments via PayPal")
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +64,6 @@ The output class
|
||||
|
||||
.. automethod:: generate
|
||||
|
||||
.. autoattribute:: download_button_text
|
||||
.. automethod:: generate_order
|
||||
|
||||
.. autoattribute:: download_button_icon
|
||||
.. autoattribute:: download_button_text
|
||||
|
||||
@@ -18,8 +18,9 @@ If you improved pretix in any way, we'd be very happy if you contribute it
|
||||
back to the main code base! The easiest way to do so is to `create a pull request`_
|
||||
on our `GitHub repository`_.
|
||||
|
||||
Before you do so, please `squash all your changes`_ into one single commit. Please
|
||||
use the test suite to check whether your changes break any existing features and run
|
||||
We recommend that you create a feature branch for every issue you work on so the changes can
|
||||
be reviewed individually.
|
||||
Please use the test suite to check whether your changes break any existing features and run
|
||||
the code style checks to confirm you are consistent with pretix's coding style. You'll
|
||||
find instructions on this in the :ref:`checksandtests` section of the development setup guide.
|
||||
|
||||
@@ -34,4 +35,3 @@ Again: If you get stuck, do not hesitate to contact any of us, or Raphael person
|
||||
|
||||
.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
|
||||
.. _GitHub repository: https://github.com/pretix/pretix
|
||||
.. _squash all your changes: https://davidwalsh.name/squash-commits-git
|
||||
|
||||
@@ -8,12 +8,12 @@ Python code
|
||||
|
||||
Use `flake8`_ to check for conformance problems. The project includes a setup.cfg file
|
||||
with a default configuration for flake8 that excludes migrations and other non-relevant
|
||||
code parts. It also silences a few checks, e.g. ``N802`` (function names should be lowercase)
|
||||
and increases the maximum line length to more than 79 characters. **However** you should
|
||||
code parts. It also silences a few checks, e.g. ``N802`` (function names should be lowercase)
|
||||
and increases the maximum line length to more than 79 characters. **However** you should
|
||||
still name all your functions lowercase [#f1]_ and keep your lines short when possible.
|
||||
|
||||
* Our build server will reject all code violating other flake8 checks than the following:
|
||||
|
||||
|
||||
* E123: closing bracket does not match indentation of opening bracket’s line
|
||||
* F403: ``from module import *`` used; unable to detect undefined names
|
||||
* F401: module imported but unused
|
||||
|
||||
@@ -6,116 +6,18 @@ One of pretix's major selling points is its multi-language capability. We make h
|
||||
way to translate *user-generated content*. In our case, we need to translate strings like product names
|
||||
or event descriptions, so we need event organizers to be able to fill in all fields in multiple languages.
|
||||
|
||||
.. note:: Implementing object-level translation in a relational database is a task that requires some difficult
|
||||
trade-off. We decided for a design that is not elegant on the database level (as it violates the `1NF`_) and
|
||||
makes searching in the respective database fields very hard, but allows for a simple design on the ORM level
|
||||
and adds only minimal performance overhead.
|
||||
For this purpose, we use ``django-i18nfield`` which started out as part of pretix and then got refactored into
|
||||
its own library. It has comprehensive documentation on how to work with its `strings`_, `database fields`_ and
|
||||
`forms`_.
|
||||
|
||||
All classes and functions introduced in this document are located in ``pretix.base.i18n`` if not stated otherwise.
|
||||
|
||||
Database storage
|
||||
----------------
|
||||
|
||||
pretix provides two custom model field types that allow you to work with localized strings: ``I18nCharField`` and
|
||||
``I18nTextField``. Both of them are stored in the database as a ``TextField`` internally, they only differ in the
|
||||
default form widget that is used by ``ModelForm``.
|
||||
|
||||
As pretix does not use these fields in places that need to be searched, the negative performance impact when searching
|
||||
and indexing these fields in negligible, as mentioned above. Lookups are currently not even implemented on these
|
||||
fields. In the database, the strings will be stored as a JSON-encoded mapping of language codes to strings.
|
||||
|
||||
Whenever you interact with those fields, you will either provide or receive an instance of the following class:
|
||||
|
||||
.. autoclass:: pretix.base.i18n.LazyI18nString
|
||||
:members: __init__, localize, __str__
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
The following examples are given to illustrate how you can work with ``LazyI18nString``.
|
||||
|
||||
.. testsetup:: *
|
||||
|
||||
from pretix.base.i18n import LazyI18nString, language
|
||||
|
||||
To create a LazyI18nString, we can cast a simple string:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> naive = LazyI18nString('Naive untranslated string')
|
||||
>>> naive
|
||||
<LazyI18nString: 'Naive untranslated string'>
|
||||
|
||||
Or we can provide a dictionary with multiple translations:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> translated = LazyI18nString({'en': 'English String', 'de': 'Deutscher String'})
|
||||
|
||||
We can use ``localize`` to get the string in a specific language:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> translated.localize('de')
|
||||
'Deutscher String'
|
||||
|
||||
>>> translated.localize('en')
|
||||
'English String'
|
||||
|
||||
If we try a locale that does not exist for the string, we might get a it either in a similar locale or in the system's default language:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> translated.localize('de-AT')
|
||||
'Deutscher String'
|
||||
|
||||
>>> translated.localize('zh')
|
||||
'English String'
|
||||
|
||||
>>> naive.localize('de')
|
||||
'Naive untranslated string'
|
||||
|
||||
If we cast a ``LazyI18nString`` to ``str``, ``localize`` will be called with the currently active language:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> from django.utils import translation
|
||||
>>> str(translated)
|
||||
'English String'
|
||||
>>> translation.activate('de')
|
||||
>>> str(translated)
|
||||
'Deutscher String'
|
||||
|
||||
You can also use our handy context manager to set the locale temporarily:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> translation.activate('en')
|
||||
>>> with language('de'):
|
||||
... str(translated)
|
||||
'Deutscher String'
|
||||
>>> str(translated)
|
||||
'English String'
|
||||
|
||||
Forms
|
||||
-----
|
||||
|
||||
We provide i18n-aware versions of the respective form fields and widgets: ``I18nFormField`` with the ``I18nTextInput``
|
||||
and ``I18nTextarea`` widgets. They transparently allow you to use ``LazyI18nString`` values in forms and render text
|
||||
inputs for multiple languages.
|
||||
|
||||
.. autoclass:: pretix.base.i18n.I18nFormField
|
||||
|
||||
To easily limit the displayed languages to the languages relevant to an event, there is a custom ``ModelForm`` subclass
|
||||
that deals with this for you:
|
||||
|
||||
.. autoclass:: pretix.base.forms.I18nModelForm
|
||||
|
||||
There are equivalents for ``BaseModelFormSet`` and ``BaseInlineFormSet``:
|
||||
|
||||
.. autoclass:: pretix.base.forms.I18nFormSet
|
||||
|
||||
.. autoclass:: pretix.base.forms.I18nInlineFormSet
|
||||
For backwards-compatibility with older parts of pretix' code base and older plugins, ``pretix.base.forms`` still
|
||||
contains a number of forms that are equivalent in name and usage to their counterparts in ``i18nfield.forms`` with
|
||||
the difference that they take an ``event`` keyword argument and then set the ``locales`` argument based on
|
||||
``event.settings.get('locales')``.
|
||||
|
||||
Useful utilities
|
||||
----------------
|
||||
@@ -135,4 +37,6 @@ action that causes the mail to be sent.
|
||||
|
||||
.. _translation features: https://docs.djangoproject.com/en/1.9/topics/i18n/translation/
|
||||
.. _GNU gettext: https://www.gnu.org/software/gettext/
|
||||
.. _1NF: https://en.wikipedia.org/wiki/First_normal_form
|
||||
.. _strings: https://django-i18nfield.readthedocs.io/en/latest/strings.html
|
||||
.. _database fields: https://django-i18nfield.readthedocs.io/en/latest/quickstart.html
|
||||
.. _forms: https://django-i18nfield.readthedocs.io/en/latest/forms.html
|
||||
|
||||
@@ -29,6 +29,9 @@ Organizers and events
|
||||
.. autoclass:: pretix.base.models.EventPermission
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.RequiredAction
|
||||
:members:
|
||||
|
||||
|
||||
Items
|
||||
-----
|
||||
@@ -64,6 +67,9 @@ Carts and Orders
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.QuestionAnswer
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.Checkin
|
||||
:members:
|
||||
|
||||
Logging
|
||||
|
||||
@@ -2,7 +2,10 @@ Settings storage
|
||||
================
|
||||
|
||||
pretix is highly configurable and therefore needs to store a lot of per-event and per-organizer settings.
|
||||
Those settings are stored in the database and accessed through a ``SettingsProxy`` instance. You can obtain
|
||||
For this purpose, we use `django-hierarkey`_ which started out as part of pretix and then got refactored into
|
||||
its own library. It has a comprehensive `documentation`_ which you should read if you work with settings in pretix.
|
||||
|
||||
The settings are stored in the database and accessed through a ``HierarkeyProxy`` instance. You can obtain
|
||||
such an instance from any event or organizer model instance by just accessing ``event.settings`` or
|
||||
``organizer.settings``, respectively.
|
||||
|
||||
@@ -17,12 +20,10 @@ includes serializers for serializing the following types:
|
||||
|
||||
In code, we recommend to always use the ``.get()`` method on the settings object to access a value, but for
|
||||
convenience in templates you can also access settings values at ``settings[name]`` and ``settings.name``.
|
||||
|
||||
.. autoclass:: pretix.base.settings.SettingsProxy
|
||||
:members: get, set, delete, freeze
|
||||
See the hierarkey `documentation`_ for more information.
|
||||
|
||||
To avoid naming conflicts, plugins are requested to prefix all settings they use with the name of the plugin
|
||||
or something unique, e.g. ``payment.paypal.api_key``. To reduce redundant typing of this prefix, we provide
|
||||
or something unique, e.g. ``payment_paypal_api_key``. To reduce redundant typing of this prefix, we provide
|
||||
another helper class:
|
||||
|
||||
.. autoclass:: pretix.base.settings.SettingsSandbox
|
||||
@@ -33,10 +34,10 @@ you will just be passed a sandbox object with a prefix generated from your provi
|
||||
Forms
|
||||
-----
|
||||
|
||||
We also provide a base class for forms that allow the modification of settings:
|
||||
Hierarkey also provides a base class for forms that allow the modification of settings. pretix contains a
|
||||
subclass that also adds suport for internationalized fields:
|
||||
|
||||
.. autoclass:: pretix.base.forms.SettingsForm
|
||||
:members: save
|
||||
|
||||
You can simply use it like this::
|
||||
|
||||
@@ -51,3 +52,17 @@ You can simply use it like this::
|
||||
help_text=_("The number of days after placing an order the user has to pay to "
|
||||
"preserve his reservation."),
|
||||
)
|
||||
|
||||
Defaults in plugins
|
||||
-------------------
|
||||
|
||||
Plugins can add custom hardcoded defaults in the following way::
|
||||
|
||||
from pretix.base.settings import settings_hierarkey
|
||||
|
||||
settings_hierarkey.add_default('key', 'value', type)
|
||||
|
||||
Make sure that you include this code in a module that is imported at app loading time.
|
||||
|
||||
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
|
||||
.. _documentation: https://django-hierarkey.readthedocs.io/en/latest/
|
||||
@@ -15,8 +15,12 @@ External Dependencies
|
||||
* Python 3.4 or newer
|
||||
* ``pip`` for Python 3 (Debian package: ``python3-pip``)
|
||||
* ``pyvenv`` for Python 3 (Debian package: ``python3-venv``)
|
||||
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
|
||||
* ``libffi`` (Debian package: ``libffi-dev``)
|
||||
* ``libssl`` (Debian package: ``libssl-dev``)
|
||||
* ``libxml2`` (Debian package ``libxml2-dev``)
|
||||
* ``libxslt`` (Debian package ``libxslt1-dev``)
|
||||
* ``msgfmt`` (Debian package ``gettext``)
|
||||
* ``git``
|
||||
|
||||
Your local python environment
|
||||
@@ -77,7 +81,11 @@ and head to http://localhost:8000/
|
||||
|
||||
As we did not implement an overall front page yet, you need to go directly to
|
||||
http://localhost:8000/control/ for the admin view or, if you imported the test
|
||||
data as suggested above, to the event page at http://localhost:8000/mrmcd/2015/
|
||||
data as suggested above, to the event page at http://localhost:8000/bigevents/2017/
|
||||
|
||||
.. note:: If you want the development server to listen on a different interface or
|
||||
port (for example because you develop on `pretixdroid`_), you can check
|
||||
`Django's documentation`_ for more options.
|
||||
|
||||
.. _`checksandtests`:
|
||||
|
||||
@@ -118,8 +126,8 @@ Then execute ``python -m smtpd -n -c DebuggingServer localhost:1025``.
|
||||
|
||||
Working with translations
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
If you want to translate new strings that are not yet known to the translation system,
|
||||
you can use the following command to scan the source code for strings to be translated
|
||||
If you want to translate new strings that are not yet known to the translation system,
|
||||
you can use the following command to scan the source code for strings to be translated
|
||||
and update the ``*.po`` files accordingly::
|
||||
|
||||
make localegen
|
||||
@@ -144,3 +152,7 @@ To build the documentation, run the following command from the ``doc/`` director
|
||||
make html
|
||||
|
||||
You will now find the generated documentation in the ``doc/_build/html/`` subdirectory.
|
||||
|
||||
|
||||
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
|
||||
.. _pretixdroid: https://github.com/pretix/pretixdroid
|
||||
|
||||
@@ -8,4 +8,5 @@ Contents:
|
||||
|
||||
admin/index
|
||||
development/index
|
||||
plugins/index
|
||||
|
||||
|
||||
13
doc/plugins/index.rst
Normal file
13
doc/plugins/index.rst
Normal file
@@ -0,0 +1,13 @@
|
||||
Plugin documentation
|
||||
====================
|
||||
|
||||
This part of the documentation contains information about available plugins
|
||||
that can be used to extend pretix's functionality.
|
||||
If you want to **create** a plugin, please go to the
|
||||
:ref:`Developer documentation <pluginsetup>` instead.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
list
|
||||
pretixdroid
|
||||
36
doc/plugins/list.rst
Normal file
36
doc/plugins/list.rst
Normal file
@@ -0,0 +1,36 @@
|
||||
List of plugins
|
||||
===============
|
||||
|
||||
The following plugins are shipped with pretix and are supported in the same
|
||||
ways that pretix itself is:
|
||||
|
||||
* Bank transfer
|
||||
* PayPal
|
||||
* Stripe
|
||||
* Check-in lists
|
||||
* pretixdroid
|
||||
* Report exporter
|
||||
* Send out emails
|
||||
* Statistics
|
||||
* PDF ticket output
|
||||
|
||||
The following plugins are not shipped with pretix but are maintained by the
|
||||
same team:
|
||||
|
||||
* `SEPA direct debit`_
|
||||
* `Pages`_
|
||||
* `Passbook/Wallet ticket output`_
|
||||
* `Cartshare`_
|
||||
|
||||
The following plugins are from independent third-party authors, so we can make
|
||||
no statements about their stability:
|
||||
|
||||
* `esPass ticket output`_
|
||||
* `IcePay integration`_
|
||||
|
||||
.. _SEPA direct debit: https://github.com/pretix/pretix-sepadebit
|
||||
.. _Passbook/Wallet ticket output: https://github.com/pretix/pretix-passbook
|
||||
.. _Cartshare: https://github.com/pretix/pretix-cartshare
|
||||
.. _Pages: https://github.com/pretix/pretix-pages
|
||||
.. _esPass ticket output: https://github.com/esPass/pretix-espass
|
||||
.. _IcePay integration: https://github.com/chotee/pretix-icepay
|
||||
174
doc/plugins/pretixdroid.rst
Normal file
174
doc/plugins/pretixdroid.rst
Normal file
@@ -0,0 +1,174 @@
|
||||
pretixdroid HTTP API
|
||||
====================
|
||||
|
||||
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
|
||||
uses to communicate with the pretix server.
|
||||
|
||||
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
|
||||
|
||||
Redeems a ticket, i.e. checks the user in.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /pretixdroid/api/demoorga/democon/redeem/?key=ABCDEF HTTP/1.1
|
||||
Host: demo.pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3
|
||||
|
||||
**Example successful response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "ok"
|
||||
"version": 2
|
||||
}
|
||||
|
||||
**Example error response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "already_redeemed",
|
||||
"version": 2
|
||||
}
|
||||
|
||||
Possible error reasons:
|
||||
|
||||
* ``unpaid`` - Ticket is not paid for or has been refunded
|
||||
* ``already_redeemed`` - Ticket already has been redeemed
|
||||
* ``unknown_ticket`` - Secret does not match a ticket in the database
|
||||
|
||||
:query key: Secret API key
|
||||
:statuscode 200: Valid request
|
||||
:statuscode 404: Unknown organizer or event
|
||||
:statuscode 403: Invalid authorization key
|
||||
|
||||
.. http:get:: /pretixdroid/api/(organizer)/(event)/search/
|
||||
|
||||
Searches for a ticket.
|
||||
At most 25 results will be returned. **Queries with less than 4 characters will always return an empty result set.**
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /pretixdroid/api/demoorga/democon/search/?key=ABCDEF&query=Peter HTTP/1.1
|
||||
Host: demo.pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
||||
"order": "ABCE6",
|
||||
"item": "Standard ticket",
|
||||
"variation": null,
|
||||
"attendee_name": "Peter Higgs",
|
||||
"redeemed": false,
|
||||
"paid": true
|
||||
},
|
||||
...
|
||||
],
|
||||
"version": 2
|
||||
}
|
||||
|
||||
:query query: Search query
|
||||
:query key: Secret API key
|
||||
:statuscode 200: Valid request
|
||||
:statuscode 404: Unknown organizer or event
|
||||
:statuscode 403: Invalid authorization key
|
||||
|
||||
.. http:get:: /pretixdroid/api/(organizer)/(event)/status/
|
||||
|
||||
Returns status information, such as the total number of tickets and the
|
||||
number of performed checkins.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /pretixdroid/api/demoorga/democon/status/?key=ABCDEF HTTP/1.1
|
||||
Host: demo.pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"checkins": 17,
|
||||
"total": 42,
|
||||
"version": 2,
|
||||
"event": {
|
||||
"name": "Demo Converence",
|
||||
"slug": "democon",
|
||||
"date_from": "2016-12-27T17:00:00Z",
|
||||
"date_to": "2016-12-30T18:00:00Z",
|
||||
"timezone": "UTC",
|
||||
"url": "https://demo.pretix.eu/demoorga/democon/",
|
||||
"organizer": {
|
||||
"name": "Demo Organizer",
|
||||
"slug": "demoorga"
|
||||
},
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"name": "T-Shirt",
|
||||
"id": 1,
|
||||
"checkins": 1,
|
||||
"admission": False,
|
||||
"total": 1,
|
||||
"variations": [
|
||||
{
|
||||
"name": "Red",
|
||||
"id": 1,
|
||||
"checkins": 1,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"name": "Blue",
|
||||
"id": 2,
|
||||
"checkins": 4,
|
||||
"total": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ticket",
|
||||
"id": 2,
|
||||
"checkins": 15,
|
||||
"admission": True,
|
||||
"total": 22,
|
||||
"variations": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query key: Secret API key
|
||||
:statuscode 200: Valid request
|
||||
:statuscode 404: Unknown organizer or event
|
||||
:statuscode 403: Invalid authorization key
|
||||
|
||||
.. _pretixdroid Android app: https://github.com/pretix/pretixdroid
|
||||
@@ -1,3 +1,4 @@
|
||||
-r ../src/requirements.txt
|
||||
sphinx
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
|
||||
3
src/.gitignore
vendored
3
src/.gitignore
vendored
@@ -7,5 +7,4 @@ build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
*.bak
|
||||
static/jsi18n/
|
||||
|
||||
pretix/static/jsi18n/
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
include LICENSE
|
||||
include README.rst
|
||||
recursive-include pretix/static *
|
||||
recursive-include pretix/static.dist *
|
||||
recursive-include pretix/locale *
|
||||
recursive-include pretix/base/templates *
|
||||
recursive-include pretix/control/templates *
|
||||
recursive-include pretix/presale/templates *
|
||||
recursive-include pretix/plugins/banktransfer/templates *
|
||||
recursive-include pretix/plugins/banktransfer/static *
|
||||
recursive-include pretix/plugins/paypal/templates *
|
||||
recursive-include pretix/plugins/pretixdroid/templates *
|
||||
recursive-include pretix/plugins/pretixdroid/static *
|
||||
|
||||
@@ -6,7 +6,7 @@ localecompile:
|
||||
|
||||
localegen:
|
||||
./manage.py makemessages --all --ignore "pretix/helpers/*"
|
||||
./manage.py makemessages --all -d djangojs --ignore "pretix/helpers/*"
|
||||
./manage.py makemessages --all -d djangojs --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "build/*"
|
||||
|
||||
staticfiles: jsi18n
|
||||
./manage.py collectstatic --noinput
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.0"
|
||||
__version__ = "1.3.1"
|
||||
|
||||
@@ -9,12 +9,16 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import exporter # NOQA
|
||||
from . import payment # NOQA
|
||||
from . import exporters # NOQA
|
||||
from .services import export, mail, tickets, cart, orders, cleanup # NOQA
|
||||
from .services import export, mail, tickets, cart, orders, cleanup, update_check # NOQA
|
||||
|
||||
try:
|
||||
from .celery import app as celery_app # NOQA
|
||||
from .celery_app import app as celery_app # NOQA
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
default_app_config = 'pretix.base.PretixBaseConfig'
|
||||
try:
|
||||
import pretix.celery_app as celery # NOQA
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -3,7 +3,7 @@ import tempfile
|
||||
from zipfile import ZipFile
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..services.invoices import invoice_pdf_task
|
||||
|
||||
@@ -68,7 +68,9 @@ class JSONExporter(BaseExporter):
|
||||
'variation': position.variation_id,
|
||||
'price': position.price,
|
||||
'attendee_name': position.attendee_name,
|
||||
'attendee_email': position.attendee_email,
|
||||
'secret': position.secret,
|
||||
'addon_to': position.addon_to_id,
|
||||
'answers': [
|
||||
{
|
||||
'question': answer.question_id,
|
||||
|
||||
@@ -2,7 +2,9 @@ from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..models import Order
|
||||
@@ -16,7 +18,11 @@ class MailExporter(BaseExporter):
|
||||
def render(self, form_data: dict):
|
||||
qs = self.event.orders.filter(status__in=form_data['status'])
|
||||
addrs = qs.values('email')
|
||||
data = "\r\n".join(set(a['email'] for a in addrs))
|
||||
pos = OrderPosition.objects.filter(
|
||||
order__event=self.event, order__status__in=form_data['status']
|
||||
).values('attendee_email')
|
||||
data = "\r\n".join(set(a['email'] for a in addrs)
|
||||
| set(a['attendee_email'] for a in pos if a['attendee_email']))
|
||||
return 'pretixemails.txt', 'text/plain', data.encode("utf-8")
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,7 +7,8 @@ import pytz
|
||||
from django import forms
|
||||
from django.db.models import Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.models import InvoiceAddress, Order, OrderPosition
|
||||
|
||||
@@ -17,7 +18,7 @@ from ..signals import register_data_exporters, register_payment_providers
|
||||
|
||||
class OrderListExporter(BaseExporter):
|
||||
identifier = 'orderlistcsv'
|
||||
verbose_name = _('List of orders (CSV)')
|
||||
verbose_name = ugettext_lazy('List of orders (CSV)')
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
@@ -50,7 +51,7 @@ class OrderListExporter(BaseExporter):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
||||
|
||||
qs = self.event.orders.all().select_related('invoice_address')
|
||||
qs = self.event.orders.all().select_related('invoice_address').prefetch_related('invoices')
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(status=Order.STATUS_PAID)
|
||||
tax_rates = self._get_all_tax_rates(qs)
|
||||
@@ -58,7 +59,7 @@ class OrderListExporter(BaseExporter):
|
||||
headers = [
|
||||
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
||||
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
_('Payment date'), _('Payment type'), _('Payment method fee')
|
||||
_('Payment date'), _('Payment type'), _('Payment method fee'),
|
||||
]
|
||||
|
||||
for tr in tax_rates:
|
||||
@@ -68,6 +69,8 @@ class OrderListExporter(BaseExporter):
|
||||
_('Tax value at {rate} % tax').format(rate=tr),
|
||||
]
|
||||
|
||||
headers.append(_('Invoice numbers'))
|
||||
|
||||
writer.writerow(headers)
|
||||
|
||||
provider_names = {}
|
||||
@@ -86,7 +89,7 @@ class OrderListExporter(BaseExporter):
|
||||
for order in qs.order_by('datetime'):
|
||||
row = [
|
||||
order.code,
|
||||
str(order.total),
|
||||
localize(order.total),
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
@@ -107,7 +110,7 @@ class OrderListExporter(BaseExporter):
|
||||
row += [
|
||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||
provider_names.get(order.payment_provider, order.payment_provider),
|
||||
str(order.payment_fee)
|
||||
localize(order.payment_fee)
|
||||
]
|
||||
|
||||
for tr in tax_rates:
|
||||
@@ -117,11 +120,12 @@ class OrderListExporter(BaseExporter):
|
||||
taxrate_values['taxsum'] += order.payment_fee_tax_value
|
||||
|
||||
row += [
|
||||
str(taxrate_values['grosssum']),
|
||||
str(taxrate_values['grosssum'] - taxrate_values['taxsum']),
|
||||
str(taxrate_values['taxsum']),
|
||||
localize(taxrate_values['grosssum']),
|
||||
localize(taxrate_values['grosssum'] - taxrate_values['taxsum']),
|
||||
localize(taxrate_values['taxsum']),
|
||||
]
|
||||
|
||||
row.append(', '.join([i.number for i in order.invoices.all()]))
|
||||
writer.writerow(row)
|
||||
|
||||
return 'orders.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
@@ -1,148 +1,68 @@
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.core.files import File
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.forms.models import (
|
||||
BaseInlineFormSet, BaseModelForm, BaseModelFormSet, ModelFormMetaclass,
|
||||
)
|
||||
import i18nfield.forms
|
||||
from django.forms.models import ModelFormMetaclass
|
||||
from django.utils import six
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from hierarkey.forms import HierarkeyForm
|
||||
|
||||
from pretix.base.i18n import I18nFormField
|
||||
from pretix.base.models import Event
|
||||
|
||||
from .validators import PlaceholderValidator # NOQA
|
||||
|
||||
logger = logging.getLogger('pretix.plugins.ticketoutputpdf')
|
||||
|
||||
|
||||
class BaseI18nModelForm(BaseModelForm):
|
||||
"""
|
||||
This is a helperclass to construct an I18nModelForm.
|
||||
"""
|
||||
class BaseI18nModelForm(i18nfield.forms.BaseI18nModelForm):
|
||||
# compatibility shim for django-i18nfield library
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.pop('event', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
if event:
|
||||
for k, field in self.fields.items():
|
||||
if isinstance(field, I18nFormField):
|
||||
field.widget.enabled_langcodes = event.settings.get('locales')
|
||||
kwargs['locales'] = event.settings.get('locales')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class I18nModelForm(six.with_metaclass(ModelFormMetaclass, BaseI18nModelForm)):
|
||||
"""
|
||||
This is a modified version of Django's ModelForm which differs from ModelForm in
|
||||
only one way: The constructor takes one additional optional argument ``event``
|
||||
expecting an `Event` instance. If given, this instance is used to select
|
||||
the visible languages in all I18nFormFields of the form. If not given, all languages
|
||||
will be displayed.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class I18nFormSet(BaseModelFormSet):
|
||||
"""
|
||||
This is equivalent to a normal BaseModelFormset, but cares for the special needs
|
||||
of I18nForms (see there for more information).
|
||||
"""
|
||||
class I18nFormSet(i18nfield.forms.I18nModelFormSet):
|
||||
# compatibility shim for django-i18nfield library
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event', None)
|
||||
event = kwargs.pop('event', None)
|
||||
if event:
|
||||
kwargs['locales'] = event.settings.get('locales')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _construct_form(self, i, **kwargs):
|
||||
kwargs['event'] = self.event
|
||||
return super()._construct_form(i, **kwargs)
|
||||
|
||||
@property
|
||||
def empty_form(self):
|
||||
form = self.form(
|
||||
auto_id=self.auto_id,
|
||||
prefix=self.add_prefix('__prefix__'),
|
||||
empty_permitted=True,
|
||||
event=self.event
|
||||
)
|
||||
self.add_fields(form, None)
|
||||
return form
|
||||
|
||||
|
||||
class I18nInlineFormSet(BaseInlineFormSet):
|
||||
"""
|
||||
This is equivalent to a normal BaseInlineFormset, but cares for the special needs
|
||||
of I18nForms (see there for more information).
|
||||
"""
|
||||
class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
|
||||
# compatibility shim for django-i18nfield library
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event', None)
|
||||
event = kwargs.pop('event', None)
|
||||
if event:
|
||||
kwargs['locales'] = event.settings.get('locales')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _construct_form(self, i, **kwargs):
|
||||
kwargs['event'] = self.event
|
||||
return super()._construct_form(i, **kwargs)
|
||||
|
||||
|
||||
class SettingsForm(forms.Form):
|
||||
"""
|
||||
This form is meant to be used for modifying EventSettings or OrganizerSettings. It takes
|
||||
care of loading the current values of the fields and saving the field inputs to the
|
||||
settings storage. It also deals with setting the available languages for internationalized
|
||||
fields.
|
||||
|
||||
:param obj: The event or organizer object which should be used for the settings storage
|
||||
"""
|
||||
BOOL_CHOICES = (
|
||||
('False', _('disabled')),
|
||||
('True', _('enabled')),
|
||||
)
|
||||
class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.obj = kwargs.pop('obj')
|
||||
self.obj = kwargs.get('obj', None)
|
||||
self.locales = self.obj.settings.get('locales') if self.obj else kwargs.pop('locales', None)
|
||||
kwargs['attribute_name'] = 'settings'
|
||||
kwargs['locales'] = self.locales
|
||||
kwargs['initial'] = self.obj.settings.freeze()
|
||||
super().__init__(*args, **kwargs)
|
||||
for k, field in self.fields.items():
|
||||
if isinstance(field, I18nFormField):
|
||||
field.widget.enabled_langcodes = self.obj.settings.get('locales')
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Performs the save operation
|
||||
"""
|
||||
for name, field in self.fields.items():
|
||||
value = self.cleaned_data[name]
|
||||
if isinstance(value, UploadedFile):
|
||||
# Delete old file
|
||||
fname = self.obj.settings.get(name, as_type=File)
|
||||
if fname:
|
||||
try:
|
||||
default_storage.delete(fname.name)
|
||||
except OSError:
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
|
||||
# Create new file
|
||||
nonce = get_random_string(length=8)
|
||||
if isinstance(self.obj, Event):
|
||||
fname = '%s/%s/%s.%s.%s' % (
|
||||
self.obj.organizer.slug, self.obj.slug, name, nonce, value.name.split('.')[-1]
|
||||
)
|
||||
else:
|
||||
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, value.name.split('.')[-1])
|
||||
newname = default_storage.save(fname, value)
|
||||
value._name = newname
|
||||
self.obj.settings.set(name, value)
|
||||
elif isinstance(value, File):
|
||||
# file is unchanged
|
||||
continue
|
||||
elif isinstance(field, forms.FileField):
|
||||
# file is deleted
|
||||
fname = self.obj.settings.get(name, as_type=File)
|
||||
if fname:
|
||||
try:
|
||||
default_storage.delete(fname.name)
|
||||
except OSError:
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
del self.obj.settings[name]
|
||||
elif value is None:
|
||||
del self.obj.settings[name]
|
||||
elif self.obj.settings.get(name, as_type=type(value)) != value:
|
||||
self.obj.settings.set(name, value)
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
nonce = get_random_string(length=8)
|
||||
if isinstance(self.obj, Event):
|
||||
fname = '%s/%s/%s.%s.%s' % (
|
||||
self.obj.organizer.slug, self.obj.slug, name, nonce, name.split('.')[-1]
|
||||
)
|
||||
else:
|
||||
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, name.split('.')[-1])
|
||||
return fname
|
||||
|
||||
@@ -39,8 +39,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'givenname',
|
||||
'familyname',
|
||||
'fullname',
|
||||
'locale',
|
||||
# 'timezone',
|
||||
'email'
|
||||
@@ -105,7 +104,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
|
||||
|
||||
class User2FADeviceAddForm(forms.Form):
|
||||
name = forms.CharField(label=_('Device name'))
|
||||
name = forms.CharField(label=_('Device name'), max_length=64)
|
||||
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
|
||||
('totp', _('Smartphone with the Authenticator application')),
|
||||
('u2f', _('U2F-compatible hardware token (e.g. Yubikey)')),
|
||||
|
||||
38
src/pretix/base/forms/validators.py
Normal file
38
src/pretix/base/forms/validators.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import BaseValidator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
|
||||
class PlaceholderValidator(BaseValidator):
|
||||
"""
|
||||
Takes list of allowed placeholders,
|
||||
validates form field by checking for placeholders,
|
||||
which are not presented in taken list.
|
||||
"""
|
||||
|
||||
def __init__(self, limit_value):
|
||||
super().__init__(limit_value)
|
||||
self.limit_value = limit_value
|
||||
|
||||
def __call__(self, value):
|
||||
if isinstance(value, LazyI18nString):
|
||||
for l, v in value.data.items():
|
||||
self.__call__(v)
|
||||
return
|
||||
|
||||
data_placeholders = list(re.findall(r'({[\w\s]*})', value, re.X))
|
||||
invalid_placeholders = []
|
||||
for placeholder in data_placeholders:
|
||||
if placeholder not in self.limit_value:
|
||||
invalid_placeholders.append(placeholder)
|
||||
if invalid_placeholders:
|
||||
raise ValidationError(
|
||||
_('Invalid placeholder(s): %(value)s'),
|
||||
code='invalid',
|
||||
params={'value': ", ".join(invalid_placeholders,)})
|
||||
|
||||
def clean(self, x):
|
||||
return x
|
||||
@@ -1,339 +1,16 @@
|
||||
import copy
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Model, QuerySet, TextField
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format, number_format
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext
|
||||
|
||||
|
||||
class LazyI18nString:
|
||||
"""
|
||||
This represents an internationalized string that is/was/will be stored in the database.
|
||||
"""
|
||||
|
||||
def __init__(self, data: Optional[Union[str, Dict[str, str]]]):
|
||||
"""
|
||||
Creates a new i18n-aware string.
|
||||
|
||||
:param data: If this is a dictionary, it is expected to map language codes to translations.
|
||||
If this is a string that can be parsed as JSON, it will be parsed and used as such a dictionary.
|
||||
If this is anything else, it will be cast to a string and used for all languages.
|
||||
"""
|
||||
self.data = data
|
||||
if isinstance(self.data, str) and self.data is not None:
|
||||
try:
|
||||
j = json.loads(self.data)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
self.data = j
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Evaluate the given string with respect to the currently active locale.
|
||||
|
||||
If no string is available in the currently active language, this will give you
|
||||
the string in the system's default language. If this is unavailable as well, it
|
||||
will give you the string in the first language available.
|
||||
"""
|
||||
return self.localize(translation.get_language() or settings.LANGUAGE_CODE)
|
||||
|
||||
def __bool__(self):
|
||||
if not self.data:
|
||||
return False
|
||||
if isinstance(self.data, dict):
|
||||
return any(self.data.values())
|
||||
return True
|
||||
|
||||
def localize(self, lng: str) -> str:
|
||||
"""
|
||||
Evaluate the given string with respect to the locale defined by ``lng``.
|
||||
|
||||
If no string is available in the currently active language, this will give you
|
||||
the string in the system's default language. If this is unavailable as well, it
|
||||
will give you the string in the first language available.
|
||||
|
||||
:param lng: A locale code, e.g. ``de``. If you specify a code including a country
|
||||
or region like ``de-AT``, exact matches will be used preferably, but if only
|
||||
a ``de`` or ``de-AT`` translation exists, this might be returned as well.
|
||||
"""
|
||||
if self.data is None:
|
||||
return ""
|
||||
|
||||
if isinstance(self.data, dict):
|
||||
firstpart = lng.split('-')[0]
|
||||
similar = [l for l in self.data.keys() if l.startswith(firstpart + "-") or firstpart == l]
|
||||
if lng in self.data and self.data[lng]:
|
||||
return self.data[lng]
|
||||
elif firstpart in self.data:
|
||||
return self.data[firstpart]
|
||||
elif similar:
|
||||
return self.data[similar[0]]
|
||||
elif settings.LANGUAGE_CODE in self.data and self.data[settings.LANGUAGE_CODE]:
|
||||
return self.data[settings.LANGUAGE_CODE]
|
||||
elif len(self.data):
|
||||
return list(self.data.items())[0][1]
|
||||
else:
|
||||
return ""
|
||||
else:
|
||||
return str(self.data)
|
||||
|
||||
def __repr__(self) -> str: # NOQA
|
||||
return '<LazyI18nString: %s>' % repr(self.data)
|
||||
|
||||
def __lt__(self, other) -> bool: # NOQA
|
||||
return str(self) < str(other)
|
||||
|
||||
def __format__(self, format_spec):
|
||||
return self.__str__()
|
||||
|
||||
def __eq__(self, other):
|
||||
if other is None:
|
||||
return False
|
||||
if hasattr(other, 'data'):
|
||||
return self.data == other.data
|
||||
return self.data == other
|
||||
|
||||
class LazyGettextProxy:
|
||||
def __init__(self, lazygettext):
|
||||
self.lazygettext = lazygettext
|
||||
|
||||
def __getitem__(self, item):
|
||||
with language(item):
|
||||
return str(ugettext(self.lazygettext))
|
||||
|
||||
def __contains__(self, item):
|
||||
return True
|
||||
|
||||
def __str__(self):
|
||||
return str(ugettext(self.lazygettext))
|
||||
|
||||
@classmethod
|
||||
def from_gettext(cls, lazygettext):
|
||||
l = LazyI18nString({})
|
||||
l.data = cls.LazyGettextProxy(lazygettext)
|
||||
return l
|
||||
|
||||
|
||||
class I18nWidget(forms.MultiWidget):
|
||||
"""
|
||||
The default form widget for I18nCharField and I18nTextField. It makes
|
||||
use of Django's MultiWidget mechanism and does some magic to save you
|
||||
time.
|
||||
"""
|
||||
widget = forms.TextInput
|
||||
|
||||
def __init__(self, langcodes: List[str], field: forms.Field, attrs=None):
|
||||
widgets = []
|
||||
self.langcodes = langcodes
|
||||
self.enabled_langcodes = langcodes
|
||||
self.field = field
|
||||
for lng in self.langcodes:
|
||||
a = copy.copy(attrs) or {}
|
||||
a['lang'] = lng
|
||||
widgets.append(self.widget(attrs=a))
|
||||
super().__init__(widgets, attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
data = []
|
||||
first_enabled = None
|
||||
any_filled = False
|
||||
if not isinstance(value, LazyI18nString):
|
||||
value = LazyI18nString(value)
|
||||
for i, lng in enumerate(self.langcodes):
|
||||
dataline = (
|
||||
value.data[lng]
|
||||
if value is not None and (
|
||||
isinstance(value.data, dict) or isinstance(value.data, LazyI18nString.LazyGettextProxy)
|
||||
) and lng in value.data
|
||||
else None
|
||||
)
|
||||
any_filled = any_filled or (lng in self.enabled_langcodes and dataline)
|
||||
if not first_enabled and lng in self.enabled_langcodes:
|
||||
first_enabled = i
|
||||
data.append(dataline)
|
||||
if value and not isinstance(value.data, dict):
|
||||
data[first_enabled] = value.data
|
||||
return data
|
||||
|
||||
def render(self, name, value, attrs=None):
|
||||
if self.is_localized:
|
||||
for widget in self.widgets:
|
||||
widget.is_localized = self.is_localized
|
||||
# value is a list of values, each corresponding to a widget
|
||||
# in self.widgets.
|
||||
if not isinstance(value, list):
|
||||
value = self.decompress(value)
|
||||
output = []
|
||||
final_attrs = self.build_attrs(attrs)
|
||||
id_ = final_attrs.get('id', None)
|
||||
for i, widget in enumerate(self.widgets):
|
||||
if self.langcodes[i] not in self.enabled_langcodes:
|
||||
continue
|
||||
try:
|
||||
widget_value = value[i]
|
||||
except IndexError:
|
||||
widget_value = None
|
||||
if id_:
|
||||
final_attrs = dict(
|
||||
final_attrs,
|
||||
id='%s_%s' % (id_, i),
|
||||
title=self.langcodes[i]
|
||||
)
|
||||
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs))
|
||||
return mark_safe(self.format_output(output))
|
||||
|
||||
def format_output(self, rendered_widgets):
|
||||
return '<div class="i18n-form-group">%s</div>' % super().format_output(rendered_widgets)
|
||||
|
||||
|
||||
class I18nTextInput(I18nWidget):
|
||||
widget = forms.TextInput
|
||||
|
||||
|
||||
class I18nTextarea(I18nWidget):
|
||||
widget = forms.Textarea
|
||||
|
||||
|
||||
class I18nFormField(forms.MultiValueField):
|
||||
"""
|
||||
The form field that is used by I18nCharField and I18nTextField. It makes use
|
||||
of Django's MultiValueField mechanism to create one sub-field per available
|
||||
language.
|
||||
|
||||
It contains special treatment to make sure that a field marked as "required" is validated
|
||||
as "filled out correctly" if *at least one* translation is filled it. It is never required
|
||||
to fill in all of them. This has the drawback that the HTML property ``required`` is set on
|
||||
none of the fields as this would lead to irritating behaviour.
|
||||
|
||||
:param langcodes: An iterable of locale codes that the widget should render a field for. If
|
||||
omitted, fields will be rendered for all languages supported by pretix.
|
||||
"""
|
||||
|
||||
def compress(self, data_list):
|
||||
langcodes = self.langcodes
|
||||
data = {}
|
||||
for i, value in enumerate(data_list):
|
||||
data[langcodes[i]] = value
|
||||
return LazyI18nString(data)
|
||||
|
||||
def clean(self, value):
|
||||
if isinstance(value, LazyI18nString):
|
||||
# This happens e.g. if the field is disabled
|
||||
return value
|
||||
found = False
|
||||
clean_data = []
|
||||
errors = []
|
||||
for i, field in enumerate(self.fields):
|
||||
try:
|
||||
field_value = value[i]
|
||||
except IndexError:
|
||||
field_value = None
|
||||
if field_value not in self.empty_values:
|
||||
found = True
|
||||
try:
|
||||
clean_data.append(field.clean(field_value))
|
||||
except forms.ValidationError as e:
|
||||
# Collect all validation errors in a single list, which we'll
|
||||
# raise at the end of clean(), rather than raising a single
|
||||
# exception for the first error we encounter. Skip duplicates.
|
||||
errors.extend(m for m in e.error_list if m not in errors)
|
||||
if errors:
|
||||
raise forms.ValidationError(errors)
|
||||
if self.one_required and not found:
|
||||
raise forms.ValidationError(self.error_messages['required'], code='required')
|
||||
|
||||
out = self.compress(clean_data)
|
||||
self.validate(out)
|
||||
self.run_validators(out)
|
||||
return out
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
fields = []
|
||||
defaults = {
|
||||
'widget': self.widget,
|
||||
'max_length': kwargs.pop('max_length', None),
|
||||
}
|
||||
self.langcodes = kwargs.pop('langcodes', [l[0] for l in settings.LANGUAGES])
|
||||
self.one_required = kwargs.get('required', True)
|
||||
kwargs['required'] = False
|
||||
kwargs['widget'] = kwargs['widget'](
|
||||
langcodes=self.langcodes, field=self, **kwargs.pop('widget_kwargs', {})
|
||||
)
|
||||
defaults.update(**kwargs)
|
||||
for lngcode in self.langcodes:
|
||||
defaults['label'] = '%s (%s)' % (defaults.get('label'), lngcode)
|
||||
fields.append(forms.CharField(**defaults))
|
||||
super().__init__(
|
||||
fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
|
||||
|
||||
class I18nFieldMixin:
|
||||
form_class = I18nFormField
|
||||
widget = I18nTextInput
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_python(self, value):
|
||||
if isinstance(value, LazyI18nString):
|
||||
return value
|
||||
return LazyI18nString(value)
|
||||
|
||||
def get_prep_value(self, value):
|
||||
if isinstance(value, LazyI18nString):
|
||||
value = value.data
|
||||
if isinstance(value, dict):
|
||||
return json.dumps({k: v for k, v in value.items() if v}, sort_keys=True)
|
||||
return value
|
||||
|
||||
def get_prep_lookup(self, lookup_type, value): # NOQA
|
||||
raise TypeError('Lookups on i18n string currently not supported.')
|
||||
|
||||
def from_db_value(self, value, expression, connection, context):
|
||||
return LazyI18nString(value)
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {'form_class': self.form_class, 'widget': self.widget}
|
||||
defaults.update(kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
|
||||
class I18nCharField(I18nFieldMixin, TextField):
|
||||
"""
|
||||
A CharField which takes internationalized data. Internally, a TextField dabase
|
||||
field is used to store JSON. If you interact with this field, you will work
|
||||
with LazyI18nString instances.
|
||||
"""
|
||||
widget = I18nTextInput
|
||||
|
||||
|
||||
class I18nTextField(I18nFieldMixin, TextField):
|
||||
"""
|
||||
Like I18nCharField, but for TextFields.
|
||||
"""
|
||||
widget = I18nTextarea
|
||||
|
||||
|
||||
class I18nJSONEncoder(DjangoJSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, LazyI18nString):
|
||||
return obj.data
|
||||
elif isinstance(obj, QuerySet):
|
||||
return list(obj)
|
||||
elif isinstance(obj, Model):
|
||||
return {'type': obj.__class__.__name__, 'id': obj.id}
|
||||
else:
|
||||
return super().default(obj)
|
||||
from i18nfield.fields import ( # noqa
|
||||
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
|
||||
)
|
||||
from i18nfield.forms import I18nFormField # noqa
|
||||
# Compatibility imports
|
||||
from i18nfield.strings import LazyI18nString # noqa
|
||||
from i18nfield.utils import I18nJSONEncoder # noqa
|
||||
|
||||
|
||||
class LazyDate:
|
||||
@@ -370,10 +47,11 @@ def language(lng):
|
||||
|
||||
|
||||
class LazyLocaleException(Exception):
|
||||
def __init__(self, msg, msgargs=None):
|
||||
self.msg = msg
|
||||
self.msgargs = msgargs
|
||||
super().__init__(msg, msgargs)
|
||||
def __init__(self, *args):
|
||||
self.msg = args[0]
|
||||
self.msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
super().__init__(self.msg, self.msgargs)
|
||||
|
||||
def __str__(self):
|
||||
if self.msgargs:
|
||||
|
||||
23
src/pretix/base/management/commands/rebuild.py
Normal file
23
src/pretix/base/management/commands/rebuild.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Rebuild static files and language files"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
call_command('compilemessages', verbosity=1, interactive=False)
|
||||
call_command('compilejsi18n', verbosity=1, interactive=False)
|
||||
call_command('collectstatic', verbosity=1, interactive=False)
|
||||
call_command('compress', verbosity=1, interactive=False)
|
||||
try:
|
||||
gs = GlobalSettingsObject()
|
||||
del gs.settings.update_check_last
|
||||
del gs.settings.update_check_result
|
||||
del gs.settings.update_check_result_warning
|
||||
except:
|
||||
# Fails when this is executed without a valid database configuration.
|
||||
# We don't care.
|
||||
pass
|
||||
@@ -1,10 +1,28 @@
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
||||
if settings.HAS_REDIS:
|
||||
import django_redis
|
||||
redis = django_redis.get_redis_connection("redis")
|
||||
|
||||
REDIS_KEY_PREFIX = "pretix_metrics_"
|
||||
REDIS_KEY = "pretix_metrics"
|
||||
_INF = float("inf")
|
||||
_MINUS_INF = float("-inf")
|
||||
|
||||
|
||||
def _float_to_go_string(d):
|
||||
# inspired by https://github.com/prometheus/client_python/blob/master/prometheus_client/core.py
|
||||
if d == _INF:
|
||||
return '+Inf'
|
||||
elif d == _MINUS_INF:
|
||||
return '-Inf'
|
||||
elif math.isnan(d):
|
||||
return 'NaN'
|
||||
else:
|
||||
return repr(float(d))
|
||||
|
||||
|
||||
class Metric(object):
|
||||
@@ -34,7 +52,7 @@ class Metric(object):
|
||||
if len(labels) != len(self.labelnames):
|
||||
raise ValueError("Unknown labels used: {}".format(", ".join(set(labels) - set(self.labelnames))))
|
||||
|
||||
def _construct_metric_identifier(self, metricname, labels=None):
|
||||
def _construct_metric_identifier(self, metricname, labels=None, labelnames=None):
|
||||
"""
|
||||
Constructs the scrapable metricname usable in the output format.
|
||||
"""
|
||||
@@ -42,26 +60,36 @@ class Metric(object):
|
||||
return metricname
|
||||
else:
|
||||
named_labels = []
|
||||
for labelname in self.labelnames:
|
||||
named_labels.append('{}="{}",'.format(labelname, labels[labelname]))
|
||||
for labelname in (labelnames or self.labelnames):
|
||||
named_labels.append('{}="{}"'.format(labelname, labels[labelname]))
|
||||
|
||||
return metricname + "{" + ",".join(named_labels) + "}"
|
||||
|
||||
def _inc_in_redis(self, key, amount):
|
||||
def _inc_in_redis(self, key, amount, pipeline=None):
|
||||
"""
|
||||
Increments given key in Redis.
|
||||
"""
|
||||
rkey = REDIS_KEY_PREFIX + key
|
||||
if settings.HAS_REDIS:
|
||||
redis.incrbyfloat(rkey, amount)
|
||||
if not pipeline:
|
||||
pipeline = redis
|
||||
pipeline.hincrbyfloat(REDIS_KEY, key, amount)
|
||||
|
||||
def _set_in_redis(self, key, value):
|
||||
def _set_in_redis(self, key, value, pipeline=None):
|
||||
"""
|
||||
Sets given key in Redis.
|
||||
"""
|
||||
rkey = REDIS_KEY_PREFIX + key
|
||||
if settings.HAS_REDIS:
|
||||
redis.set(rkey, value)
|
||||
if not pipeline:
|
||||
pipeline = redis
|
||||
pipeline.hset(REDIS_KEY, key, value)
|
||||
|
||||
def _get_redis_pipeline(self):
|
||||
if settings.HAS_REDIS:
|
||||
return redis.pipeline()
|
||||
|
||||
def _execute_redis_pipeline(self, pipeline):
|
||||
if settings.HAS_REDIS:
|
||||
return pipeline.execute()
|
||||
|
||||
|
||||
class Counter(Metric):
|
||||
@@ -124,21 +152,79 @@ class Gauge(Metric):
|
||||
self._inc_in_redis(fullmetric, amount * -1)
|
||||
|
||||
|
||||
class Histogram(Metric):
|
||||
"""
|
||||
Histogram Metric Object
|
||||
"""
|
||||
|
||||
def __init__(self, name, helpstring, labelnames=None,
|
||||
buckets=(.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, 30.0, _INF)):
|
||||
if list(buckets) != sorted(buckets):
|
||||
# This is probably an error on the part of the user,
|
||||
# so raise rather than sorting for them.
|
||||
raise ValueError('Buckets not in sorted order')
|
||||
|
||||
if buckets and buckets[-1] != _INF:
|
||||
buckets.append(_INF)
|
||||
|
||||
if len(buckets) < 2:
|
||||
raise ValueError('Must have at least two buckets')
|
||||
|
||||
self.buckets = buckets
|
||||
super().__init__(name, helpstring, labelnames)
|
||||
|
||||
def observe(self, amount, **kwargs):
|
||||
"""
|
||||
Stores a value in the histogram for the labels specified in kwargs.
|
||||
"""
|
||||
if amount < 0:
|
||||
raise ValueError("Amount must be greater than zero. Otherwise use inc().")
|
||||
|
||||
self._check_label_consistency(kwargs)
|
||||
|
||||
pipe = self._get_redis_pipeline()
|
||||
|
||||
countmetric = self._construct_metric_identifier(self.name + '_count', kwargs)
|
||||
self._inc_in_redis(countmetric, 1, pipeline=pipe)
|
||||
|
||||
summetric = self._construct_metric_identifier(self.name + '_sum', kwargs)
|
||||
self._inc_in_redis(summetric, amount, pipeline=pipe)
|
||||
|
||||
kwargs_le = dict(kwargs.items())
|
||||
for i, bound in enumerate(self.buckets):
|
||||
if amount <= bound:
|
||||
kwargs_le['le'] = _float_to_go_string(bound)
|
||||
bmetric = self._construct_metric_identifier(self.name + '_bucket', kwargs_le,
|
||||
labelnames=self.labelnames + ["le"])
|
||||
self._inc_in_redis(bmetric, 1, pipeline=pipe)
|
||||
|
||||
self._execute_redis_pipeline(pipe)
|
||||
|
||||
|
||||
def metric_values():
|
||||
"""
|
||||
Produces the scrapable textformat to be presented to the monitoring system
|
||||
Produces the the values to be presented to the monitoring system
|
||||
"""
|
||||
if not settings.HAS_REDIS:
|
||||
return ""
|
||||
metrics = defaultdict(dict)
|
||||
|
||||
metrics = {}
|
||||
# Metrics from redis
|
||||
if settings.HAS_REDIS:
|
||||
for key, value in redis.hscan_iter(REDIS_KEY):
|
||||
dkey = key.decode("utf-8")
|
||||
splitted = dkey.split("{", 2)
|
||||
value = float(value.decode("utf-8"))
|
||||
metrics[splitted[0]]["{" + splitted[1]] = value
|
||||
|
||||
for key in redis.scan_iter(match=REDIS_KEY_PREFIX + "*"):
|
||||
dkey = key.decode("utf-8")
|
||||
_, _, output_key = dkey.split("_", 2)
|
||||
value = float(redis.get(dkey).decode("utf-8"))
|
||||
# Aliases
|
||||
aliases = {
|
||||
'pretix_view_requests_total': 'pretix_view_duration_seconds_count'
|
||||
}
|
||||
for a, atarget in aliases.items():
|
||||
metrics[a] = metrics[atarget]
|
||||
|
||||
metrics[output_key] = value
|
||||
# Throwaway metrics
|
||||
for m in apps.get_models(): # Count all models
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
|
||||
|
||||
return metrics
|
||||
|
||||
@@ -146,5 +232,9 @@ def metric_values():
|
||||
"""
|
||||
Provided metrics
|
||||
"""
|
||||
http_requests_total = Counter("http_requests_total", "Total number of HTTP requests made.", ["code", "handler", "method"])
|
||||
# usage: http_requests_total.inc(code="200", handler="/foo", method="GET")
|
||||
pretix_view_duration_seconds = Histogram("pretix_view_duration_seconds", "Return time of views.",
|
||||
["status_code", "method", "url_name"])
|
||||
pretix_task_runs_total = Counter("pretix_task_runs_total", "Total calls to a celery task",
|
||||
["task_name", "status"])
|
||||
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
|
||||
["task_name"])
|
||||
|
||||
@@ -135,18 +135,30 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _parse_csp(header):
|
||||
h = {}
|
||||
for part in header.split(';'):
|
||||
k, v = part.strip().split(' ', 1)
|
||||
h[k.strip()] = v.split(' ')
|
||||
return h
|
||||
|
||||
|
||||
def _render_csp(h):
|
||||
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items())
|
||||
|
||||
|
||||
def _merge_csp(a, b):
|
||||
for k, v in a.items():
|
||||
if k in b:
|
||||
a[k] += b[k]
|
||||
|
||||
for k, v in b.items():
|
||||
if k not in a:
|
||||
a[k] = b[k]
|
||||
|
||||
|
||||
class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
def _parse_csp(self, header):
|
||||
h = {}
|
||||
for part in header.split(';'):
|
||||
k, v = part.strip().split(' ', 1)
|
||||
h[k.strip()] = v
|
||||
return h
|
||||
|
||||
def _render_csp(self, h):
|
||||
return "; ".join(k + ' ' + v for k, v in h.items())
|
||||
|
||||
def process_response(self, request, resp):
|
||||
if settings.DEBUG and resp.status_code >= 400:
|
||||
# Don't use CSP on debug error page as it breaks of Django's fancy error
|
||||
@@ -155,23 +167,23 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
resp['X-XSS-Protection'] = '1'
|
||||
h = {
|
||||
'default-src': "{static}",
|
||||
'script-src': '{static} https://checkout.stripe.com https://js.stripe.com',
|
||||
'object-src': "'none'",
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'object-src': ["'none'"],
|
||||
# frame-src is deprecated but kept for compatibility with CSP 1.0 browsers, e.g. Safari 9
|
||||
'frame-src': '{static} https://checkout.stripe.com https://js.stripe.com',
|
||||
'child-src': '{static} https://checkout.stripe.com https://js.stripe.com',
|
||||
'style-src': "{static}",
|
||||
'connect-src': "{dynamic} https://checkout.stripe.com",
|
||||
'img-src': "{static} data: https://*.stripe.com",
|
||||
'frame-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'child-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'style-src': ["{static}"],
|
||||
'connect-src': ["{dynamic}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "data:", "https://*.stripe.com"],
|
||||
# form-action is not only used to match on form actions, but also on URLs
|
||||
# form-actions redirect to. In the context of e.g. payment providers or
|
||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||
# this. However, we'll restrict it to HTTPS.
|
||||
'form-action': "{dynamic} https:",
|
||||
'form-action': ["{dynamic}", "https:"],
|
||||
}
|
||||
if 'Content-Security-Policy' in resp:
|
||||
h.update(self._parse_csp(resp['Content-Security-Policy']))
|
||||
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
||||
|
||||
staticdomain = "'self'"
|
||||
dynamicdomain = "'self'"
|
||||
@@ -184,5 +196,5 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
else:
|
||||
staticdomain += " " + settings.SITE_URL
|
||||
dynamicdomain += " " + settings.SITE_URL
|
||||
resp['Content-Security-Policy'] = self._render_csp(h).format(static=staticdomain, dynamic=dynamicdomain)
|
||||
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain)
|
||||
return resp
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-02-03 14:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.invoices
|
||||
import pretix.base.models.items
|
||||
import pretix.base.models.orders
|
||||
import pretix.base.models.vouchers
|
||||
|
||||
|
||||
def initial_user(apps, schema_editor):
|
||||
User = apps.get_model("pretixbase", "User")
|
||||
user = User(email='admin@localhost')
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
user.password = make_password('admin')
|
||||
user.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('pretixbase', '0001_initial'), ('pretixbase', '0002_auto_20160209_0940'), ('pretixbase', '0003_eventpermission_can_change_vouchers'), ('pretixbase', '0004_auto_20160209_1023'), ('pretixbase', '0005_auto_20160211_1459'), ('pretixbase', '0006_auto_20160211_1630'), ('pretixbase', '0007_auto_20160211_1710'), ('pretixbase', '0008_invoiceaddress'), ('pretixbase', '0009_auto_20160222_2002'), ('pretixbase', '0010_orderposition_secret'), ('pretixbase', '0011_auto_20160311_2052'), ('pretixbase', '0012_auto_20160312_1040'), ('pretixbase', '0013_invoice_locale'), ('pretixbase', '0014_invoice_additional_text'), ('pretixbase', '0015_auto_20160312_1924'), ('pretixbase', '0016_voucher_variation'), ('pretixbase', '0017_auto_20160324_1615'), ('pretixbase', '0018_auto_20160326_1104'), ('pretixbase', '0019_auto_20160326_1139'), ('pretixbase', '0020_auto_20160418_2106'), ('pretixbase', '0021_auto_20160418_2117'), ('pretixbase', '0020_auto_20160421_1943'), ('pretixbase', '0022_merge'), ('pretixbase', '0023_auto_20160601_1039'), ('pretixbase', '0024_auto_20160728_1725'), ('pretixbase', '0025_auto_20160802_2202'), ('pretixbase', '0026_order_comment'), ('pretixbase', '0027_auto_20160815_1254'), ('pretixbase', '0028_auto_20160816_1242')]
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0006_require_contenttypes_0002'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='E-mail')),
|
||||
('givenname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Given name')),
|
||||
('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Is active')),
|
||||
('is_staff', models.BooleanField(default=False, verbose_name='Is site admin')),
|
||||
('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='Date joined')),
|
||||
('locale', models.CharField(choices=[('en', 'English'), ('de', 'German'), ('de-informal', 'German (informal)')], default='en', max_length=50, verbose_name='Language')),
|
||||
('timezone', models.CharField(default='UTC', max_length=100, verbose_name='Timezone')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User',
|
||||
'verbose_name_plural': 'Users',
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=initial_user,
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CachedFile',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('expires', models.DateTimeField(blank=True, null=True)),
|
||||
('date', models.DateTimeField(blank=True, null=True)),
|
||||
('filename', models.CharField(max_length=255)),
|
||||
('type', models.CharField(max_length=255)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.base.cachedfile_name)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CachedTicket',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('provider', models.CharField(max_length=255)),
|
||||
('cachedfile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.CachedFile')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CartPosition',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Price')),
|
||||
('attendee_name', models.CharField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=255, null=True, verbose_name='Attendee name')),
|
||||
('voucher_discount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
|
||||
('base_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||
('cart_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Cart ID (e.g. session key)')),
|
||||
('datetime', models.DateTimeField(auto_now_add=True, verbose_name='Date')),
|
||||
('expires', models.DateTimeField(verbose_name='Expiration date')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Cart position',
|
||||
'verbose_name_plural': 'Cart positions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Event',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
('currency', models.CharField(default='EUR', max_length=10, verbose_name='Default currency')),
|
||||
('date_from', models.DateTimeField(verbose_name='Event start time')),
|
||||
('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')),
|
||||
('is_public', models.BooleanField(default=False, help_text="If selected, this event may show up on the ticket system's start page or an organization profile.", verbose_name='Visible in public lists')),
|
||||
('presale_end', models.DateTimeField(blank=True, help_text='No products will be sold after this date.', null=True, verbose_name='End of presale')),
|
||||
('presale_start', models.DateTimeField(blank=True, help_text='No products will be sold before this date.', null=True, verbose_name='Start of presale')),
|
||||
('plugins', models.TextField(blank=True, null=True, verbose_name='Plugins')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Event',
|
||||
'ordering': ('date_from', 'name'),
|
||||
'verbose_name_plural': 'Events',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventLock',
|
||||
fields=[
|
||||
('event', models.CharField(max_length=36, primary_key=True, serialize=False)),
|
||||
('date', models.DateTimeField(auto_now=True)),
|
||||
('token', models.UUIDField(default=uuid.uuid4)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventPermission',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('can_change_settings', models.BooleanField(default=True, verbose_name='Can change event settings')),
|
||||
('can_change_items', models.BooleanField(default=True, verbose_name='Can change product settings')),
|
||||
('can_view_orders', models.BooleanField(default=True, verbose_name='Can view orders')),
|
||||
('can_change_permissions', models.BooleanField(default=True, verbose_name='Can change permissions')),
|
||||
('can_change_orders', models.BooleanField(default=True, verbose_name='Can change orders')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_perms', to='pretixbase.Event')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_perms', to=settings.AUTH_USER_MODEL)),
|
||||
('can_change_vouchers', models.BooleanField(default=True, verbose_name='Can change vouchers')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Event permission',
|
||||
'verbose_name_plural': 'Event permissions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventSetting',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=255)),
|
||||
('value', models.TextField()),
|
||||
('object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='setting_objects', to='pretixbase.Event')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Item',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Item name')),
|
||||
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('description', i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the product name in lists.', null=True, verbose_name='Description')),
|
||||
('default_price', models.DecimalField(decimal_places=2, max_digits=7, null=True, verbose_name='Default price')),
|
||||
('tax_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Taxes included in percent')),
|
||||
('admission', models.BooleanField(default=False, help_text='Whether or not buying this product allows a person to enter your event', verbose_name='Is an admission ticket')),
|
||||
('position', models.IntegerField(default=0)),
|
||||
('picture', models.ImageField(blank=True, null=True, upload_to=pretix.base.models.items.itempicture_upload_to, verbose_name='Product picture')),
|
||||
('available_from', models.DateTimeField(blank=True, help_text='This product will not be sold before the given date.', null=True, verbose_name='Available from')),
|
||||
('available_until', models.DateTimeField(blank=True, help_text='This product will not be sold after the given date.', null=True, verbose_name='Available until')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Product',
|
||||
'ordering': ('category__position', 'category', 'position'),
|
||||
'verbose_name_plural': 'Products',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ItemCategory',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Category name')),
|
||||
('position', models.IntegerField(default=0)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='pretixbase.Event')),
|
||||
('description', i18nfield.fields.I18nTextField(blank=True, verbose_name='Category description')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Product category',
|
||||
'ordering': ('position', 'id'),
|
||||
'verbose_name_plural': 'Product categories',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ItemVariation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('value', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Description')),
|
||||
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('position', models.PositiveIntegerField(default=0, verbose_name='Position')),
|
||||
('default_price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Default price')),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variations', to='pretixbase.Item')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Product variation',
|
||||
'ordering': ('position', 'id'),
|
||||
'verbose_name_plural': 'Product variations',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LogEntry',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('datetime', models.DateTimeField(auto_now_add=True)),
|
||||
('action_type', models.CharField(max_length=255)),
|
||||
('data', models.TextField(default='{}')),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
|
||||
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=16, verbose_name='Order code')),
|
||||
('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')),
|
||||
('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')),
|
||||
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
|
||||
('datetime', models.DateTimeField(verbose_name='Date')),
|
||||
('expires', models.DateTimeField(verbose_name='Expiration date')),
|
||||
('payment_date', models.DateTimeField(blank=True, null=True, verbose_name='Payment date')),
|
||||
('payment_provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')),
|
||||
('payment_fee', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Payment method fee')),
|
||||
('payment_info', models.TextField(blank=True, null=True, verbose_name='Payment information')),
|
||||
('payment_manual', models.BooleanField(default=False, verbose_name='Payment state was manually modified')),
|
||||
('total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Total amount')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='pretixbase.Event', verbose_name='Event')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Order',
|
||||
'ordering': ('-datetime',),
|
||||
'verbose_name_plural': 'Orders',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderPosition',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Price')),
|
||||
('attendee_name', models.CharField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=255, null=True, verbose_name='Attendee name')),
|
||||
('voucher_discount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
|
||||
('base_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Item', verbose_name='Item')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='positions', to='pretixbase.Order', verbose_name='Order')),
|
||||
('variation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.ItemVariation', verbose_name='Variation')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Order position',
|
||||
'verbose_name_plural': 'Order positions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Organizer',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Name')),
|
||||
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organizer',
|
||||
'ordering': ('name',),
|
||||
'verbose_name_plural': 'Organizers',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganizerPermission',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('can_create_events', models.BooleanField(default=True, verbose_name='Can create events')),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_perms', to='pretixbase.Organizer')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizer_perms', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organizer permission',
|
||||
'verbose_name_plural': 'Organizer permissions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganizerSetting',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=255)),
|
||||
('value', models.TextField()),
|
||||
('object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='setting_objects', to='pretixbase.Organizer')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Question',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('question', i18nfield.fields.I18nTextField(verbose_name='Question')),
|
||||
('type', models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No')], max_length=5, verbose_name='Question type')),
|
||||
('required', models.BooleanField(default=False, verbose_name='Required question')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='pretixbase.Event')),
|
||||
('items', models.ManyToManyField(blank=True, help_text='This question will be asked to buyers of the selected products', related_name='questions', to='pretixbase.Item', verbose_name='Products')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Question',
|
||||
'verbose_name_plural': 'Questions',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QuestionAnswer',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('answer', models.TextField()),
|
||||
('cartposition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.CartPosition')),
|
||||
('orderposition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.OrderPosition')),
|
||||
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.Question')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Quota',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Name')),
|
||||
('size', models.PositiveIntegerField(blank=True, help_text='Leave empty for an unlimited number of tickets.', null=True, verbose_name='Total capacity')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quotas', to='pretixbase.Event', verbose_name='Event')),
|
||||
('items', models.ManyToManyField(blank=True, related_name='quotas', to='pretixbase.Item', verbose_name='Item')),
|
||||
('variations', models.ManyToManyField(blank=True, related_name='quotas', to='pretixbase.ItemVariation', verbose_name='Variations')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Quota',
|
||||
'verbose_name_plural': 'Quotas',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Voucher',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=255, verbose_name='Voucher code')),
|
||||
('valid_until', models.DateTimeField(blank=True, null=True, verbose_name='Valid until')),
|
||||
('block_quota', models.BooleanField(default=False, help_text="If activated, this voucher will be substracted from the affected product's quotas, such that it is guaranteed that anyone with this voucher code does receive a ticket.", verbose_name='Reserve ticket from quota')),
|
||||
('allow_ignore_quota', models.BooleanField(default=False, help_text='If activated, a holder of this voucher code can buy tickets, even if there are none left.', verbose_name='Allow to bypass quota')),
|
||||
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Set product price to')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.Event', verbose_name='Event')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Voucher',
|
||||
'verbose_name_plural': 'Vouchers',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organizer',
|
||||
name='permitted',
|
||||
field=models.ManyToManyField(related_name='organizers', through='pretixbase.OrganizerPermission', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='voucher',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='category',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='items', to='pretixbase.ItemCategory', verbose_name='Category'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='event',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='items', to='pretixbase.Event', verbose_name='Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='organizer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='pretixbase.Organizer'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='permitted',
|
||||
field=models.ManyToManyField(related_name='events', through='pretixbase.EventPermission', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='event',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event', verbose_name='Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='item',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Item', verbose_name='Item'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='variation',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.ItemVariation', verbose_name='Variation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='voucher',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='item',
|
||||
field=models.ForeignKey(blank=True, help_text="This product is added to the user's cart if the voucher is redeemed.", null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.Item', verbose_name='Product'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='price',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, help_text='If empty, the product will cost its normal price.', max_digits=10, null=True, verbose_name='Set product price to'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='redeemed',
|
||||
field=models.BooleanField(default=False, verbose_name='Redeemed'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='voucher',
|
||||
unique_together=set([('event', 'code')]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='code',
|
||||
field=models.CharField(default=pretix.base.models.vouchers.generate_code, max_length=255, verbose_name='Voucher code'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='tax_rate',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=7, verbose_name='Tax rate'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='tax_value',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Tax value'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='tax_rate',
|
||||
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=7, verbose_name='Taxes included in percent'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InvoiceAddress',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('last_modified', models.DateTimeField(auto_now=True)),
|
||||
('company', models.CharField(blank=True, max_length=255, verbose_name='Company name')),
|
||||
('name', models.CharField(blank=True, max_length=255, verbose_name='Full name')),
|
||||
('street', models.TextField(verbose_name='Address')),
|
||||
('zipcode', models.CharField(max_length=30, verbose_name='ZIP code')),
|
||||
('city', models.CharField(max_length=255, verbose_name='City')),
|
||||
('country', models.CharField(max_length=255, verbose_name='Country')),
|
||||
('vat_id', models.CharField(blank=True, max_length=255, verbose_name='VAT ID')),
|
||||
('order', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invoice_address', to='pretixbase.Order')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='live',
|
||||
field=models.BooleanField(default=False, verbose_name='Shop is live'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='secret',
|
||||
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_position_secret, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='payment_fee_tax_rate',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Payment method fee tax rate'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='payment_fee_tax_value',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Payment method fee tax'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('invoice_no', models.PositiveIntegerField(db_index=True)),
|
||||
('is_cancelled', models.BooleanField(default=False)),
|
||||
('invoice_from', models.TextField()),
|
||||
('invoice_to', models.TextField()),
|
||||
('date', models.DateField(default=datetime.date.today)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.invoices.invoice_filename)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='pretixbase.Event')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='pretixbase.Order')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InvoiceLine',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('description', models.TextField()),
|
||||
('gross_value', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('tax_value', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
|
||||
('tax_rate', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=7)),
|
||||
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='pretixbase.Invoice')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='locale',
|
||||
field=models.CharField(default='en', max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='additional_text',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='invoice',
|
||||
name='is_cancelled',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='is_cancellation',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='refers',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='refered', to='pretixbase.Invoice'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='invoice',
|
||||
unique_together=set([('event', 'invoice_no')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='variation',
|
||||
field=models.ForeignKey(blank=True, help_text='This variation of the product select above is being used.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.ItemVariation', verbose_name='Product variation'),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='logentry',
|
||||
options={'ordering': ('-datetime',)},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='free_price',
|
||||
field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event.', verbose_name='Free price input'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QuestionOption',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('answer', i18nfield.fields.I18nCharField(verbose_name='Answer')),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='question',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No'), ('C', 'Choose one from a list'), ('M', 'Choose multiple from a list')], max_length=5, verbose_name='Question type'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='questionoption',
|
||||
name='question',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='pretixbase.Question'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='questionanswer',
|
||||
name='options',
|
||||
field=models.ManyToManyField(blank=True, related_name='answers', to='pretixbase.QuestionOption'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='quota',
|
||||
field=models.ForeignKey(blank=True, help_text='If enabled, the voucher is valid for any product affected by this quota.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quota', to='pretixbase.Quota', verbose_name='Quota'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='comment',
|
||||
field=models.TextField(blank=True, help_text='The text entered in this field will not be visible to the user and is available for your convenience.', verbose_name='Comment'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='tag',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='You can use this field to group multiple vouchers together. If you enter the same value for multiple vouchers, you can get statistics on how many of them have been redeemed etc.', max_length=255, verbose_name='Tag'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='hide_without_voucher',
|
||||
field=models.BooleanField(default=False, help_text='This product will be hidden from the event page until the user enters a voucher code that is specifically tied to this product (and not via a quota).', verbose_name='This product will only be shown if a voucher matching the product is redeemed.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='require_voucher',
|
||||
field=models.BooleanField(default=False, help_text='To buy this product, the user needs a voucher that applies to this product either directly or via a quota.', verbose_name='This product can only be bought using a voucher.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='logentry',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(auto_now_add=True, db_index=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='logentry',
|
||||
name='object_id',
|
||||
field=models.PositiveIntegerField(db_index=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='code',
|
||||
field=models.CharField(db_index=True, max_length=16, verbose_name='Order code'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], db_index=True, max_length=3, verbose_name='Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='code',
|
||||
field=models.CharField(db_index=True, default=pretix.base.models.vouchers.generate_code, max_length=255, verbose_name='Voucher code'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='comment',
|
||||
field=models.TextField(blank=True, help_text='The text entered in this field will not be visible to the user and is available for your convenience.', verbose_name='Comment'),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cartposition',
|
||||
name='base_price',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cartposition',
|
||||
name='voucher_discount',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='orderposition',
|
||||
name='base_price',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='orderposition',
|
||||
name='voucher_discount',
|
||||
),
|
||||
]
|
||||
@@ -7,10 +7,10 @@ from decimal import Decimal
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.i18n
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.items
|
||||
import pretix.base.models.orders
|
||||
@@ -64,7 +64,7 @@ class Migration(migrations.Migration):
|
||||
name='Event',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', pretix.base.i18n.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
('currency', models.CharField(default='EUR', max_length=10, verbose_name='Default currency')),
|
||||
('date_from', models.DateTimeField(verbose_name='Event start time')),
|
||||
@@ -119,9 +119,9 @@ class Migration(migrations.Migration):
|
||||
name='Item',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Item name')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Item name')),
|
||||
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('description', pretix.base.i18n.I18nTextField(blank=True, help_text='This is shown below the product name in lists.', null=True, verbose_name='Description')),
|
||||
('description', i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the product name in lists.', null=True, verbose_name='Description')),
|
||||
('default_price', models.DecimalField(decimal_places=2, max_digits=7, null=True, verbose_name='Default price')),
|
||||
('tax_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Taxes included in percent')),
|
||||
('admission', models.BooleanField(default=False, help_text='Whether or not buying this product allows a person to enter your event', verbose_name='Is an admission ticket')),
|
||||
@@ -141,7 +141,7 @@ class Migration(migrations.Migration):
|
||||
name='ItemCategory',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Category name')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Category name')),
|
||||
('position', models.IntegerField(default=0)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='pretixbase.Event')),
|
||||
],
|
||||
@@ -156,7 +156,7 @@ class Migration(migrations.Migration):
|
||||
name='ItemVariation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('value', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Description')),
|
||||
('value', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Description')),
|
||||
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('position', models.PositiveIntegerField(default=0, verbose_name='Position')),
|
||||
('default_price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Default price')),
|
||||
@@ -264,7 +264,7 @@ class Migration(migrations.Migration):
|
||||
name='Question',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('question', pretix.base.i18n.I18nTextField(verbose_name='Question')),
|
||||
('question', i18nfield.fields.I18nTextField(verbose_name='Question')),
|
||||
('type', models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No')], max_length=5, verbose_name='Question type')),
|
||||
('required', models.BooleanField(default=False, verbose_name='Required question')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='pretixbase.Event')),
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.i18n
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -19,7 +18,7 @@ class Migration(migrations.Migration):
|
||||
name='QuestionOption',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('answer', pretix.base.i18n.I18nCharField(verbose_name='Answer')),
|
||||
('answer', i18nfield.fields.I18nCharField(verbose_name='Answer')),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
# Generated by Django 1.9.4 on 2016-04-21 19:43
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.i18n
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -17,7 +16,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='itemcategory',
|
||||
name='description',
|
||||
field=pretix.base.i18n.I18nTextField(blank=True, verbose_name='Category description'),
|
||||
field=i18nfield.fields.I18nTextField(blank=True, verbose_name='Category description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='questionanswer',
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-02-03 14:21
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
import django.db.migrations.operations.special
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.validators
|
||||
|
||||
|
||||
def preserve_event_settings(apps, schema_editor):
|
||||
Event = apps.get_model('pretixbase', 'Event')
|
||||
EventSetting = apps.get_model('pretixbase', 'EventSetting')
|
||||
for e in Event.objects.all():
|
||||
EventSetting.objects.create(object=e, key='mail_days_order_expire_warning', value='0')
|
||||
|
||||
|
||||
def forwards42(apps, schema_editor):
|
||||
Order = apps.get_model('pretixbase', 'Order')
|
||||
EventSetting = apps.get_model('pretixbase', 'EventSetting')
|
||||
etz = {
|
||||
s['object_id']: s['value']
|
||||
for s in EventSetting.objects.filter(key='timezone').values('object_id', 'value')
|
||||
}
|
||||
for order in Order.objects.all():
|
||||
tz = pytz.timezone(etz.get(order.event_id, 'UTC'))
|
||||
order.expires = order.expires.astimezone(tz).replace(hour=23, minute=59, second=59)
|
||||
order.save()
|
||||
|
||||
|
||||
def forwards44(apps, schema_editor):
|
||||
CachedTicket = apps.get_model('pretixbase', 'CachedTicket')
|
||||
CachedTicket.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('pretixbase', '0031_auto_20160816_0648'), ('pretixbase', '0032_question_position'), ('pretixbase', '0033_auto_20160821_2222'), ('pretixbase', '0034_auto_20160830_1952'), ('pretixbase', '0032_item_allow_cancel'), ('pretixbase', '0033_auto_20160822_1044'), ('pretixbase', '0035_merge'), ('pretixbase', '0036_auto_20160902_0755'), ('pretixbase', '0037_invoice_payment_provider_text'), ('pretixbase', '0038_auto_20160924_1448'), ('pretixbase', '0039_user_require_2fa'), ('pretixbase', '0040_u2fdevice'), ('pretixbase', '0041_auto_20161018_1654'), ('pretixbase', '0042_order_expires'), ('pretixbase', '0043_globalsetting'), ('pretixbase', '0044_auto_20161101_1610'), ('pretixbase', '0045_auto_20161108_1542'), ('pretixbase', '0046_order_meta_info'), ('pretixbase', '0047_auto_20161126_1300'), ('pretixbase', '0048_auto_20161129_1330')]
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0030_auto_20160816_0646'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='invoice',
|
||||
old_name='invoice_no_charfield',
|
||||
new_name='invoice_no',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='footer_text',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='introductory_text',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='invoice',
|
||||
unique_together=set([('event', 'invoice_no')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='position',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='question',
|
||||
options={'ordering': ('position', 'id'), 'verbose_name': 'Question', 'verbose_name_plural': 'Questions'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='allow_cancel',
|
||||
field=models.BooleanField(default=True, help_text='If you deactivate this, an order including this product might not be cancelled by the user. It may still be cancelled by you.', verbose_name='Allow product to be cancelled'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='expiry_reminder_sent',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=preserve_event_settings,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='payment_provider_text',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventpermission',
|
||||
name='can_view_vouchers',
|
||||
field=models.BooleanField(default=True, verbose_name='Can view vouchers'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='allow_cancel',
|
||||
field=models.BooleanField(default=True, help_text='If you deactivate this, an order including this product might not be canceled by the user. It may still be canceled by you.', verbose_name='Allow product to be canceled'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'canceled'), ('r', 'refunded')], db_index=True, max_length=3, verbose_name='Status'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='require_2fa',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='U2FDevice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='The human-readable name of this device.', max_length=64)),
|
||||
('confirmed', models.BooleanField(default=True, help_text='Is this device ready for use?')),
|
||||
('json_data', models.TextField()),
|
||||
('user', models.ForeignKey(help_text='The user that this device belongs to.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cachedticket',
|
||||
name='cachedfile',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.CachedFile'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=forwards42,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GlobalSetting',
|
||||
fields=[
|
||||
('key', models.CharField(max_length=255, primary_key=True, serialize=False)),
|
||||
('value', models.TextField()),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=forwards44,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cachedticket',
|
||||
name='order',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='order_position',
|
||||
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.OrderPosition'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='expires',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Expiration date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='redeemed',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='Redeemed'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='valid_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Valid until'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='meta_info',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Meta information'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='max_usages',
|
||||
field=models.PositiveIntegerField(default=1, help_text='Number of times this voucher can be redeemed.', verbose_name='Maximum usages'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='redeemed',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Redeemed'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='price_mode',
|
||||
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='set', max_length=100, verbose_name='Price mode'),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='voucher',
|
||||
old_name='price',
|
||||
new_name='value',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='value',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Voucher value'),
|
||||
),
|
||||
]
|
||||
30
src/pretix/base/migrations/0048_auto_20161129_1330.py
Normal file
30
src/pretix/base/migrations/0048_auto_20161129_1330.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.3 on 2016-11-29 13:30
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0047_auto_20161126_1300'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='price_mode',
|
||||
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='set', max_length=100, verbose_name='Price mode'),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='voucher',
|
||||
old_name='price',
|
||||
new_name='value',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='value',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Voucher value'),
|
||||
),
|
||||
]
|
||||
29
src/pretix/base/migrations/0049_checkin.py
Normal file
29
src/pretix/base/migrations/0049_checkin.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2016-12-08 16:47
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0048_auto_20161129_1330'),
|
||||
('pretixdroid', '0002_auto_20161208_1644'),
|
||||
]
|
||||
|
||||
state_operations = [
|
||||
migrations.CreateModel(
|
||||
name='Checkin',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('datetime', models.DateTimeField(auto_now_add=True)),
|
||||
('position', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pretixdroid_checkins', to='pretixbase.OrderPosition')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(state_operations=state_operations)
|
||||
]
|
||||
31
src/pretix/base/migrations/0050_orderposition_positionid.py
Normal file
31
src/pretix/base/migrations/0050_orderposition_positionid.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2016-12-21 17:05
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Order = apps.get_model('pretixbase', 'Order')
|
||||
for o in Order.objects.all():
|
||||
for i, p in enumerate(o.positions.all()):
|
||||
p.positionid = i + 1
|
||||
p.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0049_checkin'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='positionid',
|
||||
field=models.PositiveIntegerField(default=1),
|
||||
),
|
||||
migrations.RunPython(
|
||||
forwards, migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,219 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-02-03 14:12
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
import django.db.migrations.operations.special
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import i18nfield.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.event
|
||||
import pretix.base.models.orders
|
||||
import pretix.base.models.organizer
|
||||
import pretix.base.validators
|
||||
|
||||
|
||||
def forwards50(apps, schema_editor):
|
||||
Order = apps.get_model('pretixbase', 'Order')
|
||||
for o in Order.objects.all():
|
||||
for i, p in enumerate(o.positions.all()):
|
||||
p.positionid = i + 1
|
||||
p.save()
|
||||
|
||||
|
||||
def invalidate_ticket_cache(apps, schema_editor):
|
||||
CachedTicket = apps.get_model('pretixbase', 'CachedTicket')
|
||||
for ct in CachedTicket.objects.all():
|
||||
try:
|
||||
if ct.cachedfile:
|
||||
ct.cachedfile.delete()
|
||||
if ct.cachedfile.file:
|
||||
ct.cachedfile.file.delete(False)
|
||||
except models.Model.DoesNotExist:
|
||||
pass
|
||||
ct.delete()
|
||||
|
||||
|
||||
def merge_names(apps, schema_editor):
|
||||
User = apps.get_model('pretixbase', 'User')
|
||||
for u in User.objects.all():
|
||||
if u.givenname:
|
||||
if u.familyname:
|
||||
u.fullname = u.givenname + " " + u.familyname
|
||||
else:
|
||||
u.fullname = u.givenname
|
||||
elif u.familyname:
|
||||
u.fullname = u.familyname
|
||||
u.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('pretixbase', '0050_orderposition_positionid'), ('pretixbase', '0051_auto_20161221_1720'), ('pretixbase', '0052_auto_20161231_1533'), ('pretixbase', '0053_auto_20170104_1252'), ('pretixbase', '0054_auto_20170107_1058'), ('pretixbase', '0055_organizerpermission_can_change_permissions'), ('pretixbase', '0056_auto_20170107_1251'), ('pretixbase', '0057_auto_20170107_1531'), ('pretixbase', '0058_auto_20170107_1533'), ('pretixbase', '0059_cachedcombinedticket'), ('pretixbase', '0060_auto_20170113_1438'), ('pretixbase', '0061_event_location')]
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0049_checkin'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='positionid',
|
||||
field=models.PositiveIntegerField(default=1),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=forwards50,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=invalidate_ticket_cache,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cachedticket',
|
||||
name='cachedfile',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='extension',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='file',
|
||||
field=models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedticket_name),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='type',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='checkin',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='checkin',
|
||||
name='position',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkins', to='pretixbase.OrderPosition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='price_mode',
|
||||
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='none', max_length=100, verbose_name='Price mode'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RequiredAction',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('datetime', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('done', models.BooleanField(default=False)),
|
||||
('action_type', models.CharField(max_length=255)),
|
||||
('data', models.TextField(default='{}')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('datetime',),
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='requiredaction',
|
||||
name='event',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='requiredaction',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventpermission',
|
||||
name='invite_email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventpermission',
|
||||
name='invite_token',
|
||||
field=models.CharField(blank=True, default=pretix.base.models.event.generate_invite_token, max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='eventpermission',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='event_perms', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organizerpermission',
|
||||
name='can_change_permissions',
|
||||
field=models.BooleanField(default=True, verbose_name='Can change permissions'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organizerpermission',
|
||||
name='invite_email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organizerpermission',
|
||||
name='invite_token',
|
||||
field=models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizerpermission',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='organizer_perms', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='fullname',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Full name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=merge_names,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='familyname',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='givenname',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CachedCombinedTicket',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('provider', models.CharField(max_length=255)),
|
||||
('type', models.CharField(max_length=255)),
|
||||
('extension', models.CharField(max_length=255)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedcombinedticket_name)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order')),
|
||||
('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='location',
|
||||
field=i18nfield.fields.I18nCharField(blank=True, max_length=200, null=True, verbose_name='Location'),
|
||||
),
|
||||
]
|
||||
54
src/pretix/base/migrations/0051_auto_20161221_1720.py
Normal file
54
src/pretix/base/migrations/0051_auto_20161221_1720.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2016-12-21 17:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.orders
|
||||
|
||||
|
||||
def invalidate_ticket_cache(apps, schema_editor):
|
||||
CachedTicket = apps.get_model('pretixbase', 'CachedTicket')
|
||||
for ct in CachedTicket.objects.all():
|
||||
try:
|
||||
if ct.cachedfile:
|
||||
ct.cachedfile.delete()
|
||||
if ct.cachedfile.file:
|
||||
ct.cachedfile.file.delete(False)
|
||||
except models.Model.DoesNotExist:
|
||||
pass
|
||||
ct.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0050_orderposition_positionid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
invalidate_ticket_cache, migrations.RunPython.noop
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cachedticket',
|
||||
name='cachedfile',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='extension',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='file',
|
||||
field=models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedticket_name),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='type',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
42
src/pretix/base/migrations/0051_auto_20170206_2027.py
Normal file
42
src/pretix/base/migrations/0051_auto_20170206_2027.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-02-06 20:27
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0050_orderposition_positionid_squashed_0061_event_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WaitingListEntry',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='E-mail address')),
|
||||
('locale', models.CharField(default='en', max_length=190)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')),
|
||||
('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')),
|
||||
('variation', models.ForeignKey(blank=True, help_text='The variation of the product selected above.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.ItemVariation', verbose_name='Product variation')),
|
||||
('voucher', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher', verbose_name='Assigned voucher')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created'],
|
||||
'verbose_name': 'Waiting list entry',
|
||||
'verbose_name_plural': 'Waiting list entries',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cachedcombinedticket',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True),
|
||||
),
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
32
src/pretix/base/migrations/0052_auto_20161231_1533.py
Normal file
32
src/pretix/base/migrations/0052_auto_20161231_1533.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2016-12-31 15:33
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0051_auto_20161221_1720'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='checkin',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='checkin',
|
||||
name='position',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkins', to='pretixbase.OrderPosition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='price_mode',
|
||||
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='none', max_length=100, verbose_name='Price mode'),
|
||||
),
|
||||
]
|
||||
38
src/pretix/base/migrations/0052_auto_20170324_1506.py
Normal file
38
src/pretix/base/migrations/0052_auto_20170324_1506.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.6 on 2017-03-24 15:06
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0051_auto_20170206_2027'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='invoice',
|
||||
options={'ordering': ('invoice_no',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='orderposition',
|
||||
options={'ordering': ('positionid', 'id'), 'verbose_name': 'Order position', 'verbose_name_plural': 'Order positions'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='max_per_order',
|
||||
field=models.IntegerField(blank=True, help_text='This product can only be bought at most this times within one order. If you keep the field empty or set it to 0, there is no special limit for this product. The limit for the maximum number of items in the whole order applies regardless.', null=True, verbose_name='Maximum times per order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='allow_cancel',
|
||||
field=models.BooleanField(default=True, help_text='If this is active and the general event settings allo wit, orders containing this product can be canceled by the user until the order is paid for. Users cannot cancel paid orders on their own and you can cancel orders at all times, regardless of this setting', verbose_name='Allow product to be canceled'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='default_price',
|
||||
field=models.DecimalField(decimal_places=2, help_text='If this product has multiple variations, you can set different prices for each of the variations. If a variation does not have a special price or if you do not have variations, this price will be used.', max_digits=7, null=True, verbose_name='Default price'),
|
||||
),
|
||||
]
|
||||
48
src/pretix/base/migrations/0053_auto_20170104_1252.py
Normal file
48
src/pretix/base/migrations/0053_auto_20170104_1252.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-01-04 12:52
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0052_auto_20161231_1533'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RequiredAction',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('datetime', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('done', models.BooleanField(default=False)),
|
||||
('action_type', models.CharField(max_length=255)),
|
||||
('data', models.TextField(default='{}')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('datetime',),
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='requiredaction',
|
||||
name='event',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='requiredaction',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
59
src/pretix/base/migrations/0053_auto_20170409_1651.py
Normal file
59
src/pretix/base/migrations/0053_auto_20170409_1651.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-09 16:51
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_global_settings(apps, schema_editor):
|
||||
GlobalSetting = apps.get_model('pretixbase', 'GlobalSetting')
|
||||
GlobalSettingsObject_SettingsStore = apps.get_model('pretixbase', 'GlobalSettingsObject_SettingsStore')
|
||||
|
||||
l = []
|
||||
for s in GlobalSetting.objects.all():
|
||||
l.append(GlobalSettingsObject_SettingsStore(key=s.key, value=s.value))
|
||||
|
||||
GlobalSettingsObject_SettingsStore.objects.bulk_create(l)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0052_auto_20170324_1506'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='EventSetting',
|
||||
new_name='Event_SettingsStore',
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name='OrganizerSetting',
|
||||
new_name='Organizer_SettingsStore',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GlobalSettingsObject_SettingsStore',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(db_index=True, max_length=255)),
|
||||
('value', models.TextField()),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_global_settings, migrations.RunPython.noop
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='GlobalSetting',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event_settingsstore',
|
||||
name='object',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer_settingsstore',
|
||||
name='object',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Organizer'),
|
||||
),
|
||||
]
|
||||
34
src/pretix/base/migrations/0054_auto_20170107_1058.py
Normal file
34
src/pretix/base/migrations/0054_auto_20170107_1058.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-01-07 10:58
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.event
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0053_auto_20170104_1252'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='eventpermission',
|
||||
name='invite_email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventpermission',
|
||||
name='invite_token',
|
||||
field=models.CharField(blank=True, default=pretix.base.models.event.generate_invite_token, max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='eventpermission',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='event_perms', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
40
src/pretix/base/migrations/0054_auto_20170413_1050.py
Normal file
40
src/pretix/base/migrations/0054_auto_20170413_1050.py
Normal file
File diff suppressed because one or more lines are too long
40
src/pretix/base/migrations/0055_auto_20170413_1537.py
Normal file
40
src/pretix/base/migrations/0055_auto_20170413_1537.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-13 15:37
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0054_auto_20170413_1050'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='attendee_email',
|
||||
field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='attendee_email',
|
||||
field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalsettingsobject_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-01-07 12:37
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0054_auto_20170107_1058'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organizerpermission',
|
||||
name='can_change_permissions',
|
||||
field=models.BooleanField(default=True, verbose_name='Can change permissions'),
|
||||
),
|
||||
]
|
||||
34
src/pretix/base/migrations/0056_auto_20170107_1251.py
Normal file
34
src/pretix/base/migrations/0056_auto_20170107_1251.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-01-07 12:51
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.organizer
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0055_organizerpermission_can_change_permissions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organizerpermission',
|
||||
name='invite_email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organizerpermission',
|
||||
name='invite_token',
|
||||
field=models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizerpermission',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='organizer_perms', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
58
src/pretix/base/migrations/0056_auto_20170414_1044.py
Normal file
58
src/pretix/base/migrations/0056_auto_20170414_1044.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-14 10:44
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0055_auto_20170413_1537'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ItemAddOn',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('min_count', models.PositiveIntegerField(default=0, verbose_name='Minimum number')),
|
||||
('max_count', models.PositiveIntegerField(default=1, verbose_name='Maximum number')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='addon_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.CartPosition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemcategory',
|
||||
name='is_addon',
|
||||
field=models.BooleanField(default=False, help_text='If selected, the products belonging to this category are not for sale on their own. They can only be bought in combination with a product that has this category configured as a possible source for add-ons.', verbose_name='Products in this category are add-on products'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='addon_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.OrderPosition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='free_price',
|
||||
field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event. This is currently not supported for products that are bought as an add-on to other products.', verbose_name='Free price input'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='addon_category',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addon_to', to='pretixbase.ItemCategory', verbose_name='Category'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='base_item',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.Item'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='itemaddon',
|
||||
unique_together=set([('base_item', 'addon_category')]),
|
||||
),
|
||||
]
|
||||
42
src/pretix/base/migrations/0057_auto_20170107_1531.py
Normal file
42
src/pretix/base/migrations/0057_auto_20170107_1531.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-01-07 15:31
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.validators
|
||||
|
||||
|
||||
def merge_names(apps, schema_editor):
|
||||
User = apps.get_model('pretixbase', 'User')
|
||||
for u in User.objects.all():
|
||||
if u.givenname:
|
||||
if u.familyname:
|
||||
u.fullname = u.givenname + " " + u.familyname
|
||||
else:
|
||||
u.fullname = u.givenname
|
||||
elif u.familyname:
|
||||
u.fullname = u.familyname
|
||||
u.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0056_auto_20170107_1251'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='fullname',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Full name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.RunPython(merge_names, migrations.RunPython.noop)
|
||||
]
|
||||
30
src/pretix/base/migrations/0057_auto_20170501_2116.py
Normal file
30
src/pretix/base/migrations/0057_auto_20170501_2116.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-05-01 21:16
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0056_auto_20170414_1044'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='itemaddon',
|
||||
options={'ordering': ('position', 'pk')},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='position',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Position'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='description',
|
||||
field=i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the variation name in lists.', null=True, verbose_name='Description'),
|
||||
),
|
||||
]
|
||||
23
src/pretix/base/migrations/0058_auto_20170107_1533.py
Normal file
23
src/pretix/base/migrations/0058_auto_20170107_1533.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-01-07 15:33
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0057_auto_20170107_1531'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='familyname',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='givenname',
|
||||
),
|
||||
]
|
||||
29
src/pretix/base/migrations/0059_cachedcombinedticket.py
Normal file
29
src/pretix/base/migrations/0059_cachedcombinedticket.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-01-13 14:07
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.orders
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0058_auto_20170107_1533'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CachedCombinedTicket',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('provider', models.CharField(max_length=255)),
|
||||
('type', models.CharField(max_length=255)),
|
||||
('extension', models.CharField(max_length=255)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedcombinedticket_name)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order')),
|
||||
],
|
||||
),
|
||||
]
|
||||
28
src/pretix/base/migrations/0060_auto_20170113_1438.py
Normal file
28
src/pretix/base/migrations/0060_auto_20170113_1438.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-01-13 14:38
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0059_cachedcombinedticket'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cachedcombinedticket',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
21
src/pretix/base/migrations/0061_event_location.py
Normal file
21
src/pretix/base/migrations/0061_event_location.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-02-01 04:31
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import i18nfield.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0060_auto_20170113_1438'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='location',
|
||||
field=i18nfield.fields.I18nCharField(blank=True, max_length=200, null=True, verbose_name='Location'),
|
||||
),
|
||||
]
|
||||
@@ -1,15 +1,23 @@
|
||||
from ..settings import GlobalSettingsObject_SettingsStore
|
||||
from .auth import U2FDevice, User
|
||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||
from .event import Event, EventLock, EventPermission, EventSetting
|
||||
from .checkin import Checkin
|
||||
from .event import (
|
||||
Event, Event_SettingsStore, EventLock, EventPermission, RequiredAction,
|
||||
generate_invite_token,
|
||||
)
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
||||
itempicture_upload_to,
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
||||
Quota, itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .orders import (
|
||||
AbstractPosition, CachedTicket, CartPosition, InvoiceAddress, Order,
|
||||
OrderPosition, QuestionAnswer, generate_position_secret, generate_secret,
|
||||
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
|
||||
InvoiceAddress, Order, OrderPosition, QuestionAnswer,
|
||||
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
|
||||
generate_secret,
|
||||
)
|
||||
from .organizer import Organizer, OrganizerPermission, OrganizerSetting
|
||||
from .organizer import Organizer, Organizer_SettingsStore, OrganizerPermission
|
||||
from .vouchers import Voucher
|
||||
from .waitinglist import WaitingListEntry
|
||||
|
||||
@@ -43,10 +43,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
:param email: The user's email address, used for identification.
|
||||
:type email: str
|
||||
:param givenname: The user's given name. May be empty or null.
|
||||
:type givenname: str
|
||||
:param familyname: The user's given name. May be empty or null.
|
||||
:type familyname: str
|
||||
:param fullname: The user's full name. May be empty or null.
|
||||
:type fullname: str
|
||||
:param is_active: Whether this user account is activated.
|
||||
:type is_active: bool
|
||||
:param is_staff: ``True`` for system operators.
|
||||
@@ -64,10 +62,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
|
||||
verbose_name=_('E-mail'))
|
||||
givenname = models.CharField(max_length=255, blank=True, null=True,
|
||||
verbose_name=_('Given name'))
|
||||
familyname = models.CharField(max_length=255, blank=True, null=True,
|
||||
verbose_name=_('Family name'))
|
||||
fullname = models.CharField(max_length=255, blank=True, null=True,
|
||||
verbose_name=_('Full name'))
|
||||
is_active = models.BooleanField(default=True,
|
||||
verbose_name=_('Is active'))
|
||||
is_staff = models.BooleanField(default=False,
|
||||
@@ -100,14 +96,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
"""
|
||||
Returns the first of the following user properties that is found to exist:
|
||||
|
||||
* Given name
|
||||
* Family name
|
||||
* Full name
|
||||
* Email address
|
||||
|
||||
Only present for backwards compatibility
|
||||
"""
|
||||
if self.givenname:
|
||||
return self.givenname
|
||||
elif self.familyname:
|
||||
return self.familyname
|
||||
if self.fullname:
|
||||
return self.fullname
|
||||
else:
|
||||
return self.email
|
||||
|
||||
@@ -115,20 +110,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
"""
|
||||
Returns the first of the following user properties that is found to exist:
|
||||
|
||||
* A combination of given name and family name, depending on the locale
|
||||
* Given name
|
||||
* Family name
|
||||
* User name
|
||||
* Full name
|
||||
* Email address
|
||||
"""
|
||||
if self.givenname and not self.familyname:
|
||||
return self.givenname
|
||||
elif not self.givenname and self.familyname:
|
||||
return self.familyname
|
||||
elif self.familyname and self.givenname:
|
||||
return _('%(family)s, %(given)s') % {
|
||||
'family': self.familyname,
|
||||
'given': self.givenname
|
||||
}
|
||||
if self.fullname:
|
||||
return self.fullname
|
||||
else:
|
||||
return self.email
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
from pretix.base.i18n import I18nJSONEncoder
|
||||
from i18nfield.utils import I18nJSONEncoder
|
||||
|
||||
|
||||
def cachedfile_name(instance, filename: str) -> str:
|
||||
@@ -60,7 +59,6 @@ class LoggingMixin:
|
||||
|
||||
|
||||
class LoggedModel(models.Model, LoggingMixin):
|
||||
logentries = GenericRelation('pretixbase.LogEntry')
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -71,4 +69,8 @@ class LoggedModel(models.Model, LoggingMixin):
|
||||
|
||||
:return: A QuerySet of LogEntry objects
|
||||
"""
|
||||
return self.logentries.all().select_related('user')
|
||||
from .log import LogEntry
|
||||
|
||||
return LogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
|
||||
).select_related('user', 'event')
|
||||
|
||||
10
src/pretix/base/models/checkin.py
Normal file
10
src/pretix/base/models/checkin.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.db import models
|
||||
from django.utils.timezone import now
|
||||
|
||||
|
||||
class Checkin(models.Model):
|
||||
"""
|
||||
A checkin object is created when a person enters the event.
|
||||
"""
|
||||
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins')
|
||||
datetime = models.DateTimeField(default=now)
|
||||
@@ -1,27 +1,31 @@
|
||||
import string
|
||||
import uuid
|
||||
from datetime import date, datetime, time
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField
|
||||
|
||||
from pretix.base.email import CustomSMTPBackend
|
||||
from pretix.base.i18n import I18nCharField
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.settings import SettingsProxy
|
||||
from pretix.base.validators import EventSlugBlacklistValidator
|
||||
from pretix.helpers.daterange import daterange
|
||||
|
||||
from ..settings import settings_hierarkey
|
||||
from .auth import User
|
||||
from .organizer import Organizer
|
||||
from .settings import EventSetting
|
||||
|
||||
|
||||
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
||||
class Event(LoggedModel):
|
||||
"""
|
||||
This model represents an event. An event is anything you can buy
|
||||
@@ -46,12 +50,15 @@ class Event(LoggedModel):
|
||||
:type presale_start: datetime
|
||||
:param presale_end: No tickets will be sold after this date.
|
||||
:type presale_end: datetime
|
||||
:param location: venue
|
||||
:type location: str
|
||||
:param plugins: A comma-separated list of plugin names that are active for this
|
||||
event.
|
||||
:type plugins: str
|
||||
"""
|
||||
|
||||
settings_namespace = 'event'
|
||||
CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES]
|
||||
organizer = models.ForeignKey(Organizer, related_name="events", on_delete=models.PROTECT)
|
||||
name = I18nCharField(
|
||||
max_length=200,
|
||||
@@ -61,7 +68,7 @@ class Event(LoggedModel):
|
||||
max_length=50, db_index=True,
|
||||
help_text=_(
|
||||
"Should be short, only contain lowercase letters and numbers, and must be unique among your events. "
|
||||
"This is being used in addresses and bank transfer references."),
|
||||
"This will be used in order codes, invoice numbers, links and bank transfer references."),
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex="^[a-zA-Z0-9.-]+$",
|
||||
@@ -69,13 +76,14 @@ class Event(LoggedModel):
|
||||
),
|
||||
EventSlugBlacklistValidator()
|
||||
],
|
||||
verbose_name=_("Slug"),
|
||||
verbose_name=_("Short form"),
|
||||
)
|
||||
live = models.BooleanField(default=False, verbose_name=_("Shop is live"))
|
||||
permitted = models.ManyToManyField(User, through='EventPermission',
|
||||
related_name="events", )
|
||||
currency = models.CharField(max_length=10,
|
||||
verbose_name=_("Default currency"),
|
||||
choices=CURRENCY_CHOICES,
|
||||
default=settings.DEFAULT_CURRENCY)
|
||||
date_from = models.DateTimeField(verbose_name=_("Event start time"))
|
||||
date_to = models.DateTimeField(null=True, blank=True,
|
||||
@@ -94,6 +102,11 @@ class Event(LoggedModel):
|
||||
verbose_name=_("Start of presale"),
|
||||
help_text=_("No products will be sold before this date."),
|
||||
)
|
||||
location = I18nCharField(
|
||||
null=True, blank=True,
|
||||
max_length=200,
|
||||
verbose_name=_("Location"),
|
||||
)
|
||||
plugins = models.TextField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Plugins"),
|
||||
@@ -152,6 +165,12 @@ class Event(LoggedModel):
|
||||
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
|
||||
)
|
||||
|
||||
def get_date_range_display(self, tz=None) -> str:
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
if not self.settings.show_date_to or not self.date_to:
|
||||
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
|
||||
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
|
||||
|
||||
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
|
||||
"""
|
||||
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
||||
@@ -163,17 +182,6 @@ class Event(LoggedModel):
|
||||
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
@cached_property
|
||||
def settings(self) -> SettingsProxy:
|
||||
"""
|
||||
Returns an object representing this event's settings.
|
||||
"""
|
||||
try:
|
||||
return SettingsProxy(self, type=EventSetting, parent=self.organizer)
|
||||
except Organizer.DoesNotExist:
|
||||
# Should only happen when creating new events
|
||||
return SettingsProxy(self, type=EventSetting)
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
if self.presale_end and now() > self.presale_end:
|
||||
@@ -208,6 +216,96 @@ class Event(LoggedModel):
|
||||
else:
|
||||
return get_connection(fail_silently=False)
|
||||
|
||||
@property
|
||||
def payment_term_last(self):
|
||||
tz = pytz.timezone(self.settings.timezone)
|
||||
return make_aware(datetime.combine(
|
||||
self.settings.get('payment_term_last', as_type=date),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), tz)
|
||||
|
||||
def copy_data_from(self, other):
|
||||
from . import ItemAddOn, ItemCategory, Item, Question, Quota
|
||||
from ..signals import event_copy_data
|
||||
|
||||
self.plugins = other.plugins
|
||||
self.save()
|
||||
|
||||
category_map = {}
|
||||
for c in ItemCategory.objects.filter(event=other):
|
||||
category_map[c.pk] = c
|
||||
c.pk = None
|
||||
c.event = self
|
||||
c.save()
|
||||
|
||||
item_map = {}
|
||||
variation_map = {}
|
||||
for i in Item.objects.filter(event=other).prefetch_related('variations'):
|
||||
vars = list(i.variations.all())
|
||||
item_map[i.pk] = i
|
||||
i.pk = None
|
||||
i.event = self
|
||||
if i.picture:
|
||||
i.picture.save(i.picture.name, i.picture)
|
||||
if i.category_id:
|
||||
i.category = category_map[i.category_id]
|
||||
i.save()
|
||||
for v in vars:
|
||||
variation_map[v.pk] = v
|
||||
v.pk = None
|
||||
v.item = i
|
||||
v.save()
|
||||
|
||||
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
|
||||
ia.pk = None
|
||||
ia.base_item = item_map[ia.base_item.pk]
|
||||
ia.addon_category = category_map[ia.addon_category.pk]
|
||||
ia.save()
|
||||
|
||||
for q in Quota.objects.filter(event=other).prefetch_related('items', 'variations'):
|
||||
items = list(q.items.all())
|
||||
vars = list(q.variations.all())
|
||||
q.pk = None
|
||||
q.event = self
|
||||
q.save()
|
||||
for i in items:
|
||||
if i.pk in item_map:
|
||||
q.items.add(item_map[i.pk])
|
||||
for v in vars:
|
||||
q.variations.add(variation_map[v.pk])
|
||||
|
||||
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
|
||||
items = list(q.items.all())
|
||||
opts = list(q.options.all())
|
||||
q.pk = None
|
||||
q.event = self
|
||||
q.save()
|
||||
for i in items:
|
||||
q.items.add(item_map[i.pk])
|
||||
for o in opts:
|
||||
o.pk = None
|
||||
o.question = q
|
||||
o.save()
|
||||
|
||||
for s in other.settings._objects.all():
|
||||
s.object = self
|
||||
s.pk = None
|
||||
if s.value.startswith('file://'):
|
||||
fi = default_storage.open(s.value[7:], 'rb')
|
||||
nonce = get_random_string(length=8)
|
||||
fname = '%s/%s/%s.%s.%s' % (
|
||||
self.organizer.slug, self.slug, s.key, nonce, s.value.split('.')[-1]
|
||||
)
|
||||
newname = default_storage.save(fname, fi)
|
||||
s.value = 'file://' + newname
|
||||
s.save()
|
||||
|
||||
event_copy_data.send(sender=self, other=other)
|
||||
|
||||
|
||||
def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
|
||||
|
||||
class EventPermission(models.Model):
|
||||
"""
|
||||
@@ -229,7 +327,9 @@ class EventPermission(models.Model):
|
||||
"""
|
||||
|
||||
event = models.ForeignKey(Event, related_name="user_perms", on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, related_name="event_perms", on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, related_name="event_perms", on_delete=models.CASCADE, null=True, blank=True)
|
||||
invite_email = models.EmailField(null=True, blank=True)
|
||||
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
|
||||
can_change_settings = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can change event settings")
|
||||
@@ -274,3 +374,42 @@ class EventLock(models.Model):
|
||||
event = models.CharField(max_length=36, primary_key=True)
|
||||
date = models.DateTimeField(auto_now=True)
|
||||
token = models.UUIDField(default=uuid.uuid4)
|
||||
|
||||
|
||||
class RequiredAction(models.Model):
|
||||
"""
|
||||
Represents an action that is to be done by an admin. The admin will be
|
||||
displayed a list of actions to do.
|
||||
|
||||
:param datatime: The timestamp of the required action
|
||||
:type datetime: datetime
|
||||
:param user: The user that performed the action
|
||||
:type user: User
|
||||
:param done: If this action has been completed or dismissed
|
||||
:type done: bool
|
||||
:param action_type: The type of action that has to be performed. This is
|
||||
used to look up the renderer used to describe the action in a human-
|
||||
readable way. This should be some namespaced value using dotted
|
||||
notation to avoid duplicates, e.g.
|
||||
``"pretix.plugins.banktransfer.incoming_transfer"``.
|
||||
:type action_type: str
|
||||
:param data: Arbitrary data that can be used by the log action renderer
|
||||
:type data: str
|
||||
"""
|
||||
datetime = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
done = models.BooleanField(default=False)
|
||||
user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.CASCADE)
|
||||
action_type = models.CharField(max_length=255)
|
||||
data = models.TextField(default='{}')
|
||||
|
||||
class Meta:
|
||||
ordering = ('datetime',)
|
||||
|
||||
def display(self, request):
|
||||
from ..signals import requiredaction_display
|
||||
|
||||
for receiver, response in requiredaction_display.send(self.event, action=self, request=request):
|
||||
if response:
|
||||
return response
|
||||
return self.action_type
|
||||
|
||||
@@ -122,6 +122,7 @@ class Invoice(models.Model):
|
||||
|
||||
class Meta:
|
||||
unique_together = ('event', 'invoice_no')
|
||||
ordering = ('invoice_no',)
|
||||
|
||||
|
||||
class InvoiceLine(models.Model):
|
||||
|
||||
@@ -5,13 +5,15 @@ from decimal import Decimal
|
||||
from typing import Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Func, Q, Sum
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.i18n import I18nCharField, I18nTextField
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
|
||||
from .event import Event
|
||||
@@ -43,6 +45,13 @@ class ItemCategory(LoggedModel):
|
||||
position = models.IntegerField(
|
||||
default=0
|
||||
)
|
||||
is_addon = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Products in this category are add-on products'),
|
||||
help_text=_('If selected, the products belonging to this category are not for sale on their own. They can '
|
||||
'only be bought in combination with a product that has this category configured as a possible '
|
||||
'source for add-ons.')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product category")
|
||||
@@ -50,6 +59,8 @@ class ItemCategory(LoggedModel):
|
||||
ordering = ('position', 'id')
|
||||
|
||||
def __str__(self):
|
||||
if self.is_addon:
|
||||
return _('{category} (Add-On products)').format(category=str(self.name))
|
||||
return str(self.name)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
@@ -110,6 +121,10 @@ class Item(LoggedModel):
|
||||
:type hide_without_voucher: bool
|
||||
:param allow_cancel: If set to ``False``, an order with this product can not be canceled by the user.
|
||||
:type allow_cancel: bool
|
||||
:param max_per_order: Maximum number of times this item can be in an order. None for unlimited.
|
||||
:type max_per_order: int
|
||||
:param min_per_order: Minimum number of times this item needs to be in an order if bought at all. None for unlimited.
|
||||
:type min_per_order: int
|
||||
"""
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -140,6 +155,9 @@ class Item(LoggedModel):
|
||||
)
|
||||
default_price = models.DecimalField(
|
||||
verbose_name=_("Default price"),
|
||||
help_text=_("If this product has multiple variations, you can set different prices for each of the "
|
||||
"variations. If a variation does not have a special price or if you do not have variations, "
|
||||
"this price will be used."),
|
||||
max_digits=7, decimal_places=2, null=True
|
||||
)
|
||||
free_price = models.BooleanField(
|
||||
@@ -147,7 +165,8 @@ class Item(LoggedModel):
|
||||
verbose_name=_("Free price input"),
|
||||
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
|
||||
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
|
||||
"additional donations for your event.")
|
||||
"additional donations for your event. This is currently not supported for products that are "
|
||||
"bought as an add-on to other products.")
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
verbose_name=_("Taxes included in percent"),
|
||||
@@ -195,9 +214,25 @@ class Item(LoggedModel):
|
||||
allow_cancel = models.BooleanField(
|
||||
verbose_name=_('Allow product to be canceled'),
|
||||
default=True,
|
||||
help_text=_('If you deactivate this, an order including this product might not be canceled by the user. '
|
||||
'It may still be canceled by you.')
|
||||
help_text=_('If this is active and the general event settings allo wit, orders containing this product can be '
|
||||
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
|
||||
'and you can cancel orders at all times, regardless of this setting')
|
||||
)
|
||||
min_per_order = models.IntegerField(
|
||||
verbose_name=_('Minimum amount per order'),
|
||||
null=True, blank=True,
|
||||
help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep '
|
||||
'the field empty or set it to 0, there is no special limit for this product.')
|
||||
)
|
||||
max_per_order = models.IntegerField(
|
||||
verbose_name=_('Maximum amount per order'),
|
||||
null=True, blank=True,
|
||||
help_text=_('This product can only be bought at most this many times within one order. If you keep the field '
|
||||
'empty or set it to 0, there is no special limit for this product. The limit for the maximum '
|
||||
'number of items in the whole order applies regardless.')
|
||||
)
|
||||
# !!! Attention: If you add new fields here, also add them to the copying code in
|
||||
# pretix/control/views/item.py if applicable.
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product")
|
||||
@@ -217,6 +252,11 @@ class Item(LoggedModel):
|
||||
if self.event:
|
||||
self.event.get_cache().clear()
|
||||
|
||||
@property
|
||||
def default_price_net(self):
|
||||
tax_value = round_decimal(self.default_price * (1 - 100 / (100 + self.tax_rate)))
|
||||
return self.default_price - tax_value
|
||||
|
||||
def is_available(self, now_dt: datetime=None) -> bool:
|
||||
"""
|
||||
Returns whether this item is available according to its ``active`` flag
|
||||
@@ -231,7 +271,7 @@ class Item(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, _cache=None):
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None):
|
||||
"""
|
||||
This method is used to determine whether this Item is currently available
|
||||
for sale.
|
||||
@@ -253,7 +293,7 @@ class Item(LoggedModel):
|
||||
if self.variations.count() > 0: # NOQA
|
||||
raise ValueError('Do not call this directly on items which have variations '
|
||||
'but call this on their ItemVariation objects')
|
||||
return min([q.availability(_cache=_cache) for q in check_quotas],
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
|
||||
@cached_property
|
||||
@@ -270,6 +310,8 @@ class ItemVariation(models.Model):
|
||||
:type item: Item
|
||||
:param value: A string defining this variation
|
||||
:type value: str
|
||||
:param description: A short description
|
||||
:type description: str
|
||||
:param active: Whether this variation is being sold.
|
||||
:type active: bool
|
||||
:param default_price: This variation's default price
|
||||
@@ -287,6 +329,11 @@ class ItemVariation(models.Model):
|
||||
default=True,
|
||||
verbose_name=_("Active"),
|
||||
)
|
||||
description = I18nTextField(
|
||||
verbose_name=_("Description"),
|
||||
help_text=_("This is shown below the variation name in lists."),
|
||||
null=True, blank=True,
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
@@ -305,6 +352,15 @@ class ItemVariation(models.Model):
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
@property
|
||||
def price(self):
|
||||
return self.default_price if self.default_price is not None else self.item.default_price
|
||||
|
||||
@property
|
||||
def net_price(self):
|
||||
tax_value = round_decimal(self.price * (1 - 100 / (100 + self.item.tax_rate)))
|
||||
return self.price - tax_value
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
if self.item:
|
||||
@@ -315,7 +371,7 @@ class ItemVariation(models.Model):
|
||||
if self.item:
|
||||
self.item.event.get_cache().clear()
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, _cache=None) -> Tuple[int, int]:
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]:
|
||||
"""
|
||||
This method is used to determine whether this ItemVariation is currently
|
||||
available for sale in terms of quotas.
|
||||
@@ -324,6 +380,7 @@ class ItemVariation(models.Model):
|
||||
quotas will be ignored in the calculation. If this leads
|
||||
to no quotas being checked at all, this method will return
|
||||
unlimited availability.
|
||||
:param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation.
|
||||
:returns: any of the return codes of :py:meth:`Quota.availability()`.
|
||||
"""
|
||||
check_quotas = set(self.quotas.all())
|
||||
@@ -331,7 +388,7 @@ class ItemVariation(models.Model):
|
||||
check_quotas -= set(ignored_quotas)
|
||||
if not check_quotas:
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize
|
||||
return min([q.availability(_cache=_cache) for q in check_quotas],
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
|
||||
def __lt__(self, other):
|
||||
@@ -340,11 +397,49 @@ class ItemVariation(models.Model):
|
||||
return self.position < other.position
|
||||
|
||||
|
||||
class ItemAddOn(models.Model):
|
||||
"""
|
||||
An instance of this model indicates that buying a ticket of the time ``base_item``
|
||||
allows you to add up to ``max_count`` items from the category ``addon_category``
|
||||
to your order that will be associated with the base item.
|
||||
"""
|
||||
base_item = models.ForeignKey(
|
||||
Item,
|
||||
related_name='addons'
|
||||
)
|
||||
addon_category = models.ForeignKey(
|
||||
ItemCategory,
|
||||
related_name='addon_to',
|
||||
verbose_name=_('Category')
|
||||
)
|
||||
min_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_('Minimum number')
|
||||
)
|
||||
max_count = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name=_('Maximum number')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('base_item', 'addon_category'),)
|
||||
ordering = ('position', 'pk')
|
||||
|
||||
def clean(self):
|
||||
if self.max_count < self.min_count:
|
||||
raise ValidationError(_('The minimum number needs to be lower than the maximum number.'))
|
||||
|
||||
|
||||
class Question(LoggedModel):
|
||||
"""
|
||||
A question is an input field that can be used to extend a ticket
|
||||
by custom information, e.g. "Attendee age". A question can allow one of several
|
||||
input types, currently:
|
||||
A question is an input field that can be used to extend a ticket by custom information,
|
||||
e.g. "Attendee age". The answers are found next to the position. The answers may be found
|
||||
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of
|
||||
several input types, currently:
|
||||
|
||||
* a number (``TYPE_NUMBER``)
|
||||
* a one-line string (``TYPE_STRING``)
|
||||
@@ -534,7 +629,7 @@ class Quota(LoggedModel):
|
||||
if self.event:
|
||||
self.event.get_cache().clear()
|
||||
|
||||
def availability(self, now_dt: datetime=None, _cache=None) -> Tuple[int, int]:
|
||||
def availability(self, now_dt: datetime=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]:
|
||||
"""
|
||||
This method is used to determine whether Items or ItemVariations belonging
|
||||
to this quota should currently be available for sale.
|
||||
@@ -542,14 +637,18 @@ class Quota(LoggedModel):
|
||||
:returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants
|
||||
and the second is the number of available tickets.
|
||||
"""
|
||||
if _cache and count_waitinglist is not _cache.get('_count_waitinglist', True):
|
||||
_cache.clear()
|
||||
|
||||
if _cache is not None and self.pk in _cache:
|
||||
return _cache[self.pk]
|
||||
res = self._availability(now_dt)
|
||||
res = self._availability(now_dt, count_waitinglist)
|
||||
if _cache is not None:
|
||||
_cache[self.pk] = res
|
||||
_cache['_count_waitinglist'] = count_waitinglist
|
||||
return res
|
||||
|
||||
def _availability(self, now_dt: datetime=None):
|
||||
def _availability(self, now_dt: datetime=None, count_waitinglist=True):
|
||||
now_dt = now_dt or now()
|
||||
size_left = self.size
|
||||
if size_left is None:
|
||||
@@ -566,12 +665,17 @@ class Quota(LoggedModel):
|
||||
|
||||
size_left -= self.count_blocking_vouchers(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
size_left -= self.count_in_cart(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
if count_waitinglist:
|
||||
size_left -= self.count_waiting_list_pending()
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
return Quota.AVAILABILITY_OK, size_left
|
||||
|
||||
def count_blocking_vouchers(self, now_dt: datetime=None) -> int:
|
||||
@@ -584,6 +688,7 @@ class Quota(LoggedModel):
|
||||
func = 'GREATEST'
|
||||
|
||||
return Voucher.objects.filter(
|
||||
Q(event=self.event) &
|
||||
Q(block_quota=True) &
|
||||
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) &
|
||||
Q(Q(self._position_lookup) | Q(quota=self))
|
||||
@@ -591,11 +696,19 @@ class Quota(LoggedModel):
|
||||
free=Sum(Func(F('max_usages') - F('redeemed'), 0, function=func))
|
||||
)['free'] or 0
|
||||
|
||||
def count_waiting_list_pending(self) -> int:
|
||||
from pretix.base.models import WaitingListEntry
|
||||
return WaitingListEntry.objects.filter(
|
||||
Q(voucher__isnull=True) &
|
||||
self._position_lookup
|
||||
).distinct().count()
|
||||
|
||||
def count_in_cart(self, now_dt: datetime=None) -> int:
|
||||
from pretix.base.models import CartPosition
|
||||
|
||||
now_dt = now_dt or now()
|
||||
return CartPosition.objects.filter(
|
||||
Q(event=self.event) &
|
||||
Q(expires__gte=now_dt) &
|
||||
~Q(
|
||||
Q(voucher__isnull=False) & Q(voucher__block_quota=True)
|
||||
@@ -609,14 +722,14 @@ class Quota(LoggedModel):
|
||||
|
||||
# This query has beeen benchmarked against a Count('id', distinct=True) aggregate and won by a small margin.
|
||||
return OrderPosition.objects.filter(
|
||||
self._position_lookup, order__status=Order.STATUS_PENDING,
|
||||
self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event
|
||||
).values('id').distinct().count()
|
||||
|
||||
def count_paid_orders(self):
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
|
||||
return OrderPosition.objects.filter(
|
||||
self._position_lookup, order__status=Order.STATUS_PAID
|
||||
self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event
|
||||
).values('id').distinct().count()
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import json
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class LogEntry(models.Model):
|
||||
@@ -32,7 +37,7 @@ class LogEntry(models.Model):
|
||||
data = models.TextField(default='{}')
|
||||
|
||||
class Meta:
|
||||
ordering = ('-datetime', )
|
||||
ordering = ('-datetime',)
|
||||
|
||||
def display(self):
|
||||
from ..signals import logentry_display
|
||||
@@ -41,3 +46,90 @@ class LogEntry(models.Model):
|
||||
if response:
|
||||
return response
|
||||
return self.action_type
|
||||
|
||||
@cached_property
|
||||
def display_object(self):
|
||||
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event
|
||||
|
||||
if self.content_type.model_class() is Event:
|
||||
return ''
|
||||
|
||||
co = self.content_object
|
||||
a_map = None
|
||||
a_text = None
|
||||
|
||||
if isinstance(co, Order):
|
||||
a_text = _('Order {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.order', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'code': co.code
|
||||
}),
|
||||
'val': co.code,
|
||||
}
|
||||
elif isinstance(co, Voucher):
|
||||
a_text = _('Voucher {val}…')
|
||||
a_map = {
|
||||
'href': reverse('control:event.voucher', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'voucher': co.id
|
||||
}),
|
||||
'val': co.code[:6],
|
||||
}
|
||||
elif isinstance(co, Item):
|
||||
a_text = _('Product {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.item', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'item': co.id
|
||||
}),
|
||||
'val': co.name,
|
||||
}
|
||||
elif isinstance(co, Quota):
|
||||
a_text = _('Quota {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.items.quotas.show', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'quota': co.id
|
||||
}),
|
||||
'val': co.name,
|
||||
}
|
||||
elif isinstance(co, ItemCategory):
|
||||
a_text = _('Category {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.items.categories.edit', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'category': co.id
|
||||
}),
|
||||
'val': co.name,
|
||||
}
|
||||
elif isinstance(co, Question):
|
||||
a_text = _('Question {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.items.questions.show', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'question': co.id
|
||||
}),
|
||||
'val': co.question,
|
||||
}
|
||||
|
||||
if a_text and a_map:
|
||||
a_map['val'] = '<a href="{href}">{val}</a>'.format_map(a_map)
|
||||
return a_text.format_map(a_map)
|
||||
elif a_text:
|
||||
return a_text
|
||||
else:
|
||||
return ''
|
||||
|
||||
@cached_property
|
||||
def parsed_data(self):
|
||||
return json.loads(self.data)
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
raise TypeError("Logs cannot be deleted.")
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import copy
|
||||
import os
|
||||
import string
|
||||
from datetime import date, datetime, time
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import List, Union
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import F
|
||||
from django.db.models import F, Sum
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from ..decimal import round_decimal
|
||||
from .base import CachedFile, LoggedModel
|
||||
from .base import LoggedModel
|
||||
from .event import Event
|
||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||
|
||||
@@ -188,6 +191,10 @@ class Order(LoggedModel):
|
||||
"""
|
||||
return '{event}-{code}'.format(event=self.event.slug.upper(), code=self.code)
|
||||
|
||||
@property
|
||||
def changable(self):
|
||||
return self.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.code:
|
||||
self.assign_code()
|
||||
@@ -209,6 +216,18 @@ class Order(LoggedModel):
|
||||
else:
|
||||
self.payment_fee_tax_value = Decimal('0.00')
|
||||
|
||||
@property
|
||||
def payment_fee_net(self):
|
||||
return self.payment_fee - self.payment_fee_tax_value
|
||||
|
||||
@cached_property
|
||||
def tax_total(self):
|
||||
return (self.positions.aggregate(s=Sum('tax_value'))['s'] or 0) + self.payment_fee_tax_value
|
||||
|
||||
@property
|
||||
def net_total(self):
|
||||
return self.total - self.tax_total
|
||||
|
||||
@staticmethod
|
||||
def normalize_code(code):
|
||||
tr = str.maketrans({
|
||||
@@ -227,7 +246,7 @@ class Order(LoggedModel):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=settings.ENTROPY['order_code'], allowed_chars=charset)
|
||||
if not Order.objects.filter(event=self.event, code=code).exists():
|
||||
if not Order.objects.filter(event__organizer=self.event.organizer, code=code).exists():
|
||||
self.code = code
|
||||
return
|
||||
|
||||
@@ -267,25 +286,22 @@ class Order(LoggedModel):
|
||||
|
||||
def _can_be_paid(self) -> Union[bool, str]:
|
||||
error_messages = {
|
||||
'late': _("The payment is too late to be accepted."),
|
||||
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
|
||||
"payment settings is over."),
|
||||
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
|
||||
"payments should be accepted in the payment settings."),
|
||||
}
|
||||
|
||||
if self.event.settings.get('payment_term_last'):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
last_date = make_aware(datetime.combine(
|
||||
self.event.settings.get('payment_term_last', as_type=date),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), tz)
|
||||
|
||||
if now() > last_date:
|
||||
return error_messages['late']
|
||||
if not self.event.settings.get('payment_term_accept_late'):
|
||||
return error_messages['late']
|
||||
if now() > self.event.payment_term_last:
|
||||
return error_messages['late_lastdate']
|
||||
|
||||
if self.status == self.STATUS_PENDING:
|
||||
return True
|
||||
else:
|
||||
return self._is_still_available()
|
||||
if not self.event.settings.get('payment_term_accept_late'):
|
||||
return error_messages['late']
|
||||
|
||||
return self._is_still_available()
|
||||
|
||||
def _is_still_available(self, now_dt: datetime=None) -> Union[bool, str]:
|
||||
error_messages = {
|
||||
@@ -378,6 +394,8 @@ class AbstractPosition(models.Model):
|
||||
:type price: decimal.Decimal
|
||||
:param attendee_name: The attendee's name, if entered.
|
||||
:type attendee_name: str
|
||||
:param attendee_email: The attendee's email, if entered.
|
||||
:type attendee_email: str
|
||||
:param voucher: A voucher that has been applied to this sale
|
||||
:type voucher: Voucher
|
||||
"""
|
||||
@@ -402,9 +420,17 @@ class AbstractPosition(models.Model):
|
||||
blank=True, null=True,
|
||||
help_text=_("Empty, if this product is not an admission ticket")
|
||||
)
|
||||
attendee_email = models.EmailField(
|
||||
verbose_name=_("Attendee email"),
|
||||
blank=True, null=True,
|
||||
help_text=_("Empty, if this product is not an admission ticket")
|
||||
)
|
||||
voucher = models.ForeignKey(
|
||||
'Voucher', null=True, blank=True
|
||||
)
|
||||
addon_to = models.ForeignKey(
|
||||
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -429,6 +455,10 @@ class AbstractPosition(models.Model):
|
||||
else:
|
||||
q.answer = ""
|
||||
|
||||
@property
|
||||
def net_price(self):
|
||||
return self.price - self.tax_value
|
||||
|
||||
|
||||
class OrderPosition(AbstractPosition):
|
||||
"""
|
||||
@@ -439,6 +469,7 @@ class OrderPosition(AbstractPosition):
|
||||
:param order: The order this position is a part of
|
||||
:type order: Order
|
||||
"""
|
||||
positionid = models.PositiveIntegerField(default=1)
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
@@ -458,24 +489,36 @@ class OrderPosition(AbstractPosition):
|
||||
class Meta:
|
||||
verbose_name = _("Order position")
|
||||
verbose_name_plural = _("Order positions")
|
||||
ordering = ("positionid", "id")
|
||||
|
||||
@classmethod
|
||||
def transform_cart_positions(cls, cp: List, order) -> list:
|
||||
from . import Voucher
|
||||
|
||||
ops = []
|
||||
for cartpos in cp:
|
||||
cp_mapping = {}
|
||||
# The sorting key ensures that all addons come directly after the position they refer to
|
||||
for i, cartpos in enumerate(sorted(cp, key=lambda c: (c.addon_to_id or c.pk, c.addon_to_id or 0))):
|
||||
op = OrderPosition(order=order)
|
||||
for f in AbstractPosition._meta.fields:
|
||||
setattr(op, f.name, getattr(cartpos, f.name))
|
||||
if f.name == 'addon_to':
|
||||
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
|
||||
else:
|
||||
setattr(op, f.name, getattr(cartpos, f.name))
|
||||
op._calculate_tax()
|
||||
op.positionid = i + 1
|
||||
op.save()
|
||||
cp_mapping[cartpos.pk] = op
|
||||
for answ in cartpos.answers.all():
|
||||
answ.orderposition = op
|
||||
answ.cartposition = None
|
||||
answ.save()
|
||||
if cartpos.voucher:
|
||||
Voucher.objects.filter(pk=cartpos.voucher.pk).update(redeemed=F('redeemed') + 1)
|
||||
cartpos.voucher.log_action('pretix.voucher.redeemed', {
|
||||
'order_code': order.code
|
||||
})
|
||||
|
||||
cartpos.delete()
|
||||
return ops
|
||||
|
||||
@@ -494,6 +537,9 @@ class OrderPosition(AbstractPosition):
|
||||
def save(self, *args, **kwargs):
|
||||
if self.tax_rate is None:
|
||||
self._calculate_tax()
|
||||
if self.pk is None:
|
||||
while OrderPosition.objects.filter(secret=self.secret).exists():
|
||||
self.secret = generate_position_secret()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -560,7 +606,50 @@ class InvoiceAddress(models.Model):
|
||||
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'))
|
||||
|
||||
|
||||
def cachedticket_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
||||
return 'tickets/{org}/{ev}/{code}-{no}-{prov}-{secret}.dat'.format(
|
||||
org=instance.order_position.order.event.organizer.slug,
|
||||
ev=instance.order_position.order.event.slug,
|
||||
prov=instance.provider,
|
||||
no=instance.order_position.positionid,
|
||||
code=instance.order_position.order.code,
|
||||
secret=secret,
|
||||
ext=os.path.splitext(filename)[1]
|
||||
)
|
||||
|
||||
|
||||
def cachedcombinedticket_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
||||
return 'tickets/{org}/{ev}/{code}-{prov}-{secret}.dat'.format(
|
||||
org=instance.order.event.organizer.slug,
|
||||
ev=instance.order.event.slug,
|
||||
prov=instance.provider,
|
||||
code=instance.order.code,
|
||||
secret=secret
|
||||
)
|
||||
|
||||
|
||||
class CachedTicket(models.Model):
|
||||
order_position = models.ForeignKey(OrderPosition, on_delete=models.CASCADE)
|
||||
cachedfile = models.ForeignKey(CachedFile, on_delete=models.CASCADE, null=True)
|
||||
provider = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=255)
|
||||
extension = models.CharField(max_length=255)
|
||||
file = models.FileField(null=True, blank=True, upload_to=cachedticket_name)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class CachedCombinedTicket(models.Model):
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE)
|
||||
provider = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=255)
|
||||
extension = models.CharField(max_length=255)
|
||||
file = models.FileField(null=True, blank=True, upload_to=cachedcombinedticket_name)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CachedTicket)
|
||||
def cachedticket_delete(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
# Pass false so FileField doesn't save the model.
|
||||
instance.file.delete(False)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import string
|
||||
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.settings import SettingsProxy
|
||||
from pretix.base.validators import OrganizerSlugBlacklistValidator
|
||||
|
||||
from ..settings import settings_hierarkey
|
||||
from .auth import User
|
||||
from .settings import OrganizerSetting
|
||||
|
||||
|
||||
@settings_hierarkey.add(cache_namespace='organizer')
|
||||
class Organizer(LoggedModel):
|
||||
"""
|
||||
This model represents an entity organizing events, e.g. a company, institution,
|
||||
@@ -38,7 +40,7 @@ class Organizer(LoggedModel):
|
||||
),
|
||||
OrganizerSlugBlacklistValidator()
|
||||
],
|
||||
verbose_name=_("Slug"),
|
||||
verbose_name=_("Short form"),
|
||||
)
|
||||
permitted = models.ManyToManyField(User, through='OrganizerPermission',
|
||||
related_name="organizers")
|
||||
@@ -56,14 +58,6 @@ class Organizer(LoggedModel):
|
||||
self.get_cache().clear()
|
||||
return obj
|
||||
|
||||
@cached_property
|
||||
def settings(self) -> SettingsProxy:
|
||||
"""
|
||||
Returns an object representing this organizer's settings
|
||||
"""
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
return SettingsProxy(self, type=OrganizerSetting, parent=GlobalSettingsObject())
|
||||
|
||||
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
|
||||
"""
|
||||
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
||||
@@ -76,6 +70,10 @@ class Organizer(LoggedModel):
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
|
||||
def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
|
||||
|
||||
class OrganizerPermission(models.Model):
|
||||
"""
|
||||
The relation between an Organizer and a User who has permissions to
|
||||
@@ -91,11 +89,17 @@ class OrganizerPermission(models.Model):
|
||||
"""
|
||||
|
||||
organizer = models.ForeignKey(Organizer, related_name="user_perms", on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, related_name="organizer_perms")
|
||||
user = models.ForeignKey(User, related_name="organizer_perms", on_delete=models.CASCADE, null=True, blank=True)
|
||||
invite_email = models.EmailField(null=True, blank=True)
|
||||
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
|
||||
can_create_events = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can create events"),
|
||||
)
|
||||
can_change_permissions = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can change permissions"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Organizer permission")
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class GlobalSetting(models.Model):
|
||||
"""
|
||||
A global setting is a key-value setting which can be set for a
|
||||
pretix instance. It will be inherited by all events and organizers.
|
||||
It is filled via the register_global_settings signal.
|
||||
"""
|
||||
key = models.CharField(max_length=255, primary_key=True)
|
||||
value = models.TextField()
|
||||
|
||||
def __init__(self, *args, object=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class OrganizerSetting(models.Model):
|
||||
"""
|
||||
An organizer setting is a key-value setting which can be set for an
|
||||
organizer. It will be inherited by the events of this organizer
|
||||
"""
|
||||
object = models.ForeignKey('Organizer', related_name='setting_objects', on_delete=models.CASCADE)
|
||||
key = models.CharField(max_length=255)
|
||||
value = models.TextField()
|
||||
|
||||
|
||||
class EventSetting(models.Model):
|
||||
"""
|
||||
An event setting is a key-value setting which can be set for a
|
||||
specific event
|
||||
"""
|
||||
object = models.ForeignKey('Event', related_name='setting_objects', on_delete=models.CASCADE)
|
||||
key = models.CharField(max_length=255)
|
||||
value = models.TextField()
|
||||
@@ -1,3 +1,5 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
@@ -5,6 +7,7 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from ..decimal import round_decimal
|
||||
from .base import LoggedModel
|
||||
from .event import Event
|
||||
from .items import Item, ItemVariation, Quota
|
||||
@@ -40,8 +43,11 @@ class Voucher(LoggedModel):
|
||||
:type block_quota: bool
|
||||
:param allow_ignore_quota: If set to true, this voucher can be redeemed even if the event is sold out
|
||||
:type allow_ignore_quota: bool
|
||||
:param price: If set, the voucher will allow the sale of associated items for this price
|
||||
:type price: decimal.Decimal
|
||||
:param price_mode: Sets how this voucher affects a product's price. Can be ``none``, ``set``, ``subtract``
|
||||
or ``percent``.
|
||||
:type price_mode: str
|
||||
:param value: The value by which the price should be modified in the way specified by ``price_mode``.
|
||||
:type value: decimal.Decimal
|
||||
:param item: If set, the item to sell
|
||||
:type item: Item
|
||||
:param variation: If set, the variation to sell
|
||||
@@ -59,6 +65,13 @@ class Voucher(LoggedModel):
|
||||
* You need to either select a quota or an item
|
||||
* If you select an item that has variations but do not select a variation, you cannot set block_quota
|
||||
"""
|
||||
PRICE_MODES = (
|
||||
('none', _('No effect')),
|
||||
('set', _('Set product price to')),
|
||||
('subtract', _('Subtract from product price')),
|
||||
('percent', _('Reduce product price by (%)')),
|
||||
)
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -98,10 +111,15 @@ class Voucher(LoggedModel):
|
||||
"If activated, a holder of this voucher code can buy tickets, even if there are none left."
|
||||
)
|
||||
)
|
||||
price = models.DecimalField(
|
||||
verbose_name=_("Set product price to"),
|
||||
price_mode = models.CharField(
|
||||
verbose_name=_("Price mode"),
|
||||
max_length=100,
|
||||
choices=PRICE_MODES,
|
||||
default='none'
|
||||
)
|
||||
value = models.DecimalField(
|
||||
verbose_name=_("Voucher value"),
|
||||
decimal_places=2, max_digits=10, null=True, blank=True,
|
||||
help_text=_('If empty, the product will cost its normal price.')
|
||||
)
|
||||
item = models.ForeignKey(
|
||||
Item, related_name='vouchers',
|
||||
@@ -192,11 +210,11 @@ class Voucher(LoggedModel):
|
||||
Returns whether this voucher applies to a given item (and optionally
|
||||
a variation).
|
||||
"""
|
||||
if self.quota:
|
||||
return item.quotas.filter(pk=self.quota.pk).exists()
|
||||
if self.item and not self.variation:
|
||||
return self.item == item
|
||||
return (self.item == item) and (self.variation == variation)
|
||||
if self.quota_id:
|
||||
return item.quotas.filter(pk=self.quota_id).exists()
|
||||
if self.item_id and not self.variation_id:
|
||||
return self.item_id == item.pk
|
||||
return (self.item_id == item.pk) and (variation and self.variation_id == variation.pk)
|
||||
|
||||
def is_active(self):
|
||||
"""
|
||||
@@ -208,3 +226,19 @@ class Voucher(LoggedModel):
|
||||
if self.valid_until and self.valid_until < now():
|
||||
return False
|
||||
return True
|
||||
|
||||
def calculate_price(self, original_price: Decimal) -> Decimal:
|
||||
"""
|
||||
Returns how the price given in original_price would be modified if this
|
||||
voucher is applied, i.e. replaced by a different price or reduced by a
|
||||
certain percentage. If the voucher does not modify the price, the
|
||||
original price will be returned.
|
||||
"""
|
||||
if self.value is not None:
|
||||
if self.price_mode == 'set':
|
||||
return self.value
|
||||
elif self.price_mode == 'subtract':
|
||||
return original_price - self.value
|
||||
elif self.price_mode == 'percent':
|
||||
return round_decimal(original_price * (Decimal('100.00') - self.value) / Decimal('100.00'))
|
||||
return original_price
|
||||
|
||||
130
src/pretix/base/models/waitinglist.py
Normal file
130
src/pretix/base/models/waitinglist.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Voucher
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
from .base import LoggedModel
|
||||
from .event import Event
|
||||
from .items import Item, ItemVariation
|
||||
|
||||
|
||||
class WaitingListException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WaitingListEntry(LoggedModel):
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="waitinglistentries",
|
||||
verbose_name=_("Event"),
|
||||
)
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_("On waiting list since"),
|
||||
auto_now_add=True
|
||||
)
|
||||
email = models.EmailField(
|
||||
verbose_name=_("E-mail address")
|
||||
)
|
||||
voucher = models.ForeignKey(
|
||||
'Voucher',
|
||||
verbose_name=_("Assigned voucher"),
|
||||
null=True, blank=True
|
||||
)
|
||||
item = models.ForeignKey(
|
||||
Item, related_name='waitinglistentries',
|
||||
verbose_name=_("Product"),
|
||||
help_text=_(
|
||||
"The product the user waits for."
|
||||
)
|
||||
)
|
||||
variation = models.ForeignKey(
|
||||
ItemVariation, related_name='waitinglistentries',
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Product variation"),
|
||||
help_text=_(
|
||||
"The variation of the product selected above."
|
||||
)
|
||||
)
|
||||
locale = models.CharField(
|
||||
max_length=190,
|
||||
default='en'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Waiting list entry")
|
||||
verbose_name_plural = _("Waiting list entries")
|
||||
ordering = ['created']
|
||||
|
||||
def __str__(self):
|
||||
return '%s waits for %s' % (str(self.email), str(self.item))
|
||||
|
||||
def clean(self):
|
||||
if WaitingListEntry.objects.filter(
|
||||
item=self.item, variation=self.variation, email=self.email, voucher__isnull=True
|
||||
).exclude(pk=self.pk).exists():
|
||||
raise ValidationError(_('You are already on this waiting list! We will notify '
|
||||
'you as soon as we have a ticket available for you.'))
|
||||
if not self.variation and self.item.has_variations:
|
||||
raise ValidationError(_('Please select a specific variation of this product.'))
|
||||
|
||||
def send_voucher(self, quota_cache=None, user=None):
|
||||
availability = (
|
||||
self.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
if self.variation
|
||||
else self.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
)
|
||||
if availability[1] < 1:
|
||||
raise WaitingListException(_('This product is currently not available.'))
|
||||
if self.voucher:
|
||||
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
||||
|
||||
with transaction.atomic():
|
||||
v = Voucher.objects.create(
|
||||
event=self.event,
|
||||
max_usages=1,
|
||||
valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours),
|
||||
item=self.item,
|
||||
variation=self.variation,
|
||||
tag='waiting-list',
|
||||
comment=_('Automatically created from waiting list entry for {email}').format(
|
||||
email=self.email
|
||||
),
|
||||
block_quota=True,
|
||||
)
|
||||
v.log_action('pretix.voucher.added.waitinglist', {
|
||||
'item': self.item.pk,
|
||||
'variation': self.variation.pk if self.variation else None,
|
||||
'tag': 'waiting-list',
|
||||
'block_quota': True,
|
||||
'valid_until': v.valid_until.isoformat(),
|
||||
'max_usages': 1,
|
||||
'email': self.email,
|
||||
'waitinglistentry': self.pk
|
||||
}, user=user)
|
||||
self.log_action('pretix.waitinglist.voucher', user=user)
|
||||
self.voucher = v
|
||||
self.save()
|
||||
|
||||
with language(self.locale):
|
||||
mail(
|
||||
self.email,
|
||||
_('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
{
|
||||
'event': self.event.name,
|
||||
'url': build_absolute_uri(self.event, 'presale:event.redeem') + '?voucher=' + self.voucher.code,
|
||||
'code': self.voucher.code,
|
||||
'product': str(self.item) + (' - ' + str(self.variation) if self.variation else ''),
|
||||
'hours': self.event.settings.waiting_list_hours,
|
||||
},
|
||||
self.event,
|
||||
locale=self.locale
|
||||
)
|
||||
@@ -12,15 +12,25 @@ from django.http import HttpRequest
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import I18nFormField, I18nTextarea, LazyI18nString
|
||||
from pretix.base.models import Event, Order, Quota
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.presale.views import get_cart_total
|
||||
|
||||
|
||||
class PaymentProviderForm(Form):
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
for k, v in self.fields.items():
|
||||
val = cleaned_data.get(k)
|
||||
if v._required and not val:
|
||||
self.add_error(k, _('This field is required.'))
|
||||
|
||||
|
||||
class BasePaymentProvider:
|
||||
"""
|
||||
This is the base class for all payment providers.
|
||||
@@ -187,8 +197,12 @@ class BasePaymentProvider:
|
||||
process. The default implementation constructs the form using
|
||||
:py:attr:`checkout_form_fields` and sets appropriate prefixes for the form
|
||||
and all fields and fills the form with data form the user's session.
|
||||
|
||||
If you overwrite this, we strongly suggest that you inherit from
|
||||
``PaymentProviderForm`` (from this module) that handles some nasty issues about
|
||||
required fields for you.
|
||||
"""
|
||||
form = Form(
|
||||
form = PaymentProviderForm(
|
||||
data=(request.POST if request.method == 'POST' else None),
|
||||
prefix='payment_%s' % self.identifier,
|
||||
initial={
|
||||
@@ -198,6 +212,12 @@ class BasePaymentProvider:
|
||||
}
|
||||
)
|
||||
form.fields = self.payment_form_fields
|
||||
|
||||
for k, v in form.fields.items():
|
||||
v._required = v.required
|
||||
v.required = False
|
||||
v.widget.is_required = False
|
||||
|
||||
return form
|
||||
|
||||
def _is_still_available(self, now_dt=None):
|
||||
@@ -318,9 +338,7 @@ class BasePaymentProvider:
|
||||
The default implementation just returns ``None`` and therefore leaves the
|
||||
order unpaid. The user will be redirected to the order's detail page by default.
|
||||
|
||||
On errors, you should use Django's message framework to display an error message
|
||||
to the user.
|
||||
|
||||
On errors, you should raise a ``PaymentException``.
|
||||
:param order: The order object
|
||||
"""
|
||||
return None
|
||||
@@ -365,8 +383,12 @@ class BasePaymentProvider:
|
||||
whether the user should be presented with an option to retry the payment. The default
|
||||
implementation always returns False.
|
||||
|
||||
If you want to enable retrials for your payment method, the best is to just return
|
||||
``self._is_still_available()`` from this method to disable it as soon as the method
|
||||
gets disabled or the methods end date is reached.
|
||||
|
||||
The retry workflow is also used if a user switches to this payment method for an existing
|
||||
order! Therefore, they can only switch to your p
|
||||
order!
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
@@ -460,6 +482,10 @@ class BasePaymentProvider:
|
||||
'back to the buyer manually.'))
|
||||
|
||||
|
||||
class PaymentException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FreeOrderProvider(BasePaymentProvider):
|
||||
|
||||
@property
|
||||
@@ -488,7 +514,7 @@ class FreeOrderProvider(BasePaymentProvider):
|
||||
try:
|
||||
mark_order_paid(order, 'free', send_mail=False)
|
||||
except Quota.QuotaExceededException as e:
|
||||
messages.error(request, str(e))
|
||||
raise PaymentException(str(e))
|
||||
|
||||
@property
|
||||
def settings_form_fields(self) -> dict:
|
||||
|
||||
@@ -15,27 +15,50 @@ import time
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from pretix.celery import app
|
||||
from pretix.base.metrics import (
|
||||
pretix_task_duration_seconds, pretix_task_runs_total,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
class ProfiledTask(app.Task):
|
||||
abstract = True
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
|
||||
if settings.PROFILING_RATE > 0 and random.random() < settings.PROFILING_RATE / 100:
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
starttime = time.time()
|
||||
t0 = time.perf_counter()
|
||||
ret = super().__call__(*args, **kwargs)
|
||||
tottime = time.perf_counter() - t0
|
||||
profiler.disable()
|
||||
tottime = time.time() - starttime
|
||||
profiler.dump_stats(os.path.join(settings.PROFILE_DIR, '{time:.0f}_{tottime:.3f}_celery_{t}.pstat'.format(
|
||||
t=self.name, tottime=tottime, time=time.time()
|
||||
)))
|
||||
return ret
|
||||
else:
|
||||
return super().__call__(*args, **kwargs)
|
||||
t0 = time.perf_counter()
|
||||
ret = super().__call__(*args, **kwargs)
|
||||
tottime = time.perf_counter() - t0
|
||||
|
||||
if settings.METRICS_ENABLED:
|
||||
pretix_task_duration_seconds.observe(tottime, task_name=self.name)
|
||||
return ret
|
||||
|
||||
def on_failure(self, exc, task_id, args, kwargs, einfo):
|
||||
if settings.METRICS_ENABLED:
|
||||
expected = False
|
||||
for t in self.throws:
|
||||
if isinstance(exc, t):
|
||||
expected = True
|
||||
break
|
||||
pretix_task_runs_total.inc(1, task_name=self.name, status="expected-error" if expected else "error")
|
||||
|
||||
return super().on_failure(exc, task_id, args, kwargs, einfo)
|
||||
|
||||
def on_success(self, retval, task_id, args, kwargs):
|
||||
if settings.METRICS_ENABLED:
|
||||
pretix_task_runs_total.inc(1, task_name=self.name, status="success")
|
||||
|
||||
return super().on_success(retval, task_id, args, kwargs)
|
||||
|
||||
|
||||
class TransactionAwareTask(ProfiledTask):
|
||||
@@ -43,7 +66,6 @@ class TransactionAwareTask(ProfiledTask):
|
||||
Task class which is aware of django db transactions and only executes tasks
|
||||
after transaction has been committed
|
||||
"""
|
||||
abstract = True
|
||||
|
||||
def apply_async(self, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
from collections import Counter, defaultdict, namedtuple
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.base.i18n import LazyLocaleException
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, Item, ItemVariation, Quota, Voucher,
|
||||
CartPosition, Event, Item, ItemVariation, Voucher,
|
||||
)
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.celery import app
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
class CartError(LazyLocaleException):
|
||||
@@ -25,318 +27,612 @@ error_messages = {
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'empty': _('You did not select any products.'),
|
||||
'unknown_position': _('Unknown cart position.'),
|
||||
'not_for_sale': _('You selected a product which is not available for sale.'),
|
||||
'unavailable': _('Some of the products you selected are no longer available. '
|
||||
'Please see below for details.'),
|
||||
'in_part': _('Some of the products you selected are no longer available in '
|
||||
'the quantity you selected. Please see below for details.'),
|
||||
'max_items': _("You cannot select more than %s items per order."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s."),
|
||||
'min_items_per_product': _("You need to select at least %(min)s items of the product %(product)s."),
|
||||
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
|
||||
"%(min)s items of it."),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
'ended': _('The presale period has ended.'),
|
||||
'price_too_high': _('The entered price is to high.'),
|
||||
'voucher_invalid': _('This voucher code is not known in our database.'),
|
||||
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
|
||||
'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'),
|
||||
'voucher_double': _('You already used this voucher code. Remove the associated line from your '
|
||||
'cart if you want to use it for a different product.'),
|
||||
'voucher_expired': _('This voucher is expired.'),
|
||||
'voucher_invalid_item': _('This voucher is not valid for this product.'),
|
||||
'voucher_required': _('You need a valid voucher code to order this product.'),
|
||||
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
||||
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
||||
'product %(base)s.'),
|
||||
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
|
||||
}
|
||||
|
||||
|
||||
def _extend_existing(event: Event, cart_id: str, expiry: datetime, now_dt: datetime) -> None:
|
||||
# Extend this user's cart session to 30 minutes from now to ensure all items in the
|
||||
# cart expire at the same time
|
||||
# We can extend the reservation of items which are not yet expired without risk
|
||||
CartPosition.objects.filter(
|
||||
Q(cart_id=cart_id) & Q(event=event) & Q(expires__gt=now_dt)
|
||||
).update(expires=expiry)
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
||||
'addon_to'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas'))
|
||||
order = {
|
||||
RemoveOperation: 10,
|
||||
ExtendOperation: 20,
|
||||
AddOperation: 30
|
||||
}
|
||||
|
||||
def __init__(self, event: Event, cart_id: str):
|
||||
self.event = event
|
||||
self.cart_id = cart_id
|
||||
self.now_dt = now()
|
||||
self._operations = []
|
||||
self._quota_diff = Counter()
|
||||
self._voucher_use_diff = Counter()
|
||||
self._items_cache = {}
|
||||
self._variations_cache = {}
|
||||
self._expiry = None
|
||||
|
||||
def _re_add_expired_positions(items: List[dict], event: Event, cart_id: str, now_dt: datetime) -> List[CartPosition]:
|
||||
positions = set()
|
||||
# For items that are already expired, we have to delete and re-add them, as they might
|
||||
# be no longer available or prices might have changed. Sorry!
|
||||
expired = CartPosition.objects.filter(
|
||||
Q(cart_id=cart_id) & Q(event=event) & Q(expires__lte=now_dt)
|
||||
)
|
||||
for cp in expired:
|
||||
items.insert(0, {
|
||||
'item': cp.item_id,
|
||||
'variation': cp.variation_id,
|
||||
'count': 1,
|
||||
'price': cp.price,
|
||||
'_cp': cp,
|
||||
'voucher': cp.voucher.code if cp.voucher else None
|
||||
})
|
||||
positions.add(cp)
|
||||
return positions
|
||||
@property
|
||||
def positions(self):
|
||||
return CartPosition.objects.filter(
|
||||
Q(cart_id=self.cart_id) & Q(event=self.event)
|
||||
).select_related('item')
|
||||
|
||||
def _calculate_expiry(self):
|
||||
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
||||
|
||||
def _delete_expired(expired: List[CartPosition], now_dt: datetime) -> None:
|
||||
for cp in expired:
|
||||
if cp.expires <= now_dt: # Has not been extended
|
||||
cp.delete()
|
||||
def _check_presale_dates(self):
|
||||
if self.event.presale_start and self.now_dt < self.event.presale_start:
|
||||
raise CartError(error_messages['not_started'])
|
||||
if self.event.presale_end and self.now_dt > self.event.presale_end:
|
||||
raise CartError(error_messages['ended'])
|
||||
|
||||
def _extend_expiry_of_valid_existing_positions(self):
|
||||
# Extend this user's cart session to ensure all items in the cart expire at the same time
|
||||
# We can extend the reservation of items which are not yet expired without risk
|
||||
self.positions.filter(expires__gt=self.now_dt).update(expires=self._expiry)
|
||||
|
||||
def _check_date(event: Event, now_dt: datetime) -> None:
|
||||
if event.presale_start and now_dt < event.presale_start:
|
||||
raise CartError(error_messages['not_started'])
|
||||
if event.presale_end and now_dt > event.presale_end:
|
||||
raise CartError(error_messages['ended'])
|
||||
def _delete_expired(self, expired: List[CartPosition]):
|
||||
for cp in expired:
|
||||
if cp.expires <= self.now_dt:
|
||||
cp.delete()
|
||||
|
||||
|
||||
def _parse_items_and_check_constraints(event: Event, items: List[dict], cart_id: str,
|
||||
now_dt: datetime) -> Counter:
|
||||
"""
|
||||
This method does three things:
|
||||
|
||||
* Extend the item list with the database objects for the item, variation, etc.
|
||||
|
||||
* Check all constraints that are placed on the items, vouchers etc. to be valid and calculates the correct prices
|
||||
|
||||
* Return a counter object that contains the quota changes that are required to perform the operation
|
||||
"""
|
||||
err = None
|
||||
|
||||
# Fetch items from the database
|
||||
items_query = Item.objects.filter(event=event, id__in=[i['item'] for i in items]).prefetch_related(
|
||||
"quotas")
|
||||
items_cache = {i.id: i for i in items_query}
|
||||
variations_query = ItemVariation.objects.filter(
|
||||
item__event=event,
|
||||
id__in=[i['variation'] for i in items if i['variation'] is not None]
|
||||
).select_related("item", "item__event").prefetch_related("quotas")
|
||||
variations_cache = {v.id: v for v in variations_query}
|
||||
|
||||
quotadiff = Counter()
|
||||
vouchers = Counter()
|
||||
|
||||
for i in items:
|
||||
# Check whether the specified items are part of what we just fetched from the database
|
||||
# If they are not, the user supplied item IDs which either do not exist or belong to
|
||||
# a different event
|
||||
if i['item'] not in items_cache or (i['variation'] is not None and i['variation'] not in variations_cache):
|
||||
err = err or error_messages['not_for_sale']
|
||||
continue
|
||||
|
||||
item = items_cache[i['item']]
|
||||
variation = variations_cache[i['variation']] if i['variation'] is not None else None
|
||||
|
||||
# Check whether a voucher has been provided
|
||||
voucher = None
|
||||
if i.get('voucher'):
|
||||
try:
|
||||
voucher = Voucher.objects.get(code=i.get('voucher').strip(), event=event)
|
||||
if voucher.redeemed >= voucher.max_usages:
|
||||
raise CartError(error_messages['voucher_redeemed'])
|
||||
if voucher.valid_until is not None and voucher.valid_until < now_dt:
|
||||
raise CartError(error_messages['voucher_expired'])
|
||||
if not voucher.applies_to(item, variation):
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
||||
def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]):
|
||||
self._items_cache.update(
|
||||
{
|
||||
i.pk: i
|
||||
for i
|
||||
in self.event.items.select_related('category').prefetch_related(
|
||||
'addons', 'addons__addon_category', 'quotas'
|
||||
).filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)
|
||||
if 'cp' in i:
|
||||
redeemed_in_carts = redeemed_in_carts.exclude(pk=i['_cp'].pk)
|
||||
v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count()
|
||||
}
|
||||
)
|
||||
self._variations_cache.update(
|
||||
{v.pk: v for v in
|
||||
ItemVariation.objects.filter(item__event=self.event).prefetch_related(
|
||||
'quotas'
|
||||
).select_related('item', 'item__event').filter(
|
||||
id__in=[i for i in variation_ids if i and i not in self._variations_cache]
|
||||
)}
|
||||
)
|
||||
|
||||
if v_avail < 1:
|
||||
raise CartError(error_messages['voucher_redeemed'])
|
||||
if i['count'] > v_avail - vouchers[voucher]:
|
||||
raise CartError(error_messages['voucher_redeemed_partial'] % v_avail)
|
||||
def _check_max_cart_size(self):
|
||||
cartsize = self.positions.filter(addon_to__isnull=True).count()
|
||||
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
|
||||
not op.position.addon_to_id])
|
||||
if cartsize > int(self.event.settings.max_items_per_order):
|
||||
# TODO: i18n plurals
|
||||
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
|
||||
|
||||
vouchers[voucher] += i['count']
|
||||
except Voucher.DoesNotExist:
|
||||
raise CartError(error_messages['voucher_invalid'])
|
||||
def _check_item_constraints(self, op):
|
||||
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
|
||||
if op.item.require_voucher and op.voucher is None:
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
||||
if op.item.hide_without_voucher and (op.voucher is None or op.voucher.item is None or op.voucher.item.pk != op.item.pk):
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if voucher and voucher.quota and voucher.quota.pk not in [q.pk for q in quotas]:
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
if not op.item.is_available() or (op.variation and not op.variation.active):
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
if item.require_voucher and voucher is None:
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
if item.hide_without_voucher and (voucher is None or voucher.item is None or voucher.item.pk != item.pk):
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.item.category and op.item.category.is_addon and not op.addon_to:
|
||||
raise CartError(error_messages['addon_only'])
|
||||
|
||||
if len(quotas) == 0 or not item.is_available() or (variation and not variation.active):
|
||||
err = err or error_messages['unavailable']
|
||||
continue
|
||||
if op.item.max_per_order or op.item.min_per_order:
|
||||
new_total = (
|
||||
len([1 for p in self.positions if p.item_id == op.item.pk]) +
|
||||
sum([_op.count for _op in self._operations
|
||||
if isinstance(_op, self.AddOperation) and _op.item == op.item]) +
|
||||
op.count -
|
||||
len([1 for _op in self._operations
|
||||
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
|
||||
)
|
||||
|
||||
if voucher and voucher.price is not None:
|
||||
price = voucher.price
|
||||
else:
|
||||
price = item.default_price if variation is None else (
|
||||
variation.default_price if variation.default_price is not None else item.default_price)
|
||||
if op.item.max_per_order and new_total > op.item.max_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['max_items_per_product']) % {
|
||||
'max': op.item.max_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
|
||||
if item.free_price and 'price' in i and i['price'] is not None and i['price'] != "":
|
||||
custom_price = i['price']
|
||||
if op.item.min_per_order and new_total < op.item.min_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['min_items_per_product']) % {
|
||||
'min': op.item.min_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
|
||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||
voucher: Optional[Voucher], custom_price: Optional[Decimal]):
|
||||
price = item.default_price if variation is None else (
|
||||
variation.default_price if variation.default_price is not None else item.default_price
|
||||
)
|
||||
if voucher:
|
||||
price = voucher.calculate_price(price)
|
||||
|
||||
if item.free_price and custom_price is not None and custom_price != "":
|
||||
if not isinstance(custom_price, Decimal):
|
||||
custom_price = Decimal(custom_price.replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
raise CartError(error_messages['price_too_high'])
|
||||
if self.event.settings.display_net_prices:
|
||||
custom_price = round_decimal(custom_price * (100 + item.tax_rate) / 100)
|
||||
price = max(custom_price, price)
|
||||
|
||||
# Check that all quotas allow us to buy i['count'] instances of the object
|
||||
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
|
||||
for quota in quotas:
|
||||
quotadiff[quota] += i['count']
|
||||
i['_quotas'] = quotas
|
||||
else:
|
||||
i['_quotas'] = []
|
||||
return price
|
||||
|
||||
i['_price'] = price
|
||||
i['_item'] = item
|
||||
i['_variation'] = variation
|
||||
i['_voucher'] = voucher
|
||||
def extend_expired_positions(self):
|
||||
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
||||
'item', 'variation', 'voucher'
|
||||
).prefetch_related('item__quotas', 'variation__quotas')
|
||||
for cp in expired:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price)
|
||||
|
||||
if err:
|
||||
raise CartError(err)
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota):
|
||||
for quota in quotas:
|
||||
self._quota_diff[quota] += 1
|
||||
else:
|
||||
quotas = []
|
||||
|
||||
return quotadiff
|
||||
op = self.ExtendOperation(
|
||||
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
|
||||
price=price, quotas=quotas
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
|
||||
if cp.voucher:
|
||||
self._voucher_use_diff[cp.voucher] += 1
|
||||
|
||||
def _check_quota_and_create_positions(event: Event, items: List[dict], cart_id: str, now_dt: datetime,
|
||||
expiry: datetime, quotadiff: Counter):
|
||||
"""
|
||||
This method takes the modified items and the quotadiff from _parse_items_and_check_constraints
|
||||
and then
|
||||
self._operations.append(op)
|
||||
|
||||
* checks that the given quotas are available
|
||||
def add_new_items(self, items: List[dict]):
|
||||
# Fetch items from the database
|
||||
self._update_items_cache([i['item'] for i in items], [i['variation'] for i in items])
|
||||
quota_diff = Counter()
|
||||
voucher_use_diff = Counter()
|
||||
operations = []
|
||||
|
||||
* creates as many cart positions as possible
|
||||
"""
|
||||
err = None
|
||||
quotas_ok = {}
|
||||
cartpositions = []
|
||||
for i in items:
|
||||
# Check whether the specified items are part of what we just fetched from the database
|
||||
# If they are not, the user supplied item IDs which either do not exist or belong to
|
||||
# a different event
|
||||
if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
with event.lock():
|
||||
for quota, count in quotadiff.items():
|
||||
avail = quota.availability(now_dt)
|
||||
if avail[1] is not None and avail[1] < count:
|
||||
# This quota is not available or less than i['count'] items are left, so we have to
|
||||
# reduce the number of bought items
|
||||
if avail[0] != Quota.AVAILABILITY_OK:
|
||||
err = err or error_messages['unavailable']
|
||||
item = self._items_cache[i['item']]
|
||||
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
|
||||
voucher = None
|
||||
|
||||
if i.get('voucher'):
|
||||
try:
|
||||
voucher = self.event.vouchers.get(code=i.get('voucher').strip())
|
||||
except Voucher.DoesNotExist:
|
||||
raise CartError(error_messages['voucher_invalid'])
|
||||
else:
|
||||
err = err or error_messages['in_part']
|
||||
voucher_use_diff[voucher] += i['count']
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
|
||||
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
|
||||
for quota in quotas:
|
||||
quota_diff[quota] += i['count']
|
||||
else:
|
||||
quotas = []
|
||||
|
||||
price = self._get_price(item, variation, voucher, i.get('price'))
|
||||
op = self.AddOperation(
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
|
||||
addon_to=False
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
|
||||
self._quota_diff += quota_diff
|
||||
self._voucher_use_diff += voucher_use_diff
|
||||
self._operations += operations
|
||||
|
||||
def remove_item(self, pos_id: int):
|
||||
# TODO: We could calculate quotadiffs and voucherdiffs here, which would lead to more
|
||||
# flexible usages (e.g. a RemoveOperation and an AddOperation in the same transaction
|
||||
# could cancel each other out quota-wise). However, we are not taking this performance
|
||||
# penalty for now as there is currently no outside interface that would allow building
|
||||
# such a transaction.
|
||||
try:
|
||||
cp = self.positions.get(pk=pos_id)
|
||||
except CartPosition.DoesNotExist:
|
||||
raise CartError(error_messages['unknown_position'])
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
|
||||
def clear(self):
|
||||
# TODO: We could calculate quotadiffs and voucherdiffs here, which would lead to more
|
||||
# flexible usages (e.g. a RemoveOperation and an AddOperation in the same transaction
|
||||
# could cancel each other out quota-wise). However, we are not taking this performance
|
||||
# penalty for now as there is currently no outside interface that would allow building
|
||||
# such a transaction.
|
||||
for cp in self.positions.all():
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
|
||||
def set_addons(self, addons):
|
||||
self._update_items_cache(
|
||||
[a['item'] for a in addons],
|
||||
[a['variation'] for a in addons],
|
||||
)
|
||||
|
||||
# Prepare various containers to hold data later
|
||||
current_addons = defaultdict(dict) # CartPos -> currently attached add-ons
|
||||
input_addons = defaultdict(set) # CartPos -> add-ons according to input
|
||||
selected_addons = defaultdict(set) # CartPos -> final desired set of add-ons
|
||||
cpcache = {} # CartPos.pk -> CartPos
|
||||
quota_diff = Counter() # Quota -> Number of usages
|
||||
operations = []
|
||||
available_categories = defaultdict(set) # CartPos -> Category IDs to choose from
|
||||
toplevel_cp = self.positions.filter(
|
||||
addon_to__isnull=True
|
||||
).prefetch_related(
|
||||
'addons', 'item__addons', 'item__addons__addon_category'
|
||||
).select_related('item', 'variation')
|
||||
|
||||
# Prefill some of the cache containers
|
||||
for cp in toplevel_cp:
|
||||
available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()}
|
||||
cpcache[cp.pk] = cp
|
||||
current_addons[cp] = {
|
||||
(a.item_id, a.variation_id): a
|
||||
for a in cp.addons.all()
|
||||
}
|
||||
|
||||
# Create operations, perform various checks
|
||||
for a in addons:
|
||||
# Check whether the specified items are part of what we just fetched from the database
|
||||
# If they are not, the user supplied item IDs which either do not exist or belong to
|
||||
# a different event
|
||||
if a['item'] not in self._items_cache or (a['variation'] and a['variation'] not in self._variations_cache):
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
# Only attach addons to things that are actually in this user's cart
|
||||
if a['addon_to'] not in cpcache:
|
||||
raise CartError(error_messages['addon_invalid_base'])
|
||||
|
||||
cp = cpcache[a['addon_to']]
|
||||
item = self._items_cache[a['item']]
|
||||
variation = self._variations_cache[a['variation']] if a['variation'] is not None else None
|
||||
|
||||
if item.category_id not in available_categories[cp.pk]:
|
||||
raise CartError(error_messages['addon_invalid_base'])
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
# Every item can be attached to very CartPosition at most once
|
||||
if a['item'] in ([_a[0] for _a in input_addons[cp.id]]):
|
||||
raise CartError(error_messages['addon_duplicate_item'])
|
||||
|
||||
input_addons[cp.id].add((a['item'], a['variation']))
|
||||
selected_addons[cp.id, item.category_id].add((a['item'], a['variation']))
|
||||
|
||||
if (a['item'], a['variation']) not in current_addons[cp]:
|
||||
# This add-on is new, add it to the cart
|
||||
for quota in quotas:
|
||||
quota_diff[quota] += 1
|
||||
|
||||
price = self._get_price(item, variation, None, None)
|
||||
|
||||
op = self.AddOperation(
|
||||
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
|
||||
# Check constraints on the add-on combinations
|
||||
for cp in toplevel_cp:
|
||||
item = cp.item
|
||||
for iao in item.addons.all():
|
||||
selected = selected_addons[cp.id, iao.addon_category_id]
|
||||
if len(selected) > iao.max_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise CartError(
|
||||
error_messages['addon_max_count'],
|
||||
{
|
||||
'base': str(item.name),
|
||||
'max': iao.max_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
elif len(selected) < iao.min_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise CartError(
|
||||
error_messages['addon_min_count'],
|
||||
{
|
||||
'base': str(item.name),
|
||||
'min': iao.min_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
|
||||
# Detect removed add-ons and create RemoveOperations
|
||||
for cp, al in current_addons.items():
|
||||
for k, v in al.items():
|
||||
if k not in input_addons[cp.id]:
|
||||
if v.expires > self.now_dt:
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
|
||||
for quota in quotas:
|
||||
quota_diff[quota] -= 1
|
||||
|
||||
op = self.RemoveOperation(position=v)
|
||||
operations.append(op)
|
||||
|
||||
self._quota_diff += quota_diff
|
||||
self._operations += operations
|
||||
|
||||
def _get_quota_availability(self):
|
||||
quotas_ok = {}
|
||||
for quota, count in self._quota_diff.items():
|
||||
avail = quota.availability(self.now_dt)
|
||||
if avail[1] is not None and avail[1] < count:
|
||||
quotas_ok[quota] = min(count, avail[1])
|
||||
else:
|
||||
quotas_ok[quota] = count
|
||||
return quotas_ok
|
||||
|
||||
for i in items:
|
||||
# Create a CartPosition for as much items as we can
|
||||
requested_count = i['count']
|
||||
available_count = requested_count
|
||||
if i['_quotas']:
|
||||
available_count = min(requested_count, min(quotas_ok[q] for q in i['_quotas']))
|
||||
def _get_voucher_availability(self):
|
||||
vouchers_ok = {}
|
||||
for voucher, count in self._voucher_use_diff.items():
|
||||
voucher.refresh_from_db()
|
||||
|
||||
for q in i['_quotas']:
|
||||
quotas_ok[q] -= available_count
|
||||
if voucher.valid_until is not None and voucher.valid_until < self.now_dt:
|
||||
raise CartError(error_messages['voucher_expired'])
|
||||
|
||||
for k in range(available_count):
|
||||
if '_cp' in i and i['count'] == 1:
|
||||
# Recreating an existing position
|
||||
cp = i['_cp']
|
||||
cp.expires = expiry
|
||||
cp.price = i['_price']
|
||||
cp.save()
|
||||
else:
|
||||
cartpositions.append(CartPosition(
|
||||
event=event, item=i['_item'], variation=i['_variation'],
|
||||
price=i['_price'],
|
||||
expires=expiry,
|
||||
cart_id=cart_id, voucher=i['_voucher']
|
||||
))
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=self.event) &
|
||||
Q(expires__gte=self.now_dt)
|
||||
).exclude(pk__in=[
|
||||
op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||
])
|
||||
v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count()
|
||||
vouchers_ok[voucher] = v_avail
|
||||
|
||||
CartPosition.objects.bulk_create(cartpositions)
|
||||
return vouchers_ok
|
||||
|
||||
if err:
|
||||
raise CartError(err)
|
||||
def _check_min_per_product(self):
|
||||
per_product = Counter()
|
||||
min_per_product = {}
|
||||
for p in self.positions:
|
||||
per_product[p.item_id] += 1
|
||||
min_per_product[p.item.pk] = p.item.min_per_order
|
||||
|
||||
for op in self._operations:
|
||||
if isinstance(op, self.AddOperation):
|
||||
per_product[op.item.pk] += op.count
|
||||
min_per_product[op.item.pk] = op.item.min_per_order
|
||||
elif isinstance(op, self.RemoveOperation):
|
||||
per_product[op.position.item_id] -= 1
|
||||
min_per_product[op.position.item.pk] = op.position.item.min_per_order
|
||||
|
||||
err = None
|
||||
for itemid, num in per_product.items():
|
||||
min_p = min_per_product[itemid]
|
||||
if min_p and num < min_p:
|
||||
self._operations = [o for o in self._operations if not (
|
||||
isinstance(o, self.AddOperation) and o.item.pk == itemid
|
||||
)]
|
||||
removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)]
|
||||
for p in self.positions:
|
||||
if p.item_id == itemid and p.pk not in removals:
|
||||
self._operations.append(self.RemoveOperation(position=p))
|
||||
err = _(error_messages['min_items_per_product_removed']) % {
|
||||
'min': min_p,
|
||||
'product': p.item.name
|
||||
}
|
||||
|
||||
return err
|
||||
|
||||
def _perform_operations(self):
|
||||
vouchers_ok = self._get_voucher_availability()
|
||||
quotas_ok = self._get_quota_availability()
|
||||
err = None
|
||||
new_cart_positions = []
|
||||
|
||||
err = err or self._check_min_per_product()
|
||||
|
||||
self._operations.sort(key=lambda a: self.order[type(a)])
|
||||
|
||||
for op in self._operations:
|
||||
if isinstance(op, self.RemoveOperation):
|
||||
op.position.delete()
|
||||
|
||||
elif isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
|
||||
# Create a CartPosition for as much items as we can
|
||||
requested_count = quota_available_count = voucher_available_count = op.count
|
||||
|
||||
if op.quotas:
|
||||
quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas))
|
||||
|
||||
if op.voucher:
|
||||
voucher_available_count = min(voucher_available_count, vouchers_ok[op.voucher])
|
||||
|
||||
if quota_available_count < 1:
|
||||
err = err or error_messages['unavailable']
|
||||
elif quota_available_count < requested_count:
|
||||
err = err or error_messages['in_part']
|
||||
|
||||
if voucher_available_count < 1:
|
||||
err = err or error_messages['voucher_redeemed']
|
||||
elif voucher_available_count < requested_count:
|
||||
err = err or error_messages['voucher_redeemed_partial'] % voucher_available_count
|
||||
|
||||
available_count = min(quota_available_count, voucher_available_count)
|
||||
|
||||
for q in op.quotas:
|
||||
quotas_ok[q] -= available_count
|
||||
if op.voucher:
|
||||
vouchers_ok[op.voucher] -= available_count
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
for k in range(available_count):
|
||||
new_cart_positions.append(CartPosition(
|
||||
event=self.event, item=op.item, variation=op.variation,
|
||||
price=op.price, expires=self._expiry,
|
||||
cart_id=self.cart_id, voucher=op.voucher,
|
||||
addon_to=op.addon_to if op.addon_to else None
|
||||
))
|
||||
elif isinstance(op, self.ExtendOperation):
|
||||
if available_count == 1:
|
||||
op.position.expires = self._expiry
|
||||
op.position.price = op.price
|
||||
op.position.save()
|
||||
elif available_count == 0:
|
||||
op.position.delete()
|
||||
else:
|
||||
raise AssertionError("ExtendOperation cannot affect more than one item")
|
||||
|
||||
CartPosition.objects.bulk_create(new_cart_positions)
|
||||
return err
|
||||
|
||||
def commit(self):
|
||||
self._check_presale_dates()
|
||||
self._check_max_cart_size()
|
||||
self._calculate_expiry()
|
||||
|
||||
with self.event.lock() as now_dt:
|
||||
with transaction.atomic():
|
||||
self.now_dt = now_dt
|
||||
self._extend_expiry_of_valid_existing_positions()
|
||||
self.extend_expired_positions()
|
||||
err = self._perform_operations()
|
||||
if err:
|
||||
raise CartError(err)
|
||||
|
||||
|
||||
def _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> None:
|
||||
now_dt = now()
|
||||
_check_date(event, now_dt)
|
||||
|
||||
existing = CartPosition.objects.filter(Q(cart_id=cart_id) & Q(event=event)).count()
|
||||
if sum(i['count'] for i in items) + existing > int(event.settings.max_items_per_order):
|
||||
# TODO: i18n plurals
|
||||
raise CartError(error_messages['max_items'], (event.settings.max_items_per_order,))
|
||||
|
||||
expiry = now_dt + timedelta(minutes=event.settings.get('reservation_time', as_type=int))
|
||||
_extend_existing(event, cart_id, expiry, now_dt)
|
||||
|
||||
expired = _re_add_expired_positions(items, event, cart_id, now_dt)
|
||||
|
||||
try:
|
||||
if items:
|
||||
quotadiff = _parse_items_and_check_constraints(event, items, cart_id, now_dt)
|
||||
_check_quota_and_create_positions(event, items, cart_id, now_dt, expiry, quotadiff)
|
||||
except CartError as e:
|
||||
_delete_expired(expired, now_dt)
|
||||
raise e
|
||||
else:
|
||||
_delete_expired(expired, now_dt)
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
|
||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None) -> None:
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Adds a list of items to a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of tuple of the form (item id, variation id or None, number, custom_price, voucher)
|
||||
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
|
||||
:param session: Session ID of a guest
|
||||
:param coupon: A coupon that should also be reeemed
|
||||
:raises CartError: On any error that occured
|
||||
"""
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
_add_items_to_cart(event, items, cart_id)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.add_new_items(items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
def _remove_items_from_cart(event: Event, items: List[dict], cart_id: str) -> None:
|
||||
with event.lock():
|
||||
for i in items:
|
||||
cw = Q(cart_id=cart_id) & Q(item_id=i['item']) & Q(event=event)
|
||||
if i['variation']:
|
||||
cw &= Q(variation_id=i['variation'])
|
||||
else:
|
||||
cw &= Q(variation__isnull=True)
|
||||
# Prefer to delete positions that have the same price as the one the user clicked on, after thet
|
||||
# prefer the most expensive ones.
|
||||
cnt = i['count']
|
||||
if i['price']:
|
||||
correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(i['price'].replace(",", ".")))[:cnt]
|
||||
for cp in correctprice:
|
||||
cp.delete()
|
||||
cnt -= len(correctprice)
|
||||
if cnt > 0:
|
||||
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
|
||||
cp.delete()
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
|
||||
def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None) -> None:
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_cart_position(self, event: int, position: int, cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of tuple of the form (item id, variation id or None, number)
|
||||
:param position: A cart position ID
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
_remove_items_from_cart(event, items, cart_id)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.remove_item(position)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.clear()
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param addons: A list of dicts with the keys addon_to, item, variation
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.set_addons(addons)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
@@ -6,7 +6,7 @@ from pretix.base.i18n import language
|
||||
from pretix.base.models import CachedFile, Event, cachedfile_name
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.signals import register_data_exporters
|
||||
from pretix.celery import app
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
@@ -21,3 +21,4 @@ def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) ->
|
||||
file.filename, file.type, data = ex.render(form_data)
|
||||
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
|
||||
file.save()
|
||||
return file.pk
|
||||
|
||||
@@ -3,17 +3,18 @@ import tempfile
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from locale import format as lformat
|
||||
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import (
|
||||
@@ -21,11 +22,11 @@ from reportlab.platypus import (
|
||||
Table, TableStyle,
|
||||
)
|
||||
|
||||
from pretix.base.i18n import LazyI18nString, language
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
|
||||
from pretix.base.services.async import TransactionAwareTask
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.celery import app
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
|
||||
|
||||
@@ -71,6 +72,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
desc = str(p.item.name)
|
||||
if p.variation:
|
||||
desc += " - " + str(p.variation.value)
|
||||
if p.addon_to_id:
|
||||
desc = " + " + desc
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=desc,
|
||||
gross_value=p.price, tax_value=p.tax_value,
|
||||
@@ -182,17 +185,29 @@ def _invoice_generate_german(invoice, f):
|
||||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLines(invoice.invoice_from.strip())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
p = Paragraph(invoice.invoice_from.strip().replace('\n', '<br />\n'), style=styles['Normal'])
|
||||
p.wrapOn(canvas, 70 * mm, 50 * mm)
|
||||
p_size = p.wrap(70 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
p = Paragraph(invoice.invoice_to.strip().replace('\n', '<br />\n'), style=styles['Normal'])
|
||||
p.wrapOn(canvas, 85 * mm, 50 * mm)
|
||||
p_size = p.wrap(85 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLines(invoice.invoice_to.strip())
|
||||
textobject.textLine(invoice.order.full_code)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||
@@ -238,32 +253,33 @@ def _invoice_generate_german(invoice, f):
|
||||
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(165 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.order.full_code)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(invoice.order.datetime, "DATE_FORMAT"))
|
||||
canvas.drawText(textobject)
|
||||
if invoice.event.settings.invoice_logo_image:
|
||||
logo_file = invoice.event.settings.get('invoice_logo_image', binary_file=True)
|
||||
canvas.drawImage(ImageReader(logo_file),
|
||||
95 * mm, (297 - 38) * mm,
|
||||
width=25 * mm, height=25 * mm,
|
||||
preserveAspectRatio=True, anchor='n',
|
||||
mask='auto')
|
||||
|
||||
if invoice.event.settings.show_date_to:
|
||||
p_str = (
|
||||
str(invoice.event.name) + '\n' + _('{from_date}\nuntil {to_date}').format(
|
||||
from_date=invoice.event.get_date_from_display(),
|
||||
to_date=invoice.event.get_date_to_display())
|
||||
)
|
||||
else:
|
||||
p_str = (
|
||||
str(invoice.event.name) + '\n' + invoice.event.get_date_from_display()
|
||||
)
|
||||
|
||||
p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=styles['Normal'])
|
||||
p.wrapOn(canvas, 65 * mm, 50 * mm)
|
||||
p_size = p.wrap(65 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Event').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(str(invoice.event.name))
|
||||
if invoice.event.settings.show_date_to:
|
||||
textobject.textLines(
|
||||
_('{from_date}\nuntil {to_date}').format(from_date=invoice.event.get_date_from_display(),
|
||||
to_date=invoice.event.get_date_to_display()))
|
||||
else:
|
||||
textobject.textLine(invoice.event.get_date_from_display())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
canvas.restoreState()
|
||||
@@ -306,6 +322,7 @@ def _invoice_generate_german(invoice, f):
|
||||
|
||||
tstyledata = [
|
||||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
|
||||
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
@@ -320,16 +337,16 @@ def _invoice_generate_german(invoice, f):
|
||||
total = Decimal('0.00')
|
||||
for line in invoice.lines.all():
|
||||
tdata.append((
|
||||
line.description,
|
||||
lformat("%.2f", line.tax_rate) + " %",
|
||||
lformat("%.2f", line.net_value) + " " + invoice.event.currency,
|
||||
lformat("%.2f", line.gross_value) + " " + invoice.event.currency,
|
||||
Paragraph(line.description, styles['Normal']),
|
||||
localize(line.tax_rate) + " %",
|
||||
localize(line.net_value) + " " + invoice.event.currency,
|
||||
localize(line.gross_value) + " " + invoice.event.currency,
|
||||
))
|
||||
taxvalue_map[line.tax_rate] += line.tax_value
|
||||
grossvalue_map[line.tax_rate] += line.gross_value
|
||||
total += line.gross_value
|
||||
|
||||
tdata.append([pgettext('invoice', 'Invoice total'), '', '', lformat("%.2f", total) + " " + invoice.event.currency])
|
||||
tdata.append([pgettext('invoice', 'Invoice total'), '', '', localize(total) + " " + invoice.event.currency])
|
||||
colwidths = [a * doc.width for a in (.55, .15, .15, .15)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=1)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
@@ -361,10 +378,10 @@ def _invoice_generate_german(invoice, f):
|
||||
tax = taxvalue_map[rate]
|
||||
tdata.append((
|
||||
'',
|
||||
lformat("%.2f", rate) + " %",
|
||||
lformat("%.2f", (gross - tax)) + " " + invoice.event.currency,
|
||||
lformat("%.2f", gross) + " " + invoice.event.currency,
|
||||
lformat("%.2f", tax) + " " + invoice.event.currency,
|
||||
localize(rate) + " %",
|
||||
localize((gross - tax)) + " " + invoice.event.currency,
|
||||
localize(gross) + " " + invoice.event.currency,
|
||||
localize(tax) + " " + invoice.event.currency,
|
||||
))
|
||||
|
||||
if len(tdata) > 2:
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import bleach
|
||||
import cssutils
|
||||
import markdown
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMessage, get_connection
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import get_template
|
||||
from django.utils.translation import ugettext as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.i18n import LazyI18nString, language
|
||||
from pretix.base.models import Event, Order
|
||||
from pretix.celery import app
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Event, InvoiceAddress, Order
|
||||
from pretix.celery_app import app
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger('pretix.base.mail')
|
||||
INVALID_ADDRESS = 'invalid-pretix-mail-address'
|
||||
cssutils.log.setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
class TolerantDict(dict):
|
||||
@@ -25,7 +31,7 @@ class SendMailException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def mail(email: str, subject: str, template: str,
|
||||
def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, event: Event=None, locale: str=None,
|
||||
order: Order=None, headers: dict=None):
|
||||
"""
|
||||
@@ -38,7 +44,7 @@ def mail(email: str, subject: str, template: str,
|
||||
|
||||
:param template: The filename of a template to be used. It will be rendered with the locale given in the locale
|
||||
argument and the context given in the next argument. Alternatively, you can pass a LazyI18nString and
|
||||
``context`` will be used as the argument to a Python ``.format()`` call on the template.
|
||||
``context`` will be used as the argument to a Python ``.format_map()`` call on the template.
|
||||
|
||||
:param context: The context for rendering the template (see ``template`` parameter)
|
||||
|
||||
@@ -58,42 +64,84 @@ def mail(email: str, subject: str, template: str,
|
||||
if email == INVALID_ADDRESS:
|
||||
return
|
||||
|
||||
headers = headers or {}
|
||||
|
||||
with language(locale):
|
||||
if isinstance(context, dict) and order:
|
||||
try:
|
||||
context.update({
|
||||
'invoice_name': order.invoice_address.name,
|
||||
'invoice_company': order.invoice_address.company
|
||||
})
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
context.update({
|
||||
'invoice_name': '',
|
||||
'invoice_company': ''
|
||||
})
|
||||
if isinstance(template, LazyI18nString):
|
||||
body = str(template)
|
||||
if context:
|
||||
body = body.format_map(TolerantDict(context))
|
||||
body_md = bleach.linkify(bleach.clean(markdown.markdown(body), tags=bleach.ALLOWED_TAGS + [
|
||||
'p',
|
||||
]))
|
||||
else:
|
||||
tpl = get_template(template)
|
||||
body = tpl.render(context)
|
||||
body_md = bleach.linkify(markdown.markdown(body))
|
||||
|
||||
sender = event.settings.get('mail_from') if event else settings.MAIL_FROM
|
||||
|
||||
subject = str(subject)
|
||||
body_plain = body
|
||||
|
||||
htmlctx = {
|
||||
'site': settings.PRETIX_INSTANCE_NAME,
|
||||
'site_url': settings.SITE_URL,
|
||||
'body': body_md,
|
||||
'color': '#8E44B3'
|
||||
}
|
||||
|
||||
if event:
|
||||
htmlctx['event'] = event
|
||||
htmlctx['color'] = event.settings.primary_color
|
||||
|
||||
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail:
|
||||
headers['Reply-To'] = event.settings.contact_mail
|
||||
|
||||
prefix = event.settings.get('mail_prefix')
|
||||
if prefix:
|
||||
subject = "[%s] %s" % (prefix, subject)
|
||||
|
||||
body += "\r\n\r\n-- \r\n"
|
||||
body += _(
|
||||
body_plain += "\r\n\r\n-- \r\n"
|
||||
body_plain += _(
|
||||
"You are receiving this email because you placed an order for {event}."
|
||||
).format(event=event.name)
|
||||
if order:
|
||||
body += "\r\n"
|
||||
body += _(
|
||||
"You can view your order details at the following URL:\r\n{orderurl}."
|
||||
).format(event=event.name, orderurl=build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}))
|
||||
body += "\r\n"
|
||||
return mail_send([email], subject, body, sender, event.id if event else None, headers)
|
||||
htmlctx['order'] = order
|
||||
body_plain += "\r\n"
|
||||
body_plain += _(
|
||||
"You can view your order details at the following URL:\n{orderurl}."
|
||||
).replace("\n", "\r\n").format(
|
||||
event=event.name, orderurl=build_absolute_uri(
|
||||
order.event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}
|
||||
)
|
||||
)
|
||||
body_plain += "\r\n"
|
||||
|
||||
tpl = get_template('pretixbase/email/plainwrapper.html')
|
||||
body_html = tpl.render(htmlctx)
|
||||
return mail_send([email], subject, body_plain, body_html, sender, event.id if event else None, headers)
|
||||
|
||||
|
||||
@app.task
|
||||
def mail_send_task(to: str, subject: str, body: str, sender: str, event: int=None, headers: dict=None) -> bool:
|
||||
email = EmailMessage(subject, body, sender, to=to, headers=headers)
|
||||
def mail_send_task(to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int=None, headers: dict=None) -> bool:
|
||||
email = EmailMultiAlternatives(subject, body, sender, to=to, headers=headers)
|
||||
email.attach_alternative(inline_css(html), "text/html")
|
||||
if event:
|
||||
event = Event.objects.get(id=event)
|
||||
backend = event.get_mail_backend()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from collections import Counter, namedtuple
|
||||
@@ -8,6 +7,7 @@ from typing import List, Optional
|
||||
|
||||
import pytz
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.dispatch import receiver
|
||||
@@ -22,7 +22,7 @@ from pretix.base.models import (
|
||||
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
|
||||
User, Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import InvoiceAddress
|
||||
from pretix.base.models.orders import CachedTicket, InvoiceAddress
|
||||
from pretix.base.payment import BasePaymentProvider
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.services.invoices import (
|
||||
@@ -33,7 +33,7 @@ from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.base.signals import (
|
||||
order_paid, order_placed, periodic_task, register_payment_providers,
|
||||
)
|
||||
from pretix.celery import app
|
||||
from pretix.celery_app import app
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
error_messages = {
|
||||
@@ -44,6 +44,9 @@ error_messages = {
|
||||
'price_changed': _('The price of some of the items in your cart has changed in the '
|
||||
'meantime. Please see below for details.'),
|
||||
'internal': _("An internal error occured, please try again."),
|
||||
'empty': _("Your cart is empty."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s. We removed the "
|
||||
"surplus items from your cart."),
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
@@ -51,8 +54,6 @@ error_messages = {
|
||||
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
|
||||
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
|
||||
'number of times allowed. We removed this item from your cart.'),
|
||||
'voucher_redeemed_partial': _('The voucher code used for one of the items in your cart can only be redeemed %d '
|
||||
'more times. We removed this item from your cart.'),
|
||||
'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item '
|
||||
'from your cart.'),
|
||||
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
|
||||
@@ -65,7 +66,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None,
|
||||
force: bool=False, send_mail: bool=True, user: User=None) -> Order:
|
||||
force: bool=False, send_mail: bool=True, user: User=None, mail_text='') -> Order:
|
||||
"""
|
||||
Marks an order as paid. This sets the payment provider, info and date and returns
|
||||
the order object.
|
||||
@@ -83,17 +84,14 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
|
||||
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
|
||||
:type send_mail: boolean
|
||||
:param user: The user that performed the change
|
||||
:param mail_text: Additional text to be included in the email
|
||||
:type mail_text: str
|
||||
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
|
||||
"""
|
||||
lock_func = order.event.lock
|
||||
if order.status == order.STATUS_PENDING and order.expires > now() + timedelta(minutes=10):
|
||||
# No lock necessary in this case. The 10 minute offset is just to be safe and prevent
|
||||
# collisions with the cronjob.
|
||||
@contextlib.contextmanager
|
||||
def lock_func():
|
||||
yield now()
|
||||
if order.status == Order.STATUS_PAID:
|
||||
return order
|
||||
|
||||
with lock_func() as now_dt:
|
||||
with order.event.lock() as now_dt:
|
||||
can_be_paid = order._can_be_paid()
|
||||
if not force and can_be_paid is not True:
|
||||
raise Quota.QuotaExceededException(can_be_paid)
|
||||
@@ -108,14 +106,24 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
|
||||
order.log_action('pretix.event.order.paid', {
|
||||
'provider': provider,
|
||||
'info': info,
|
||||
'date': date,
|
||||
'date': date or now_dt,
|
||||
'manual': manual,
|
||||
'force': force
|
||||
}, user=user)
|
||||
order_paid.send(order.event, order=order)
|
||||
|
||||
if order.event.settings.get('invoice_generate') in ('True', 'paid') and invoice_qualified(order):
|
||||
if not order.invoices.exists():
|
||||
generate_invoice(order)
|
||||
|
||||
if send_mail:
|
||||
with language(order.locale):
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
mail(
|
||||
order.email, _('Payment received for your order: %(code)s') % {'code': order.code},
|
||||
order.event.settings.mail_text_order_paid,
|
||||
@@ -125,7 +133,10 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'downloads': order.event.settings.get('ticket_download', as_type=bool)
|
||||
'downloads': order.event.settings.get('ticket_download', as_type=bool),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
'payment_info': mail_text
|
||||
},
|
||||
order.event, locale=order.locale
|
||||
)
|
||||
@@ -181,7 +192,7 @@ def _cancel_order(order, user=None):
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
|
||||
return order
|
||||
return order.pk
|
||||
|
||||
|
||||
class OrderError(LazyLocaleException):
|
||||
@@ -196,18 +207,35 @@ def _check_date(event: Event, now_dt: datetime):
|
||||
|
||||
|
||||
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition]):
|
||||
"""
|
||||
Checks constraints on all positions except quota
|
||||
"""
|
||||
err = None
|
||||
errargs = None
|
||||
_check_date(event, now_dt)
|
||||
|
||||
products_seen = Counter()
|
||||
for i, cp in enumerate(positions):
|
||||
if not cp.item.active or (cp.variation and not cp.variation.active):
|
||||
err = err or error_messages['unavailable']
|
||||
cp.delete()
|
||||
continue
|
||||
cp._quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
|
||||
products_seen[cp.item] += 1
|
||||
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
|
||||
err = error_messages['max_items_per_product']
|
||||
errargs = {'max': cp.item.max_per_order,
|
||||
'product': cp.item.name}
|
||||
cp.delete() # Sorry!
|
||||
break
|
||||
|
||||
if cp.voucher:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
||||
).exclude(pk=cp.pk)
|
||||
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
|
||||
if v_avail < 1:
|
||||
err = err or error_messages['voucher_redeemed']
|
||||
cp.delete() # Sorry!
|
||||
continue
|
||||
|
||||
if cp.item.require_voucher and cp.voucher is None:
|
||||
cp.delete()
|
||||
@@ -227,7 +255,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
price = cp.item.default_price if cp.variation is None else (
|
||||
cp.variation.default_price if cp.variation.default_price is not None else cp.item.default_price)
|
||||
|
||||
if price is False or len(cp._quotas) == 0:
|
||||
if price is False or len(quotas) == 0:
|
||||
err = err or error_messages['unavailable']
|
||||
cp.delete()
|
||||
continue
|
||||
@@ -237,8 +265,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
err = err or error_messages['voucher_expired']
|
||||
cp.delete()
|
||||
continue
|
||||
if cp.voucher.price is not None:
|
||||
price = cp.voucher.price
|
||||
price = cp.voucher.calculate_price(price)
|
||||
|
||||
if price != cp.price and not (cp.item.free_price and cp.price > price):
|
||||
positions[i] = cp
|
||||
@@ -246,126 +273,53 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.save()
|
||||
err = err or error_messages['price_changed']
|
||||
continue
|
||||
|
||||
quota_ok = True
|
||||
|
||||
ignore_all_quotas = cp.expires >= now_dt or (
|
||||
cp.voucher and (cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)))
|
||||
|
||||
if not ignore_all_quotas:
|
||||
for quota in quotas:
|
||||
if cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id == quota.pk:
|
||||
continue
|
||||
avail = quota.availability(now_dt)
|
||||
if avail[0] != Quota.AVAILABILITY_OK:
|
||||
# This quota is sold out/currently unavailable, so do not sell this at all
|
||||
err = err or error_messages['unavailable']
|
||||
quota_ok = False
|
||||
break
|
||||
|
||||
if quota_ok:
|
||||
positions[i] = cp
|
||||
cp.expires = now_dt + timedelta(
|
||||
minutes=event.settings.get('reservation_time', as_type=int))
|
||||
cp.save()
|
||||
else:
|
||||
cp.delete() # Sorry!
|
||||
if err:
|
||||
raise OrderError(err)
|
||||
raise OrderError(err, errargs)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
payment_provider: BasePaymentProvider, expires: datetime, locale: str=None, address: int=None,
|
||||
payment_provider: BasePaymentProvider, locale: str=None, address: int=None,
|
||||
meta_info: dict=None):
|
||||
from datetime import date, time
|
||||
|
||||
total = sum([c.price for c in positions])
|
||||
payment_fee = payment_provider.calculate_fee(total)
|
||||
total += payment_fee
|
||||
|
||||
order = Order.objects.create(
|
||||
status=Order.STATUS_PENDING,
|
||||
event=event,
|
||||
email=email,
|
||||
datetime=now_dt,
|
||||
expires=expires,
|
||||
locale=locale,
|
||||
total=total,
|
||||
payment_fee=payment_fee,
|
||||
payment_provider=payment_provider.identifier,
|
||||
meta_info=json.dumps(meta_info or {}),
|
||||
)
|
||||
OrderPosition.transform_cart_positions(positions, order)
|
||||
|
||||
if address is not None:
|
||||
try:
|
||||
addr = InvoiceAddress.objects.get(
|
||||
pk=address
|
||||
)
|
||||
if addr.order is not None:
|
||||
addr.pk = None
|
||||
addr.order = order
|
||||
addr.save()
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
order.log_action('pretix.event.order.placed')
|
||||
order_placed.send(event, order=order)
|
||||
return order
|
||||
|
||||
|
||||
def _check_quota_on_expired_positions(event: Event, positions: List[CartPosition], now_dt: datetime):
|
||||
err = None
|
||||
quotadiff = Counter()
|
||||
vouchers = Counter()
|
||||
for cp in positions:
|
||||
if not cp.id:
|
||||
continue
|
||||
|
||||
ignore_all_quotas = cp.expires >= now_dt or (
|
||||
cp.voucher and (cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)))
|
||||
|
||||
if ignore_all_quotas:
|
||||
cp._quotas = []
|
||||
elif cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id:
|
||||
cp._quotas = [q for q in cp._quotas if cp.voucher.quota_id != q.pk]
|
||||
|
||||
for quota in cp._quotas:
|
||||
quotadiff[quota] += 1
|
||||
|
||||
quotas_ok = {}
|
||||
for quota, count in quotadiff.items():
|
||||
avail = quota.availability(now_dt)
|
||||
if avail[1] is not None and avail[1] < count:
|
||||
# This quota is not available or less than items are than requested left, so we have to
|
||||
# reduce the number of bought items
|
||||
if avail[0] != Quota.AVAILABILITY_OK:
|
||||
err = err or error_messages['unavailable']
|
||||
else:
|
||||
err = err or error_messages['in_part']
|
||||
quotas_ok[quota] = min(count, avail[1])
|
||||
else:
|
||||
quotas_ok[quota] = count
|
||||
|
||||
for cp in positions:
|
||||
if not cp.id:
|
||||
continue
|
||||
|
||||
if cp.voucher:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
||||
).exclude(pk__in=[cp2.pk for cp2 in positions])
|
||||
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
|
||||
if v_avail < 1:
|
||||
err = err or error_messages['voucher_redeemed']
|
||||
cp.delete() # Sorry!
|
||||
continue
|
||||
if v_avail - vouchers[cp.voucher] < 1:
|
||||
err = err or (error_messages['voucher_redeemed_partial'] % v_avail)
|
||||
cp.delete() # Sorry!
|
||||
continue
|
||||
vouchers[cp.voucher] += 1
|
||||
|
||||
if cp._quotas:
|
||||
if min(quotas_ok[q] for q in cp._quotas) > 0:
|
||||
cp.expires = now_dt + timedelta(minutes=event.settings.get('reservation_time', as_type=int))
|
||||
cp.save()
|
||||
for q in cp._quotas:
|
||||
quotas_ok[q] -= 1
|
||||
else:
|
||||
cp.delete()
|
||||
|
||||
if err:
|
||||
raise OrderError(err)
|
||||
|
||||
|
||||
def _calculate_expiry(event: Event, now_dt: datetime):
|
||||
from datetime import date, time
|
||||
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
expires = now_dt.astimezone(tz) + timedelta(days=event.settings.get('payment_term_days', as_type=int))
|
||||
expires = expires.replace(hour=23, minute=59, second=59, microsecond=0)
|
||||
exp_by_date = now_dt.astimezone(tz) + timedelta(days=event.settings.get('payment_term_days', as_type=int))
|
||||
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
|
||||
if event.settings.get('payment_term_weekdays'):
|
||||
if expires.weekday() == 5:
|
||||
expires += timedelta(days=2)
|
||||
elif expires.weekday() == 6:
|
||||
expires += timedelta(days=1)
|
||||
if exp_by_date.weekday() == 5:
|
||||
exp_by_date += timedelta(days=2)
|
||||
elif exp_by_date.weekday() == 6:
|
||||
exp_by_date += timedelta(days=1)
|
||||
|
||||
expires = exp_by_date
|
||||
|
||||
if event.settings.get('payment_term_last'):
|
||||
last_date = make_aware(datetime.combine(
|
||||
@@ -375,7 +329,37 @@ def _calculate_expiry(event: Event, now_dt: datetime):
|
||||
if last_date < expires:
|
||||
expires = last_date
|
||||
|
||||
return expires
|
||||
with transaction.atomic():
|
||||
order = Order.objects.create(
|
||||
status=Order.STATUS_PENDING,
|
||||
event=event,
|
||||
email=email,
|
||||
datetime=now_dt,
|
||||
expires=expires,
|
||||
locale=locale,
|
||||
total=total,
|
||||
payment_fee=payment_fee,
|
||||
payment_provider=payment_provider.identifier,
|
||||
meta_info=json.dumps(meta_info or {}),
|
||||
)
|
||||
OrderPosition.transform_cart_positions(positions, order)
|
||||
|
||||
if address is not None:
|
||||
try:
|
||||
addr = InvoiceAddress.objects.get(
|
||||
pk=address
|
||||
)
|
||||
if addr.order is not None:
|
||||
addr.pk = None
|
||||
addr.order = order
|
||||
addr.save()
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
order.log_action('pretix.event.order.placed')
|
||||
|
||||
order_placed.send(event, order=order)
|
||||
return order
|
||||
|
||||
|
||||
def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
@@ -391,45 +375,51 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
if not pprov:
|
||||
raise OrderError(error_messages['internal'])
|
||||
|
||||
now_dt = now()
|
||||
|
||||
positions = list(CartPosition.objects.filter(id__in=position_ids).select_related('item', 'variation'))
|
||||
if set(str(p) for p in position_ids) != set(str(p.id) for p in positions):
|
||||
raise OrderError(error_messages['internal'])
|
||||
|
||||
_check_positions(event, now_dt, positions)
|
||||
expires = _calculate_expiry(event, now_dt)
|
||||
|
||||
with event.lock() as now_dt:
|
||||
_check_quota_on_expired_positions(event, positions, now_dt)
|
||||
order = _create_order(event, email, positions, now_dt, pprov, expires,
|
||||
positions = list(CartPosition.objects.filter(
|
||||
id__in=position_ids).select_related('item', 'variation'))
|
||||
if len(positions) == 0:
|
||||
raise OrderError(error_messages['empty'])
|
||||
if len(position_ids) != len(positions):
|
||||
raise OrderError(error_messages['internal'])
|
||||
_check_positions(event, now_dt, positions)
|
||||
order = _create_order(event, email, positions, now_dt, pprov,
|
||||
locale=locale, address=address, meta_info=meta_info)
|
||||
|
||||
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
|
||||
if not order.invoices.exists():
|
||||
generate_invoice(order)
|
||||
|
||||
with language(order.locale):
|
||||
if order.total == Decimal('0.00'):
|
||||
mailtext = event.settings.mail_text_order_free
|
||||
else:
|
||||
mailtext = event.settings.mail_text_order_placed
|
||||
mail(
|
||||
order.email, _('Your order: %(code)s') % {'code': order.code},
|
||||
mailtext,
|
||||
{
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'paymentinfo': str(pprov.order_pending_mail_render(order))
|
||||
},
|
||||
event, locale=order.locale
|
||||
)
|
||||
if order.total == Decimal('0.00'):
|
||||
mailtext = event.settings.mail_text_order_free
|
||||
else:
|
||||
mailtext = event.settings.mail_text_order_placed
|
||||
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
|
||||
mail(
|
||||
order.email, _('Your order: %(code)s') % {'code': order.code},
|
||||
mailtext,
|
||||
{
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'paymentinfo': str(pprov.order_pending_mail_render(order)),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
event, locale=order.locale
|
||||
)
|
||||
|
||||
return order.id
|
||||
|
||||
@@ -455,29 +445,39 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
today = now().replace(hour=0, minute=0, second=0)
|
||||
|
||||
for o in Order.objects.filter(expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING).select_related('event'):
|
||||
settings = eventcache.get(o.event.pk, None)
|
||||
if settings is None:
|
||||
settings = o.event.settings
|
||||
eventcache[o.event.pk] = settings
|
||||
eventsettings = eventcache.get(o.event.pk, None)
|
||||
if eventsettings is None:
|
||||
eventsettings = o.event.settings
|
||||
eventcache[o.event.pk] = eventsettings
|
||||
|
||||
days = settings.get('mail_days_order_expire_warning', as_type=int)
|
||||
days = eventsettings.get('mail_days_order_expire_warning', as_type=int)
|
||||
tz = pytz.timezone(eventsettings.get('timezone', settings.TIME_ZONE))
|
||||
if days and (o.expires - today).days <= days:
|
||||
o.expiry_reminder_sent = True
|
||||
o.save()
|
||||
try:
|
||||
mail(
|
||||
o.email, _('Your order is about to expire: %(code)s') % {'code': o.code},
|
||||
settings.mail_text_order_expire_warning,
|
||||
{
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
}),
|
||||
'expire_date': date_format(o.expires, 'SHORT_DATE_FORMAT')
|
||||
},
|
||||
o.event, locale=o.locale
|
||||
)
|
||||
invoice_name = o.invoice_address.name
|
||||
invoice_company = o.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
try:
|
||||
with language(o.locale):
|
||||
mail(
|
||||
o.email, _('Your order is about to expire: %(code)s') % {'code': o.code},
|
||||
eventsettings.mail_text_order_expire_warning,
|
||||
{
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
}),
|
||||
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
o.event, locale=o.locale
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
else:
|
||||
@@ -491,9 +491,11 @@ class OrderChangeManager:
|
||||
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
|
||||
'product_invalid': _('The selected product is not active or has no price set.'),
|
||||
'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'),
|
||||
'not_pending': _('Only pending orders can be changed.'),
|
||||
'not_pending_or_paid': _('Only pending or paid orders can be changed.'),
|
||||
'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
|
||||
'no quota is available.'),
|
||||
'paid_price_change': _('Currently, paid orders can only be changed in a way that does not change the total '
|
||||
'price of the order as partial payments or refunds are not yet supported.')
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price'))
|
||||
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
|
||||
@@ -509,8 +511,7 @@ class OrderChangeManager:
|
||||
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
|
||||
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
|
||||
raise OrderError(self.error_messages['product_without_variation'])
|
||||
price = item.default_price if variation is None else (
|
||||
variation.default_price if variation.default_price is not None else item.default_price)
|
||||
price = item.default_price if variation is None else variation.price
|
||||
if not price:
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
self._totaldiff = price - position.price
|
||||
@@ -539,6 +540,10 @@ class OrderChangeManager:
|
||||
if self.order.total == Decimal('0.00') and self._totaldiff > 0:
|
||||
raise OrderError(self.error_messages['free_to_paid'])
|
||||
|
||||
def _check_paid_price_change(self):
|
||||
if self.order.status == Order.STATUS_PAID and self._totaldiff != 0:
|
||||
raise OrderError(self.error_messages['paid_price_change'])
|
||||
|
||||
def _check_paid_to_free(self):
|
||||
if self.order.total == 0:
|
||||
try:
|
||||
@@ -551,11 +556,13 @@ class OrderChangeManager:
|
||||
if isinstance(op, self.ItemOperation):
|
||||
self.order.log_action('pretix.event.order.changed.item', user=self.user, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'old_item': op.position.item.pk,
|
||||
'old_variation': op.position.variation.pk if op.position.variation else None,
|
||||
'new_item': op.item.pk,
|
||||
'new_variation': op.variation.pk if op.variation else None,
|
||||
'old_price': op.position.price,
|
||||
'addon_to': op.position.addon_to_id,
|
||||
'new_price': op.price
|
||||
})
|
||||
op.position.item = op.item
|
||||
@@ -566,18 +573,31 @@ class OrderChangeManager:
|
||||
elif isinstance(op, self.PriceOperation):
|
||||
self.order.log_action('pretix.event.order.changed.price', user=self.user, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'old_price': op.position.price,
|
||||
'addon_to': op.position.addon_to_id,
|
||||
'new_price': op.price
|
||||
})
|
||||
op.position.price = op.price
|
||||
op.position._calculate_tax()
|
||||
op.position.save()
|
||||
elif isinstance(op, self.CancelOperation):
|
||||
for opa in op.position.addons.all():
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
|
||||
'position': opa.pk,
|
||||
'positionid': opa.positionid,
|
||||
'old_item': opa.item.pk,
|
||||
'old_variation': opa.variation.pk if opa.variation else None,
|
||||
'addon_to': opa.addon_to_id,
|
||||
'old_price': opa.price,
|
||||
})
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'old_item': op.position.item.pk,
|
||||
'old_variation': op.position.variation.pk if op.position.variation else None,
|
||||
'old_price': op.position.price,
|
||||
'addon_to': None,
|
||||
})
|
||||
op.position.delete()
|
||||
|
||||
@@ -605,6 +625,12 @@ class OrderChangeManager:
|
||||
|
||||
def _notify_user(self):
|
||||
with language(self.order.locale):
|
||||
try:
|
||||
invoice_name = self.order.invoice_address.name
|
||||
invoice_company = self.order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
mail(
|
||||
self.order.email, _('Your order has been changed: %(code)s') % {'code': self.order.code},
|
||||
self.order.event.settings.mail_text_order_changed,
|
||||
@@ -614,6 +640,8 @@ class OrderChangeManager:
|
||||
'order': self.order.code,
|
||||
'secret': self.order.secret
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
self.order.event, locale=self.order.locale
|
||||
)
|
||||
@@ -623,18 +651,23 @@ class OrderChangeManager:
|
||||
# Do nothing
|
||||
return
|
||||
with transaction.atomic():
|
||||
self._check_free_to_paid()
|
||||
self._check_complete_cancel()
|
||||
with self.order.event.lock():
|
||||
if self.order.status != Order.STATUS_PENDING:
|
||||
raise OrderError(self.error_messages['not_pending'])
|
||||
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
raise OrderError(self.error_messages['not_pending_or_paid'])
|
||||
self._check_free_to_paid()
|
||||
self._check_paid_price_change()
|
||||
self._check_quotas()
|
||||
self._check_complete_cancel()
|
||||
self._perform_operations()
|
||||
self._recalculate_total_and_payment_fee()
|
||||
self._reissue_invoice()
|
||||
self._clear_tickets_cache()
|
||||
self._check_paid_to_free()
|
||||
self._notify_user()
|
||||
|
||||
def _clear_tickets_cache(self):
|
||||
CachedTicket.objects.filter(order_position__order=self.order).delete()
|
||||
|
||||
def _get_payment_provider(self):
|
||||
responses = register_payment_providers.send(self.order.event)
|
||||
pprov = None
|
||||
@@ -646,19 +679,20 @@ class OrderChangeManager:
|
||||
raise OrderError(error_messages['internal'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
email: str=None, locale: str=None, address: int=None, meta_info: dict=None):
|
||||
try:
|
||||
with language(locale):
|
||||
try:
|
||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
return OrderError(error_messages['busy'])
|
||||
try:
|
||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
return OrderError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_order(self, order: int, user: int=None):
|
||||
try:
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import timedelta
|
||||
import os
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import now
|
||||
@@ -6,34 +6,62 @@ from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, CachedTicket, Event, Order, OrderPosition, cachedfile_name,
|
||||
CachedCombinedTicket, CachedTicket, Event, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.celery import app
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def generate(order_position: str, provider: str):
|
||||
order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position)
|
||||
ct = CachedTicket.objects.get_or_create(order_position=order_position, provider=provider)[0]
|
||||
if not ct.cachedfile:
|
||||
cf = CachedFile()
|
||||
cf.date = now()
|
||||
cf.expires = order_position.order.event.date_from + timedelta(days=30)
|
||||
cf.save()
|
||||
ct.cachedfile = cf
|
||||
ct.save()
|
||||
try:
|
||||
ct = CachedTicket.objects.get(order_position=order_position, provider=provider)
|
||||
except CachedTicket.MultipleObjectsReturned:
|
||||
CachedTicket.objects.filter(order_position=order_position, provider=provider).delete()
|
||||
ct = CachedTicket.objects.create(order_position=order_position, provider=provider, extension='',
|
||||
type='', file=None)
|
||||
except CachedTicket.DoesNotExist:
|
||||
ct = CachedTicket.objects.create(order_position=order_position, provider=provider, extension='',
|
||||
type='', file=None)
|
||||
|
||||
with language(order_position.order.locale):
|
||||
responses = register_ticket_outputs.send(order_position.order.event)
|
||||
for receiver, response in responses:
|
||||
prov = response(order_position.order.event)
|
||||
if prov.identifier == provider:
|
||||
ct.cachedfile.filename, ct.cachedfile.type, data = prov.generate(order_position)
|
||||
ct.cachedfile.file.save(cachedfile_name(ct.cachedfile, ct.cachedfile.filename), ContentFile(data))
|
||||
ct.cachedfile.save()
|
||||
filename, ct.type, data = prov.generate(order_position)
|
||||
path, ext = os.path.splitext(filename)
|
||||
ct.extension = ext
|
||||
ct.save()
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def generate_order(order: int, provider: str):
|
||||
order = Order.objects.select_related('event').get(id=order)
|
||||
try:
|
||||
ct = CachedCombinedTicket.objects.get(order=order, provider=provider)
|
||||
except CachedCombinedTicket.MultipleObjectsReturned:
|
||||
CachedCombinedTicket.objects.filter(order=order, provider=provider).delete()
|
||||
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension='',
|
||||
type='', file=None)
|
||||
except CachedCombinedTicket.DoesNotExist:
|
||||
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension='',
|
||||
type='', file=None)
|
||||
|
||||
with language(order.locale):
|
||||
responses = register_ticket_outputs.send(order.event)
|
||||
for receiver, response in responses:
|
||||
prov = response(order.event)
|
||||
if prov.identifier == provider:
|
||||
filename, ct.type, data = prov.generate_order(order)
|
||||
path, ext = os.path.splitext(filename)
|
||||
ct.extension = ext
|
||||
ct.save()
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
|
||||
|
||||
class DummyRollbackException(Exception):
|
||||
|
||||
125
src/pretix/base/services/update_check.py
Normal file
125
src/pretix/base/services/update_check.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _, ugettext_noop
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix import __version__
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
def run_update_check(sender, **kwargs):
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.update_check_perform:
|
||||
return
|
||||
|
||||
if not gs.settings.update_check_last or now() - gs.settings.update_check_last > timedelta(hours=23):
|
||||
update_check.apply_async()
|
||||
|
||||
|
||||
@app.task
|
||||
def update_check():
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.update_check_perform:
|
||||
return
|
||||
|
||||
if not gs.settings.update_check_id:
|
||||
gs.settings.set('update_check_id', uuid.uuid4().hex)
|
||||
|
||||
if 'runserver' in sys.argv:
|
||||
gs.settings.set('update_check_last', now())
|
||||
gs.settings.set('update_check_result', {
|
||||
'error': 'development'
|
||||
})
|
||||
return
|
||||
|
||||
check_payload = {
|
||||
'id': gs.settings.get('update_check_id'),
|
||||
'version': __version__,
|
||||
'events': {
|
||||
'total': Event.objects.count(),
|
||||
'live': Event.objects.filter(live=True).count(),
|
||||
},
|
||||
'plugins': [
|
||||
{
|
||||
'name': p.module,
|
||||
'version': p.version
|
||||
} for p in get_all_plugins()
|
||||
]
|
||||
}
|
||||
try:
|
||||
r = requests.post('https://pretix.eu/.update_check/', json=check_payload)
|
||||
gs.settings.set('update_check_last', now())
|
||||
if r.status_code != 200:
|
||||
gs.settings.set('update_check_result', {
|
||||
'error': 'http_error'
|
||||
})
|
||||
else:
|
||||
rdata = r.json()
|
||||
update_available = rdata['version']['updatable'] or any(p['updatable'] for p in rdata['plugins'].values())
|
||||
gs.settings.set('update_check_result_warning', update_available)
|
||||
if update_available and rdata != gs.settings.update_check_result:
|
||||
send_update_notification_email()
|
||||
gs.settings.set('update_check_result', rdata)
|
||||
except requests.RequestException:
|
||||
gs.settings.set('update_check_last', now())
|
||||
gs.settings.set('update_check_result', {
|
||||
'error': 'unavailable'
|
||||
})
|
||||
|
||||
|
||||
def send_update_notification_email():
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.update_check_email:
|
||||
return
|
||||
|
||||
mail(
|
||||
gs.settings.update_check_email,
|
||||
_('pretix update available'),
|
||||
LazyI18nString.from_gettext(
|
||||
ugettext_noop(
|
||||
'Hi!\n\nAn update is available for pretix or for one of the plugins you installed in your '
|
||||
'pretix installation. Please click on the following link for more information:\n\n {url} \n\n'
|
||||
'You can always find information on the latest updates on the pretix.eu blog:\n\n'
|
||||
'https://pretix.eu/about/en/blog/'
|
||||
'\n\nBest,\n\nyour pretix developers'
|
||||
)
|
||||
),
|
||||
{
|
||||
'url': build_absolute_uri('control:global.update')
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def check_result_table():
|
||||
gs = GlobalSettingsObject()
|
||||
res = gs.settings.update_check_result
|
||||
if not res:
|
||||
return {
|
||||
'error': 'no_result'
|
||||
}
|
||||
|
||||
if 'error' in res:
|
||||
return res
|
||||
|
||||
table = []
|
||||
table.append(('pretix', __version__, res['version']['latest'], res['version']['updatable']))
|
||||
for p in get_all_plugins():
|
||||
if p.module in res['plugins']:
|
||||
pdata = res['plugins'][p.module]
|
||||
table.append((_('Plugin: %s') % p.name, p.version, pdata['latest'], pdata['updatable']))
|
||||
else:
|
||||
table.append((_('Plugin: %s') % p.name, p.version, '?', False))
|
||||
|
||||
return table
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user