Compare commits

..
672 changed files with 4538 additions and 15330 deletions
-35
View File
@@ -1,35 +0,0 @@
codecov:
notify:
require_ci_to_pass: yes
coverage:
precision: 2
round: down
range: "60...100"
status:
project:
default:
target: auto
threshold: 2%
base: auto
patch:
default:
target: auto
threshold: 2%
base: auto
changes: no
parsers:
gcov:
branch_detection:
conditional: yes
loop: yes
method: no
macro: no
comment:
require_changes: yes
layout: "header, diff, files"
behavior: default
require_changes: no
+2 -23
View File
@@ -2,32 +2,11 @@ before_script:
tests: tests:
stage: test stage: test
script: script:
- virtualenv env - virtualenv-3.4 env
- source env/bin/activate - source env/bin/activate
- pip install -U pip wheel setuptools - pip install -U pip wheel setuptools
- XDG_CACHE_HOME=/cache bash .travis.sh style - XDG_CACHE_HOME=/cache bash .travis.sh style
- XDG_CACHE_HOME=/cache bash .travis.sh tests - XDG_CACHE_HOME=/cache bash .travis.sh tests
- XDG_CACHE_HOME=/cache bash .travis.sh doctests
tags: tags:
- python3 - 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
+1 -11
View File
@@ -4,16 +4,6 @@ set -x
echo "Executing job $1" 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 if [ "$1" == "style" ]; then
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
cd src cd src
@@ -37,5 +27,5 @@ if [ "$1" == "tests-cov" ]; then
cd src cd src
python manage.py check python manage.py check
make all compress make all compress
coverage run -m py.test --rerun 5 tests && codecov coverage run -m py.test --rerun 5 tests && coveralls
fi fi
+10 -33
View File
@@ -1,38 +1,15 @@
language: python language: python
sudo: false sudo: false
python:
- "3.4"
install: install:
- pip install -U pip wheel setuptools==28.6.1 - pip install -U pip wheel setuptools==28.6.1
script: script:
- bash .travis.sh $JOB - bash .travis.sh $JOB
cache: cache:
directories: directories:
- $HOME/.cache/pip - $HOME/.cache/pip
services: env:
- mysql - JOB=style
- postgresql - JOB=doctests
matrix: - JOB=tests-cov
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=tests-cov
addons:
postgresql: "9.4"
-1
View File
@@ -21,4 +21,3 @@ an awesome project. Thank you all!
Tobias Kunze <rixx@cutebit.de> Tobias Kunze <rixx@cutebit.de>
Oliver Knapp <github@oliverknapp.de> Oliver Knapp <github@oliverknapp.de>
Vishal Sodani <vishalsodani@rediffmail.com> Vishal Sodani <vishalsodani@rediffmail.com>
Jan Felix Wiebe <git@jfwie.be>
+35 -32
View File
@@ -1,46 +1,49 @@
FROM debian:jessie FROM debian:jessie
RUN apt-get update && \ RUN apt-get update && apt-get install -y python3 git python3-pip \
apt-get install -y python3 git python3-pip \ libxml2-dev libxslt1-dev python-dev python-virtualenv locales libffi-dev \
libxml2-dev libxslt1-dev python-dev python-virtualenv locales libffi-dev \ build-essential python3-dev zlib1g-dev libssl-dev gettext \
build-essential python3-dev zlib1g-dev libssl-dev gettext \ libpq-dev libmysqlclient-dev libmemcached-dev libjpeg-dev \
libpq-dev libmysqlclient-dev libmemcached-dev libjpeg-dev \ aqbanking-tools supervisor nginx sudo \
aqbanking-tools supervisor nginx sudo \ --no-install-recommends
--no-install-recommends && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
dpkg-reconfigure locales && \
locale-gen C.UTF-8 && \
/usr/sbin/update-locale LANG=C.UTF-8 && \
mkdir /etc/pretix && \
mkdir /data && \
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
echo 'pretixuser ALL=(ALL) NOPASSWD: /usr/bin/supervisord' >> /etc/sudoers && \
mkdir /static
ENV LC_ALL=C.UTF-8 \ WORKDIR /
DJANGO_SETTINGS_MODULE=production_settings
RUN dpkg-reconfigure locales && \
locale-gen C.UTF-8 && \
/usr/sbin/update-locale LANG=C.UTF-8
ENV LC_ALL C.UTF-8
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
RUN useradd -ms /bin/bash -d /pretix -u 15371 pretixuser
RUN echo 'pretixuser ALL=(ALL) NOPASSWD: /usr/bin/supervisord' >> /etc/sudoers
RUN mkdir /etc/pretix
RUN mkdir /data
VOLUME /etc/pretix
COPY deployment/docker/pretix.bash /usr/local/bin/pretix COPY deployment/docker/pretix.bash /usr/local/bin/pretix
RUN chmod +x /usr/local/bin/pretix
COPY deployment/docker/supervisord.conf /etc/supervisord.conf COPY deployment/docker/supervisord.conf /etc/supervisord.conf
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py RUN rm /etc/nginx/sites-enabled/default
COPY src /pretix/src COPY src /pretix/src
WORKDIR /pretix/src
ADD deployment/docker/production_settings.py /pretix/src/production_settings.py
ENV DJANGO_SETTINGS_MODULE production_settings
RUN chmod +x /usr/local/bin/pretix && \ RUN pip3 install -r requirements.txt -r requirements/mysql.txt -r requirements/postgres.txt \
rm /etc/nginx/sites-enabled/default && \ -r requirements/memcached.txt -r requirements/redis.txt \
pip3 install -U pip wheel setuptools && \ -r requirements/py34.txt gunicorn
cd /pretix/src && \
rm -f pretix.cfg && \
pip3 install -r requirements.txt -r requirements/mysql.txt -r requirements/postgres.txt \
-r requirements/memcached.txt -r requirements/redis.txt \
-r requirements/py34.txt gunicorn && \
mkdir -p data && \
chown -R pretixuser:pretixuser /pretix /data data && \
sudo -u pretixuser make production
RUN mkdir /static && chown -R pretixuser:pretixuser /static /pretix /data
USER pretixuser USER pretixuser
VOLUME ["/etc/pretix", "/data"] RUN make production
EXPOSE 80 EXPOSE 80
ENTRYPOINT ["pretix"] ENTRYPOINT ["pretix"]
CMD ["all"] CMD ["all"]
+41
View File
@@ -0,0 +1,41 @@
pretix
======
[![Docs](https://readthedocs.org/projects/pretix/badge/?version=latest)](http://docs.pretix.eu/en/latest/)
[![Build Status](https://travis-ci.org/pretix/pretix.svg?branch=master)](https://travis-ci.org/pretix/pretix)
[![Coverage Status](https://img.shields.io/coveralls/pretix/pretix.svg)](https://coveralls.io/r/pretix/pretix)
Reinventing ticket presales, one bit at a time.
Project status
--------------
Most features are present and sufficiently stable. pretix has been in use for multiple event and
sold a few thousand tickets so far. There is still a bunch of features to come and there surely is
still a bunch of bugs in there, but we consider it stable enough that we use it in production ourselves.
If you deploy and use pretix, there will be a safe upgrade path for all changes to come. We're planning
on an 1.0 release in late 2016 or early 2017. Until then, we take the liberty of changing the code as we
like, but we try to keep the changes to documented APIs as small as possible. If you want to use pretix
in production or develop a plugin now, I invite you to send me an email so that I can notify you of changes
and bugs that require your attention.
Since very recently we now have an [installation guide](https://docs.pretix.eu/en/latest/admin/installation/index.html)
in our documentation.
Contributing
------------
If you want to contribute to pretix, please read the [developer documentation](https://docs.pretix.eu/en/latest/development/index.html)
in our documentation. If you have any further questions, please do not hesitate to ask!
License
-------
The code in this repository is published under the terms of the Apache License.
See the LICENSE file for the complete license text.
This project is maintained by Raphael Michel <mail@raphaelmichel.de>. See the
AUTHORS file for a list of all the awesome folks who contributed to this project.
This project is 100 percent free and open source software. If you are interested in
commercial support, hosting services or supporting this project financially, please
go to [pretix.eu](https://pretix.eu) or contact Raphael directly.
-54
View File
@@ -1,54 +0,0 @@
pretix
======
.. image:: https://img.shields.io/pypi/v/pretix.svg
:target: https://pypi.python.org/pypi/pretix
.. image:: https://readthedocs.org/projects/pretix/badge/?version=latest
:target: https://docs.pretix.eu/en/latest/
.. image:: https://travis-ci.org/pretix/pretix.svg?branch=master
:target: https://travis-ci.org/pretix/pretix
.. image:: https://codecov.io/gh/pretix/pretix/branch/master/graph/badge.svg
:target: https://codecov.io/gh/pretix/pretix
Reinventing ticket presales, one ticket at a time.
Project status & release cycle
------------------------------
While there is always a lot to do and improve on, pretix by now has been in use for more than a dozen
conferences that sold over ten thousand tickets combined without major problems. We therefore think of
pretix as being stable and ready to use.
If you want to use or extend pretix, we strongly recommend to follow our `blog`_. We will announce all
releases there. You can always find the latest stable version on PyPI or in the ``release/X.Y`` branch of
this repository. The ``master`` branch contains a development version that we also try to keep stable in
the sense that it does not break your data, but its APIs might change without prior notice.
To get started using pretix on your own server, look at the `installation guide`_ in our documentation.
This project is 100 percent free and open source software. If you are interested in commercial support,
hosting services or supporting this project financially, please go to `pretix.eu`_ or contact us at
support@pretix.eu.
Contributing
------------
If you want to contribute to pretix, please read the `developer documentation`_
in our documentation. If you have any further questions, please do not hesitate to ask!
License
-------
The code in this repository is published under the terms of the Apache License.
See the LICENSE file for the complete license text.
This project is maintained by Raphael Michel <mail@raphaelmichel.de>. See the
AUTHORS file for a list of all the awesome folks who contributed to this project.
.. _installation guide: https://docs.pretix.eu/en/latest/admin/installation/index.html
.. _developer documentation: https://docs.pretix.eu/en/latest/development/index.html
.. _pretix.eu: https://pretix.eu
.. _blog: https://pretix.eu/about/en/blog/
+1 -9
View File
@@ -48,16 +48,8 @@ http {
expires 7d; expires 7d;
access_log off; access_log off;
} }
location ^~ /media/cachedfiles {
deny all;
return 404;
}
location ^~ /media/invoices {
deny all;
return 404;
}
location /static/ { location /static/ {
alias /pretix/src/pretix/static.dist/; alias /static/;
access_log off; access_log off;
expires 365d; expires 365d;
add_header Cache-Control "public"; add_header Cache-Control "public";
+1 -1
View File
@@ -33,7 +33,7 @@ fi
if [ "$1" == "taskworker" ]; then if [ "$1" == "taskworker" ]; then
export C_FORCE_ROOT=True export C_FORCE_ROOT=True
exec celery -A pretix.celery_app worker -l info exec celery -A pretix worker -l info
fi fi
if [ "$1" == "shell" ]; then if [ "$1" == "shell" ]; then
+2
View File
@@ -1,4 +1,6 @@
from pretix.settings import * from pretix.settings import *
LOGGING['handlers']['mail_admins']['include_html'] = True LOGGING['handlers']['mail_admins']['include_html'] = True
STATIC_ROOT = '/static'
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
+1 -23
View File
@@ -16,9 +16,7 @@ the files found before.
The file is expected to be in the INI format as specified in the `Python documentation`_. The file is expected to be in the INI format as specified in the `Python documentation`_.
The config file may contain the following sections (all settings are optional and have The config file may contain the following sections (all settings are optional and have default values).
default values). We suggest that you start from the examples given in one of the
installation tutorials.
pretix settings pretix settings
--------------- ---------------
@@ -102,10 +100,6 @@ Example::
``user``, ``password``, ``host``, ``port`` ``user``, ``password``, ``host``, ``port``
Connection details for the database connection. Empty by default. 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 URLs
---- ----
@@ -207,9 +201,6 @@ You can use an existing memcached server as pretix's caching backend::
If no memcached is configured, pretix will use Django's built-in local-memory caching method. If no memcached is configured, pretix will use Django's built-in local-memory caching method.
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
shared memcached instance, not multiple ones, because cache invalidations would not be
propagated otherwise.
Redis Redis
----- -----
@@ -247,19 +238,6 @@ RabbitMQ might be the better choice if you have a complex, multi-server, high-pe
but as you already should have a redis instance ready for session and lock storage, we recommend but as you already should have a redis instance ready for session and lock storage, we recommend
redis for convenience. See the `Celery documentation`_ for more details. redis for convenience. See the `Celery documentation`_ for more details.
Sentry
------
pretix has native support for sentry, a tool that you can use to track errors in the
application. If you want to use sentry, you need to set a DSN in the configuration file::
[sentry]
dsn=https://<key>:<secret>@sentry.io/<project>
``dsn``
You will be given this value by your sentry installation.
Secret length Secret length
------------- -------------
+10 -32
View File
@@ -74,7 +74,7 @@ redis instance to be running on the same host. To avoid the hassle with network
recommend connecting to redis via a unix socket. To enable redis on unix sockets, add the following to your recommend connecting to redis via a unix socket. To enable redis on unix sockets, add the following to your
``/etc/redis/redis.conf``:: ``/etc/redis/redis.conf``::
unixsocket /var/run/redis/redis.sock unixsocket /tmp/redis.sock
unixsocketperm 777 unixsocketperm 777
Now restart redis-server:: Now restart redis-server::
@@ -111,7 +111,7 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
datadir=/data datadir=/data
[database] [database]
; Replace mysql with postgresql_psycopg2 for PostgreSQL ; Replace mysql with psycopg2 for PostgreSQL
backend=mysql backend=mysql
name=pretix name=pretix
user=pretix user=pretix
@@ -127,23 +127,23 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
host=172.17.0.1 host=172.17.0.1
[redis] [redis]
location=unix:///var/run/redis/redis.sock?db=0 location=unix:///tmp/redis.sock?db=0
; Remove the following line if you are unsure about your redis' security ; Remove the following line if you are unsure about your redis' security
; to reduce impact if redis gets compromised. ; to reduce impact if redis gets compromised.
sessions=true sessions=true
[celery] [celery]
backend=redis+socket:///var/run/redis/redis.sock?virtual_host=1 backend=redis+socket:///tmp/redis.sock?virtual_host=1
broker=redis+socket:///var/run/redis/redis.sock?virtual_host=2 broker=redis+socket:///tmp/redis.sock?virtual_host=2
See :ref:`email configuration <mail-settings>` to learn more about configuring mail features. See :ref:`email configuration <mail-settings>` to learn more about configuring mail features.
Docker image and service Docker image and service
------------------------ ------------------------
First of all, download the latest stable pretix image by running:: First of all, download the latest pretix image by running::
$ docker pull pretix/standalone:stable $ docker pull pretix/standalone:latest
We recommend starting the docker container using systemd to make sure it runs correctly after a reboot. Create a file 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:: named ``/etc/systemd/system/pretix.service`` with the following content::
@@ -160,7 +160,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
ExecStart=/usr/bin/docker run --name %n -p 8345:80 \ ExecStart=/usr/bin/docker run --name %n -p 8345:80 \
-v /var/pretix-data:/data \ -v /var/pretix-data:/data \
-v /etc/pretix:/etc/pretix \ -v /etc/pretix:/etc/pretix \
-v /var/run/redis:/var/run/redis \ -v /tmp/redis.sock:/tmp/redis.sock \
-v /var/run/mysqld:/var/run/mysqld \ -v /var/run/mysqld:/var/run/mysqld \
pretix/standalone all pretix/standalone all
ExecStop=/usr/bin/docker stop %n ExecStop=/usr/bin/docker stop %n
@@ -168,7 +168,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
You can leave the MySQL socket volume out if you're using PostgreSQL. You can now run the following commands You can leave the MySQL socket volume out if you're using PostgreSQL. You can now run the following comamnds
to enable and start the service:: to enable and start the service::
# systemctl daemon-reload # systemctl daemon-reload
@@ -229,33 +229,11 @@ Updates
Updates are fairly simple, but require at least a short downtime:: Updates are fairly simple, but require at least a short downtime::
# docker pull pretix/standalone:stable # docker pull pretix/standalone
# systemctl restart pretix.service # systemctl restart pretix.service
# docker exec -it pretix.service pretix upgrade # docker exec -it pretix.service pretix upgrade
Restarting the service can take a few seconds, especially if the update requires changes to the database. Restarting the service can take a few seconds, especially if the update requires changes to the database.
Replace ``stable`` above with a specific version number like ``1.0`` or with ``latest`` for the development
version, if you want to.
Install a plugin
----------------
To install a plugin, you need to build your own docker image. To do so, create a new directory and place a file
named ``Dockerfile`` in it. The Dockerfile could look like this (replace ``pretix-passbook`` with the plugins of your
choice)::
FROM pretix/standalone
USER root
RUN pip3 install pretix-passbook
USER pretixuser
RUN make production
Then, go to that directory and build the image::
$ docker build -t mypretix
You can now use that image ``mypretix`` instead of ``pretix/standalone`` in your service file (see above). Be sure
to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an update.
.. _Docker: https://docs.docker.com/engine/installation/linux/debian/ .. _Docker: https://docs.docker.com/engine/installation/linux/debian/
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04 .. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
+35 -17
View File
@@ -5,33 +5,51 @@ General remarks
Requirements Requirements
------------ ------------
To use pretix, you will need the following things: To use pretix, the most minimal setup consists of:
* **pretix** and the python packages it depends on * **pretix** and the python packages it depends on
* An **WSGI application server** (we recommend gunicorn) * An **WSGI application server** (we recommend gunicorn)
* A periodic task runner, e.g. ``cron`` * A periodic task runner, e.g. ``cron``
* **A database**. This needs to be a SQL-based that is supported by Django. We highly recommend to either To run pretix, you will need **at least Python 3.4**. We only recommend installations on **Linux**, Windows is not
go for **PostgreSQL** or **MySQL/MariaDB**. If you do not provide one, pretix will run on SQLite, which is useful officially supported (but might work).
for evaluation and development purposes.
.. warning:: Do not ever use SQLite in production. It will break. Optional requirements
---------------------
* A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix pretix is built in a way that makes many of the following requirements optional. However, performance or security might
is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much be very low if you skip some of them, therefore they are only partly optional.
faster. Also, you need a proxying web server in front to provide SSL encryption.
.. warning:: Do not ever run without SSL in production. Your users deserve encrypted connections and thanks to Database
`Let's Encrypt`_ SSL certificates can be obtained for free these days. A good SQL-based database to run on that is supported by Django. We highly recommend to either go for **PostgreSQL**
or **MySQL/MariaDB**.
If you do not provide one, pretix will run on SQLite, which is useful for evaluation and development purposes.
* A **redis** server. This will be used for caching, session storage and task queuing. .. warning:: Do not ever use SQLite in production. It will break.
.. warning:: pretix can run without redis, however this is only intended for development and should never be Reverse proxy
used in production. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix is capable of
doing this, having this handled by a proper web server like **nginx** or **Apache** will be much faster. Also, you
need a proxying web server in front to provide SSL encryption.
* Optionally: RabbitMQ or memcached. Both of them might provide speedups, but if they are not present, .. warning:: Do not ever run without SSL in production. Your users deserve encrypted connections and thanks to
redis will take over their job. `Let's Encrypt`_ SSL certificates can be obtained for free these days.
Task worker
When pretix has to do heavy stuff, it is better to offload it into a background process instead of having the
users connection wait. Therefore pretix provides a background service that can be used to work on those
longer-running tasks.
This requires at least Redis (and optionally RabbitMQ).
Redis
If you provide a redis instance, pretix is able to make use of it in the three following ways:
* Caching
* Fast session storage
* Queuing and result storage for the task worker queue
RabbitMQ
RabbitMQ can be used as a more advanced queue manager for the task workers if necessary.
.. _Let's Encrypt: https://letsencrypt.org/ .. _Let's Encrypt: https://letsencrypt.org/
+29 -50
View File
@@ -38,10 +38,10 @@ Unix user
As we do not want to run pretix as root, we first create a new unprivileged user:: As we do not want to run pretix as root, we first create a new unprivileged user::
# adduser pretix --disabled-password --home /var/pretix # sudo adduser pretix --disabled-password --home /var/pretix
In this guide, all code lines prepended with a ``#`` symbol are commands that you need to execute on your server as In this guide, all code lines prepended with a ``#`` symbol are commands that you need to execute on your server as
``root`` user (e.g. using ``sudo``); all lines prepended with a ``$`` symbol should be run by the unprivileged user. ``root`` user; all lines prepended with a ``$`` symbol should be run by the unprivileged user.
Database Database
-------- --------
@@ -82,7 +82,7 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
datadir=/var/pretix/data datadir=/var/pretix/data
[database] [database]
; Replace mysql with postgresql_psycopg2 for PostgreSQL ; Replace mysql with psycopg2 for PostgreSQL
backend=mysql backend=mysql
name=pretix name=pretix
user=pretix user=pretix
@@ -100,13 +100,13 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
sessions=true sessions=true
[celery] [celery]
backend=redis://127.0.0.1/1 backend=redis://127.0.0.1?virtual_host=1
broker=redis://127.0.0.1/2 broker=redis://127.0.0.1?virtual_host=2
See :ref:`email configuration <mail-settings>` to learn more about configuring mail features. See :ref:`email configuration <mail-settings>` to learn more about configuring mail features.
Install pretix from PyPI Install pretix from source
------------------------ --------------------------
Now we will install pretix itself. The following steps are to be executed as the ``pretix`` user. Before we Now we will install pretix itself. The following steps are to be executed as the ``pretix`` user. Before we
actually install pretix, we will create a virtual environment to isolate the python packages from your global actually install pretix, we will create a virtual environment to isolate the python packages from your global
@@ -116,13 +116,14 @@ python installation::
$ source /var/pretix/venv/bin/activate $ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U pip setuptools wheel (venv)$ pip3 install -U pip setuptools wheel
We now install pretix, its direct dependencies and gunicorn. Replace ``mysql`` with ``postgres`` in the following We now clone pretix and install its Python dependencies (replace ``mysql`` with ``postgres`` if you're running
command if you're running PostgreSQL:: PostgreSQL)::
(venv)$ pip3 install "pretix[mysql]" gunicorn (venv)$ git clone https://github.com/pretix/pretix.git /var/pretix/source
(venv)$ cd /var/pretix/source/src
If you are running Python 3.4, you also need to ``pip3 install typing``. This is not required on 3.5 or newer. (venv)$ pip3 install -r requirements.txt -r requirements/mysql.txt \
You can find out your Python version using ``python -V``. -r requirements/redis.txt \
-r requirements/py34.txt gunicorn
We also need to create a data directory:: We also need to create a data directory::
@@ -130,8 +131,8 @@ We also need to create a data directory::
Finally, we compile static files and translation data and create the database structure:: Finally, we compile static files and translation data and create the database structure::
(venv)$ python -m pretix migrate (venv)$ make production
(venv)$ python -m pretix rebuild (venv)$ python manage.py migrate
Start pretix as a service Start pretix as a service
@@ -153,7 +154,7 @@ named ``/etc/systemd/system/pretix-web.service`` with the following content::
--name pretix --workers 5 \ --name pretix --workers 5 \
--max-requests 1200 --max-requests-jitter 50 \ --max-requests 1200 --max-requests-jitter 50 \
--log-level=info --bind=127.0.0.1:8345 --log-level=info --bind=127.0.0.1:8345
WorkingDirectory=/var/pretix WorkingDirectory=/var/pretix/source/src
Restart=on-failure Restart=on-failure
[Install] [Install]
@@ -170,8 +171,8 @@ For background tasks we need a second service ``/etc/systemd/system/pretix-worke
Group=pretix Group=pretix
Environment="VIRTUAL_ENV=/var/pretix/venv" Environment="VIRTUAL_ENV=/var/pretix/venv"
Environment="PATH=/var/pretix/venv/bin:/usr/local/bin:/usr/bin:/bin" Environment="PATH=/var/pretix/venv/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=/var/pretix/venv/bin/celery -A pretix.celery_app worker -l info ExecStart=/var/pretix/venv/bin/celery -A pretix worker -l info
WorkingDirectory=/var/pretix WorkingDirectory=/var/pretix/source/src
Restart=on-failure Restart=on-failure
[Install] [Install]
@@ -190,7 +191,7 @@ Cronjob
You need to set up a cronjob that runs the management command ``runperiodic``. The exact interval is not important 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:: 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 && python -m pretix runperiodic 15,45 * * * * export PATH=/var/pretix/venv/bin:$PATH && cd /var/pretix/source/src && ./manage.py runperiodic
The cronjob should run as the ``pretix`` user (``crontab -e -u pretix``). The cronjob should run as the ``pretix`` user (``crontab -e -u pretix``).
@@ -226,25 +227,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
access_log off; access_log off;
} }
location ^~ /media/cachedfiles {
deny all;
return 404;
}
location ^~ /media/invoices {
deny all;
return 404;
}
location /static/ { location /static/ {
alias /var/pretix/venv/lib/python3.5/site-packages/pretix/static.dist/; alias /var/pretix/source/src/pretix/static.dist/;
access_log off; access_log off;
expires 365d; expires 365d;
add_header Cache-Control "public"; 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. We recommend reading about setting `strong encryption settings`_ for your web server.
@@ -264,27 +254,16 @@ To upgrade to a new pretix release, pull the latest code changes and run the fol
``mysql`` with ``postgres`` if necessary):: ``mysql`` with ``postgres`` if necessary)::
$ source /var/pretix/venv/bin/activate $ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U pretix[mysql] gunicorn (venv)$ cd /var/pretix/source/src
(venv)$ python -m pretix migrate (venv)$ git pull origin master
(venv)$ python -m pretix rebuild (venv)$ pip3 install -r requirements.txt -r requirements/mysql.txt \
(venv)$ python -m pretix updatestyles -r requirements/redis.txt \
-r requirements/py34.txt gunicorn
(venv)$ python manage.py migrate
(venv)$ make production
(venv)$ python manage.py updatestyles
# systemctl restart pretix-web pretix-worker # systemctl restart pretix-web pretix-worker
Install a plugin
----------------
To install a plugin, just use ``pip``! Depending on the plugin, you should probably apply database migrations and
rebuild the static files afterwards. Replace ``pretix-passbook`` with the plugin of your choice in the following
example::
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install pretix-passbook
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
# systemctl restart pretix-web pretix-worker
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04 .. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/ .. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
.. _Let's Encrypt: https://letsencrypt.org/ .. _Let's Encrypt: https://letsencrypt.org/
-1
View File
@@ -38,7 +38,6 @@ extensions = [
'sphinx.ext.doctest', 'sphinx.ext.doctest',
'sphinx.ext.todo', 'sphinx.ext.todo',
'sphinx.ext.coverage', 'sphinx.ext.coverage',
'sphinxcontrib.httpdomain',
] ]
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
+5 -5
View File
@@ -11,7 +11,7 @@ Core
---- ----
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues :members: periodic_task
Order events Order events
"""""""""""" """"""""""""
@@ -19,13 +19,13 @@ Order events
There are multiple signals that will be sent out in the ordering cycle: There are multiple signals that will be sent out in the ordering cycle:
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: validate_cart, order_paid, order_placed :members: order_paid, order_placed
Frontend Frontend
-------- --------
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
:members: html_head, footer_links, front_page_top, front_page_bottom, checkout_confirm_messages :members: html_head, footer_links, front_page_top, front_page_bottom
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
@@ -47,11 +47,11 @@ Backend
------- -------
.. automodule:: pretix.control.signals .. automodule:: pretix.control.signals
:members: nav_event, html_head, quota_detail_html, nav_topbar, organizer_edit_tabs :members: nav_event, html_head, quota_detail_html
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: logentry_display, requiredaction_display :members: logentry_display
Vouchers Vouchers
"""""""" """"""""
+2
View File
@@ -70,6 +70,8 @@ The provider class
.. automethod:: is_allowed .. automethod:: is_allowed
.. automethod:: is_allowed_for_order
.. autoattribute:: payment_form_fields .. autoattribute:: payment_form_fields
.. automethod:: checkout_prepare .. automethod:: checkout_prepare
-9
View File
@@ -42,13 +42,6 @@ configuration class. The metadata class must define the following attributes:
``description`` (``str``): ``description`` (``str``):
A more verbose description of what your plugin does. A more verbose description of what your plugin does.
``visible`` (``bool``):
``True`` by default, can hide a plugin so it cannot be normally activated.
``restricted`` (``bool``):
``False`` by default, restricts a plugin such that it can only be enabled for an event
by system administrators / superusers.
A working example would be:: A working example would be::
# file: pretix/plugins/timerestriction/__init__.py # file: pretix/plugins/timerestriction/__init__.py
@@ -64,8 +57,6 @@ A working example would be::
name = _("PayPal") name = _("PayPal")
author = _("the pretix team") author = _("the pretix team")
version = '1.0.0' version = '1.0.0'
visible = True
restricted = False
description = _("This plugin allows you to receive payments via PayPal") description = _("This plugin allows you to receive payments via PayPal")
+2 -2
View File
@@ -64,6 +64,6 @@ The output class
.. automethod:: generate .. automethod:: generate
.. automethod:: generate_order
.. autoattribute:: download_button_text .. autoattribute:: download_button_text
.. autoattribute:: download_button_icon
+3 -3
View File
@@ -8,12 +8,12 @@ Python code
Use `flake8`_ to check for conformance problems. The project includes a setup.cfg file Use `flake8`_ to check for conformance problems. The project includes a setup.cfg file
with a default configuration for flake8 that excludes migrations and other non-relevant with a default configuration for flake8 that excludes migrations and other non-relevant
code parts. It also silences a few checks, e.g. ``N802`` (function names should be lowercase) code parts. It also silences a few checks, e.g. ``N802`` (function names should be lowercase)
and increases the maximum line length to more than 79 characters. **However** you should and increases the maximum line length to more than 79 characters. **However** you should
still name all your functions lowercase [#f1]_ and keep your lines short when possible. still name all your functions lowercase [#f1]_ and keep your lines short when possible.
* Our build server will reject all code violating other flake8 checks than the following: * Our build server will reject all code violating other flake8 checks than the following:
* E123: closing bracket does not match indentation of opening bracket’s line * E123: closing bracket does not match indentation of opening bracket’s line
* F403: ``from module import *`` used; unable to detect undefined names * F403: ``from module import *`` used; unable to detect undefined names
* F401: module imported but unused * F401: module imported but unused
+106 -10
View File
@@ -6,18 +6,116 @@ 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 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. or event descriptions, so we need event organizers to be able to fill in all fields in multiple languages.
For this purpose, we use ``django-i18nfield`` which started out as part of pretix and then got refactored into .. note:: Implementing object-level translation in a relational database is a task that requires some difficult
its own library. It has comprehensive documentation on how to work with its `strings`_, `database fields`_ and trade-off. We decided for a design that is not elegant on the database level (as it violates the `1NF`_) and
`forms`_. 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.
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 Forms
----- -----
For backwards-compatibility with older parts of pretix' code base and older plugins, ``pretix.base.forms`` still We provide i18n-aware versions of the respective form fields and widgets: ``I18nFormField`` with the ``I18nTextInput``
contains a number of forms that are equivalent in name and usage to their counterparts in ``i18nfield.forms`` with and ``I18nTextarea`` widgets. They transparently allow you to use ``LazyI18nString`` values in forms and render text
the difference that they take an ``event`` keyword argument and then set the ``locales`` argument based on inputs for multiple languages.
``event.settings.get('locales')``.
.. 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
Useful utilities Useful utilities
---------------- ----------------
@@ -37,6 +135,4 @@ action that causes the mail to be sent.
.. _translation features: https://docs.djangoproject.com/en/1.9/topics/i18n/translation/ .. _translation features: https://docs.djangoproject.com/en/1.9/topics/i18n/translation/
.. _GNU gettext: https://www.gnu.org/software/gettext/ .. _GNU gettext: https://www.gnu.org/software/gettext/
.. _strings: https://django-i18nfield.readthedocs.io/en/latest/strings.html .. _1NF: https://en.wikipedia.org/wiki/First_normal_form
.. _database fields: https://django-i18nfield.readthedocs.io/en/latest/quickstart.html
.. _forms: https://django-i18nfield.readthedocs.io/en/latest/forms.html
@@ -29,9 +29,6 @@ Organizers and events
.. autoclass:: pretix.base.models.EventPermission .. autoclass:: pretix.base.models.EventPermission
:members: :members:
.. autoclass:: pretix.base.models.RequiredAction
:members:
Items Items
----- -----
@@ -67,9 +64,6 @@ Carts and Orders
:members: :members:
.. autoclass:: pretix.base.models.QuestionAnswer .. autoclass:: pretix.base.models.QuestionAnswer
:members:
.. autoclass:: pretix.base.models.Checkin
:members: :members:
Logging Logging
+3 -6
View File
@@ -17,9 +17,6 @@ External Dependencies
* ``pyvenv`` for Python 3 (Debian package: ``python3-venv``) * ``pyvenv`` for Python 3 (Debian package: ``python3-venv``)
* ``libffi`` (Debian package: ``libffi-dev``) * ``libffi`` (Debian package: ``libffi-dev``)
* ``libssl`` (Debian package: ``libssl-dev``) * ``libssl`` (Debian package: ``libssl-dev``)
* ``libxml2`` (Debian package ``libxml2-dev``)
* ``libxslt`` (Debian package ``libxslt1-dev``)
* ``msgfmt`` (Debian package ``gettext``)
* ``git`` * ``git``
Your local python environment Your local python environment
@@ -80,7 +77,7 @@ and head to http://localhost:8000/
As we did not implement an overall front page yet, you need to go directly to As we did not implement an overall front page yet, you need to go directly to
http://localhost:8000/control/ for the admin view or, if you imported the test http://localhost:8000/control/ for the admin view or, if you imported the test
data as suggested above, to the event page at http://localhost:8000/bigevents/2017/ data as suggested above, to the event page at http://localhost:8000/mrmcd/2015/
.. _`checksandtests`: .. _`checksandtests`:
@@ -121,8 +118,8 @@ Then execute ``python -m smtpd -n -c DebuggingServer localhost:1025``.
Working with translations Working with translations
^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^
If you want to translate new strings that are not yet known to the translation system, If you want to translate new strings that are not yet known to the translation system,
you can use the following command to scan the source code for strings to be translated you can use the following command to scan the source code for strings to be translated
and update the ``*.po`` files accordingly:: and update the ``*.po`` files accordingly::
make localegen make localegen
-1
View File
@@ -8,5 +8,4 @@ Contents:
admin/index admin/index
development/index development/index
plugins/index
-13
View File
@@ -1,13 +0,0 @@
Plugin documentation
====================
This part of the documentation contains information about available plugins
that can be used to extend pretix's functionality.
If you want to **create** a plugin, please go to the
:ref:`Developer documentation <pluginsetup>` instead.
.. toctree::
:maxdepth: 2
list
pretixdroid
-34
View File
@@ -1,34 +0,0 @@
List of plugins
===============
The following plugins are shipped with pretix and are supported in the same
ways that pretix itself is:
* Bank transfer
* PayPal
* Stripe
* Check-in lists
* pretixdroid
* Report exporter
* Send out emails
* Statistics
* PDF ticket output
The following plugins are not shipped with pretix but are maintained by the
same team:
* `SEPA direct debit`_
* `Pages`_
* `Passbook/Wallet ticket output`_
* `Cartshare`_
The following plugins are from independent third-party authors, so we can make
no statements about their stability:
* `esPass ticket output`_
.. _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
-174
View File
@@ -1,174 +0,0 @@
pretixdroid HTTP API
====================
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
uses to communicate with the pretix server.
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
Redeems a ticket, i.e. checks the user in.
**Example request**:
.. sourcecode:: http
POST /pretixdroid/api/demoorga/democon/redeem/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
Content-Type: application/x-www-form-urlencoded
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3
**Example successful response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "ok"
"version": 2
}
**Example error response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "error",
"reason": "already_redeemed",
"version": 2
}
Possible error reasons:
* ``unpaid`` - Ticket is not paid for or has been refunded
* ``already_redeemed`` - Ticket already has been redeemed
* ``unknown_ticket`` - Secret does not match a ticket in the database
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/search/
Searches for a ticket.
At most 25 results will be returned. **Queries with less than 4 characters will always return an empty result set.**
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/search/?key=ABCDEF&query=Peter HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"results": [
{
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCE6",
"item": "Standard ticket",
"variation": null,
"attendee_name": "Peter Higgs",
"redeemed": false,
"paid": true
},
...
],
"version": 2
}
:query query: Search query
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/status/
Returns status information, such as the total number of tickets and the
number of performed checkins.
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/status/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"checkins": 17,
"total": 42,
"version": 2,
"event": {
"name": "Demo Converence",
"slug": "democon",
"date_from": "2016-12-27T17:00:00Z",
"date_to": "2016-12-30T18:00:00Z",
"timezone": "UTC",
"url": "https://demo.pretix.eu/demoorga/democon/",
"organizer": {
"name": "Demo Organizer",
"slug": "demoorga"
},
},
"items": [
{
"name": "T-Shirt",
"id": 1,
"checkins": 1,
"admission": False,
"total": 1,
"variations": [
{
"name": "Red",
"id": 1,
"checkins": 1,
"total": 12
},
{
"name": "Blue",
"id": 2,
"checkins": 4,
"total": 8
}
]
},
{
"name": "Ticket",
"id": 2,
"checkins": 15,
"admission": True,
"total": 22,
"variations": []
}
]
}
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. _pretixdroid Android app: https://github.com/pretix/pretixdroid
-1
View File
@@ -1,4 +1,3 @@
-r ../src/requirements.txt -r ../src/requirements.txt
sphinx sphinx
sphinx-rtd-theme sphinx-rtd-theme
sphinxcontrib-httpdomain
+2 -1
View File
@@ -7,4 +7,5 @@ build/
dist/ dist/
*.egg-info/ *.egg-info/
*.bak *.bak
pretix/static/jsi18n/ static/jsi18n/
-2
View File
@@ -1,13 +1,11 @@
include LICENSE include LICENSE
include README.rst include README.rst
recursive-include pretix/static *
recursive-include pretix/static.dist * recursive-include pretix/static.dist *
recursive-include pretix/locale * recursive-include pretix/locale *
recursive-include pretix/base/templates * recursive-include pretix/base/templates *
recursive-include pretix/control/templates * recursive-include pretix/control/templates *
recursive-include pretix/presale/templates * recursive-include pretix/presale/templates *
recursive-include pretix/plugins/banktransfer/templates * recursive-include pretix/plugins/banktransfer/templates *
recursive-include pretix/plugins/banktransfer/static *
recursive-include pretix/plugins/paypal/templates * recursive-include pretix/plugins/paypal/templates *
recursive-include pretix/plugins/pretixdroid/templates * recursive-include pretix/plugins/pretixdroid/templates *
recursive-include pretix/plugins/pretixdroid/static * recursive-include pretix/plugins/pretixdroid/static *
+1 -1
View File
@@ -6,7 +6,7 @@ localecompile:
localegen: localegen:
./manage.py makemessages --all --ignore "pretix/helpers/*" ./manage.py makemessages --all --ignore "pretix/helpers/*"
./manage.py makemessages --all -d djangojs --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "build/*" ./manage.py makemessages --all -d djangojs --ignore "pretix/helpers/*"
staticfiles: jsi18n staticfiles: jsi18n
./manage.py collectstatic --noinput ./manage.py collectstatic --noinput
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.1.3" __version__ = "0.0.0"
+1 -5
View File
@@ -12,13 +12,9 @@ class PretixBaseConfig(AppConfig):
from .services import export, mail, tickets, cart, orders, cleanup # NOQA from .services import export, mail, tickets, cart, orders, cleanup # NOQA
try: try:
from .celery_app import app as celery_app # NOQA from .celery import app as celery_app # NOQA
except ImportError: except ImportError:
pass pass
default_app_config = 'pretix.base.PretixBaseConfig' default_app_config = 'pretix.base.PretixBaseConfig'
try:
import pretix.celery_app as celery # NOQA
except ImportError:
pass
+7 -9
View File
@@ -7,7 +7,6 @@ import pytz
from django import forms from django import forms
from django.db.models import Sum from django.db.models import Sum
from django.dispatch import receiver 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 _
from pretix.base.models import InvoiceAddress, Order, OrderPosition from pretix.base.models import InvoiceAddress, Order, OrderPosition
@@ -51,7 +50,7 @@ class OrderListExporter(BaseExporter):
tz = pytz.timezone(self.event.settings.timezone) tz = pytz.timezone(self.event.settings.timezone)
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",") writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
qs = self.event.orders.all().select_related('invoice_address').prefetch_related('invoices') qs = self.event.orders.all().select_related('invoice_address')
if form_data['paid_only']: if form_data['paid_only']:
qs = qs.filter(status=Order.STATUS_PAID) qs = qs.filter(status=Order.STATUS_PAID)
tax_rates = self._get_all_tax_rates(qs) tax_rates = self._get_all_tax_rates(qs)
@@ -59,7 +58,7 @@ class OrderListExporter(BaseExporter):
headers = [ headers = [
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'), _('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
_('Payment date'), _('Payment type'), _('Payment method fee'), _('Invoice numbers') _('Payment date'), _('Payment type'), _('Payment method fee')
] ]
for tr in tax_rates: for tr in tax_rates:
@@ -87,7 +86,7 @@ class OrderListExporter(BaseExporter):
for order in qs.order_by('datetime'): for order in qs.order_by('datetime'):
row = [ row = [
order.code, order.code,
localize(order.total), str(order.total),
order.get_status_display(), order.get_status_display(),
order.email, order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'), order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
@@ -108,7 +107,7 @@ class OrderListExporter(BaseExporter):
row += [ row += [
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '', order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
provider_names.get(order.payment_provider, order.payment_provider), provider_names.get(order.payment_provider, order.payment_provider),
localize(order.payment_fee) str(order.payment_fee)
] ]
for tr in tax_rates: for tr in tax_rates:
@@ -118,12 +117,11 @@ class OrderListExporter(BaseExporter):
taxrate_values['taxsum'] += order.payment_fee_tax_value taxrate_values['taxsum'] += order.payment_fee_tax_value
row += [ row += [
localize(taxrate_values['grosssum']), str(taxrate_values['grosssum']),
localize(taxrate_values['grosssum'] - taxrate_values['taxsum']), str(taxrate_values['grosssum'] - taxrate_values['taxsum']),
localize(taxrate_values['taxsum']), str(taxrate_values['taxsum']),
] ]
row.append(', '.join([i.number for i in order.invoices.all()]))
writer.writerow(row) writer.writerow(row)
return 'orders.csv', 'text/csv', output.getvalue().encode("utf-8") return 'orders.csv', 'text/csv', output.getvalue().encode("utf-8")
+55 -22
View File
@@ -1,55 +1,88 @@
import logging import logging
import i18nfield.forms
from django import forms from django import forms
from django.core.files import File from django.core.files import File
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from django.forms.models import ModelFormMetaclass from django.forms.models import (
BaseInlineFormSet, BaseModelForm, BaseModelFormSet, ModelFormMetaclass,
)
from django.utils import six from django.utils import six
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from pretix.base.i18n import I18nFormField
from pretix.base.models import Event from pretix.base.models import Event
logger = logging.getLogger('pretix.plugins.ticketoutputpdf') logger = logging.getLogger('pretix.plugins.ticketoutputpdf')
class BaseI18nModelForm(i18nfield.forms.BaseI18nModelForm): class BaseI18nModelForm(BaseModelForm):
# compatibility shim for django-i18nfield library """
This is a helperclass to construct an I18nModelForm.
"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
event = kwargs.pop('event', None) event = kwargs.pop('event', None)
if event:
kwargs['locales'] = event.settings.get('locales')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if event:
for k, field in self.fields.items():
if isinstance(field, I18nFormField):
field.widget.enabled_langcodes = event.settings.get('locales')
class I18nModelForm(six.with_metaclass(ModelFormMetaclass, BaseI18nModelForm)): 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 pass
class I18nFormSet(i18nfield.forms.I18nModelFormSet): class I18nFormSet(BaseModelFormSet):
# compatibility shim for django-i18nfield library """
This is equivalent to a normal BaseModelFormset, but cares for the special needs
of I18nForms (see there for more information).
"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
event = kwargs.pop('event', None) self.event = kwargs.pop('event', None)
if event:
kwargs['locales'] = event.settings.get('locales')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
return super()._construct_form(i, **kwargs)
class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet): @property
# compatibility shim for django-i18nfield library 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).
"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
event = kwargs.pop('event', None) self.event = kwargs.pop('event', None)
if event:
kwargs['locales'] = event.settings.get('locales')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
return super()._construct_form(i, **kwargs)
class SettingsForm(i18nfield.forms.I18nForm):
class SettingsForm(forms.Form):
""" """
This form is meant to be used for modifying EventSettings or OrganizerSettings. It takes 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 care of loading the current values of the fields and saving the field inputs to the
@@ -58,18 +91,18 @@ class SettingsForm(i18nfield.forms.I18nForm):
:param obj: The event or organizer object which should be used for the settings storage :param obj: The event or organizer object which should be used for the settings storage
""" """
BOOL_CHOICES = ( BOOL_CHOICES = (
('False', _('disabled')), ('False', _('disabled')),
('True', _('enabled')), ('True', _('enabled')),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.obj = kwargs.pop('obj', None) self.obj = kwargs.pop('obj')
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() kwargs['initial'] = self.obj.settings.freeze()
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for k, field in self.fields.items():
if isinstance(field, I18nFormField):
field.widget.enabled_langcodes = self.obj.settings.get('locales')
def save(self): def save(self):
""" """
+2 -1
View File
@@ -39,7 +39,8 @@ class UserSettingsForm(forms.ModelForm):
class Meta: class Meta:
model = User model = User
fields = [ fields = [
'fullname', 'givenname',
'familyname',
'locale', 'locale',
# 'timezone', # 'timezone',
'email' 'email'
+330 -7
View File
@@ -1,16 +1,339 @@
import copy
import json
from contextlib import contextmanager from contextlib import contextmanager
from typing import Dict, List, Optional, Union
from django import forms
from django.conf import settings 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 import translation
from django.utils.formats import date_format, number_format from django.utils.formats import date_format, number_format
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext from django.utils.translation import ugettext
from i18nfield.fields import ( # noqa
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
) class LazyI18nString:
from i18nfield.forms import I18nFormField # noqa """
# Compatibility imports This represents an internationalized string that is/was/will be stored in the database.
from i18nfield.strings import LazyI18nString # noqa """
from i18nfield.utils import I18nJSONEncoder # noqa
def __init__(self, data: Optional[Union[str, Dict[str, str]]]):
"""
Creates a new i18n-aware string.
:param data: If this is a dictionary, it is expected to map language codes to translations.
If this is a string that can be parsed as JSON, it will be parsed and used as such a dictionary.
If this is anything else, it will be cast to a string and used for all languages.
"""
self.data = data
if isinstance(self.data, str) and self.data is not None:
try:
j = json.loads(self.data)
except ValueError:
pass
else:
self.data = j
def __str__(self) -> str:
"""
Evaluate the given string with respect to the currently active locale.
If no string is available in the currently active language, this will give you
the string in the system's default language. If this is unavailable as well, it
will give you the string in the first language available.
"""
return self.localize(translation.get_language() or settings.LANGUAGE_CODE)
def __bool__(self):
if not self.data:
return False
if isinstance(self.data, dict):
return any(self.data.values())
return True
def localize(self, lng: str) -> str:
"""
Evaluate the given string with respect to the locale defined by ``lng``.
If no string is available in the currently active language, this will give you
the string in the system's default language. If this is unavailable as well, it
will give you the string in the first language available.
:param lng: A locale code, e.g. ``de``. If you specify a code including a country
or region like ``de-AT``, exact matches will be used preferably, but if only
a ``de`` or ``de-AT`` translation exists, this might be returned as well.
"""
if self.data is None:
return ""
if isinstance(self.data, dict):
firstpart = lng.split('-')[0]
similar = [l for l in self.data.keys() if l.startswith(firstpart + "-") or firstpart == l]
if lng in self.data and self.data[lng]:
return self.data[lng]
elif firstpart in self.data:
return self.data[firstpart]
elif similar:
return self.data[similar[0]]
elif settings.LANGUAGE_CODE in self.data and self.data[settings.LANGUAGE_CODE]:
return self.data[settings.LANGUAGE_CODE]
elif len(self.data):
return list(self.data.items())[0][1]
else:
return ""
else:
return str(self.data)
def __repr__(self) -> str: # NOQA
return '<LazyI18nString: %s>' % repr(self.data)
def __lt__(self, other) -> bool: # NOQA
return str(self) < str(other)
def __format__(self, format_spec):
return self.__str__()
def __eq__(self, other):
if other is None:
return False
if hasattr(other, 'data'):
return self.data == other.data
return self.data == other
class LazyGettextProxy:
def __init__(self, lazygettext):
self.lazygettext = lazygettext
def __getitem__(self, item):
with language(item):
return str(ugettext(self.lazygettext))
def __contains__(self, item):
return True
def __str__(self):
return str(ugettext(self.lazygettext))
@classmethod
def from_gettext(cls, lazygettext):
l = LazyI18nString({})
l.data = cls.LazyGettextProxy(lazygettext)
return l
class I18nWidget(forms.MultiWidget):
"""
The default form widget for I18nCharField and I18nTextField. It makes
use of Django's MultiWidget mechanism and does some magic to save you
time.
"""
widget = forms.TextInput
def __init__(self, langcodes: List[str], field: forms.Field, attrs=None):
widgets = []
self.langcodes = langcodes
self.enabled_langcodes = langcodes
self.field = field
for lng in self.langcodes:
a = copy.copy(attrs) or {}
a['lang'] = lng
widgets.append(self.widget(attrs=a))
super().__init__(widgets, attrs)
def decompress(self, value):
data = []
first_enabled = None
any_filled = False
if not isinstance(value, LazyI18nString):
value = LazyI18nString(value)
for i, lng in enumerate(self.langcodes):
dataline = (
value.data[lng]
if value is not None and (
isinstance(value.data, dict) or isinstance(value.data, LazyI18nString.LazyGettextProxy)
) and lng in value.data
else None
)
any_filled = any_filled or (lng in self.enabled_langcodes and dataline)
if not first_enabled and lng in self.enabled_langcodes:
first_enabled = i
data.append(dataline)
if value and not isinstance(value.data, dict):
data[first_enabled] = value.data
return data
def render(self, name, value, attrs=None):
if self.is_localized:
for widget in self.widgets:
widget.is_localized = self.is_localized
# value is a list of values, each corresponding to a widget
# in self.widgets.
if not isinstance(value, list):
value = self.decompress(value)
output = []
final_attrs = self.build_attrs(attrs)
id_ = final_attrs.get('id', None)
for i, widget in enumerate(self.widgets):
if self.langcodes[i] not in self.enabled_langcodes:
continue
try:
widget_value = value[i]
except IndexError:
widget_value = None
if id_:
final_attrs = dict(
final_attrs,
id='%s_%s' % (id_, i),
title=self.langcodes[i]
)
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs))
return mark_safe(self.format_output(output))
def format_output(self, rendered_widgets):
return '<div class="i18n-form-group">%s</div>' % super().format_output(rendered_widgets)
class I18nTextInput(I18nWidget):
widget = forms.TextInput
class I18nTextarea(I18nWidget):
widget = forms.Textarea
class I18nFormField(forms.MultiValueField):
"""
The form field that is used by I18nCharField and I18nTextField. It makes use
of Django's MultiValueField mechanism to create one sub-field per available
language.
It contains special treatment to make sure that a field marked as "required" is validated
as "filled out correctly" if *at least one* translation is filled it. It is never required
to fill in all of them. This has the drawback that the HTML property ``required`` is set on
none of the fields as this would lead to irritating behaviour.
:param langcodes: An iterable of locale codes that the widget should render a field for. If
omitted, fields will be rendered for all languages supported by pretix.
"""
def compress(self, data_list):
langcodes = self.langcodes
data = {}
for i, value in enumerate(data_list):
data[langcodes[i]] = value
return LazyI18nString(data)
def clean(self, value):
if isinstance(value, LazyI18nString):
# This happens e.g. if the field is disabled
return value
found = False
clean_data = []
errors = []
for i, field in enumerate(self.fields):
try:
field_value = value[i]
except IndexError:
field_value = None
if field_value not in self.empty_values:
found = True
try:
clean_data.append(field.clean(field_value))
except forms.ValidationError as e:
# Collect all validation errors in a single list, which we'll
# raise at the end of clean(), rather than raising a single
# exception for the first error we encounter. Skip duplicates.
errors.extend(m for m in e.error_list if m not in errors)
if errors:
raise forms.ValidationError(errors)
if self.one_required and not found:
raise forms.ValidationError(self.error_messages['required'], code='required')
out = self.compress(clean_data)
self.validate(out)
self.run_validators(out)
return out
def __init__(self, *args, **kwargs):
fields = []
defaults = {
'widget': self.widget,
'max_length': kwargs.pop('max_length', None),
}
self.langcodes = kwargs.pop('langcodes', [l[0] for l in settings.LANGUAGES])
self.one_required = kwargs.get('required', True)
kwargs['required'] = False
kwargs['widget'] = kwargs['widget'](
langcodes=self.langcodes, field=self, **kwargs.pop('widget_kwargs', {})
)
defaults.update(**kwargs)
for lngcode in self.langcodes:
defaults['label'] = '%s (%s)' % (defaults.get('label'), lngcode)
fields.append(forms.CharField(**defaults))
super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs
)
class I18nFieldMixin:
form_class = I18nFormField
widget = I18nTextInput
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event', None)
super().__init__(*args, **kwargs)
def to_python(self, value):
if isinstance(value, LazyI18nString):
return value
return LazyI18nString(value)
def get_prep_value(self, value):
if isinstance(value, LazyI18nString):
value = value.data
if isinstance(value, dict):
return json.dumps({k: v for k, v in value.items() if v}, sort_keys=True)
return value
def get_prep_lookup(self, lookup_type, value): # NOQA
raise TypeError('Lookups on i18n string currently not supported.')
def from_db_value(self, value, expression, connection, context):
return LazyI18nString(value)
def formfield(self, **kwargs):
defaults = {'form_class': self.form_class, 'widget': self.widget}
defaults.update(kwargs)
return super().formfield(**defaults)
class I18nCharField(I18nFieldMixin, TextField):
"""
A CharField which takes internationalized data. Internally, a TextField dabase
field is used to store JSON. If you interact with this field, you will work
with LazyI18nString instances.
"""
widget = I18nTextInput
class I18nTextField(I18nFieldMixin, TextField):
"""
Like I18nCharField, but for TextFields.
"""
widget = I18nTextarea
class I18nJSONEncoder(DjangoJSONEncoder):
def default(self, obj):
if isinstance(obj, LazyI18nString):
return obj.data
elif isinstance(obj, QuerySet):
return list(obj)
elif isinstance(obj, Model):
return {'type': obj.__class__.__name__, 'id': obj.id}
else:
return super().default(obj)
class LazyDate: class LazyDate:
@@ -1,12 +0,0 @@
from django.core.management import call_command
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Rebuild static files and language files"
def handle(self, *args, **options):
call_command('compilemessages', verbosity=1, interactive=False)
call_command('compilejsi18n', verbosity=1, interactive=False)
call_command('collectstatic', verbosity=1, interactive=False)
call_command('compress', verbosity=1, interactive=False)
@@ -1,658 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-03 14:20
from __future__ import unicode_literals
import datetime
import uuid
from decimal import Decimal
import django.core.validators
import django.db.models.deletion
import i18nfield.fields
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.db import migrations, models
import pretix.base.models.base
import pretix.base.models.invoices
import pretix.base.models.items
import pretix.base.models.orders
import pretix.base.models.vouchers
def initial_user(apps, schema_editor):
User = apps.get_model("pretixbase", "User")
user = User(email='admin@localhost')
user.is_staff = True
user.is_superuser = True
user.password = make_password('admin')
user.save()
class Migration(migrations.Migration):
replaces = [('pretixbase', '0001_initial'), ('pretixbase', '0002_auto_20160209_0940'), ('pretixbase', '0003_eventpermission_can_change_vouchers'), ('pretixbase', '0004_auto_20160209_1023'), ('pretixbase', '0005_auto_20160211_1459'), ('pretixbase', '0006_auto_20160211_1630'), ('pretixbase', '0007_auto_20160211_1710'), ('pretixbase', '0008_invoiceaddress'), ('pretixbase', '0009_auto_20160222_2002'), ('pretixbase', '0010_orderposition_secret'), ('pretixbase', '0011_auto_20160311_2052'), ('pretixbase', '0012_auto_20160312_1040'), ('pretixbase', '0013_invoice_locale'), ('pretixbase', '0014_invoice_additional_text'), ('pretixbase', '0015_auto_20160312_1924'), ('pretixbase', '0016_voucher_variation'), ('pretixbase', '0017_auto_20160324_1615'), ('pretixbase', '0018_auto_20160326_1104'), ('pretixbase', '0019_auto_20160326_1139'), ('pretixbase', '0020_auto_20160418_2106'), ('pretixbase', '0021_auto_20160418_2117'), ('pretixbase', '0020_auto_20160421_1943'), ('pretixbase', '0022_merge'), ('pretixbase', '0023_auto_20160601_1039'), ('pretixbase', '0024_auto_20160728_1725'), ('pretixbase', '0025_auto_20160802_2202'), ('pretixbase', '0026_order_comment'), ('pretixbase', '0027_auto_20160815_1254'), ('pretixbase', '0028_auto_20160816_1242')]
initial = True
dependencies = [
('auth', '0006_require_contenttypes_0002'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='E-mail')),
('givenname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Given name')),
('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')),
('is_active', models.BooleanField(default=True, verbose_name='Is active')),
('is_staff', models.BooleanField(default=False, verbose_name='Is site admin')),
('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='Date joined')),
('locale', models.CharField(choices=[('en', 'English'), ('de', 'German'), ('de-informal', 'German (informal)')], default='en', max_length=50, verbose_name='Language')),
('timezone', models.CharField(default='UTC', max_length=100, verbose_name='Timezone')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'User',
'verbose_name_plural': 'Users',
},
),
migrations.RunPython(
code=initial_user,
),
migrations.CreateModel(
name='CachedFile',
fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('expires', models.DateTimeField(blank=True, null=True)),
('date', models.DateTimeField(blank=True, null=True)),
('filename', models.CharField(max_length=255)),
('type', models.CharField(max_length=255)),
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.base.cachedfile_name)),
],
),
migrations.CreateModel(
name='CachedTicket',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider', models.CharField(max_length=255)),
('cachedfile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.CachedFile')),
],
),
migrations.CreateModel(
name='CartPosition',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Price')),
('attendee_name', models.CharField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=255, null=True, verbose_name='Attendee name')),
('voucher_discount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('base_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('cart_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Cart ID (e.g. session key)')),
('datetime', models.DateTimeField(auto_now_add=True, verbose_name='Date')),
('expires', models.DateTimeField(verbose_name='Expiration date')),
],
options={
'verbose_name': 'Cart position',
'verbose_name_plural': 'Cart positions',
},
),
migrations.CreateModel(
name='Event',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')),
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
('currency', models.CharField(default='EUR', max_length=10, verbose_name='Default currency')),
('date_from', models.DateTimeField(verbose_name='Event start time')),
('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')),
('is_public', models.BooleanField(default=False, help_text="If selected, this event may show up on the ticket system's start page or an organization profile.", verbose_name='Visible in public lists')),
('presale_end', models.DateTimeField(blank=True, help_text='No products will be sold after this date.', null=True, verbose_name='End of presale')),
('presale_start', models.DateTimeField(blank=True, help_text='No products will be sold before this date.', null=True, verbose_name='Start of presale')),
('plugins', models.TextField(blank=True, null=True, verbose_name='Plugins')),
],
options={
'verbose_name': 'Event',
'ordering': ('date_from', 'name'),
'verbose_name_plural': 'Events',
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='EventLock',
fields=[
('event', models.CharField(max_length=36, primary_key=True, serialize=False)),
('date', models.DateTimeField(auto_now=True)),
('token', models.UUIDField(default=uuid.uuid4)),
],
),
migrations.CreateModel(
name='EventPermission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('can_change_settings', models.BooleanField(default=True, verbose_name='Can change event settings')),
('can_change_items', models.BooleanField(default=True, verbose_name='Can change product settings')),
('can_view_orders', models.BooleanField(default=True, verbose_name='Can view orders')),
('can_change_permissions', models.BooleanField(default=True, verbose_name='Can change permissions')),
('can_change_orders', models.BooleanField(default=True, verbose_name='Can change orders')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_perms', to='pretixbase.Event')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_perms', to=settings.AUTH_USER_MODEL)),
('can_change_vouchers', models.BooleanField(default=True, verbose_name='Can change vouchers')),
],
options={
'verbose_name': 'Event permission',
'verbose_name_plural': 'Event permissions',
},
),
migrations.CreateModel(
name='EventSetting',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=255)),
('value', models.TextField()),
('object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='setting_objects', to='pretixbase.Event')),
],
),
migrations.CreateModel(
name='Item',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Item name')),
('active', models.BooleanField(default=True, verbose_name='Active')),
('description', i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the product name in lists.', null=True, verbose_name='Description')),
('default_price', models.DecimalField(decimal_places=2, max_digits=7, null=True, verbose_name='Default price')),
('tax_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Taxes included in percent')),
('admission', models.BooleanField(default=False, help_text='Whether or not buying this product allows a person to enter your event', verbose_name='Is an admission ticket')),
('position', models.IntegerField(default=0)),
('picture', models.ImageField(blank=True, null=True, upload_to=pretix.base.models.items.itempicture_upload_to, verbose_name='Product picture')),
('available_from', models.DateTimeField(blank=True, help_text='This product will not be sold before the given date.', null=True, verbose_name='Available from')),
('available_until', models.DateTimeField(blank=True, help_text='This product will not be sold after the given date.', null=True, verbose_name='Available until')),
],
options={
'verbose_name': 'Product',
'ordering': ('category__position', 'category', 'position'),
'verbose_name_plural': 'Products',
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='ItemCategory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Category name')),
('position', models.IntegerField(default=0)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='pretixbase.Event')),
('description', i18nfield.fields.I18nTextField(blank=True, verbose_name='Category description')),
],
options={
'verbose_name': 'Product category',
'ordering': ('position', 'id'),
'verbose_name_plural': 'Product categories',
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='ItemVariation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Description')),
('active', models.BooleanField(default=True, verbose_name='Active')),
('position', models.PositiveIntegerField(default=0, verbose_name='Position')),
('default_price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Default price')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variations', to='pretixbase.Item')),
],
options={
'verbose_name': 'Product variation',
'ordering': ('position', 'id'),
'verbose_name_plural': 'Product variations',
},
),
migrations.CreateModel(
name='LogEntry',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('datetime', models.DateTimeField(auto_now_add=True)),
('action_type', models.CharField(max_length=255)),
('data', models.TextField(default='{}')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=16, verbose_name='Order code')),
('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')),
('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')),
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
('datetime', models.DateTimeField(verbose_name='Date')),
('expires', models.DateTimeField(verbose_name='Expiration date')),
('payment_date', models.DateTimeField(blank=True, null=True, verbose_name='Payment date')),
('payment_provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')),
('payment_fee', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Payment method fee')),
('payment_info', models.TextField(blank=True, null=True, verbose_name='Payment information')),
('payment_manual', models.BooleanField(default=False, verbose_name='Payment state was manually modified')),
('total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Total amount')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='pretixbase.Event', verbose_name='Event')),
],
options={
'verbose_name': 'Order',
'ordering': ('-datetime',),
'verbose_name_plural': 'Orders',
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='OrderPosition',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Price')),
('attendee_name', models.CharField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=255, null=True, verbose_name='Attendee name')),
('voucher_discount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('base_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Item', verbose_name='Item')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='positions', to='pretixbase.Order', verbose_name='Order')),
('variation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.ItemVariation', verbose_name='Variation')),
],
options={
'verbose_name': 'Order position',
'verbose_name_plural': 'Order positions',
},
),
migrations.CreateModel(
name='Organizer',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Name')),
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
],
options={
'verbose_name': 'Organizer',
'ordering': ('name',),
'verbose_name_plural': 'Organizers',
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='OrganizerPermission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('can_create_events', models.BooleanField(default=True, verbose_name='Can create events')),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_perms', to='pretixbase.Organizer')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizer_perms', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Organizer permission',
'verbose_name_plural': 'Organizer permissions',
},
),
migrations.CreateModel(
name='OrganizerSetting',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=255)),
('value', models.TextField()),
('object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='setting_objects', to='pretixbase.Organizer')),
],
),
migrations.CreateModel(
name='Question',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question', i18nfield.fields.I18nTextField(verbose_name='Question')),
('type', models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No')], max_length=5, verbose_name='Question type')),
('required', models.BooleanField(default=False, verbose_name='Required question')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='pretixbase.Event')),
('items', models.ManyToManyField(blank=True, help_text='This question will be asked to buyers of the selected products', related_name='questions', to='pretixbase.Item', verbose_name='Products')),
],
options={
'verbose_name': 'Question',
'verbose_name_plural': 'Questions',
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='QuestionAnswer',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('answer', models.TextField()),
('cartposition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.CartPosition')),
('orderposition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.OrderPosition')),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.Question')),
],
),
migrations.CreateModel(
name='Quota',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Name')),
('size', models.PositiveIntegerField(blank=True, help_text='Leave empty for an unlimited number of tickets.', null=True, verbose_name='Total capacity')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quotas', to='pretixbase.Event', verbose_name='Event')),
('items', models.ManyToManyField(blank=True, related_name='quotas', to='pretixbase.Item', verbose_name='Item')),
('variations', models.ManyToManyField(blank=True, related_name='quotas', to='pretixbase.ItemVariation', verbose_name='Variations')),
],
options={
'verbose_name': 'Quota',
'verbose_name_plural': 'Quotas',
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='Voucher',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=255, verbose_name='Voucher code')),
('valid_until', models.DateTimeField(blank=True, null=True, verbose_name='Valid until')),
('block_quota', models.BooleanField(default=False, help_text="If activated, this voucher will be substracted from the affected product's quotas, such that it is guaranteed that anyone with this voucher code does receive a ticket.", verbose_name='Reserve ticket from quota')),
('allow_ignore_quota', models.BooleanField(default=False, help_text='If activated, a holder of this voucher code can buy tickets, even if there are none left.', verbose_name='Allow to bypass quota')),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Set product price to')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.Event', verbose_name='Event')),
],
options={
'verbose_name': 'Voucher',
'verbose_name_plural': 'Vouchers',
},
),
migrations.AddField(
model_name='organizer',
name='permitted',
field=models.ManyToManyField(related_name='organizers', through='pretixbase.OrganizerPermission', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='orderposition',
name='voucher',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher'),
),
migrations.AddField(
model_name='item',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='items', to='pretixbase.ItemCategory', verbose_name='Category'),
),
migrations.AddField(
model_name='item',
name='event',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='items', to='pretixbase.Event', verbose_name='Event'),
),
migrations.AddField(
model_name='event',
name='organizer',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='pretixbase.Organizer'),
),
migrations.AddField(
model_name='event',
name='permitted',
field=models.ManyToManyField(related_name='events', through='pretixbase.EventPermission', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='cartposition',
name='event',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event', verbose_name='Event'),
),
migrations.AddField(
model_name='cartposition',
name='item',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Item', verbose_name='Item'),
),
migrations.AddField(
model_name='cartposition',
name='variation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.ItemVariation', verbose_name='Variation'),
),
migrations.AddField(
model_name='cartposition',
name='voucher',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher'),
),
migrations.AddField(
model_name='cachedticket',
name='order',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order'),
),
migrations.AddField(
model_name='voucher',
name='item',
field=models.ForeignKey(blank=True, help_text="This product is added to the user's cart if the voucher is redeemed.", null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.Item', verbose_name='Product'),
),
migrations.AlterField(
model_name='voucher',
name='price',
field=models.DecimalField(blank=True, decimal_places=2, help_text='If empty, the product will cost its normal price.', max_digits=10, null=True, verbose_name='Set product price to'),
),
migrations.AddField(
model_name='voucher',
name='redeemed',
field=models.BooleanField(default=False, verbose_name='Redeemed'),
),
migrations.AlterUniqueTogether(
name='voucher',
unique_together=set([('event', 'code')]),
),
migrations.AlterField(
model_name='voucher',
name='code',
field=models.CharField(default=pretix.base.models.vouchers.generate_code, max_length=255, verbose_name='Voucher code'),
),
migrations.AddField(
model_name='orderposition',
name='tax_rate',
field=models.DecimalField(decimal_places=2, default=0, max_digits=7, verbose_name='Tax rate'),
preserve_default=False,
),
migrations.AddField(
model_name='orderposition',
name='tax_value',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Tax value'),
preserve_default=False,
),
migrations.AlterField(
model_name='item',
name='tax_rate',
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=7, verbose_name='Taxes included in percent'),
),
migrations.CreateModel(
name='InvoiceAddress',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_modified', models.DateTimeField(auto_now=True)),
('company', models.CharField(blank=True, max_length=255, verbose_name='Company name')),
('name', models.CharField(blank=True, max_length=255, verbose_name='Full name')),
('street', models.TextField(verbose_name='Address')),
('zipcode', models.CharField(max_length=30, verbose_name='ZIP code')),
('city', models.CharField(max_length=255, verbose_name='City')),
('country', models.CharField(max_length=255, verbose_name='Country')),
('vat_id', models.CharField(blank=True, max_length=255, verbose_name='VAT ID')),
('order', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invoice_address', to='pretixbase.Order')),
],
),
migrations.AddField(
model_name='event',
name='live',
field=models.BooleanField(default=False, verbose_name='Shop is live'),
),
migrations.AddField(
model_name='orderposition',
name='secret',
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_position_secret, max_length=64),
),
migrations.AddField(
model_name='order',
name='payment_fee_tax_rate',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Payment method fee tax rate'),
preserve_default=False,
),
migrations.AddField(
model_name='order',
name='payment_fee_tax_value',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Payment method fee tax'),
),
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invoice_no', models.PositiveIntegerField(db_index=True)),
('is_cancelled', models.BooleanField(default=False)),
('invoice_from', models.TextField()),
('invoice_to', models.TextField()),
('date', models.DateField(default=datetime.date.today)),
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.invoices.invoice_filename)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='pretixbase.Event')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='pretixbase.Order')),
],
),
migrations.CreateModel(
name='InvoiceLine',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField()),
('gross_value', models.DecimalField(decimal_places=2, max_digits=10)),
('tax_value', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('tax_rate', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=7)),
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='pretixbase.Invoice')),
],
),
migrations.AddField(
model_name='invoice',
name='locale',
field=models.CharField(default='en', max_length=50),
),
migrations.AddField(
model_name='invoice',
name='additional_text',
field=models.TextField(blank=True),
),
migrations.RemoveField(
model_name='invoice',
name='is_cancelled',
),
migrations.AddField(
model_name='invoice',
name='is_cancellation',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='invoice',
name='refers',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='refered', to='pretixbase.Invoice'),
),
migrations.AlterUniqueTogether(
name='invoice',
unique_together=set([('event', 'invoice_no')]),
),
migrations.AddField(
model_name='voucher',
name='variation',
field=models.ForeignKey(blank=True, help_text='This variation of the product select above is being used.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.ItemVariation', verbose_name='Product variation'),
),
migrations.AlterModelOptions(
name='logentry',
options={'ordering': ('-datetime',)},
),
migrations.AddField(
model_name='item',
name='free_price',
field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event.', verbose_name='Free price input'),
),
migrations.CreateModel(
name='QuestionOption',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('answer', i18nfield.fields.I18nCharField(verbose_name='Answer')),
],
),
migrations.AlterField(
model_name='question',
name='type',
field=models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No'), ('C', 'Choose one from a list'), ('M', 'Choose multiple from a list')], max_length=5, verbose_name='Question type'),
),
migrations.AddField(
model_name='questionoption',
name='question',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='pretixbase.Question'),
),
migrations.AddField(
model_name='questionanswer',
name='options',
field=models.ManyToManyField(blank=True, related_name='answers', to='pretixbase.QuestionOption'),
),
migrations.AddField(
model_name='voucher',
name='quota',
field=models.ForeignKey(blank=True, help_text='If enabled, the voucher is valid for any product affected by this quota.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quota', to='pretixbase.Quota', verbose_name='Quota'),
),
migrations.AddField(
model_name='voucher',
name='comment',
field=models.TextField(blank=True, help_text='The text entered in this field will not be visible to the user and is available for your convenience.', verbose_name='Comment'),
),
migrations.AddField(
model_name='voucher',
name='tag',
field=models.CharField(blank=True, db_index=True, help_text='You can use this field to group multiple vouchers together. If you enter the same value for multiple vouchers, you can get statistics on how many of them have been redeemed etc.', max_length=255, verbose_name='Tag'),
),
migrations.AddField(
model_name='item',
name='hide_without_voucher',
field=models.BooleanField(default=False, help_text='This product will be hidden from the event page until the user enters a voucher code that is specifically tied to this product (and not via a quota).', verbose_name='This product will only be shown if a voucher matching the product is redeemed.'),
),
migrations.AddField(
model_name='item',
name='require_voucher',
field=models.BooleanField(default=False, help_text='To buy this product, the user needs a voucher that applies to this product either directly or via a quota.', verbose_name='This product can only be bought using a voucher.'),
),
migrations.AlterField(
model_name='logentry',
name='datetime',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
migrations.AlterField(
model_name='logentry',
name='object_id',
field=models.PositiveIntegerField(db_index=True),
),
migrations.AlterField(
model_name='order',
name='code',
field=models.CharField(db_index=True, max_length=16, verbose_name='Order code'),
),
migrations.AlterField(
model_name='order',
name='status',
field=models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], db_index=True, max_length=3, verbose_name='Status'),
),
migrations.AlterField(
model_name='voucher',
name='code',
field=models.CharField(db_index=True, default=pretix.base.models.vouchers.generate_code, max_length=255, verbose_name='Voucher code'),
),
migrations.AddField(
model_name='order',
name='comment',
field=models.TextField(blank=True, help_text='The text entered in this field will not be visible to the user and is available for your convenience.', verbose_name='Comment'),
),
migrations.RemoveField(
model_name='cartposition',
name='base_price',
),
migrations.RemoveField(
model_name='cartposition',
name='voucher_discount',
),
migrations.RemoveField(
model_name='orderposition',
name='base_price',
),
migrations.RemoveField(
model_name='orderposition',
name='voucher_discount',
),
]
@@ -7,10 +7,10 @@ from decimal import Decimal
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
import i18nfield.fields
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import pretix.base.i18n
import pretix.base.models.base import pretix.base.models.base
import pretix.base.models.items import pretix.base.models.items
import pretix.base.models.orders import pretix.base.models.orders
@@ -64,7 +64,7 @@ class Migration(migrations.Migration):
name='Event', name='Event',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')), ('name', pretix.base.i18n.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')), ('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')), ('currency', models.CharField(default='EUR', max_length=10, verbose_name='Default currency')),
('date_from', models.DateTimeField(verbose_name='Event start time')), ('date_from', models.DateTimeField(verbose_name='Event start time')),
@@ -119,9 +119,9 @@ class Migration(migrations.Migration):
name='Item', name='Item',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Item name')), ('name', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Item name')),
('active', models.BooleanField(default=True, verbose_name='Active')), ('active', models.BooleanField(default=True, verbose_name='Active')),
('description', i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the product name in lists.', null=True, verbose_name='Description')), ('description', pretix.base.i18n.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')), ('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')), ('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')), ('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', name='ItemCategory',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Category name')), ('name', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Category name')),
('position', models.IntegerField(default=0)), ('position', models.IntegerField(default=0)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='pretixbase.Event')), ('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', name='ItemVariation',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Description')), ('value', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Description')),
('active', models.BooleanField(default=True, verbose_name='Active')), ('active', models.BooleanField(default=True, verbose_name='Active')),
('position', models.PositiveIntegerField(default=0, verbose_name='Position')), ('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')), ('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', name='Question',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question', i18nfield.fields.I18nTextField(verbose_name='Question')), ('question', pretix.base.i18n.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')), ('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')), ('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')), ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='pretixbase.Event')),
@@ -3,9 +3,10 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import django.db.models.deletion import django.db.models.deletion
import i18nfield.fields
from django.db import migrations, models from django.db import migrations, models
import pretix.base.i18n
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -18,7 +19,7 @@ class Migration(migrations.Migration):
name='QuestionOption', name='QuestionOption',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('answer', i18nfield.fields.I18nCharField(verbose_name='Answer')), ('answer', pretix.base.i18n.I18nCharField(verbose_name='Answer')),
], ],
), ),
migrations.AlterField( migrations.AlterField(
@@ -2,9 +2,10 @@
# Generated by Django 1.9.4 on 2016-04-21 19:43 # Generated by Django 1.9.4 on 2016-04-21 19:43
from __future__ import unicode_literals from __future__ import unicode_literals
import i18nfield.fields
from django.db import migrations, models from django.db import migrations, models
import pretix.base.i18n
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -16,7 +17,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='itemcategory', model_name='itemcategory',
name='description', name='description',
field=i18nfield.fields.I18nTextField(blank=True, verbose_name='Category description'), field=pretix.base.i18n.I18nTextField(blank=True, verbose_name='Category description'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='questionanswer', model_name='questionanswer',
@@ -1,212 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-03 14:21
from __future__ import unicode_literals
import django.core.validators
import django.db.migrations.operations.special
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.validators
def preserve_event_settings(apps, schema_editor):
Event = apps.get_model('pretixbase', 'Event')
EventSetting = apps.get_model('pretixbase', 'EventSetting')
for e in Event.objects.all():
EventSetting.objects.create(object=e, key='mail_days_order_expire_warning', value='0')
def forwards42(apps, schema_editor):
Order = apps.get_model('pretixbase', 'Order')
EventSetting = apps.get_model('pretixbase', 'EventSetting')
etz = {
s['object_id']: s['value']
for s in EventSetting.objects.filter(key='timezone').values('object_id', 'value')
}
for order in Order.objects.all():
tz = pytz.timezone(etz.get(order.event_id, 'UTC'))
order.expires = order.expires.astimezone(tz).replace(hour=23, minute=59, second=59)
order.save()
def forwards44(apps, schema_editor):
CachedTicket = apps.get_model('pretixbase', 'CachedTicket')
CachedTicket.objects.all().delete()
class Migration(migrations.Migration):
replaces = [('pretixbase', '0031_auto_20160816_0648'), ('pretixbase', '0032_question_position'), ('pretixbase', '0033_auto_20160821_2222'), ('pretixbase', '0034_auto_20160830_1952'), ('pretixbase', '0032_item_allow_cancel'), ('pretixbase', '0033_auto_20160822_1044'), ('pretixbase', '0035_merge'), ('pretixbase', '0036_auto_20160902_0755'), ('pretixbase', '0037_invoice_payment_provider_text'), ('pretixbase', '0038_auto_20160924_1448'), ('pretixbase', '0039_user_require_2fa'), ('pretixbase', '0040_u2fdevice'), ('pretixbase', '0041_auto_20161018_1654'), ('pretixbase', '0042_order_expires'), ('pretixbase', '0043_globalsetting'), ('pretixbase', '0044_auto_20161101_1610'), ('pretixbase', '0045_auto_20161108_1542'), ('pretixbase', '0046_order_meta_info'), ('pretixbase', '0047_auto_20161126_1300'), ('pretixbase', '0048_auto_20161129_1330')]
dependencies = [
('pretixbase', '0030_auto_20160816_0646'),
]
operations = [
migrations.RenameField(
model_name='invoice',
old_name='invoice_no_charfield',
new_name='invoice_no',
),
migrations.AddField(
model_name='invoice',
name='footer_text',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='invoice',
name='introductory_text',
field=models.TextField(blank=True),
),
migrations.AlterUniqueTogether(
name='invoice',
unique_together=set([('event', 'invoice_no')]),
),
migrations.AddField(
model_name='question',
name='position',
field=models.IntegerField(default=0),
),
migrations.AlterModelOptions(
name='question',
options={'ordering': ('position', 'id'), 'verbose_name': 'Question', 'verbose_name_plural': 'Questions'},
),
migrations.AddField(
model_name='item',
name='allow_cancel',
field=models.BooleanField(default=True, help_text='If you deactivate this, an order including this product might not be cancelled by the user. It may still be cancelled by you.', verbose_name='Allow product to be cancelled'),
),
migrations.AddField(
model_name='order',
name='expiry_reminder_sent',
field=models.BooleanField(default=False),
),
migrations.RunPython(
code=preserve_event_settings,
),
migrations.AddField(
model_name='invoice',
name='payment_provider_text',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='eventpermission',
name='can_view_vouchers',
field=models.BooleanField(default=True, verbose_name='Can view vouchers'),
),
migrations.AlterField(
model_name='item',
name='allow_cancel',
field=models.BooleanField(default=True, help_text='If you deactivate this, an order including this product might not be canceled by the user. It may still be canceled by you.', verbose_name='Allow product to be canceled'),
),
migrations.AlterField(
model_name='order',
name='status',
field=models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'canceled'), ('r', 'refunded')], db_index=True, max_length=3, verbose_name='Status'),
),
migrations.AddField(
model_name='user',
name='require_2fa',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='U2FDevice',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='The human-readable name of this device.', max_length=64)),
('confirmed', models.BooleanField(default=True, help_text='Is this device ready for use?')),
('json_data', models.TextField()),
('user', models.ForeignKey(help_text='The user that this device belongs to.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='cachedticket',
name='cachedfile',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.CachedFile'),
),
migrations.RunPython(
code=forwards42,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.CreateModel(
name='GlobalSetting',
fields=[
('key', models.CharField(max_length=255, primary_key=True, serialize=False)),
('value', models.TextField()),
],
),
migrations.RunPython(
code=forwards44,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.RemoveField(
model_name='cachedticket',
name='order',
),
migrations.AddField(
model_name='cachedticket',
name='order_position',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.OrderPosition'),
preserve_default=False,
),
migrations.AlterField(
model_name='cartposition',
name='expires',
field=models.DateTimeField(db_index=True, verbose_name='Expiration date'),
),
migrations.AlterField(
model_name='voucher',
name='redeemed',
field=models.BooleanField(db_index=True, default=False, verbose_name='Redeemed'),
),
migrations.AlterField(
model_name='voucher',
name='valid_until',
field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Valid until'),
),
migrations.AddField(
model_name='order',
name='meta_info',
field=models.TextField(blank=True, null=True, verbose_name='Meta information'),
),
migrations.AddField(
model_name='voucher',
name='max_usages',
field=models.PositiveIntegerField(default=1, help_text='Number of times this voucher can be redeemed.', verbose_name='Maximum usages'),
),
migrations.AlterField(
model_name='event',
name='slug',
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Slug'),
),
migrations.AlterField(
model_name='organizer',
name='slug',
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Slug'),
),
migrations.AlterField(
model_name='voucher',
name='redeemed',
field=models.PositiveIntegerField(default=0, verbose_name='Redeemed'),
),
migrations.AddField(
model_name='voucher',
name='price_mode',
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='set', max_length=100, verbose_name='Price mode'),
),
migrations.RenameField(
model_name='voucher',
old_name='price',
new_name='value',
),
migrations.AlterField(
model_name='voucher',
name='value',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Voucher value'),
),
]
@@ -1,30 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.3 on 2016-11-29 13:30
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0047_auto_20161126_1300'),
]
operations = [
migrations.AddField(
model_name='voucher',
name='price_mode',
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='set', max_length=100, verbose_name='Price mode'),
),
migrations.RenameField(
model_name='voucher',
old_name='price',
new_name='value',
),
migrations.AlterField(
model_name='voucher',
name='value',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Voucher value'),
),
]
@@ -1,29 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-08 16:47
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0048_auto_20161129_1330'),
('pretixdroid', '0002_auto_20161208_1644'),
]
state_operations = [
migrations.CreateModel(
name='Checkin',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datetime', models.DateTimeField(auto_now_add=True)),
('position', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pretixdroid_checkins', to='pretixbase.OrderPosition')),
],
),
]
operations = [
migrations.SeparateDatabaseAndState(state_operations=state_operations)
]
@@ -1,31 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-21 17:05
from __future__ import unicode_literals
from django.db import migrations, models
def forwards(apps, schema_editor):
Order = apps.get_model('pretixbase', 'Order')
for o in Order.objects.all():
for i, p in enumerate(o.positions.all()):
p.positionid = i + 1
p.save()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0049_checkin'),
]
operations = [
migrations.AddField(
model_name='orderposition',
name='positionid',
field=models.PositiveIntegerField(default=1),
),
migrations.RunPython(
forwards, migrations.RunPython.noop
),
]
@@ -1,219 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-03 14:12
from __future__ import unicode_literals
import django.core.validators
import django.db.migrations.operations.special
import django.db.models.deletion
import django.utils.timezone
import i18nfield.fields
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.event
import pretix.base.models.orders
import pretix.base.models.organizer
import pretix.base.validators
def forwards50(apps, schema_editor):
Order = apps.get_model('pretixbase', 'Order')
for o in Order.objects.all():
for i, p in enumerate(o.positions.all()):
p.positionid = i + 1
p.save()
def invalidate_ticket_cache(apps, schema_editor):
CachedTicket = apps.get_model('pretixbase', 'CachedTicket')
for ct in CachedTicket.objects.all():
try:
if ct.cachedfile:
ct.cachedfile.delete()
if ct.cachedfile.file:
ct.cachedfile.file.delete(False)
except models.Model.DoesNotExist:
pass
ct.delete()
def merge_names(apps, schema_editor):
User = apps.get_model('pretixbase', 'User')
for u in User.objects.all():
if u.givenname:
if u.familyname:
u.fullname = u.givenname + " " + u.familyname
else:
u.fullname = u.givenname
elif u.familyname:
u.fullname = u.familyname
u.save()
class Migration(migrations.Migration):
replaces = [('pretixbase', '0050_orderposition_positionid'), ('pretixbase', '0051_auto_20161221_1720'), ('pretixbase', '0052_auto_20161231_1533'), ('pretixbase', '0053_auto_20170104_1252'), ('pretixbase', '0054_auto_20170107_1058'), ('pretixbase', '0055_organizerpermission_can_change_permissions'), ('pretixbase', '0056_auto_20170107_1251'), ('pretixbase', '0057_auto_20170107_1531'), ('pretixbase', '0058_auto_20170107_1533'), ('pretixbase', '0059_cachedcombinedticket'), ('pretixbase', '0060_auto_20170113_1438'), ('pretixbase', '0061_event_location')]
dependencies = [
('pretixbase', '0049_checkin'),
]
operations = [
migrations.AddField(
model_name='orderposition',
name='positionid',
field=models.PositiveIntegerField(default=1),
),
migrations.RunPython(
code=forwards50,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.RunPython(
code=invalidate_ticket_cache,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.RemoveField(
model_name='cachedticket',
name='cachedfile',
),
migrations.AddField(
model_name='cachedticket',
name='extension',
field=models.CharField(default='', max_length=255),
preserve_default=False,
),
migrations.AddField(
model_name='cachedticket',
name='file',
field=models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedticket_name),
),
migrations.AddField(
model_name='cachedticket',
name='type',
field=models.CharField(default='', max_length=255),
preserve_default=False,
),
migrations.AlterField(
model_name='checkin',
name='datetime',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterField(
model_name='checkin',
name='position',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkins', to='pretixbase.OrderPosition'),
),
migrations.AlterField(
model_name='voucher',
name='price_mode',
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='none', max_length=100, verbose_name='Price mode'),
),
migrations.CreateModel(
name='RequiredAction',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datetime', models.DateTimeField(auto_now_add=True, db_index=True)),
('done', models.BooleanField(default=False)),
('action_type', models.CharField(max_length=255)),
('data', models.TextField(default='{}')),
],
options={
'ordering': ('datetime',),
},
),
migrations.AlterField(
model_name='event',
name='slug',
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
),
migrations.AddField(
model_name='requiredaction',
name='event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event'),
),
migrations.AddField(
model_name='requiredaction',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='eventpermission',
name='invite_email',
field=models.EmailField(blank=True, max_length=254, null=True),
),
migrations.AddField(
model_name='eventpermission',
name='invite_token',
field=models.CharField(blank=True, default=pretix.base.models.event.generate_invite_token, max_length=64, null=True),
),
migrations.AlterField(
model_name='eventpermission',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='event_perms', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='organizerpermission',
name='can_change_permissions',
field=models.BooleanField(default=True, verbose_name='Can change permissions'),
),
migrations.AddField(
model_name='organizerpermission',
name='invite_email',
field=models.EmailField(blank=True, max_length=254, null=True),
),
migrations.AddField(
model_name='organizerpermission',
name='invite_token',
field=models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64, null=True),
),
migrations.AlterField(
model_name='organizerpermission',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='organizer_perms', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='fullname',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Full name'),
),
migrations.AlterField(
model_name='organizer',
name='slug',
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
),
migrations.RunPython(
code=merge_names,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.RemoveField(
model_name='user',
name='familyname',
),
migrations.RemoveField(
model_name='user',
name='givenname',
),
migrations.CreateModel(
name='CachedCombinedTicket',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider', models.CharField(max_length=255)),
('type', models.CharField(max_length=255)),
('extension', models.CharField(max_length=255)),
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedcombinedticket_name)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order')),
('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)),
],
),
migrations.AddField(
model_name='cachedticket',
name='created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='event',
name='location',
field=i18nfield.fields.I18nCharField(blank=True, max_length=200, null=True, verbose_name='Location'),
),
]
@@ -1,54 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-21 17:20
from __future__ import unicode_literals
from django.db import migrations, models
import pretix.base.models.orders
def invalidate_ticket_cache(apps, schema_editor):
CachedTicket = apps.get_model('pretixbase', 'CachedTicket')
for ct in CachedTicket.objects.all():
try:
if ct.cachedfile:
ct.cachedfile.delete()
if ct.cachedfile.file:
ct.cachedfile.file.delete(False)
except models.Model.DoesNotExist:
pass
ct.delete()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0050_orderposition_positionid'),
]
operations = [
migrations.RunPython(
invalidate_ticket_cache, migrations.RunPython.noop
),
migrations.RemoveField(
model_name='cachedticket',
name='cachedfile',
),
migrations.AddField(
model_name='cachedticket',
name='extension',
field=models.CharField(default='', max_length=255),
preserve_default=False,
),
migrations.AddField(
model_name='cachedticket',
name='file',
field=models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedticket_name),
),
migrations.AddField(
model_name='cachedticket',
name='type',
field=models.CharField(default='', max_length=255),
preserve_default=False,
),
]
@@ -1,42 +0,0 @@
# -*- 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),
),
]
@@ -1,32 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-31 15:33
from __future__ import unicode_literals
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0051_auto_20161221_1720'),
]
operations = [
migrations.AlterField(
model_name='checkin',
name='datetime',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterField(
model_name='checkin',
name='position',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkins', to='pretixbase.OrderPosition'),
),
migrations.AlterField(
model_name='voucher',
name='price_mode',
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='none', max_length=100, verbose_name='Price mode'),
),
]
@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-04 12:52
from __future__ import unicode_literals
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.validators
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0052_auto_20161231_1533'),
]
operations = [
migrations.CreateModel(
name='RequiredAction',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datetime', models.DateTimeField(auto_now_add=True, db_index=True)),
('done', models.BooleanField(default=False)),
('action_type', models.CharField(max_length=255)),
('data', models.TextField(default='{}')),
],
options={
'ordering': ('datetime',),
},
),
migrations.AlterField(
model_name='event',
name='slug',
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
),
migrations.AddField(
model_name='requiredaction',
name='event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event'),
),
migrations.AddField(
model_name='requiredaction',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
]
@@ -1,34 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-07 10:58
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.event
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0053_auto_20170104_1252'),
]
operations = [
migrations.AddField(
model_name='eventpermission',
name='invite_email',
field=models.EmailField(blank=True, max_length=254, null=True),
),
migrations.AddField(
model_name='eventpermission',
name='invite_token',
field=models.CharField(blank=True, default=pretix.base.models.event.generate_invite_token, max_length=64, null=True),
),
migrations.AlterField(
model_name='eventpermission',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='event_perms', to=settings.AUTH_USER_MODEL),
),
]
@@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-07 12:37
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0054_auto_20170107_1058'),
]
operations = [
migrations.AddField(
model_name='organizerpermission',
name='can_change_permissions',
field=models.BooleanField(default=True, verbose_name='Can change permissions'),
),
]
@@ -1,34 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-07 12:51
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.organizer
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0055_organizerpermission_can_change_permissions'),
]
operations = [
migrations.AddField(
model_name='organizerpermission',
name='invite_email',
field=models.EmailField(blank=True, max_length=254, null=True),
),
migrations.AddField(
model_name='organizerpermission',
name='invite_token',
field=models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64, null=True),
),
migrations.AlterField(
model_name='organizerpermission',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='organizer_perms', to=settings.AUTH_USER_MODEL),
),
]
@@ -1,42 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-07 15:31
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import pretix.base.validators
def merge_names(apps, schema_editor):
User = apps.get_model('pretixbase', 'User')
for u in User.objects.all():
if u.givenname:
if u.familyname:
u.fullname = u.givenname + " " + u.familyname
else:
u.fullname = u.givenname
elif u.familyname:
u.fullname = u.familyname
u.save()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0056_auto_20170107_1251'),
]
operations = [
migrations.AddField(
model_name='user',
name='fullname',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Full name'),
),
migrations.AlterField(
model_name='organizer',
name='slug',
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
),
migrations.RunPython(merge_names, migrations.RunPython.noop)
]
@@ -1,23 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-07 15:33
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0057_auto_20170107_1531'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='familyname',
),
migrations.RemoveField(
model_name='user',
name='givenname',
),
]
@@ -1,29 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-13 14:07
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.orders
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0058_auto_20170107_1533'),
]
operations = [
migrations.CreateModel(
name='CachedCombinedTicket',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider', models.CharField(max_length=255)),
('type', models.CharField(max_length=255)),
('extension', models.CharField(max_length=255)),
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedcombinedticket_name)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order')),
],
),
]
@@ -1,28 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-13 14:38
from __future__ import unicode_literals
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0059_cachedcombinedticket'),
]
operations = [
migrations.AddField(
model_name='cachedcombinedticket',
name='created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='cachedticket',
name='created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]
@@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-01 04:31
from __future__ import unicode_literals
import i18nfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0060_auto_20170113_1438'),
]
operations = [
migrations.AddField(
model_name='event',
name='location',
field=i18nfield.fields.I18nCharField(blank=True, max_length=200, null=True, verbose_name='Location'),
),
]
+3 -10
View File
@@ -1,10 +1,6 @@
from .auth import U2FDevice, User from .auth import U2FDevice, User
from .base import CachedFile, LoggedModel, cachedfile_name from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin from .event import Event, EventLock, EventPermission, EventSetting
from .event import (
Event, EventLock, EventPermission, EventSetting, RequiredAction,
generate_invite_token,
)
from .invoices import Invoice, InvoiceLine, invoice_filename from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import ( from .items import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota, Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
@@ -12,11 +8,8 @@ from .items import (
) )
from .log import LogEntry from .log import LogEntry
from .orders import ( from .orders import (
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition, AbstractPosition, CachedTicket, CartPosition, InvoiceAddress, Order,
InvoiceAddress, Order, OrderPosition, QuestionAnswer, OrderPosition, QuestionAnswer, generate_position_secret, generate_secret,
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
generate_secret,
) )
from .organizer import Organizer, OrganizerPermission, OrganizerSetting from .organizer import Organizer, OrganizerPermission, OrganizerSetting
from .vouchers import Voucher from .vouchers import Voucher
from .waitinglist import WaitingListEntry
+27 -13
View File
@@ -43,8 +43,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:param email: The user's email address, used for identification. :param email: The user's email address, used for identification.
:type email: str :type email: str
:param fullname: The user's full name. May be empty or null. :param givenname: The user's given name. May be empty or null.
:type fullname: str :type givenname: str
:param familyname: The user's given name. May be empty or null.
:type familyname: str
:param is_active: Whether this user account is activated. :param is_active: Whether this user account is activated.
:type is_active: bool :type is_active: bool
:param is_staff: ``True`` for system operators. :param is_staff: ``True`` for system operators.
@@ -62,8 +64,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
email = models.EmailField(unique=True, db_index=True, null=True, blank=True, email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('E-mail')) verbose_name=_('E-mail'))
fullname = models.CharField(max_length=255, blank=True, null=True, givenname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name')) verbose_name=_('Given name'))
familyname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Family name'))
is_active = models.BooleanField(default=True, is_active = models.BooleanField(default=True,
verbose_name=_('Is active')) verbose_name=_('Is active'))
is_staff = models.BooleanField(default=False, is_staff = models.BooleanField(default=False,
@@ -96,13 +100,14 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
""" """
Returns the first of the following user properties that is found to exist: Returns the first of the following user properties that is found to exist:
* Full name * Given name
* Family name
* Email address * Email address
Only present for backwards compatibility
""" """
if self.fullname: if self.givenname:
return self.fullname return self.givenname
elif self.familyname:
return self.familyname
else: else:
return self.email return self.email
@@ -110,11 +115,20 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
""" """
Returns the first of the following user properties that is found to exist: Returns the first of the following user properties that is found to exist:
* Full name * A combination of given name and family name, depending on the locale
* Email address * Given name
* Family name
* User name
""" """
if self.fullname: if self.givenname and not self.familyname:
return self.fullname return self.givenname
elif not self.givenname and self.familyname:
return self.familyname
elif self.familyname and self.givenname:
return _('%(family)s, %(given)s') % {
'family': self.familyname,
'given': self.givenname
}
else: else:
return self.email return self.email
+5 -7
View File
@@ -1,12 +1,13 @@
import json import json
import uuid import uuid
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.db.models.signals import post_delete from django.db.models.signals import post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from i18nfield.utils import I18nJSONEncoder
from pretix.base.i18n import I18nJSONEncoder
def cachedfile_name(instance, filename: str) -> str: def cachedfile_name(instance, filename: str) -> str:
@@ -59,6 +60,7 @@ class LoggingMixin:
class LoggedModel(models.Model, LoggingMixin): class LoggedModel(models.Model, LoggingMixin):
logentries = GenericRelation('pretixbase.LogEntry')
class Meta: class Meta:
abstract = True abstract = True
@@ -69,8 +71,4 @@ class LoggedModel(models.Model, LoggingMixin):
:return: A QuerySet of LogEntry objects :return: A QuerySet of LogEntry objects
""" """
from .log import LogEntry return self.logentries.all().select_related('user')
return LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
).select_related('user', 'event')
-10
View File
@@ -1,10 +0,0 @@
from django.db import models
from django.utils.timezone import now
class Checkin(models.Model):
"""
A checkin object is created when a person enters the event.
"""
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins')
datetime = models.DateTimeField(default=now)
+5 -143
View File
@@ -1,26 +1,21 @@
import string
import uuid import uuid
from datetime import date, datetime, time
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.mail import get_connection from django.core.mail import get_connection
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.template.defaultfilters import date as _date from django.template.defaultfilters import date as _date
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from i18nfield.fields import I18nCharField
from pretix.base.email import CustomSMTPBackend from pretix.base.email import CustomSMTPBackend
from pretix.base.i18n import I18nCharField
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.settings import SettingsProxy from pretix.base.settings import SettingsProxy
from pretix.base.validators import EventSlugBlacklistValidator from pretix.base.validators import EventSlugBlacklistValidator
from pretix.helpers.daterange import daterange
from .auth import User from .auth import User
from .organizer import Organizer from .organizer import Organizer
@@ -51,8 +46,6 @@ class Event(LoggedModel):
:type presale_start: datetime :type presale_start: datetime
:param presale_end: No tickets will be sold after this date. :param presale_end: No tickets will be sold after this date.
:type presale_end: datetime :type presale_end: datetime
:param location: venue
:type location: str
:param plugins: A comma-separated list of plugin names that are active for this :param plugins: A comma-separated list of plugin names that are active for this
event. event.
:type plugins: str :type plugins: str
@@ -68,7 +61,7 @@ class Event(LoggedModel):
max_length=50, db_index=True, max_length=50, db_index=True,
help_text=_( help_text=_(
"Should be short, only contain lowercase letters and numbers, and must be unique among your events. " "Should be short, only contain lowercase letters and numbers, and must be unique among your events. "
"This will be used in order codes, invoice numbers, links and bank transfer references."), "This is being used in addresses and bank transfer references."),
validators=[ validators=[
RegexValidator( RegexValidator(
regex="^[a-zA-Z0-9.-]+$", regex="^[a-zA-Z0-9.-]+$",
@@ -76,7 +69,7 @@ class Event(LoggedModel):
), ),
EventSlugBlacklistValidator() EventSlugBlacklistValidator()
], ],
verbose_name=_("Short form"), verbose_name=_("Slug"),
) )
live = models.BooleanField(default=False, verbose_name=_("Shop is live")) live = models.BooleanField(default=False, verbose_name=_("Shop is live"))
permitted = models.ManyToManyField(User, through='EventPermission', permitted = models.ManyToManyField(User, through='EventPermission',
@@ -101,11 +94,6 @@ class Event(LoggedModel):
verbose_name=_("Start of presale"), verbose_name=_("Start of presale"),
help_text=_("No products will be sold before this date."), help_text=_("No products will be sold before this date."),
) )
location = I18nCharField(
null=True, blank=True,
max_length=200,
verbose_name=_("Location"),
)
plugins = models.TextField( plugins = models.TextField(
null=True, blank=True, null=True, blank=True,
verbose_name=_("Plugins"), verbose_name=_("Plugins"),
@@ -164,12 +152,6 @@ class Event(LoggedModel):
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" "DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
) )
def get_date_range_display(self, tz=None) -> str:
tz = tz or pytz.timezone(self.settings.timezone)
if not self.settings.show_date_to or not self.date_to:
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache": def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
""" """
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
@@ -226,85 +208,6 @@ class Event(LoggedModel):
else: else:
return get_connection(fail_silently=False) return get_connection(fail_silently=False)
@property
def payment_term_last(self):
tz = pytz.timezone(self.settings.timezone)
return make_aware(datetime.combine(
self.settings.get('payment_term_last', as_type=date),
time(hour=23, minute=59, second=59)
), tz)
def copy_data_from(self, other):
from . import ItemCategory, Item, Question, Quota
self.plugins = other.plugins
self.save()
category_map = {}
for c in ItemCategory.objects.filter(event=other):
category_map[c.pk] = c
c.pk = None
c.event = self
c.save()
item_map = {}
variation_map = {}
for i in Item.objects.filter(event=other).prefetch_related('variations'):
vars = list(i.variations.all())
item_map[i.pk] = i
i.pk = None
i.event = self
if i.picture:
i.picture.save(i.picture.name, i.picture)
if i.category_id:
i.category = category_map[i.category_id]
i.save()
for v in vars:
variation_map[v.pk] = v
v.pk = None
v.item = i
v.save()
for q in Quota.objects.filter(event=other).prefetch_related('items', 'variations'):
items = list(q.items.all())
vars = list(q.variations.all())
q.pk = None
q.event = self
q.save()
for i in items:
q.items.add(item_map[i.pk])
for v in vars:
q.variations.add(variation_map[v.pk])
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
items = list(q.items.all())
opts = list(q.options.all())
q.pk = None
q.event = self
q.save()
for i in items:
q.items.add(item_map[i.pk])
for o in opts:
o.pk = None
o.question = q
o.save()
for s in EventSetting.objects.filter(object=other):
s.object = self
s.pk = None
if s.value.startswith('file://'):
fi = default_storage.open(s.value[7:], 'rb')
nonce = get_random_string(length=8)
fname = '%s/%s/%s.%s.%s' % (
self.organizer.slug, self.slug, s.key, nonce, s.value.split('.')[-1]
)
newname = default_storage.save(fname, fi)
s.value = 'file://' + newname
s.save()
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
class EventPermission(models.Model): class EventPermission(models.Model):
""" """
@@ -326,9 +229,7 @@ class EventPermission(models.Model):
""" """
event = models.ForeignKey(Event, related_name="user_perms", on_delete=models.CASCADE) event = models.ForeignKey(Event, related_name="user_perms", on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name="event_perms", on_delete=models.CASCADE, null=True, blank=True) user = models.ForeignKey(User, related_name="event_perms", on_delete=models.CASCADE)
invite_email = models.EmailField(null=True, blank=True)
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
can_change_settings = models.BooleanField( can_change_settings = models.BooleanField(
default=True, default=True,
verbose_name=_("Can change event settings") verbose_name=_("Can change event settings")
@@ -373,42 +274,3 @@ class EventLock(models.Model):
event = models.CharField(max_length=36, primary_key=True) event = models.CharField(max_length=36, primary_key=True)
date = models.DateTimeField(auto_now=True) date = models.DateTimeField(auto_now=True)
token = models.UUIDField(default=uuid.uuid4) token = models.UUIDField(default=uuid.uuid4)
class RequiredAction(models.Model):
"""
Represents an action that is to be done by an admin. The admin will be
displayed a list of actions to do.
:param datatime: The timestamp of the required action
:type datetime: datetime
:param user: The user that performed the action
:type user: User
:param done: If this action has been completed or dismissed
:type done: bool
:param action_type: The type of action that has to be performed. This is
used to look up the renderer used to describe the action in a human-
readable way. This should be some namespaced value using dotted
notation to avoid duplicates, e.g.
``"pretix.plugins.banktransfer.incoming_transfer"``.
:type action_type: str
:param data: Arbitrary data that can be used by the log action renderer
:type data: str
"""
datetime = models.DateTimeField(auto_now_add=True, db_index=True)
done = models.BooleanField(default=False)
user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.CASCADE)
action_type = models.CharField(max_length=255)
data = models.TextField(default='{}')
class Meta:
ordering = ('datetime',)
def display(self, request):
from ..signals import requiredaction_display
for receiver, response in requiredaction_display.send(self.event, action=self, request=request):
if response:
return response
return self.action_type
+13 -50
View File
@@ -10,9 +10,8 @@ from django.db.models import F, Func, Q, Sum
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.decimal import round_decimal from pretix.base.i18n import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from .event import Event from .event import Event
@@ -141,9 +140,6 @@ class Item(LoggedModel):
) )
default_price = models.DecimalField( default_price = models.DecimalField(
verbose_name=_("Default price"), 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 max_digits=7, decimal_places=2, null=True
) )
free_price = models.BooleanField( free_price = models.BooleanField(
@@ -199,9 +195,8 @@ class Item(LoggedModel):
allow_cancel = models.BooleanField( allow_cancel = models.BooleanField(
verbose_name=_('Allow product to be canceled'), verbose_name=_('Allow product to be canceled'),
default=True, default=True,
help_text=_('If this is active and the general event settings allo wit, orders containing this product can be ' help_text=_('If you deactivate this, an order including this product might not be canceled by the user. '
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own ' 'It may still be canceled by you.')
'and you can cancel orders at all times, regardless of this setting')
) )
class Meta: class Meta:
@@ -222,11 +217,6 @@ class Item(LoggedModel):
if self.event: if self.event:
self.event.get_cache().clear() 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: def is_available(self, now_dt: datetime=None) -> bool:
""" """
Returns whether this item is available according to its ``active`` flag Returns whether this item is available according to its ``active`` flag
@@ -241,7 +231,7 @@ class Item(LoggedModel):
return False return False
return True return True
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None): def check_quotas(self, ignored_quotas=None, _cache=None):
""" """
This method is used to determine whether this Item is currently available This method is used to determine whether this Item is currently available
for sale. for sale.
@@ -263,7 +253,7 @@ class Item(LoggedModel):
if self.variations.count() > 0: # NOQA if self.variations.count() > 0: # NOQA
raise ValueError('Do not call this directly on items which have variations ' raise ValueError('Do not call this directly on items which have variations '
'but call this on their ItemVariation objects') 'but call this on their ItemVariation objects')
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas], return min([q.availability(_cache=_cache) for q in check_quotas],
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize)) key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
@cached_property @cached_property
@@ -315,15 +305,6 @@ class ItemVariation(models.Model):
def __str__(self): def __str__(self):
return str(self.value) 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): def delete(self, *args, **kwargs):
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
if self.item: if self.item:
@@ -334,7 +315,7 @@ class ItemVariation(models.Model):
if self.item: if self.item:
self.item.event.get_cache().clear() self.item.event.get_cache().clear()
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]: def check_quotas(self, ignored_quotas=None, _cache=None) -> Tuple[int, int]:
""" """
This method is used to determine whether this ItemVariation is currently This method is used to determine whether this ItemVariation is currently
available for sale in terms of quotas. available for sale in terms of quotas.
@@ -343,7 +324,6 @@ class ItemVariation(models.Model):
quotas will be ignored in the calculation. If this leads quotas will be ignored in the calculation. If this leads
to no quotas being checked at all, this method will return to no quotas being checked at all, this method will return
unlimited availability. 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()`. :returns: any of the return codes of :py:meth:`Quota.availability()`.
""" """
check_quotas = set(self.quotas.all()) check_quotas = set(self.quotas.all())
@@ -351,7 +331,7 @@ class ItemVariation(models.Model):
check_quotas -= set(ignored_quotas) check_quotas -= set(ignored_quotas)
if not check_quotas: if not check_quotas:
return Quota.AVAILABILITY_OK, sys.maxsize return Quota.AVAILABILITY_OK, sys.maxsize
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas], return min([q.availability(_cache=_cache) for q in check_quotas],
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize)) key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
def __lt__(self, other): def __lt__(self, other):
@@ -362,10 +342,9 @@ class ItemVariation(models.Model):
class Question(LoggedModel): class Question(LoggedModel):
""" """
A question is an input field that can be used to extend a ticket by custom information, A question is an input field that can be used to extend a ticket
e.g. "Attendee age". The answers are found next to the position. The answers may be found by custom information, e.g. "Attendee age". A question can allow one of several
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of input types, currently:
several input types, currently:
* a number (``TYPE_NUMBER``) * a number (``TYPE_NUMBER``)
* a one-line string (``TYPE_STRING``) * a one-line string (``TYPE_STRING``)
@@ -555,7 +534,7 @@ class Quota(LoggedModel):
if self.event: if self.event:
self.event.get_cache().clear() self.event.get_cache().clear()
def availability(self, now_dt: datetime=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]: def availability(self, now_dt: datetime=None, _cache=None) -> Tuple[int, int]:
""" """
This method is used to determine whether Items or ItemVariations belonging This method is used to determine whether Items or ItemVariations belonging
to this quota should currently be available for sale. to this quota should currently be available for sale.
@@ -563,18 +542,14 @@ class Quota(LoggedModel):
:returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants :returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants
and the second is the number of available tickets. 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: if _cache is not None and self.pk in _cache:
return _cache[self.pk] return _cache[self.pk]
res = self._availability(now_dt, count_waitinglist) res = self._availability(now_dt)
if _cache is not None: if _cache is not None:
_cache[self.pk] = res _cache[self.pk] = res
_cache['_count_waitinglist'] = count_waitinglist
return res return res
def _availability(self, now_dt: datetime=None, count_waitinglist=True): def _availability(self, now_dt: datetime=None):
now_dt = now_dt or now() now_dt = now_dt or now()
size_left = self.size size_left = self.size
if size_left is None: if size_left is None:
@@ -597,11 +572,6 @@ class Quota(LoggedModel):
if size_left <= 0: if size_left <= 0:
return Quota.AVAILABILITY_RESERVED, 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 return Quota.AVAILABILITY_OK, size_left
def count_blocking_vouchers(self, now_dt: datetime=None) -> int: def count_blocking_vouchers(self, now_dt: datetime=None) -> int:
@@ -621,13 +591,6 @@ class Quota(LoggedModel):
free=Sum(Func(F('max_usages') - F('redeemed'), 0, function=func)) free=Sum(Func(F('max_usages') - F('redeemed'), 0, function=func))
)['free'] or 0 )['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: def count_in_cart(self, now_dt: datetime=None) -> int:
from pretix.base.models import CartPosition from pretix.base.models import CartPosition
+1 -93
View File
@@ -1,11 +1,6 @@
import json
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
class LogEntry(models.Model): class LogEntry(models.Model):
@@ -37,7 +32,7 @@ class LogEntry(models.Model):
data = models.TextField(default='{}') data = models.TextField(default='{}')
class Meta: class Meta:
ordering = ('-datetime',) ordering = ('-datetime', )
def display(self): def display(self):
from ..signals import logentry_display from ..signals import logentry_display
@@ -46,90 +41,3 @@ class LogEntry(models.Model):
if response: if response:
return response return response
return self.action_type return self.action_type
@cached_property
def display_object(self):
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event
if self.content_type.model_class() is Event:
return ''
co = self.content_object
a_map = None
a_text = None
if isinstance(co, Order):
a_text = _('Order {val}')
a_map = {
'href': reverse('control:event.order', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'code': co.code
}),
'val': co.code,
}
elif isinstance(co, Voucher):
a_text = _('Voucher {val}…')
a_map = {
'href': reverse('control:event.voucher', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'voucher': co.id
}),
'val': co.code[:6],
}
elif isinstance(co, Item):
a_text = _('Product {val}')
a_map = {
'href': reverse('control:event.item', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'item': co.id
}),
'val': co.name,
}
elif isinstance(co, Quota):
a_text = _('Quota {val}')
a_map = {
'href': reverse('control:event.items.quotas.show', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'quota': co.id
}),
'val': co.name,
}
elif isinstance(co, ItemCategory):
a_text = _('Category {val}')
a_map = {
'href': reverse('control:event.items.categories.edit', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'category': co.id
}),
'val': co.name,
}
elif isinstance(co, Question):
a_text = _('Question {val}')
a_map = {
'href': reverse('control:event.items.questions.show', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'question': co.id
}),
'val': co.question,
}
if a_text and a_map:
a_map['val'] = '<a href="{href}">{val}</a>'.format_map(a_map)
return a_text.format_map(a_map)
elif a_text:
return a_text
else:
return ''
@cached_property
def parsed_data(self):
return json.loads(self.data)
def delete(self, using=None, keep_parents=False):
raise TypeError("Logs cannot be deleted.")
+20 -92
View File
@@ -1,22 +1,19 @@
import copy import copy
import os
import string import string
from datetime import datetime from datetime import date, datetime, time
from decimal import Decimal from decimal import Decimal
from typing import List, Union from typing import List, Union
import pytz
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db.models import F, Sum from django.db.models import F
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.utils.crypto import get_random_string 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.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from ..decimal import round_decimal from ..decimal import round_decimal
from .base import LoggedModel from .base import CachedFile, LoggedModel
from .event import Event from .event import Event
from .items import Item, ItemVariation, Question, QuestionOption, Quota from .items import Item, ItemVariation, Question, QuestionOption, Quota
@@ -191,10 +188,6 @@ class Order(LoggedModel):
""" """
return '{event}-{code}'.format(event=self.event.slug.upper(), code=self.code) 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): def save(self, *args, **kwargs):
if not self.code: if not self.code:
self.assign_code() self.assign_code()
@@ -216,18 +209,6 @@ class Order(LoggedModel):
else: else:
self.payment_fee_tax_value = Decimal('0.00') 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 @staticmethod
def normalize_code(code): def normalize_code(code):
tr = str.maketrans({ tr = str.maketrans({
@@ -246,7 +227,7 @@ class Order(LoggedModel):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True: while True:
code = get_random_string(length=settings.ENTROPY['order_code'], allowed_chars=charset) code = get_random_string(length=settings.ENTROPY['order_code'], allowed_chars=charset)
if not Order.objects.filter(event__organizer=self.event.organizer, code=code).exists(): if not Order.objects.filter(event=self.event, code=code).exists():
self.code = code self.code = code
return return
@@ -286,22 +267,25 @@ class Order(LoggedModel):
def _can_be_paid(self) -> Union[bool, str]: def _can_be_paid(self) -> Union[bool, str]:
error_messages = { error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the " 'late': _("The payment is too late to be accepted."),
"payment settings is over."),
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
"payments should be accepted in the payment settings."),
} }
if self.event.settings.get('payment_term_last'): if self.event.settings.get('payment_term_last'):
if now() > self.event.payment_term_last: tz = pytz.timezone(self.event.settings.timezone)
return error_messages['late_lastdate'] last_date = make_aware(datetime.combine(
self.event.settings.get('payment_term_last', as_type=date),
time(hour=23, minute=59, second=59)
), tz)
if self.status == self.STATUS_PENDING: if now() > last_date:
return True return error_messages['late']
if not self.event.settings.get('payment_term_accept_late'): if not self.event.settings.get('payment_term_accept_late'):
return error_messages['late'] return error_messages['late']
return self._is_still_available() if self.status == self.STATUS_PENDING:
return True
else:
return self._is_still_available()
def _is_still_available(self, now_dt: datetime=None) -> Union[bool, str]: def _is_still_available(self, now_dt: datetime=None) -> Union[bool, str]:
error_messages = { error_messages = {
@@ -445,10 +429,6 @@ class AbstractPosition(models.Model):
else: else:
q.answer = "" q.answer = ""
@property
def net_price(self):
return self.price - self.tax_value
class OrderPosition(AbstractPosition): class OrderPosition(AbstractPosition):
""" """
@@ -459,7 +439,6 @@ class OrderPosition(AbstractPosition):
:param order: The order this position is a part of :param order: The order this position is a part of
:type order: Order :type order: Order
""" """
positionid = models.PositiveIntegerField(default=1)
order = models.ForeignKey( order = models.ForeignKey(
Order, Order,
verbose_name=_("Order"), verbose_name=_("Order"),
@@ -485,12 +464,11 @@ class OrderPosition(AbstractPosition):
from . import Voucher from . import Voucher
ops = [] ops = []
for i, cartpos in enumerate(cp): for cartpos in cp:
op = OrderPosition(order=order) op = OrderPosition(order=order)
for f in AbstractPosition._meta.fields: for f in AbstractPosition._meta.fields:
setattr(op, f.name, getattr(cartpos, f.name)) setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax() op._calculate_tax()
op.positionid = i + 1
op.save() op.save()
for answ in cartpos.answers.all(): for answ in cartpos.answers.all():
answ.orderposition = op answ.orderposition = op
@@ -498,10 +476,6 @@ class OrderPosition(AbstractPosition):
answ.save() answ.save()
if cartpos.voucher: if cartpos.voucher:
Voucher.objects.filter(pk=cartpos.voucher.pk).update(redeemed=F('redeemed') + 1) Voucher.objects.filter(pk=cartpos.voucher.pk).update(redeemed=F('redeemed') + 1)
cartpos.voucher.log_action('pretix.voucher.redeemed', {
'order_code': order.code
})
cartpos.delete() cartpos.delete()
return ops return ops
@@ -520,9 +494,6 @@ class OrderPosition(AbstractPosition):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.tax_rate is None: if self.tax_rate is None:
self._calculate_tax() self._calculate_tax()
if self.pk is None:
while OrderPosition.objects.filter(secret=self.secret).exists():
self.secret = generate_position_secret()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@@ -589,50 +560,7 @@ class InvoiceAddress(models.Model):
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID')) vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'))
def cachedticket_name(instance, filename: str) -> str:
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
return 'tickets/{org}/{ev}/{code}-{no}-{prov}-{secret}.dat'.format(
org=instance.order_position.order.event.organizer.slug,
ev=instance.order_position.order.event.slug,
prov=instance.provider,
no=instance.order_position.positionid,
code=instance.order_position.order.code,
secret=secret,
ext=os.path.splitext(filename)[1]
)
def cachedcombinedticket_name(instance, filename: str) -> str:
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
return 'tickets/{org}/{ev}/{code}-{prov}-{secret}.dat'.format(
org=instance.order.event.organizer.slug,
ev=instance.order.event.slug,
prov=instance.provider,
code=instance.order.code,
secret=secret
)
class CachedTicket(models.Model): class CachedTicket(models.Model):
order_position = models.ForeignKey(OrderPosition, on_delete=models.CASCADE) order_position = models.ForeignKey(OrderPosition, on_delete=models.CASCADE)
cachedfile = models.ForeignKey(CachedFile, on_delete=models.CASCADE, null=True)
provider = models.CharField(max_length=255) provider = models.CharField(max_length=255)
type = models.CharField(max_length=255)
extension = models.CharField(max_length=255)
file = models.FileField(null=True, blank=True, upload_to=cachedticket_name)
created = models.DateTimeField(auto_now_add=True)
class CachedCombinedTicket(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE)
provider = models.CharField(max_length=255)
type = models.CharField(max_length=255)
extension = models.CharField(max_length=255)
file = models.FileField(null=True, blank=True, upload_to=cachedcombinedticket_name)
created = models.DateTimeField(auto_now_add=True)
@receiver(post_delete, sender=CachedTicket)
def cachedticket_delete(sender, instance, **kwargs):
if instance.file:
# Pass false so FileField doesn't save the model.
instance.file.delete(False)
+2 -15
View File
@@ -1,8 +1,5 @@
import string
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@@ -41,7 +38,7 @@ class Organizer(LoggedModel):
), ),
OrganizerSlugBlacklistValidator() OrganizerSlugBlacklistValidator()
], ],
verbose_name=_("Short form"), verbose_name=_("Slug"),
) )
permitted = models.ManyToManyField(User, through='OrganizerPermission', permitted = models.ManyToManyField(User, through='OrganizerPermission',
related_name="organizers") related_name="organizers")
@@ -79,10 +76,6 @@ class Organizer(LoggedModel):
return ObjectRelatedCache(self) return ObjectRelatedCache(self)
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
class OrganizerPermission(models.Model): class OrganizerPermission(models.Model):
""" """
The relation between an Organizer and a User who has permissions to The relation between an Organizer and a User who has permissions to
@@ -98,17 +91,11 @@ class OrganizerPermission(models.Model):
""" """
organizer = models.ForeignKey(Organizer, related_name="user_perms", on_delete=models.CASCADE) organizer = models.ForeignKey(Organizer, related_name="user_perms", on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name="organizer_perms", on_delete=models.CASCADE, null=True, blank=True) user = models.ForeignKey(User, related_name="organizer_perms")
invite_email = models.EmailField(null=True, blank=True)
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
can_create_events = models.BooleanField( can_create_events = models.BooleanField(
default=True, default=True,
verbose_name=_("Can create events"), verbose_name=_("Can create events"),
) )
can_change_permissions = models.BooleanField(
default=True,
verbose_name=_("Can change permissions"),
)
class Meta: class Meta:
verbose_name = _("Organizer permission") verbose_name = _("Organizer permission")
+10 -44
View File
@@ -1,5 +1,3 @@
from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@@ -7,7 +5,6 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from ..decimal import round_decimal
from .base import LoggedModel from .base import LoggedModel
from .event import Event from .event import Event
from .items import Item, ItemVariation, Quota from .items import Item, ItemVariation, Quota
@@ -43,11 +40,8 @@ class Voucher(LoggedModel):
:type block_quota: bool :type block_quota: bool
:param allow_ignore_quota: If set to true, this voucher can be redeemed even if the event is sold out :param allow_ignore_quota: If set to true, this voucher can be redeemed even if the event is sold out
:type allow_ignore_quota: bool :type allow_ignore_quota: bool
:param price_mode: Sets how this voucher affects a product's price. Can be ``none``, ``set``, ``subtract`` :param price: If set, the voucher will allow the sale of associated items for this price
or ``percent``. :type price: decimal.Decimal
:type price_mode: str
:param value: The value by which the price should be modified in the way specified by ``price_mode``.
:type value: decimal.Decimal
:param item: If set, the item to sell :param item: If set, the item to sell
:type item: Item :type item: Item
:param variation: If set, the variation to sell :param variation: If set, the variation to sell
@@ -65,13 +59,6 @@ class Voucher(LoggedModel):
* You need to either select a quota or an item * You need to either select a quota or an item
* If you select an item that has variations but do not select a variation, you cannot set block_quota * If you select an item that has variations but do not select a variation, you cannot set block_quota
""" """
PRICE_MODES = (
('none', _('No effect')),
('set', _('Set product price to')),
('subtract', _('Subtract from product price')),
('percent', _('Reduce product price by (%)')),
)
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -111,15 +98,10 @@ class Voucher(LoggedModel):
"If activated, a holder of this voucher code can buy tickets, even if there are none left." "If activated, a holder of this voucher code can buy tickets, even if there are none left."
) )
) )
price_mode = models.CharField( price = models.DecimalField(
verbose_name=_("Price mode"), verbose_name=_("Set product price to"),
max_length=100,
choices=PRICE_MODES,
default='none'
)
value = models.DecimalField(
verbose_name=_("Voucher value"),
decimal_places=2, max_digits=10, null=True, blank=True, decimal_places=2, max_digits=10, null=True, blank=True,
help_text=_('If empty, the product will cost its normal price.')
) )
item = models.ForeignKey( item = models.ForeignKey(
Item, related_name='vouchers', Item, related_name='vouchers',
@@ -210,11 +192,11 @@ class Voucher(LoggedModel):
Returns whether this voucher applies to a given item (and optionally Returns whether this voucher applies to a given item (and optionally
a variation). a variation).
""" """
if self.quota_id: if self.quota:
return item.quotas.filter(pk=self.quota_id).exists() return item.quotas.filter(pk=self.quota.pk).exists()
if self.item_id and not self.variation_id: if self.item and not self.variation:
return self.item_id == item.pk return self.item == item
return (self.item_id == item.pk) and (variation and self.variation_id == variation.pk) return (self.item == item) and (self.variation == variation)
def is_active(self): def is_active(self):
""" """
@@ -226,19 +208,3 @@ class Voucher(LoggedModel):
if self.valid_until and self.valid_until < now(): if self.valid_until and self.valid_until < now():
return False return False
return True return True
def calculate_price(self, original_price: Decimal) -> Decimal:
"""
Returns how the price given in original_price would be modified if this
voucher is applied, i.e. replaced by a different price or reduced by a
certain percentage. If the voucher does not modify the price, the
original price will be returned.
"""
if self.value is not None:
if self.price_mode == 'set':
return self.value
elif self.price_mode == 'subtract':
return original_price - self.value
elif self.price_mode == 'percent':
return round_decimal(original_price * (Decimal('100.00') - self.value) / Decimal('100.00'))
return original_price
-130
View File
@@ -1,130 +0,0 @@
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
)
+7 -33
View File
@@ -12,25 +12,15 @@ from django.http import HttpRequest
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ 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.decimal import round_decimal
from pretix.base.i18n import I18nFormField, I18nTextarea, LazyI18nString
from pretix.base.models import Event, Order, Quota from pretix.base.models import Event, Order, Quota
from pretix.base.settings import SettingsSandbox from pretix.base.settings import SettingsSandbox
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
from pretix.presale.views import get_cart_total from pretix.presale.views import get_cart_total
class PaymentProviderForm(Form):
def clean(self):
cleaned_data = super().clean()
for k, v in self.fields.items():
val = cleaned_data.get(k)
if v._required and not val:
self.add_error(k, _('This field is required.'))
class BasePaymentProvider: class BasePaymentProvider:
""" """
This is the base class for all payment providers. This is the base class for all payment providers.
@@ -197,12 +187,8 @@ class BasePaymentProvider:
process. The default implementation constructs the form using process. The default implementation constructs the form using
:py:attr:`checkout_form_fields` and sets appropriate prefixes for the form :py:attr:`checkout_form_fields` and sets appropriate prefixes for the form
and all fields and fills the form with data form the user's session. and all fields and fills the form with data form the user's session.
If you overwrite this, we strongly suggest that you inherit from
``PaymentProviderForm`` (from this module) that handles some nasty issues about
required fields for you.
""" """
form = PaymentProviderForm( form = Form(
data=(request.POST if request.method == 'POST' else None), data=(request.POST if request.method == 'POST' else None),
prefix='payment_%s' % self.identifier, prefix='payment_%s' % self.identifier,
initial={ initial={
@@ -212,12 +198,6 @@ class BasePaymentProvider:
} }
) )
form.fields = self.payment_form_fields form.fields = self.payment_form_fields
for k, v in form.fields.items():
v._required = v.required
v.required = False
v.widget.is_required = False
return form return form
def _is_still_available(self, now_dt=None): def _is_still_available(self, now_dt=None):
@@ -338,7 +318,9 @@ class BasePaymentProvider:
The default implementation just returns ``None`` and therefore leaves the 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. order unpaid. The user will be redirected to the order's detail page by default.
On errors, you should raise a ``PaymentException``. On errors, you should use Django's message framework to display an error message
to the user.
:param order: The order object :param order: The order object
""" """
return None return None
@@ -383,12 +365,8 @@ class BasePaymentProvider:
whether the user should be presented with an option to retry the payment. The default whether the user should be presented with an option to retry the payment. The default
implementation always returns False. implementation always returns False.
If you want to enable retrials for your payment method, the best is to just return
``self._is_still_available()`` from this method to disable it as soon as the method
gets disabled or the methods end date is reached.
The retry workflow is also used if a user switches to this payment method for an existing The retry workflow is also used if a user switches to this payment method for an existing
order! order! Therefore, they can only switch to your p
:param order: The order object :param order: The order object
""" """
@@ -482,10 +460,6 @@ class BasePaymentProvider:
'back to the buyer manually.')) 'back to the buyer manually.'))
class PaymentException(Exception):
pass
class FreeOrderProvider(BasePaymentProvider): class FreeOrderProvider(BasePaymentProvider):
@property @property
@@ -514,7 +488,7 @@ class FreeOrderProvider(BasePaymentProvider):
try: try:
mark_order_paid(order, 'free', send_mail=False) mark_order_paid(order, 'free', send_mail=False)
except Quota.QuotaExceededException as e: except Quota.QuotaExceededException as e:
raise PaymentException(str(e)) messages.error(request, str(e))
@property @property
def settings_form_fields(self) -> dict: def settings_form_fields(self) -> dict:
+3 -1
View File
@@ -15,10 +15,11 @@ import time
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from pretix.celery_app import app from pretix.celery import app
class ProfiledTask(app.Task): class ProfiledTask(app.Task):
abstract = True
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
@@ -42,6 +43,7 @@ class TransactionAwareTask(ProfiledTask):
Task class which is aware of django db transactions and only executes tasks Task class which is aware of django db transactions and only executes tasks
after transaction has been committed after transaction has been committed
""" """
abstract = True
def apply_async(self, *args, **kwargs): def apply_async(self, *args, **kwargs):
""" """
+232 -284
View File
@@ -1,22 +1,20 @@
from collections import Counter, namedtuple from collections import Counter
from datetime import timedelta from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
from typing import List, Optional from typing import List
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from pretix.base.decimal import round_decimal
from pretix.base.i18n import LazyLocaleException from pretix.base.i18n import LazyLocaleException
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Event, Item, ItemVariation, Voucher, CartPosition, Event, Item, ItemVariation, Quota, Voucher,
) )
from pretix.base.services.async import ProfiledTask from pretix.base.services.async import ProfiledTask
from pretix.base.services.locking import LockTimeoutException from pretix.base.services.locking import LockTimeoutException
from pretix.celery_app import app from pretix.celery import app
class CartError(LazyLocaleException): class CartError(LazyLocaleException):
@@ -39,320 +37,253 @@ error_messages = {
'voucher_invalid': _('This voucher code is not known in our database.'), 'voucher_invalid': _('This voucher code is not known in our database.'),
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'), 'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'), 'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'),
'voucher_double': _('You already used this voucher code. Remove the associated line from your '
'cart if you want to use it for a different product.'),
'voucher_expired': _('This voucher is expired.'), 'voucher_expired': _('This voucher is expired.'),
'voucher_invalid_item': _('This voucher is not valid for this product.'), 'voucher_invalid_item': _('This voucher is not valid for this product.'),
'voucher_required': _('You need a valid voucher code to order this product.'), 'voucher_required': _('You need a valid voucher code to order this product.'),
} }
class CartManager: def _extend_existing(event: Event, cart_id: str, expiry: datetime, now_dt: datetime) -> None:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas')) # Extend this user's cart session to 30 minutes from now to ensure all items in the
RemoveOperation = namedtuple('RemoveOperation', ('position',)) # cart expire at the same time
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher', # We can extend the reservation of items which are not yet expired without risk
'quotas')) CartPosition.objects.filter(
order = { Q(cart_id=cart_id) & Q(event=event) & Q(expires__gt=now_dt)
RemoveOperation: 10, ).update(expires=expiry)
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
@property def _re_add_expired_positions(items: List[dict], event: Event, cart_id: str, now_dt: datetime) -> List[CartPosition]:
def positions(self): positions = set()
return CartPosition.objects.filter( # For items that are already expired, we have to delete and re-add them, as they might
Q(cart_id=self.cart_id) & Q(event=self.event) # 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
def _calculate_expiry(self):
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
def _check_presale_dates(self): def _delete_expired(expired: List[CartPosition], now_dt: datetime) -> None:
if self.event.presale_start and self.now_dt < self.event.presale_start: for cp in expired:
raise CartError(error_messages['not_started']) if cp.expires <= now_dt: # Has not been extended
if self.event.presale_end and self.now_dt > self.event.presale_end: cp.delete()
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 _delete_expired(self, expired: List[CartPosition]): def _check_date(event: Event, now_dt: datetime) -> None:
for cp in expired: if event.presale_start and now_dt < event.presale_start:
if cp.expires <= self.now_dt: raise CartError(error_messages['not_started'])
cp.delete() if event.presale_end and now_dt > event.presale_end:
raise CartError(error_messages['ended'])
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 _check_max_cart_size(self): def _parse_items_and_check_constraints(event: Event, items: List[dict], cart_id: str,
cartsize = self.positions.count() now_dt: datetime) -> Counter:
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)]) This method does three things:
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,))
def _check_item_constraints(self, op): * Extend the item list with the database objects for the item, variation, etc.
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'])
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): * Check all constraints that are placed on the items, vouchers etc. to be valid and calculates the correct prices
raise CartError(error_messages['voucher_required'])
if not op.item.is_available() or (op.variation and not op.variation.active): * Return a counter object that contains the quota changes that are required to perform the operation
raise CartError(error_messages['unavailable']) """
err = None
if op.voucher and not op.voucher.applies_to(op.item, op.variation): # Fetch items from the database
raise CartError(error_messages['voucher_invalid_item']) 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 _get_price(self, item: Item, variation: Optional[ItemVariation], quotadiff = Counter()
voucher: Optional[Voucher], custom_price: Optional[Decimal]): vouchers = Counter()
price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price
)
if voucher:
price = voucher.calculate_price(price)
if item.free_price and custom_price is not None and custom_price != "": for i in items:
# Check whether the specified items are part of what we just fetched from the database
# If they are not, the user supplied item IDs which either do not exist or belong to
# a different event
if i['item'] not in items_cache or (i['variation'] is not None and i['variation'] not in variations_cache):
err = err or error_messages['not_for_sale']
continue
item = items_cache[i['item']]
variation = variations_cache[i['variation']] if i['variation'] is not None else None
# Check whether a voucher has been provided
voucher = None
if i.get('voucher'):
try:
voucher = Voucher.objects.get(code=i.get('voucher').strip(), event=event)
if voucher.redeemed >= voucher.max_usages:
raise CartError(error_messages['voucher_redeemed'])
if voucher.valid_until is not None and voucher.valid_until < now_dt:
raise CartError(error_messages['voucher_expired'])
if not voucher.applies_to(item, variation):
raise CartError(error_messages['voucher_invalid_item'])
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=voucher) & Q(event=event) & Q(expires__gte=now_dt)
)
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:
raise CartError(error_messages['voucher_redeemed'])
if i['count'] > v_avail - vouchers[voucher]:
raise CartError(error_messages['voucher_redeemed_partial'] % v_avail)
vouchers[voucher] += i['count']
except Voucher.DoesNotExist:
raise CartError(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]:
raise CartError(error_messages['voucher_invalid_item'])
if item.require_voucher and voucher is None:
raise CartError(error_messages['voucher_required'])
if item.hide_without_voucher and (voucher is None or voucher.item is None or voucher.item.pk != item.pk):
raise CartError(error_messages['voucher_required'])
if len(quotas) == 0 or not item.is_available() or (variation and not variation.active):
err = err or error_messages['unavailable']
continue
if voucher and voucher.price is not None:
price = voucher.price
else:
price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price)
if item.free_price and 'price' in i and i['price'] is not None and i['price'] != "":
custom_price = i['price']
if not isinstance(custom_price, Decimal): if not isinstance(custom_price, Decimal):
custom_price = Decimal(custom_price.replace(",", ".")) custom_price = Decimal(custom_price.replace(",", "."))
if custom_price > 100000000: 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) price = max(custom_price, price)
return price # Check that all quotas allow us to buy i['count'] instances of the object
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
for quota in quotas:
quotadiff[quota] += i['count']
i['_quotas'] = quotas
else:
i['_quotas'] = []
def extend_expired_positions(self): i['_price'] = price
expired = self.positions.filter(expires__lte=self.now_dt).select_related( i['_item'] = item
'item', 'variation', 'voucher' i['_variation'] = variation
).prefetch_related('item__quotas', 'variation__quotas') i['_voucher'] = voucher
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 err:
if not quotas: raise CartError(err)
raise CartError(error_messages['unavailable'])
if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota):
for quota in quotas:
self._quota_diff[quota] += 1
else:
quotas = []
op = self.ExtendOperation( return quotadiff
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
price=price, quotas=quotas
)
self._check_item_constraints(op)
if cp.voucher:
self._voucher_use_diff[cp.voucher] += 1
self._operations.append(op) def _check_quota_and_create_positions(event: Event, items: List[dict], cart_id: str, now_dt: datetime,
expiry: datetime, quotadiff: Counter):
"""
This method takes the modified items and the quotadiff from _parse_items_and_check_constraints
and then
def add_new_items(self, items: List[dict]): * checks that the given quotas are available
# 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 = []
for i in items: * creates as many cart positions as possible
# 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 err = None
# a different event quotas_ok = {}
if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache): cartpositions = []
raise CartError(error_messages['not_for_sale'])
item = self._items_cache[i['item']] with event.lock():
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None for quota, count in quotadiff.items():
voucher = None avail = quota.availability(now_dt)
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:
cw &= Q(variation__isnull=True)
# Prefer to delete positions that have the same price as the one the user clicked on, after thet
# prefer the most expensive ones.
cnt = i['count']
if i['price']:
correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(i['price'].replace(",", ".")))[:cnt]
for cp in correctprice:
self._operations.append(self.RemoveOperation(position=cp))
cnt -= len(correctprice)
if cnt > 0:
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
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: if avail[1] is not None and avail[1] < count:
# This quota is not available or less than i['count'] items are left, so we have to
# reduce the number of bought items
if avail[0] != Quota.AVAILABILITY_OK:
err = err or error_messages['unavailable']
else:
err = err or error_messages['in_part']
quotas_ok[quota] = min(count, avail[1]) quotas_ok[quota] = min(count, avail[1])
else: else:
quotas_ok[quota] = count quotas_ok[quota] = count
return quotas_ok
def _get_voucher_availability(self): for i in items:
vouchers_ok = {} # Create a CartPosition for as much items as we can
for voucher, count in self._voucher_use_diff.items(): requested_count = i['count']
voucher.refresh_from_db() available_count = requested_count
if i['_quotas']:
available_count = min(requested_count, min(quotas_ok[q] for q in i['_quotas']))
if voucher.valid_until is not None and voucher.valid_until < self.now_dt: for q in i['_quotas']:
raise CartError(error_messages['voucher_expired']) quotas_ok[q] -= available_count
redeemed_in_carts = CartPosition.objects.filter( for k in range(available_count):
Q(voucher=voucher) & Q(event=self.event) & if '_cp' in i and i['count'] == 1:
Q(expires__gte=self.now_dt) # Recreating an existing position
).exclude(pk__in=[ cp = i['_cp']
op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation) cp.expires = expiry
]) cp.price = i['_price']
v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count() cp.save()
vouchers_ok[voucher] = v_avail else:
cartpositions.append(CartPosition(
event=event, item=i['_item'], variation=i['_variation'],
price=i['_price'],
expires=expiry,
cart_id=cart_id, voucher=i['_voucher']
))
return vouchers_ok CartPosition.objects.bulk_create(cartpositions)
def _perform_operations(self): if err:
vouchers_ok = self._get_voucher_availability() raise CartError(err)
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 _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> None:
now_dt = now()
_check_date(event, now_dt)
existing = CartPosition.objects.filter(Q(cart_id=cart_id) & Q(event=event)).count()
if sum(i['count'] for i in items) + existing > int(event.settings.max_items_per_order):
# TODO: i18n plurals
raise CartError(error_messages['max_items'], (event.settings.max_items_per_order,))
expiry = now_dt + timedelta(minutes=event.settings.get('reservation_time', as_type=int))
_extend_existing(event, cart_id, expiry, now_dt)
expired = _re_add_expired_positions(items, event, cart_id, now_dt)
try:
if items:
quotadiff = _parse_items_and_check_constraints(event, items, cart_id, now_dt)
_check_quota_and_create_positions(event, items, cart_id, now_dt, expiry, quotadiff)
except CartError as e:
_delete_expired(expired, now_dt)
raise e
else:
_delete_expired(expired, now_dt)
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None) -> None: 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. Adds a list of items to a user's cart.
@@ -365,16 +296,35 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None) ->
event = Event.objects.get(id=event) event = Event.objects.get(id=event)
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id) _add_items_to_cart(event, items, cart_id)
cm.add_new_items(items)
cm.commit()
except LockTimeoutException: except LockTimeoutException:
self.retry() self.retry()
except (MaxRetriesExceededError, LockTimeoutException): except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy']) 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(event: Event, items: List[dict], cart_id: str) -> None:
with event.lock():
for i in items:
cw = Q(cart_id=cart_id) & Q(item_id=i['item']) & Q(event=event)
if i['variation']:
cw &= Q(variation_id=i['variation'])
else:
cw &= Q(variation__isnull=True)
# Prefer to delete positions that have the same price as the one the user clicked on, after thet
# prefer the most expensive ones.
cnt = i['count']
if i['price']:
correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(i['price'].replace(",", ".")))[:cnt]
for cp in correctprice:
cp.delete()
cnt -= len(correctprice)
if cnt > 0:
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
cp.delete()
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None) -> None: def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None) -> None:
""" """
Removes a list of items from a user's cart. Removes a list of items from a user's cart.
@@ -385,9 +335,7 @@ def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=Non
event = Event.objects.get(id=event) event = Event.objects.get(id=event)
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id) _remove_items_from_cart(event, items, cart_id)
cm.remove_items(items)
cm.commit()
except LockTimeoutException: except LockTimeoutException:
self.retry() self.retry()
except (MaxRetriesExceededError, LockTimeoutException): except (MaxRetriesExceededError, LockTimeoutException):
+1 -1
View File
@@ -6,7 +6,7 @@ from pretix.base.i18n import language
from pretix.base.models import CachedFile, Event, cachedfile_name from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.async import ProfiledTask from pretix.base.services.async import ProfiledTask
from pretix.base.signals import register_data_exporters from pretix.base.signals import register_data_exporters
from pretix.celery_app import app from pretix.celery import app
@app.task(base=ProfiledTask) @app.task(base=ProfiledTask)
+28 -49
View File
@@ -3,18 +3,17 @@ import tempfile
from collections import defaultdict from collections import defaultdict
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from locale import format as lformat
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import transaction from django.db import transaction
from django.utils.formats import date_format, localize from django.utils.formats import date_format
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext as _ from django.utils.translation import pgettext, ugettext as _
from i18nfield.strings import LazyI18nString
from reportlab.lib import pagesizes from reportlab.lib import pagesizes
from reportlab.lib.styles import ParagraphStyle, StyleSheet1 from reportlab.lib.styles import ParagraphStyle, StyleSheet1
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import ( from reportlab.platypus import (
@@ -22,11 +21,11 @@ from reportlab.platypus import (
Table, TableStyle, Table, TableStyle,
) )
from pretix.base.i18n import language from pretix.base.i18n import LazyI18nString, language
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
from pretix.base.services.async import TransactionAwareTask from pretix.base.services.async import TransactionAwareTask
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
from pretix.celery_app import app from pretix.celery import app
from pretix.helpers.database import rolledback_transaction from pretix.helpers.database import rolledback_transaction
@@ -183,23 +182,19 @@ def _invoice_generate_german(invoice, f):
textobject = canvas.beginText(25 * mm, (297 - 15) * mm) textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
textobject.setFont('OpenSansBd', 8) textobject.setFont('OpenSansBd', 8)
textobject.textLine(pgettext('invoice', 'Invoice from').upper()) textobject.textLine(pgettext('invoice', 'Invoice from').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.textLines(invoice.invoice_from.strip())
canvas.drawText(textobject) 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 = canvas.beginText(25 * mm, (297 - 50) * mm)
textobject.setFont('OpenSansBd', 8) textobject.setFont('OpenSansBd', 8)
textobject.textLine(pgettext('invoice', 'Invoice to').upper()) textobject.textLine(pgettext('invoice', 'Invoice to').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.textLines(invoice.invoice_to.strip())
canvas.drawText(textobject) 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 = canvas.beginText(125 * mm, (297 - 50) * mm)
textobject.setFont('OpenSansBd', 8) textobject.setFont('OpenSansBd', 8)
if invoice.is_cancellation: if invoice.is_cancellation:
@@ -257,33 +252,18 @@ def _invoice_generate_german(invoice, f):
textobject.textLine(date_format(invoice.order.datetime, "DATE_FORMAT")) textobject.textLine(date_format(invoice.order.datetime, "DATE_FORMAT"))
canvas.drawText(textobject) canvas.drawText(textobject)
if invoice.event.settings.invoice_logo_image:
logo_file = invoice.event.settings.get('invoice_logo_image', binary_file=True)
canvas.drawImage(ImageReader(logo_file),
95 * mm, (297 - 38) * mm,
width=25 * mm, height=25 * mm,
preserveAspectRatio=True, anchor='n',
mask='auto')
if invoice.event.settings.show_date_to:
p_str = (
str(invoice.event.name) + '\n' + _('{from_date}\nuntil {to_date}').format(
from_date=invoice.event.get_date_from_display(),
to_date=invoice.event.get_date_to_display())
)
else:
p_str = (
str(invoice.event.name) + '\n' + invoice.event.get_date_from_display()
)
p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=styles['Normal'])
p.wrapOn(canvas, 65 * mm, 50 * mm)
p_size = p.wrap(65 * mm, 50 * mm)
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
textobject = canvas.beginText(125 * mm, (297 - 15) * mm) textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
textobject.setFont('OpenSansBd', 8) textobject.setFont('OpenSansBd', 8)
textobject.textLine(_('Event').upper()) 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.drawText(textobject)
canvas.restoreState() canvas.restoreState()
@@ -326,7 +306,6 @@ def _invoice_generate_german(invoice, f):
tstyledata = [ tstyledata = [
('ALIGN', (1, 0), (-1, -1), 'RIGHT'), ('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'), ('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'), ('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
('LEFTPADDING', (0, 0), (0, -1), 0), ('LEFTPADDING', (0, 0), (0, -1), 0),
@@ -341,16 +320,16 @@ def _invoice_generate_german(invoice, f):
total = Decimal('0.00') total = Decimal('0.00')
for line in invoice.lines.all(): for line in invoice.lines.all():
tdata.append(( tdata.append((
Paragraph(line.description, styles['Normal']), line.description,
localize(line.tax_rate) + " %", lformat("%.2f", line.tax_rate) + " %",
localize(line.net_value) + " " + invoice.event.currency, lformat("%.2f", line.net_value) + " " + invoice.event.currency,
localize(line.gross_value) + " " + invoice.event.currency, lformat("%.2f", line.gross_value) + " " + invoice.event.currency,
)) ))
taxvalue_map[line.tax_rate] += line.tax_value taxvalue_map[line.tax_rate] += line.tax_value
grossvalue_map[line.tax_rate] += line.gross_value grossvalue_map[line.tax_rate] += line.gross_value
total += line.gross_value total += line.gross_value
tdata.append([pgettext('invoice', 'Invoice total'), '', '', localize(total) + " " + invoice.event.currency]) tdata.append([pgettext('invoice', 'Invoice total'), '', '', lformat("%.2f", total) + " " + invoice.event.currency])
colwidths = [a * doc.width for a in (.55, .15, .15, .15)] colwidths = [a * doc.width for a in (.55, .15, .15, .15)]
table = Table(tdata, colWidths=colwidths, repeatRows=1) table = Table(tdata, colWidths=colwidths, repeatRows=1)
table.setStyle(TableStyle(tstyledata)) table.setStyle(TableStyle(tstyledata))
@@ -382,10 +361,10 @@ def _invoice_generate_german(invoice, f):
tax = taxvalue_map[rate] tax = taxvalue_map[rate]
tdata.append(( tdata.append((
'', '',
localize(rate) + " %", lformat("%.2f", rate) + " %",
localize((gross - tax)) + " " + invoice.event.currency, lformat("%.2f", (gross - tax)) + " " + invoice.event.currency,
localize(gross) + " " + invoice.event.currency, lformat("%.2f", gross) + " " + invoice.event.currency,
localize(tax) + " " + invoice.event.currency, lformat("%.2f", tax) + " " + invoice.event.currency,
)) ))
if len(tdata) > 2: if len(tdata) > 2:
+20 -68
View File
@@ -1,24 +1,18 @@
import logging import logging
from typing import Any, Dict, Union from typing import Any, Dict
import bleach
import cssutils
import markdown
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMessage, get_connection
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from i18nfield.strings import LazyI18nString
from inlinestyler.utils import inline_css
from pretix.base.i18n import language from pretix.base.i18n import LazyI18nString, language
from pretix.base.models import Event, InvoiceAddress, Order from pretix.base.models import Event, Order
from pretix.celery_app import app from pretix.celery import app
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger('pretix.base.mail') logger = logging.getLogger('pretix.base.mail')
INVALID_ADDRESS = 'invalid-pretix-mail-address' INVALID_ADDRESS = 'invalid-pretix-mail-address'
cssutils.log.setLevel(logging.CRITICAL)
class TolerantDict(dict): class TolerantDict(dict):
@@ -31,7 +25,7 @@ class SendMailException(Exception):
pass pass
def mail(email: str, subject: str, template: Union[str, LazyI18nString], def mail(email: str, subject: str, template: str,
context: Dict[str, Any]=None, event: Event=None, locale: str=None, context: Dict[str, Any]=None, event: Event=None, locale: str=None,
order: Order=None, headers: dict=None): order: Order=None, headers: dict=None):
""" """
@@ -44,7 +38,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
:param template: The filename of a template to be used. It will be rendered with the locale given in the locale :param template: The filename of a template to be used. It will be rendered with the locale given in the locale
argument and the context given in the next argument. Alternatively, you can pass a LazyI18nString and argument and the context given in the next argument. Alternatively, you can pass a LazyI18nString and
``context`` will be used as the argument to a Python ``.format_map()`` call on the template. ``context`` will be used as the argument to a Python ``.format()`` call on the template.
:param context: The context for rendering the template (see ``template`` parameter) :param context: The context for rendering the template (see ``template`` parameter)
@@ -64,84 +58,42 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
if email == INVALID_ADDRESS: if email == INVALID_ADDRESS:
return return
headers = headers or {}
with language(locale): with language(locale):
if isinstance(context, dict) and order:
try:
context.update({
'invoice_name': order.invoice_address.name,
'invoice_company': order.invoice_address.company
})
except InvoiceAddress.DoesNotExist:
context.update({
'invoice_name': '',
'invoice_company': ''
})
if isinstance(template, LazyI18nString): if isinstance(template, LazyI18nString):
body = str(template) body = str(template)
if context: if context:
body = body.format_map(TolerantDict(context)) body = body.format_map(TolerantDict(context))
body_md = bleach.linkify(bleach.clean(markdown.markdown(body), tags=bleach.ALLOWED_TAGS + [
'p',
]))
else: else:
tpl = get_template(template) tpl = get_template(template)
body = tpl.render(context) body = tpl.render(context)
body_md = bleach.linkify(markdown.markdown(body))
sender = event.settings.get('mail_from') if event else settings.MAIL_FROM sender = event.settings.get('mail_from') if event else settings.MAIL_FROM
subject = str(subject) subject = str(subject)
body_plain = body
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
'body': body_md,
'color': '#8E44B3'
}
if event: if event:
htmlctx['event'] = event
htmlctx['color'] = event.settings.primary_color
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail:
headers['Reply-To'] = event.settings.contact_mail
prefix = event.settings.get('mail_prefix') prefix = event.settings.get('mail_prefix')
if prefix: if prefix:
subject = "[%s] %s" % (prefix, subject) subject = "[%s] %s" % (prefix, subject)
body_plain += "\r\n\r\n-- \r\n" body += "\r\n\r\n-- \r\n"
body_plain += _( body += _(
"You are receiving this email because you placed an order for {event}." "You are receiving this email because you placed an order for {event}."
).format(event=event.name) ).format(event=event.name)
if order: if order:
htmlctx['order'] = order body += "\r\n"
body_plain += "\r\n" body += _(
body_plain += _( "You can view your order details at the following URL:\r\n{orderurl}."
"You can view your order details at the following URL:\n{orderurl}." ).format(event=event.name, orderurl=build_absolute_uri(order.event, 'presale:event.order', kwargs={
).replace("\n", "\r\n").format( 'order': order.code,
event=event.name, orderurl=build_absolute_uri( 'secret': order.secret
order.event, 'presale:event.order', kwargs={ }))
'order': order.code, body += "\r\n"
'secret': order.secret return mail_send([email], subject, body, sender, event.id if event else None, headers)
}
)
)
body_plain += "\r\n"
tpl = get_template('pretixbase/email/plainwrapper.html')
body_html = tpl.render(htmlctx)
return mail_send([email], subject, body_plain, body_html, sender, event.id if event else None, headers)
@app.task @app.task
def mail_send_task(to: str, subject: str, body: str, html: str, sender: str, def mail_send_task(to: str, subject: str, body: str, sender: str, event: int=None, headers: dict=None) -> bool:
event: int=None, headers: dict=None) -> bool: email = EmailMessage(subject, body, sender, to=to, headers=headers)
email = EmailMultiAlternatives(subject, body, sender, to=to, headers=headers)
email.attach_alternative(inline_css(html), "text/html")
if event: if event:
event = Event.objects.get(id=event) event = Event.objects.get(id=event)
backend = event.get_mail_backend() backend = event.get_mail_backend()
+172 -182
View File
@@ -1,3 +1,4 @@
import contextlib
import json import json
import logging import logging
from collections import Counter, namedtuple from collections import Counter, namedtuple
@@ -7,7 +8,6 @@ from typing import List, Optional
import pytz import pytz
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.dispatch import receiver from django.dispatch import receiver
@@ -22,7 +22,7 @@ from pretix.base.models import (
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota, CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
User, Voucher, User, Voucher,
) )
from pretix.base.models.orders import CachedTicket, InvoiceAddress from pretix.base.models.orders import InvoiceAddress
from pretix.base.payment import BasePaymentProvider from pretix.base.payment import BasePaymentProvider
from pretix.base.services.async import ProfiledTask from pretix.base.services.async import ProfiledTask
from pretix.base.services.invoices import ( from pretix.base.services.invoices import (
@@ -33,7 +33,7 @@ from pretix.base.services.mail import SendMailException, mail
from pretix.base.signals import ( from pretix.base.signals import (
order_paid, order_placed, periodic_task, register_payment_providers, order_paid, order_placed, periodic_task, register_payment_providers,
) )
from pretix.celery_app import app from pretix.celery import app
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
error_messages = { error_messages = {
@@ -44,7 +44,6 @@ error_messages = {
'price_changed': _('The price of some of the items in your cart has changed in the ' 'price_changed': _('The price of some of the items in your cart has changed in the '
'meantime. Please see below for details.'), 'meantime. Please see below for details.'),
'internal': _("An internal error occured, please try again."), 'internal': _("An internal error occured, please try again."),
'empty': _("Your cart is empty."),
'busy': _('We were not able to process your request completely as the ' 'busy': _('We were not able to process your request completely as the '
'server was too busy. Please try again.'), 'server was too busy. Please try again.'),
'not_started': _('The presale period for this event has not yet started.'), 'not_started': _('The presale period for this event has not yet started.'),
@@ -52,6 +51,8 @@ error_messages = {
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'), 'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum ' 'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
'number of times allowed. We removed this item from your cart.'), 'number of times allowed. We removed this item from your cart.'),
'voucher_redeemed_partial': _('The voucher code used for one of the items in your cart can only be redeemed %d '
'more times. We removed this item from your cart.'),
'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item ' 'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item '
'from your cart.'), 'from your cart.'),
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We ' 'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
@@ -64,7 +65,7 @@ logger = logging.getLogger(__name__)
def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None, def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None,
force: bool=False, send_mail: bool=True, user: User=None, mail_text='') -> Order: force: bool=False, send_mail: bool=True, user: User=None) -> Order:
""" """
Marks an order as paid. This sets the payment provider, info and date and returns Marks an order as paid. This sets the payment provider, info and date and returns
the order object. the order object.
@@ -82,14 +83,17 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``). :param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
:type send_mail: boolean :type send_mail: boolean
:param user: The user that performed the change :param user: The user that performed the change
:param mail_text: Additional text to be included in the email
:type mail_text: str
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False`` :raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
""" """
if order.status == Order.STATUS_PAID: lock_func = order.event.lock
return order if order.status == order.STATUS_PENDING and order.expires > now() + timedelta(minutes=10):
# No lock necessary in this case. The 10 minute offset is just to be safe and prevent
# collisions with the cronjob.
@contextlib.contextmanager
def lock_func():
yield now()
with order.event.lock() as now_dt: with lock_func() as now_dt:
can_be_paid = order._can_be_paid() can_be_paid = order._can_be_paid()
if not force and can_be_paid is not True: if not force and can_be_paid is not True:
raise Quota.QuotaExceededException(can_be_paid) raise Quota.QuotaExceededException(can_be_paid)
@@ -104,24 +108,14 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
order.log_action('pretix.event.order.paid', { order.log_action('pretix.event.order.paid', {
'provider': provider, 'provider': provider,
'info': info, 'info': info,
'date': date or now_dt, 'date': date,
'manual': manual, 'manual': manual,
'force': force 'force': force
}, user=user) }, user=user)
order_paid.send(order.event, order=order) order_paid.send(order.event, order=order)
if order.event.settings.get('invoice_generate') in ('True', 'paid') and invoice_qualified(order):
if not order.invoices.exists():
generate_invoice(order)
if send_mail: if send_mail:
with language(order.locale): with language(order.locale):
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
mail( mail(
order.email, _('Payment received for your order: %(code)s') % {'code': order.code}, order.email, _('Payment received for your order: %(code)s') % {'code': order.code},
order.event.settings.mail_text_order_paid, order.event.settings.mail_text_order_paid,
@@ -131,10 +125,7 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
'order': order.code, 'order': order.code,
'secret': order.secret 'secret': order.secret
}), }),
'downloads': order.event.settings.get('ticket_download', as_type=bool), 'downloads': order.event.settings.get('ticket_download', as_type=bool)
'invoice_name': invoice_name,
'invoice_company': invoice_company,
'payment_info': mail_text
}, },
order.event, locale=order.locale order.event, locale=order.locale
) )
@@ -190,7 +181,7 @@ def _cancel_order(order, user=None):
if position.voucher: if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1) Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
return order.pk return order
class OrderError(LazyLocaleException): class OrderError(LazyLocaleException):
@@ -205,6 +196,9 @@ def _check_date(event: Event, now_dt: datetime):
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition]): def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition]):
"""
Checks constraints on all positions except quota
"""
err = None err = None
_check_date(event, now_dt) _check_date(event, now_dt)
@@ -213,17 +207,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
cp.delete() cp.delete()
continue continue
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) cp._quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
if cp.voucher:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
).exclude(pk=cp.pk)
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
err = err or error_messages['voucher_redeemed']
cp.delete() # Sorry!
continue
if cp.item.require_voucher and cp.voucher is None: if cp.item.require_voucher and cp.voucher is None:
cp.delete() cp.delete()
@@ -243,7 +227,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
price = cp.item.default_price if cp.variation is None else ( price = cp.item.default_price if cp.variation is None else (
cp.variation.default_price if cp.variation.default_price is not None else cp.item.default_price) cp.variation.default_price if cp.variation.default_price is not None else cp.item.default_price)
if price is False or len(quotas) == 0: if price is False or len(cp._quotas) == 0:
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
cp.delete() cp.delete()
continue continue
@@ -253,7 +237,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = err or error_messages['voucher_expired'] err = err or error_messages['voucher_expired']
cp.delete() cp.delete()
continue continue
price = cp.voucher.calculate_price(price) if cp.voucher.price is not None:
price = cp.voucher.price
if price != cp.price and not (cp.item.free_price and cp.price > price): if price != cp.price and not (cp.item.free_price and cp.price > price):
positions[i] = cp positions[i] = cp
@@ -261,53 +246,126 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.save() cp.save()
err = err or error_messages['price_changed'] err = err or error_messages['price_changed']
continue continue
quota_ok = True
ignore_all_quotas = cp.expires >= now_dt or (
cp.voucher and (cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)))
if not ignore_all_quotas:
for quota in quotas:
if cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id == quota.pk:
continue
avail = quota.availability(now_dt)
if avail[0] != Quota.AVAILABILITY_OK:
# This quota is sold out/currently unavailable, so do not sell this at all
err = err or error_messages['unavailable']
quota_ok = False
break
if quota_ok:
positions[i] = cp
cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
else:
cp.delete() # Sorry!
if err: if err:
raise OrderError(err) raise OrderError(err)
@transaction.atomic
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_provider: BasePaymentProvider, locale: str=None, address: int=None, payment_provider: BasePaymentProvider, expires: datetime, locale: str=None, address: int=None,
meta_info: dict=None): meta_info: dict=None):
from datetime import date, time
total = sum([c.price for c in positions]) total = sum([c.price for c in positions])
payment_fee = payment_provider.calculate_fee(total) payment_fee = payment_provider.calculate_fee(total)
total += payment_fee total += payment_fee
tz = pytz.timezone(event.settings.timezone) order = Order.objects.create(
exp_by_date = now_dt.astimezone(tz) + timedelta(days=event.settings.get('payment_term_days', as_type=int)) status=Order.STATUS_PENDING,
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0) event=event,
if event.settings.get('payment_term_weekdays'): email=email,
if exp_by_date.weekday() == 5: datetime=now_dt,
exp_by_date += timedelta(days=2) expires=expires,
elif exp_by_date.weekday() == 6: locale=locale,
exp_by_date += timedelta(days=1) total=total,
payment_fee=payment_fee,
payment_provider=payment_provider.identifier,
meta_info=json.dumps(meta_info or {}),
)
OrderPosition.transform_cart_positions(positions, order)
expires = exp_by_date if address is not None:
try:
addr = InvoiceAddress.objects.get(
pk=address
)
if addr.order is not None:
addr.pk = None
addr.order = order
addr.save()
except InvoiceAddress.DoesNotExist:
pass
order.log_action('pretix.event.order.placed')
order_placed.send(event, order=order)
return order
def _check_quota_on_expired_positions(event: Event, positions: List[CartPosition], now_dt: datetime):
err = None
quotadiff = Counter()
vouchers = Counter()
for cp in positions:
if not cp.id:
continue
ignore_all_quotas = cp.expires >= now_dt or (
cp.voucher and (cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)))
if ignore_all_quotas:
cp._quotas = []
elif cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id:
cp._quotas = [q for q in cp._quotas if cp.voucher.quota_id != q.pk]
for quota in cp._quotas:
quotadiff[quota] += 1
quotas_ok = {}
for quota, count in quotadiff.items():
avail = quota.availability(now_dt)
if avail[1] is not None and avail[1] < count:
# This quota is not available or less than items are than requested left, so we have to
# reduce the number of bought items
if avail[0] != Quota.AVAILABILITY_OK:
err = err or error_messages['unavailable']
else:
err = err or error_messages['in_part']
quotas_ok[quota] = min(count, avail[1])
else:
quotas_ok[quota] = count
for cp in positions:
if not cp.id:
continue
if cp.voucher:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
).exclude(pk__in=[cp2.pk for cp2 in positions])
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
err = err or error_messages['voucher_redeemed']
cp.delete() # Sorry!
continue
if v_avail - vouchers[cp.voucher] < 1:
err = err or (error_messages['voucher_redeemed_partial'] % v_avail)
cp.delete() # Sorry!
continue
vouchers[cp.voucher] += 1
if cp._quotas:
if min(quotas_ok[q] for q in cp._quotas) > 0:
cp.expires = now_dt + timedelta(minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
for q in cp._quotas:
quotas_ok[q] -= 1
else:
cp.delete()
if err:
raise OrderError(err)
def _calculate_expiry(event: Event, now_dt: datetime):
from datetime import date, time
tz = pytz.timezone(event.settings.timezone)
expires = now_dt.astimezone(tz) + timedelta(days=event.settings.get('payment_term_days', as_type=int))
expires = expires.replace(hour=23, minute=59, second=59, microsecond=0)
if event.settings.get('payment_term_weekdays'):
if expires.weekday() == 5:
expires += timedelta(days=2)
elif expires.weekday() == 6:
expires += timedelta(days=1)
if event.settings.get('payment_term_last'): if event.settings.get('payment_term_last'):
last_date = make_aware(datetime.combine( last_date = make_aware(datetime.combine(
@@ -317,37 +375,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
if last_date < expires: if last_date < expires:
expires = last_date expires = last_date
with transaction.atomic(): return expires
order = Order.objects.create(
status=Order.STATUS_PENDING,
event=event,
email=email,
datetime=now_dt,
expires=expires,
locale=locale,
total=total,
payment_fee=payment_fee,
payment_provider=payment_provider.identifier,
meta_info=json.dumps(meta_info or {}),
)
OrderPosition.transform_cart_positions(positions, order)
if address is not None:
try:
addr = InvoiceAddress.objects.get(
pk=address
)
if addr.order is not None:
addr.pk = None
addr.order = order
addr.save()
except InvoiceAddress.DoesNotExist:
pass
order.log_action('pretix.event.order.placed')
order_placed.send(event, order=order)
return order
def _perform_order(event: str, payment_provider: str, position_ids: List[str], def _perform_order(event: str, payment_provider: str, position_ids: List[str],
@@ -363,15 +391,18 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
if not pprov: if not pprov:
raise OrderError(error_messages['internal']) raise OrderError(error_messages['internal'])
now_dt = now()
positions = list(CartPosition.objects.filter(id__in=position_ids).select_related('item', 'variation'))
if set(str(p) for p in position_ids) != set(str(p.id) for p in positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions)
expires = _calculate_expiry(event, now_dt)
with event.lock() as now_dt: with event.lock() as now_dt:
positions = list(CartPosition.objects.filter( _check_quota_on_expired_positions(event, positions, now_dt)
id__in=position_ids).select_related('item', 'variation')) order = _create_order(event, email, positions, now_dt, pprov, expires,
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions)
order = _create_order(event, email, positions, now_dt, pprov,
locale=locale, address=address, meta_info=meta_info) locale=locale, address=address, meta_info=meta_info)
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order): if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
@@ -383,14 +414,6 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
mailtext = event.settings.mail_text_order_free mailtext = event.settings.mail_text_order_free
else: else:
mailtext = event.settings.mail_text_order_placed mailtext = event.settings.mail_text_order_placed
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
mail( mail(
order.email, _('Your order: %(code)s') % {'code': order.code}, order.email, _('Your order: %(code)s') % {'code': order.code},
mailtext, mailtext,
@@ -403,9 +426,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
'order': order.code, 'order': order.code,
'secret': order.secret 'secret': order.secret
}), }),
'paymentinfo': str(pprov.order_pending_mail_render(order)), 'paymentinfo': str(pprov.order_pending_mail_render(order))
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}, },
event, locale=order.locale event, locale=order.locale
) )
@@ -434,39 +455,29 @@ def send_expiry_warnings(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0) today = now().replace(hour=0, minute=0, second=0)
for o in Order.objects.filter(expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING).select_related('event'): for o in Order.objects.filter(expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING).select_related('event'):
eventsettings = eventcache.get(o.event.pk, None) settings = eventcache.get(o.event.pk, None)
if eventsettings is None: if settings is None:
eventsettings = o.event.settings settings = o.event.settings
eventcache[o.event.pk] = eventsettings eventcache[o.event.pk] = settings
days = eventsettings.get('mail_days_order_expire_warning', as_type=int) days = settings.get('mail_days_order_expire_warning', as_type=int)
tz = pytz.timezone(eventsettings.get('timezone', settings.TIME_ZONE))
if days and (o.expires - today).days <= days: if days and (o.expires - today).days <= days:
o.expiry_reminder_sent = True o.expiry_reminder_sent = True
o.save() o.save()
try: try:
invoice_name = o.invoice_address.name mail(
invoice_company = o.invoice_address.company o.email, _('Your order is about to expire: %(code)s') % {'code': o.code},
except InvoiceAddress.DoesNotExist: settings.mail_text_order_expire_warning,
invoice_name = "" {
invoice_company = "" 'event': o.event.name,
try: 'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
with language(o.locale): 'order': o.code,
mail( 'secret': o.secret
o.email, _('Your order is about to expire: %(code)s') % {'code': o.code}, }),
eventsettings.mail_text_order_expire_warning, 'expire_date': date_format(o.expires, 'SHORT_DATE_FORMAT')
{ },
'event': o.event.name, o.event, locale=o.locale
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={ )
'order': o.code,
'secret': o.secret
}),
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
},
o.event, locale=o.locale
)
except SendMailException: except SendMailException:
logger.exception('Reminder email could not be sent') logger.exception('Reminder email could not be sent')
else: else:
@@ -480,11 +491,9 @@ class OrderChangeManager:
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'), '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.'), '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.'), 'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'),
'not_pending_or_paid': _('Only pending or paid orders can be changed.'), 'not_pending': _('Only pending orders can be changed.'),
'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however ' 'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
'no quota is available.'), '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')) ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price')) PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
@@ -500,7 +509,8 @@ class OrderChangeManager:
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]): 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): if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
raise OrderError(self.error_messages['product_without_variation']) raise OrderError(self.error_messages['product_without_variation'])
price = item.default_price if variation is None else variation.price price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price)
if not price: if not price:
raise OrderError(self.error_messages['product_invalid']) raise OrderError(self.error_messages['product_invalid'])
self._totaldiff = price - position.price self._totaldiff = price - position.price
@@ -529,10 +539,6 @@ class OrderChangeManager:
if self.order.total == Decimal('0.00') and self._totaldiff > 0: if self.order.total == Decimal('0.00') and self._totaldiff > 0:
raise OrderError(self.error_messages['free_to_paid']) 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): def _check_paid_to_free(self):
if self.order.total == 0: if self.order.total == 0:
try: try:
@@ -545,7 +551,6 @@ class OrderChangeManager:
if isinstance(op, self.ItemOperation): if isinstance(op, self.ItemOperation):
self.order.log_action('pretix.event.order.changed.item', user=self.user, data={ self.order.log_action('pretix.event.order.changed.item', user=self.user, data={
'position': op.position.pk, 'position': op.position.pk,
'positionid': op.position.positionid,
'old_item': op.position.item.pk, 'old_item': op.position.item.pk,
'old_variation': op.position.variation.pk if op.position.variation else None, 'old_variation': op.position.variation.pk if op.position.variation else None,
'new_item': op.item.pk, 'new_item': op.item.pk,
@@ -561,7 +566,6 @@ class OrderChangeManager:
elif isinstance(op, self.PriceOperation): elif isinstance(op, self.PriceOperation):
self.order.log_action('pretix.event.order.changed.price', user=self.user, data={ self.order.log_action('pretix.event.order.changed.price', user=self.user, data={
'position': op.position.pk, 'position': op.position.pk,
'positionid': op.position.positionid,
'old_price': op.position.price, 'old_price': op.position.price,
'new_price': op.price 'new_price': op.price
}) })
@@ -571,7 +575,6 @@ class OrderChangeManager:
elif isinstance(op, self.CancelOperation): elif isinstance(op, self.CancelOperation):
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={ self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
'position': op.position.pk, 'position': op.position.pk,
'positionid': op.position.positionid,
'old_item': op.position.item.pk, 'old_item': op.position.item.pk,
'old_variation': op.position.variation.pk if op.position.variation else None, 'old_variation': op.position.variation.pk if op.position.variation else None,
'old_price': op.position.price, 'old_price': op.position.price,
@@ -602,12 +605,6 @@ class OrderChangeManager:
def _notify_user(self): def _notify_user(self):
with language(self.order.locale): with language(self.order.locale):
try:
invoice_name = self.order.invoice_address.name
invoice_company = self.order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
mail( mail(
self.order.email, _('Your order has been changed: %(code)s') % {'code': self.order.code}, self.order.email, _('Your order has been changed: %(code)s') % {'code': self.order.code},
self.order.event.settings.mail_text_order_changed, self.order.event.settings.mail_text_order_changed,
@@ -617,8 +614,6 @@ class OrderChangeManager:
'order': self.order.code, 'order': self.order.code,
'secret': self.order.secret 'secret': self.order.secret
}), }),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}, },
self.order.event, locale=self.order.locale self.order.event, locale=self.order.locale
) )
@@ -628,23 +623,18 @@ class OrderChangeManager:
# Do nothing # Do nothing
return return
with transaction.atomic(): with transaction.atomic():
self._check_free_to_paid()
self._check_complete_cancel()
with self.order.event.lock(): with self.order.event.lock():
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID): if self.order.status != Order.STATUS_PENDING:
raise OrderError(self.error_messages['not_pending_or_paid']) raise OrderError(self.error_messages['not_pending'])
self._check_free_to_paid()
self._check_paid_price_change()
self._check_quotas() self._check_quotas()
self._check_complete_cancel()
self._perform_operations() self._perform_operations()
self._recalculate_total_and_payment_fee() self._recalculate_total_and_payment_fee()
self._reissue_invoice() self._reissue_invoice()
self._clear_tickets_cache()
self._check_paid_to_free() self._check_paid_to_free()
self._notify_user() self._notify_user()
def _clear_tickets_cache(self):
CachedTicket.objects.filter(order_position__order=self.order).delete()
def _get_payment_provider(self): def _get_payment_provider(self):
responses = register_payment_providers.send(self.order.event) responses = register_payment_providers.send(self.order.event)
pprov = None pprov = None
@@ -656,7 +646,7 @@ class OrderChangeManager:
raise OrderError(error_messages['internal']) raise OrderError(error_messages['internal'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
def perform_order(self, event: str, payment_provider: str, positions: List[str], 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): email: str=None, locale: str=None, address: int=None, meta_info: dict=None):
try: try:
@@ -668,7 +658,7 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
return OrderError(error_messages['busy']) return OrderError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
def cancel_order(self, order: int, user: int=None): def cancel_order(self, order: int, user: int=None):
try: try:
try: try:
+14 -42
View File
@@ -1,4 +1,4 @@
import os from datetime import timedelta
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.timezone import now from django.utils.timezone import now
@@ -6,62 +6,34 @@ from django.utils.translation import ugettext as _
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, Order, OrderPosition, CachedFile, CachedTicket, Event, Order, OrderPosition, cachedfile_name,
) )
from pretix.base.services.async import ProfiledTask from pretix.base.services.async import ProfiledTask
from pretix.base.signals import register_ticket_outputs from pretix.base.signals import register_ticket_outputs
from pretix.celery_app import app from pretix.celery import app
from pretix.helpers.database import rolledback_transaction from pretix.helpers.database import rolledback_transaction
@app.task(base=ProfiledTask) @app.task(base=ProfiledTask)
def generate(order_position: str, provider: str): def generate(order_position: str, provider: str):
order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position) order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position)
try: ct = CachedTicket.objects.get_or_create(order_position=order_position, provider=provider)[0]
ct = CachedTicket.objects.get(order_position=order_position, provider=provider) if not ct.cachedfile:
except CachedTicket.MultipleObjectsReturned: cf = CachedFile()
CachedTicket.objects.filter(order_position=order_position, provider=provider).delete() cf.date = now()
ct = CachedTicket.objects.create(order_position=order_position, provider=provider, extension='', cf.expires = order_position.order.event.date_from + timedelta(days=30)
type='', file=None) cf.save()
except CachedTicket.DoesNotExist: ct.cachedfile = cf
ct = CachedTicket.objects.create(order_position=order_position, provider=provider, extension='', ct.save()
type='', file=None)
with language(order_position.order.locale): with language(order_position.order.locale):
responses = register_ticket_outputs.send(order_position.order.event) responses = register_ticket_outputs.send(order_position.order.event)
for receiver, response in responses: for receiver, response in responses:
prov = response(order_position.order.event) prov = response(order_position.order.event)
if prov.identifier == provider: if prov.identifier == provider:
filename, ct.type, data = prov.generate(order_position) ct.cachedfile.filename, ct.cachedfile.type, data = prov.generate(order_position)
path, ext = os.path.splitext(filename) ct.cachedfile.file.save(cachedfile_name(ct.cachedfile, ct.cachedfile.filename), ContentFile(data))
ct.extension = ext ct.cachedfile.save()
ct.save()
ct.file.save(filename, ContentFile(data))
@app.task(base=ProfiledTask)
def generate_order(order: int, provider: str):
order = Order.objects.select_related('event').get(id=order)
try:
ct = CachedCombinedTicket.objects.get(order=order, provider=provider)
except CachedCombinedTicket.MultipleObjectsReturned:
CachedCombinedTicket.objects.filter(order=order, provider=provider).delete()
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension='',
type='', file=None)
except CachedCombinedTicket.DoesNotExist:
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension='',
type='', file=None)
with language(order.locale):
responses = register_ticket_outputs.send(order.event)
for receiver, response in responses:
prov = response(order.event)
if prov.identifier == provider:
filename, ct.type, data = prov.generate_order(order)
path, ext = os.path.splitext(filename)
ct.extension = ext
ct.save()
ct.file.save(filename, ContentFile(data))
class DummyRollbackException(Exception): class DummyRollbackException(Exception):
-60
View File
@@ -1,60 +0,0 @@
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,))
+5 -54
View File
@@ -12,7 +12,7 @@ from django.core.files.storage import default_storage
from django.db.models import Model from django.db.models import Model
from django.utils.translation import ugettext_noop from django.utils.translation import ugettext_noop
from i18nfield.strings import LazyI18nString from pretix.base.i18n import LazyI18nString
from pretix.base.models.settings import GlobalSetting from pretix.base.models.settings import GlobalSetting
DEFAULTS = { DEFAULTS = {
@@ -20,10 +20,6 @@ DEFAULTS = {
'default': '10', 'default': '10',
'type': int 'type': int
}, },
'display_net_prices': {
'default': 'False',
'type': bool
},
'attendee_names_asked': { 'attendee_names_asked': {
'default': 'True', 'default': 'True',
'type': bool 'type': bool
@@ -132,22 +128,6 @@ DEFAULTS = {
'default': 'False', 'default': 'False',
'type': bool 'type': bool
}, },
'show_variations_expanded': {
'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': { 'ticket_download': {
'default': 'False', 'default': 'False',
'type': bool 'type': bool
@@ -251,8 +231,6 @@ Your {event} team"""))
we successfully received your payment for {event}. Thank you! we successfully received your payment for {event}. Thank you!
{payment_info}
You can change your order details and view the status of your order at You can change your order details and view the status of your order at
{url} {url}
@@ -274,29 +252,6 @@ your payment before {expire_date}.
You can view the payment information and the status of your order at You can view the payment information and the status of your order at
{url} {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, Best regards,
Your {event} team""")) Your {event} team"""))
}, },
@@ -344,10 +299,6 @@ Your {event} team"""))
'default': None, 'default': None,
'type': File 'type': File
}, },
'invoice_logo_image': {
'default': None,
'type': File
},
'frontpage_text': { 'frontpage_text': {
'default': '', 'default': '',
'type': LazyI18nString 'type': LazyI18nString
@@ -408,7 +359,7 @@ class SettingsProxy:
settings[key] = self.get(key) settings[key] = self.get(key)
return settings return settings
def _unserialize(self, value: str, as_type: type, binary_file=False) -> Any: def _unserialize(self, value: str, as_type: type) -> Any:
if as_type is None and value is not None and value.startswith('file://'): if as_type is None and value is not None and value.startswith('file://'):
as_type = File as_type = File
@@ -424,7 +375,7 @@ class SettingsProxy:
return value == 'True' return value == 'True'
elif as_type == File: elif as_type == File:
try: try:
fi = default_storage.open(value[7:], 'rb' if binary_file else 'r') fi = default_storage.open(value[7:], 'r')
fi.url = default_storage.url(value[7:]) fi.url = default_storage.url(value[7:])
return fi return fi
except OSError: except OSError:
@@ -463,7 +414,7 @@ class SettingsProxy:
raise TypeError('Unable to serialize %s into a setting.' % str(type(value))) raise TypeError('Unable to serialize %s into a setting.' % str(type(value)))
def get(self, key: str, default=None, as_type: type=None, binary_file=False): def get(self, key: str, default=None, as_type: type=None):
""" """
Get a setting specified by key ``key``. Normally, settings are strings, but Get a setting specified by key ``key``. Normally, settings are strings, but
if you put non-strings into the settings object, you can request unserialization if you put non-strings into the settings object, you can request unserialization
@@ -489,7 +440,7 @@ class SettingsProxy:
if value is None and default is not None: if value is None and default is not None:
value = default value = default
return self._unserialize(value, as_type, binary_file=binary_file) return self._unserialize(value, as_type)
def __getitem__(self, key: str) -> Any: def __getitem__(self, key: str) -> Any:
return self.get(key) return self.get(key)
+1 -39
View File
@@ -52,18 +52,6 @@ class EventPluginSignal(django.dispatch.Signal):
return responses return responses
event_live_issues = EventPluginSignal(
providing_args=[]
)
"""
This signal is sent out to determine whether an event can be taken live. If you want to
prevent the event from going live, return a string that will be displayed to the user
as the error message. If you don't, your receiver should return ``None``.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_payment_providers = EventPluginSignal( register_payment_providers = EventPluginSignal(
providing_args=[] providing_args=[]
) )
@@ -94,18 +82,6 @@ subclass of pretix.base.exporter.BaseExporter
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """
validate_cart = EventPluginSignal(
providing_args=["positions"]
)
"""
This signal is sent out before the user starts checkout. It includes an iterable
with the current CartPosition objects.
The response of receivers will be ignored, but you can raise a CartError with an
appropriate exception message.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
order_placed = EventPluginSignal( order_placed = EventPluginSignal(
providing_args=["order"] providing_args=["order"]
) )
@@ -134,21 +110,7 @@ To display an instance of the ``LogEntry`` model to a human user,
``pretix.base.signals.logentry_display`` will be sent out with a ``logentry`` argument. ``pretix.base.signals.logentry_display`` will be sent out with a ``logentry`` argument.
The first received response that is not ``None`` will be used to display the log entry The first received response that is not ``None`` will be used to display the log entry
to the user. The receivers are expected to return plain text. to the user.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
requiredaction_display = EventPluginSignal(
providing_args=["action", "request"]
)
"""
To display an instance of the ``RequiredAction`` model to a human user,
``pretix.base.signals.requiredaction_display`` will be sent out with a ``action`` argument.
You will also get the current ``request`` in a different argument.
The first received response that is not ``None`` will be used to display the log entry
to the user. The receivers are expected to return HTML code.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """
@@ -1,166 +0,0 @@
{% load eventurl %}
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=false">
</head>
<style type="text/css">
body {
background-color: #e8e8e8;
background-position: top;
background-repeat: repeat-x;
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 20px;
color: #333;
margin: 0;
}
.header h1 {
margin-top: 20px;
margin-bottom: 5px;
}
.header h1 a {
text-decoration: none;
}
a {
color: {{ color }};
font-weight: bold;
}
a:hover, a:focus {
color: {{ color }};
text-decoration: underline;
}
a:hover, a:active {
outline: 0;
}
p {
margin: 0 0 10px;
/* These are technically the same, but use both */
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
/* This is the dangerous one in WebKit, as it breaks things wherever */
word-break: break-all;
/* Instead use this non-standard one: */
word-break: break-word;
/* Adds a hyphen where the word breaks, if supported (No Blink) */
-ms-hyphens: auto;
-moz-hyphens: auto;
-webkit-hyphens: auto;
hyphens: auto;
}
.footer {
padding: 10px;
text-align: center;
font-size: 12px;
}
.content {
padding: 8px 18px 8px;
}
::selection {
background: {{ color }};
color: #FFF;
}
table {
width: 90%;
max-width: 900px;
border-spacing: 0px;
border-collapse: separate;
margin: auto;
}
@media (max-width: 480px) {
.header h1 {
font-size: 19px;
line-height: 24px;
margin: 0 9px 3px 0;
border-radius: 5px 5px;
-webkit-border-radius: 5px 5px;
-moz-border-radius: 5px 5px;
}
.header h1 a {
padding: 3px 9px;
display: block;
}
.header {
margin: 0;
padding: 12px 0 8px;
}
}
td.containertd {
background-color: #FFFFFF;
border: 1px solid #cccccc;
}
{% block addcss %}{% endblock %}
</style>
<body>
<table>
<tr>
<td class="header" background="">
{% if event %}
<h1><a href="{% eventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
{% else %}
<h1><a href="{{ site_url }}" target="_blank">{{ site }}</a></h1>
{% endif %}
</td>
</tr>
<tr>
<td class="containertd">
<div class="content">
{{ body|safe }}
</div>
</td>
</tr>
{% if order %}
<tr>
<td class="gap"></td>
</tr>
<tr>
<td class="order containertd">
<div class="content">
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
<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 %}">
{% trans "View order details" %}
</a>
</div>
</td>
</tr>
{% endif %}
<tr>
<td class="footer">
<div>
{% with 'target="blank" href="https://pretix.eu"'|safe as a_attr %}
{% blocktrans trimmed %}
powered by <a {{ a_attr }}>pretix</a>
{% endblocktrans %}
{% endwith %}
</div>
</td>
</tr>
</table>
<br/>
<br/>
</body>
</html>
-45
View File
@@ -1,45 +0,0 @@
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',
]
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'],
'abbr': ['title'],
'acronym': ['title'],
'table': ['width'],
'td': ['width', 'align'],
}
@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)
+3 -30
View File
@@ -1,14 +1,11 @@
import os
import tempfile
from collections import OrderedDict from collections import OrderedDict
from typing import Tuple from typing import Tuple
from zipfile import ZipFile
from django import forms from django import forms
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Event, Order, OrderPosition from pretix.base.models import Event, OrderPosition
from pretix.base.settings import SettingsSandbox from pretix.base.settings import SettingsSandbox
@@ -32,37 +29,13 @@ class BaseTicketOutput:
""" """
return self.settings.get('_enabled', as_type=bool) return self.settings.get('_enabled', as_type=bool)
def generate(self, position: OrderPosition) -> Tuple[str, str, str]: def generate(self, order: OrderPosition) -> Tuple[str, str, str]:
""" """
This method should generate the download file and return a tuple consisting of a This method should generate the download file and return a tuple consisting of a
filename, a file type and file content. The extension will be taken from the filename filename, a file type and file content.
which is otherwise ignored.
""" """
raise NotImplementedError() raise NotImplementedError()
def generate_order(self, order: Order) -> Tuple[str, str, str]:
"""
This method is the same as order() but should not generate one file per order position
but instead one file for the full order.
This method is optional to implement. If you don't implement it, the default
implementation will offer a zip file of the generate() results for the order positions.
This method should generate a download file and return a tuple consisting of a
filename, a file type and file content. The extension will be taken from the filename
which is otherwise ignored.
"""
with tempfile.TemporaryDirectory() as d:
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for pos in order.positions.all():
fname, __, content = self.generate(pos)
zipf.writestr('{}-{}{}'.format(
order.code, pos.positionid, os.path.splitext(fname)[1]
), content)
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return '{}-{}.zip'.format(order.code, self.identifier), 'application/zip', zipf.read()
@property @property
def verbose_name(self) -> str: def verbose_name(self) -> str:
""" """
+30 -40
View File
@@ -8,8 +8,7 @@ from django.http import JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from pretix.celery_app import app from pretix.celery import app
from pretix.helpers.database import casual_reads
logger = logging.getLogger('pretix.base.async') logger = logging.getLogger('pretix.base.async')
@@ -18,7 +17,6 @@ class AsyncAction:
task = None task = None
success_url = None success_url = None
error_url = None error_url = None
known_errortypes = []
def do(self, *args): def do(self, *args):
if not isinstance(self.task, app.Task): if not isinstance(self.task, app.Task):
@@ -32,11 +30,10 @@ class AsyncAction:
return JsonResponse(data) return JsonResponse(data)
else: else:
if res.ready(): if res.ready():
with casual_reads(): if res.successful() and not isinstance(res.info, Exception):
if res.successful() and not isinstance(res.info, Exception): return self.success(res.info)
return self.success(res.info) else:
else: return self.error(res.info)
return self.error(res.info)
return redirect(self.get_check_url(res.id, False)) return redirect(self.get_check_url(res.id, False))
def get_success_url(self, value): def get_success_url(self, value):
@@ -56,7 +53,7 @@ class AsyncAction:
def _return_ajax_result(self, res, timeout=.5): def _return_ajax_result(self, res, timeout=.5):
if not res.ready(): if not res.ready():
try: try:
res.get(timeout=timeout, propagate=False) res.get(timeout=timeout)
except celery.exceptions.TimeoutError: except celery.exceptions.TimeoutError:
pass pass
@@ -66,25 +63,24 @@ class AsyncAction:
'ready': ready 'ready': ready
} }
if ready: if ready:
with casual_reads(): if res.successful() and not isinstance(res.info, Exception):
if res.successful() and not isinstance(res.info, Exception): smes = self.get_success_message(res.info)
smes = self.get_success_message(res.info) if smes:
if smes: messages.success(self.request, smes)
messages.success(self.request, smes) # TODO: Do not store message if the ajax client states that it will not redirect
# TODO: Do not store message if the ajax client states that it will not redirect # but handle the mssage itself
# but handle the mssage itself data.update({
data.update({ 'redirect': self.get_success_url(res.info),
'redirect': self.get_success_url(res.info), 'message': str(self.get_success_message(res.info))
'message': str(self.get_success_message(res.info)) })
}) else:
else: messages.error(self.request, self.get_error_message(res.info))
messages.error(self.request, self.get_error_message(res.info)) # TODO: Do not store message if the ajax client states that it will not redirect
# TODO: Do not store message if the ajax client states that it will not redirect # but handle the mssage itself
# but handle the mssage itself data.update({
data.update({ 'redirect': self.get_error_url(),
'redirect': self.get_error_url(), 'message': str(self.get_error_message(res.info))
'message': str(self.get_error_message(res.info)) })
})
return data return data
def get_result(self, request): def get_result(self, request):
@@ -93,11 +89,10 @@ class AsyncAction:
return JsonResponse(self._return_ajax_result(res, timeout=0.25)) return JsonResponse(self._return_ajax_result(res, timeout=0.25))
else: else:
if res.ready(): if res.ready():
with casual_reads(): if res.successful() and not isinstance(res.info, Exception):
if res.successful() and not isinstance(res.info, Exception): return self.success(res.info)
return self.success(res.info) else:
else: return self.error(res.info)
return self.error(res.info)
return render(request, 'pretixpresale/waiting.html') return render(request, 'pretixpresale/waiting.html')
def success(self, value): def success(self, value):
@@ -123,13 +118,8 @@ class AsyncAction:
return redirect(self.get_error_url()) return redirect(self.get_error_url())
def get_error_message(self, exception): def get_error_message(self, exception):
if isinstance(exception, dict) and exception['exc_type'] in self.known_errortypes: logger.error('Unexpected exception: %r' % exception)
return exception['exc_message'] return _('An unexpected error has occured.')
elif exception.__class__.__name__ in self.known_errortypes:
return str(exception)
else:
logger.error('Unexpected exception: %r' % exception)
return _('An unexpected error has occured.')
def get_success_message(self, value): def get_success_message(self, value):
return _('The task has been completed.') return _('The task has been completed.')
+6 -6
View File
@@ -1,4 +1,6 @@
from django.http import FileResponse, Http404, HttpRequest, HttpResponse import os
from django.http import FileResponse, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.views.generic import TemplateView from django.views.generic import TemplateView
@@ -11,17 +13,15 @@ class DownloadView(TemplateView):
@cached_property @cached_property
def object(self) -> CachedFile: def object(self) -> CachedFile:
try: return get_object_or_404(CachedFile, id=self.kwargs['id'])
return get_object_or_404(CachedFile, id=self.kwargs['id'])
except ValueError: # Invalid URLs
raise Http404()
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
if 'ajax' in request.GET: if 'ajax' in request.GET:
return HttpResponse('1' if self.object.file else '0') return HttpResponse('1' if self.object.file else '0')
elif self.object.file: elif self.object.file:
resp = FileResponse(self.object.file.file, content_type=self.object.type) resp = FileResponse(self.object.file.file, content_type=self.object.type)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(self.object.filename) _, ext = os.path.splitext(self.object.filename)
resp['Content-Disposition'] = 'attachment; filename="{}{}"'.format(self.object.id, ext)
return resp return resp
else: else:
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
+4 -2
View File
@@ -1,7 +1,9 @@
from django.utils import timezone from django.utils import timezone
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from django.views.decorators.http import etag from django.views.decorators.http import etag
from django.views.i18n import get_javascript_catalog, render_javascript_catalog from django.views.i18n import (
get_javascript_catalog, render_javascript_catalog, to_locale,
)
# Yes, we want to regenerate this every time the module has been imported to # Yes, we want to regenerate this every time the module has been imported to
# refresh the cache at least at every code deployment # refresh the cache at least at every code deployment
@@ -19,5 +21,5 @@ js_info_dict = {
@cache_page(3600, key_prefix='js18n-%s' % import_date) @cache_page(3600, key_prefix='js18n-%s' % import_date)
def js_catalog(request, lang): def js_catalog(request, lang):
packages = ['pretix'] packages = ['pretix']
catalog, plural = get_javascript_catalog(lang, 'djangojs', packages) catalog, plural = get_javascript_catalog(to_locale(lang), 'djangojs', packages)
return render_javascript_catalog(catalog, plural) return render_javascript_catalog(catalog, plural)
+2 -4
View File
@@ -1,5 +1,3 @@
import hmac
from django.conf import settings from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
@@ -28,9 +26,9 @@ def serve_metrics(request):
user, passphrase = credentials.strip().decode("base64").split(":", 1) user, passphrase = credentials.strip().decode("base64").split(":", 1)
if not hmac.compare_digest(user, settings.METRICS_USER): if user != settings.METRICS_USER:
return unauthed_response() return unauthed_response()
if not hmac.compare_digest(passphrase, settings.METRICS_PASSPHRASE): if passphrase != settings.METRICS_PASSPHRASE:
return unauthed_response() return unauthed_response()
# ok, the request passed the authentication-barrier, let's hand out the metrics: # ok, the request passed the authentication-barrier, let's hand out the metrics:
+29
View File
@@ -0,0 +1,29 @@
import os
from celery import Celery
from celery.utils.mail import ErrorMail
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
from django.conf import settings
app = Celery('pretix')
class MyErrorMail(ErrorMail):
def should_send(self, context, exc):
from pretix.base.services.orders import OrderError
from pretix.base.services.cart import CartError
blacklist = (OrderError, CartError)
return not isinstance(exc, blacklist)
app.config_from_object('django.conf:settings')
app.conf.CELERY_ANNOTATIONS = {
'*': {
'ErrorMail': MyErrorMail,
}
}
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
-18
View File
@@ -1,18 +0,0 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
from django.conf import settings
app = Celery('pretix')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
if hasattr(settings, 'RAVEN_CONFIG'):
# Celery signal registration
from raven.contrib.celery import register_signal
from raven.contrib.django.models import client
register_signal(client, ignore_expected=True)
+1 -6
View File
@@ -1,7 +1,7 @@
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import Resolver404, get_script_prefix, resolve from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
from .signals import html_head, nav_event, nav_topbar from .signals import html_head, nav_event
from .utils.i18n import get_javascript_format, get_moment_locale from .utils.i18n import get_javascript_format, get_moment_locale
@@ -37,11 +37,6 @@ def contextprocessor(request):
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
ctx['nav_event'] = _nav_event ctx['nav_event'] = _nav_event
_nav_topbar = []
for receiver, response in nav_topbar.send(request, request=request):
_nav_topbar += response
ctx['nav_topbar'] = _nav_topbar
ctx['js_datetime_format'] = get_javascript_format('DATETIME_INPUT_FORMATS') ctx['js_datetime_format'] = get_javascript_format('DATETIME_INPUT_FORMATS')
ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS') ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS')
ctx['js_locale'] = get_moment_locale() ctx['js_locale'] = get_moment_locale()
+32 -145
View File
@@ -2,50 +2,19 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.utils.timezone import get_current_timezone_name
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea from pytz import common_timezones
from pytz import common_timezones, timezone
from pretix.base.forms import I18nModelForm, SettingsForm from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.models import Event, Organizer from pretix.base.i18n import I18nFormField, I18nTextarea
from pretix.base.models import Event
from pretix.control.forms import ExtFileField from pretix.control.forms import ExtFileField
class EventWizardFoundationForm(forms.Form): class EventCreateForm(I18nModelForm):
locales = forms.MultipleChoiceField(
choices=settings.LANGUAGES,
label=_("Use languages"),
widget=forms.CheckboxSelectMultiple,
help_text=_('Choose all languages that your event should be available in.')
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['organizer'] = forms.ModelChoiceField(
label=_("Organizer"),
queryset=Organizer.objects.filter(
id__in=self.user.organizer_perms.filter(can_create_events=True).values_list('organizer', flat=True)
),
widget=forms.RadioSelect,
empty_label=None,
required=True
)
class EventWizardBasicsForm(I18nModelForm):
error_messages = { error_messages = {
'duplicate_slug': _("You already used this slug for a different event. Please choose a new one."), 'duplicate_slug': _("You already used this slug for a different event. Please choose a new one."),
} }
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Default timezone"),
)
locale = forms.ChoiceField(
choices=settings.LANGUAGES,
label=_("Default language"),
)
class Meta: class Meta:
model = Event model = Event
@@ -56,8 +25,7 @@ class EventWizardBasicsForm(I18nModelForm):
'date_from', 'date_from',
'date_to', 'date_to',
'presale_start', 'presale_start',
'presale_end', 'presale_end'
'location',
] ]
widgets = { widgets = {
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), 'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
@@ -68,34 +36,7 @@ class EventWizardBasicsForm(I18nModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer') self.organizer = kwargs.pop('organizer')
self.locales = kwargs.get('locales')
kwargs.pop('user')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.initial['timezone'] = get_current_timezone_name()
self.fields['locale'].choices = [(a, b) for a, b in settings.LANGUAGES if a in self.locales]
def clean(self):
data = super().clean()
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): def clean_slug(self):
slug = self.cleaned_data['slug'] slug = self.cleaned_data['slug']
@@ -107,24 +48,27 @@ class EventWizardBasicsForm(I18nModelForm):
return slug return slug
class EventWizardCopyForm(forms.Form): class EventCreateSettingsForm(SettingsForm):
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Default timezone"),
)
locales = forms.MultipleChoiceField(
choices=settings.LANGUAGES,
label=_("Available langauges"),
)
locale = forms.ChoiceField(
choices=settings.LANGUAGES,
label=_("Default language"),
)
def __init__(self, *args, **kwargs): def clean(self):
kwargs.pop('organizer') data = super().clean()
kwargs.pop('locales') if data['locale'] not in data['locales']:
self.user = kwargs.pop('user') raise ValidationError({
super().__init__(*args, **kwargs) 'locale': _('Your default locale must also be enabled for your event (see box above).')
self.fields['copy_from_event'] = forms.ModelChoiceField( })
label=_("Copy configuration from"), return data
queryset=Event.objects.filter(
id__in=self.user.event_perms.filter(
can_change_items=True, can_change_settings=True
).values_list('event', flat=True)
),
widget=forms.RadioSelect,
empty_label=_('Do not copy'),
required=False
)
class EventUpdateForm(I18nModelForm): class EventUpdateForm(I18nModelForm):
@@ -147,7 +91,6 @@ class EventUpdateForm(I18nModelForm):
'is_public', 'is_public',
'presale_start', 'presale_start',
'presale_end', 'presale_end',
'location',
] ]
widgets = { widgets = {
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), 'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
@@ -173,12 +116,6 @@ class EventSettingsForm(SettingsForm):
help_text=_("Show item details before presale has started and after presale has ended"), help_text=_("Show item details before presale has started and after presale has ended"),
required=False 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( presale_start_show_date = forms.BooleanField(
label=_("Show start date"), label=_("Show start date"),
help_text=_("Show the presale start date before presale has started."), help_text=_("Show the presale start date before presale has started."),
@@ -208,27 +145,6 @@ class EventSettingsForm(SettingsForm):
help_text=_("Publicly show how many tickets of a certain type are still available."), help_text=_("Publicly show how many tickets of a certain type are still available."),
required=False 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( attendee_names_asked = forms.BooleanField(
label=_("Ask for attendee names"), label=_("Ask for attendee names"),
help_text=_("Ask for a name for all tickets which include admission to the event."), help_text=_("Ask for a name for all tickets which include admission to the event."),
@@ -315,17 +231,6 @@ class PaymentSettingsForm(SettingsForm):
"(in percent)."), "(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): class ProviderForm(SettingsForm):
""" """
@@ -384,8 +289,7 @@ class InvoiceSettingsForm(SettingsForm):
('False', _('No')), ('False', _('No')),
('admin', _('Manually in admin panel')), ('admin', _('Manually in admin panel')),
('user', _('Automatically on user request')), ('user', _('Automatically on user request')),
('True', _('Automatically for all created orders')), ('True', _('Automatically for all created orders'))
('paid', _('Automatically on payment')),
) )
) )
invoice_address_from = forms.CharField( invoice_address_from = forms.CharField(
@@ -417,12 +321,6 @@ class InvoiceSettingsForm(SettingsForm):
label=_("Invoice language"), label=_("Invoice language"),
choices=[('__user__', _('The user\'s language'))] + settings.LANGUAGES, choices=[('__user__', _('The user\'s language'))] + settings.LANGUAGES,
) )
invoice_logo_image = ExtFileField(
label=_('Logo image'),
ext_whitelist=(".png", ".jpg", ".svg", ".gif", ".jpeg"),
required=False,
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
)
class MailSettingsForm(SettingsForm): class MailSettingsForm(SettingsForm):
@@ -440,32 +338,31 @@ class MailSettingsForm(SettingsForm):
label=_("Text"), label=_("Text"),
required=False, required=False,
widget=I18nTextarea, widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {total}, {currency}, {date}, {paymentinfo}, {url}, " help_text=_("Available placeholders: {event}, {total}, {currency}, {date}, {paymentinfo}, {url}")
"{invoice_name}, {invoice_company}")
) )
mail_text_order_paid = I18nFormField( mail_text_order_paid = I18nFormField(
label=_("Text"), label=_("Text"),
required=False, required=False,
widget=I18nTextarea, widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}") help_text=_("Available placeholders: {event}, {url}")
) )
mail_text_order_free = I18nFormField( mail_text_order_free = I18nFormField(
label=_("Text"), label=_("Text"),
required=False, required=False,
widget=I18nTextarea, widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}") help_text=_("Available placeholders: {event}, {url}")
) )
mail_text_order_changed = I18nFormField( mail_text_order_changed = I18nFormField(
label=_("Text"), label=_("Text"),
required=False, required=False,
widget=I18nTextarea, widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}") help_text=_("Available placeholders: {event}, {url}")
) )
mail_text_resend_link = I18nFormField( mail_text_resend_link = I18nFormField(
label=_("Text (sent by admin)"), label=_("Text (sent by admin)"),
required=False, required=False,
widget=I18nTextarea, widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}") help_text=_("Available placeholders: {event}, {url}")
) )
mail_text_resend_all_links = I18nFormField( mail_text_resend_all_links = I18nFormField(
label=_("Text (requested by user)"), label=_("Text (requested by user)"),
@@ -484,13 +381,7 @@ class MailSettingsForm(SettingsForm):
label=_("Text"), label=_("Text"),
required=False, required=False,
widget=I18nTextarea, widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}") help_text=_("Available placeholders: {event}, {url}, {expire_date}")
)
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( smtp_use_custom = forms.BooleanField(
label=_("Use custom SMTP server"), label=_("Use custom SMTP server"),
@@ -560,10 +451,6 @@ class DisplaySettingsForm(SettingsForm):
required=False, required=False,
widget=I18nTextarea widget=I18nTextarea
) )
show_variations_expanded = forms.BooleanField(
label=_("Show variations of a product expanded by default"),
required=False
)
class TicketSettingsForm(SettingsForm): class TicketSettingsForm(SettingsForm):
+1 -1
View File
@@ -1,9 +1,9 @@
from collections import OrderedDict from collections import OrderedDict
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextInput
from pretix.base.forms import SettingsForm from pretix.base.forms import SettingsForm
from pretix.base.i18n import I18nFormField, I18nTextInput
from pretix.base.settings import GlobalSettingsObject from pretix.base.settings import GlobalSettingsObject
from pretix.base.signals import register_global_settings from pretix.base.signals import register_global_settings
+5 -61
View File
@@ -1,13 +1,11 @@
import copy import copy
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.forms import BooleanField, ModelMultipleChoiceField 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 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.forms import I18nModelForm
from pretix.base.i18n import I18nFormField, I18nTextarea
from pretix.base.models import ( from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota, Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
) )
@@ -105,44 +103,12 @@ class ItemCreateForm(I18nModelForm):
'You can select the variations in the next step.'), 'You can select the variations in the next step.'),
required=False) 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): 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) instance = super().save(*args, **kwargs)
if self.cleaned_data.get('has_variations'): if self.cleaned_data.get('has_variations'):
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations: ItemVariation.objects.create(
for variation in self.cleaned_data['copy_from'].variations.all(): item=instance, value=__('Standard')
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 return instance
class Meta: class Meta:
@@ -187,28 +153,6 @@ class ItemUpdateForm(I18nModelForm):
} }
class ItemVariationsFormSet(I18nFormSet):
def clean(self):
super().clean()
for f in self.forms:
if hasattr(f, '_delete_fail'):
f.fields['DELETE'].initial = False
f.fields['DELETE'].disabled = True
raise ValidationError(
message=_('The variation "%s" cannot be deleted because it has already been ordered by a user or '
'currently is in a users\'s cart. Please set the variation as "inactive" instead.'),
params=(str(f.instance),)
)
def _should_delete_form(self, form):
should_delete = super()._should_delete_form(form)
if should_delete and (form.instance.orderposition_set.exists() or form.instance.cartposition_set.exists()):
form._delete_fail = True
return False
return form.cleaned_data.get(DELETION_FIELD_NAME, False)
class ItemVariationForm(I18nModelForm): class ItemVariationForm(I18nModelForm):
class Meta: class Meta:
model = ItemVariation model = ItemVariation
+5 -15
View File
@@ -1,7 +1,6 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.formats import localize
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@@ -22,9 +21,9 @@ class ExtendForm(I18nModelForm):
def clean(self): def clean(self):
data = super().clean() data = super().clean()
data['expires'] = data['expires'].replace(hour=23, minute=59, second=59)
if data['expires'] < now(): if data['expires'] < now():
raise ValidationError(_('The new expiry date needs to be in the future.')) raise ValidationError(_('The new expiry date needs to be in the future.'))
data['expires'] = data['expires'].replace(hour=23, minute=59, second=59)
return data return data
@@ -59,7 +58,7 @@ class OrderPositionChangeForm(forms.Form):
price = forms.DecimalField( price = forms.DecimalField(
required=False, required=False,
max_digits=10, decimal_places=2, max_digits=10, decimal_places=2,
label=_('New price (gross)') label=_('New price')
) )
operation = forms.ChoiceField( operation = forms.ChoiceField(
required=False, required=False,
@@ -89,18 +88,15 @@ class OrderPositionChangeForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
choices = [] choices = []
for i in instance.order.event.items.prefetch_related('variations').all(): for i in instance.order.event.items.prefetch_related('variations').all():
pname = str(i.name) pname = i.name
if not i.is_available(): if not i.is_available():
pname += ' ({})'.format(_('inactive')) pname += ' ({})'.format(_('inactive'))
variations = list(i.variations.all()) variations = list(i.variations.all())
if variations: if variations:
for v in variations: for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (pname, v.value)))
'%s – %s (%s %s)' % (pname, v.value, localize(v.price),
instance.order.event.currency)))
else: else:
choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(i.default_price), choices.append((str(i.pk), pname))
instance.order.event.currency)))
self.fields['itemvar'].choices = choices self.fields['itemvar'].choices = choices
def clean(self): def clean(self):
@@ -109,12 +105,6 @@ class OrderPositionChangeForm(forms.Form):
class OrderContactForm(forms.ModelForm): class OrderContactForm(forms.ModelForm):
regenerate_secrets = forms.BooleanField(required=False, label=_('Invalidate secrets'),
help_text=_('Regenerates the order and ticket secrets. You will '
'need to re-send the link to the order page to the user and '
'the user will need to download his tickets again. The old '
'versions will be invalid.'))
class Meta: class Meta:
model = Order model = Order
fields = ['email'] fields = ['email']
+4 -4
View File
@@ -22,8 +22,8 @@ class VoucherForm(I18nModelForm):
model = Voucher model = Voucher
localized_fields = '__all__' localized_fields = '__all__'
fields = [ fields = [
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag',
'comment', 'max_usages', 'price_mode' 'comment', 'max_usages'
] ]
widgets = { widgets = {
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), 'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
@@ -187,8 +187,8 @@ class VoucherBulkForm(VoucherForm):
model = Voucher model = Voucher
localized_fields = '__all__' localized_fields = '__all__'
fields = [ fields = [
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag', 'comment',
'max_usages', 'price_mode' 'max_usages'
] ]
widgets = { widgets = {
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), 'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
+21 -81
View File
@@ -4,15 +4,10 @@ from decimal import Decimal
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import formats from django.utils import formats
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from i18nfield.strings import LazyI18nString
from pretix.base.models import Event, ItemVariation, LogEntry from pretix.base.models import Event, ItemVariation, LogEntry
from pretix.base.signals import logentry_display from pretix.base.signals import logentry_display
OVERVIEW_BLACKLIST = [
'pretix.plugins.sendmail.order.email.sent'
]
def _display_order_changed(event: Event, logentry: LogEntry): def _display_order_changed(event: Event, logentry: LogEntry):
data = json.loads(logentry.data) data = json.loads(logentry.data)
@@ -21,22 +16,18 @@ def _display_order_changed(event: Event, logentry: LogEntry):
if logentry.action_type == 'pretix.event.order.changed.item': if logentry.action_type == 'pretix.event.order.changed.item':
old_item = str(event.items.get(pk=data['old_item'])) old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']: if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['old_variation'])) old_item += ' - ' + str(event.itemvariations.get(pk=data['old_variation']))
new_item = str(event.items.get(pk=data['new_item'])) new_item = str(event.items.get(pk=data['new_item']))
if data['new_variation']: if data['new_variation']:
new_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['new_variation'])) new_item += ' - ' + str(event.itemvariations.get(pk=data['new_variation']))
return text + ' ' + _('Position #{posid}: {old_item} ({old_price} {currency}) changed ' return text + ' ' + _('{old_item} ({old_price} {currency}) changed to {new_item} ({new_price} {currency}).').format(
'to {new_item} ({new_price} {currency}).').format(
posid=data.get('positionid', '?'),
old_item=old_item, new_item=new_item, old_item=old_item, new_item=new_item,
old_price=formats.localize(Decimal(data['old_price'])), old_price=formats.localize(Decimal(data['old_price'])),
new_price=formats.localize(Decimal(data['new_price'])), new_price=formats.localize(Decimal(data['new_price'])),
currency=event.currency currency=event.currency
) )
elif logentry.action_type == 'pretix.event.order.changed.price': elif logentry.action_type == 'pretix.event.order.changed.price':
return text + ' ' + _('Price of position #{posid} changed from {old_price} {currency} ' return text + ' ' + _('Price of a position changed from {old_price} {currency} to {new_price} {currency}.').format(
'to {new_price} {currency}.').format(
posid=data.get('positionid', '?'),
old_price=formats.localize(Decimal(data['old_price'])), old_price=formats.localize(Decimal(data['old_price'])),
new_price=formats.localize(Decimal(data['new_price'])), new_price=formats.localize(Decimal(data['new_price'])),
currency=event.currency currency=event.currency
@@ -45,8 +36,7 @@ def _display_order_changed(event: Event, logentry: LogEntry):
old_item = str(event.items.get(pk=data['old_item'])) old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']: if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation'])) old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
return text + ' ' + _('Position #{posid} ({old_item}, {old_price} {currency}) removed.').format( return text + ' ' + _('{old_item} ({old_price} {currency}) removed.').format(
posid=data.get('positionid', '?'),
old_item=old_item, old_item=old_item,
old_price=formats.localize(Decimal(data['old_price'])), old_price=formats.localize(Decimal(data['old_price'])),
currency=event.currency currency=event.currency
@@ -59,94 +49,44 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.modified': _('The order details have been modified.'), 'pretix.event.order.modified': _('The order details have been modified.'),
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'), 'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
'pretix.event.order.resend': _('The link to the order detail page has been resent to the user.'), 'pretix.event.order.resend': _('The link to the order detail page has been resent to the user.'),
'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'),
'pretix.event.order.expirychanged': _('The order\'s expiry date has been changed.'), 'pretix.event.order.expirychanged': _('The order\'s expiry date has been changed.'),
'pretix.event.order.expired': _('The order has been marked as expired.'), 'pretix.event.order.expired': _('The order has been marked as expired.'),
'pretix.event.order.paid': _('The order has been marked as paid.'), 'pretix.event.order.paid': _('The order has been marked as paid.'),
'pretix.event.order.refunded': _('The order has been refunded.'), 'pretix.event.order.refunded': _('The order has been refunded.'),
'pretix.event.order.canceled': _('The order has been canceled.'), 'pretix.event.order.canceled': _('The order has been canceled.'),
'pretix.event.order.placed': _('The order has been created.'), 'pretix.event.order.placed': _('The order has been created.'),
'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
'to "{new_email}".'),
'pretix.event.order.invoice.generated': _('The invoice has been generated.'), 'pretix.event.order.invoice.generated': _('The invoice has been generated.'),
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'), 'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),
'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'), 'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'),
'pretix.event.order.comment': _('The order\'s internal comment has been updated.'), 'pretix.event.order.comment': _('The order\'s internal comment has been updated.'),
'pretix.event.order.contact.changed': _('The email address has been changed.'),
'pretix.event.order.payment.changed': _('The payment method has been changed.'), 'pretix.event.order.payment.changed': _('The payment method has been changed.'),
'pretix.event.order.expire_warning_sent': _('An email has been sent with a warning that the order is about ' 'pretix.event.order.expire_warning_sent': _('An email has been sent with a warning that the order is about to expire.'),
'to expire.'),
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'), 'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'), 'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'), 'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to '
'your account.'),
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '
'from your account.'),
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'), '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.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}.'),
'pretix.event.item.added': _('The product has been created.'),
'pretix.event.item.changed': _('The product has been modified.'),
'pretix.event.item.deleted': _('The product has been deleted.'),
'pretix.event.item.variation.added': _('The variation "{value}" has been created.'),
'pretix.event.item.variation.deleted': _('The variation "{value}" has been deleted.'),
'pretix.event.item.variation.changed': _('The variation "{value}" has been modified.'),
'pretix.event.quota.added': _('The quota has been added.'),
'pretix.event.quota.deleted': _('The quota has been deleted.'),
'pretix.event.quota.changed': _('The quota has been modified.'),
'pretix.event.category.added': _('The category has been added.'),
'pretix.event.category.deleted': _('The category has been deleted.'),
'pretix.event.category.changed': _('The category has been modified.'),
'pretix.event.question.added': _('The question has been added.'),
'pretix.event.question.deleted': _('The question has been deleted.'),
'pretix.event.question.changed': _('The question has been modified.'),
'pretix.event.settings': _('The event settings have been changed.'),
'pretix.event.tickets.settings': _('The ticket download settings have been changed.'),
'pretix.event.plugins.enabled': _('A plugin has been enabled.'),
'pretix.event.plugins.disabled': _('A plugin has been disabled.'),
'pretix.event.live.activated': _('The shop has been taken live.'),
'pretix.event.live.deactivated': _('The shop has been taken offline.'),
'pretix.event.changed': _('The event settings have been changed.'),
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
'pretix.event.question.option.changed': _('An answer option has been changed.'),
'pretix.event.permissions.added': _('A user has been added to the event team.'),
'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)
if logentry.action_type.startswith('pretix.event.item.variation'):
if 'value' not in data:
# Backwards compatibility
var = ItemVariation.objects.filter(id=data['id']).first()
if var:
data['value'] = str(var.value)
else:
data['value'] = '?'
else:
data['value'] = LazyI18nString(data['value'])
if logentry.action_type in plains: if logentry.action_type in plains:
return plains[logentry.action_type].format_map(data) return plains[logentry.action_type]
if logentry.action_type.startswith('pretix.event.order.changed'): if logentry.action_type.startswith('pretix.event.order.changed'):
return _display_order_changed(sender, logentry) return _display_order_changed(sender, logentry)
if logentry.action_type.startswith('pretix.event.payment.provider.'): if logentry.action_type == 'pretix.user.settings.2fa.device.added':
return _('The settings of a payment provider have been changed.') data = json.loads(logentry.data)
return _('A new two-factor authentication device "{name}" has been added to your account.').format(
if logentry.action_type.startswith('pretix.event.tickets.provider.'): name=data['name']
return _('The settings of a ticket output provider have been changed.') )
if logentry.action_type == 'pretix.user.settings.2fa.device.deleted':
data = json.loads(logentry.data)
return _('The two-factor authentication device "{name}" has been removed from your account.').format(
name=data['name']
)
if logentry.action_type == 'pretix.user.settings.changed': if logentry.action_type == 'pretix.user.settings.changed':
data = json.loads(logentry.data)
text = str(_('Your account settings have been changed.')) text = str(_('Your account settings have been changed.'))
if 'email' in data: if 'email' in data:
text = text + ' ' + str(_('Your email address has been changed to {email}.').format(email=data['email'])) text = text + ' ' + str(_('Your email address has been changed to {email}.').format(email=data['email']))
+16 -45
View File
@@ -9,9 +9,7 @@ from django.utils.deprecation import MiddlewareMixin
from django.utils.encoding import force_str from django.utils.encoding import force_str
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from pretix.base.models import ( from pretix.base.models import Event, EventPermission, Organizer
Event, EventPermission, Organizer, OrganizerPermission,
)
class PermissionMiddleware(MiddlewareMixin): class PermissionMiddleware(MiddlewareMixin):
@@ -26,18 +24,15 @@ class PermissionMiddleware(MiddlewareMixin):
"auth.login.2fa", "auth.login.2fa",
"auth.register", "auth.register",
"auth.forgot", "auth.forgot",
"auth.forgot.recover", "auth.forgot.recover"
"auth.invite",
) )
def process_request(self, request): def process_request(self, request):
url = resolve(request.path_info) url = resolve(request.path_info)
url_name = url.url_name url_name = url.url_name
if not request.path.startswith(get_script_prefix() + 'control'): if not request.path.startswith(get_script_prefix() + 'control'):
# This middleware should only touch the /control subpath # This middleware should only touch the /control subpath
return return
if hasattr(request, 'organizer'): if hasattr(request, 'organizer'):
# If the user is on a organizer's subdomain, he should be redirected to pretix # If the user is on a organizer's subdomain, he should be redirected to pretix
return redirect(urljoin(settings.SITE_URL, request.get_full_path())) return redirect(urljoin(settings.SITE_URL, request.get_full_path()))
@@ -61,53 +56,29 @@ class PermissionMiddleware(MiddlewareMixin):
return redirect_to_login( return redirect_to_login(
path, resolved_login_url, REDIRECT_FIELD_NAME) path, resolved_login_url, REDIRECT_FIELD_NAME)
events = Event.objects.all() if request.user.is_superuser else request.user.events request.user.events_cache = request.user.events.order_by(
request.user.events_cache = events.order_by(
"organizer", "date_from").prefetch_related("organizer") "organizer", "date_from").prefetch_related("organizer")
if 'event' in url.kwargs and 'organizer' in url.kwargs: if 'event' in url.kwargs and 'organizer' in url.kwargs:
try: try:
if request.user.is_superuser: request.event = Event.objects.filter(
request.event = Event.objects.filter( slug=url.kwargs['event'],
slug=url.kwargs['event'], permitted__id__exact=request.user.id,
organizer__slug=url.kwargs['organizer'], organizer__slug=url.kwargs['organizer'],
).select_related('organizer')[0] ).select_related('organizer')[0]
request.eventperm = EventPermission( request.eventperm = EventPermission.objects.get(
event=request.event, event=request.event,
user=request.user user=request.user
) )
else:
request.event = Event.objects.filter(
slug=url.kwargs['event'],
permitted__id__exact=request.user.id,
organizer__slug=url.kwargs['organizer'],
).select_related('organizer')[0]
request.eventperm = EventPermission.objects.get(
event=request.event,
user=request.user
)
request.organizer = request.event.organizer request.organizer = request.event.organizer
except IndexError: except IndexError:
raise Http404(_("The selected event was not found or you " raise Http404(_("The selected event was not found or you "
"have no permission to administrate it.")) "have no permission to administrate it."))
elif 'organizer' in url.kwargs: elif 'organizer' in url.kwargs:
try: try:
if request.user.is_superuser: request.organizer = Organizer.objects.filter(
request.organizer = Organizer.objects.filter( slug=url.kwargs['organizer'],
slug=url.kwargs['organizer'], permitted__id__exact=request.user.id,
)[0] )[0]
request.orgaperm = OrganizerPermission(
organizer=request.organizer,
user=request.user
)
else:
request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'],
permitted__id__exact=request.user.id,
)[0]
request.orgaperm = OrganizerPermission.objects.get(
organizer=request.organizer,
user=request.user
)
except IndexError: except IndexError:
raise Http404(_("The selected organizer was not found or you " raise Http404(_("The selected organizer was not found or you "
"have no permission to administrate it.")) "have no permission to administrate it."))
+1 -5
View File
@@ -14,8 +14,6 @@ def event_permission_required(permission):
if not request.user.is_authenticated: # NOQA if not request.user.is_authenticated: # NOQA
# just a double check, should not ever happen # just a double check, should not ever happen
raise PermissionDenied() raise PermissionDenied()
if request.user.is_superuser:
return function(request, *args, **kw)
try: try:
perm = EventPermission.objects.get( perm = EventPermission.objects.get(
event=request.event, event=request.event,
@@ -30,7 +28,7 @@ def event_permission_required(permission):
allowed = getattr(perm, permission) allowed = getattr(perm, permission)
except AttributeError: except AttributeError:
pass pass
if allowed or request.user.is_superuser: if allowed:
return function(request, *args, **kw) return function(request, *args, **kw)
raise PermissionDenied(_('You do not have permission to view this content.')) raise PermissionDenied(_('You do not have permission to view this content.'))
return wrapper return wrapper
@@ -60,8 +58,6 @@ def organizer_permission_required(permission):
if not request.user.is_authenticated: # NOQA if not request.user.is_authenticated: # NOQA
# just a double check, should not ever happen # just a double check, should not ever happen
raise PermissionDenied() raise PermissionDenied()
if request.user.is_superuser:
return function(request, *args, **kw)
try: try:
perm = OrganizerPermission.objects.get( perm = OrganizerPermission.objects.get(
organizer=request.organizer, organizer=request.organizer,

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