Compare commits

...

123 Commits

Author SHA1 Message Date
Raphael Michel
b4a7947062 Bump version 2017-05-18 10:19:21 +02:00
Raphael Michel
66ce94cac3 Add release config for GitLab CI 2017-05-18 10:18:59 +02:00
Raphael Michel
4556914a2d Fix critical bug in item creation 2017-05-18 10:18:11 +02:00
Raphael Michel
52f057053e Bump version 2017-04-17 13:36:10 +02:00
Raphael Michel
c3eb909742 Fix locale formatting in PDF exporter 2017-04-17 13:34:02 +02:00
Jan Felix Wiebe
8b88fb0988 Switched checkbox order (#461)
The boxes vor adding and editing vouchers were switched for new users.
2017-04-17 13:33:48 +02:00
Raphael Michel
8884502d19 Update minimal django version 2017-04-17 13:33:20 +02:00
Raphael Michel
a6a3544628 Bump version number 2017-04-01 16:19:57 +02:00
Raphael Michel
ca762083b6 Update translations 2017-04-01 15:55:31 +02:00
alainrk
550ab7de18 Added python3-dev package to External Dependencies (#451) 2017-04-01 15:40:46 +02:00
Raphael Michel
4919f8991c Automatic update checks (#434)
* Basic update checks

* Fix issues pointed out by @rixx

* First test

* Add tests

* Even more tests
2017-04-01 15:34:34 +02:00
Raphael Michel
867a8132aa Add error messages to forms 2017-04-01 15:23:11 +02:00
Raphael Michel
c661122bb6 Increase margin in emails 2017-04-01 15:22:40 +02:00
Raphael Michel
80bd8d2039 Fix TypeError introduced in 9dacea11 2017-03-31 11:49:25 +02:00
Raphael Michel
7267496367 Fix small UI glitches 2017-03-31 11:11:40 +02:00
Raphael Michel
9dacea11dd Improve wording 2017-03-31 09:54:07 +02:00
Raphael Michel
91c48c50e5 Fix unclear labels and validations 2017-03-30 23:34:28 +02:00
Raphael Michel
67e5ecb931 Add IcePay to list of plugins 2017-03-30 16:45:01 +02:00
Raphael Michel
887152a0e2 Fix retry logic for failed order downloads 2017-03-29 14:36:09 +02:00
Raphael Michel
c1a76c4c18 HTML Sanitizer: Allow the class attribute 2017-03-28 10:54:08 +02:00
Raphael Michel
8dacbe0fc6 Add docs for metrics and FakeRedis.pipeline 2017-03-27 23:07:33 +02:00
Raphael Michel
a4ead5bd07 Metrics: Pipeline redis commands 2017-03-27 22:31:20 +02:00
Raphael Michel
2f6e36c504 Metrics: Replace redundant metrics by aliases 2017-03-27 22:24:35 +02:00
Raphael Michel
bcdb4fd000 Metrics: Count model instances in database 2017-03-27 21:58:38 +02:00
Raphael Michel
99395c722d Run CommonMiddleware before metrics (because of APPEND_SLASH) 2017-03-27 21:52:00 +02:00
Raphael Michel
e28030576a Metrics: Follow conventions (thanks @cherti) 2017-03-27 21:43:11 +02:00
Raphael Michel
455b0f2015 Add documentation on backups and monitoring 2017-03-25 22:54:39 +01:00
Raphael Michel
6da0125b7d Metrics: Add empty line at end 2017-03-25 22:21:22 +01:00
Raphael Michel
48912bdf55 Healthcheck: Check redis and cache 2017-03-25 22:21:07 +01:00
Raphael Michel
ba70ddfb76 Metrics: Fix key output 2017-03-25 22:12:36 +01:00
Raphael Michel
f828fcdcab Fix and extend metrics tests 2017-03-25 22:08:44 +01:00
Raphael Michel
c1403207de Metrics: Store all metrics within a redis hash 2017-03-25 22:08:15 +01:00
Raphael Michel
4514bd7e53 Add metrics for view/task time 2017-03-25 21:38:42 +01:00
Raphael Michel
f2378168c1 Metrics: Implement histograms 2017-03-25 21:38:04 +01:00
Raphael Michel
e0e3a72268 Add metrics for request/task counting 2017-03-25 21:17:12 +01:00
Raphael Michel
c932892dbd Fix bugs in metrics tooling 2017-03-25 21:16:07 +01:00
Raphael Michel
f03ad7c68f Revert 36d6b6f9, pass language to async tasks 2017-03-25 11:32:29 +01:00
Raphael Michel
d3a26d8022 Display checkbox for items with max_per_order == 1 2017-03-24 17:30:37 +01:00
Raphael Michel
446698d52f Update German translation 2017-03-24 17:17:09 +01:00
Raphael Michel
69faab01b2 Add constraint for a maximum number of a ticket per order 2017-03-24 17:11:48 +01:00
Raphael Michel
36d6b6f9ab Support LazyI18nString arguments for LazyLocaleException 2017-03-24 17:03:41 +01:00
Raphael Michel
ea70b5fa46 Remove double negative 2017-03-24 15:44:23 +01:00
Raphael Michel
927e21e5d1 Fix display error on variations with required vouchers 2017-03-20 16:01:09 +01:00
Raphael Michel
259c0cca69 Explicit position ordering in invoice tests 2017-03-19 15:04:21 +01:00
Raphael Michel
11ce4c2078 Add default ordering for Invoice and OrderPosition 2017-03-19 14:47:30 +01:00
Raphael Michel
76ec402fc5 Fix #436 -- Fix test cases broken in aed78c2d 2017-03-19 13:58:08 +01:00
Raphael Michel
df956816b4 Refs #436 -- Try to fix travis script 2017-03-19 13:47:58 +01:00
Flavia Bastos
5d431b3843 Add test for sendmail plugin (#417)
* add test for sendmail plugin

* add post request

* failing get request

* test sendmail simple case

* add tests for sendmail plugin

* add tests to sendmail plugin - adjust test name

* fix multi_locales test for sendmail plugin
2017-03-17 11:03:43 +01:00
Raphael Michel
91ca4f2184 Fix untranslated titles of export providers 2017-03-16 23:46:42 +01:00
Raphael Michel
b00a0eccc6 Add names to AUTHORS file 2017-03-16 23:46:20 +01:00
Raphael Michel
d675ad18e0 Absolute URLs in HTML mails 2017-03-16 09:59:54 +01:00
Raphael Michel
031ed8f3cd Add new template tag abseventurl 2017-03-16 09:59:54 +01:00
Raphael Michel
aed78c2d69 Show "sold out" if all quota is blocked by orders, even unpaid ones 2017-03-13 18:02:45 +01:00
jlwt90
af3e811f94 Fix #428 -- Timezone handling on event creation/update (#432)
* add event timezone during event creation

* add timezone handling in EventUpdate

* added event creation test cases & form cleaning bug fix
2017-03-13 16:44:05 +01:00
Raphael Michel
811c498080 Fix typo 2017-03-12 23:09:11 +01:00
Raphael Michel
e6d58b3b0d Show warnings about development mode and DEBUG mode 2017-03-12 23:03:03 +01:00
Raphael Michel
b7dc671028 Move global settings to a distinct URL 2017-03-12 20:17:55 +01:00
Raphael Michel
8418eb2c6b Add basic tests for event creation 2017-03-12 17:23:39 +01:00
Raphael Michel
5a882a0fae Fix broken i18nfield style 2017-03-12 11:11:13 +01:00
Raphael Michel
be1cbfeb91 Integrate cartshare tests (#425) 2017-03-12 00:32:33 +01:00
Raphael Michel
96c61a073c Fix wrong email subject 2017-03-11 20:03:10 +01:00
Raphael Michel
64ef293ce2 Move "Add to calendar" button down 2017-03-09 21:41:18 +01:00
jlwt90
55953d5b4e Fix #389 -- Add event ical download feature (#413)
* added event ical download feature

* handle event settings and timezone

* add test cases for ical download

* fix failed test case for timezone settings

* using vobject lib to generate ical

* customised UID & add vobject dependency
2017-03-09 21:13:08 +01:00
Raphael Michel
c63e69db5f Remove version pin of html5lib and update bleach 2017-03-08 18:26:52 +01:00
Raphael Michel
f9646d9325 Do casual reads only on Galera 2017-03-08 18:15:39 +01:00
Raphael Michel
6bbdbddfaa Reorder meta tags 2017-03-08 17:54:55 +01:00
Raphael Michel
177d46ab8d New signal: html_footer 2017-03-08 14:38:25 +01:00
Raphael Michel
ecd90da554 Fix syntax fuckup 2017-03-07 23:37:37 +01:00
Raphael Michel
2302dbade6 Even slightly more CSP refactoring 2017-03-07 22:30:15 +01:00
Raphael Michel
cbf735487f Improved merging of CSP headers 2017-03-07 21:48:59 +01:00
Tobias Kunze
a10090b1fb Set autofocus on search/filter field (#427) 2017-03-06 14:19:14 +01:00
Raphael Michel
babf76371e Sendmail plugin: Fix usage of old argument 2017-03-05 17:23:16 +01:00
jlwt90
1baac6bb21 add voucher code in success message (#426)
* add voucher code in success message

* change voucher message
2017-03-05 09:32:56 +01:00
Raphael Michel
b1c60065b2 Bump version number 2017-03-02 23:10:31 +01:00
Raphael Michel
af4a2c7184 Run tests on multiple Python versions and databases (#424)
* Test against more python versions

* Add testing on MySQL

* Add testing on PostgreSQL
2017-03-02 11:59:09 +01:00
Tobias Kunze
b6f42ecd6d Update translations. (#423)
(Including adding a missing whitespace.)
2017-03-02 08:19:34 +01:00
Raphael Michel
8b7d2314b8 Use django-i18nfield library (#418) 2017-02-27 21:16:28 +01:00
Tobias Kunze
81adbb3813 Expand Question docstring (#420) 2017-02-26 13:11:37 +01:00
Raphael Michel
684198fc08 Docker: fix build on docker hub 2017-02-26 12:40:57 +01:00
Raphael Michel
a86431bb6d Docker: Move static files 2017-02-26 11:55:19 +01:00
Raphael Michel
767e3ac659 Docker: Use stable version in docs 2017-02-26 11:32:23 +01:00
Raphael Michel
910d6831bf Docker: Ignore local pretix.cfg on build host 2017-02-26 11:31:24 +01:00
Jakob Schnell
c251a48e31 Fix #248 -- Failed Payment error handling (#333)
* [WIP] Failed Payment error handling

When finished, this should fix #248

* rename PaymentFailedException to PaymentException\nimported Exception where neccessary

* comments fixed

* minor style fixes

* Fixed a name error
2017-02-24 14:11:41 +01:00
Raphael Michel
8e4b71eb19 Display a small pretix logo next to changes performed by superusers 2017-02-23 20:36:19 +01:00
Raphael Michel
a2cb219d9b Remove GenericRelation to prevent cascade deletion of logs 2017-02-23 17:52:48 +01:00
Raphael Michel
f722d4e83e Update translations 2017-02-22 17:31:13 +01:00
Raphael Michel
ed04f3124f Introduce a setting to show net prices (#415)
* Introduce a setting to show net prices in the frontend

* Show net prices in the backend as well
2017-02-22 16:59:54 +01:00
Raphael Michel
08e7a29623 MySQL Galera workaround (#416) 2017-02-22 16:59:23 +01:00
Raphael Michel
09020143e7 Refactoring of cart services (#414) 2017-02-21 17:15:43 +01:00
Raphael Michel
33e7a10bea Fix editing organizers (now for real) 2017-02-20 16:46:48 +01:00
Raphael Michel
5e64f6ac88 Update German translation 2017-02-20 15:41:41 +01:00
Raphael Michel
f16aabc136 Add signal for required pre-checkout confirmations 2017-02-20 15:40:55 +01:00
Raphael Michel
2d00563088 Code style fix 2017-02-20 15:11:03 +01:00
Raphael Michel
124c3a99e6 Fix possible TypeError 2017-02-20 15:11:03 +01:00
Raphael Michel
7e135be012 Add tests and remove redundant checks 2017-02-17 16:19:03 +01:00
Raphael Michel
d94c67bc7a Adjust test to previous change 2017-02-17 09:58:52 +01:00
Raphael Michel
3636bbbf3f Fix invoice logos with transparency 2017-02-17 09:51:06 +01:00
Raphael Michel
7c687ee397 Fix dashboard widgets to show correct waiting list numbers 2017-02-17 09:45:05 +01:00
Raphael Michel
c3fb033d33 Update .codecov.yml 2017-02-16 10:33:08 +01:00
Raphael Michel
8b2257161f Fix voucher redemption and event index after c4bf73c 2017-02-15 19:17:42 +01:00
Raphael Michel
c4bf73c8d6 Refs #340 -- Allow order changes for paid orders if they don't change the total 2017-02-15 18:42:46 +01:00
Raphael Michel
0db927407d Clarify help texts 2017-02-15 18:07:40 +01:00
Tobias Kunze
9b7223c0e8 Enforce a sane last payment date (#412) 2017-02-15 16:37:10 +01:00
Flavia Bastos
7b33fc6633 Fix #409 -- false success message on sendmail plugin (#410)
* Fix false success message on sendmail plugin
#409

* remove unnecessary else statement when fixing false success message on sendmail
#409
2017-02-11 21:49:10 +01:00
Raphael Michel
8310597944 Waitinglist: Improve waitinglist and logging 2017-02-10 11:19:22 +01:00
Raphael Michel
c03ac624fc Update translation 2017-02-10 11:19:05 +01:00
Raphael Michel
323beb1ab0 Add word wrapping to Invoice from/to/event 2017-02-10 10:38:45 +01:00
Raphael Michel
73490d2923 Add custom rich_text template filter 2017-02-10 10:38:45 +01:00
Jan Felix Wiebe
a8e630d271 corrected typo in pdf ticket generator help text (#408)
* Corrected small typo

* corrected same typo in informal language file
2017-02-10 08:36:05 +01:00
Raphael Michel
e3e8a162bd Fix KeyError in sendmail history 2017-02-08 12:06:11 +01:00
Raphael Michel
824ca54478 Refs #386 -- Add unit test 2017-02-08 10:24:50 +01:00
Adam K. Sumner
8661bfe4a4 Fix #386 -- Allow to copy products (#396)
* add copy item info functionality

* fix formatting

* Revert "fix formatting"

This reverts commit 779bd79e8b.

* Revert "add copy item info functionality"

This reverts commit dbec76bf5a.

* add copy functionality

* copy questions from item

* add copy functionality

* copy questions from item

* add copy functionality

* copy questions from item
2017-02-08 10:16:18 +01:00
Raphael Michel
4c2c302bfd Fix organizer team changes 2017-02-07 10:50:20 +01:00
Raphael Michel
c83f539bba Add waiting list 2017-02-07 10:03:30 +01:00
Raphael Michel
8f5849a90c Test on SQLite if not configured otherwise 2017-02-07 10:03:30 +01:00
Raphael Michel
b7df5eff19 Move asynctask/asyncdownload to base 2017-02-07 10:03:30 +01:00
Raphael Michel
eb4ba70be8 Codecov config 2017-02-07 08:39:31 +01:00
Raphael Michel
136094caf9 Updated README (thanks Dr. h.c. pun. @rixx) 2017-02-07 00:09:35 +01:00
Raphael Michel
1fa0256363 Switch from coveralls to codecov 2017-02-07 00:04:28 +01:00
Ahrdie
6de44aee02 Small Typo correction (#404) 2017-02-06 22:04:12 +01:00
Marc-Pascal Clement
43facbecda Fix path in cronjob of smallscale deployment guide (#401) 2017-02-04 14:10:41 +01:00
Raphael Michel
0dfca824e2 Fix install docs, thanks @buenaventure 2017-02-04 14:09:04 +01:00
Raphael Michel
70ee678fef Fix language classifiers 2017-02-03 15:58:38 +01:00
167 changed files with 7364 additions and 2575 deletions

35
.codecov.yml Normal file
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

17
AUTHORS
View File

@@ -3,18 +3,31 @@ 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

View File

@@ -31,11 +31,12 @@ 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 /static /pretix /data data && \
chown -R pretixuser:pretixuser /pretix /data data && \
sudo -u pretixuser make production
USER pretixuser

View File

@@ -10,11 +10,12 @@ pretix
.. image:: https://travis-ci.org/pretix/pretix.svg?branch=master
:target: https://travis-ci.org/pretix/pretix
.. image:: https://coveralls.io/repos/github/pretix/pretix/badge.svg?branch=master
:target: https://coveralls.io/r/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 bit at a time.
Reinventing ticket presales, one ticket at a time.
Project status & release cycle
------------------------------

View File

@@ -57,7 +57,7 @@ http {
return 404;
}
location /static/ {
alias /static/;
alias /pretix/src/pretix/static.dist/;
access_log off;
expires 365d;
add_header Cache-Control "public";

View File

@@ -1,5 +1,4 @@
from pretix.settings import *
LOGGING['handlers']['mail_admins']['include_html'] = True
STATIC_ROOT = '/static'
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'

View File

@@ -102,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
----
@@ -151,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
---------------
@@ -175,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
-------

View File

@@ -10,3 +10,4 @@ Contents:
installation/index
config
maintainance

View File

@@ -141,9 +141,9 @@ See :ref:`email configuration <mail-settings>` to learn more about configuring m
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::
@@ -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,13 @@ 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
----------------

View File

@@ -5,7 +5,7 @@ General remarks
Requirements
------------
To use pretix, you wull need the following things:
To use pretix, you will need the following things:
* **pretix** and the python packages it depends on

View File

@@ -190,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 && python -m pretix 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``).
@@ -236,13 +236,15 @@ The following snippet is an example on how to configure a nginx proxy for pretix
}
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.
@@ -253,6 +255,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
-------

View 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

View File

@@ -25,7 +25,7 @@ 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

View File

@@ -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

View File

@@ -15,6 +15,7 @@ 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``)

View File

@@ -26,9 +26,11 @@ 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

View File

@@ -1 +1 @@
__version__ = "1.0.0"
__version__ = "1.2.2"

View File

@@ -9,7 +9,7 @@ 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_app import app as celery_app # NOQA

View File

@@ -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

View File

@@ -2,7 +2,7 @@ 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 ..exporter import BaseExporter
from ..models import Order

View File

@@ -8,7 +8,7 @@ from django import forms
from django.db.models import Sum
from django.dispatch import receiver
from django.utils.formats import localize
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext as _, ugettext_lazy
from pretix.base.models import InvoiceAddress, Order, OrderPosition
@@ -18,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):

View File

@@ -1,89 +1,55 @@
import logging
import i18nfield.forms
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,
)
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 pretix.base.i18n import I18nFormField
from pretix.base.models import Event
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)
locales = kwargs.pop('locales', None)
if event:
kwargs['locales'] = event.settings.get('locales')
super().__init__(*args, **kwargs)
if event or locales:
for k, field in self.fields.items():
if isinstance(field, I18nFormField):
field.widget.enabled_langcodes = event.settings.get('locales') if event else locales
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):
class SettingsForm(i18nfield.forms.I18nForm):
"""
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
@@ -92,6 +58,7 @@ class SettingsForm(forms.Form):
:param obj: The event or organizer object which should be used for the settings storage
"""
BOOL_CHOICES = (
('False', _('disabled')),
('True', _('enabled')),
@@ -100,12 +67,9 @@ class SettingsForm(forms.Form):
def __init__(self, *args, **kwargs):
self.obj = kwargs.pop('obj', None)
self.locales = kwargs.pop('locales', None)
kwargs['locales'] = self.obj.settings.get('locales') if self.obj else self.locales
kwargs['initial'] = self.obj.settings.freeze()
super().__init__(*args, **kwargs)
if self.obj or self.locales:
for k, field in self.fields.items():
if isinstance(field, I18nFormField):
field.widget.enabled_langcodes = self.obj.settings.get('locales') if self.obj else self.locales
def save(self):
"""

View File

@@ -104,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)')),

View File

@@ -1,346 +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) and l != lng]
if self.data.get(lng):
return self.data[lng]
elif self.data.get(firstpart):
return self.data[firstpart]
elif similar and any([self.data.get(s) for s in similar]):
for s in similar:
if self.data.get(s):
return self.data.get(s)
elif self.data.get(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
any_enabled_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
if dataline:
any_enabled_filled = True
data.append(dataline)
if value and not isinstance(value.data, dict):
data[first_enabled] = value.data
elif value and not any_enabled_filled:
data[first_enabled] = value.localize(self.enabled_langcodes[0])
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:
@@ -377,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:

View File

@@ -1,6 +1,8 @@
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"
@@ -10,3 +12,7 @@ class Command(BaseCommand):
call_command('compilejsi18n', verbosity=1, interactive=False)
call_command('collectstatic', verbosity=1, interactive=False)
call_command('compress', verbosity=1, interactive=False)
gs = GlobalSettingsObject()
del gs.settings.update_check_last
del gs.settings.update_check_result
del gs.settings.update_check_result_warning

View File

@@ -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"])

View File

@@ -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

View File

@@ -8,11 +8,11 @@ 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.i18n
import pretix.base.models.base
import pretix.base.models.invoices
import pretix.base.models.items
@@ -107,7 +107,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')),
@@ -163,9 +163,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')),
@@ -185,10 +185,10 @@ 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')),
('description', pretix.base.i18n.I18nTextField(blank=True, verbose_name='Category description')),
('description', i18nfield.fields.I18nTextField(blank=True, verbose_name='Category description')),
],
options={
'verbose_name': 'Product category',
@@ -201,7 +201,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')),
@@ -309,7 +309,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')),
@@ -566,7 +566,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(

View File

@@ -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')),

View File

@@ -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(

View File

@@ -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',

View File

@@ -6,10 +6,10 @@ 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.i18n
import pretix.base.models.event
import pretix.base.models.orders
import pretix.base.models.organizer
@@ -214,6 +214,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='event',
name='location',
field=pretix.base.i18n.I18nCharField(blank=True, max_length=200, null=True, verbose_name='Location'),
field=i18nfield.fields.I18nCharField(blank=True, max_length=200, null=True, verbose_name='Location'),
),
]

View 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),
),
]

View 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'),
),
]

View File

@@ -2,10 +2,9 @@
# 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
import pretix.base.i18n
class Migration(migrations.Migration):
@@ -17,6 +16,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='event',
name='location',
field=pretix.base.i18n.I18nCharField(blank=True, max_length=200, null=True, verbose_name='Location'),
field=i18nfield.fields.I18nCharField(blank=True, max_length=200, null=True, verbose_name='Location'),
),
]

View File

@@ -19,3 +19,4 @@ from .orders import (
)
from .organizer import Organizer, OrganizerPermission, OrganizerSetting
from .vouchers import Voucher
from .waitinglist import WaitingListEntry

View File

@@ -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', 'event')
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')

View File

@@ -14,9 +14,9 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
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

View File

@@ -122,6 +122,7 @@ class Invoice(models.Model):
class Meta:
unique_together = ('event', 'invoice_no')
ordering = ('invoice_no',)
class InvoiceLine(models.Model):

View File

@@ -10,8 +10,9 @@ 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
@@ -110,6 +111,8 @@ 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
"""
event = models.ForeignKey(
@@ -140,6 +143,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(
@@ -195,8 +201,16 @@ 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')
)
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.')
)
class Meta:
@@ -217,6 +231,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 +250,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 +272,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
@@ -305,6 +324,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 +343,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 +352,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 +360,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):
@@ -342,9 +371,10 @@ class ItemVariation(models.Model):
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 +564,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 +572,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 +600,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:
@@ -591,6 +630,13 @@ 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

View File

@@ -130,3 +130,6 @@ class LogEntry(models.Model):
@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.")

View File

@@ -7,10 +7,11 @@ from typing import List, Union
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.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
@@ -190,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()
@@ -211,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({
@@ -278,6 +295,7 @@ class Order(LoggedModel):
if self.event.settings.get('payment_term_last'):
if now() > self.event.payment_term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING:
return True
if not self.event.settings.get('payment_term_accept_late'):
@@ -427,6 +445,10 @@ class AbstractPosition(models.Model):
else:
q.answer = ""
@property
def net_price(self):
return self.price - self.tax_value
class OrderPosition(AbstractPosition):
"""
@@ -457,6 +479,7 @@ 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:

View File

@@ -210,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):
"""

View 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
)

View File

@@ -12,9 +12,10 @@ 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
@@ -337,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
@@ -483,6 +482,10 @@ class BasePaymentProvider:
'back to the buyer manually.'))
class PaymentException(Exception):
pass
class FreeOrderProvider(BasePaymentProvider):
@property
@@ -511,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:

View File

@@ -15,26 +15,50 @@ import time
from django.conf import settings
from django.db import transaction
from pretix.base.metrics import (
pretix_task_duration_seconds, pretix_task_runs_total,
)
from pretix.celery_app import app
class ProfiledTask(app.Task):
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):

View File

@@ -1,14 +1,18 @@
from datetime import datetime, timedelta
from collections import Counter, namedtuple
from datetime import timedelta
from decimal import Decimal
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
@@ -29,6 +33,7 @@ error_messages = {
'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."),
'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.'),
@@ -43,205 +48,211 @@ error_messages = {
}
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'))
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)
)
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:
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 _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.prefetch_related('quotas').filter(
id__in=[i for i in item_ids if i and i not in self._items_cache]
)}
)
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]
)}
)
def _add_new_items(event: Event, items: List[dict],
cart_id: str, expiry: datetime, now_dt: datetime) -> Optional[str]:
err = None
def _check_max_cart_size(self):
cartsize = self.positions.count()
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation)])
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,))
# 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}
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'])
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
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'])
item = items_cache[i['item']]
variation = variations_cache[i['variation']] if i['variation'] is not None else None
if not op.item.is_available() or (op.variation and not op.variation.active):
raise CartError(error_messages['unavailable'])
# 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:
return error_messages['voucher_redeemed']
if voucher.valid_until is not None and voucher.valid_until < now_dt:
return error_messages['voucher_expired']
if not voucher.applies_to(item, variation):
return error_messages['voucher_invalid_item']
if op.voucher and not op.voucher.applies_to(op.item, op.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) | Q(cart_id=cart_id))
if isinstance(op, self.AddOperation):
if op.item.max_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 '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()
if v_avail < 1:
return error_messages['voucher_redeemed']
if i['count'] > v_avail:
return error_messages['voucher_redeemed_partial'] % v_avail
except Voucher.DoesNotExist:
return error_messages['voucher_invalid']
# 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 voucher and voucher.quota and voucher.quota.pk not in [q.pk for q in quotas]:
return error_messages['voucher_invalid_item']
if item.require_voucher and voucher is None:
return error_messages['voucher_required']
if item.hide_without_voucher and (voucher is None or voucher.item is None or voucher.item.pk != item.pk):
return error_messages['voucher_required']
if len(quotas) == 0 or not item.is_available() or (variation and not variation.active):
err = err or error_messages['unavailable']
continue
# Check that all quotas allow us to buy i['count'] instances of the object
quota_ok = i['count']
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
for quota in quotas:
avail = quota.availability()
if avail[1] is not None and avail[1] < i['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']
else:
err = err or error_messages['in_part']
quota_ok = min(quota_ok, avail[1])
if 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
}
)
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)
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 'price' in i and i['price'] is not None and i['price'] != "":
custom_price = i['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:
return error_messages['price_too_high']
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)
# Create a CartPosition for as much items as we can
for k in range(quota_ok):
if 'cp' in i and i['count'] == 1:
# Recreating
cp = i['cp']
cp.expires = expiry
cp.price = price
cp.save()
return price
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)
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:
CartPosition.objects.create(
event=event, item=item, variation=variation,
price=price,
expires=expiry,
cart_id=cart_id, voucher=voucher
)
return err
quotas = []
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)
def _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> None:
with event.lock() as now_dt:
_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,))
if cp.voucher:
self._voucher_use_diff[cp.voucher] += 1
expiry = now_dt + timedelta(minutes=event.settings.get('reservation_time', as_type=int))
_extend_existing(event, cart_id, expiry, now_dt)
self._operations.append(op)
expired = _re_add_expired_positions(items, event, cart_id, now_dt)
if items:
err = _add_new_items(event, items, cart_id, expiry, now_dt)
_delete_expired(expired, now_dt)
if err:
raise CartError(err)
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 = []
@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) -> 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 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:
try:
_add_items_to_cart(event, items, cart_id)
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)
# 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'])
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:
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
)
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_items(self, items: List[dict]):
# 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 i in items:
cw = Q(cart_id=self.cart_id) & Q(item_id=i['item']) & Q(event=self.event)
if i['variation']:
cw &= Q(variation_id=i['variation'])
else:
@@ -252,26 +263,154 @@ def _remove_items_from_cart(event: Event, items: List[dict], cart_id: str) -> No
if i['price']:
correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(i['price'].replace(",", ".")))[:cnt]
for cp in correctprice:
cp.delete()
self._operations.append(self.RemoveOperation(position=cp))
cnt -= len(correctprice)
if cnt > 0:
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
cp.delete()
self._operations.append(self.RemoveOperation(position=cp))
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
def _get_voucher_availability(self):
vouchers_ok = {}
for voucher, count in self._voucher_use_diff.items():
voucher.refresh_from_db()
if voucher.valid_until is not None and voucher.valid_until < self.now_dt:
raise CartError(error_messages['voucher_expired'])
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
return vouchers_ok
def _perform_operations(self):
vouchers_ok = self._get_voucher_availability()
quotas_ok = self._get_quota_availability()
err = None
new_cart_positions = []
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
))
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)
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None) -> None:
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 session: Session ID of a guest
:param coupon: A coupon that should also be reeemed
:raises CartError: On any error that occured
"""
with language(locale):
event = Event.objects.get(id=event)
try:
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'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_items_from_cart(self, event: int, items: 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 items: A list of tuple of the form (item id, variation id or None, number)
: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_items(items)
cm.commit()
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy'])

View File

@@ -10,6 +10,7 @@ from django.db import transaction
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
@@ -21,7 +22,7 @@ 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
@@ -182,19 +183,23 @@ 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())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.textLines(invoice.invoice_to.strip())
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 - 50) * mm)
textobject.setFont('OpenSansBd', 8)
if invoice.is_cancellation:
@@ -257,20 +262,28 @@ def _invoice_generate_german(invoice, f):
canvas.drawImage(ImageReader(logo_file),
95 * mm, (297 - 38) * mm,
width=25 * mm, height=25 * mm,
preserveAspectRatio=True, anchor='n')
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()

View File

@@ -8,9 +8,10 @@ from django.conf import settings
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.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

View File

@@ -45,6 +45,8 @@ error_messages = {
'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.'),
@@ -206,8 +208,10 @@ def _check_date(event: Event, now_dt: datetime):
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition]):
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']
@@ -215,6 +219,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
continue
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)
@@ -286,7 +298,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
else:
cp.delete() # Sorry!
if err:
raise OrderError(err)
raise OrderError(err, errargs)
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
@@ -378,37 +390,36 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
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
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 = ""
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
)
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
@@ -480,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'))
@@ -498,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
@@ -528,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:
@@ -624,9 +640,10 @@ class OrderChangeManager:
return
with transaction.atomic():
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()
@@ -653,13 +670,14 @@ class OrderChangeManager:
@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, throws=(OrderError,))

View 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

View File

@@ -0,0 +1,60 @@
from django.dispatch import receiver
from pretix.base.models import Event, User, WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException
from pretix.base.services.async import ProfiledTask
from pretix.base.signals import periodic_task
from pretix.celery_app import app
@app.task(base=ProfiledTask)
def assign_automatically(event_id: int, user_id: int=None):
event = Event.objects.get(id=event_id)
if user_id:
user = User.objects.get(id=user_id)
else:
user = None
quota_cache = {}
gone = set()
qs = WaitingListEntry.objects.filter(
event=event, voucher__isnull=True
).select_related('item', 'variation').prefetch_related('item__quotas', 'variation__quotas').order_by('created')
sent = 0
for wle in qs:
if (wle.item, wle.variation) in gone:
continue
quotas = wle.variation.quotas.all() if wle.variation else wle.item.quotas.all()
availability = (
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
if wle.variation
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
)
if availability[1] > 0:
try:
wle.send_voucher(quota_cache, user=user)
sent += 1
except WaitingListException: # noqa
continue
# Reduce affected quotas in cache
for q in quotas:
quota_cache[q.pk] = (
quota_cache[q.pk][0] if quota_cache[q.pk][0] > 1 else 0,
quota_cache[q.pk][1] - 1
)
else:
gone.add((wle.item, wle.variation))
return sent
@receiver(signal=periodic_task)
def process_waitinglist(sender, **kwargs):
qs = Event.objects.prefetch_related('setting_objects', 'organizer__setting_objects').select_related('organizer')
for e in qs:
if e.settings.waiting_list_enabled and e.settings.waiting_list_auto:
assign_automatically.apply_async(args=(e.pk,))

View File

@@ -12,7 +12,7 @@ from django.core.files.storage import default_storage
from django.db.models import Model
from django.utils.translation import ugettext_noop
from pretix.base.i18n import LazyI18nString
from i18nfield.strings import LazyI18nString
from pretix.base.models.settings import GlobalSetting
DEFAULTS = {
@@ -20,6 +20,10 @@ DEFAULTS = {
'default': '10',
'type': int
},
'display_net_prices': {
'default': 'False',
'type': bool
},
'attendee_names_asked': {
'default': 'True',
'type': bool
@@ -132,6 +136,18 @@ DEFAULTS = {
'default': 'False',
'type': bool
},
'waiting_list_enabled': {
'default': 'False',
'type': bool
},
'waiting_list_auto': {
'default': 'True',
'type': bool
},
'waiting_list_hours': {
'default': '48',
'type': int
},
'ticket_download': {
'default': 'False',
'type': bool
@@ -258,6 +274,29 @@ your payment before {expire_date}.
You can view the payment information and the status of your order at
{url}
Best regards,
Your {event} team"""))
},
'mail_text_waiting_list': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
you submitted yourself to the waiting list for {event},
for the product {product}.
We now have a ticket ready for you! You can redeem it in our ticket shop
within the next {hours} hours by entering the following voucher code:
{code}
Alternatively, you can just click on the following link:
{url}
Please note that this link is only valid within the next {hours} hours!
We will reassign the ticket to the next person on the list if you do not
redeem the voucher within that timeframe.
Best regards,
Your {event} team"""))
},
@@ -312,6 +351,34 @@ Your {event} team"""))
'frontpage_text': {
'default': '',
'type': LazyI18nString
},
'update_check_ack': {
'default': 'False',
'type': bool
},
'update_check_email': {
'default': '',
'type': str
},
'update_check_perform': {
'default': 'True',
'type': bool
},
'update_check_result': {
'default': None,
'type': dict
},
'update_check_result_warning': {
'default': 'False',
'type': bool
},
'update_check_last': {
'default': None,
'type': datetime
},
'update_check_id': {
'default': None,
'type': str
}
}

View File

@@ -20,7 +20,7 @@
.header h1 {
margin-top: 20px;
margin-bottom: 5px;
margin-bottom: 20px;
}
.header h1 a {
@@ -117,7 +117,7 @@
<tr>
<td class="header" background="">
{% if event %}
<h1><a href="{% eventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
<h1><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
{% else %}
<h1><a href="{{ site_url }}" target="_blank">{{ site }}</a></h1>
{% endif %}
@@ -141,7 +141,7 @@
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% eventurl event "presale:event.order" order=order.code secret=order.secret %}">
<a href="{% abseventurl event "presale:event.order" order=order.code secret=order.secret %}">
{% trans "View order details" %}
</a>
</div>

View File

@@ -0,0 +1,54 @@
import bleach
import markdown
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
ALLOWED_TAGS = [
'a',
'abbr',
'acronym',
'b',
'blockquote',
'code',
'em',
'i',
'li',
'ol',
'strong',
'ul',
'p',
'table',
'tbody',
'thead',
'tr',
'td',
'th',
'div',
'span'
]
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'],
'abbr': ['title'],
'acronym': ['title'],
'table': ['width'],
'td': ['width', 'align'],
'div': ['class'],
'p': ['class'],
'span': ['class'],
}
@register.filter
def rich_text(text: str, **kwargs):
"""
Processes markdown and cleans HTML in a text input.
"""
body_md = bleach.linkify(bleach.clean(
markdown.markdown(text),
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
))
return mark_safe(body_md)

View File

@@ -1,3 +1,5 @@
from django.conf import settings
from django.core import cache
from django.http import HttpResponse
from ..models import User
@@ -6,4 +8,18 @@ from ..models import User
def healthcheck(request):
# Perform a simple DB query to see that DB access works
User.objects.exists()
# Test if redis access works
if settings.HAS_REDIS:
import django_redis
redis = django_redis.get_redis_connection("redis")
redis.set("_healthcheck", 1)
if not redis.exists("_healthcheck"):
return HttpResponse("Redis not available.", status=503)
cache.cache.set("_healthcheck", "1")
if not cache.cache.get("_healthcheck") == "1":
return HttpResponse("Cache not available.", status=503)
return HttpResponse()

View File

@@ -1,3 +1,4 @@
import base64
import hmac
from django.conf import settings
@@ -26,7 +27,7 @@ def serve_metrics(request):
if method.lower() != "basic":
return unauthed_response()
user, passphrase = credentials.strip().decode("base64").split(":", 1)
user, passphrase = base64.b64decode(credentials.strip()).decode().split(":", 1)
if not hmac.compare_digest(user, settings.METRICS_USER):
return unauthed_response()
@@ -37,9 +38,10 @@ def serve_metrics(request):
m = metrics.metric_values()
output = []
for metric, value in m:
output.append("{} {}".format(metric, str(value)))
for metric, sub in m.items():
for label, value in sub.items():
output.append("{}{} {}".format(metric, label, str(value)))
content = "\n".join(output)
content = "\n".join(output) + "\n"
return HttpResponse(content)

View File

@@ -1,6 +1,10 @@
import sys
from django.conf import settings
from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
from pretix.base.settings import GlobalSettingsObject
from .signals import html_head, nav_event, nav_topbar
from .utils.i18n import get_javascript_format, get_moment_locale
@@ -46,4 +50,18 @@ def contextprocessor(request):
ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS')
ctx['js_locale'] = get_moment_locale()
if settings.DEBUG and 'runserver' not in sys.argv:
ctx['debug_warning'] = True
elif 'runserver' in sys.argv:
ctx['development_warning'] = True
ctx['warning_update_available'] = False
ctx['warning_update_check_active'] = False
if request.user.is_superuser:
gs = GlobalSettingsObject()
if gs.settings.update_check_result_warning:
ctx['warning_update_available'] = True
if not gs.settings.update_check_ack and 'runserver' not in sys.argv:
ctx['warning_update_check_active'] = True
return ctx

View File

@@ -4,10 +4,10 @@ from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.utils.timezone import get_current_timezone_name
from django.utils.translation import ugettext_lazy as _
from pytz import common_timezones
from i18nfield.forms import I18nFormField, I18nTextarea
from pytz import common_timezones, timezone
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.i18n import I18nFormField, I18nTextarea
from pretix.base.models import Event, Organizer
from pretix.control.forms import ExtFileField
@@ -76,12 +76,27 @@ class EventWizardBasicsForm(I18nModelForm):
def clean(self):
data = super().clean()
if data['locale'] not in self.locales:
if data.get('locale') not in self.locales:
raise ValidationError({
'locale': _('Your default locale must also be enabled for your event (see box above).')
})
if data.get('timezone') not in common_timezones:
raise ValidationError({
'timezone': _('Your default locale must be specified.')
})
# change timezone
zone = timezone(data.get('timezone'))
data['date_from'] = self.reset_timezone(zone, data.get('date_from'))
data['date_to'] = self.reset_timezone(zone, data.get('date_to'))
data['presale_start'] = self.reset_timezone(zone, data.get('presale_start'))
data['presale_end'] = self.reset_timezone(zone, data.get('presale_end'))
return data
@staticmethod
def reset_timezone(tz, dt):
return tz.localize(dt.replace(tzinfo=None)) if dt is not None else None
def clean_slug(self):
slug = self.cleaned_data['slug']
if Event.objects.filter(slug=slug, organizer=self.organizer).exists():
@@ -158,6 +173,12 @@ class EventSettingsForm(SettingsForm):
help_text=_("Show item details before presale has started and after presale has ended"),
required=False
)
display_net_prices = forms.BooleanField(
label=_("Show net prices instead of gross prices in the product list (not recommended!)"),
help_text=_("Independent of your choice, the cart will show gross prices as this the price that needs to be "
"paid"),
required=False
)
presale_start_show_date = forms.BooleanField(
label=_("Show start date"),
help_text=_("Show the presale start date before presale has started."),
@@ -176,7 +197,8 @@ class EventSettingsForm(SettingsForm):
)
locales = forms.MultipleChoiceField(
choices=settings.LANGUAGES,
label=_("Available langauges"),
widget=forms.CheckboxSelectMultiple,
label=_("Available languages"),
)
locale = forms.ChoiceField(
choices=settings.LANGUAGES,
@@ -187,6 +209,27 @@ class EventSettingsForm(SettingsForm):
help_text=_("Publicly show how many tickets of a certain type are still available."),
required=False
)
waiting_list_enabled = forms.BooleanField(
label=_("Enable waiting list"),
help_text=_("Once a ticket is sold out, people can add themselves to a waiting list. As soon as a ticket "
"becomes available again, it will be reserved for the first person on the waiting list and this "
"person will receive an email notification with a voucher that can be used to buy a ticket."),
required=False
)
waiting_list_hours = forms.IntegerField(
label=_("Waiting list response time"),
min_value=6,
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
"number of hours until it expires and can be re-assigned to the next person on the list."),
required=False
)
waiting_list_auto = forms.BooleanField(
label=_("Automatic waiting list assignments"),
help_text=_("If ticket capacity becomes free, automatically create a voucher and send it to the first person "
"on the waiting list for that product. If this is not active, mails will not be send automatically "
"but you can send them manually via the control panel."),
required=False
)
attendee_names_asked = forms.BooleanField(
label=_("Ask for attendee names"),
help_text=_("Ask for a name for all tickets which include admission to the event."),
@@ -217,7 +260,7 @@ class EventSettingsForm(SettingsForm):
)
cancel_allow_user = forms.BooleanField(
label=_("Allow user to cancel unpaid orders"),
help_text=_("If unchecked, users cannot cancel orders by themselves"),
help_text=_("If checked, users can cancel orders by themselves as long as they are not yet paid."),
required=False
)
@@ -273,6 +316,17 @@ class PaymentSettingsForm(SettingsForm):
"(in percent)."),
)
def clean(self):
cleaned_data = super().clean()
payment_term_last = cleaned_data.get('payment_term_last')
if payment_term_last and self.obj.presale_end:
if payment_term_last < self.obj.presale_end.date():
self.add_error(
'payment_term_last',
_('The last payment date cannot be before the end of presale.'),
)
return cleaned_data
class ProviderForm(SettingsForm):
"""
@@ -433,6 +487,12 @@ class MailSettingsForm(SettingsForm):
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}")
)
mail_text_waiting_list = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}")
)
smtp_use_custom = forms.BooleanField(
label=_("Use custom SMTP server"),
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),

View File

@@ -1,9 +1,10 @@
from collections import OrderedDict
from django import forms
from django.utils.translation import ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextInput
from pretix.base.forms import SettingsForm
from pretix.base.i18n import I18nFormField, I18nTextInput
from pretix.base.settings import GlobalSettingsObject
from pretix.base.signals import register_global_settings
@@ -32,3 +33,26 @@ class GlobalSettingsForm(SettingsForm):
for key, value in response.items():
# We need to be this explicit, since OrderedDict.update does not retain ordering
self.fields[key] = value
class UpdateSettingsForm(SettingsForm):
update_check_perform = forms.BooleanField(
required=False,
label=_("Perform update checks"),
help_text=_("During the update check, pretix will report an anonymous, unique installation ID, "
"the current version of pretix and your installed plugins and the number of active and "
"inactive events in your installation to servers operated by the pretix developers. We "
"will only store anonymous data, never any IP adresses and we will not know who you are "
"or where to find your instance. You can disable this behaviour here at any time.")
)
update_check_email = forms.EmailField(
required=False,
label=_("E-mail notifications"),
help_text=_("We will notify you at this address if we detect that a new update is available. This "
"address will not be transmitted to pretix.eu, the emails will be sent by this server "
"locally.")
)
def __init__(self, *args, **kwargs):
self.obj = GlobalSettingsObject()
super().__init__(*args, obj=self.obj, **kwargs)

View File

@@ -5,9 +5,9 @@ from django.core.exceptions import ValidationError
from django.forms import BooleanField, ModelMultipleChoiceField
from django.forms.formsets import DELETION_FIELD_NAME
from django.utils.translation import ugettext as __, ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.forms import I18nFormSet, I18nModelForm
from pretix.base.i18n import I18nFormField, I18nTextarea
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
)
@@ -105,12 +105,44 @@ class ItemCreateForm(I18nModelForm):
'You can select the variations in the next step.'),
required=False)
def __init__(self, *args, **kwargs):
self.event = kwargs['event']
super().__init__(*args, **kwargs)
self.fields['copy_from'] = forms.ModelChoiceField(
label=_("Copy product information"),
queryset=self.event.items.all(),
widget=forms.Select,
empty_label=_('Do not copy'),
required=False
)
def save(self, *args, **kwargs):
if self.cleaned_data.get('copy_from'):
self.instance.category = self.cleaned_data['copy_from'].category
self.instance.description = self.cleaned_data['copy_from'].description
self.instance.active = self.cleaned_data['copy_from'].active
self.instance.available_from = self.cleaned_data['copy_from'].available_from
self.instance.available_until = self.cleaned_data['copy_from'].available_until
self.instance.require_voucher = self.cleaned_data['copy_from'].require_voucher
self.instance.hide_without_voucher = self.cleaned_data['copy_from'].hide_without_voucher
self.instance.allow_cancel = self.cleaned_data['copy_from'].allow_cancel
instance = super().save(*args, **kwargs)
if self.cleaned_data.get('has_variations'):
ItemVariation.objects.create(
item=instance, value=__('Standard')
)
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
for variation in self.cleaned_data['copy_from'].variations.all():
ItemVariation.objects.create(item=instance, value=variation.value, active=variation.active,
position=variation.position, default_price=variation.default_price)
else:
ItemVariation.objects.create(
item=instance, value=__('Standard')
)
if self.cleaned_data.get('copy_from'):
for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance)
return instance
class Meta:
@@ -147,7 +179,8 @@ class ItemUpdateForm(I18nModelForm):
'available_until',
'require_voucher',
'hide_without_voucher',
'allow_cancel'
'allow_cancel',
'max_per_order'
]
widgets = {
'available_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),

View File

@@ -1,6 +1,7 @@
from django import forms
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.formats import localize
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
@@ -58,7 +59,7 @@ class OrderPositionChangeForm(forms.Form):
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
label=_('New price')
label=_('New price (gross)')
)
operation = forms.ChoiceField(
required=False,
@@ -88,15 +89,18 @@ class OrderPositionChangeForm(forms.Form):
super().__init__(*args, **kwargs)
choices = []
for i in instance.order.event.items.prefetch_related('variations').all():
pname = i.name
pname = str(i.name)
if not i.is_available():
pname += ' ({})'.format(_('inactive'))
variations = list(i.variations.all())
if variations:
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (pname, v.value)))
choices.append(('%d-%d' % (i.pk, v.pk),
'%s %s (%s %s)' % (pname, v.value, localize(v.price),
instance.order.event.currency)))
else:
choices.append((str(i.pk), pname))
choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(i.default_price),
instance.order.event.currency)))
self.fields['itemvar'].choices = choices
def clean(self):

View File

@@ -4,8 +4,8 @@ from decimal import Decimal
from django.dispatch import receiver
from django.utils import formats
from django.utils.translation import ugettext_lazy as _
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import LazyI18nString
from pretix.base.models import Event, ItemVariation, LogEntry
from pretix.base.signals import logentry_display
@@ -85,6 +85,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'),
'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
'pretix.voucher.added': _('The voucher has been created.'),
'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'),
'pretix.voucher.changed': _('The voucher has been modified.'),
'pretix.voucher.deleted': _('The voucher has been deleted.'),
'pretix.voucher.redeemed': _('The voucher has been redeemed in order {order_code}.'),
@@ -117,6 +118,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.')
}
data = json.loads(logentry.data)

View File

@@ -32,13 +32,15 @@
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asyncdownload.js" %}"></script>
{% endcompress %}
{{ html_head|safe }}
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
</head>
<body data-datetimeformat="{{ js_datetime_format }}" data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}" data-payment-weekdays-disabled="{{ js_payment_weekdays_disabled }}">
<div id="#wrapper">
<div id="wrapper">
<nav class="navbar navbar-inverse navbar-static-top" role="navigation">
<div class="navbar-header">
<button type="button" class="navbar-toggle"
@@ -103,6 +105,13 @@
</li>
{% endfor %}
{% if warning_update_available %}
<li>
<a href="{% url 'control:global.update' %}" class="danger">
<i class="fa fa-bell"></i>
</a>
</li>
{% endif %}
<li>
<a href="{% url 'control:user.settings' %}">
<i class="fa fa-user"></i> {{ request.user.get_full_name }}
@@ -139,7 +148,8 @@
</li>
{% if request.user.is_superuser %}
<li>
<a href="{% url 'control:global-settings' %}" {% if "global-settings" in url_name %}class="active"{% endif %}>
<a href="{% url 'control:global.settings' %}"
{% if "global.settings" in url_name %}class="active"{% endif %}>
<i class="fa fa-wrench fa-fw"></i>
{% trans "Global settings" %}
</a>
@@ -171,6 +181,25 @@
</div>
{% endfor %}
{% endif %}
{% if warning_update_check_active %}
<div class="alert alert-info">
<a href="{% url "control:global.update" %}">
{% blocktrans trimmed %}
Starting with version 1.2.0, pretix automatically checks for updates in the background.
During this check, anonymous data is transmitted to servers operated by pretix'
developers. Click on this message to find out more, disable this feature or enter your
email address to get notified via email if a new update arrives. This message will
disappear once you clicked it.
{% endblocktrans %}
</a>
</div>
{% endif %}
{% if debug_warning %}
<div class="alert alert-danger">
{% trans "pretix is running in debug mode. For security reasons, please never run debug mode on a production instance." %}
</div>
{% endif %}
{% block content %}
{% endblock %}
@@ -180,6 +209,9 @@
powered by <a {{ a_attr }}>pretix</a>
{% endblocktrans %}
{% endwith %}
{% if development_warning %}
<span class="text-warning">&middot; {% trans "running in development mode" %}</span>
{% endif %}
</footer>
</div>
</div>

View File

@@ -84,6 +84,12 @@
{% trans "Export" %}
</a>
</li>
<li>
<a href="{% url 'control:event.orders.waitinglist' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.orders.waitinglist" %}class="active"{% endif %}>
{% trans "Waiting list" %}
</a>
</li>
</ul>
</li>
{% endif %}

View File

@@ -1,6 +1,7 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load staticfiles %}
{% block title %}{{ request.event.name }}{% endblock %}
{% block content %}
<form action="{% eventurl request.event "presale:event.auth" %}" method="post" target="_blank">
@@ -85,6 +86,11 @@
<div class="col-lg-2 col-sm-6 col-xs-12">
{% if log.user %}
<span class="fa fa-user"></span> {{ log.user.get_full_name }}
{% if log.user.is_superuser %}
<img src="{% static "pretixbase/img/pretix-icon-colored.svg" %}"
data-toggle="tooltip" class="user-admin-icon"
title="{% trans "This change was performed by a pretix administrator." %}">
{% endif %}
{% endif %}
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">

View File

@@ -1,5 +1,6 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load staticfiles %}
{% block title %}{% trans "Event logs" %}{% endblock %}
{% block inside %}
<h1>{% trans "Event logs" %}</h1>
@@ -32,6 +33,11 @@
<div class="col-lg-2 col-sm-6 col-xs-12">
{% if log.user %}
<span class="fa fa-user"></span> {{ log.user.get_full_name }}
{% if log.user.is_superuser %}
<img src="{% static "pretixbase/img/pretix-icon-colored.svg" %}"
data-toggle="tooltip" class="user-admin-icon"
title="{% trans "This change was performed by a pretix administrator." %}">
{% endif %}
{% endif %}
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">

View File

@@ -99,6 +99,20 @@
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#waiting_list">
<strong>{% trans "Waiting list notification" %}</strong>
</a>
</h4>
</div>
<div id="waiting_list" class="panel-collapse collapse">
<div class="panel-body">
{% bootstrap_field form.mail_text_waiting_list layout="horizontal" %}
</div>
</div>
</div>
</div>
</fieldset>
<fieldset>

View File

@@ -72,8 +72,8 @@
<td>{{ add_form.can_view_orders }}</td>
<td>{{ add_form.can_change_orders }}</td>
<td>{{ add_form.can_change_permissions }}</td>
<td>{{ add_form.can_change_vouchers }}</td>
<td>{{ add_form.can_view_vouchers }}</td>
<td>{{ add_form.can_change_vouchers }}</td>
</tr>
</tfoot>
</table>

View File

@@ -25,6 +25,7 @@
{% bootstrap_field sform.contact_mail layout="horizontal" %}
{% bootstrap_field sform.imprint_url layout="horizontal" %}
{% bootstrap_field sform.show_quota_left layout="horizontal" %}
{% bootstrap_field sform.display_net_prices layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Timeline" %}</legend>
@@ -42,6 +43,12 @@
{% bootstrap_field sform.attendee_names_required layout="horizontal" %}
{% bootstrap_field sform.cancel_allow_user layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Waiting list" %}</legend>
{% bootstrap_field sform.waiting_list_enabled layout="horizontal" %}
{% bootstrap_field sform.waiting_list_auto layout="horizontal" %}
{% bootstrap_field sform.waiting_list_hours layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}

View File

@@ -1,10 +1,8 @@
{% extends "pretixcontrol/base.html" %}
{% extends "pretixcontrol/global_settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Global settings" %}{% endblock %}
{% block content %}
<h1>{% trans "Global settings" %}</h1>
{% block inner %}
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}

View File

@@ -0,0 +1,22 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Global settings" %}{% endblock %}
{% block content %}
<h1>{% trans "Global settings" %}</h1>
<ul class="nav nav-pills">
<li {% if "global.settings" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:global.settings' %}">
{% trans "General" %}
</a>
</li>
<li {% if "global.update" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:global.update' %}">
{% trans "Update check" %}
</a>
</li>
</ul>
{% block inner %}
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% extends "pretixcontrol/global_settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<fieldset>
<legend>{% trans "Update check results" %}</legend>
{% if not gs.settings.update_check_perform %}
<div class="alert alert-warning">
{% trans "Update checks are disabled." %}
</div>
{% elif not gs.settings.update_check_last %}
<div class="alert alert-info">
{% trans "No update check has been performed yet since the last update of this installation. Update checks are performed on a daily basis if your cronjob is set up properly." %}
</div>
<form action="" method="post">
{% csrf_token %}
<p>
<button type="submit" name="trigger" value="1" class="btn btn-default">
{% trans "Check for updates now" %}
</button>
</p>
</form>
{% elif "error" in gs.settings.update_check_result %}
<div class="alert alert-danger">
{% trans "The last update check was not successful." %}
{% if gs.settings.update_check_result.error == "http_error" %}
{% trans "The pretix.eu server returned an error code." %}
{% elif gs.settings.update_check_result.error == "unavailable" %}
{% trans "The pretix.eu server could not be reached." %}
{% elif gs.settings.update_check_result.error == "development" %}
{% trans "This installation appears to be a development installation." %}
{% endif %}
</div>
<form action="" method="post">
{% csrf_token %}
<p>
<button type="submit" name="trigger" value="1" class="btn btn-default">
{% trans "Check for updates now" %}
</button>
</p>
</form>
{% else %}
<form action="" method="post">
{% csrf_token %}
<p>
{% blocktrans trimmed with date=gs.settings.update_check_last|date:"SHORT_DATETIME_FORMAT" %}
Last updated: {{ date }}
{% endblocktrans %}
<button type="submit" name="trigger" value="1" class="btn btn-default btn-xs">
{% trans "Check for updates now" %}
</button>
</p>
</form>
<div class="table-responsive">
<table class="table table-condensed">
<thead>
<tr>
<th>{% trans "Component" %}</th>
<th>{% trans "Installed version" %}</th>
<th>{% trans "Latest version" %}</th>
</tr>
</thead>
<tbody>
{% for row in tbl %}
<tr class="{% if row.3 %}danger{% elif row.2 == "?" %}warning{% else %}success{% endif %}">
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
<td>{{ row.2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</fieldset>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
<fieldset>
<legend>{% trans "Update check settings" %}</legend>
{% bootstrap_form_errors form %}
{% bootstrap_form form layout='horizontal' %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -1,3 +1,5 @@
{% load staticfiles %}
{% load i18n %}
<ul class="list-group">
{% for log in obj.all_logentries %}
<li class="list-group-item logentry">
@@ -5,6 +7,11 @@
<span class="fa fa-clock-o"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if log.user %}
<br/><span class="fa fa-user"></span> {{ log.user.get_full_name }}
{% if log.user.is_superuser %}
<img src="{% static "pretixbase/img/pretix-icon-colored.svg" %}"
data-toggle="tooltip" class="user-admin-icon"
title="{% trans "This change was performed by a pretix administrator." %}">
{% endif %}
{% endif %}
</p>

View File

@@ -7,6 +7,7 @@
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.copy_from layout="horizontal" %}
{% bootstrap_field form.has_variations layout="horizontal" %}
{% bootstrap_field form.admission layout="horizontal" %}
</fieldset>

View File

@@ -25,6 +25,7 @@
<legend>{% trans "Availability" %}</legend>
{% bootstrap_field form.available_from layout="horizontal" %}
{% bootstrap_field form.available_until layout="horizontal" %}
{% bootstrap_field form.max_per_order layout="horizontal" %}
{% bootstrap_field form.require_voucher layout="horizontal" %}
{% bootstrap_field form.hide_without_voucher layout="horizontal" %}
{% bootstrap_field form.allow_cancel layout="horizontal" %}

View File

@@ -29,8 +29,8 @@
<thead>
<tr>
<th>{% trans "Product categories" %}</th>
<th></th>
<th></th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
@@ -44,6 +44,7 @@
<a href="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
</td>
<td class="text-right">
<a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>

View File

@@ -32,8 +32,8 @@
<tr>
<th>{% trans "Product name" %}</th>
<th>{% trans "Category" %}</th>
<th></th>
<th></th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
@@ -53,6 +53,7 @@
<a href="{% url "control:event.items.down" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
</td>
<td class="text-right">
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.delete" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>

View File

@@ -31,7 +31,8 @@
<tr>
<th>{% trans "Question" %}</th>
<th>{% trans "Type" %}</th>
<th></th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>

View File

@@ -36,7 +36,7 @@
<th>{% trans "Products" %}</th>
<th>{% trans "Total capacity" %}</th>
<th>{% trans "Capacity left" %}</th>
<th></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>

View File

@@ -79,6 +79,9 @@
{% if position.form.operation.value == "price" %}checked="checked"{% endif %}>
{% trans "Change price to" %}
{% bootstrap_field position.form.price layout='inline' %}
{% if request.event.settings.display_net_prices %}
<em>{% trans "Enter a gross price including taxes." %}</em>
{% endif %}
</label>
</div>
<div class="radio">

View File

@@ -144,7 +144,7 @@
<div class="panel panel-default items">
<div class="panel-heading">
<div class="pull-right">
{% if order.status == "n" and request.eventperm.can_change_orders %}
{% if order.changable and request.eventperm.can_change_orders %}
<a href="{% url "control:event.order.change" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
<span class="fa fa-edit"></span>
{% trans "Change products" %}
@@ -189,12 +189,20 @@
{% endif %}
</div>
<div class="col-md-3 col-xs-6 price">
<strong>{{ event.currency }} {{ line.price|floatformat:2 }}</strong>
{% if line.tax_rate %}
<br/>
<small>{% blocktrans trimmed with rate=line.tax_rate %}
{% if event.settings.display_net_prices %}
<strong>{{ event.currency }} {{ line.net_price|floatformat:2 }}</strong>
{% if line.tax_rate %}
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
<strong>plus</strong> {{ rate }}% taxes
{% endblocktrans %}</small>
{% endif %}
{% else %}
<strong>{{ event.currency }} {{ line.price|floatformat:2 }}</strong>
{% if line.tax_rate %}
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
{% endif %}
{% endif %}
</div>
<div class="clearfix"></div>
@@ -206,17 +214,47 @@
<strong>{% trans "Payment method fee" %}</strong>
</div>
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
<strong>{{ event.currency }} {{ items.payment_fee|floatformat:2 }}</strong>
{% if order.payment_fee_tax_rate %}
<br/>
<small>{% blocktrans trimmed with rate=order.payment_fee_tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
{% if event.settings.display_net_prices %}
<strong>{{ event.currency }} {{ order.payment_fee_net|floatformat:2 }}</strong>
{% if order.payment_fee_tax_rate %}
<br/>
<small>{% blocktrans trimmed with rate=order.payment_fee_tax_rate %}
<strong>plus</strong> {{ rate }}% taxes
{% endblocktrans %}</small>
{% endif %}
{% else %}
<strong>{{ event.currency }} {{ items.payment_fee|floatformat:2 }}</strong>
{% if order.payment_fee_tax_rate %}
<br/>
<small>{% blocktrans trimmed with rate=order.payment_fee_tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
{% endif %}
{% endif %}
</div>
<div class="clearfix"></div>
</div>
{% endif %}
{% if event.settings.display_net_prices %}
<div class="row-fluid product-row total">
<div class="col-md-4 col-xs-6">
<strong>{% trans "Net total" %}</strong>
</div>
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
{{ event.currency }} {{ items.net_total|floatformat:2 }}
</div>
<div class="clearfix"></div>
</div>
<div class="row-fluid product-row">
<div class="col-md-4 col-xs-6">
<strong>{% trans "Taxes" %}</strong>
</div>
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
{{ event.currency }} {{ items.tax_total|floatformat:2 }}
</div>
<div class="clearfix"></div>
</div>
{% endif %}
<div class="row-fluid product-row total">
<div class="col-md-4 col-xs-6">
<strong>{% trans "Total" %}</strong>

View File

@@ -29,7 +29,7 @@
<form class="form-inline helper-display-inline"
action="{% url "control:event.orders.go" event=request.event.slug organizer=request.event.organizer.slug %}">
<div class="input-group">
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}">
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}" autofocus>
<span class="input-group-btn">
<button class="btn btn-primary" type="submit">{% trans "Go!" %}</button>
</span>

View File

@@ -47,7 +47,7 @@
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Enabled devices" %}</h3>
<h3 class="panel-title">{% trans "Registered devices" %}</h3>
</div>
<ul class="list-group">
{% for d in devices %}

View File

@@ -11,7 +11,7 @@
<form class="form-inline helper-display-inline" action="" method="get">
<p>
<input type="text" name="search" class="form-control" placeholder="{% trans "Search voucher" %}"
value="{{ request.GET.search }}">
value="{{ request.GET.search }}" autofocus>
<input type="text" name="tag" class="form-control" placeholder="{% trans "Filter by tag" %}"
value="{{ request.GET.tag }}">
<select name="status" class="form-control">

View File

@@ -0,0 +1,145 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% block title %}{% trans "Waiting list" %}{% endblock %}
{% block content %}
<h1>{% trans "Waiting list" %}</h1>
{% if not request.event.settings.waiting_list_enabled %}
<div class="alert alert-danger">
{% trans "The waiting list is disabled, so if the event is sold out, people cannot add themselves to this list. If you want to enable it, go to the event settings." %}
</div>
{% endif %}
<div class="row">
{% if request.eventperm.can_change_orders %}
<form method="post" class="col-md-6"
action="{% url "control:event.orders.waitinglist.auto" event=request.event.slug organizer=request.organizer.slug %}"
data-asynctask>
<div class="panel panel-default">
<div class="panel-heading">
{% trans "Send vouchers" %}
</div>
<div class="panel-body">
{% csrf_token %}
{% if request.event.settings.waiting_list_auto %}
<p>
{% blocktrans trimmed %}
You have configured that vouchers will automatically be sent to the persons on this list who waited
the longest as soon as capacity becomes available. It might take up to half an hour for the
vouchers to be sent after the capacity is available, so don't worry if entries do not disappear
here immediately. If you want, you can also send them out manually right now.
{% endblocktrans %}
</p>
{% else %}
<p>
{% blocktrans trimmed %}
You have configured that vouchers will <strong>not</strong> be sent automatically.
You can either send them one-by-one in an order of your choice by clicking the
buttons next to a line in this table (if sufficient quota is available) or you can
press the big button below this text to send out as many vouchers as currently
possible to the persons who waitet longest.
{% endblocktrans %}
</p>
{% endif %}
<button class="btn btn-large btn-primary" type="submit">
{% trans "Send as many vouchers as possible" %}
</button>
</div>
</div>
</form>
{% endif %}
<div class="{% if request.eventperm.can_change_orders %}col-md-6{% else %}col-md-12{% endif %}">
<div class="panel panel-default">
<div class="panel-heading">
{% trans "Sales estimate" %}
</div>
<div class="panel-body">
{% blocktrans trimmed with amount=estimate|default:0|floatformat:2 currency=request.event.currency %}
If you can make enough room at your event to fit all the persons on the waiting list in, you
could sell tickets worth an additional <strong>{{ amount }} {{ currency }}</strong>.
{% endblocktrans %}
</div>
</div>
</div>
</div>
<p>
<form class="form-inline helper-display-inline" action="" method="get">
<select name="status" class="form-control">
<option value="a"
{% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "All entries" %}</option>
<option value="w"
{% if request.GET.status == "w" or not request.GET.status %}selected="selected"{% endif %}>{% trans "Waiting" %}</option>
<option value="s"
{% if request.GET.status == "s" %}selected="selected"{% endif %}>{% trans "Voucher assigned" %}</option>
</select>
<select name="item" class="form-control">
<option value="">{% trans "All products" %}</option>
{% for item in items %}
<option value="{{ item.id }}"
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
{{ item.name }}
</option>
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
</form>
</p>
<form method="post" action="">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "User" %}</th>
<th>{% trans "Product" %}</th>
<th>{% trans "On the list since" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Voucher" %}</th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td>{{ e.email }}</td>
<td>
{{ e.item }}
{% if e.variation %}
{{ e.variation }}
{% endif %}
</td>
<td>{{ e.created|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>
{% if e.voucher %}
<span class="label label-success">{% trans "Voucher assigned" %}</span>
{% elif e.availability.0 == 100 %}
<span class="label label-warning">
{% blocktrans with num=e.availability.1 %}
Waiting, product {{ num }}x available
{% endblocktrans %}
</span>
{% else %}
<span class="label label-danger">{% trans "Waiting, product unavailable" %}</span>
{% endif %}
</td>
<td>
{% if e.voucher %}
<a href="{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=e.voucher.pk %}">
{{ e.voucher }}
</a>
{% elif not e.voucher and e.availability.0 == 100 %}
<button name="assign" value="{{ e.pk }}" class="btn btn-default btn-xs">
{% trans "Send a voucher" %}
</button>
{% endif %}
</td>
<td class="text-right">
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -2,7 +2,7 @@ from django.conf.urls import include, url
from pretix.control.views import (
auth, dashboards, event, global_settings, help, item, main, orders,
organizer, user, vouchers,
organizer, user, vouchers, waitinglist,
)
urlpatterns = [
@@ -14,9 +14,10 @@ urlpatterns = [
url(r'^forgot$', auth.Forgot.as_view(), name='auth.forgot'),
url(r'^forgot/recover$', auth.Recover.as_view(), name='auth.forgot.recover'),
url(r'^$', dashboards.user_index, name='index'),
url(r'^settings/$', global_settings.GlobalSettingsView.as_view(), name='global-settings'),
url(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='global.settings'),
url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'),
url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'),
url(r'^settings$', user.UserSettings.as_view(), name='user.settings'),
url(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
url(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
url(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
url(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'),
@@ -121,6 +122,8 @@ urlpatterns = [
url(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
url(r'^waitinglist/$', waitinglist.WaitingListView.as_view(), name='event.orders.waitinglist'),
url(r'^waitinglist/auto_assign$', waitinglist.AutoAssign.as_view(), name='event.orders.waitinglist.auto'),
])),
url(r'^help/(?P<topic>[a-zA-Z0-9_/]+)$', help.HelpView.as_view(), name='help'),
]

View File

@@ -33,5 +33,5 @@ class ChartContainingView:
def get(self, request, *args, **kwargs):
resp = super().get(request, *args, **kwargs)
# required by raphael.js
resp['Content-Security-Policy'] = "script-src {static} 'unsafe-eval'; style-src {static} 'unsafe-inline'"
resp['Content-Security-Policy'] = "script-src 'unsafe-eval'; style-src 'unsafe-inline'"
return resp

View File

@@ -12,7 +12,9 @@ from django.utils import formats
from django.utils.formats import date_format
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Event, Item, Order, OrderPosition, Voucher
from pretix.base.models import (
Event, Item, Order, OrderPosition, Voucher, WaitingListEntry,
)
from pretix.control.signals import (
event_dashboard_widgets, user_dashboard_widgets,
)
@@ -85,9 +87,53 @@ def base_widgets(sender, **kwargs):
]
@receiver(signal=event_dashboard_widgets)
def waitinglist_widgets(sender, **kwargs):
widgets = []
wles = WaitingListEntry.objects.filter(event=sender, voucher__isnull=True)
if wles.count():
quota_cache = {}
itemvar_cache = {}
happy = 0
for wle in wles:
if (wle.item, wle.variation) not in itemvar_cache:
itemvar_cache[(wle.item, wle.variation)] = (
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
if wle.variation
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
)
row = itemvar_cache.get((wle.item, wle.variation))
if row[1] > 0:
itemvar_cache[(wle.item, wle.variation)] = (row[0], row[1] - 1)
happy += 1
widgets.append({
'content': NUM_WIDGET.format(num=str(happy), text=_('available to give to people on waiting list')),
'priority': 50,
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
})
})
widgets.append({
'content': NUM_WIDGET.format(num=str(wles.count()), text=_('total waiting list length')),
'display_size': 'small',
'priority': 50,
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': sender.slug,
'organizer': sender.organizer.slug,
})
})
return widgets
@receiver(signal=event_dashboard_widgets)
def quota_widgets(sender, **kwargs):
widgets = []
for q in sender.quotas.all():
status, left = q.availability()
widgets.append({

View File

@@ -14,6 +14,7 @@ from django.utils.translation import ugettext_lazy as _
from django.views.generic import FormView, ListView
from django.views.generic.base import TemplateView, View
from django.views.generic.detail import SingleObjectMixin
from pytz import timezone
from pretix.base.forms import I18nModelForm
from pretix.base.models import (
@@ -88,10 +89,22 @@ class EventUpdate(EventPermissionRequiredMixin, UpdateView):
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid() and self.sform.is_valid():
# reset timezone
zone = timezone(self.sform.cleaned_data['timezone'])
event = form.instance
event.date_from = self.reset_timezone(zone, event.date_from)
event.date_to = self.reset_timezone(zone, event.date_to)
event.presale_start = self.reset_timezone(zone, event.presale_start)
event.presale_end = self.reset_timezone(zone, event.presale_end)
return self.form_valid(form)
else:
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.form_invalid(form)
@staticmethod
def reset_timezone(tz, dt):
return tz.localize(dt.replace(tzinfo=None)) if dt is not None else None
class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin):
model = Event
@@ -443,6 +456,10 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
form.prepare_fields()
return form
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
@transaction.atomic
def post(self, request, *args, **kwargs):
success = True
@@ -475,7 +492,7 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
return self.get(request)
return self.form_invalid(form)
@cached_property
def provider_forms(self) -> list:
@@ -553,7 +570,7 @@ class EventPermissions(EventPermissionRequiredMixin, TemplateView):
try:
mail(
instance.invite_email,
_('Account information changed'),
_('pretix account invitation'),
'pretixcontrol/email/invitation.txt',
{
'user': self,

View File

@@ -1,9 +1,13 @@
from django.contrib import messages
from django.shortcuts import reverse
from django.shortcuts import redirect, reverse
from django.utils.translation import ugettext_lazy as _
from django.views.generic import FormView
from pretix.control.forms.global_settings import GlobalSettingsForm
from pretix.base.services.update_check import check_result_table, update_check
from pretix.base.settings import GlobalSettingsObject
from pretix.control.forms.global_settings import (
GlobalSettingsForm, UpdateSettingsForm,
)
from pretix.control.permissions import AdministratorPermissionRequiredMixin
@@ -21,4 +25,34 @@ class GlobalSettingsView(AdministratorPermissionRequiredMixin, FormView):
return super().form_invalid(form)
def get_success_url(self):
return reverse('control:global-settings')
return reverse('control:global.settings')
class UpdateCheckView(AdministratorPermissionRequiredMixin, FormView):
template_name = 'pretixcontrol/global_update.html'
form_class = UpdateSettingsForm
def post(self, request, *args, **kwargs):
if 'trigger' in request.POST:
update_check.apply()
return redirect(self.get_success_url())
return super().post(request, *args, **kwargs)
def form_valid(self, form):
form.save()
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, _('Your changes have not been saved, see below for errors.'))
return super().form_invalid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['gs'] = GlobalSettingsObject()
ctx['gs'].settings.set('update_check_ack', True)
ctx['tbl'] = check_result_table()
return ctx
def get_success_url(self):
return reverse('control:global.update')

View File

@@ -156,6 +156,10 @@ class CategoryUpdate(EventPermissionRequiredMixin, UpdateView):
'event': self.request.event.slug,
})
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class CategoryCreate(EventPermissionRequiredMixin, CreateView):
model = ItemCategory
@@ -178,6 +182,10 @@ class CategoryCreate(EventPermissionRequiredMixin, CreateView):
form.instance.log_action('pretix.event.category.added', data=dict(form.cleaned_data), user=self.request.user)
return ret
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class CategoryList(ListView):
model = ItemCategory
@@ -479,6 +487,10 @@ class QuestionUpdate(EventPermissionRequiredMixin, QuestionMixin, UpdateView):
'event': self.request.event.slug,
})
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
model = Question
@@ -501,6 +513,10 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
def get_object(self, **kwargs):
return None
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
@transaction.atomic
def form_valid(self, form):
if form.cleaned_data.get('type') in ('M', 'C'):
@@ -592,6 +608,10 @@ class QuotaCreate(EventPermissionRequiredMixin, QuotaEditorMixin, CreateView):
form.instance.log_action('pretix.event.quota.added', user=self.request.user, data=dict(form.cleaned_data))
return ret
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class QuotaView(ChartContainingView, DetailView):
model = Quota
@@ -607,24 +627,33 @@ class QuotaView(ChartContainingView, DetailView):
data = [
{
'label': ugettext('Paid orders'),
'value': self.object.count_paid_orders()
'value': self.object.count_paid_orders(),
'sum': True,
},
{
'label': ugettext('Pending orders'),
'value': self.object.count_pending_orders()
'value': self.object.count_pending_orders(),
'sum': True,
},
{
'label': ugettext('Vouchers'),
'value': self.object.count_blocking_vouchers()
'value': self.object.count_blocking_vouchers(),
'sum': True,
},
{
'label': ugettext('Current user\'s carts'),
'value': self.object.count_in_cart()
}
'value': self.object.count_in_cart(),
'sum': True,
},
{
'label': ugettext('Waiting list'),
'value': self.object.count_waiting_list_pending(),
'sum': False,
},
]
ctx['quota_table_rows'] = list(data)
sum_values = sum([d['value'] for d in data])
sum_values = sum([d['value'] for d in data if d['sum']])
if self.object.size is not None:
data.append({
@@ -690,6 +719,10 @@ class QuotaUpdate(EventPermissionRequiredMixin, QuotaEditorMixin, UpdateView):
'quota': self.object.pk
})
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class QuotaDelete(EventPermissionRequiredMixin, DeleteView):
model = Quota
@@ -757,6 +790,7 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
@transaction.atomic
def form_valid(self, form):
messages.success(self.request, _('Your changes have been saved.'))
ret = super().form_valid(form)
form.instance.log_action('pretix.event.item.added', user=self.request.user, data={
k: (form.cleaned_data.get(k).name
@@ -775,6 +809,10 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
kwargs.update({'instance': newinst})
return kwargs
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateView):
form_class = ItemUpdateForm
@@ -803,6 +841,10 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie
CachedTicket.objects.filter(order_position__item=self.item).delete()
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
@@ -859,6 +901,7 @@ class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.get(request, *args, **kwargs)
def get_success_url(self) -> str:

View File

@@ -179,6 +179,8 @@ class OrderDetail(OrderView):
'raw': cartpos,
'total': self.object.total,
'payment_fee': self.object.payment_fee,
'net_total': self.object.net_total,
'tax_total': self.object.tax_total,
}
@@ -446,8 +448,8 @@ class OrderChange(OrderView):
template_name = 'pretixcontrol/order/change.html'
def dispatch(self, request, *args, **kwargs):
if self.order.status != Order.STATUS_PENDING:
messages.error(self.request, _('This action is only allowed for pending orders.'))
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
messages.error(self.request, _('This action is only allowed for pending or paid orders.'))
return self._redirect_back()
return super().dispatch(request, *args, **kwargs)

View File

@@ -64,7 +64,7 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
return fs(
data=(
self.request.POST
if self.request.method == "POST" and 'id_formset-TOTAL_FORMS' in self.request.POST
if self.request.method == "POST" and 'formset-TOTAL_FORMS' in self.request.POST
else None
),
prefix="formset",
@@ -76,7 +76,7 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
return OrganizerPermissionCreateForm(
data=(
self.request.POST
if self.request.method == "POST" and 'id_formset-TOTAL_FORMS' in self.request.POST
if self.request.method == "POST" and 'formset-TOTAL_FORMS' in self.request.POST
else None
),
prefix="add"
@@ -99,7 +99,7 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
try:
mail(
instance.invite_email,
_('Account information changed'),
_('pretix account invitation'),
'pretixcontrol/email/invitation_organizer.txt',
{
'user': self,
@@ -119,7 +119,7 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
if not self.request.orgaperm.can_change_permissions:
raise PermissionDenied(_("You have no permission to do this."))
if 'id_formset-TOTAL_FORMS' not in self.request.POST:
if 'formset-TOTAL_FORMS' not in self.request.POST:
return self.get(*args, **kwargs)
if self.formset.is_valid() and self.add_form.is_valid():

View File

@@ -155,6 +155,10 @@ class User2FADeviceAddView(RecentAuthenticationRequiredMixin, FormView):
'device': dev.pk
}))
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_delete.html'
@@ -232,7 +236,12 @@ class User2FADeviceConfirmU2FView(RecentAuthenticationRequiredMixin, TemplateVie
_('A new two-factor authentication device has been added to your account.')
])
messages.success(request, _('The device has been verified and can now be used.'))
note = ''
if not self.request.user.require_2fa:
note = ' ' + str(_('Please note that you still need to enable two-factor authentication for your '
'account using the buttons below to make a second factor required for logging '
'into your accont.'))
messages.success(request, str(_('The device has been verified and can now be used.')) + note)
return redirect(reverse('control:user.settings.2fa'))
except Exception:
messages.error(request, _('The registration could not be completed. Please try again.'))
@@ -275,7 +284,12 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
_('A new two-factor authentication device has been added to your account.')
])
messages.success(request, _('The device has been verified and can now be used.'))
note = ''
if not self.request.user.require_2fa:
note = ' ' + str(_('Please note that you still need to enable two-factor authentication for your '
'account using the buttons below to make a second factor required for logging '
'into your accont.'))
messages.success(request, str(_('The device has been verified and can now be used.')) + note)
return redirect(reverse('control:user.settings.2fa'))
else:
messages.error(request, _('The code you entered was not valid. If this problem persists, please check '

View File

@@ -215,7 +215,7 @@ class VoucherCreate(EventPermissionRequiredMixin, CreateView):
@transaction.atomic
def form_valid(self, form):
form.instance.event = self.request.event
messages.success(self.request, _('The new voucher has been created.'))
messages.success(self.request, _('The new voucher has been created: {code}').format(code=form.instance.code))
ret = super().form_valid(form)
form.instance.log_action('pretix.voucher.added', data=dict(form.cleaned_data), user=self.request.user)
return ret

View File

@@ -0,0 +1,127 @@
from django.contrib import messages
from django.db.models import Sum
from django.db.models.functions import Coalesce
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from django.views import View
from django.views.generic import ListView
from pretix.base.models import Item, WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException
from pretix.base.services.waitinglist import assign_automatically
from pretix.base.views.async import AsyncAction
from pretix.control.permissions import EventPermissionRequiredMixin
class AutoAssign(EventPermissionRequiredMixin, AsyncAction, View):
task = assign_automatically
known_errortypes = ['WaitingListError']
permission = 'can_change_orders'
def get_success_message(self, value):
return _('{num} vouchers have been created and sent out via email.').format(num=value)
def get_success_url(self, value):
return self.get_error_url()
def get_error_url(self):
return reverse('control:event.orders.waitinglist', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug
})
def post(self, request, *args, **kwargs):
return self.do(self.request.event.id, self.request.user.id)
class WaitingListView(EventPermissionRequiredMixin, ListView):
model = WaitingListEntry
context_object_name = 'entries'
paginate_by = 30
template_name = 'pretixcontrol/waitinglist/index.html'
permission = 'can_view_orders'
def post(self, request, *args, **kwargs):
if 'assign' in request.POST:
if not request.eventperm.can_change_orders:
messages.error(request, _('You do not have permission to do this'))
return redirect(reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug
}))
try:
wle = WaitingListEntry.objects.get(
pk=request.POST.get('assign'), event=self.request.event,
)
try:
wle.send_voucher(user=request.user)
except WaitingListException as e:
messages.error(request, str(e))
else:
messages.success(request, _('An email containing a voucher code has been sent to the '
'specified address.'))
return redirect(reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug
}))
except WaitingListEntry.DoesNotExist:
messages.error(request, _('Waiting list entry not found.'))
return redirect(reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug
}))
def get_queryset(self):
qs = WaitingListEntry.objects.filter(
event=self.request.event
).select_related('item', 'variation', 'voucher').prefetch_related('item__quotas', 'variation__quotas')
s = self.request.GET.get("status", "")
if s == 's':
qs = qs.filter(voucher__isnull=False)
elif s == 'a':
pass
else:
qs = qs.filter(voucher__isnull=True)
if self.request.GET.get("item", "") != "":
i = self.request.GET.get("item", "")
qs = qs.filter(item_id__in=(i,))
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['items'] = Item.objects.filter(event=self.request.event)
ctx['filtered'] = ("status" in self.request.GET or "item" in self.request.GET)
itemvar_cache = {}
quota_cache = {}
any_avail = False
for wle in ctx[self.context_object_name]:
if (wle.item, wle.variation) in itemvar_cache:
wle.availability = itemvar_cache.get((wle.item, wle.variation))
else:
wle.availability = (
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
if wle.variation
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
)
itemvar_cache[(wle.item, wle.variation)] = wle.availability
if wle.availability[0] == 100:
any_avail = True
ctx['any_avail'] = any_avail
ctx['estimate'] = self.get_sales_estimate()
return ctx
def get_sales_estimate(self):
qs = WaitingListEntry.objects.filter(
event=self.request.event, voucher__isnull=True
).aggregate(
s=Sum(
Coalesce('variation__default_price', 'item__default_price')
)
)
return qs['s']

Some files were not shown because too many files have changed in this diff Show More