forked from CGM_Public/pretix_original
Compare commits
339
Commits
v2.2.0
...
searchindex
+1
-1
@@ -38,7 +38,7 @@ if [ "$1" == "translation-spelling" ]; then
|
|||||||
potypo
|
potypo
|
||||||
fi
|
fi
|
||||||
if [ "$1" == "tests" ]; then
|
if [ "$1" == "tests" ]; then
|
||||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt pytest-xdist
|
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||||
cd src
|
cd src
|
||||||
python manage.py check
|
python manage.py check
|
||||||
make all compress
|
make all compress
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ matrix:
|
|||||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||||
- python: 3.6
|
- python: 3.6
|
||||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
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
|
- python: 3.6
|
||||||
env: JOB=plugins
|
env: JOB=plugins
|
||||||
- python: 3.6
|
- python: 3.6
|
||||||
@@ -36,6 +38,9 @@ addons:
|
|||||||
- enchant
|
- enchant
|
||||||
- myspell-de-de
|
- myspell-de-de
|
||||||
- aspell-en
|
- aspell-en
|
||||||
|
- sqlite3
|
||||||
|
sources:
|
||||||
|
- travis-ci/sqlite3
|
||||||
branches:
|
branches:
|
||||||
except:
|
except:
|
||||||
- /^weblate-.*/
|
- /^weblate-.*/
|
||||||
|
|||||||
+36
-7
@@ -1,10 +1,26 @@
|
|||||||
FROM python:3.6
|
FROM python:3.6
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y git libxml2-dev libxslt1-dev python-dev python-virtualenv locales \
|
apt-get install -y --no-install-recommends \
|
||||||
libffi-dev build-essential python3-dev zlib1g-dev libssl-dev gettext libpq-dev \
|
build-essential \
|
||||||
default-libmysqlclient-dev libmemcached-dev libjpeg-dev supervisor nginx sudo \
|
default-libmysqlclient-dev \
|
||||||
--no-install-recommends && \
|
gettext \
|
||||||
|
git \
|
||||||
|
libffi-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
libmemcached-dev \
|
||||||
|
libpq-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libxslt1-dev \
|
||||||
|
locales \
|
||||||
|
nginx \
|
||||||
|
python-dev \
|
||||||
|
python-virtualenv \
|
||||||
|
python3-dev \
|
||||||
|
sudo \
|
||||||
|
supervisor \
|
||||||
|
zlib1g-dev && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
dpkg-reconfigure locales && \
|
dpkg-reconfigure locales && \
|
||||||
@@ -19,6 +35,22 @@ RUN apt-get update && \
|
|||||||
ENV LC_ALL=C.UTF-8 \
|
ENV LC_ALL=C.UTF-8 \
|
||||||
DJANGO_SETTINGS_MODULE=production_settings
|
DJANGO_SETTINGS_MODULE=production_settings
|
||||||
|
|
||||||
|
# To copy only the requirements files needed to install from PIP
|
||||||
|
COPY src/requirements /pretix/src/requirements
|
||||||
|
COPY src/requirements.txt /pretix/src
|
||||||
|
RUN pip3 install -U \
|
||||||
|
pip \
|
||||||
|
setuptools \
|
||||||
|
wheel && \
|
||||||
|
cd /pretix/src && \
|
||||||
|
pip3 install \
|
||||||
|
-r requirements.txt \
|
||||||
|
-r requirements/memcached.txt \
|
||||||
|
-r requirements/mysql.txt \
|
||||||
|
-r requirements/redis.txt \
|
||||||
|
gunicorn && \
|
||||||
|
rm -rf ~/.cache/pip
|
||||||
|
|
||||||
COPY deployment/docker/pretix.bash /usr/local/bin/pretix
|
COPY deployment/docker/pretix.bash /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
|
||||||
@@ -27,11 +59,8 @@ COPY src /pretix/src
|
|||||||
|
|
||||||
RUN chmod +x /usr/local/bin/pretix && \
|
RUN chmod +x /usr/local/bin/pretix && \
|
||||||
rm /etc/nginx/sites-enabled/default && \
|
rm /etc/nginx/sites-enabled/default && \
|
||||||
pip3 install -U pip wheel setuptools && \
|
|
||||||
cd /pretix/src && \
|
cd /pretix/src && \
|
||||||
rm -f pretix.cfg && \
|
rm -f pretix.cfg && \
|
||||||
pip3 install -r requirements.txt -r requirements/mysql.txt \
|
|
||||||
-r requirements/memcached.txt -r requirements/redis.txt gunicorn && \
|
|
||||||
mkdir -p data && \
|
mkdir -p data && \
|
||||||
chown -R pretixuser:pretixuser /pretix /data data && \
|
chown -R pretixuser:pretixuser /pretix /data data && \
|
||||||
sudo -u pretixuser make production
|
sudo -u pretixuser make production
|
||||||
|
|||||||
@@ -125,6 +125,23 @@ Example::
|
|||||||
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||||
turns on some optimizations/special case handlers. Default: ``False``
|
turns on some optimizations/special case handlers. Default: ``False``
|
||||||
|
|
||||||
|
Database replica settings
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
If you use a replicated database setup, pretix expects that the default database connection always points to the primary database node.
|
||||||
|
Routing read queries to a replica on databse layer is **strongly** discouraged since this can lead to inaccurate such as more tickets
|
||||||
|
being sold than are actually available.
|
||||||
|
|
||||||
|
However, pretix can still make use of a database replica to keep some expensive queries with that can tolerate some latency from your
|
||||||
|
primary database, such as backend search queries. The ``replica`` configuration section can have the same settings as the ``database``
|
||||||
|
section (except for the ``backend`` setting) and will default back to the ``database`` settings for all values that are not given. This
|
||||||
|
way, you just need to specify the settings that are different for the replica.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
[replica]
|
||||||
|
host=192.168.0.2
|
||||||
|
|
||||||
URLs
|
URLs
|
||||||
----
|
----
|
||||||
|
|
||||||
@@ -295,5 +312,13 @@ various places like order codes, secrets in the ticket QR codes, etc. Example::
|
|||||||
; Voucher code needs to be < 255 characters, default is 16
|
; Voucher code needs to be < 255 characters, default is 16
|
||||||
voucher_code=16
|
voucher_code=16
|
||||||
|
|
||||||
|
External tools
|
||||||
|
--------------
|
||||||
|
|
||||||
|
pretix can make use of some external tools if they are installed. Currently, they are all optional. Example::
|
||||||
|
|
||||||
|
[tools]
|
||||||
|
pdftk=/usr/bin/pdftk
|
||||||
|
|
||||||
.. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure
|
.. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure
|
||||||
.. _Celery documentation: http://docs.celeryproject.org/en/latest/userguide/configuration.html
|
.. _Celery documentation: http://docs.celeryproject.org/en/latest/userguide/configuration.html
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.. highlight:: none
|
||||||
|
|
||||||
|
Installing a development version
|
||||||
|
================================
|
||||||
|
|
||||||
|
If you want to use a feature of pretix that is not yet contained in the last monthly release, you can also
|
||||||
|
install a development version with pretix.
|
||||||
|
|
||||||
|
.. warning:: When in production, we strongly recommend only installing released versions. Development versions might
|
||||||
|
be broken, incompatible to plugins, or in rare cases incompatible to upgrade later on.
|
||||||
|
|
||||||
|
|
||||||
|
Manual installation
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
You can use ``pip`` to update pretix directly to the development branch. Then, upgrade as usual::
|
||||||
|
|
||||||
|
$ source /var/pretix/venv/bin/activate
|
||||||
|
(venv)$ pip3 install -U "git+https://github.com/pretix/pretix.git#egg=pretix&subdirectory=src"
|
||||||
|
(venv)$ python -m pretix migrate
|
||||||
|
(venv)$ python -m pretix rebuild
|
||||||
|
(venv)$ python -m pretix updatestyles
|
||||||
|
# systemctl restart pretix-web pretix-worker
|
||||||
|
|
||||||
|
Docker installation
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
To use the latest development version with Docker, first pull it from Docker Hub::
|
||||||
|
|
||||||
|
$ docker pull pretix/standalone:latest
|
||||||
|
|
||||||
|
|
||||||
|
Then change your ``/etc/systemd/system/pretix.service`` file to use the ``:latest`` tag instead of ``:stable`` as well
|
||||||
|
and upgrade as usual::
|
||||||
|
|
||||||
|
$ systemctl restart pretix.service
|
||||||
|
$ docker exec -it pretix.service pretix upgrade
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
.. highlight:: none
|
||||||
|
|
||||||
|
Installing pretix Enterprise plugins
|
||||||
|
====================================
|
||||||
|
|
||||||
|
If you want to use a feature of pretix that is part of our commercial offering pretix Enterprise, you need to follow
|
||||||
|
some extra steps. Installation works similar to normal pretix plugins, but involves a few extra steps.
|
||||||
|
|
||||||
|
Buying the license
|
||||||
|
------------------
|
||||||
|
|
||||||
|
To obtain a license, please get in touch at sales@pretix.eu. Please let us know how many tickets you roughly intend
|
||||||
|
to sell per year and how many servers you want to use the plugin on. We recommend having a look at our `price list`_
|
||||||
|
first.
|
||||||
|
|
||||||
|
|
||||||
|
Manual installation
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
First, generate an SSH key for the system user that you install pretix as. In our tutorial, that would be the user
|
||||||
|
``pretix``. Choose an empty passphrase::
|
||||||
|
|
||||||
|
# su pretix
|
||||||
|
$ ssh-keygen
|
||||||
|
Generating public/private rsa key pair.
|
||||||
|
Enter file in which to save the key (/var/pretix/.ssh/id_rsa):
|
||||||
|
Enter passphrase (empty for no passphrase):
|
||||||
|
Enter same passphrase again:
|
||||||
|
Your identification has been saved in /var/pretix/.ssh/id_rsa.
|
||||||
|
Your public key has been saved in /var/pretix/.ssh/id_rsa.pub.
|
||||||
|
|
||||||
|
Next, send the content of the *public* key to your sales representative at pretix::
|
||||||
|
|
||||||
|
$ cat /var/pretix/.ssh/id_rsa.pub
|
||||||
|
ssh-rsa AAAAB3N...744HZawHlD pretix@foo
|
||||||
|
|
||||||
|
After we configured your key in our system, you can install the plugin directly using ``pip`` from the URL we told
|
||||||
|
you, for example::
|
||||||
|
|
||||||
|
$ source /var/pretix/venv/bin/activate
|
||||||
|
(venv)$ pip3 install -U "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack"
|
||||||
|
(venv)$ python -m pretix migrate
|
||||||
|
(venv)$ python -m pretix rebuild
|
||||||
|
# systemctl restart pretix-web pretix-worker
|
||||||
|
|
||||||
|
Docker installation
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
To install a plugin, you need to build your own docker image. To do so, create a new directory to work in. As a first
|
||||||
|
step, generate a new SSH key in that directory to use for authentication with us::
|
||||||
|
|
||||||
|
$ cd /home/me/mypretixdocker
|
||||||
|
$ ssh-keygen -N "" -f id_pretix_enterprise
|
||||||
|
|
||||||
|
Next, send the content of the *public* key to your sales representative at pretix::
|
||||||
|
|
||||||
|
$ cat id_pretix_enterprise.pub
|
||||||
|
ssh-rsa AAAAB3N...744HZawHlD pretix@foo
|
||||||
|
|
||||||
|
After we configured your key in our system, you can add a ``Dockerfile`` in your directory that includes the newly
|
||||||
|
generated key and installs the plugin from the URL we told you::
|
||||||
|
|
||||||
|
FROM pretix/standalone:stable
|
||||||
|
USER root
|
||||||
|
COPY id_pretix_enterprise /root/.ssh/id_rsa
|
||||||
|
COPY id_pretix_enterprise.pub /root/.ssh/id_rsa.pub
|
||||||
|
RUN chmod -R 0600 /root/.ssh && \
|
||||||
|
mkdir -p /etc/ssh && \
|
||||||
|
ssh-keyscan -t rsa -p 10022 code.rami.io >> /root/.ssh/known_hosts && \
|
||||||
|
echo StrictHostKeyChecking=no >> /root/.ssh/config && \
|
||||||
|
pip3 install -Ue "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack" && \
|
||||||
|
cd /pretix/src && \
|
||||||
|
sudo -u pretixuser make production
|
||||||
|
USER pretixuser
|
||||||
|
|
||||||
|
Then, build the image for docker::
|
||||||
|
|
||||||
|
$ docker build -t mypretix
|
||||||
|
|
||||||
|
You can now use that image ``mypretix`` instead of ``pretix/standalone:stable`` in your ``/etc/systemd/system/pretix.service``
|
||||||
|
service file. Be sure to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an
|
||||||
|
update to a new version of pretix.
|
||||||
|
|
||||||
|
.. _price list: https://pretix.eu/about/en/pricing
|
||||||
@@ -10,3 +10,5 @@ for your needs.
|
|||||||
general
|
general
|
||||||
docker_smallscale
|
docker_smallscale
|
||||||
manual_smallscale
|
manual_smallscale
|
||||||
|
dev_version
|
||||||
|
enterprise
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ To build and run pretix, you will need the following debian packages::
|
|||||||
|
|
||||||
# apt-get install git build-essential python-dev python-virtualenv python3 python3-pip \
|
# apt-get install git build-essential python-dev python-virtualenv python3 python3-pip \
|
||||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||||
gettext libpq-dev libmysqlclient-dev libjpeg-dev
|
gettext libpq-dev libmysqlclient-dev libjpeg-dev libopenjp2-7-dev
|
||||||
|
|
||||||
Config file
|
Config file
|
||||||
-----------
|
-----------
|
||||||
|
|||||||
@@ -445,6 +445,8 @@ Order position endpoints
|
|||||||
The result format is the same as the :ref:`order-position-resource`, with one important difference: the
|
The result format is the same as the :ref:`order-position-resource`, with one important difference: the
|
||||||
``checkins`` value will only include check-ins for the selected list.
|
``checkins`` value will only include check-ins for the selected list.
|
||||||
|
|
||||||
|
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -516,6 +518,8 @@ Order position endpoints
|
|||||||
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
||||||
accepts a number of optional requests in the body.
|
accepts a number of optional requests in the body.
|
||||||
|
|
||||||
|
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||||
|
|
||||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||||
you do not implement question handling in your user interface, you **must**
|
you do not implement question handling in your user interface, you **must**
|
||||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||||
@@ -561,7 +565,10 @@ Order position endpoints
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"status": "ok"
|
"status": "ok",
|
||||||
|
"position": {
|
||||||
|
…
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
**Example response with required questions**:
|
**Example response with required questions**:
|
||||||
@@ -572,7 +579,10 @@ Order position endpoints
|
|||||||
Content-Type: text/json
|
Content-Type: text/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"status": "incomplete"
|
"status": "incomplete",
|
||||||
|
"position": {
|
||||||
|
…
|
||||||
|
},
|
||||||
"questions": [
|
"questions": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
@@ -617,6 +627,9 @@ Order position endpoints
|
|||||||
{
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"reason": "unpaid",
|
"reason": "unpaid",
|
||||||
|
"position": {
|
||||||
|
…
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Possible error reasons:
|
Possible error reasons:
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ admission boolean ``True`` for it
|
|||||||
position integer An integer, used for sorting
|
position integer An integer, used for sorting
|
||||||
picture string A product picture to be displayed in the shop
|
picture string A product picture to be displayed in the shop
|
||||||
(read-only).
|
(read-only).
|
||||||
|
sales_channels list of strings Sales channels this product is available on, such as
|
||||||
|
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
|
||||||
available_from datetime The first date time at which this item can be bought
|
available_from datetime The first date time at which this item can be bought
|
||||||
(or ``null``).
|
(or ``null``).
|
||||||
available_until datetime The last date time at which this item can be bought
|
available_until datetime The last date time at which this item can be bought
|
||||||
@@ -62,6 +64,12 @@ original_price money (string) An original pri
|
|||||||
require_approval boolean If ``True``, orders with this product will need to be
|
require_approval boolean If ``True``, orders with this product will need to be
|
||||||
approved by the event organizer before they can be
|
approved by the event organizer before they can be
|
||||||
paid.
|
paid.
|
||||||
|
generate_tickets boolean If ``False``, tickets are never generated for this
|
||||||
|
product, regardless of other settings. If ``True``,
|
||||||
|
tickets are generated even if this is a
|
||||||
|
non-admission or add-on product, regardless of event
|
||||||
|
settings. If this is ``null``, regular ticketing
|
||||||
|
rules apply.
|
||||||
has_variations boolean Shows whether or not this item has variations.
|
has_variations boolean Shows whether or not this item has variations.
|
||||||
variations list of objects A list with one object for each variation of this item.
|
variations list of objects A list with one object for each variation of this item.
|
||||||
Can be empty. Only writable during creation,
|
Can be empty. Only writable during creation,
|
||||||
@@ -105,6 +113,14 @@ addons list of objects Definition of a
|
|||||||
|
|
||||||
The field ``require_approval`` has been added.
|
The field ``require_approval`` has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.3
|
||||||
|
|
||||||
|
The ``sales_channels`` attribute has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.4
|
||||||
|
|
||||||
|
The ``generate_tickets`` attribute has been added.
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
-----
|
-----
|
||||||
Please note that an item either always has variations or never has. Once created with variations the item can never
|
Please note that an item either always has variations or never has. Once created with variations the item can never
|
||||||
@@ -147,6 +163,7 @@ Endpoints
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "Standard ticket"},
|
"name": {"en": "Standard ticket"},
|
||||||
"internal_name": "",
|
"internal_name": "",
|
||||||
|
"sales_channels": ["web"],
|
||||||
"default_price": "23.00",
|
"default_price": "23.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"category": null,
|
"category": null,
|
||||||
@@ -167,6 +184,7 @@ Endpoints
|
|||||||
"max_per_order": null,
|
"max_per_order": null,
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
"has_variations": false,
|
"has_variations": false,
|
||||||
|
"generate_tickets": null,
|
||||||
"require_approval": false,
|
"require_approval": false,
|
||||||
"variations": [
|
"variations": [
|
||||||
{
|
{
|
||||||
@@ -232,6 +250,7 @@ Endpoints
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "Standard ticket"},
|
"name": {"en": "Standard ticket"},
|
||||||
"internal_name": "",
|
"internal_name": "",
|
||||||
|
"sales_channels": ["web"],
|
||||||
"default_price": "23.00",
|
"default_price": "23.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"category": null,
|
"category": null,
|
||||||
@@ -248,6 +267,7 @@ Endpoints
|
|||||||
"require_voucher": false,
|
"require_voucher": false,
|
||||||
"hide_without_voucher": false,
|
"hide_without_voucher": false,
|
||||||
"allow_cancel": true,
|
"allow_cancel": true,
|
||||||
|
"generate_tickets": null,
|
||||||
"min_per_order": null,
|
"min_per_order": null,
|
||||||
"max_per_order": null,
|
"max_per_order": null,
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
@@ -298,6 +318,7 @@ Endpoints
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "Standard ticket"},
|
"name": {"en": "Standard ticket"},
|
||||||
"internal_name": "",
|
"internal_name": "",
|
||||||
|
"sales_channels": ["web"],
|
||||||
"default_price": "23.00",
|
"default_price": "23.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"category": null,
|
"category": null,
|
||||||
@@ -314,6 +335,7 @@ Endpoints
|
|||||||
"require_voucher": false,
|
"require_voucher": false,
|
||||||
"hide_without_voucher": false,
|
"hide_without_voucher": false,
|
||||||
"allow_cancel": true,
|
"allow_cancel": true,
|
||||||
|
"generate_tickets": null,
|
||||||
"min_per_order": null,
|
"min_per_order": null,
|
||||||
"max_per_order": null,
|
"max_per_order": null,
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
@@ -351,6 +373,7 @@ Endpoints
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "Standard ticket"},
|
"name": {"en": "Standard ticket"},
|
||||||
"internal_name": "",
|
"internal_name": "",
|
||||||
|
"sales_channels": ["web"],
|
||||||
"default_price": "23.00",
|
"default_price": "23.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"category": null,
|
"category": null,
|
||||||
@@ -369,6 +392,7 @@ Endpoints
|
|||||||
"allow_cancel": true,
|
"allow_cancel": true,
|
||||||
"min_per_order": null,
|
"min_per_order": null,
|
||||||
"max_per_order": null,
|
"max_per_order": null,
|
||||||
|
"generate_tickets": null,
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
"has_variations": true,
|
"has_variations": true,
|
||||||
"require_approval": false,
|
"require_approval": false,
|
||||||
@@ -436,6 +460,7 @@ Endpoints
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "Ticket"},
|
"name": {"en": "Ticket"},
|
||||||
"internal_name": "",
|
"internal_name": "",
|
||||||
|
"sales_channels": ["web"],
|
||||||
"default_price": "25.00",
|
"default_price": "25.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"category": null,
|
"category": null,
|
||||||
@@ -451,6 +476,7 @@ Endpoints
|
|||||||
"available_until": null,
|
"available_until": null,
|
||||||
"require_voucher": false,
|
"require_voucher": false,
|
||||||
"hide_without_voucher": false,
|
"hide_without_voucher": false,
|
||||||
|
"generate_tickets": null,
|
||||||
"allow_cancel": true,
|
"allow_cancel": true,
|
||||||
"min_per_order": null,
|
"min_per_order": null,
|
||||||
"max_per_order": null,
|
"max_per_order": null,
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ status string Order status, o
|
|||||||
* ``p`` – paid
|
* ``p`` – paid
|
||||||
* ``e`` – expired
|
* ``e`` – expired
|
||||||
* ``c`` – canceled
|
* ``c`` – canceled
|
||||||
* ``r`` – refunded
|
|
||||||
secret string The secret contained in the link sent to the customer
|
secret string The secret contained in the link sent to the customer
|
||||||
email string The customer email address
|
email string The customer email address
|
||||||
locale string The locale used for communication with this customer
|
locale string The locale used for communication with this customer
|
||||||
|
sales_channel string Channel this sale was created through, such as
|
||||||
|
``"web"``.
|
||||||
datetime datetime Time of order creation
|
datetime datetime Time of order creation
|
||||||
expires datetime The order will expire, if it is still pending by this time
|
expires datetime The order will expire, if it is still pending by this time
|
||||||
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
|
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
|
||||||
@@ -56,9 +57,9 @@ invoice_address object Invoice address
|
|||||||
└ vat_id_validated string ``True``, if the VAT ID has been validated against the
|
└ vat_id_validated string ``True``, if the VAT ID has been validated against the
|
||||||
EU VAT service and validation was successful. This only
|
EU VAT service and validation was successful. This only
|
||||||
happens in rare cases.
|
happens in rare cases.
|
||||||
positions list of objects List of order positions (see below)
|
positions list of objects List of non-canceled order positions (see below)
|
||||||
fees list of objects List of fees included in the order total (i.e.
|
fees list of objects List of non-canceled fees included in the order total
|
||||||
payment fees)
|
(i.e. payment fees)
|
||||||
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
|
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
|
||||||
``other``)
|
``other``)
|
||||||
├ value money (string) Fee amount
|
├ value money (string) Fee amount
|
||||||
@@ -121,6 +122,15 @@ last_modified datetime Last modificati
|
|||||||
nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval``
|
nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval``
|
||||||
attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints.
|
attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.3
|
||||||
|
|
||||||
|
The ``sales_channel`` attribute has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.4:
|
||||||
|
|
||||||
|
``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and
|
||||||
|
``…/mark_refunded/`` has been deprecated.
|
||||||
|
|
||||||
.. _order-position-resource:
|
.. _order-position-resource:
|
||||||
|
|
||||||
Order position resource
|
Order position resource
|
||||||
@@ -265,6 +275,7 @@ List of all orders
|
|||||||
"secret": "k24fiuwvu8kxz3y1",
|
"secret": "k24fiuwvu8kxz3y1",
|
||||||
"email": "tester@example.org",
|
"email": "tester@example.org",
|
||||||
"locale": "en",
|
"locale": "en",
|
||||||
|
"sales_channel": "web",
|
||||||
"datetime": "2017-12-01T10:00:00Z",
|
"datetime": "2017-12-01T10:00:00Z",
|
||||||
"expires": "2017-12-10T10:00:00Z",
|
"expires": "2017-12-10T10:00:00Z",
|
||||||
"last_modified": "2017-12-01T10:00:00Z",
|
"last_modified": "2017-12-01T10:00:00Z",
|
||||||
@@ -401,6 +412,7 @@ Fetching individual orders
|
|||||||
"secret": "k24fiuwvu8kxz3y1",
|
"secret": "k24fiuwvu8kxz3y1",
|
||||||
"email": "tester@example.org",
|
"email": "tester@example.org",
|
||||||
"locale": "en",
|
"locale": "en",
|
||||||
|
"sales_channel": "web",
|
||||||
"datetime": "2017-12-01T10:00:00Z",
|
"datetime": "2017-12-01T10:00:00Z",
|
||||||
"expires": "2017-12-10T10:00:00Z",
|
"expires": "2017-12-10T10:00:00Z",
|
||||||
"last_modified": "2017-12-01T10:00:00Z",
|
"last_modified": "2017-12-01T10:00:00Z",
|
||||||
@@ -561,6 +573,8 @@ Creating orders
|
|||||||
|
|
||||||
* does not validate if products are only to be sold in a specific time frame
|
* does not validate if products are only to be sold in a specific time frame
|
||||||
|
|
||||||
|
* does not validate if products are only to be sold on other sales channels
|
||||||
|
|
||||||
* does not validate if the event's ticket sales are already over or haven't started
|
* does not validate if the event's ticket sales are already over or haven't started
|
||||||
|
|
||||||
* does not validate the number of items per order or the number of times an item can be included in an order
|
* does not validate the number of items per order or the number of times an item can be included in an order
|
||||||
@@ -597,6 +611,7 @@ Creating orders
|
|||||||
creation.
|
creation.
|
||||||
* ``email``
|
* ``email``
|
||||||
* ``locale``
|
* ``locale``
|
||||||
|
* ``sales_channel``
|
||||||
* ``payment_provider`` – The identifier of the payment provider set for this order. This needs to be an existing
|
* ``payment_provider`` – The identifier of the payment provider set for this order. This needs to be an existing
|
||||||
payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` for all
|
payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` for all
|
||||||
orders you create as paid.
|
orders you create as paid.
|
||||||
@@ -661,6 +676,7 @@ Creating orders
|
|||||||
{
|
{
|
||||||
"email": "dummy@example.org",
|
"email": "dummy@example.org",
|
||||||
"locale": "en",
|
"locale": "en",
|
||||||
|
"sales_channel": "web",
|
||||||
"fees": [
|
"fees": [
|
||||||
{
|
{
|
||||||
"fee_type": "payment",
|
"fee_type": "payment",
|
||||||
@@ -764,7 +780,10 @@ Order state operations
|
|||||||
|
|
||||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_canceled/
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_canceled/
|
||||||
|
|
||||||
Marks a pending order as canceled.
|
Cancels an order. For a pending order, this will set the order to status ``c``. For a paid order, this will set
|
||||||
|
the order to status ``c`` if no ``cancellation_fee`` is passed. If you do pass a ``cancellation_fee``, the order
|
||||||
|
will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation
|
||||||
|
fee as the only component of the order.
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
@@ -776,7 +795,8 @@ Order state operations
|
|||||||
Content-Type: text/json
|
Content-Type: text/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"send_email": true
|
"send_email": true,
|
||||||
|
"cancellation_fee": null
|
||||||
}
|
}
|
||||||
|
|
||||||
**Example response**:
|
**Example response**:
|
||||||
@@ -837,44 +857,6 @@ Order state operations
|
|||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
:statuscode 404: The requested order does not exist.
|
:statuscode 404: The requested order does not exist.
|
||||||
|
|
||||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_refunded/
|
|
||||||
|
|
||||||
Marks a paid order as refunded.
|
|
||||||
|
|
||||||
.. warning:: In the current implementation, this will **bypass** the payment provider, i.e. the money will **not** be
|
|
||||||
transferred back to the user automatically, the order will only be *marked* as refunded within pretix.
|
|
||||||
|
|
||||||
**Example request**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_expired/ HTTP/1.1
|
|
||||||
Host: pretix.eu
|
|
||||||
Accept: application/json, text/javascript
|
|
||||||
|
|
||||||
**Example response**:
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Vary: Accept
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"code": "ABC12",
|
|
||||||
"status": "r",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to modify
|
|
||||||
:param event: The ``slug`` field of the event to modify
|
|
||||||
:param code: The ``code`` field of the order to modify
|
|
||||||
:statuscode 200: no error
|
|
||||||
:statuscode 400: The order cannot be marked as expired since the current order status does not allow it.
|
|
||||||
:statuscode 401: Authentication failure
|
|
||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
|
||||||
:statuscode 404: The requested order does not exist.
|
|
||||||
|
|
||||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/
|
||||||
|
|
||||||
Marks a unpaid order as expired.
|
Marks a unpaid order as expired.
|
||||||
@@ -1054,6 +1036,8 @@ List of all order positions
|
|||||||
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
|
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
|
||||||
``pseudonymization_id``.
|
``pseudonymization_id``.
|
||||||
|
|
||||||
|
.. note:: Individually canceled order positions are currently not visible via the API at all.
|
||||||
|
|
||||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
||||||
|
|
||||||
Returns a list of all order positions within a given event.
|
Returns a list of all order positions within a given event.
|
||||||
@@ -1489,7 +1473,7 @@ Order payment endpoints
|
|||||||
|
|
||||||
{
|
{
|
||||||
"amount": "23.00",
|
"amount": "23.00",
|
||||||
"mark_refunded": false
|
"mark_canceled": false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1636,7 +1620,7 @@ Order refund endpoints
|
|||||||
"payment": 1,
|
"payment": 1,
|
||||||
"execution_date": null,
|
"execution_date": null,
|
||||||
"provider": "manual",
|
"provider": "manual",
|
||||||
"mark_refunded": false
|
"mark_canceled": false
|
||||||
}
|
}
|
||||||
|
|
||||||
**Example response**:
|
**Example response**:
|
||||||
@@ -1706,7 +1690,7 @@ Order refund endpoints
|
|||||||
|
|
||||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/
|
||||||
|
|
||||||
Acts on an external refund, either marks the order as refunded or pending. Only allowed in state ``external``.
|
Acts on an external refund, either marks the order as canceled or pending. Only allowed in state ``external``.
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
@@ -1717,7 +1701,7 @@ Order refund endpoints
|
|||||||
Accept: application/json, text/javascript
|
Accept: application/json, text/javascript
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{"mark_refunded": false}
|
{"mark_canceled": false}
|
||||||
|
|
||||||
**Example response**:
|
**Example response**:
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ The following values for ``action_types`` are valid with pretix core:
|
|||||||
* ``pretix.event.order.contact.changed``
|
* ``pretix.event.order.contact.changed``
|
||||||
* ``pretix.event.order.changed.*``
|
* ``pretix.event.order.changed.*``
|
||||||
* ``pretix.event.order.refund.created.externally``
|
* ``pretix.event.order.refund.created.externally``
|
||||||
* ``pretix.event.order.refunded``
|
|
||||||
* ``pretix.event.order.approved``
|
* ``pretix.event.order.approved``
|
||||||
* ``pretix.event.order.denied``
|
* ``pretix.event.order.denied``
|
||||||
* ``pretix.event.checkin``
|
* ``pretix.event.checkin``
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Core
|
|||||||
|
|
||||||
.. automodule:: pretix.base.signals
|
.. automodule:: pretix.base.signals
|
||||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
|
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
|
||||||
item_copy_data
|
item_copy_data, register_sales_channels
|
||||||
|
|
||||||
Order events
|
Order events
|
||||||
""""""""""""
|
""""""""""""
|
||||||
@@ -26,7 +26,7 @@ Frontend
|
|||||||
--------
|
--------
|
||||||
|
|
||||||
.. automodule:: pretix.presale.signals
|
.. automodule:: pretix.presale.signals
|
||||||
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content
|
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional
|
||||||
|
|
||||||
|
|
||||||
.. automodule:: pretix.presale.signals
|
.. automodule:: pretix.presale.signals
|
||||||
@@ -49,7 +49,7 @@ Backend
|
|||||||
|
|
||||||
.. automodule:: pretix.control.signals
|
.. automodule:: pretix.control.signals
|
||||||
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
|
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
|
||||||
order_info, event_settings_widget, oauth_application_registered
|
order_info, event_settings_widget, oauth_application_registered, order_position_buttons
|
||||||
|
|
||||||
|
|
||||||
.. automodule:: pretix.base.signals
|
.. automodule:: pretix.base.signals
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ that we'll provide in this plugin::
|
|||||||
|
|
||||||
|
|
||||||
@receiver(register_invoice_renderers, dispatch_uid="output_custom")
|
@receiver(register_invoice_renderers, dispatch_uid="output_custom")
|
||||||
def register_infoice_renderers(sender, **kwargs):
|
def register_invoice_renderers(sender, **kwargs):
|
||||||
from .invoice import MyInvoiceRenderer
|
from .invoice import MyInvoiceRenderer
|
||||||
return MyInvoiceRenderer
|
return MyInvoiceRenderer
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ human-readable error messages. We recommend using the ``django.utils.functional.
|
|||||||
decorator, as it might get called a lot. You can also implement ``compatibility_warnings``,
|
decorator, as it might get called a lot. You can also implement ``compatibility_warnings``,
|
||||||
those will be displayed but not block the plugin execution.
|
those will be displayed but not block the plugin execution.
|
||||||
|
|
||||||
|
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
|
||||||
|
is available for a specific event. If not, it will not be shown in the plugin list of that event.
|
||||||
|
|
||||||
Plugin registration
|
Plugin registration
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ default boolean ``true`` if thi
|
|||||||
layout object Layout specification for libpretixprint
|
layout object Layout specification for libpretixprint
|
||||||
background URL Background PDF file
|
background URL Background PDF file
|
||||||
item_assignments list of objects Products this layout is assigned to
|
item_assignments list of objects Products this layout is assigned to
|
||||||
|
├ sales_channel string Sales channel (defaults to ``web``).
|
||||||
└ item integer Item ID
|
└ item integer Item ID
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
@@ -27,6 +28,10 @@ item_assignments list of objects Products this l
|
|||||||
|
|
||||||
This resource has been added.
|
This resource has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 2.3
|
||||||
|
|
||||||
|
The ``item_assignments.sales_channel`` field has been added.
|
||||||
|
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
addon
|
addon
|
||||||
addons
|
addons
|
||||||
|
Analytics
|
||||||
anonymize
|
anonymize
|
||||||
api
|
api
|
||||||
auditability
|
auditability
|
||||||
auth
|
auth
|
||||||
autobuild
|
autobuild
|
||||||
|
availabilities
|
||||||
backend
|
backend
|
||||||
backends
|
backends
|
||||||
banktransfer
|
banktransfer
|
||||||
@@ -65,6 +67,7 @@ ons
|
|||||||
optimizations
|
optimizations
|
||||||
overpayment
|
overpayment
|
||||||
param
|
param
|
||||||
|
passphrase
|
||||||
percental
|
percental
|
||||||
positionid
|
positionid
|
||||||
pre
|
pre
|
||||||
|
|||||||
@@ -149,8 +149,101 @@ Just as the widget, the button supports the optional attributes ``voucher`` and
|
|||||||
|
|
||||||
You can style the button using the ``pretix-button`` CSS class.
|
You can style the button using the ``pretix-button`` CSS class.
|
||||||
|
|
||||||
.. versionchanged:: 1.13
|
Dynamically loading the widget
|
||||||
|
------------------------------
|
||||||
|
|
||||||
The pretix Button has been added in version 1.13.
|
If you need to control the way or timing the widget loads, for example because you want to modify user data (see
|
||||||
|
below) dynamically via JavaScript, you can register a listener that we will call before creating the widget::
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.pretixWidgetCallback = function () {
|
||||||
|
// Will be run before we create the widget.
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
If you want, you can suppress us loading the widget and/or modify the user data passed to the widget::
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.pretixWidgetCallback = function () {
|
||||||
|
window.PretixWidget.build_widgets = false;
|
||||||
|
window.PretixWidget.widget_data["email"] = "test@example.org";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
|
||||||
|
|
||||||
|
|
||||||
|
Passing user data to the widget
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
If you display the widget in a restricted area of your website and you want to pre-fill fields in the checkout process
|
||||||
|
with known user data to save your users some typing and increase conversions, you can pass additional data attributes
|
||||||
|
with that information::
|
||||||
|
|
||||||
|
<pretix-widget event="https://pretix.eu/demo/democon/"
|
||||||
|
data-attendee-name-given-name="John"
|
||||||
|
data-attendee-name-family-name="Doe"
|
||||||
|
data-invoice-address-name-given-name="John"
|
||||||
|
data-invoice-address-name-family-name="Doe"
|
||||||
|
data-email="test@example.org"
|
||||||
|
data-question-L9G8NG9M="Foobar">
|
||||||
|
</pretix-widget>
|
||||||
|
|
||||||
|
This works for the pretix Button as well. Currently, the following attributes are understood by pretix itself:
|
||||||
|
|
||||||
|
* ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled).
|
||||||
|
|
||||||
|
* ``data-question-IDENTIFIER`` will pre-fill the answer for the question with the given identifier. You can view and set
|
||||||
|
identifiers in the *Questions* section of the backend.
|
||||||
|
|
||||||
|
* Depending on the person name scheme configured in your event settings, you can pass one or more of
|
||||||
|
``data-attendee-name-full-name``, ``data-attendee-name-given-name``, ``data-attendee-name-family-name``,
|
||||||
|
``data-attendee-name-middle-name``, ``data-attendee-name-title``, ``data-attendee-name-calling-name``,
|
||||||
|
``data-attendee-name-latin-transcription``. If you don't know or don't care, you can also just pass a string as
|
||||||
|
``data-attendee-name``, which will pre-fill the last part of the name, whatever that is.
|
||||||
|
|
||||||
|
* ``data-invoice-address-FIELD`` will pre-fill the corresponding field of the invoice address. Possible values for
|
||||||
|
``FIELD`` are ``company``, ``street``, ``zipcode``, ``city`` and ``country``, as well as fields specified by the
|
||||||
|
naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character
|
||||||
|
country code.
|
||||||
|
|
||||||
|
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
|
||||||
|
Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||||
|
|
||||||
|
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
|
||||||
|
made through this widget will be counted towards this campaign.
|
||||||
|
|
||||||
|
* If you use the tracking plugin, you can pass a Google Analytics User ID to enable cross-domain tracking. This will
|
||||||
|
require you to dynamically load the widget, like this::
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||||
|
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||||
|
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||||
|
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||||
|
|
||||||
|
ga('create', 'UA-XXXXXX-1', 'auto');
|
||||||
|
ga('send', 'pageview');
|
||||||
|
|
||||||
|
window.pretixWidgetCallback = function () {
|
||||||
|
window.PretixWidget.build_widgets = false;
|
||||||
|
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||||
|
if(window.ga && ga.create) {
|
||||||
|
ga(function(tracker) {
|
||||||
|
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
|
||||||
|
window.PretixWidget.buildWidgets()
|
||||||
|
});
|
||||||
|
} else { // Tracking is probably blocked
|
||||||
|
window.PretixWidget.buildWidgets()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
.. versionchanged:: 2.3
|
||||||
|
|
||||||
|
Data passing options have been added in pretix 2.3. If you use a self-hosted version of pretix, they only work
|
||||||
|
fully if you configured a redis server.
|
||||||
|
|
||||||
.. _Let's Encrypt: https://letsencrypt.org/
|
.. _Let's Encrypt: https://letsencrypt.org/
|
||||||
|
|||||||
@@ -34,4 +34,5 @@ git push
|
|||||||
# Unlock Weblate
|
# Unlock Weblate
|
||||||
for c in $COMPONENTS; do
|
for c in $COMPONENTS; do
|
||||||
wlc unlock $c;
|
wlc unlock $c;
|
||||||
|
wlc pull $c;
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "2.2.0"
|
__version__ = "2.5.0.dev0"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@@ -95,7 +96,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
|||||||
from pretix.base.plugins import get_all_plugins
|
from pretix.base.plugins import get_all_plugins
|
||||||
|
|
||||||
plugins_available = {
|
plugins_available = {
|
||||||
p.module for p in get_all_plugins()
|
p.module for p in get_all_plugins(self.instance)
|
||||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
|||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
meta_data = validated_data.pop('meta_data', None)
|
meta_data = validated_data.pop('meta_data', None)
|
||||||
plugins = validated_data.pop('plugins', None)
|
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
|
||||||
event = super().create(validated_data)
|
event = super().create(validated_data)
|
||||||
|
|
||||||
# Meta data
|
# Meta data
|
||||||
@@ -122,6 +123,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
|||||||
# Plugins
|
# Plugins
|
||||||
if plugins is not None:
|
if plugins is not None:
|
||||||
event.set_active_plugins(plugins)
|
event.set_active_plugins(plugins)
|
||||||
|
event.save(update_fields=['plugins'])
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,20 @@ class I18nField(Field):
|
|||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
def to_representation(self, value):
|
def to_representation(self, value):
|
||||||
if value is None or value.data is None:
|
if hasattr(value, 'data'):
|
||||||
|
if isinstance(value.data, dict):
|
||||||
|
return value.data
|
||||||
|
elif value.data is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
settings.LANGUAGE_CODE: str(value.data)
|
||||||
|
}
|
||||||
|
elif value is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(value.data, dict):
|
|
||||||
return value.data
|
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
settings.LANGUAGE_CODE: str(value.data)
|
settings.LANGUAGE_CODE: str(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ class ItemSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Item
|
model = Item
|
||||||
fields = ('id', 'category', 'name', 'internal_name', 'active', 'description',
|
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
|
||||||
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
|
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
|
||||||
'position', 'picture', 'available_from', 'available_until',
|
'position', 'picture', 'available_from', 'available_until',
|
||||||
'require_voucher', 'hide_without_voucher', 'allow_cancel',
|
'require_voucher', 'hide_without_voucher', 'allow_cancel',
|
||||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
|
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
|
||||||
'variations', 'addons', 'original_price', 'require_approval')
|
'variations', 'addons', 'original_price', 'require_approval', 'generate_tickets')
|
||||||
read_only_fields = ('has_variations', 'picture')
|
read_only_fields = ('has_variations', 'picture')
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from rest_framework.relations import SlugRelatedField
|
|||||||
from rest_framework.reverse import reverse
|
from rest_framework.reverse import reverse
|
||||||
|
|
||||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||||
|
from pretix.base.channels import get_all_sales_channels
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||||
Question, QuestionAnswer,
|
Question, QuestionAnswer,
|
||||||
@@ -113,9 +114,7 @@ class PositionDownloadsField(serializers.Field):
|
|||||||
if instance.order.status != Order.STATUS_PAID:
|
if instance.order.status != Order.STATUS_PAID:
|
||||||
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
|
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
|
||||||
return []
|
return []
|
||||||
if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons:
|
if not instance.generate_ticket:
|
||||||
return []
|
|
||||||
if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm:
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
request = self.context['request']
|
request = self.context['request']
|
||||||
@@ -232,7 +231,7 @@ class OrderSerializer(I18nAwareModelSerializer):
|
|||||||
model = Order
|
model = Order
|
||||||
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
||||||
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
||||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval')
|
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel')
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -323,7 +322,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
|||||||
'secret', 'addon_to', 'subevent', 'answers')
|
'secret', 'addon_to', 'subevent', 'answers')
|
||||||
|
|
||||||
def validate_secret(self, secret):
|
def validate_secret(self, secret):
|
||||||
if secret and OrderPosition.objects.filter(order__event=self.context['event'], secret=secret).exists():
|
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
'You cannot assign a position secret that already exists.'
|
'You cannot assign a position secret that already exists.'
|
||||||
)
|
)
|
||||||
@@ -412,7 +411,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment',
|
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
|
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
|
||||||
|
|
||||||
def validate_payment_provider(self, pp):
|
def validate_payment_provider(self, pp):
|
||||||
@@ -420,6 +419,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
raise ValidationError('The given payment provider is not known.')
|
raise ValidationError('The given payment provider is not known.')
|
||||||
return pp
|
return pp
|
||||||
|
|
||||||
|
def validate_sales_channel(self, channel):
|
||||||
|
if channel not in get_all_sales_channels():
|
||||||
|
raise ValidationError('Unknown sales channel.')
|
||||||
|
return channel
|
||||||
|
|
||||||
def validate_code(self, code):
|
def validate_code(self, code):
|
||||||
if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists():
|
if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists():
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
@@ -551,7 +555,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
order.status = Order.STATUS_PAID
|
order.status = Order.STATUS_PAID
|
||||||
order.save()
|
order.save()
|
||||||
order.payments.create(
|
order.payments.create(
|
||||||
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||||
|
payment_date=now()
|
||||||
)
|
)
|
||||||
elif payment_provider == "free" and order.total != Decimal('0.00'):
|
elif payment_provider == "free" and order.total != Decimal('0.00'):
|
||||||
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
|
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Subquery
|
from django.db.models import Count, F, Max, OuterRef, Prefetch, Q, Subquery
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
@@ -16,7 +16,9 @@ from pretix.api.serializers.item import QuestionSerializer
|
|||||||
from pretix.api.serializers.order import OrderPositionSerializer
|
from pretix.api.serializers.order import OrderPositionSerializer
|
||||||
from pretix.api.views import RichOrderingFilter
|
from pretix.api.views import RichOrderingFilter
|
||||||
from pretix.api.views.order import OrderPositionFilter
|
from pretix.api.views.order import OrderPositionFilter
|
||||||
from pretix.base.models import Checkin, CheckinList, Order, OrderPosition
|
from pretix.base.models import (
|
||||||
|
Checkin, CheckinList, Event, Order, OrderPosition,
|
||||||
|
)
|
||||||
from pretix.base.services.checkin import (
|
from pretix.base.services.checkin import (
|
||||||
CheckInError, RequiredQuestionsError, perform_checkin,
|
CheckInError, RequiredQuestionsError, perform_checkin,
|
||||||
)
|
)
|
||||||
@@ -201,12 +203,39 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
subevent=self.checkinlist.subevent
|
subevent=self.checkinlist.subevent
|
||||||
).annotate(
|
).annotate(
|
||||||
last_checked_in=Subquery(cqs)
|
last_checked_in=Subquery(cqs)
|
||||||
).prefetch_related(
|
)
|
||||||
Prefetch(
|
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||||
lookup='checkins',
|
qs = qs.prefetch_related(
|
||||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
Prefetch(
|
||||||
|
lookup='checkins',
|
||||||
|
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||||
|
),
|
||||||
|
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||||
|
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||||
|
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
'event',
|
||||||
|
Event.objects.select_related('organizer')
|
||||||
|
),
|
||||||
|
Prefetch(
|
||||||
|
'positions',
|
||||||
|
OrderPosition.objects.prefetch_related(
|
||||||
|
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
).select_related(
|
||||||
|
'item', 'variation', 'item__category', 'addon_to'
|
||||||
)
|
)
|
||||||
).select_related('item', 'variation', 'order', 'addon_to')
|
else:
|
||||||
|
qs = qs.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
lookup='checkins',
|
||||||
|
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||||
|
),
|
||||||
|
'answers', 'answers__options', 'answers__question',
|
||||||
|
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||||
|
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
|
||||||
|
|
||||||
if not self.checkinlist.all_products:
|
if not self.checkinlist.all_products:
|
||||||
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
||||||
@@ -251,6 +280,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
except RequiredQuestionsError as e:
|
except RequiredQuestionsError as e:
|
||||||
return Response({
|
return Response({
|
||||||
'status': 'incomplete',
|
'status': 'incomplete',
|
||||||
|
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||||
|
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data,
|
||||||
'questions': [
|
'questions': [
|
||||||
QuestionSerializer(q).data for q in e.questions
|
QuestionSerializer(q).data for q in e.questions
|
||||||
]
|
]
|
||||||
@@ -258,9 +289,21 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
except CheckInError as e:
|
except CheckInError as e:
|
||||||
return Response({
|
return Response({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'reason': e.code
|
'reason': e.code,
|
||||||
|
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||||
|
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||||
}, status=400)
|
}, status=400)
|
||||||
else:
|
else:
|
||||||
return Response({
|
return Response({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
|
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||||
|
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||||
}, status=201)
|
}, status=201)
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
if self.kwargs['pk'].isnumeric():
|
||||||
|
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||||
|
else:
|
||||||
|
obj = get_object_or_404(queryset, secret=self.kwargs['pk'])
|
||||||
|
return obj
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
import pytz
|
import pytz
|
||||||
@@ -25,8 +26,8 @@ from pretix.api.serializers.order import (
|
|||||||
OrderRefundSerializer, OrderSerializer,
|
OrderRefundSerializer, OrderSerializer,
|
||||||
)
|
)
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Device, Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota,
|
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, Order,
|
||||||
TeamAPIToken,
|
OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
|
||||||
)
|
)
|
||||||
from pretix.base.payment import PaymentException
|
from pretix.base.payment import PaymentException
|
||||||
from pretix.base.services.invoices import (
|
from pretix.base.services.invoices import (
|
||||||
@@ -38,9 +39,7 @@ from pretix.base.services.orders import (
|
|||||||
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
|
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
|
||||||
extend_order, mark_order_expired, mark_order_refunded,
|
extend_order, mark_order_expired, mark_order_refunded,
|
||||||
)
|
)
|
||||||
from pretix.base.services.tickets import (
|
from pretix.base.services.tickets import generate
|
||||||
get_cachedticket_for_order, get_cachedticket_for_position,
|
|
||||||
)
|
|
||||||
from pretix.base.signals import order_placed, register_ticket_outputs
|
from pretix.base.signals import order_placed, register_ticket_outputs
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +83,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
'positions',
|
'positions',
|
||||||
OrderPosition.objects.all().prefetch_related(
|
OrderPosition.objects.all().prefetch_related(
|
||||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||||
|
'item__category', 'addon_to',
|
||||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -130,9 +130,11 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
if order.status != Order.STATUS_PAID:
|
if order.status != Order.STATUS_PAID:
|
||||||
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
||||||
|
|
||||||
ct = get_cachedticket_for_order(order, provider.identifier)
|
ct = CachedCombinedTicket.objects.filter(
|
||||||
|
order=order, provider=provider.identifier, file__isnull=False
|
||||||
if not ct.file:
|
).last()
|
||||||
|
if not ct or not ct.file:
|
||||||
|
generate.apply_async(args=('order', order.pk, provider.identifier))
|
||||||
raise RetryException()
|
raise RetryException()
|
||||||
else:
|
else:
|
||||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||||
@@ -186,6 +188,12 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
@detail_route(methods=['POST'])
|
@detail_route(methods=['POST'])
|
||||||
def mark_canceled(self, request, **kwargs):
|
def mark_canceled(self, request, **kwargs):
|
||||||
send_mail = request.data.get('send_email', True)
|
send_mail = request.data.get('send_email', True)
|
||||||
|
cancellation_fee = request.data.get('cancellation_fee', None)
|
||||||
|
if cancellation_fee:
|
||||||
|
try:
|
||||||
|
cancellation_fee = float(Decimal(cancellation_fee))
|
||||||
|
except:
|
||||||
|
cancellation_fee = None
|
||||||
|
|
||||||
order = self.get_object()
|
order = self.get_object()
|
||||||
if not order.cancel_allowed():
|
if not order.cancel_allowed():
|
||||||
@@ -194,14 +202,21 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
cancel_order(
|
try:
|
||||||
order,
|
cancel_order(
|
||||||
user=request.user if request.user.is_authenticated else None,
|
order,
|
||||||
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
user=request.user if request.user.is_authenticated else None,
|
||||||
device=request.auth if isinstance(request.auth, Device) else None,
|
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
||||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
device=request.auth if isinstance(request.auth, Device) else None,
|
||||||
send_mail=send_mail
|
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||||
)
|
send_mail=send_mail,
|
||||||
|
cancellation_fee=cancellation_fee
|
||||||
|
)
|
||||||
|
except OrderError as e:
|
||||||
|
return Response(
|
||||||
|
{'detail': str(e)},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
return self.retrieve(request, [], **kwargs)
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
@detail_route(methods=['POST'])
|
@detail_route(methods=['POST'])
|
||||||
@@ -377,6 +392,7 @@ class OrderPositionFilter(FilterSet):
|
|||||||
| Q(addon_to__attendee_name_cached__icontains=value)
|
| Q(addon_to__attendee_name_cached__icontains=value)
|
||||||
| Q(order__code__istartswith=value)
|
| Q(order__code__istartswith=value)
|
||||||
| Q(order__invoice_address__name_cached__icontains=value)
|
| Q(order__invoice_address__name_cached__icontains=value)
|
||||||
|
| Q(order__email__icontains=value)
|
||||||
)
|
)
|
||||||
|
|
||||||
def has_checkin_qs(self, queryset, name, value):
|
def has_checkin_qs(self, queryset, name, value):
|
||||||
@@ -421,11 +437,33 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
|
qs = OrderPosition.objects.filter(order__event=self.request.event)
|
||||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||||
).select_related(
|
qs = qs.prefetch_related(
|
||||||
'item', 'order', 'order__event', 'order__event__organizer'
|
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||||
)
|
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||||
|
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
'event',
|
||||||
|
Event.objects.select_related('organizer')
|
||||||
|
),
|
||||||
|
Prefetch(
|
||||||
|
'positions',
|
||||||
|
OrderPosition.objects.prefetch_related(
|
||||||
|
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
).select_related(
|
||||||
|
'item', 'variation', 'item__category', 'addon_to'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
qs = qs.prefetch_related(
|
||||||
|
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||||
|
).select_related(
|
||||||
|
'item', 'order', 'order__event', 'order__event__organizer'
|
||||||
|
)
|
||||||
|
return qs
|
||||||
|
|
||||||
def _get_output_provider(self, identifier):
|
def _get_output_provider(self, identifier):
|
||||||
responses = register_ticket_outputs.send(self.request.event)
|
responses = register_ticket_outputs.send(self.request.event)
|
||||||
@@ -442,14 +480,14 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
|||||||
|
|
||||||
if pos.order.status != Order.STATUS_PAID:
|
if pos.order.status != Order.STATUS_PAID:
|
||||||
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
||||||
if pos.addon_to_id and not request.event.settings.ticket_download_addons:
|
if not pos.generate_ticket:
|
||||||
raise PermissionDenied("Downloads are not enabled for add-on products.")
|
raise PermissionDenied("Downloads are not enabled for this product.")
|
||||||
if not pos.item.admission and not request.event.settings.ticket_download_nonadm:
|
|
||||||
raise PermissionDenied("Downloads are not enabled for non-admission products.")
|
|
||||||
|
|
||||||
ct = get_cachedticket_for_position(pos, provider.identifier)
|
ct = CachedTicket.objects.filter(
|
||||||
|
order_position=pos, provider=provider.identifier, file__isnull=False
|
||||||
if not ct.file:
|
).last()
|
||||||
|
if not ct or not ct.file:
|
||||||
|
generate.apply_async(args=('orderposition', pos.pk, provider.identifier))
|
||||||
raise RetryException()
|
raise RetryException()
|
||||||
else:
|
else:
|
||||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||||
@@ -513,7 +551,10 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||||
request.data.get('amount', str(payment.amount))
|
request.data.get('amount', str(payment.amount))
|
||||||
)
|
)
|
||||||
mark_refunded = request.data.get('mark_refunded', False)
|
if 'mark_refunded' in request.data:
|
||||||
|
mark_refunded = request.data.get('mark_refunded', False)
|
||||||
|
else:
|
||||||
|
mark_refunded = request.data.get('mark_canceled', False)
|
||||||
|
|
||||||
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
|
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
|
||||||
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@@ -622,10 +663,14 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||||
if request.data.get('mark_refunded', False):
|
if 'mark_refunded' in request.data:
|
||||||
|
mark_refunded = request.data.get('mark_refunded', False)
|
||||||
|
else:
|
||||||
|
mark_refunded = request.data.get('mark_canceled', False)
|
||||||
|
if mark_refunded:
|
||||||
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
|
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
auth=self.request.auth)
|
auth=self.request.auth)
|
||||||
else:
|
elif not (refund.order.status == Order.STATUS_PAID and refund.order.pending_sum <= 0):
|
||||||
refund.order.status = Order.STATUS_PENDING
|
refund.order.status = Order.STATUS_PENDING
|
||||||
refund.order.set_expires(
|
refund.order.set_expires(
|
||||||
now(),
|
now(),
|
||||||
@@ -651,7 +696,10 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
mark_refunded = request.data.pop('mark_refunded', False)
|
if 'mark_refunded' in request.data:
|
||||||
|
mark_refunded = request.data.pop('mark_refunded', False)
|
||||||
|
else:
|
||||||
|
mark_refunded = request.data.pop('mark_canceled', False)
|
||||||
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
|
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -768,7 +816,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||||
else:
|
else:
|
||||||
c = generate_cancellation(inv)
|
c = generate_cancellation(inv)
|
||||||
if inv.order.status not in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
|
if inv.order.status != Order.STATUS_CANCELED:
|
||||||
inv = generate_invoice(inv.order)
|
inv = generate_invoice(inv.order)
|
||||||
else:
|
else:
|
||||||
inv = c
|
inv = c
|
||||||
|
|||||||
@@ -124,12 +124,12 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
serializer.save(event=self.request.event)
|
serializer.save(event=self.request.event)
|
||||||
for i in serializer.instance:
|
for i, v in enumerate(serializer.instance):
|
||||||
i.log_action(
|
v.log_action(
|
||||||
'pretix.voucher.added',
|
'pretix.voucher.added',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data=self.request.data
|
data=self.request.data[i]
|
||||||
)
|
)
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
|||||||
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
|
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
|
||||||
d['checkin_list'] = logentry.parsed_data.get('list')
|
d['checkin_list'] = logentry.parsed_data.get('list')
|
||||||
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
|
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
|
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
|
||||||
@@ -111,6 +112,10 @@ def register_default_webhook_events(sender, **kwargs):
|
|||||||
'pretix.event.order.placed',
|
'pretix.event.order.placed',
|
||||||
_('New order placed'),
|
_('New order placed'),
|
||||||
),
|
),
|
||||||
|
ParametrizedOrderWebhookEvent(
|
||||||
|
'pretix.event.order.placed.required_approval',
|
||||||
|
_('New order requires approval'),
|
||||||
|
),
|
||||||
ParametrizedOrderWebhookEvent(
|
ParametrizedOrderWebhookEvent(
|
||||||
'pretix.event.order.paid',
|
'pretix.event.order.paid',
|
||||||
_('Order marked as paid'),
|
_('Order marked as paid'),
|
||||||
@@ -139,10 +144,6 @@ def register_default_webhook_events(sender, **kwargs):
|
|||||||
'pretix.event.order.refund.created.externally',
|
'pretix.event.order.refund.created.externally',
|
||||||
_('External refund of payment'),
|
_('External refund of payment'),
|
||||||
),
|
),
|
||||||
ParametrizedOrderWebhookEvent(
|
|
||||||
'pretix.event.order.refunded',
|
|
||||||
_('Order refunded'),
|
|
||||||
),
|
|
||||||
ParametrizedOrderWebhookEvent(
|
ParametrizedOrderWebhookEvent(
|
||||||
'pretix.event.order.approved',
|
'pretix.event.order.approved',
|
||||||
_('Order approved'),
|
_('Order approved'),
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import logging
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from pretix.base.signals import register_sales_channels
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
_ALL_CHANNELS = None
|
||||||
|
|
||||||
|
|
||||||
|
class SalesChannel:
|
||||||
|
def __repr__(self):
|
||||||
|
return '<SalesChannel: {}>'.format(self.identifier)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identifier(self) -> str:
|
||||||
|
"""
|
||||||
|
The internal identifier of this sales channel.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
@property
|
||||||
|
def verbose_name(self) -> str:
|
||||||
|
"""
|
||||||
|
A human-readable name of this sales channel.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str:
|
||||||
|
"""
|
||||||
|
The name of a Font Awesome icon to represent this channel
|
||||||
|
"""
|
||||||
|
return "circle"
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_sales_channels():
|
||||||
|
global _ALL_CHANNELS
|
||||||
|
|
||||||
|
if _ALL_CHANNELS:
|
||||||
|
return _ALL_CHANNELS
|
||||||
|
|
||||||
|
types = OrderedDict()
|
||||||
|
for recv, ret in register_sales_channels.send(None):
|
||||||
|
if isinstance(ret, (list, tuple)):
|
||||||
|
for r in ret:
|
||||||
|
types[r.identifier] = r
|
||||||
|
else:
|
||||||
|
types[ret.identifier] = ret
|
||||||
|
_ALL_CHANNELS = types
|
||||||
|
return types
|
||||||
|
|
||||||
|
|
||||||
|
class WebshopSalesChannel(SalesChannel):
|
||||||
|
identifier = "web"
|
||||||
|
verbose_name = _('Online shop')
|
||||||
|
icon = "globe"
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(register_sales_channels, dispatch_uid="base_register_default_sales_channels")
|
||||||
|
def base_sales_channels(sender, **kwargs):
|
||||||
|
return (
|
||||||
|
WebshopSalesChannel(),
|
||||||
|
)
|
||||||
+145
-1
@@ -1,5 +1,14 @@
|
|||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
from collections import OrderedDict
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
|
from defusedcsv import csv
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import ugettext, ugettext_lazy as _
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.cell.cell import KNOWN_TYPES
|
||||||
|
|
||||||
|
|
||||||
class BaseExporter:
|
class BaseExporter:
|
||||||
"""
|
"""
|
||||||
@@ -55,7 +64,7 @@ class BaseExporter:
|
|||||||
"""
|
"""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def render(self, form_data: dict) -> Tuple[str, str, str]:
|
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
|
||||||
"""
|
"""
|
||||||
Render the exported file and return a tuple consisting of a filename, a file type
|
Render the exported file and return a tuple consisting of a filename, a file type
|
||||||
and file content.
|
and file content.
|
||||||
@@ -69,3 +78,138 @@ class BaseExporter:
|
|||||||
tasks.
|
tasks.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError() # NOQA
|
raise NotImplementedError() # NOQA
|
||||||
|
|
||||||
|
|
||||||
|
class ListExporter(BaseExporter):
|
||||||
|
|
||||||
|
@property
|
||||||
|
def export_form_fields(self) -> dict:
|
||||||
|
ff = OrderedDict(
|
||||||
|
[
|
||||||
|
('_format',
|
||||||
|
forms.ChoiceField(
|
||||||
|
label=_('Export format'),
|
||||||
|
choices=(
|
||||||
|
('xlsx', _('Excel (.xlsx)')),
|
||||||
|
('default', _('CSV (with commas)')),
|
||||||
|
('excel', _('CSV (Excel-style)')),
|
||||||
|
('semicolon', _('CSV (with semicolons)')),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ff.update(self.additional_form_fields)
|
||||||
|
return ff
|
||||||
|
|
||||||
|
@property
|
||||||
|
def additional_form_fields(self) -> dict:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def iterate_list(self, form_data):
|
||||||
|
raise NotImplementedError() # noqa
|
||||||
|
|
||||||
|
def get_filename(self):
|
||||||
|
return 'export.csv'
|
||||||
|
|
||||||
|
def _render_csv(self, form_data, **kwargs):
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output, **kwargs)
|
||||||
|
for line in self.iterate_list(form_data):
|
||||||
|
writer.writerow(line)
|
||||||
|
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
def _render_xlsx(self, form_data):
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.get_active_sheet()
|
||||||
|
try:
|
||||||
|
ws.title = str(self.verbose_name)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
for i, line in enumerate(self.iterate_list(form_data)):
|
||||||
|
for j, val in enumerate(line):
|
||||||
|
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
|
||||||
|
wb.save(f.name)
|
||||||
|
f.seek(0)
|
||||||
|
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
|
||||||
|
|
||||||
|
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
|
||||||
|
if form_data.get('_format') == 'xlsx':
|
||||||
|
return self._render_xlsx(form_data)
|
||||||
|
elif form_data.get('_format') == 'default':
|
||||||
|
return self._render_csv(form_data, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
|
||||||
|
elif form_data.get('_format') == 'csv-excel':
|
||||||
|
return self._render_csv(form_data, dialect='excel')
|
||||||
|
elif form_data.get('_format') == 'semicolon':
|
||||||
|
return self._render_csv(form_data, dialect='excel', delimiter=';')
|
||||||
|
|
||||||
|
|
||||||
|
class MultiSheetListExporter(ListExporter):
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sheets(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def export_form_fields(self) -> dict:
|
||||||
|
choices = [
|
||||||
|
('xlsx', _('Combined Excel (.xlsx)')),
|
||||||
|
]
|
||||||
|
for s, l in self.sheets:
|
||||||
|
choices += [
|
||||||
|
(s + ':default', str(l) + ' – ' + ugettext('CSV (with commas)')),
|
||||||
|
(s + ':excel', str(l) + ' – ' + ugettext('CSV (Excel-style)')),
|
||||||
|
(s + ':semicolon', str(l) + ' – ' + ugettext('CSV (with semicolons)')),
|
||||||
|
]
|
||||||
|
ff = OrderedDict(
|
||||||
|
[
|
||||||
|
('_format',
|
||||||
|
forms.ChoiceField(
|
||||||
|
label=_('Export format'),
|
||||||
|
choices=choices,
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ff.update(self.additional_form_fields)
|
||||||
|
return ff
|
||||||
|
|
||||||
|
def iterate_list(self, form_data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def iterate_sheet(self, form_data, sheet):
|
||||||
|
raise NotImplementedError() # noqa
|
||||||
|
|
||||||
|
def _render_sheet_csv(self, form_data, sheet, **kwargs):
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output, **kwargs)
|
||||||
|
for line in self.iterate_sheet(form_data, sheet):
|
||||||
|
writer.writerow(line)
|
||||||
|
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
def _render_xlsx(self, form_data):
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.get_active_sheet()
|
||||||
|
wb.remove(ws)
|
||||||
|
for s, l in self.sheets:
|
||||||
|
ws = wb.create_sheet(str(l))
|
||||||
|
for i, line in enumerate(self.iterate_sheet(form_data, sheet=s)):
|
||||||
|
for j, val in enumerate(line):
|
||||||
|
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
|
||||||
|
wb.save(f.name)
|
||||||
|
f.seek(0)
|
||||||
|
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
|
||||||
|
|
||||||
|
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
|
||||||
|
if form_data.get('_format') == 'xlsx':
|
||||||
|
return self._render_xlsx(form_data)
|
||||||
|
elif ':' in form_data.get('_format'):
|
||||||
|
sheet, f = form_data.get('_format').split(':')
|
||||||
|
if f == 'default':
|
||||||
|
return self._render_sheet_csv(form_data, sheet, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
|
||||||
|
elif f == 'excel':
|
||||||
|
return self._render_sheet_csv(form_data, sheet, dialect='excel')
|
||||||
|
elif f == 'semicolon':
|
||||||
|
return self._render_sheet_csv(form_data, sheet, dialect='excel', delimiter=';')
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from ..signals import register_data_exporters
|
|||||||
|
|
||||||
class JSONExporter(BaseExporter):
|
class JSONExporter(BaseExporter):
|
||||||
identifier = 'json'
|
identifier = 'json'
|
||||||
verbose_name = 'JSON'
|
verbose_name = 'Order data (JSON)'
|
||||||
|
|
||||||
def render(self, form_data):
|
def render(self, form_data):
|
||||||
jo = {
|
jo = {
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
import io
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from defusedcsv import csv
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import DateTimeField, Max, OuterRef, Subquery, Sum
|
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.formats import localize
|
from django.utils.formats import date_format, localize
|
||||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||||
|
|
||||||
from pretix.base.models import InvoiceAddress, Order, OrderPosition
|
from pretix.base.models import (
|
||||||
|
InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||||
|
)
|
||||||
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
|
|
||||||
from ..exporter import BaseExporter
|
from ..exporter import ListExporter, MultiSheetListExporter
|
||||||
from ..signals import register_data_exporters
|
from ..signals import register_data_exporters
|
||||||
|
|
||||||
|
|
||||||
class OrderListExporter(BaseExporter):
|
class OrderListExporter(MultiSheetListExporter):
|
||||||
identifier = 'orderlistcsv'
|
identifier = 'orderlist'
|
||||||
verbose_name = ugettext_lazy('List of orders (CSV)')
|
verbose_name = ugettext_lazy('Order data')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def export_form_fields(self):
|
def sheets(self):
|
||||||
|
return (
|
||||||
|
('orders', _('Orders')),
|
||||||
|
('positions', _('Order positions')),
|
||||||
|
('fees', _('Order fees')),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def additional_form_fields(self):
|
||||||
return OrderedDict(
|
return OrderedDict(
|
||||||
[
|
[
|
||||||
('paid_only',
|
('paid_only',
|
||||||
@@ -51,10 +59,16 @@ class OrderListExporter(BaseExporter):
|
|||||||
tax_rates = sorted(tax_rates)
|
tax_rates = sorted(tax_rates)
|
||||||
return tax_rates
|
return tax_rates
|
||||||
|
|
||||||
def render(self, form_data: dict):
|
def iterate_sheet(self, form_data, sheet):
|
||||||
output = io.StringIO()
|
if sheet == 'orders':
|
||||||
|
return self.iterate_orders(form_data)
|
||||||
|
elif sheet == 'positions':
|
||||||
|
return self.iterate_positions(form_data)
|
||||||
|
elif sheet == 'fees':
|
||||||
|
return self.iterate_fees(form_data)
|
||||||
|
|
||||||
|
def iterate_orders(self, form_data: dict):
|
||||||
tz = pytz.timezone(self.event.settings.timezone)
|
tz = pytz.timezone(self.event.settings.timezone)
|
||||||
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
|
||||||
|
|
||||||
p_date = OrderPayment.objects.filter(
|
p_date = OrderPayment.objects.filter(
|
||||||
order=OuterRef('pk'),
|
order=OuterRef('pk'),
|
||||||
@@ -95,7 +109,7 @@ class OrderListExporter(BaseExporter):
|
|||||||
|
|
||||||
headers.append(_('Invoice numbers'))
|
headers.append(_('Invoice numbers'))
|
||||||
|
|
||||||
writer.writerow(headers)
|
yield headers
|
||||||
|
|
||||||
full_fee_sum_cache = {
|
full_fee_sum_cache = {
|
||||||
o['order__id']: o['grosssum'] for o in
|
o['order__id']: o['grosssum'] for o in
|
||||||
@@ -162,17 +176,193 @@ class OrderListExporter(BaseExporter):
|
|||||||
]
|
]
|
||||||
|
|
||||||
row.append(', '.join([i.number for i in order.invoices.all()]))
|
row.append(', '.join([i.number for i in order.invoices.all()]))
|
||||||
writer.writerow(row)
|
yield row
|
||||||
|
|
||||||
return '{}_orders.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
|
def iterate_fees(self, form_data: dict):
|
||||||
|
tz = pytz.timezone(self.event.settings.timezone)
|
||||||
|
|
||||||
|
qs = OrderFee.objects.filter(
|
||||||
|
order__event=self.event,
|
||||||
|
).select_related('order', 'order__invoice_address', 'tax_rule')
|
||||||
|
if form_data['paid_only']:
|
||||||
|
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
_('Order code'),
|
||||||
|
_('Status'),
|
||||||
|
_('Email'),
|
||||||
|
_('Order date'),
|
||||||
|
_('Fee type'),
|
||||||
|
_('Description'),
|
||||||
|
_('Price'),
|
||||||
|
_('Tax rate'),
|
||||||
|
_('Tax rule'),
|
||||||
|
_('Tax value'),
|
||||||
|
_('Company'),
|
||||||
|
_('Invoice address name'),
|
||||||
|
]
|
||||||
|
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||||
|
if len(name_scheme['fields']) > 1:
|
||||||
|
for k, label, w in name_scheme['fields']:
|
||||||
|
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||||
|
headers += [
|
||||||
|
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||||
|
]
|
||||||
|
|
||||||
|
yield headers
|
||||||
|
|
||||||
|
for op in qs.order_by('order__datetime'):
|
||||||
|
order = op.order
|
||||||
|
row = [
|
||||||
|
order.code,
|
||||||
|
order.get_status_display(),
|
||||||
|
order.email,
|
||||||
|
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||||
|
op.get_fee_type_display(),
|
||||||
|
op.description,
|
||||||
|
op.value,
|
||||||
|
op.tax_rate,
|
||||||
|
str(op.tax_rule) if op.tax_rule else '',
|
||||||
|
op.tax_value,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
row += [
|
||||||
|
order.invoice_address.company,
|
||||||
|
order.invoice_address.name,
|
||||||
|
]
|
||||||
|
if len(name_scheme['fields']) > 1:
|
||||||
|
for k, label, w in name_scheme['fields']:
|
||||||
|
row.append(
|
||||||
|
order.invoice_address.name_parts.get(k, '')
|
||||||
|
)
|
||||||
|
row += [
|
||||||
|
order.invoice_address.street,
|
||||||
|
order.invoice_address.zipcode,
|
||||||
|
order.invoice_address.city,
|
||||||
|
order.invoice_address.country if order.invoice_address.country else
|
||||||
|
order.invoice_address.country_old,
|
||||||
|
order.invoice_address.vat_id,
|
||||||
|
]
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||||
|
yield row
|
||||||
|
|
||||||
|
def iterate_positions(self, form_data: dict):
|
||||||
|
tz = pytz.timezone(self.event.settings.timezone)
|
||||||
|
|
||||||
|
qs = OrderPosition.objects.filter(
|
||||||
|
order__event=self.event,
|
||||||
|
).select_related(
|
||||||
|
'order', 'order__invoice_address', 'item', 'variation',
|
||||||
|
'voucher', 'tax_rule'
|
||||||
|
).prefetch_related(
|
||||||
|
'answers', 'answers__question'
|
||||||
|
)
|
||||||
|
if form_data['paid_only']:
|
||||||
|
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
_('Order code'),
|
||||||
|
_('Position ID'),
|
||||||
|
_('Status'),
|
||||||
|
_('Email'),
|
||||||
|
_('Order date'),
|
||||||
|
_('Product'),
|
||||||
|
_('Variation'),
|
||||||
|
_('Price'),
|
||||||
|
_('Tax rate'),
|
||||||
|
_('Tax rule'),
|
||||||
|
_('Tax value'),
|
||||||
|
_('Attendee name'),
|
||||||
|
]
|
||||||
|
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||||
|
if len(name_scheme['fields']) > 1:
|
||||||
|
for k, label, w in name_scheme['fields']:
|
||||||
|
headers.append(_('Attendee name') + ': ' + str(label))
|
||||||
|
headers += [
|
||||||
|
_('Attendee email'),
|
||||||
|
_('Voucher'),
|
||||||
|
_('Pseudonymization ID'),
|
||||||
|
]
|
||||||
|
questions = list(self.event.questions.all())
|
||||||
|
for q in questions:
|
||||||
|
headers.append(str(q.question))
|
||||||
|
headers += [
|
||||||
|
_('Company'),
|
||||||
|
_('Invoice address name'),
|
||||||
|
]
|
||||||
|
if len(name_scheme['fields']) > 1:
|
||||||
|
for k, label, w in name_scheme['fields']:
|
||||||
|
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||||
|
headers += [
|
||||||
|
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||||
|
]
|
||||||
|
|
||||||
|
yield headers
|
||||||
|
|
||||||
|
for op in qs.order_by('order__datetime', 'positionid'):
|
||||||
|
order = op.order
|
||||||
|
row = [
|
||||||
|
order.code,
|
||||||
|
op.positionid,
|
||||||
|
order.get_status_display(),
|
||||||
|
order.email,
|
||||||
|
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||||
|
str(op.item),
|
||||||
|
str(op.variation) if op.variation else '',
|
||||||
|
op.price,
|
||||||
|
op.tax_rate,
|
||||||
|
str(op.tax_rule) if op.tax_rule else '',
|
||||||
|
op.tax_value,
|
||||||
|
op.attendee_name,
|
||||||
|
]
|
||||||
|
if len(name_scheme['fields']) > 1:
|
||||||
|
for k, label, w in name_scheme['fields']:
|
||||||
|
row.append(
|
||||||
|
op.attendee_name_parts.get(k, '')
|
||||||
|
)
|
||||||
|
row += [
|
||||||
|
op.attendee_email,
|
||||||
|
op.voucher.code if op.voucher else '',
|
||||||
|
op.pseudonymization_id,
|
||||||
|
]
|
||||||
|
acache = {}
|
||||||
|
for a in op.answers.all():
|
||||||
|
acache[a.question_id] = str(a)
|
||||||
|
for q in questions:
|
||||||
|
row.append(acache.get(q.pk, ''))
|
||||||
|
try:
|
||||||
|
row += [
|
||||||
|
order.invoice_address.company,
|
||||||
|
order.invoice_address.name,
|
||||||
|
]
|
||||||
|
if len(name_scheme['fields']) > 1:
|
||||||
|
for k, label, w in name_scheme['fields']:
|
||||||
|
row.append(
|
||||||
|
order.invoice_address.name_parts.get(k, '')
|
||||||
|
)
|
||||||
|
row += [
|
||||||
|
order.invoice_address.street,
|
||||||
|
order.invoice_address.zipcode,
|
||||||
|
order.invoice_address.city,
|
||||||
|
order.invoice_address.country if order.invoice_address.country else
|
||||||
|
order.invoice_address.country_old,
|
||||||
|
order.invoice_address.vat_id,
|
||||||
|
]
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||||
|
yield row
|
||||||
|
|
||||||
|
def get_filename(self):
|
||||||
|
return '{}_orders'.format(self.event.slug)
|
||||||
|
|
||||||
|
|
||||||
class PaymentListExporter(BaseExporter):
|
class PaymentListExporter(ListExporter):
|
||||||
identifier = 'paymentlistcsv'
|
identifier = 'paymentlist'
|
||||||
verbose_name = ugettext_lazy('List of payments and refunds (CSV)')
|
verbose_name = ugettext_lazy('Order payments and refunds')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def export_form_fields(self):
|
def additional_form_fields(self):
|
||||||
return OrderedDict(
|
return OrderedDict(
|
||||||
[
|
[
|
||||||
('successful_only',
|
('successful_only',
|
||||||
@@ -184,10 +374,8 @@ class PaymentListExporter(BaseExporter):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def render(self, form_data: dict):
|
def iterate_list(self, form_data):
|
||||||
output = io.StringIO()
|
|
||||||
tz = pytz.timezone(self.event.settings.timezone)
|
tz = pytz.timezone(self.event.settings.timezone)
|
||||||
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
|
||||||
|
|
||||||
provider_names = {
|
provider_names = {
|
||||||
k: v.verbose_name
|
k: v.verbose_name
|
||||||
@@ -213,9 +401,9 @@ class PaymentListExporter(BaseExporter):
|
|||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||||
_('Amount'), _('Payment method')
|
_('Status code'), _('Amount'), _('Payment method')
|
||||||
]
|
]
|
||||||
writer.writerow(headers)
|
yield headers
|
||||||
|
|
||||||
for obj in objs:
|
for obj in objs:
|
||||||
if isinstance(obj, OrderPayment) and obj.payment_date:
|
if isinstance(obj, OrderPayment) and obj.payment_date:
|
||||||
@@ -230,27 +418,26 @@ class PaymentListExporter(BaseExporter):
|
|||||||
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||||
d2,
|
d2,
|
||||||
obj.get_state_display(),
|
obj.get_state_display(),
|
||||||
|
obj.state,
|
||||||
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)),
|
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)),
|
||||||
provider_names.get(obj.provider, obj.provider)
|
provider_names.get(obj.provider, obj.provider)
|
||||||
]
|
]
|
||||||
writer.writerow(row)
|
yield row
|
||||||
|
|
||||||
return '{}_payments.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
|
def get_filename(self):
|
||||||
|
return '{}_payments'.format(self.event.slug)
|
||||||
|
|
||||||
|
|
||||||
class QuotaListExporter(BaseExporter):
|
class QuotaListExporter(ListExporter):
|
||||||
identifier = 'quotalistcsv'
|
identifier = 'quotalist'
|
||||||
verbose_name = ugettext_lazy('Quota availabilities (CSV)')
|
verbose_name = ugettext_lazy('Quota availabilities')
|
||||||
|
|
||||||
def render(self, form_data: dict):
|
|
||||||
output = io.StringIO()
|
|
||||||
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
|
||||||
|
|
||||||
|
def iterate_list(self, form_data):
|
||||||
headers = [
|
headers = [
|
||||||
_('Quota name'), _('Total quota'), _('Paid orders'), _('Pending orders'), _('Blocking vouchers'),
|
_('Quota name'), _('Total quota'), _('Paid orders'), _('Pending orders'), _('Blocking vouchers'),
|
||||||
_('Current user\'s carts'), _('Waiting list'), _('Current availability')
|
_('Current user\'s carts'), _('Waiting list'), _('Current availability')
|
||||||
]
|
]
|
||||||
writer.writerow(headers)
|
yield headers
|
||||||
|
|
||||||
for quota in self.event.quotas.all():
|
for quota in self.event.quotas.all():
|
||||||
avail = quota.availability()
|
avail = quota.availability()
|
||||||
@@ -264,9 +451,180 @@ class QuotaListExporter(BaseExporter):
|
|||||||
quota.count_waiting_list_pending(),
|
quota.count_waiting_list_pending(),
|
||||||
_('Infinite') if avail[1] is None else avail[1]
|
_('Infinite') if avail[1] is None else avail[1]
|
||||||
]
|
]
|
||||||
writer.writerow(row)
|
yield row
|
||||||
|
|
||||||
return '{}_quotas.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
|
def get_filename(self):
|
||||||
|
return '{}_quotas'.format(self.event.slug)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceDataExporter(MultiSheetListExporter):
|
||||||
|
identifier = 'invoicedata'
|
||||||
|
verbose_name = ugettext_lazy('Invoice data')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sheets(self):
|
||||||
|
return (
|
||||||
|
('invoices', _('Invoices')),
|
||||||
|
('lines', _('Invoice lines')),
|
||||||
|
)
|
||||||
|
|
||||||
|
def iterate_sheet(self, form_data, sheet):
|
||||||
|
if sheet == 'invoices':
|
||||||
|
yield [
|
||||||
|
_('Invoice number'),
|
||||||
|
_('Date'),
|
||||||
|
_('Order code'),
|
||||||
|
_('E-mail address'),
|
||||||
|
_('Invoice type'),
|
||||||
|
_('Cancellation of'),
|
||||||
|
_('Language'),
|
||||||
|
_('Invoice sender:') + ' ' + _('Name'),
|
||||||
|
_('Invoice sender:') + ' ' + _('Address'),
|
||||||
|
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||||
|
_('Invoice sender:') + ' ' + _('City'),
|
||||||
|
_('Invoice sender:') + ' ' + _('Country'),
|
||||||
|
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||||
|
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Company'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Name'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Street address'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('City'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Country'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||||
|
_('Reverse charge'),
|
||||||
|
_('Shown foreign currency'),
|
||||||
|
_('Foreign currency rate'),
|
||||||
|
_('Total value (with taxes)'),
|
||||||
|
_('Total value (without taxes)'),
|
||||||
|
]
|
||||||
|
qs = self.event.invoices.order_by('full_invoice_no').select_related(
|
||||||
|
'order', 'refers'
|
||||||
|
).annotate(
|
||||||
|
total_gross=Subquery(
|
||||||
|
InvoiceLine.objects.filter(
|
||||||
|
invoice=OuterRef('pk')
|
||||||
|
).order_by().values('invoice').annotate(
|
||||||
|
s=Sum('gross_value')
|
||||||
|
).values('s')
|
||||||
|
),
|
||||||
|
total_net=Subquery(
|
||||||
|
InvoiceLine.objects.filter(
|
||||||
|
invoice=OuterRef('pk')
|
||||||
|
).order_by().values('invoice').annotate(
|
||||||
|
s=Sum(F('gross_value') - F('tax_value'))
|
||||||
|
).values('s')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for i in qs:
|
||||||
|
yield [
|
||||||
|
i.full_invoice_no,
|
||||||
|
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||||
|
i.order.code,
|
||||||
|
i.order.email,
|
||||||
|
_('Cancellation') if i.is_cancellation else _('Invoice'),
|
||||||
|
i.refers.full_invoice_no if i.refers else '',
|
||||||
|
i.locale,
|
||||||
|
i.invoice_from_name,
|
||||||
|
i.invoice_from,
|
||||||
|
i.invoice_from_zipcode,
|
||||||
|
i.invoice_from_city,
|
||||||
|
i.invoice_from_country,
|
||||||
|
i.invoice_from_tax_id,
|
||||||
|
i.invoice_from_vat_id,
|
||||||
|
i.invoice_to_company,
|
||||||
|
i.invoice_to_name,
|
||||||
|
i.invoice_to_street or i.invoice_to,
|
||||||
|
i.invoice_to_zipcode,
|
||||||
|
i.invoice_to_city,
|
||||||
|
i.invoice_to_country,
|
||||||
|
i.invoice_to_vat_id,
|
||||||
|
i.invoice_to_beneficiary,
|
||||||
|
i.internal_reference,
|
||||||
|
_('Yes') if i.reverse_charge else _('No'),
|
||||||
|
i.foreign_currency_display,
|
||||||
|
i.foreign_currency_rate,
|
||||||
|
i.total_gross if i.total_gross else Decimal('0.00'),
|
||||||
|
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
|
||||||
|
]
|
||||||
|
elif sheet == 'lines':
|
||||||
|
yield [
|
||||||
|
_('Invoice number'),
|
||||||
|
_('Line number'),
|
||||||
|
_('Description'),
|
||||||
|
_('Gross price'),
|
||||||
|
_('Net price'),
|
||||||
|
_('Tax value'),
|
||||||
|
_('Tax rate'),
|
||||||
|
_('Tax name'),
|
||||||
|
_('Event start date'),
|
||||||
|
|
||||||
|
_('Date'),
|
||||||
|
_('Order code'),
|
||||||
|
_('E-mail address'),
|
||||||
|
_('Invoice type'),
|
||||||
|
_('Cancellation of'),
|
||||||
|
_('Invoice sender:') + ' ' + _('Name'),
|
||||||
|
_('Invoice sender:') + ' ' + _('Address'),
|
||||||
|
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||||
|
_('Invoice sender:') + ' ' + _('City'),
|
||||||
|
_('Invoice sender:') + ' ' + _('Country'),
|
||||||
|
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||||
|
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Company'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Name'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Street address'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('City'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Country'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||||
|
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||||
|
]
|
||||||
|
qs = InvoiceLine.objects.filter(
|
||||||
|
invoice__event=self.event
|
||||||
|
).order_by('invoice__full_invoice_no', 'position').select_related(
|
||||||
|
'invoice', 'invoice__order', 'invoice__refers'
|
||||||
|
)
|
||||||
|
for l in qs:
|
||||||
|
i = l.invoice
|
||||||
|
yield [
|
||||||
|
i.full_invoice_no,
|
||||||
|
l.position + 1,
|
||||||
|
l.description.replace("<br />", " - "),
|
||||||
|
l.gross_value,
|
||||||
|
l.net_value,
|
||||||
|
l.tax_value,
|
||||||
|
l.tax_rate,
|
||||||
|
l.tax_name,
|
||||||
|
date_format(l.event_date_from, "SHORT_DATE_FORMAT") if l.event_date_from else "",
|
||||||
|
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||||
|
i.order.code,
|
||||||
|
i.order.email,
|
||||||
|
_('Cancellation') if i.is_cancellation else _('Invoice'),
|
||||||
|
i.refers.full_invoice_no if i.refers else '',
|
||||||
|
i.invoice_from_name,
|
||||||
|
i.invoice_from,
|
||||||
|
i.invoice_from_zipcode,
|
||||||
|
i.invoice_from_city,
|
||||||
|
i.invoice_from_country,
|
||||||
|
i.invoice_from_tax_id,
|
||||||
|
i.invoice_from_vat_id,
|
||||||
|
i.invoice_to_company,
|
||||||
|
i.invoice_to_name,
|
||||||
|
i.invoice_to_street or i.invoice_to,
|
||||||
|
i.invoice_to_zipcode,
|
||||||
|
i.invoice_to_city,
|
||||||
|
i.invoice_to_country,
|
||||||
|
i.invoice_to_vat_id,
|
||||||
|
i.invoice_to_beneficiary,
|
||||||
|
i.internal_reference,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_filename(self):
|
||||||
|
return '{}_invoices'.format(self.event.slug)
|
||||||
|
|
||||||
|
|
||||||
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
||||||
@@ -282,3 +640,8 @@ def register_paymentlist_exporter(sender, **kwargs):
|
|||||||
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
|
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
|
||||||
def register_quotalist_exporter(sender, **kwargs):
|
def register_quotalist_exporter(sender, **kwargs):
|
||||||
return QuotaListExporter
|
return QuotaListExporter
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
|
||||||
|
def register_invoicedata_exporter(sender, **kwargs):
|
||||||
|
return InvoiceDataExporter
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class RegistrationForm(forms.Form):
|
|||||||
|
|
||||||
def clean_email(self):
|
def clean_email(self):
|
||||||
email = self.cleaned_data['email']
|
email = self.cleaned_data['email']
|
||||||
if User.objects.filter(email=email).exists():
|
if User.objects.filter(email__iexact=email).exists():
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['duplicate_email'],
|
self.error_messages['duplicate_email'],
|
||||||
code='duplicate_email'
|
code='duplicate_email'
|
||||||
|
|||||||
@@ -275,10 +275,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = InvoiceAddress
|
model = InvoiceAddress
|
||||||
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||||
'internal_reference')
|
'internal_reference', 'beneficiary')
|
||||||
widgets = {
|
widgets = {
|
||||||
'is_business': BusinessBooleanRadio,
|
'is_business': BusinessBooleanRadio,
|
||||||
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
|
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
|
||||||
|
'beneficiary': forms.Textarea(attrs={'rows': 3}),
|
||||||
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||||
'internal_reference': forms.TextInput,
|
'internal_reference': forms.TextInput,
|
||||||
@@ -291,17 +292,18 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
self.event = event = kwargs.pop('event')
|
self.event = event = kwargs.pop('event')
|
||||||
self.request = kwargs.pop('request', None)
|
self.request = kwargs.pop('request', None)
|
||||||
self.validate_vat_id = kwargs.pop('validate_vat_id')
|
self.validate_vat_id = kwargs.pop('validate_vat_id')
|
||||||
|
self.all_optional = kwargs.pop('all_optional', False)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if not event.settings.invoice_address_vatid:
|
if not event.settings.invoice_address_vatid:
|
||||||
del self.fields['vat_id']
|
del self.fields['vat_id']
|
||||||
|
|
||||||
if not event.settings.invoice_address_required:
|
if not event.settings.invoice_address_required or self.all_optional:
|
||||||
for k, f in self.fields.items():
|
for k, f in self.fields.items():
|
||||||
f.required = False
|
f.required = False
|
||||||
f.widget.is_required = False
|
f.widget.is_required = False
|
||||||
if 'required' in f.widget.attrs:
|
if 'required' in f.widget.attrs:
|
||||||
del f.widget.attrs['required']
|
del f.widget.attrs['required']
|
||||||
elif event.settings.invoice_address_company_required:
|
elif event.settings.invoice_address_company_required and not self.all_optional:
|
||||||
self.initial['is_business'] = True
|
self.initial['is_business'] = True
|
||||||
|
|
||||||
self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True)
|
self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True)
|
||||||
@@ -314,16 +316,19 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.fields['name_parts'] = NamePartsFormField(
|
self.fields['name_parts'] = NamePartsFormField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
required=event.settings.invoice_name_required,
|
required=event.settings.invoice_name_required and not self.all_optional,
|
||||||
scheme=event.settings.name_scheme,
|
scheme=event.settings.name_scheme,
|
||||||
label=_('Name'),
|
label=_('Name'),
|
||||||
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
|
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
|
||||||
)
|
)
|
||||||
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required:
|
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
|
||||||
self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
||||||
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
|
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
|
||||||
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
|
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
|
||||||
|
|
||||||
|
if not event.settings.invoice_address_beneficiary:
|
||||||
|
del self.fields['beneficiary']
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
data = self.cleaned_data
|
data = self.cleaned_data
|
||||||
if not data.get('is_business'):
|
if not data.get('is_business'):
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
|
|
||||||
def clean_email(self):
|
def clean_email(self):
|
||||||
email = self.cleaned_data['email']
|
email = self.cleaned_data['email']
|
||||||
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
|
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['duplicate_identifier'],
|
self.error_messages['duplicate_identifier'],
|
||||||
code='duplicate_identifier',
|
code='duplicate_identifier',
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
|||||||
time_attrs = dict(attrs)
|
time_attrs = dict(attrs)
|
||||||
date_attrs.setdefault('class', 'form-control splitdatetimepart')
|
date_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||||
time_attrs.setdefault('class', 'form-control splitdatetimepart')
|
time_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||||
|
date_attrs.setdefault('autocomplete', 'off')
|
||||||
|
time_attrs.setdefault('autocomplete', 'off')
|
||||||
date_attrs['class'] += ' datepickerfield'
|
date_attrs['class'] += ' datepickerfield'
|
||||||
time_attrs['class'] += ' timepickerfield'
|
time_attrs['class'] += ' timepickerfield'
|
||||||
|
|
||||||
|
|||||||
+66
-28
@@ -4,6 +4,7 @@ from decimal import Decimal
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
|
import bleach
|
||||||
import vat_moss.exchange_rates
|
import vat_moss.exchange_rates
|
||||||
from django.contrib.staticfiles import finders
|
from django.contrib.staticfiles import finders
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -31,6 +32,31 @@ from pretix.base.templatetags.money import money_filter
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NumberedCanvas(Canvas):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.font_regular = kwargs.pop('font_regular')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._saved_page_states = []
|
||||||
|
|
||||||
|
def showPage(self):
|
||||||
|
self._saved_page_states.append(dict(self.__dict__))
|
||||||
|
self._startPage()
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
num_pages = len(self._saved_page_states)
|
||||||
|
for state in self._saved_page_states:
|
||||||
|
self.__dict__.update(state)
|
||||||
|
self.draw_page_number(num_pages)
|
||||||
|
Canvas.showPage(self)
|
||||||
|
Canvas.save(self)
|
||||||
|
|
||||||
|
def draw_page_number(self, page_count):
|
||||||
|
self.saveState()
|
||||||
|
self.setFont(self.font_regular, 8)
|
||||||
|
self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,))
|
||||||
|
self.restoreState()
|
||||||
|
|
||||||
|
|
||||||
class BaseInvoiceRenderer:
|
class BaseInvoiceRenderer:
|
||||||
"""
|
"""
|
||||||
This is the base class for all invoice renderers.
|
This is the base class for all invoice renderers.
|
||||||
@@ -79,6 +105,9 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
|||||||
top_margin = 20 * mm
|
top_margin = 20 * mm
|
||||||
bottom_margin = 15 * mm
|
bottom_margin = 15 * mm
|
||||||
doc_template_class = BaseDocTemplate
|
doc_template_class = BaseDocTemplate
|
||||||
|
canvas_class = Canvas
|
||||||
|
font_regular = 'OpenSans'
|
||||||
|
font_bold = 'OpenSansBd'
|
||||||
|
|
||||||
def _init(self):
|
def _init(self):
|
||||||
"""
|
"""
|
||||||
@@ -92,10 +121,10 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
|||||||
Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles.
|
Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles.
|
||||||
"""
|
"""
|
||||||
stylesheet = StyleSheet1()
|
stylesheet = StyleSheet1()
|
||||||
stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12))
|
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
|
||||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2))
|
stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2))
|
||||||
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName='OpenSansBd', fontSize=8, leading=12))
|
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
|
||||||
stylesheet.add(ParagraphStyle(name='Fineprint', fontName='OpenSans', fontSize=8, leading=10))
|
stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10))
|
||||||
return stylesheet
|
return stylesheet
|
||||||
|
|
||||||
def _register_fonts(self):
|
def _register_fonts(self):
|
||||||
@@ -171,7 +200,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
|||||||
)
|
)
|
||||||
])
|
])
|
||||||
story = self._get_story(doc)
|
story = self._get_story(doc)
|
||||||
doc.build(story)
|
doc.build(story, canvasmaker=self.canvas_class)
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
def generate(self, invoice: Invoice):
|
def generate(self, invoice: Invoice):
|
||||||
@@ -206,10 +235,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
identifier = 'classic'
|
identifier = 'classic'
|
||||||
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
|
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
|
||||||
|
|
||||||
|
def canvas_class(self, *args, **kwargs):
|
||||||
|
kwargs['font_regular'] = self.font_regular
|
||||||
|
return NumberedCanvas(*args, **kwargs)
|
||||||
|
|
||||||
def _on_other_page(self, canvas: Canvas, doc):
|
def _on_other_page(self, canvas: Canvas, doc):
|
||||||
canvas.saveState()
|
canvas.saveState()
|
||||||
canvas.setFont('OpenSans', 8)
|
canvas.setFont(self.font_regular, 8)
|
||||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
|
|
||||||
|
|
||||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||||
@@ -233,72 +265,71 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
|
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
|
||||||
|
|
||||||
canvas.saveState()
|
canvas.saveState()
|
||||||
canvas.setFont('OpenSans', 8)
|
canvas.setFont(self.font_regular, 8)
|
||||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
|
|
||||||
|
|
||||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||||
|
|
||||||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||||||
textobject.setFont('OpenSansBd', 8)
|
textobject.setFont(self.font_bold, 8)
|
||||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||||
canvas.drawText(textobject)
|
canvas.drawText(textobject)
|
||||||
|
|
||||||
self._draw_invoice_from(canvas)
|
self._draw_invoice_from(canvas)
|
||||||
|
|
||||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||||
textobject.setFont('OpenSansBd', 8)
|
textobject.setFont(self.font_bold, 8)
|
||||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||||
canvas.drawText(textobject)
|
canvas.drawText(textobject)
|
||||||
|
|
||||||
self._draw_invoice_to(canvas)
|
self._draw_invoice_to(canvas)
|
||||||
|
|
||||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||||
textobject.setFont('OpenSansBd', 8)
|
textobject.setFont(self.font_bold, 8)
|
||||||
textobject.textLine(pgettext('invoice', 'Order code').upper())
|
textobject.textLine(pgettext('invoice', 'Order code').upper())
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
textobject.setFont('OpenSans', 10)
|
textobject.setFont(self.font_regular, 10)
|
||||||
textobject.textLine(self.invoice.order.full_code)
|
textobject.textLine(self.invoice.order.full_code)
|
||||||
canvas.drawText(textobject)
|
canvas.drawText(textobject)
|
||||||
|
|
||||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||||
textobject.setFont('OpenSansBd', 8)
|
textobject.setFont(self.font_bold, 8)
|
||||||
if self.invoice.is_cancellation:
|
if self.invoice.is_cancellation:
|
||||||
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
|
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
textobject.setFont('OpenSans', 10)
|
textobject.setFont(self.font_regular, 10)
|
||||||
textobject.textLine(self.invoice.number)
|
textobject.textLine(self.invoice.number)
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
textobject.setFont('OpenSansBd', 8)
|
textobject.setFont(self.font_bold, 8)
|
||||||
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
|
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
textobject.setFont('OpenSans', 10)
|
textobject.setFont(self.font_regular, 10)
|
||||||
textobject.textLine(self.invoice.refers.number)
|
textobject.textLine(self.invoice.refers.number)
|
||||||
else:
|
else:
|
||||||
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
|
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
textobject.setFont('OpenSans', 10)
|
textobject.setFont(self.font_regular, 10)
|
||||||
textobject.textLine(self.invoice.number)
|
textobject.textLine(self.invoice.number)
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
|
|
||||||
if self.invoice.is_cancellation:
|
if self.invoice.is_cancellation:
|
||||||
textobject.setFont('OpenSansBd', 8)
|
textobject.setFont(self.font_bold, 8)
|
||||||
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
|
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
textobject.setFont('OpenSans', 10)
|
textobject.setFont(self.font_regular, 10)
|
||||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
textobject.setFont('OpenSansBd', 8)
|
textobject.setFont(self.font_bold, 8)
|
||||||
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
|
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
textobject.setFont('OpenSans', 10)
|
textobject.setFont(self.font_regular, 10)
|
||||||
textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT"))
|
textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT"))
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
else:
|
else:
|
||||||
textobject.setFont('OpenSansBd', 8)
|
textobject.setFont(self.font_bold, 8)
|
||||||
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
|
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
textobject.setFont('OpenSans', 10)
|
textobject.setFont(self.font_regular, 10)
|
||||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||||
textobject.moveCursor(0, 5)
|
textobject.moveCursor(0, 5)
|
||||||
|
|
||||||
@@ -349,7 +380,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
|
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(self.font_bold, 8)
|
||||||
textobject.textLine(pgettext('invoice', 'Event').upper())
|
textobject.textLine(pgettext('invoice', 'Event').upper())
|
||||||
canvas.drawText(textobject)
|
canvas.drawText(textobject)
|
||||||
|
|
||||||
@@ -390,6 +421,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
self.stylesheet['Normal']
|
self.stylesheet['Normal']
|
||||||
))
|
))
|
||||||
|
|
||||||
|
if self.invoice.invoice_to_beneficiary:
|
||||||
|
story.append(Paragraph(
|
||||||
|
pgettext('invoice', 'Beneficiary') + ':<br />' +
|
||||||
|
bleach.clean(self.invoice.invoice_to_beneficiary, tags=[]).replace("\n", "<br />\n"),
|
||||||
|
self.stylesheet['Normal']
|
||||||
|
))
|
||||||
|
|
||||||
if self.invoice.introductory_text:
|
if self.invoice.introductory_text:
|
||||||
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
|
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
|
||||||
story.append(Spacer(1, 10 * mm))
|
story.append(Spacer(1, 10 * mm))
|
||||||
@@ -400,8 +438,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
tstyledata = [
|
tstyledata = [
|
||||||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||||
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
|
('FONTNAME', (0, 0), (-1, 0), self.font_bold),
|
||||||
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
|
('FONTNAME', (0, -1), (-1, -1), self.font_bold),
|
||||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||||
]
|
]
|
||||||
@@ -469,7 +507,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||||
('FONTNAME', (0, 0), (-1, -1), 'OpenSans'),
|
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
|
||||||
]
|
]
|
||||||
thead = [
|
thead = [
|
||||||
pgettext('invoice', 'Tax rate'),
|
pgettext('invoice', 'Tax rate'),
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
|
||||||
|
from pretix.base.models import Order, OrderSearchIndex
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Rebuild search index"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--clean', action='store_true', dest='clean',
|
||||||
|
help="Clear search index before run.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def iter_pages(self, qs):
|
||||||
|
paginator = Paginator(qs, 500)
|
||||||
|
for index in range(paginator.num_pages):
|
||||||
|
yield paginator.get_page(index + 1)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if options.get('clean'):
|
||||||
|
OrderSearchIndex.objects.all().delete()
|
||||||
|
qs = Order.objects.select_related('event', 'event__organizer', 'invoice_address').prefetch_related('all_positions', 'payments')
|
||||||
|
for page in self.iter_pages(qs):
|
||||||
|
if options.get('clean'):
|
||||||
|
OrderSearchIndex.objects.bulk_create([o.index() for o in page])
|
||||||
|
else:
|
||||||
|
for o in page:
|
||||||
|
o.index()
|
||||||
@@ -189,6 +189,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
|
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
|
||||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"],
|
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"],
|
||||||
'font-src': ["{static}"],
|
'font-src': ["{static}"],
|
||||||
|
'media-src': ["{static}", "data:"],
|
||||||
# form-action is not only used to match on form actions, but also on URLs
|
# form-action is not only used to match on form actions, but also on URLs
|
||||||
# form-actions redirect to. In the context of e.g. payment providers or
|
# form-actions redirect to. In the context of e.g. payment providers or
|
||||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 2.1.1 on 2018-11-21 12:24
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import jsonfallback.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.base.models.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0102_auto_20181017_0024'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='sales_channels',
|
||||||
|
field=pretix.base.models.fields.MultiStringField(default=['web'], verbose_name='Sales channels'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='sales_channel',
|
||||||
|
field=models.CharField(default='web', max_length=190),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Generated by Django 2.1.1 on 2018-11-14 15:26
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.db.models.manager
|
||||||
|
import jsonfallback.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def change_refunded_to_canceled(apps, schema_editor):
|
||||||
|
Order = apps.get_model('pretixbase', 'Order')
|
||||||
|
Order.objects.filter(status='r').update(status='c')
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0103_auto_20181121_1224'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='orderposition',
|
||||||
|
managers=[
|
||||||
|
('all', django.db.models.manager.Manager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderposition',
|
||||||
|
name='canceled',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderfee',
|
||||||
|
name='fee_type',
|
||||||
|
field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('cancellation', 'Cancellation fee'), ('other', 'Other fees'), ('giftcard', 'Gift card')], max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderposition',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_positions',
|
||||||
|
to='pretixbase.Order', verbose_name='Order'),
|
||||||
|
),
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='orderfee',
|
||||||
|
managers=[
|
||||||
|
('all', django.db.models.manager.Manager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderfee',
|
||||||
|
name='canceled',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderfee',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_fees', to='pretixbase.Order', verbose_name='Order'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
change_refunded_to_canceled, migrations.RunPython.noop
|
||||||
|
)
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 2.1 on 2019-01-12 15:12
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import jsonfallback.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.base.models.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0104_auto_20181114_1526'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoiceaddress',
|
||||||
|
name='beneficiary',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Beneficiary'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoice',
|
||||||
|
name='invoice_to_beneficiary',
|
||||||
|
field=models.TextField(blank=True, null=True, verbose_name='Beneficiary'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-02-04 13:02
|
||||||
|
|
||||||
|
import django.db.migrations.operations.special
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def enable_notifications_for_everyone(apps, schema_editor):
|
||||||
|
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
|
||||||
|
User = apps.get_model('pretixbase', 'User')
|
||||||
|
create = []
|
||||||
|
for u in User.objects.iterator():
|
||||||
|
create.append(NotificationSetting(
|
||||||
|
user=u,
|
||||||
|
action_type='pretix.event.order.refund.requested',
|
||||||
|
event=None,
|
||||||
|
method='mail',
|
||||||
|
enabled=True
|
||||||
|
))
|
||||||
|
if len(create) > 200:
|
||||||
|
NotificationSetting.objects.bulk_create(create)
|
||||||
|
create.clear()
|
||||||
|
NotificationSetting.objects.bulk_create(create)
|
||||||
|
create.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
replaces = [('pretixbase', '0105_auto_20190112_1512'), ('pretixbase', '0106_auto_20190118_1527'),
|
||||||
|
('pretixbase', '0107_auto_20190129_1337')]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0104_auto_20181114_1526'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoiceaddress',
|
||||||
|
name='beneficiary',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Beneficiary'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoice',
|
||||||
|
name='invoice_to_beneficiary',
|
||||||
|
field=models.TextField(blank=True, null=True, verbose_name='Beneficiary'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=enable_notifications_for_everyone,
|
||||||
|
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='order',
|
||||||
|
name='datetime',
|
||||||
|
field=models.DateTimeField(db_index=True, verbose_name='Date'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-18 15:27
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def enable_notifications_for_everyone(apps, schema_editor):
|
||||||
|
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
|
||||||
|
User = apps.get_model('pretixbase', 'User')
|
||||||
|
create = []
|
||||||
|
for u in User.objects.iterator():
|
||||||
|
create.append(NotificationSetting(
|
||||||
|
user=u,
|
||||||
|
action_type='pretix.event.order.refund.requested',
|
||||||
|
event=None,
|
||||||
|
method='mail',
|
||||||
|
enabled=True
|
||||||
|
))
|
||||||
|
if len(create) > 200:
|
||||||
|
NotificationSetting.objects.bulk_create(create)
|
||||||
|
create.clear()
|
||||||
|
NotificationSetting.objects.bulk_create(create)
|
||||||
|
create.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0105_auto_20190112_1512'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(enable_notifications_for_everyone, migrations.RunPython.noop)
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-01-29 13:37
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import jsonfallback.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.base.models.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0106_auto_20190118_1527'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='order',
|
||||||
|
name='datetime',
|
||||||
|
field=models.DateTimeField(db_index=True, verbose_name='Date'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 2.1.5 on 2019-02-01 15:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import jsonfallback.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.base.models.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0107_auto_20190129_1337'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='generate_tickets',
|
||||||
|
field=models.NullBooleanField(verbose_name='Allow ticket download'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 2.1 on 2019-02-08 14:32
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import jsonfallback.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.base.models.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0108_auto_20190201_1527'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoiceline',
|
||||||
|
name='event_date_from',
|
||||||
|
field=models.DateTimeField(null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='invoiceline',
|
||||||
|
name='subevent',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Generated by Django 2.1 on 2019-02-14 10:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def create_extension(apps, schema_editor):
|
||||||
|
if 'postgresql' in schema_editor.connection.settings_dict['ENGINE']:
|
||||||
|
schema_editor.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||||
|
|
||||||
|
|
||||||
|
def create_trigram_index(apps, schema_editor):
|
||||||
|
if 'postgresql' in schema_editor.connection.settings_dict['ENGINE']:
|
||||||
|
schema_editor.execute("CREATE INDEX pretixbase_ordersearchindex_s ON pretixbase_ordersearchindex USING gin (search_body gin_trgm_ops);")
|
||||||
|
schema_editor.execute("CREATE INDEX pretixbase_ordersearchindex_spp ON pretixbase_ordersearchindex USING gin (payment_providers gin_trgm_ops);")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0109_auto_20190208_1432'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_extension),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OrderSearchIndex',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('search_body', models.TextField()),
|
||||||
|
('payment_providers', models.TextField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ordersearchindex',
|
||||||
|
name='event',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ordersearchindex',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order', unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ordersearchindex',
|
||||||
|
name='organizer',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Organizer'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_trigram_index)
|
||||||
|
]
|
||||||
@@ -7,6 +7,7 @@ from .event import (
|
|||||||
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
||||||
RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token,
|
RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token,
|
||||||
)
|
)
|
||||||
|
from .index import OrderSearchIndex
|
||||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||||
from .items import (
|
from .items import (
|
||||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
||||||
|
|||||||
@@ -114,7 +114,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
self.email = self.email.lower()
|
self.email = self.email.lower()
|
||||||
|
is_new = not self.pk
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
if is_new:
|
||||||
|
self.notification_settings.create(
|
||||||
|
action_type='pretix.event.order.refund.requested',
|
||||||
|
event=None,
|
||||||
|
method='mail',
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email
|
return self.email
|
||||||
|
|||||||
@@ -76,8 +76,10 @@ class LoggingMixin:
|
|||||||
kwargs['api_token'] = api_token
|
kwargs['api_token'] = api_token
|
||||||
|
|
||||||
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, **kwargs)
|
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, **kwargs)
|
||||||
if data:
|
if isinstance(data, dict):
|
||||||
logentry.data = json.dumps(data, cls=CustomJSONEncoder)
|
logentry.data = json.dumps(data, cls=CustomJSONEncoder)
|
||||||
|
elif data:
|
||||||
|
raise TypeError("You should only supply dictionaries as log data.")
|
||||||
if save:
|
if save:
|
||||||
logentry.save()
|
logentry.save()
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class CheckinList(LoggedModel):
|
|||||||
'order have not been paid. This only works with pretixdesk '
|
'order have not been paid. This only works with pretixdesk '
|
||||||
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
|
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('subevent__date_from', 'name')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def annotate_with_numbers(qs, event):
|
def annotate_with_numbers(qs, event):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ 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.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
|
||||||
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.crypto import get_random_string
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@@ -22,6 +22,7 @@ from i18nfield.fields import I18nCharField, I18nTextField
|
|||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.reldate import RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateWrapper
|
||||||
from pretix.base.validators import EventSlugBlacklistValidator
|
from pretix.base.validators import EventSlugBlacklistValidator
|
||||||
|
from pretix.helpers.database import GroupConcat
|
||||||
from pretix.helpers.daterange import daterange
|
from pretix.helpers.daterange import daterange
|
||||||
from pretix.helpers.json import safe_string
|
from pretix.helpers.json import safe_string
|
||||||
|
|
||||||
@@ -159,6 +160,79 @@ class EventMixin:
|
|||||||
|
|
||||||
return safe_string(json.dumps(eventdict))
|
return safe_string(json.dumps(eventdict))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def annotated(cls, qs, channel='web'):
|
||||||
|
from pretix.base.models import Item, ItemVariation, Quota
|
||||||
|
|
||||||
|
sq_active_item = Item.objects.filter_available(channel=channel).filter(
|
||||||
|
Q(variations__isnull=True)
|
||||||
|
& Q(quotas__pk=OuterRef('pk'))
|
||||||
|
).order_by().values_list('quotas__pk').annotate(
|
||||||
|
items=GroupConcat('pk', delimiter=',')
|
||||||
|
).values('items')
|
||||||
|
sq_active_variation = ItemVariation.objects.filter(
|
||||||
|
Q(active=True)
|
||||||
|
& Q(item__active=True)
|
||||||
|
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||||
|
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
||||||
|
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
|
||||||
|
& Q(item__sales_channels__contains=channel)
|
||||||
|
& Q(item__hide_without_voucher=False) # TODO: does this make sense?
|
||||||
|
& Q(quotas__pk=OuterRef('pk'))
|
||||||
|
).order_by().values_list('quotas__pk').annotate(
|
||||||
|
items=GroupConcat('pk', delimiter=',')
|
||||||
|
).values('items')
|
||||||
|
return qs.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
'quotas',
|
||||||
|
to_attr='active_quotas',
|
||||||
|
queryset=Quota.objects.annotate(
|
||||||
|
active_items=Subquery(sq_active_item, output_field=models.TextField()),
|
||||||
|
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
|
||||||
|
).exclude(
|
||||||
|
Q(active_items="") & Q(active_variations="")
|
||||||
|
).select_related('event', 'subevent')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def best_availability_state(self):
|
||||||
|
from .items import Quota
|
||||||
|
|
||||||
|
if not hasattr(self, 'active_quotas'):
|
||||||
|
raise TypeError("Call this only if you fetched the subevents via Event/SubEvent.annotated()")
|
||||||
|
items_available = set()
|
||||||
|
vars_available = set()
|
||||||
|
items_reserved = set()
|
||||||
|
vars_reserved = set()
|
||||||
|
items_gone = set()
|
||||||
|
vars_gone = set()
|
||||||
|
for q in self.active_quotas:
|
||||||
|
res = q.availability(allow_cache=True)
|
||||||
|
|
||||||
|
if res[0] == Quota.AVAILABILITY_OK:
|
||||||
|
if q.active_items:
|
||||||
|
items_available.update(q.active_items.split(","))
|
||||||
|
if q.active_variations:
|
||||||
|
vars_available.update(q.active_variations.split(","))
|
||||||
|
elif res[0] == Quota.AVAILABILITY_RESERVED:
|
||||||
|
if q.active_items:
|
||||||
|
items_reserved.update(q.active_items.split(","))
|
||||||
|
if q.active_variations:
|
||||||
|
vars_available.update(q.active_variations.split(","))
|
||||||
|
elif res[0] < Quota.AVAILABILITY_RESERVED:
|
||||||
|
if q.active_items:
|
||||||
|
items_gone.update(q.active_items.split(","))
|
||||||
|
if q.active_variations:
|
||||||
|
vars_gone.update(q.active_variations.split(","))
|
||||||
|
if not self.active_quotas:
|
||||||
|
return None
|
||||||
|
if items_available - items_reserved - items_gone or vars_available - vars_reserved - vars_gone:
|
||||||
|
return Quota.AVAILABILITY_OK
|
||||||
|
if items_reserved - items_gone or vars_reserved - vars_gone:
|
||||||
|
return Quota.AVAILABILITY_RESERVED
|
||||||
|
return Quota.AVAILABILITY_GONE
|
||||||
|
|
||||||
|
|
||||||
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
||||||
class Event(EventMixin, LoggedModel):
|
class Event(EventMixin, LoggedModel):
|
||||||
@@ -227,10 +301,9 @@ class Event(EventMixin, LoggedModel):
|
|||||||
verbose_name=_("Event end time"))
|
verbose_name=_("Event end time"))
|
||||||
date_admission = models.DateTimeField(null=True, blank=True,
|
date_admission = models.DateTimeField(null=True, blank=True,
|
||||||
verbose_name=_("Admission time"))
|
verbose_name=_("Admission time"))
|
||||||
is_public = models.BooleanField(default=False,
|
is_public = models.BooleanField(default=True,
|
||||||
verbose_name=_("Visible in public lists"),
|
verbose_name=_("Show in lists"),
|
||||||
help_text=_("If selected, this event may show up on the ticket system's start page "
|
help_text=_("If selected, this event will show up publicly on the list of events for your organizer account."))
|
||||||
"or an organization profile."))
|
|
||||||
presale_end = models.DateTimeField(
|
presale_end = models.DateTimeField(
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
verbose_name=_("End of presale"),
|
verbose_name=_("End of presale"),
|
||||||
@@ -282,10 +355,11 @@ class Event(EventMixin, LoggedModel):
|
|||||||
if not really:
|
if not really:
|
||||||
raise TypeError("Pass really=True as a parameter.")
|
raise TypeError("Pass really=True as a parameter.")
|
||||||
|
|
||||||
OrderPosition.objects.filter(order__event=self).delete()
|
OrderPosition.all.filter(order__event=self, addon_to__isnull=False).delete()
|
||||||
|
OrderPosition.all.filter(order__event=self).delete()
|
||||||
OrderFee.objects.filter(order__event=self).delete()
|
OrderFee.objects.filter(order__event=self).delete()
|
||||||
OrderPayment.objects.filter(order__event=self).delete()
|
|
||||||
OrderRefund.objects.filter(order__event=self).delete()
|
OrderRefund.objects.filter(order__event=self).delete()
|
||||||
|
OrderPayment.objects.filter(order__event=self).delete()
|
||||||
self.orders.all().delete()
|
self.orders.all().delete()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
@@ -469,6 +543,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
else:
|
else:
|
||||||
s.save()
|
s.save()
|
||||||
|
|
||||||
|
self.settings.flush()
|
||||||
event_copy_data.send(
|
event_copy_data.send(
|
||||||
sender=self, other=other,
|
sender=self, other=other,
|
||||||
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
|
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
|
||||||
@@ -573,8 +648,10 @@ class Event(EventMixin, LoggedModel):
|
|||||||
)
|
)
|
||||||
).order_by('date_from', 'name')
|
).order_by('date_from', 'name')
|
||||||
|
|
||||||
@property
|
def subevents_annotated(self, channel):
|
||||||
def subevent_list_subevents(self):
|
return SubEvent.annotated(self.subevents, channel)
|
||||||
|
|
||||||
|
def subevents_sorted(self, queryset):
|
||||||
ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
|
ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
|
||||||
orderfields = {
|
orderfields = {
|
||||||
'date_ascending': ('date_from', 'name'),
|
'date_ascending': ('date_from', 'name'),
|
||||||
@@ -582,7 +659,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
'name_ascending': ('name', 'date_from'),
|
'name_ascending': ('name', 'date_from'),
|
||||||
'name_descending': ('-name', 'date_from'),
|
'name_descending': ('-name', 'date_from'),
|
||||||
}[ordering]
|
}[ordering]
|
||||||
subevs = self.subevents.filter(
|
subevs = queryset.filter(
|
||||||
Q(active=True) & (
|
Q(active=True) & (
|
||||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||||
| Q(date_to__gte=now())
|
| Q(date_to__gte=now())
|
||||||
@@ -675,6 +752,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
return not self.orders.exists() and not self.invoices.exists()
|
return not self.orders.exists() and not self.invoices.exists()
|
||||||
|
|
||||||
def delete_sub_objects(self):
|
def delete_sub_objects(self):
|
||||||
|
self.cartposition_set.all().delete()
|
||||||
self.items.all().delete()
|
self.items.all().delete()
|
||||||
self.subevents.all().delete()
|
self.subevents.all().delete()
|
||||||
|
|
||||||
@@ -683,7 +761,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
|
|
||||||
plugins_active = self.get_plugins()
|
plugins_active = self.get_plugins()
|
||||||
plugins_available = {
|
plugins_available = {
|
||||||
p.module: p for p in get_all_plugins()
|
p.module: p for p in get_all_plugins(self)
|
||||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from django.core import exceptions
|
||||||
|
from django.db.models import TextField, lookups as builtin_lookups
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
DELIMITER = "\x1F"
|
||||||
|
|
||||||
|
|
||||||
|
class MultiStringField(TextField):
|
||||||
|
default_error_messages = {
|
||||||
|
'delimiter_found': _('No value can contain the delimiter character.')
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, verbose_name=None, name=None, **kwargs):
|
||||||
|
super().__init__(verbose_name, name, **kwargs)
|
||||||
|
|
||||||
|
def deconstruct(self):
|
||||||
|
name, path, args, kwargs = super().deconstruct()
|
||||||
|
return name, path, args, kwargs
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return value
|
||||||
|
elif value:
|
||||||
|
return [v for v in value.split(DELIMITER) if v]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_prep_value(self, value):
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return DELIMITER + DELIMITER.join(value) + DELIMITER
|
||||||
|
elif value is None:
|
||||||
|
return ""
|
||||||
|
raise TypeError("Invalid data type passed.")
|
||||||
|
|
||||||
|
def get_prep_lookup(self, lookup_type, value): # NOQA
|
||||||
|
raise TypeError('Lookups on multi strings are currently not supported.')
|
||||||
|
|
||||||
|
def from_db_value(self, value, expression, connection, context):
|
||||||
|
if value:
|
||||||
|
return [v for v in value.split(DELIMITER) if v]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def validate(self, value, model_instance):
|
||||||
|
super().validate(value, model_instance)
|
||||||
|
for l in value:
|
||||||
|
if DELIMITER in l:
|
||||||
|
raise exceptions.ValidationError(
|
||||||
|
self.error_messages['delimiter_found'],
|
||||||
|
code='delimiter_found',
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_lookup(self, lookup_name):
|
||||||
|
if lookup_name == 'contains':
|
||||||
|
return MultiStringContains
|
||||||
|
elif lookup_name == 'icontains':
|
||||||
|
return MultiStringIContains
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Lookup '{}' doesn't work with MultiStringField".format(lookup_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiStringContains(builtin_lookups.Contains):
|
||||||
|
def process_rhs(self, qn, connection):
|
||||||
|
sql, params = super().process_rhs(qn, connection)
|
||||||
|
params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
|
||||||
|
return sql, params
|
||||||
|
|
||||||
|
|
||||||
|
class MultiStringIContains(builtin_lookups.IContains):
|
||||||
|
def process_rhs(self, qn, connection):
|
||||||
|
sql, params = super().process_rhs(qn, connection)
|
||||||
|
params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%"
|
||||||
|
return sql, params
|
||||||
|
|
||||||
|
|
||||||
|
class MultiStringSerializer(serializers.Field):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.allow_blank = kwargs.pop('allow_blank', False)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def to_representation(self, value):
|
||||||
|
return value
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
raise ValidationError('Invalid data type.')
|
||||||
|
|
||||||
|
|
||||||
|
serializers.ModelSerializer.serializer_field_mapping[MultiStringField] = MultiStringSerializer
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
DELIMITER = "\x1F"
|
||||||
|
|
||||||
|
|
||||||
|
class OrderSearchIndex(models.Model):
|
||||||
|
order = models.ForeignKey('Order', unique=True, null=False, on_delete=models.CASCADE)
|
||||||
|
event = models.ForeignKey('Event', null=False, on_delete=models.CASCADE)
|
||||||
|
organizer = models.ForeignKey('Organizer', null=False, on_delete=models.CASCADE)
|
||||||
|
search_body = models.TextField()
|
||||||
|
payment_providers = models.TextField()
|
||||||
@@ -2,6 +2,8 @@ import string
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.db import DatabaseError, models, transaction
|
from django.db import DatabaseError, models, transaction
|
||||||
|
from django.db.models import Max
|
||||||
|
from django.db.models.functions import Cast
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
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.functional import cached_property
|
||||||
@@ -89,6 +91,7 @@ class Invoice(models.Model):
|
|||||||
invoice_to_city = models.TextField(null=True)
|
invoice_to_city = models.TextField(null=True)
|
||||||
invoice_to_country = CountryField(null=True)
|
invoice_to_country = CountryField(null=True)
|
||||||
invoice_to_vat_id = models.TextField(null=True)
|
invoice_to_vat_id = models.TextField(null=True)
|
||||||
|
invoice_to_beneficiary = models.TextField(null=True)
|
||||||
date = models.DateField(default=today)
|
date = models.DateField(default=today)
|
||||||
locale = models.CharField(max_length=50, default='en')
|
locale = models.CharField(max_length=50, default='en')
|
||||||
introductory_text = models.TextField(blank=True)
|
introductory_text = models.TextField(blank=True)
|
||||||
@@ -124,8 +127,12 @@ class Invoice(models.Model):
|
|||||||
numeric_invoices = Invoice.objects.filter(
|
numeric_invoices = Invoice.objects.filter(
|
||||||
event__organizer=self.event.organizer,
|
event__organizer=self.event.organizer,
|
||||||
prefix=self.prefix,
|
prefix=self.prefix,
|
||||||
).exclude(invoice_no__contains='-')
|
).exclude(invoice_no__contains='-').annotate(
|
||||||
return self._to_numeric_invoice_number(numeric_invoices.count() + 1)
|
numeric_number=Cast('invoice_no', models.IntegerField())
|
||||||
|
).aggregate(
|
||||||
|
max=Max('numeric_number')
|
||||||
|
)['max'] or 0
|
||||||
|
return self._to_numeric_invoice_number(numeric_invoices + 1)
|
||||||
|
|
||||||
def _get_invoice_number_from_order(self):
|
def _get_invoice_number_from_order(self):
|
||||||
return '{order}-{count}'.format(
|
return '{order}-{count}'.format(
|
||||||
@@ -183,7 +190,10 @@ class Invoice(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('organizer', 'prefix', 'invoice_no')
|
unique_together = ('organizer', 'prefix', 'invoice_no')
|
||||||
ordering = ('invoice_no',)
|
ordering = ('date', 'invoice_no',)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Invoice {} / {}>'.format(self.full_invoice_no, self.pk)
|
||||||
|
|
||||||
|
|
||||||
class InvoiceLine(models.Model):
|
class InvoiceLine(models.Model):
|
||||||
@@ -202,6 +212,10 @@ class InvoiceLine(models.Model):
|
|||||||
:type tax_rate: decimal.Decimal
|
:type tax_rate: decimal.Decimal
|
||||||
:param tax_name: The name of the applied tax rate
|
:param tax_name: The name of the applied tax rate
|
||||||
:type tax_name: str
|
:type tax_name: str
|
||||||
|
:param subevent: The subevent this line refers to
|
||||||
|
:type subevent: SubEvent
|
||||||
|
:param event_date_from: Event date of the (sub)event at the time the invoice was created
|
||||||
|
:type event_date_from: datetime
|
||||||
"""
|
"""
|
||||||
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
|
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
|
||||||
position = models.PositiveIntegerField(default=0)
|
position = models.PositiveIntegerField(default=0)
|
||||||
@@ -210,6 +224,8 @@ class InvoiceLine(models.Model):
|
|||||||
tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
||||||
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
|
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
|
||||||
tax_name = models.CharField(max_length=190)
|
tax_name = models.CharField(max_length=190)
|
||||||
|
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
|
||||||
|
event_date_from = models.DateTimeField(null=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def net_value(self):
|
def net_value(self):
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from django.utils.timezone import is_naive, make_aware, now
|
|||||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||||
from i18nfield.fields import I18nCharField, I18nTextField
|
from i18nfield.fields import I18nCharField, I18nTextField
|
||||||
|
|
||||||
|
from pretix.base.models import fields
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.models.tax import TaxedPrice
|
from pretix.base.models.tax import TaxedPrice
|
||||||
|
|
||||||
@@ -152,6 +153,30 @@ class SubEventItemVariation(models.Model):
|
|||||||
self.subevent.event.cache.clear()
|
self.subevent.event.cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class ItemQuerySet(models.QuerySet):
|
||||||
|
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||||
|
q = (
|
||||||
|
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||||
|
# in models/event.py: EventMixin.annotated()
|
||||||
|
Q(active=True)
|
||||||
|
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||||
|
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||||
|
& Q(sales_channels__contains=channel)
|
||||||
|
)
|
||||||
|
if not allow_addons:
|
||||||
|
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
||||||
|
qs = self.filter(q)
|
||||||
|
|
||||||
|
vouchq = Q(hide_without_voucher=False)
|
||||||
|
if voucher:
|
||||||
|
if voucher.item_id:
|
||||||
|
vouchq |= Q(pk=voucher.item_id)
|
||||||
|
qs = qs.filter(pk=voucher.item_id)
|
||||||
|
elif voucher.quota_id:
|
||||||
|
qs = qs.filter(quotas__in=[voucher.quota_id])
|
||||||
|
return qs.filter(vouchq)
|
||||||
|
|
||||||
|
|
||||||
class Item(LoggedModel):
|
class Item(LoggedModel):
|
||||||
"""
|
"""
|
||||||
An item is a thing which can be sold. It belongs to an event and may or may not belong to a category.
|
An item is a thing which can be sold. It belongs to an event and may or may not belong to a category.
|
||||||
@@ -195,8 +220,12 @@ class Item(LoggedModel):
|
|||||||
:type original_price: decimal.Decimal
|
:type original_price: decimal.Decimal
|
||||||
:param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator
|
:param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator
|
||||||
:type require_approval: bool
|
:type require_approval: bool
|
||||||
|
:param sales_channels: Sales channels this item is available on.
|
||||||
|
:type sales_channels: bool
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
objects = ItemQuerySet.as_manager()
|
||||||
|
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
Event,
|
Event,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@@ -258,6 +287,10 @@ class Item(LoggedModel):
|
|||||||
),
|
),
|
||||||
default=False
|
default=False
|
||||||
)
|
)
|
||||||
|
generate_tickets = models.NullBooleanField(
|
||||||
|
verbose_name=_("Generate tickets"),
|
||||||
|
blank=True, null=True,
|
||||||
|
)
|
||||||
position = models.IntegerField(
|
position = models.IntegerField(
|
||||||
default=0
|
default=0
|
||||||
)
|
)
|
||||||
@@ -298,9 +331,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 allow it, orders containing this product can be '
|
help_text=_('If this is checked, the usual cancellation settings of this event apply. If this is unchecked, '
|
||||||
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
|
'orders containing this product can not be canceled by users but only by you.')
|
||||||
'and you can cancel orders at all times, regardless of this setting')
|
|
||||||
)
|
)
|
||||||
min_per_order = models.IntegerField(
|
min_per_order = models.IntegerField(
|
||||||
verbose_name=_('Minimum amount per order'),
|
verbose_name=_('Minimum amount per order'),
|
||||||
@@ -329,6 +361,10 @@ class Item(LoggedModel):
|
|||||||
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
|
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
|
||||||
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
||||||
)
|
)
|
||||||
|
sales_channels = fields.MultiStringField(
|
||||||
|
verbose_name=_('Sales channels'),
|
||||||
|
default=['web']
|
||||||
|
)
|
||||||
# !!! Attention: If you add new fields here, also add them to the copying code in
|
# !!! Attention: If you add new fields here, also add them to the copying code in
|
||||||
# pretix/control/forms/item.py if applicable.
|
# pretix/control/forms/item.py if applicable.
|
||||||
|
|
||||||
@@ -357,17 +393,21 @@ class Item(LoggedModel):
|
|||||||
rate=Decimal('0.00'), name='')
|
rate=Decimal('0.00'), name='')
|
||||||
return self.tax_rule.tax(price, base_price_is=base_price_is)
|
return self.tax_rule.tax(price, base_price_is=base_price_is)
|
||||||
|
|
||||||
|
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
||||||
|
now_dt = now_dt or now()
|
||||||
|
if self.available_from and self.available_from > now_dt:
|
||||||
|
return False
|
||||||
|
if self.available_until and self.available_until < now_dt:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def is_available(self, now_dt: datetime=None) -> bool:
|
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
|
||||||
and its ``available_from`` and ``available_until`` fields
|
and its ``available_from`` and ``available_until`` fields
|
||||||
"""
|
"""
|
||||||
now_dt = now_dt or now()
|
now_dt = now_dt or now()
|
||||||
if not self.active:
|
if not self.active or not self.is_available_by_time(now_dt):
|
||||||
return False
|
|
||||||
if self.available_from and self.available_from > now_dt:
|
|
||||||
return False
|
|
||||||
if self.available_until and self.available_until < now_dt:
|
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -405,7 +445,7 @@ class Item(LoggedModel):
|
|||||||
def allow_delete(self):
|
def allow_delete(self):
|
||||||
from pretix.base.models.orders import OrderPosition
|
from pretix.base.models.orders import OrderPosition
|
||||||
|
|
||||||
return not OrderPosition.objects.filter(item=self).exists()
|
return not OrderPosition.all.filter(item=self).exists()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def has_variations(self):
|
def has_variations(self):
|
||||||
@@ -750,7 +790,7 @@ class Question(LoggedModel):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _clean_identifier(event, code, instance=None):
|
def _clean_identifier(event, code, instance=None):
|
||||||
qs = Question.objects.filter(event=event, identifier=code)
|
qs = Question.objects.filter(event=event, identifier__iexact=code)
|
||||||
if instance:
|
if instance:
|
||||||
qs = qs.exclude(pk=instance.pk)
|
qs = qs.exclude(pk=instance.pk)
|
||||||
if qs.exists():
|
if qs.exists():
|
||||||
@@ -919,6 +959,16 @@ class Quota(LoggedModel):
|
|||||||
:type size: int
|
:type size: int
|
||||||
:param items: The set of :py:class:`Item` objects this quota applies to
|
:param items: The set of :py:class:`Item` objects this quota applies to
|
||||||
:param variations: The set of :py:class:`ItemVariation` objects this quota applies to
|
:param variations: The set of :py:class:`ItemVariation` objects this quota applies to
|
||||||
|
|
||||||
|
This model keeps a cache of the quota availability that is used in places where up-to-date
|
||||||
|
data is not important. This cache might be out of date even though a more recent quota was
|
||||||
|
calculated. This is intentional to keep database writes low. Currently, the cached values
|
||||||
|
are written whenever the quota is being calculated throughout the system and the cache is
|
||||||
|
at least 120 seconds old or if the new value is qualitatively "better" than the cached one
|
||||||
|
(i.e. more free quota).
|
||||||
|
|
||||||
|
There's also a cronjob that refreshes the cache of every quota if there is any log entry in
|
||||||
|
the event that is newer than the quota's cached time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
AVAILABILITY_GONE = 0
|
AVAILABILITY_GONE = 0
|
||||||
@@ -1001,6 +1051,15 @@ class Quota(LoggedModel):
|
|||||||
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.
|
||||||
|
|
||||||
|
:param count_waitinglist: Whether or not take waiting list reservations into account. Defaults
|
||||||
|
to ``True``.
|
||||||
|
:param _cache: A dictionary mapping quota IDs to availabilities. If this quota is already
|
||||||
|
contained in that dictionary, this value will be used. Otherwise, the dict
|
||||||
|
will be populated accordingly.
|
||||||
|
:param allow_cache: Allow for values to be returned from the longer-term cache, see also
|
||||||
|
the documentation of this model class. Only works if ``count_waitinglist`` is
|
||||||
|
set to ``True``.
|
||||||
|
|
||||||
: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.
|
||||||
"""
|
"""
|
||||||
@@ -1016,7 +1075,10 @@ class Quota(LoggedModel):
|
|||||||
res = self._availability(now_dt, count_waitinglist)
|
res = self._availability(now_dt, count_waitinglist)
|
||||||
|
|
||||||
self.event.cache.delete('item_quota_cache')
|
self.event.cache.delete('item_quota_cache')
|
||||||
if count_waitinglist and not self.cache_is_hot(now_dt):
|
rewrite_cache = count_waitinglist and (
|
||||||
|
not self.cache_is_hot(now_dt) or res[0] > self.cached_availability_state
|
||||||
|
)
|
||||||
|
if rewrite_cache:
|
||||||
self.cached_availability_state = res[0]
|
self.cached_availability_state = res[0]
|
||||||
self.cached_availability_number = res[1]
|
self.cached_availability_number = res[1]
|
||||||
self.cached_availability_time = now_dt
|
self.cached_availability_time = now_dt
|
||||||
@@ -1053,16 +1115,16 @@ class Quota(LoggedModel):
|
|||||||
|
|
||||||
size_left -= self.count_blocking_vouchers(now_dt)
|
size_left -= self.count_blocking_vouchers(now_dt)
|
||||||
if size_left <= 0:
|
if size_left <= 0:
|
||||||
return Quota.AVAILABILITY_RESERVED, 0
|
return Quota.AVAILABILITY_ORDERED, 0
|
||||||
|
|
||||||
size_left -= self.count_in_cart(now_dt)
|
|
||||||
if size_left <= 0:
|
|
||||||
return Quota.AVAILABILITY_RESERVED, 0
|
|
||||||
|
|
||||||
if count_waitinglist:
|
if count_waitinglist:
|
||||||
size_left -= self.count_waiting_list_pending()
|
size_left -= self.count_waiting_list_pending()
|
||||||
if size_left <= 0:
|
if size_left <= 0:
|
||||||
return Quota.AVAILABILITY_RESERVED, 0
|
return Quota.AVAILABILITY_ORDERED, 0
|
||||||
|
|
||||||
|
size_left -= self.count_in_cart(now_dt)
|
||||||
|
if size_left <= 0:
|
||||||
|
return Quota.AVAILABILITY_RESERVED, 0
|
||||||
|
|
||||||
return Quota.AVAILABILITY_OK, size_left
|
return Quota.AVAILABILITY_OK, size_left
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from django_countries.fields import CountryField
|
|||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
from jsonfallback.fields import FallbackJSONField
|
from jsonfallback.fields import FallbackJSONField
|
||||||
|
|
||||||
|
from pretix.base.decimal import round_decimal
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import User
|
from pretix.base.models import User
|
||||||
from pretix.base.reldate import RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateWrapper
|
||||||
@@ -70,7 +71,6 @@ class Order(LockModel, LoggedModel):
|
|||||||
* ``STATUS_PAID``
|
* ``STATUS_PAID``
|
||||||
* ``STATUS_EXPIRED``
|
* ``STATUS_EXPIRED``
|
||||||
* ``STATUS_CANCELED``
|
* ``STATUS_CANCELED``
|
||||||
* ``STATUS_REFUNDED``
|
|
||||||
|
|
||||||
:param event: The event this order belongs to
|
:param event: The event this order belongs to
|
||||||
:type event: Event
|
:type event: Event
|
||||||
@@ -94,19 +94,20 @@ class Order(LockModel, LoggedModel):
|
|||||||
:type require_approval: bool
|
:type require_approval: bool
|
||||||
:param meta_info: Additional meta information on the order, JSON-encoded.
|
:param meta_info: Additional meta information on the order, JSON-encoded.
|
||||||
:type meta_info: str
|
:type meta_info: str
|
||||||
|
:param sales_channel: Identifier of the sales channel this order was created through.
|
||||||
|
:type sales_channel: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
STATUS_PENDING = "n"
|
STATUS_PENDING = "n"
|
||||||
STATUS_PAID = "p"
|
STATUS_PAID = "p"
|
||||||
STATUS_EXPIRED = "e"
|
STATUS_EXPIRED = "e"
|
||||||
STATUS_CANCELED = "c"
|
STATUS_CANCELED = "c"
|
||||||
STATUS_REFUNDED = "r"
|
STATUS_REFUNDED = "c" # deprecated
|
||||||
STATUS_CHOICE = (
|
STATUS_CHOICE = (
|
||||||
(STATUS_PENDING, _("pending")),
|
(STATUS_PENDING, _("pending")),
|
||||||
(STATUS_PAID, _("paid")),
|
(STATUS_PAID, _("paid")),
|
||||||
(STATUS_EXPIRED, _("expired")),
|
(STATUS_EXPIRED, _("expired")),
|
||||||
(STATUS_CANCELED, _("canceled")),
|
(STATUS_CANCELED, _("canceled")),
|
||||||
(STATUS_REFUNDED, _("refunded"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
code = models.CharField(
|
code = models.CharField(
|
||||||
@@ -136,7 +137,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
)
|
)
|
||||||
secret = models.CharField(max_length=32, default=generate_secret)
|
secret = models.CharField(max_length=32, default=generate_secret)
|
||||||
datetime = models.DateTimeField(
|
datetime = models.DateTimeField(
|
||||||
verbose_name=_("Date")
|
verbose_name=_("Date"), db_index=True
|
||||||
)
|
)
|
||||||
expires = models.DateTimeField(
|
expires = models.DateTimeField(
|
||||||
verbose_name=_("Expiration date")
|
verbose_name=_("Expiration date")
|
||||||
@@ -174,6 +175,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
require_approval = models.BooleanField(
|
require_approval = models.BooleanField(
|
||||||
default=False
|
default=False
|
||||||
)
|
)
|
||||||
|
sales_channel = models.CharField(max_length=190, default="web")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Order")
|
verbose_name = _("Order")
|
||||||
@@ -183,6 +185,28 @@ class Order(LockModel, LoggedModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.full_code
|
return self.full_code
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fees(self):
|
||||||
|
"""
|
||||||
|
Related manager for all non-canceled fees. Use ``all_fees`` instead if you want
|
||||||
|
canceled positions as well.
|
||||||
|
"""
|
||||||
|
return self.all_fees(manager='objects')
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def count_positions(self):
|
||||||
|
if hasattr(self, 'pcnt'):
|
||||||
|
return self.pcnt or 0
|
||||||
|
return self.positions.count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def positions(self):
|
||||||
|
"""
|
||||||
|
Related manager for all non-canceled positions. Use ``all_positions`` instead if you want
|
||||||
|
canceled positions as well.
|
||||||
|
"""
|
||||||
|
return self.all_positions(manager='objects')
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def meta_info_data(self):
|
def meta_info_data(self):
|
||||||
try:
|
try:
|
||||||
@@ -204,8 +228,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
@property
|
@property
|
||||||
def pending_sum(self):
|
def pending_sum(self):
|
||||||
total = self.total
|
total = self.total
|
||||||
if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
|
if self.status == Order.STATUS_CANCELED:
|
||||||
total = 0
|
total = Decimal('0.00')
|
||||||
payment_sum = self.payments.filter(
|
payment_sum = self.payments.filter(
|
||||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
||||||
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
@@ -216,7 +240,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
return total - payment_sum + refund_sum
|
return total - payment_sum + refund_sum
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def annotate_overpayments(cls, qs):
|
def annotate_overpayments(cls, qs, results=True, refunds=True, sums=False):
|
||||||
payment_sum = OrderPayment.objects.filter(
|
payment_sum = OrderPayment.objects.filter(
|
||||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||||
order=OuterRef('pk')
|
order=OuterRef('pk')
|
||||||
@@ -234,38 +258,47 @@ class Order(LockModel, LoggedModel):
|
|||||||
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
|
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
|
||||||
order=OuterRef('pk')
|
order=OuterRef('pk')
|
||||||
)
|
)
|
||||||
|
payment_sum_sq = Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
|
||||||
|
refund_sum_sq = Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
|
||||||
|
if sums:
|
||||||
|
qs = qs.annotate(
|
||||||
|
payment_sum=payment_sum_sq,
|
||||||
|
refund_sum=refund_sum_sq,
|
||||||
|
)
|
||||||
|
|
||||||
qs = qs.annotate(
|
qs = qs.annotate(
|
||||||
payment_sum=Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
|
pending_sum_t=F('total') - Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
||||||
refund_sum=Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
|
pending_sum_rc=-1 * Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
||||||
has_external_refund=Exists(external_refund),
|
|
||||||
has_pending_refund=Exists(pending_refund),
|
|
||||||
).annotate(
|
|
||||||
pending_sum_t=F('total') - Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
|
|
||||||
pending_sum_rc=-1 * Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
|
|
||||||
).annotate(
|
|
||||||
is_overpaid=Case(
|
|
||||||
When(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0),
|
|
||||||
then=Value('1')),
|
|
||||||
When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0),
|
|
||||||
then=Value('1')),
|
|
||||||
default=Value('0'),
|
|
||||||
output_field=models.IntegerField()
|
|
||||||
),
|
|
||||||
is_pending_with_full_payment=Case(
|
|
||||||
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
|
||||||
& Q(require_approval=False),
|
|
||||||
then=Value('1')),
|
|
||||||
default=Value('0'),
|
|
||||||
output_field=models.IntegerField()
|
|
||||||
),
|
|
||||||
is_underpaid=Case(
|
|
||||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=0),
|
|
||||||
then=Value('1')),
|
|
||||||
default=Value('0'),
|
|
||||||
output_field=models.IntegerField()
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if refunds:
|
||||||
|
qs = qs.annotate(
|
||||||
|
has_external_refund=Exists(external_refund),
|
||||||
|
has_pending_refund=Exists(pending_refund),
|
||||||
|
)
|
||||||
|
if results:
|
||||||
|
qs = qs.annotate(
|
||||||
|
is_overpaid=Case(
|
||||||
|
When(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=-1e-8),
|
||||||
|
then=Value('1')),
|
||||||
|
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=-1e-8),
|
||||||
|
then=Value('1')),
|
||||||
|
default=Value('0'),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
),
|
||||||
|
is_pending_with_full_payment=Case(
|
||||||
|
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=1e-8)
|
||||||
|
& Q(require_approval=False),
|
||||||
|
then=Value('1')),
|
||||||
|
default=Value('0'),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
),
|
||||||
|
is_underpaid=Case(
|
||||||
|
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
|
||||||
|
then=Value('1')),
|
||||||
|
default=Value('0'),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
)
|
||||||
|
)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -333,10 +366,112 @@ class Order(LockModel, LoggedModel):
|
|||||||
|
|
||||||
def cancel_allowed(self):
|
def cancel_allowed(self):
|
||||||
return (
|
return (
|
||||||
self.status == Order.STATUS_PENDING
|
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.count_positions
|
||||||
or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00'))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def user_cancel_deadline(self):
|
||||||
|
if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'):
|
||||||
|
until = self.event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper)
|
||||||
|
else:
|
||||||
|
until = self.event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper)
|
||||||
|
if until:
|
||||||
|
if self.event.has_subevents:
|
||||||
|
return min([
|
||||||
|
until.datetime(se)
|
||||||
|
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
return until.datetime(self.event)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def user_cancel_fee(self):
|
||||||
|
fee = Decimal('0.00')
|
||||||
|
if self.event.settings.cancel_allow_user_paid_keep:
|
||||||
|
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||||
|
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||||
|
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total
|
||||||
|
if self.event.settings.cancel_allow_user_paid_keep_fees:
|
||||||
|
fee += self.fees.filter(
|
||||||
|
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE)
|
||||||
|
).aggregate(
|
||||||
|
s=Sum('value')
|
||||||
|
)['s'] or 0
|
||||||
|
return round_decimal(fee, self.event.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_cancel_allowed(self) -> bool:
|
||||||
|
"""
|
||||||
|
Returns whether or not this order can be canceled by the user.
|
||||||
|
"""
|
||||||
|
from .checkin import Checkin
|
||||||
|
|
||||||
|
positions = list(
|
||||||
|
self.positions.all().annotate(
|
||||||
|
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
|
||||||
|
).select_related('item')
|
||||||
|
)
|
||||||
|
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
|
||||||
|
if not cancelable or not positions:
|
||||||
|
return False
|
||||||
|
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||||
|
return False
|
||||||
|
if self.status == Order.STATUS_PENDING:
|
||||||
|
return self.event.settings.cancel_allow_user
|
||||||
|
elif self.status == Order.STATUS_PAID:
|
||||||
|
if self.total == Decimal('0.00'):
|
||||||
|
return self.event.settings.cancel_allow_user
|
||||||
|
return self.event.settings.cancel_allow_user_paid
|
||||||
|
return False
|
||||||
|
|
||||||
|
def propose_auto_refunds(self, amount: Decimal, payments: list=None):
|
||||||
|
# Algorithm to choose which payments are to be refunded to create the least hassle
|
||||||
|
payments = payments or self.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED)
|
||||||
|
for p in payments:
|
||||||
|
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
|
||||||
|
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
|
||||||
|
p.propose_refund = Decimal('0.00')
|
||||||
|
p.available_amount = p.amount - p.refunded_amount
|
||||||
|
|
||||||
|
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
|
||||||
|
to_refund = amount
|
||||||
|
proposals = {}
|
||||||
|
|
||||||
|
while to_refund and unused_payments:
|
||||||
|
bigger = sorted([
|
||||||
|
p for p in unused_payments
|
||||||
|
if p.available_amount > to_refund
|
||||||
|
and p.partial_refund_possible
|
||||||
|
], key=lambda p: p.available_amount)
|
||||||
|
same = [
|
||||||
|
p for p in unused_payments
|
||||||
|
if p.available_amount == to_refund
|
||||||
|
and (p.full_refund_possible or p.partial_refund_possible)
|
||||||
|
]
|
||||||
|
smaller = sorted([
|
||||||
|
p for p in unused_payments
|
||||||
|
if p.available_amount < to_refund
|
||||||
|
and (p.full_refund_possible or p.partial_refund_possible)
|
||||||
|
], key=lambda p: p.available_amount, reverse=True)
|
||||||
|
if same:
|
||||||
|
payment = same[0]
|
||||||
|
proposals[payment] = payment.available_amount
|
||||||
|
to_refund -= payment.available_amount
|
||||||
|
unused_payments.remove(payment)
|
||||||
|
elif bigger:
|
||||||
|
payment = bigger[0]
|
||||||
|
proposals[payment] = to_refund
|
||||||
|
to_refund -= to_refund
|
||||||
|
unused_payments.remove(payment)
|
||||||
|
elif smaller:
|
||||||
|
payment = smaller[0]
|
||||||
|
proposals[payment] = payment.available_amount
|
||||||
|
to_refund -= payment.available_amount
|
||||||
|
unused_payments.remove(payment)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return proposals
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize_code(code):
|
def normalize_code(code):
|
||||||
tr = str.maketrans({
|
tr = str.maketrans({
|
||||||
@@ -389,15 +524,6 @@ class Order(LockModel, LoggedModel):
|
|||||||
|
|
||||||
return False # nothing there to modify
|
return False # nothing there to modify
|
||||||
|
|
||||||
@property
|
|
||||||
def can_user_cancel(self) -> bool:
|
|
||||||
"""
|
|
||||||
Returns whether or not this order can be canceled by the user.
|
|
||||||
"""
|
|
||||||
positions = self.positions.all().select_related('item')
|
|
||||||
cancelable = all([op.item.allow_cancel for op in positions])
|
|
||||||
return self.cancel_allowed() and self.event.settings.cancel_allow_user and cancelable
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_expired_by_time(self):
|
def is_expired_by_time(self):
|
||||||
return (
|
return (
|
||||||
@@ -555,10 +681,57 @@ class Order(LockModel, LoggedModel):
|
|||||||
'subject': subject,
|
'subject': subject,
|
||||||
'message': email_content,
|
'message': email_content,
|
||||||
'recipient': recipient,
|
'recipient': recipient,
|
||||||
'invoices': [i.pk for i in invoices] if invoices else []
|
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||||
|
'attach_tickets': attach_tickets,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def positions_with_tickets(self):
|
||||||
|
for op in self.positions.all():
|
||||||
|
if not op.generate_ticket:
|
||||||
|
continue
|
||||||
|
yield op
|
||||||
|
|
||||||
|
def index(self, save=True):
|
||||||
|
from .index import OrderSearchIndex
|
||||||
|
indexed_strings = [
|
||||||
|
self.code,
|
||||||
|
self.full_code,
|
||||||
|
self.email,
|
||||||
|
self.comment,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
indexed_strings.append(self.invoice_address.name_cached)
|
||||||
|
indexed_strings.append(self.invoice_address.company)
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
pass
|
||||||
|
for p in self.all_positions.all():
|
||||||
|
indexed_strings.append(p.attendee_name_cached)
|
||||||
|
indexed_strings.append(p.attendee_email)
|
||||||
|
indexed_strings.append(p.secret)
|
||||||
|
pprovs = set()
|
||||||
|
for p in self.payments.all():
|
||||||
|
pprovs.add(p.provider)
|
||||||
|
if save:
|
||||||
|
return OrderSearchIndex.objects.update_or_create(
|
||||||
|
order=self,
|
||||||
|
defaults={
|
||||||
|
'event': self.event,
|
||||||
|
'organizer': self.event.organizer,
|
||||||
|
'search_body': '\x1E'.join([str(v) for v in indexed_strings if v]),
|
||||||
|
'payment_providers': '\x1E' + '\x1E'.join([str(v) for v in pprovs if v]) + '\x1E',
|
||||||
|
}
|
||||||
|
)[0]
|
||||||
|
else:
|
||||||
|
return OrderSearchIndex(
|
||||||
|
order=self,
|
||||||
|
event=self.event,
|
||||||
|
organizer=self.event.organizer,
|
||||||
|
search_body='\x1E'.join([str(v) for v in indexed_strings if v]),
|
||||||
|
payment_providers='\x1E' + '\x1E'.join([str(v) for v in pprovs if v]) + '\x1E',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def answerfile_name(instance, filename: str) -> str:
|
def answerfile_name(instance, filename: str) -> str:
|
||||||
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
||||||
@@ -715,7 +888,7 @@ class AbstractPosition(models.Model):
|
|||||||
subevent = models.ForeignKey(
|
subevent = models.ForeignKey(
|
||||||
SubEvent,
|
SubEvent,
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.PROTECT,
|
||||||
verbose_name=pgettext_lazy("subevent", "Date"),
|
verbose_name=pgettext_lazy("subevent", "Date"),
|
||||||
)
|
)
|
||||||
item = models.ForeignKey(
|
item = models.ForeignKey(
|
||||||
@@ -748,10 +921,10 @@ class AbstractPosition(models.Model):
|
|||||||
help_text=_("Empty, if this product is not an admission ticket")
|
help_text=_("Empty, if this product is not an admission ticket")
|
||||||
)
|
)
|
||||||
voucher = models.ForeignKey(
|
voucher = models.ForeignKey(
|
||||||
'Voucher', null=True, blank=True, on_delete=models.CASCADE
|
'Voucher', null=True, blank=True, on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
addon_to = models.ForeignKey(
|
addon_to = models.ForeignKey(
|
||||||
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
|
'self', null=True, blank=True, on_delete=models.PROTECT, related_name='addons'
|
||||||
)
|
)
|
||||||
meta_info = models.TextField(
|
meta_info = models.TextField(
|
||||||
verbose_name=_("Meta information"),
|
verbose_name=_("Meta information"),
|
||||||
@@ -964,9 +1137,23 @@ class OrderPayment(models.Model):
|
|||||||
from pretix.base.services.mail import SendMailException
|
from pretix.base.services.mail import SendMailException
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
|
|
||||||
self.state = self.PAYMENT_STATE_CONFIRMED
|
with transaction.atomic():
|
||||||
self.payment_date = now()
|
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||||
self.save()
|
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
|
||||||
|
# Race condition detected, this payment is already confirmed
|
||||||
|
return
|
||||||
|
|
||||||
|
locked_instance.state = self.PAYMENT_STATE_CONFIRMED
|
||||||
|
locked_instance.payment_date = now()
|
||||||
|
locked_instance.info = self.info # required for backwards compatibility
|
||||||
|
locked_instance.save(update_fields=['state', 'payment_date', 'info'])
|
||||||
|
|
||||||
|
# Do a cheap manual "refresh from db" on non-complex fields
|
||||||
|
for field in self._meta.concrete_fields:
|
||||||
|
if not field.is_relation:
|
||||||
|
setattr(self, field.attname, getattr(locked_instance, field.attname))
|
||||||
|
|
||||||
|
self.refresh_from_db()
|
||||||
|
|
||||||
self.order.log_action('pretix.event.order.payment.confirmed', {
|
self.order.log_action('pretix.event.order.payment.confirmed', {
|
||||||
'local_id': self.local_id,
|
'local_id': self.local_id,
|
||||||
@@ -1250,11 +1437,19 @@ class OrderRefund(models.Model):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ActivePositionManager(models.Manager):
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(canceled=False)
|
||||||
|
|
||||||
|
|
||||||
class OrderFee(models.Model):
|
class OrderFee(models.Model):
|
||||||
"""
|
"""
|
||||||
An OrderFee object represents a fee that is added to the order total independently of
|
An OrderFee object represents a fee that is added to the order total independently of
|
||||||
the actual positions. This might for example be a payment or a shipping fee.
|
the actual positions. This might for example be a payment or a shipping fee.
|
||||||
|
|
||||||
|
The default ``OrderFee.objects`` manager only contains fees that are not ``canceled``. If
|
||||||
|
you ant all objects, you need to use ``OrderFee.all`` instead.
|
||||||
|
|
||||||
:param value: Gross price of this fee
|
:param value: Gross price of this fee
|
||||||
:type value: Decimal
|
:type value: Decimal
|
||||||
:param order: Order this fee is charged with
|
:param order: Order this fee is charged with
|
||||||
@@ -1271,16 +1466,20 @@ class OrderFee(models.Model):
|
|||||||
:type tax_rule: TaxRule
|
:type tax_rule: TaxRule
|
||||||
:param tax_value: The tax amount included in the price
|
:param tax_value: The tax amount included in the price
|
||||||
:type tax_value: Decimal
|
:type tax_value: Decimal
|
||||||
|
:param canceled: True, if this position is canceled and should no longer be regarded
|
||||||
|
:type canceled: bool
|
||||||
"""
|
"""
|
||||||
FEE_TYPE_PAYMENT = "payment"
|
FEE_TYPE_PAYMENT = "payment"
|
||||||
FEE_TYPE_SHIPPING = "shipping"
|
FEE_TYPE_SHIPPING = "shipping"
|
||||||
FEE_TYPE_SERVICE = "service"
|
FEE_TYPE_SERVICE = "service"
|
||||||
|
FEE_TYPE_CANCELLATION = "cancellation"
|
||||||
FEE_TYPE_OTHER = "other"
|
FEE_TYPE_OTHER = "other"
|
||||||
FEE_TYPE_GIFTCARD = "giftcard"
|
FEE_TYPE_GIFTCARD = "giftcard"
|
||||||
FEE_TYPES = (
|
FEE_TYPES = (
|
||||||
(FEE_TYPE_PAYMENT, _("Payment fee")),
|
(FEE_TYPE_PAYMENT, _("Payment fee")),
|
||||||
(FEE_TYPE_SHIPPING, _("Shipping fee")),
|
(FEE_TYPE_SHIPPING, _("Shipping fee")),
|
||||||
(FEE_TYPE_SERVICE, _("Service fee")),
|
(FEE_TYPE_SERVICE, _("Service fee")),
|
||||||
|
(FEE_TYPE_CANCELLATION, _("Cancellation fee")),
|
||||||
(FEE_TYPE_OTHER, _("Other fees")),
|
(FEE_TYPE_OTHER, _("Other fees")),
|
||||||
(FEE_TYPE_GIFTCARD, _("Gift card")),
|
(FEE_TYPE_GIFTCARD, _("Gift card")),
|
||||||
)
|
)
|
||||||
@@ -1292,7 +1491,7 @@ class OrderFee(models.Model):
|
|||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
Order,
|
Order,
|
||||||
verbose_name=_("Order"),
|
verbose_name=_("Order"),
|
||||||
related_name='fees',
|
related_name='all_fees',
|
||||||
on_delete=models.PROTECT
|
on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
fee_type = models.CharField(
|
fee_type = models.CharField(
|
||||||
@@ -1313,6 +1512,10 @@ class OrderFee(models.Model):
|
|||||||
max_digits=10, decimal_places=2,
|
max_digits=10, decimal_places=2,
|
||||||
verbose_name=_('Tax value')
|
verbose_name=_('Tax value')
|
||||||
)
|
)
|
||||||
|
canceled = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
all = models.Manager()
|
||||||
|
objects = ActivePositionManager()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def net_value(self):
|
def net_value(self):
|
||||||
@@ -1367,6 +1570,9 @@ class OrderPosition(AbstractPosition):
|
|||||||
of a specified type (or variation). This has all properties of
|
of a specified type (or variation). This has all properties of
|
||||||
AbstractPosition.
|
AbstractPosition.
|
||||||
|
|
||||||
|
The default ``OrderPosition.objects`` manager only contains fees that are not ``canceled``. If
|
||||||
|
you ant all objects, you need to use ``OrderPosition.all`` instead.
|
||||||
|
|
||||||
: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
|
||||||
:param positionid: A local ID of this position, counted for each order individually
|
:param positionid: A local ID of this position, counted for each order individually
|
||||||
@@ -1379,6 +1585,8 @@ class OrderPosition(AbstractPosition):
|
|||||||
:type tax_value: Decimal
|
:type tax_value: Decimal
|
||||||
:param secret: The secret used for ticket QR codes
|
:param secret: The secret used for ticket QR codes
|
||||||
:type secret: str
|
:type secret: str
|
||||||
|
:param canceled: True, if this position is canceled and should no longer be regarded
|
||||||
|
:type canceled: bool
|
||||||
:param pseudonymization_id: The QR code content for lead scanning
|
:param pseudonymization_id: The QR code content for lead scanning
|
||||||
:type pseudonymization_id: str
|
:type pseudonymization_id: str
|
||||||
"""
|
"""
|
||||||
@@ -1386,7 +1594,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
Order,
|
Order,
|
||||||
verbose_name=_("Order"),
|
verbose_name=_("Order"),
|
||||||
related_name='positions',
|
related_name='all_positions',
|
||||||
on_delete=models.PROTECT
|
on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
tax_rate = models.DecimalField(
|
tax_rate = models.DecimalField(
|
||||||
@@ -1408,6 +1616,10 @@ class OrderPosition(AbstractPosition):
|
|||||||
unique=True,
|
unique=True,
|
||||||
db_index=True
|
db_index=True
|
||||||
)
|
)
|
||||||
|
canceled = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
all = models.Manager()
|
||||||
|
objects = ActivePositionManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Order position")
|
verbose_name = _("Order position")
|
||||||
@@ -1418,6 +1630,15 @@ class OrderPosition(AbstractPosition):
|
|||||||
def sort_key(self):
|
def sort_key(self):
|
||||||
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
|
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generate_ticket(self):
|
||||||
|
if self.item.generate_tickets is not None:
|
||||||
|
return self.item.generate_tickets
|
||||||
|
return (
|
||||||
|
(self.order.event.settings.ticket_download_addons or not self.addon_to_id) and
|
||||||
|
(self.event.settings.ticket_download_nonadm or self.item.admission)
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def transform_cart_positions(cls, cp: List, order) -> list:
|
def transform_cart_positions(cls, cp: List, order) -> list:
|
||||||
from . import Voucher
|
from . import Voucher
|
||||||
@@ -1449,6 +1670,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
# Delete afterwards. Deleting in between might cause deletion of things related to add-ons
|
# Delete afterwards. Deleting in between might cause deletion of things related to add-ons
|
||||||
# due to the deletion cascade.
|
# due to the deletion cascade.
|
||||||
for cartpos in cp:
|
for cartpos in cp:
|
||||||
|
cartpos.addons.all().delete()
|
||||||
cartpos.delete()
|
cartpos.delete()
|
||||||
return ops
|
return ops
|
||||||
|
|
||||||
@@ -1487,7 +1709,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
self._calculate_tax()
|
self._calculate_tax()
|
||||||
self.order.touch()
|
self.order.touch()
|
||||||
if self.pk is None:
|
if self.pk is None:
|
||||||
while OrderPosition.objects.filter(secret=self.secret).exists():
|
while OrderPosition.all.filter(secret=self.secret).exists():
|
||||||
self.secret = generate_position_secret()
|
self.secret = generate_position_secret()
|
||||||
|
|
||||||
if not self.pseudonymization_id:
|
if not self.pseudonymization_id:
|
||||||
@@ -1503,7 +1725,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||||
while True:
|
while True:
|
||||||
code = get_random_string(length=10, allowed_chars=charset)
|
code = get_random_string(length=10, allowed_chars=charset)
|
||||||
if not OrderPosition.objects.filter(pseudonymization_id=code).exists():
|
if not OrderPosition.all.filter(pseudonymization_id=code).exists():
|
||||||
self.pseudonymization_id = code
|
self.pseudonymization_id = code
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1591,6 +1813,10 @@ class InvoiceAddress(models.Model):
|
|||||||
help_text=_('This reference will be printed on your invoice for your convenience.'),
|
help_text=_('This reference will be printed on your invoice for your convenience.'),
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
beneficiary = models.TextField(
|
||||||
|
verbose_name=_('Beneficiary'),
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
if self.order:
|
if self.order:
|
||||||
@@ -1600,6 +1826,7 @@ class InvoiceAddress(models.Model):
|
|||||||
self.name_cached = self.name
|
self.name_cached = self.name
|
||||||
else:
|
else:
|
||||||
self.name_cached = ""
|
self.name_cached = ""
|
||||||
|
self.name_parts = {}
|
||||||
super().save(**kwargs)
|
super().save(**kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class TaxRule(LoggedModel):
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
|
not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||||
and not OrderPosition.objects.filter(tax_rule=self, order__event=self.event).exists()
|
and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists()
|
||||||
and not self.event.items.filter(tax_rule=self).exists()
|
and not self.event.items.filter(tax_rule=self).exists()
|
||||||
and self.event.settings.tax_rate_default != self
|
and self.event.settings.tax_rate_default != self
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from ..decimal import round_decimal
|
|||||||
from .base import LoggedModel
|
from .base import LoggedModel
|
||||||
from .event import Event, SubEvent
|
from .event import Event, SubEvent
|
||||||
from .items import Item, ItemVariation, Quota
|
from .items import Item, ItemVariation, Quota
|
||||||
|
from .orders import Order
|
||||||
|
|
||||||
|
|
||||||
def _generate_random_code(prefix=None):
|
def _generate_random_code(prefix=None):
|
||||||
@@ -182,7 +183,7 @@ class Voucher(LoggedModel):
|
|||||||
return self.code
|
return self.code
|
||||||
|
|
||||||
def allow_delete(self):
|
def allow_delete(self):
|
||||||
return self.redeemed == 0
|
return self.redeemed == 0 and not self.orderposition_set.exists()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
Voucher.clean_item_properties(
|
Voucher.clean_item_properties(
|
||||||
@@ -380,3 +381,11 @@ class Voucher(LoggedModel):
|
|||||||
return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||||
return p
|
return p
|
||||||
return original_price
|
return original_price
|
||||||
|
|
||||||
|
def distinct_orders(self):
|
||||||
|
"""
|
||||||
|
Return the list of orders where this voucher has been used.
|
||||||
|
Each order will appear at most once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ class WaitingListEntry(LoggedModel):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def clean_duplicate(email, item, variation, subevent, pk):
|
def clean_duplicate(email, item, variation, subevent, pk):
|
||||||
if WaitingListEntry.objects.filter(
|
if WaitingListEntry.objects.filter(
|
||||||
item=item, variation=variation, email=email, voucher__isnull=True, subevent=subevent
|
item=item, variation=variation, email__iexact=email, voucher__isnull=True, subevent=subevent
|
||||||
).exclude(pk=pk).exists():
|
).exclude(pk=pk).exists():
|
||||||
raise ValidationError(_('You are already on this waiting list! We will notify '
|
raise ValidationError(_('You are already on this waiting list! We will notify '
|
||||||
'you as soon as we have a ticket available for you.'))
|
'you as soon as we have a ticket available for you.'))
|
||||||
|
|||||||
@@ -193,6 +193,12 @@ def register_default_notification_types(sender, **kwargs):
|
|||||||
_('New order placed'),
|
_('New order placed'),
|
||||||
_('A new order has been placed: {order.code}'),
|
_('A new order has been placed: {order.code}'),
|
||||||
),
|
),
|
||||||
|
ParametrizedOrderNotificationType(
|
||||||
|
sender,
|
||||||
|
'pretix.event.order.placed.require_approval',
|
||||||
|
_('New order requires approval'),
|
||||||
|
_('A new order has been placed that requires approval: {order.code}'),
|
||||||
|
),
|
||||||
ParametrizedOrderNotificationType(
|
ParametrizedOrderNotificationType(
|
||||||
sender,
|
sender,
|
||||||
'pretix.event.order.paid',
|
'pretix.event.order.paid',
|
||||||
@@ -237,9 +243,9 @@ def register_default_notification_types(sender, **kwargs):
|
|||||||
),
|
),
|
||||||
ParametrizedOrderNotificationType(
|
ParametrizedOrderNotificationType(
|
||||||
sender,
|
sender,
|
||||||
'pretix.event.order.refunded',
|
'pretix.event.order.refund.requested',
|
||||||
_('Order refunded'),
|
_('Refund requested'),
|
||||||
_('Order {order.code} has been refunded.')
|
_('You have been requested to issue a refund for {order.code}.')
|
||||||
),
|
),
|
||||||
ActionRequiredNotificationType(
|
ActionRequiredNotificationType(
|
||||||
sender,
|
sender,
|
||||||
|
|||||||
@@ -788,9 +788,9 @@ class ManualPayment(BasePaymentProvider):
|
|||||||
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
|
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
def order_pending_render(self, request, order) -> str:
|
def payment_pending_render(self, request, payment) -> str:
|
||||||
return rich_text(
|
return rich_text(
|
||||||
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(order))
|
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(payment.order))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+114
-20
@@ -1,13 +1,20 @@
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from functools import partial
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
import bleach
|
import bleach
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.staticfiles import finders
|
from django.contrib.staticfiles import finders
|
||||||
|
from django.dispatch import receiver
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from PyPDF2 import PdfFileReader
|
from PyPDF2 import PdfFileReader
|
||||||
from pytz import timezone
|
from pytz import timezone
|
||||||
@@ -25,7 +32,7 @@ from reportlab.pdfgen.canvas import Canvas
|
|||||||
from reportlab.platypus import Paragraph
|
from reportlab.platypus import Paragraph
|
||||||
|
|
||||||
from pretix.base.invoice import ThumbnailingImageReader
|
from pretix.base.invoice import ThumbnailingImageReader
|
||||||
from pretix.base.models import Order, OrderPosition
|
from pretix.base.models import Order, OrderPosition, QuestionAnswer
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
from pretix.base.signals import layout_text_variables
|
from pretix.base.signals import layout_text_variables
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
@@ -118,6 +125,14 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
"SHORT_DATETIME_FORMAT"
|
"SHORT_DATETIME_FORMAT"
|
||||||
) if ev.date_to else ""
|
) if ev.date_to else ""
|
||||||
}),
|
}),
|
||||||
|
("event_end_date", {
|
||||||
|
"label": _("Event end date"),
|
||||||
|
"editor_sample": _("2017-05-31"),
|
||||||
|
"evaluate": lambda op, order, ev: date_format(
|
||||||
|
ev.date_to.astimezone(timezone(ev.settings.timezone)),
|
||||||
|
"SHORT_DATE_FORMAT"
|
||||||
|
) if ev.date_to else ""
|
||||||
|
}),
|
||||||
("event_end_time", {
|
("event_end_time", {
|
||||||
"label": _("Event end time"),
|
"label": _("Event end time"),
|
||||||
"editor_sample": _("22:00"),
|
"editor_sample": _("22:00"),
|
||||||
@@ -178,9 +193,65 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
"editor_sample": _("Event organizer info text"),
|
"editor_sample": _("Event organizer info text"),
|
||||||
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
|
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
|
||||||
}),
|
}),
|
||||||
|
("now_date", {
|
||||||
|
"label": _("Printing date"),
|
||||||
|
"editor_sample": _("2017-05-31"),
|
||||||
|
"evaluate": lambda op, order, ev: date_format(
|
||||||
|
now().astimezone(timezone(ev.settings.timezone)),
|
||||||
|
"SHORT_DATE_FORMAT"
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
("now_datetime", {
|
||||||
|
"label": _("Printing date and time"),
|
||||||
|
"editor_sample": _("2017-05-31 19:00"),
|
||||||
|
"evaluate": lambda op, order, ev: date_format(
|
||||||
|
now().astimezone(timezone(ev.settings.timezone)),
|
||||||
|
"SHORT_DATETIME_FORMAT"
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
("now_time", {
|
||||||
|
"label": _("Printing time"),
|
||||||
|
"editor_sample": _("19:00"),
|
||||||
|
"evaluate": lambda op, order, ev: date_format(
|
||||||
|
now().astimezone(timezone(ev.settings.timezone)),
|
||||||
|
"TIME_FORMAT"
|
||||||
|
) if ev.date_admission else ""
|
||||||
|
}),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(layout_text_variables, dispatch_uid="pretix_base_layout_text_variables_questions")
|
||||||
|
def variables_from_questions(sender, *args, **kwargs):
|
||||||
|
def get_answer(op, order, event, question_id):
|
||||||
|
try:
|
||||||
|
if 'answers' in getattr(op, '_prefetched_objects_cache', {}):
|
||||||
|
a = [a for a in op.answers.all() if a.question_id == question_id][0]
|
||||||
|
else:
|
||||||
|
a = op.answers.get(question_id=question_id)
|
||||||
|
return str(a).replace("\n", "<br/>\n")
|
||||||
|
except QuestionAnswer.DoesNotExist:
|
||||||
|
return ""
|
||||||
|
except IndexError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
d = {}
|
||||||
|
for q in sender.questions.all():
|
||||||
|
d['question_{}'.format(q.pk)] = {
|
||||||
|
'label': _('Question: {question}').format(question=q.question),
|
||||||
|
'editor_sample': _('<Answer: {question}>').format(question=q.question),
|
||||||
|
'evaluate': partial(get_answer, question_id=q.pk)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _get_attendee_name_part(key, op, order, ev):
|
||||||
|
return op.attendee_name_parts.get(key, '')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ia_name_part(key, op, order, ev):
|
||||||
|
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||||
|
|
||||||
|
|
||||||
def get_variables(event):
|
def get_variables(event):
|
||||||
v = copy.copy(DEFAULT_VARIABLES)
|
v = copy.copy(DEFAULT_VARIABLES)
|
||||||
|
|
||||||
@@ -189,7 +260,7 @@ def get_variables(event):
|
|||||||
v['attendee_name_%s' % key] = {
|
v['attendee_name_%s' % key] = {
|
||||||
'label': _("Attendee name: {part}").format(part=label),
|
'label': _("Attendee name: {part}").format(part=label),
|
||||||
'editor_sample': scheme['sample'][key],
|
'editor_sample': scheme['sample'][key],
|
||||||
'evaluate': lambda op, order, ev: op.attendee_name_parts.get(key, '')
|
'evaluate': partial(_get_attendee_name_part, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
|
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
|
||||||
@@ -199,7 +270,7 @@ def get_variables(event):
|
|||||||
v['invoice_name_%s' % key] = {
|
v['invoice_name_%s' % key] = {
|
||||||
'label': _("Invoice address name: {part}").format(part=label),
|
'label': _("Invoice address name: {part}").format(part=label),
|
||||||
'editor_sample': scheme['sample'][key],
|
'editor_sample': scheme['sample'][key],
|
||||||
"evaluate": lambda op, order, ev: order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
"evaluate": partial(_get_ia_name_part, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
for recv, res in layout_text_variables.send(sender=event):
|
for recv, res in layout_text_variables.send(sender=event):
|
||||||
@@ -215,8 +286,10 @@ class Renderer:
|
|||||||
self.background_file = background_file
|
self.background_file = background_file
|
||||||
self.variables = get_variables(event)
|
self.variables = get_variables(event)
|
||||||
if self.background_file:
|
if self.background_file:
|
||||||
self.bg_pdf = PdfFileReader(BytesIO(self.background_file.read()), strict=False)
|
self.bg_bytes = self.background_file.read()
|
||||||
|
self.bg_pdf = PdfFileReader(BytesIO(self.bg_bytes), strict=False)
|
||||||
else:
|
else:
|
||||||
|
self.bg_bytes = None
|
||||||
self.bg_pdf = None
|
self.bg_pdf = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -329,24 +402,45 @@ class Renderer:
|
|||||||
self._draw_textarea(canvas, op, order, o)
|
self._draw_textarea(canvas, op, order, o)
|
||||||
elif o['type'] == "poweredby":
|
elif o['type'] == "poweredby":
|
||||||
self._draw_poweredby(canvas, op, o)
|
self._draw_poweredby(canvas, op, o)
|
||||||
|
if self.bg_pdf:
|
||||||
|
canvas.setPageSize((self.bg_pdf.getPage(0).mediaBox[2], self.bg_pdf.getPage(0).mediaBox[3]))
|
||||||
canvas.showPage()
|
canvas.showPage()
|
||||||
|
|
||||||
def render_background(self, buffer, title=_('Ticket')):
|
def render_background(self, buffer, title=_('Ticket')):
|
||||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
if settings.PDFTK:
|
||||||
buffer.seek(0)
|
buffer.seek(0)
|
||||||
new_pdf = PdfFileReader(buffer)
|
with tempfile.TemporaryDirectory() as d:
|
||||||
output = PdfFileWriter()
|
with open(os.path.join(d, 'back.pdf'), 'wb') as f:
|
||||||
|
f.write(self.bg_bytes)
|
||||||
|
with open(os.path.join(d, 'front.pdf'), 'wb') as f:
|
||||||
|
f.write(buffer.read())
|
||||||
|
subprocess.run([
|
||||||
|
settings.PDFTK,
|
||||||
|
os.path.join(d, 'front.pdf'),
|
||||||
|
'background',
|
||||||
|
os.path.join(d, 'back.pdf'),
|
||||||
|
'output',
|
||||||
|
os.path.join(d, 'out.pdf'),
|
||||||
|
'compress'
|
||||||
|
], check=True)
|
||||||
|
with open(os.path.join(d, 'out.pdf'), 'rb') as f:
|
||||||
|
return BytesIO(f.read())
|
||||||
|
else:
|
||||||
|
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||||
|
buffer.seek(0)
|
||||||
|
new_pdf = PdfFileReader(buffer)
|
||||||
|
output = PdfFileWriter()
|
||||||
|
|
||||||
for page in new_pdf.pages:
|
for page in new_pdf.pages:
|
||||||
bg_page = copy.copy(self.bg_pdf.getPage(0))
|
bg_page = copy.copy(self.bg_pdf.getPage(0))
|
||||||
bg_page.mergePage(page)
|
bg_page.mergePage(page)
|
||||||
output.addPage(bg_page)
|
output.addPage(bg_page)
|
||||||
|
|
||||||
output.addMetadata({
|
output.addMetadata({
|
||||||
'/Title': str(title),
|
'/Title': str(title),
|
||||||
'/Creator': 'pretix',
|
'/Creator': 'pretix',
|
||||||
})
|
})
|
||||||
outbuffer = BytesIO()
|
outbuffer = BytesIO()
|
||||||
output.write(outbuffer)
|
output.write(outbuffer)
|
||||||
outbuffer.seek(0)
|
outbuffer.seek(0)
|
||||||
return outbuffer
|
return outbuffer
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class PluginType(Enum):
|
|||||||
EXPORT = 4
|
EXPORT = 4
|
||||||
|
|
||||||
|
|
||||||
def get_all_plugins() -> List[type]:
|
def get_all_plugins(event=None) -> List[type]:
|
||||||
"""
|
"""
|
||||||
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
|
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
|
||||||
"""
|
"""
|
||||||
@@ -29,5 +29,13 @@ def get_all_plugins() -> List[type]:
|
|||||||
meta.app = app
|
meta.app = app
|
||||||
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
|
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if hasattr(app, 'is_available') and event:
|
||||||
|
if not app.is_available(event):
|
||||||
|
continue
|
||||||
|
|
||||||
plugins.append(meta)
|
plugins.append(meta)
|
||||||
return plugins
|
return sorted(
|
||||||
|
plugins,
|
||||||
|
key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', ''))
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from decimal import Decimal
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from celery.exceptions import MaxRetriesExceededError
|
from celery.exceptions import MaxRetriesExceededError
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -17,9 +18,11 @@ from pretix.base.models import (
|
|||||||
from pretix.base.models.event import SubEvent
|
from pretix.base.models.event import SubEvent
|
||||||
from pretix.base.models.orders import OrderFee
|
from pretix.base.models.orders import OrderFee
|
||||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||||
|
from pretix.base.services.checkin import _save_answers
|
||||||
from pretix.base.services.locking import LockTimeoutException
|
from pretix.base.services.locking import LockTimeoutException
|
||||||
from pretix.base.services.pricing import get_price
|
from pretix.base.services.pricing import get_price
|
||||||
from pretix.base.services.tasks import ProfiledTask
|
from pretix.base.services.tasks import ProfiledTask
|
||||||
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
from pretix.base.templatetags.rich_text import rich_text
|
from pretix.base.templatetags.rich_text import rich_text
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.presale.signals import (
|
from pretix.presale.signals import (
|
||||||
@@ -99,7 +102,8 @@ class CartManager:
|
|||||||
AddOperation: 30
|
AddOperation: 30
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None):
|
def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None,
|
||||||
|
sales_channel='web'):
|
||||||
self.event = event
|
self.event = event
|
||||||
self.cart_id = cart_id
|
self.cart_id = cart_id
|
||||||
self.now_dt = now()
|
self.now_dt = now()
|
||||||
@@ -111,6 +115,8 @@ class CartManager:
|
|||||||
self._variations_cache = {}
|
self._variations_cache = {}
|
||||||
self._expiry = None
|
self._expiry = None
|
||||||
self.invoice_address = invoice_address
|
self.invoice_address = invoice_address
|
||||||
|
self._widget_data = widget_data or {}
|
||||||
|
self._sales_channel = sales_channel
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def positions(self):
|
def positions(self):
|
||||||
@@ -137,10 +143,12 @@ class CartManager:
|
|||||||
for cp in self.positions:
|
for cp in self.positions:
|
||||||
if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start:
|
if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start:
|
||||||
err = error_messages['some_subevent_not_started']
|
err = error_messages['some_subevent_not_started']
|
||||||
|
cp.addons.all().delete()
|
||||||
cp.delete()
|
cp.delete()
|
||||||
|
|
||||||
if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end:
|
if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end:
|
||||||
err = error_messages['some_subevent_ended']
|
err = error_messages['some_subevent_ended']
|
||||||
|
cp.addons.all().delete()
|
||||||
cp.delete()
|
cp.delete()
|
||||||
return err
|
return err
|
||||||
|
|
||||||
@@ -188,6 +196,9 @@ class CartManager:
|
|||||||
if not op.item.is_available() or (op.variation and not op.variation.active):
|
if not op.item.is_available() or (op.variation and not op.variation.active):
|
||||||
raise CartError(error_messages['unavailable'])
|
raise CartError(error_messages['unavailable'])
|
||||||
|
|
||||||
|
if self._sales_channel not in op.item.sales_channels:
|
||||||
|
raise CartError(error_messages['unavailable'])
|
||||||
|
|
||||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
||||||
raise CartError(error_messages['voucher_invalid_item'])
|
raise CartError(error_messages['voucher_invalid_item'])
|
||||||
|
|
||||||
@@ -360,7 +371,7 @@ class CartManager:
|
|||||||
# could cancel each other out quota-wise). However, we are not taking this performance
|
# 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
|
# penalty for now as there is currently no outside interface that would allow building
|
||||||
# such a transaction.
|
# such a transaction.
|
||||||
for cp in self.positions.all():
|
for cp in self.positions.filter(addon_to__isnull=True):
|
||||||
self._operations.append(self.RemoveOperation(position=cp))
|
self._operations.append(self.RemoveOperation(position=cp))
|
||||||
|
|
||||||
def set_addons(self, addons):
|
def set_addons(self, addons):
|
||||||
@@ -605,23 +616,53 @@ class CartManager:
|
|||||||
|
|
||||||
if isinstance(op, self.AddOperation):
|
if isinstance(op, self.AddOperation):
|
||||||
for k in range(available_count):
|
for k in range(available_count):
|
||||||
new_cart_positions.append(CartPosition(
|
cp = CartPosition(
|
||||||
event=self.event, item=op.item, variation=op.variation,
|
event=self.event, item=op.item, variation=op.variation,
|
||||||
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
||||||
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
|
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
|
||||||
subevent=op.subevent, includes_tax=op.includes_tax
|
subevent=op.subevent, includes_tax=op.includes_tax
|
||||||
))
|
)
|
||||||
|
if self.event.settings.attendee_names_asked:
|
||||||
|
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
|
||||||
|
if 'attendee-name' in self._widget_data:
|
||||||
|
cp.attendee_name_parts = {'_legacy': self._widget_data['attendee-name']}
|
||||||
|
if any('attendee-name-{}'.format(k.replace('_', '-')) in self._widget_data for k, l, w
|
||||||
|
in scheme['fields']):
|
||||||
|
cp.attendee_name_parts = {
|
||||||
|
k: self._widget_data.get('attendee-name-{}'.format(k.replace('_', '-')), '')
|
||||||
|
for k, l, w in scheme['fields']
|
||||||
|
}
|
||||||
|
if self.event.settings.attendee_emails_asked and 'email' in self._widget_data:
|
||||||
|
cp.attendee_email = self._widget_data.get('email')
|
||||||
|
|
||||||
|
cp._answers = {}
|
||||||
|
for k, v in self._widget_data.items():
|
||||||
|
if not k.startswith('question-'):
|
||||||
|
continue
|
||||||
|
q = cp.item.questions.filter(ask_during_checkin=False, identifier__iexact=k[9:]).first()
|
||||||
|
if q:
|
||||||
|
try:
|
||||||
|
cp._answers[q] = q.clean_answer(v)
|
||||||
|
except ValidationError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
new_cart_positions.append(cp)
|
||||||
elif isinstance(op, self.ExtendOperation):
|
elif isinstance(op, self.ExtendOperation):
|
||||||
if available_count == 1:
|
if available_count == 1:
|
||||||
op.position.expires = self._expiry
|
op.position.expires = self._expiry
|
||||||
op.position.price = op.price.gross
|
op.position.price = op.price.gross
|
||||||
op.position.save()
|
op.position.save()
|
||||||
elif available_count == 0:
|
elif available_count == 0:
|
||||||
|
op.position.addons.all().delete()
|
||||||
op.position.delete()
|
op.position.delete()
|
||||||
else:
|
else:
|
||||||
raise AssertionError("ExtendOperation cannot affect more than one item")
|
raise AssertionError("ExtendOperation cannot affect more than one item")
|
||||||
|
|
||||||
CartPosition.objects.bulk_create(new_cart_positions)
|
for p in new_cart_positions:
|
||||||
|
if p._answers:
|
||||||
|
p.save()
|
||||||
|
_save_answers(p, {}, p._answers)
|
||||||
|
CartPosition.objects.bulk_create([p for p in new_cart_positions if not p._answers])
|
||||||
return err
|
return err
|
||||||
|
|
||||||
def commit(self):
|
def commit(self):
|
||||||
@@ -702,7 +743,7 @@ def get_fees(event, request, total, invoice_address, provider):
|
|||||||
|
|
||||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
|
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
|
||||||
invoice_address: int=None) -> None:
|
invoice_address: int=None, widget_data=None, sales_channel='web') -> None:
|
||||||
"""
|
"""
|
||||||
Adds a list of items to a user's cart.
|
Adds a list of items to a user's cart.
|
||||||
:param event: The event ID in question
|
:param event: The event ID in question
|
||||||
@@ -722,7 +763,8 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia)
|
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data,
|
||||||
|
sales_channel=sales_channel)
|
||||||
cm.add_new_items(items)
|
cm.add_new_items(items)
|
||||||
cm.commit()
|
cm.commit()
|
||||||
except LockTimeoutException:
|
except LockTimeoutException:
|
||||||
@@ -774,7 +816,7 @@ def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
|
|||||||
|
|
||||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||||
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en',
|
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en',
|
||||||
invoice_address: int=None) -> None:
|
invoice_address: int=None, sales_channel='web') -> None:
|
||||||
"""
|
"""
|
||||||
Removes a list of items from a user's cart.
|
Removes a list of items from a user's cart.
|
||||||
:param event: The event ID in question
|
:param event: The event ID in question
|
||||||
@@ -792,7 +834,7 @@ def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, loc
|
|||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia)
|
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
|
||||||
cm.set_addons(addons)
|
cm.set_addons(addons)
|
||||||
cm.commit()
|
cm.commit()
|
||||||
except LockTimeoutException:
|
except LockTimeoutException:
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
|||||||
dt = datetime or now()
|
dt = datetime or now()
|
||||||
|
|
||||||
# Fetch order position with related objects
|
# Fetch order position with related objects
|
||||||
op = OrderPosition.objects.select_related(
|
op = OrderPosition.all.select_related(
|
||||||
'item', 'variation', 'order', 'addon_to'
|
'item', 'variation', 'order', 'addon_to'
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'item__questions',
|
'item__questions',
|
||||||
@@ -90,10 +90,16 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
|||||||
'answers'
|
'answers'
|
||||||
).get(pk=op.pk)
|
).get(pk=op.pk)
|
||||||
|
|
||||||
|
if op.canceled:
|
||||||
|
raise CheckInError(
|
||||||
|
_('This order position has been canceled.'),
|
||||||
|
'unpaid'
|
||||||
|
)
|
||||||
|
|
||||||
answers = {a.question: a for a in op.answers.all()}
|
answers = {a.question: a for a in op.answers.all()}
|
||||||
require_answers = []
|
require_answers = []
|
||||||
for q in op.item.checkin_questions:
|
for q in op.item.checkin_questions:
|
||||||
if q not in given_answers:
|
if q not in given_answers and q not in answers:
|
||||||
require_answers.append(q)
|
require_answers.append(q)
|
||||||
|
|
||||||
_save_answers(op, answers, given_answers)
|
_save_answers(op, answers, given_answers)
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ from datetime import timedelta
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
|
||||||
|
from pretix.base.models import CachedCombinedTicket, CachedTicket
|
||||||
|
|
||||||
from ..models import CachedFile, CartPosition, InvoiceAddress
|
from ..models import CachedFile, CartPosition, InvoiceAddress
|
||||||
from ..signals import periodic_task
|
from ..signals import periodic_task
|
||||||
|
|
||||||
|
|
||||||
@receiver(signal=periodic_task)
|
@receiver(signal=periodic_task)
|
||||||
def clean_cart_positions(sender, **kwargs):
|
def clean_cart_positions(sender, **kwargs):
|
||||||
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14)):
|
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=False):
|
||||||
|
cp.delete()
|
||||||
|
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
|
||||||
cp.delete()
|
cp.delete()
|
||||||
for ia in InvoiceAddress.objects.filter(order__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
for ia in InvoiceAddress.objects.filter(order__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
||||||
ia.delete()
|
ia.delete()
|
||||||
@@ -19,3 +23,15 @@ def clean_cart_positions(sender, **kwargs):
|
|||||||
def clean_cached_files(sender, **kwargs):
|
def clean_cached_files(sender, **kwargs):
|
||||||
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
|
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
|
||||||
cf.delete()
|
cf.delete()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(signal=periodic_task)
|
||||||
|
def clean_cached_tickets(sender, **kwargs):
|
||||||
|
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=30)):
|
||||||
|
cf.delete()
|
||||||
|
for cf in CachedCombinedTicket.objects.filter(created__lte=now() - timedelta(days=30)):
|
||||||
|
cf.delete()
|
||||||
|
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(minutes=30), file__isnull=True):
|
||||||
|
cf.delete()
|
||||||
|
for cf in CachedCombinedTicket.objects.filter(created__lte=now() - timedelta(minutes=30), file__isnull=True):
|
||||||
|
cf.delete()
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
|
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
|
||||||
if open_payment and open_payment.payment_provider:
|
if open_payment and open_payment.payment_provider:
|
||||||
payment = open_payment.payment_provider.render_invoice_text(invoice.order)
|
payment = open_payment.payment_provider.render_invoice_text(invoice.order)
|
||||||
|
elif invoice.order.status == Order.STATUS_PAID:
|
||||||
|
payment = pgettext('invoice', 'The payment for this invoice has already been received.')
|
||||||
else:
|
else:
|
||||||
payment = ""
|
payment = ""
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
invoice.invoice_to_zipcode = ia.zipcode
|
invoice.invoice_to_zipcode = ia.zipcode
|
||||||
invoice.invoice_to_city = ia.city
|
invoice.invoice_to_city = ia.city
|
||||||
invoice.invoice_to_country = ia.country
|
invoice.invoice_to_country = ia.country
|
||||||
|
invoice.invoice_to_beneficiary = ia.beneficiary
|
||||||
|
|
||||||
if ia.vat_id:
|
if ia.vat_id:
|
||||||
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id
|
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id
|
||||||
@@ -139,6 +142,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
InvoiceLine.objects.create(
|
InvoiceLine.objects.create(
|
||||||
position=i, invoice=invoice, description=desc,
|
position=i, invoice=invoice, description=desc,
|
||||||
gross_value=p.price, tax_value=p.tax_value,
|
gross_value=p.price, tax_value=p.tax_value,
|
||||||
|
subevent=p.subevent, event_date_from=(p.subevent.date_from if p.subevent else invoice.event.date_from),
|
||||||
tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
|
tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,7 +236,7 @@ def generate_invoice(order: Order, trigger_pdf=True):
|
|||||||
if trigger_pdf:
|
if trigger_pdf:
|
||||||
invoice_pdf(invoice.pk)
|
invoice_pdf(invoice.pk)
|
||||||
|
|
||||||
if order.status in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
|
if order.status == Order.STATUS_CANCELED:
|
||||||
generate_cancellation(invoice, trigger_pdf)
|
generate_cancellation(invoice, trigger_pdf)
|
||||||
|
|
||||||
return invoice
|
return invoice
|
||||||
@@ -308,6 +312,7 @@ def build_preview_invoice_pdf(event):
|
|||||||
invoice.invoice_to_name, invoice.invoice_to_street,
|
invoice.invoice_to_name, invoice.invoice_to_street,
|
||||||
invoice.invoice_to_zipcode, invoice.invoice_to_city
|
invoice.invoice_to_zipcode, invoice.invoice_to_city
|
||||||
)
|
)
|
||||||
|
invoice.invoice_to_beneficiary = ''
|
||||||
invoice.file = None
|
invoice.file = None
|
||||||
invoice.save()
|
invoice.save()
|
||||||
invoice.lines.all().delete()
|
invoice.lines.all().delete()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import smtplib
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
from typing import Any, Dict, List, Union
|
from typing import Any, Dict, List, Union
|
||||||
|
|
||||||
@@ -170,8 +171,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
|||||||
chain(*task_chain).apply_async()
|
chain(*task_chain).apply_async()
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
@app.task(bind=True)
|
||||||
def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sender: str,
|
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||||
event: int=None, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None,
|
event: int=None, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None,
|
||||||
order: int=None, attach_tickets=False) -> bool:
|
order: int=None, attach_tickets=False) -> bool:
|
||||||
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||||
@@ -219,7 +220,34 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
backend.send_messages([email])
|
backend.send_messages([email])
|
||||||
except Exception:
|
except smtplib.SMTPResponseException as e:
|
||||||
|
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
|
||||||
|
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
|
||||||
|
logger.exception('Error sending email')
|
||||||
|
|
||||||
|
if order:
|
||||||
|
order.log_action(
|
||||||
|
'pretix.event.order.email.error',
|
||||||
|
data={
|
||||||
|
'subject': 'SMTP code {}'.format(e.smtp_code),
|
||||||
|
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
|
||||||
|
'recipient': '',
|
||||||
|
'invoices': [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||||
|
except Exception as e:
|
||||||
|
if order:
|
||||||
|
order.log_action(
|
||||||
|
'pretix.event.order.email.error',
|
||||||
|
data={
|
||||||
|
'subject': 'Internal error',
|
||||||
|
'message': str(e),
|
||||||
|
'recipient': '',
|
||||||
|
'invoices': [],
|
||||||
|
}
|
||||||
|
)
|
||||||
logger.exception('Error sending email')
|
logger.exception('Error sending email')
|
||||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from celery.exceptions import MaxRetriesExceededError
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import F, Max, Q, Sum
|
from django.db.models import F, Max, Q, Sum
|
||||||
|
from django.db.models.functions import Greatest
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@@ -31,7 +32,7 @@ from pretix.base.models.orders import (
|
|||||||
)
|
)
|
||||||
from pretix.base.models.organizer import TeamAPIToken
|
from pretix.base.models.organizer import TeamAPIToken
|
||||||
from pretix.base.models.tax import TaxedPrice
|
from pretix.base.models.tax import TaxedPrice
|
||||||
from pretix.base.payment import BasePaymentProvider
|
from pretix.base.payment import BasePaymentProvider, PaymentException
|
||||||
from pretix.base.services.invoices import (
|
from pretix.base.services.invoices import (
|
||||||
generate_cancellation, generate_invoice, invoice_qualified,
|
generate_cancellation, generate_invoice, invoice_qualified,
|
||||||
)
|
)
|
||||||
@@ -91,59 +92,45 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
|
|||||||
"""
|
"""
|
||||||
if new_date < now():
|
if new_date < now():
|
||||||
raise OrderError(_('The new expiry date needs to be in the future.'))
|
raise OrderError(_('The new expiry date needs to be in the future.'))
|
||||||
if order.status == Order.STATUS_PENDING:
|
|
||||||
|
def change(was_expired=True):
|
||||||
order.expires = new_date
|
order.expires = new_date
|
||||||
order.save(update_fields=['expires'])
|
if was_expired:
|
||||||
|
order.status = Order.STATUS_PENDING
|
||||||
|
order.save(update_fields=['expires'] + (['status'] if was_expired else []))
|
||||||
order.log_action(
|
order.log_action(
|
||||||
'pretix.event.order.expirychanged',
|
'pretix.event.order.expirychanged',
|
||||||
user=user,
|
user=user,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
data={
|
data={
|
||||||
'expires': order.expires,
|
'expires': order.expires,
|
||||||
'state_change': False
|
'state_change': was_expired
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if was_expired:
|
||||||
|
num_invoices = order.invoices.filter(is_cancellation=False).count()
|
||||||
|
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices:
|
||||||
|
generate_invoice(order)
|
||||||
|
|
||||||
|
if order.status == Order.STATUS_PENDING:
|
||||||
|
change(was_expired=False)
|
||||||
else:
|
else:
|
||||||
with order.event.lock() as now_dt:
|
with order.event.lock() as now_dt:
|
||||||
is_available = order._is_still_available(now_dt, count_waitinglist=False)
|
is_available = order._is_still_available(now_dt, count_waitinglist=False)
|
||||||
if is_available is True or force is True:
|
if is_available is True or force is True:
|
||||||
order.expires = new_date
|
change(was_expired=True)
|
||||||
order.status = Order.STATUS_PENDING
|
|
||||||
order.save(update_fields=['expires', 'status'])
|
|
||||||
order.log_action(
|
|
||||||
'pretix.event.order.expirychanged',
|
|
||||||
user=user,
|
|
||||||
auth=auth,
|
|
||||||
data={
|
|
||||||
'expires': order.expires,
|
|
||||||
'state_change': True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise OrderError(is_available)
|
raise OrderError(is_available)
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def mark_order_refunded(order, user=None, auth=None, api_token=None):
|
def mark_order_refunded(order, user=None, auth=None, api_token=None):
|
||||||
"""
|
oautha = auth.pk if isinstance(auth, OAuthApplication) else None
|
||||||
Mark this order as refunded. This sets the payment status and returns the order object.
|
device = auth.pk if isinstance(auth, Device) else None
|
||||||
:param order: The order to change
|
api_token = (api_token.pk if api_token else None) or (auth if isinstance(auth, TeamAPIToken) else None)
|
||||||
:param user: The user that performed the change
|
return _cancel_order(
|
||||||
"""
|
order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha
|
||||||
if isinstance(order, int):
|
)
|
||||||
order = Order.objects.get(pk=order)
|
|
||||||
if isinstance(user, int):
|
|
||||||
user = User.objects.get(pk=user)
|
|
||||||
with order.event.lock():
|
|
||||||
order.status = Order.STATUS_REFUNDED
|
|
||||||
order.save(update_fields=['status'])
|
|
||||||
|
|
||||||
order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token)
|
|
||||||
i = order.invoices.filter(is_cancellation=False).last()
|
|
||||||
if i:
|
|
||||||
generate_cancellation(i)
|
|
||||||
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@@ -269,7 +256,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
|||||||
|
|
||||||
for position in order.positions.all():
|
for position in order.positions.all():
|
||||||
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=Greatest(0, F('redeemed') - 1))
|
||||||
|
|
||||||
if send_mail:
|
if send_mail:
|
||||||
try:
|
try:
|
||||||
@@ -307,7 +294,8 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
|||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None):
|
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
|
||||||
|
cancellation_fee=None):
|
||||||
"""
|
"""
|
||||||
Mark this order as canceled
|
Mark this order as canceled
|
||||||
:param order: The order to change
|
:param order: The order to change
|
||||||
@@ -323,20 +311,54 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
|||||||
device = Device.objects.get(pk=device)
|
device = Device.objects.get(pk=device)
|
||||||
if isinstance(oauth_application, int):
|
if isinstance(oauth_application, int):
|
||||||
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
|
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
|
||||||
with order.event.lock():
|
if isinstance(cancellation_fee, str):
|
||||||
if not order.cancel_allowed():
|
cancellation_fee = Decimal(cancellation_fee)
|
||||||
raise OrderError(_('You cannot cancel this order.'))
|
|
||||||
order.status = Order.STATUS_CANCELED
|
|
||||||
order.save(update_fields=['status'])
|
|
||||||
|
|
||||||
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device)
|
if not order.cancel_allowed():
|
||||||
|
raise OrderError(_('You cannot cancel this order.'))
|
||||||
i = order.invoices.filter(is_cancellation=False).last()
|
i = order.invoices.filter(is_cancellation=False).last()
|
||||||
if i:
|
if i:
|
||||||
generate_cancellation(i)
|
generate_cancellation(i)
|
||||||
|
|
||||||
for position in order.positions.all():
|
if cancellation_fee:
|
||||||
if position.voucher:
|
with order.event.lock():
|
||||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
for position in order.positions.all():
|
||||||
|
if position.voucher:
|
||||||
|
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||||
|
position.canceled = True
|
||||||
|
position.save(update_fields=['canceled'])
|
||||||
|
for fee in order.fees.all():
|
||||||
|
fee.canceled = True
|
||||||
|
fee.save(update_fields=['canceled'])
|
||||||
|
|
||||||
|
f = OrderFee(
|
||||||
|
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
||||||
|
value=cancellation_fee,
|
||||||
|
tax_rule=order.event.settings.tax_rate_default,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
f._calculate_tax()
|
||||||
|
f.save()
|
||||||
|
|
||||||
|
if order.payment_refund_sum < cancellation_fee:
|
||||||
|
raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
|
||||||
|
order.status = Order.STATUS_PAID
|
||||||
|
order.total = f.value
|
||||||
|
order.save(update_fields=['status', 'total'])
|
||||||
|
|
||||||
|
if i:
|
||||||
|
generate_invoice(order)
|
||||||
|
else:
|
||||||
|
with order.event.lock():
|
||||||
|
order.status = Order.STATUS_CANCELED
|
||||||
|
order.save(update_fields=['status'])
|
||||||
|
|
||||||
|
for position in order.positions.all():
|
||||||
|
if position.voucher:
|
||||||
|
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||||
|
|
||||||
|
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
|
||||||
|
data={'cancellation_fee': cancellation_fee})
|
||||||
|
|
||||||
if send_mail:
|
if send_mail:
|
||||||
email_template = order.event.settings.mail_text_order_canceled
|
email_template = order.event.settings.mail_text_order_canceled
|
||||||
@@ -379,7 +401,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
|||||||
|
|
||||||
products_seen = Counter()
|
products_seen = Counter()
|
||||||
for i, cp in enumerate(positions):
|
for i, cp in enumerate(positions):
|
||||||
if not cp.item.active or (cp.variation and not cp.variation.active):
|
if not cp.item.is_available() or (cp.variation and not cp.variation.active):
|
||||||
err = err or error_messages['unavailable']
|
err = err or error_messages['unavailable']
|
||||||
cp.delete()
|
cp.delete()
|
||||||
continue
|
continue
|
||||||
@@ -499,7 +521,7 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
|
|||||||
|
|
||||||
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: InvoiceAddress=None,
|
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
|
||||||
meta_info: dict=None):
|
meta_info: dict=None, sales_channel: str='web'):
|
||||||
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event)
|
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event)
|
||||||
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
||||||
|
|
||||||
@@ -512,7 +534,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
|||||||
locale=locale,
|
locale=locale,
|
||||||
total=total,
|
total=total,
|
||||||
meta_info=json.dumps(meta_info or {}),
|
meta_info=json.dumps(meta_info or {}),
|
||||||
require_approval=any(p.item.require_approval for p in positions)
|
require_approval=any(p.item.require_approval for p in positions),
|
||||||
|
sales_channel=sales_channel
|
||||||
)
|
)
|
||||||
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
|
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
|
||||||
order.save()
|
order.save()
|
||||||
@@ -532,7 +555,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
|||||||
fee.tax_rule = None # TODO: deprecate
|
fee.tax_rule = None # TODO: deprecate
|
||||||
fee.save()
|
fee.save()
|
||||||
|
|
||||||
if payment_provider:
|
if payment_provider and not order.require_approval:
|
||||||
order.payments.create(
|
order.payments.create(
|
||||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||||
provider=payment_provider.identifier,
|
provider=payment_provider.identifier,
|
||||||
@@ -542,6 +565,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
|||||||
|
|
||||||
OrderPosition.transform_cart_positions(positions, order)
|
OrderPosition.transform_cart_positions(positions, order)
|
||||||
order.log_action('pretix.event.order.placed')
|
order.log_action('pretix.event.order.placed')
|
||||||
|
if order.require_approval:
|
||||||
|
order.log_action('pretix.event.order.placed.require_approval')
|
||||||
if meta_info:
|
if meta_info:
|
||||||
for msg in meta_info.get('confirm_messages', []):
|
for msg in meta_info.get('confirm_messages', []):
|
||||||
order.log_action('pretix.event.order.consent', data={'msg': msg})
|
order.log_action('pretix.event.order.consent', data={'msg': msg})
|
||||||
@@ -551,7 +576,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
|||||||
|
|
||||||
|
|
||||||
def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||||
email: str, locale: str, address: int, meta_info: dict=None):
|
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
|
||||||
|
|
||||||
event = Event.objects.get(id=event)
|
event = Event.objects.get(id=event)
|
||||||
if payment_provider:
|
if payment_provider:
|
||||||
@@ -580,7 +605,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
|||||||
raise OrderError(error_messages['internal'])
|
raise OrderError(error_messages['internal'])
|
||||||
_check_positions(event, now_dt, positions, address=addr)
|
_check_positions(event, now_dt, positions, address=addr)
|
||||||
order = _create_order(event, email, positions, now_dt, pprov,
|
order = _create_order(event, email, positions, now_dt, pprov,
|
||||||
locale=locale, address=addr, meta_info=meta_info)
|
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel)
|
||||||
|
|
||||||
invoice = order.invoices.last() # Might be generated by plugin already
|
invoice = order.invoices.last() # Might be generated by plugin already
|
||||||
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
|
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
|
||||||
@@ -661,7 +686,9 @@ def send_expiry_warnings(sender, **kwargs):
|
|||||||
eventcache = {}
|
eventcache = {}
|
||||||
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).only('pk'):
|
for o in Order.objects.filter(
|
||||||
|
expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING, datetime__lte=now() - timedelta(hours=2)
|
||||||
|
).only('pk'):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
|
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
|
||||||
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
|
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
|
||||||
@@ -723,7 +750,7 @@ def send_download_reminders(sender, **kwargs):
|
|||||||
|
|
||||||
if now() < reminder_date:
|
if now() < reminder_date:
|
||||||
continue
|
continue
|
||||||
for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False).only('pk'):
|
for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False, datetime__lte=now() - timedelta(hours=2)).only('pk'):
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
|
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
|
||||||
if o.download_reminder_sent:
|
if o.download_reminder_sent:
|
||||||
@@ -788,12 +815,17 @@ class OrderChangeManager:
|
|||||||
self.notify = notify
|
self.notify = notify
|
||||||
self._invoice_dirty = False
|
self._invoice_dirty = False
|
||||||
|
|
||||||
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
|
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation], keep_price=False):
|
||||||
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 = get_price(item, variation, voucher=position.voucher, subevent=position.subevent,
|
if keep_price:
|
||||||
invoice_address=self._invoice_address)
|
price = TaxedPrice(gross=position.price, net=position.price - position.tax_value,
|
||||||
|
tax=position.tax_value, rate=position.tax_rate,
|
||||||
|
name=position.tax_rule.name)
|
||||||
|
else:
|
||||||
|
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent,
|
||||||
|
invoice_address=self._invoice_address)
|
||||||
|
|
||||||
if price is None: # NOQA
|
if price is None: # NOQA
|
||||||
raise OrderError(self.error_messages['product_invalid'])
|
raise OrderError(self.error_messages['product_invalid'])
|
||||||
@@ -933,6 +965,21 @@ class OrderChangeManager:
|
|||||||
if self.order.pending_sum <= Decimal('0.00'):
|
if self.order.pending_sum <= Decimal('0.00'):
|
||||||
self.order.status = Order.STATUS_PAID
|
self.order.status = Order.STATUS_PAID
|
||||||
self.order.save()
|
self.order.save()
|
||||||
|
elif self.open_payment:
|
||||||
|
self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||||
|
self.open_payment.save()
|
||||||
|
self.order.log_action('pretix.event.order.payment.canceled', {
|
||||||
|
'local_id': self.open_payment.local_id,
|
||||||
|
'provider': self.open_payment.provider,
|
||||||
|
}, user=self.user, auth=self.auth)
|
||||||
|
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0:
|
||||||
|
if self.open_payment:
|
||||||
|
self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||||
|
self.open_payment.save()
|
||||||
|
self.order.log_action('pretix.event.order.payment.canceled', {
|
||||||
|
'local_id': self.open_payment.local_id,
|
||||||
|
'provider': self.open_payment.provider,
|
||||||
|
}, user=self.user, auth=self.auth)
|
||||||
|
|
||||||
def _check_paid_to_free(self):
|
def _check_paid_to_free(self):
|
||||||
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)):
|
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)):
|
||||||
@@ -1024,6 +1071,10 @@ class OrderChangeManager:
|
|||||||
'addon_to': opa.addon_to_id,
|
'addon_to': opa.addon_to_id,
|
||||||
'old_price': opa.price,
|
'old_price': opa.price,
|
||||||
})
|
})
|
||||||
|
opa.canceled = True
|
||||||
|
if opa.voucher:
|
||||||
|
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||||
|
opa.save(update_fields=['canceled'])
|
||||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
||||||
'position': op.position.pk,
|
'position': op.position.pk,
|
||||||
'positionid': op.position.positionid,
|
'positionid': op.position.positionid,
|
||||||
@@ -1032,7 +1083,10 @@ class OrderChangeManager:
|
|||||||
'old_price': op.position.price,
|
'old_price': op.position.price,
|
||||||
'addon_to': None,
|
'addon_to': None,
|
||||||
})
|
})
|
||||||
op.position.delete()
|
op.position.canceled = True
|
||||||
|
if op.position.voucher:
|
||||||
|
Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||||
|
op.position.save(update_fields=['canceled'])
|
||||||
elif isinstance(op, self.AddOperation):
|
elif isinstance(op, self.AddOperation):
|
||||||
pos = OrderPosition.objects.create(
|
pos = OrderPosition.objects.create(
|
||||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||||
@@ -1097,7 +1151,7 @@ class OrderChangeManager:
|
|||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
split_order.total = sum([p.price for p in split_positions])
|
split_order.total = sum([p.price for p in split_positions if not p.canceled])
|
||||||
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
|
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
|
||||||
pp = self._get_payment_provider()
|
pp = self._get_payment_provider()
|
||||||
if pp:
|
if pp:
|
||||||
@@ -1252,7 +1306,7 @@ class OrderChangeManager:
|
|||||||
except SendMailException:
|
except SendMailException:
|
||||||
logger.exception('Order changed email could not be sent')
|
logger.exception('Order changed email could not be sent')
|
||||||
|
|
||||||
def commit(self):
|
def commit(self, check_quotas=True):
|
||||||
if self._committed:
|
if self._committed:
|
||||||
# an order change can only be committed once
|
# an order change can only be committed once
|
||||||
raise OrderError(error_messages['internal'])
|
raise OrderError(error_messages['internal'])
|
||||||
@@ -1269,7 +1323,8 @@ class OrderChangeManager:
|
|||||||
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 not in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||||
raise OrderError(self.error_messages['not_pending_or_paid'])
|
raise OrderError(self.error_messages['not_pending_or_paid'])
|
||||||
self._check_quotas()
|
if check_quotas:
|
||||||
|
self._check_quotas()
|
||||||
self._check_complete_cancel()
|
self._check_complete_cancel()
|
||||||
self._perform_operations()
|
self._perform_operations()
|
||||||
self._recalculate_total_and_payment_fee()
|
self._recalculate_total_and_payment_fee()
|
||||||
@@ -1303,11 +1358,13 @@ class OrderChangeManager:
|
|||||||
|
|
||||||
@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, throws=(OrderError,))
|
||||||
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,
|
||||||
|
sales_channel: str='web'):
|
||||||
with language(locale):
|
with language(locale):
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info)
|
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
|
||||||
|
sales_channel)
|
||||||
except LockTimeoutException:
|
except LockTimeoutException:
|
||||||
self.retry()
|
self.retry()
|
||||||
except (MaxRetriesExceededError, LockTimeoutException):
|
except (MaxRetriesExceededError, LockTimeoutException):
|
||||||
@@ -1316,10 +1373,59 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
|||||||
|
|
||||||
@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, throws=(OrderError,))
|
||||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||||
device=None):
|
device=None, cancellation_fee=None, try_auto_refund=False):
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
return _cancel_order(order, user, send_mail, api_token, device, oauth_application)
|
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||||
|
cancellation_fee)
|
||||||
|
if try_auto_refund:
|
||||||
|
notify_admin = False
|
||||||
|
error = False
|
||||||
|
order = Order.objects.get(pk=order)
|
||||||
|
refund_amount = order.pending_sum * -1
|
||||||
|
proposals = order.propose_auto_refunds(refund_amount)
|
||||||
|
can_auto_refund = sum(proposals.values()) == refund_amount
|
||||||
|
if can_auto_refund:
|
||||||
|
for p, value in proposals.items():
|
||||||
|
with transaction.atomic():
|
||||||
|
r = order.refunds.create(
|
||||||
|
payment=p,
|
||||||
|
source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||||
|
state=OrderRefund.REFUND_STATE_CREATED,
|
||||||
|
amount=value,
|
||||||
|
provider=p.provider
|
||||||
|
)
|
||||||
|
order.log_action('pretix.event.order.refund.created', {
|
||||||
|
'local_id': r.local_id,
|
||||||
|
'provider': r.provider,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
r.payment_provider.execute_refund(r)
|
||||||
|
except PaymentException as e:
|
||||||
|
with transaction.atomic():
|
||||||
|
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||||
|
r.save()
|
||||||
|
order.log_action('pretix.event.order.refund.failed', {
|
||||||
|
'local_id': r.local_id,
|
||||||
|
'provider': r.provider,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
error = True
|
||||||
|
notify_admin = True
|
||||||
|
else:
|
||||||
|
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||||
|
notify_admin = True
|
||||||
|
elif refund_amount != Decimal('0.00'):
|
||||||
|
notify_admin = True
|
||||||
|
|
||||||
|
if notify_admin:
|
||||||
|
order.log_action('pretix.event.order.refund.requested')
|
||||||
|
if error:
|
||||||
|
raise OrderError(
|
||||||
|
_('There was an error while trying to send the money back to you. Please contact the event organizer for further information.')
|
||||||
|
)
|
||||||
|
return ret
|
||||||
except LockTimeoutException:
|
except LockTimeoutException:
|
||||||
self.retry()
|
self.retry()
|
||||||
except (MaxRetriesExceededError, LockTimeoutException):
|
except (MaxRetriesExceededError, LockTimeoutException):
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import F, Max, OuterRef, Q, Subquery
|
from django.db.models import F, Max, OuterRef, Q, Subquery
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
from django.utils.timezone import now
|
||||||
|
|
||||||
from pretix.base.models import LogEntry, Quota
|
from pretix.base.models import LogEntry, Quota
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
@@ -26,7 +29,8 @@ def refresh_quota_caches():
|
|||||||
last_activity=Subquery(last_activity, output_field=models.DateTimeField())
|
last_activity=Subquery(last_activity, output_field=models.DateTimeField())
|
||||||
).filter(
|
).filter(
|
||||||
Q(cached_availability_time__isnull=True) |
|
Q(cached_availability_time__isnull=True) |
|
||||||
Q(cached_availability_time__lt=F('last_activity'))
|
Q(cached_availability_time__lt=F('last_activity')) |
|
||||||
|
Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7))
|
||||||
)
|
)
|
||||||
for q in quotas:
|
for q in quotas:
|
||||||
q.availability()
|
q.availability()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Dict, Iterable, List, Tuple
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
|
|
||||||
from django.db.models import Count, Sum
|
from django.db.models import Case, Count, F, Sum, Value, When
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition
|
from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition
|
||||||
@@ -79,18 +79,22 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
|||||||
'variations'
|
'variations'
|
||||||
).order_by('category__position', 'category_id', 'position', 'name')
|
).order_by('category__position', 'category_id', 'position', 'name')
|
||||||
|
|
||||||
qs = OrderPosition.objects
|
qs = OrderPosition.all
|
||||||
if subevent:
|
if subevent:
|
||||||
qs = qs.filter(subevent=subevent)
|
qs = qs.filter(subevent=subevent)
|
||||||
counters = qs.filter(
|
counters = qs.filter(
|
||||||
order__event=event
|
order__event=event
|
||||||
|
).annotate(
|
||||||
|
status=Case(
|
||||||
|
When(canceled=True, then=Value('c')),
|
||||||
|
default=F('order__status')
|
||||||
|
)
|
||||||
).values(
|
).values(
|
||||||
'item', 'variation', 'order__status'
|
'item', 'variation', 'status'
|
||||||
).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by()
|
).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by()
|
||||||
|
|
||||||
states = {
|
states = {
|
||||||
'canceled': Order.STATUS_CANCELED,
|
'canceled': Order.STATUS_CANCELED,
|
||||||
'refunded': Order.STATUS_REFUNDED,
|
|
||||||
'paid': Order.STATUS_PAID,
|
'paid': Order.STATUS_PAID,
|
||||||
'pending': Order.STATUS_PENDING,
|
'pending': Order.STATUS_PENDING,
|
||||||
'expired': Order.STATUS_EXPIRED,
|
'expired': Order.STATUS_EXPIRED,
|
||||||
@@ -99,7 +103,7 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
|||||||
for l, s in states.items():
|
for l, s in states.items():
|
||||||
num[l] = {
|
num[l] = {
|
||||||
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
|
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
|
||||||
for p in counters if p['order__status'] == s
|
for p in counters if p['status'] == s
|
||||||
}
|
}
|
||||||
|
|
||||||
num['total'] = dictsum(num['pending'], num['paid'])
|
num['total'] = dictsum(num['pending'], num['paid'])
|
||||||
@@ -149,16 +153,21 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
|||||||
payment_items = []
|
payment_items = []
|
||||||
|
|
||||||
if not subevent:
|
if not subevent:
|
||||||
counters = OrderFee.objects.filter(
|
counters = OrderFee.all.filter(
|
||||||
order__event=event
|
order__event=event
|
||||||
|
).annotate(
|
||||||
|
status=Case(
|
||||||
|
When(canceled=True, then=Value('c')),
|
||||||
|
default=F('order__status')
|
||||||
|
)
|
||||||
).values(
|
).values(
|
||||||
'fee_type', 'internal_type', 'order__status'
|
'fee_type', 'internal_type', 'status'
|
||||||
).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by()
|
).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by()
|
||||||
|
|
||||||
for l, s in states.items():
|
for l, s in states.items():
|
||||||
num[l] = {
|
num[l] = {
|
||||||
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
|
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
|
||||||
for o in counters if o['order__status'] == s
|
for o in counters if o['status'] == s
|
||||||
}
|
}
|
||||||
num['total'] = dictsum(num['pending'], num['paid'])
|
num['total'] = dictsum(num['pending'], num['paid'])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
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
|
||||||
@@ -20,54 +19,48 @@ from pretix.helpers.database import rolledback_transaction
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledTask)
|
def generate_orderposition(order_position: int, 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(order_position=order_position, provider=provider)
|
|
||||||
except CachedTicket.MultipleObjectsReturned:
|
|
||||||
CachedTicket.objects.filter(order_position=order_position, provider=provider).delete()
|
|
||||||
ct = CachedTicket.objects.create(order_position=order_position, provider=provider, extension='',
|
|
||||||
type='', file=None)
|
|
||||||
except CachedTicket.DoesNotExist:
|
|
||||||
ct = CachedTicket.objects.create(order_position=order_position, provider=provider, extension='',
|
|
||||||
type='', file=None)
|
|
||||||
|
|
||||||
with language(order_position.order.locale):
|
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)
|
filename, ttype, data = prov.generate(order_position)
|
||||||
path, ext = os.path.splitext(filename)
|
path, ext = os.path.splitext(filename)
|
||||||
ct.extension = ext
|
for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider):
|
||||||
ct.save()
|
ct.delete()
|
||||||
|
ct = CachedTicket.objects.create(order_position=order_position, provider=provider,
|
||||||
|
extension=ext, type=ttype, file=None)
|
||||||
ct.file.save(filename, ContentFile(data))
|
ct.file.save(filename, ContentFile(data))
|
||||||
|
return ct.pk
|
||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledTask)
|
|
||||||
def generate_order(order: int, provider: str):
|
def generate_order(order: int, provider: str):
|
||||||
order = Order.objects.select_related('event').get(id=order)
|
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):
|
with language(order.locale):
|
||||||
responses = register_ticket_outputs.send(order.event)
|
responses = register_ticket_outputs.send(order.event)
|
||||||
for receiver, response in responses:
|
for receiver, response in responses:
|
||||||
prov = response(order.event)
|
prov = response(order.event)
|
||||||
if prov.identifier == provider:
|
if prov.identifier == provider:
|
||||||
filename, ct.type, data = prov.generate_order(order)
|
filename, ttype, data = prov.generate_order(order)
|
||||||
path, ext = os.path.splitext(filename)
|
path, ext = os.path.splitext(filename)
|
||||||
ct.extension = ext
|
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
|
||||||
ct.save()
|
ct.delete()
|
||||||
|
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext,
|
||||||
|
type=ttype, file=None)
|
||||||
ct.file.save(filename, ContentFile(data))
|
ct.file.save(filename, ContentFile(data))
|
||||||
|
return ct.pk
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(base=ProfiledTask)
|
||||||
|
def generate(model: str, pk: int, provider: str):
|
||||||
|
if model == 'order':
|
||||||
|
return generate_order(pk, provider)
|
||||||
|
elif model == 'orderposition':
|
||||||
|
return generate_orderposition(pk, provider)
|
||||||
|
|
||||||
|
|
||||||
class DummyRollbackException(Exception):
|
class DummyRollbackException(Exception):
|
||||||
@@ -103,56 +96,6 @@ def preview(event: int, provider: str):
|
|||||||
return prov.generate(p)
|
return prov.generate(p)
|
||||||
|
|
||||||
|
|
||||||
def get_cachedticket_for_position(pos, identifier, generate_async=True):
|
|
||||||
apply_method = 'apply_async' if generate_async else 'apply'
|
|
||||||
try:
|
|
||||||
ct = CachedTicket.objects.filter(
|
|
||||||
order_position=pos, provider=identifier
|
|
||||||
).last()
|
|
||||||
except CachedTicket.DoesNotExist:
|
|
||||||
ct = None
|
|
||||||
|
|
||||||
if not ct:
|
|
||||||
ct = CachedTicket.objects.create(
|
|
||||||
order_position=pos, provider=identifier,
|
|
||||||
extension='', type='', file=None)
|
|
||||||
getattr(generate, apply_method)(args=(pos.id, identifier))
|
|
||||||
if not generate_async:
|
|
||||||
ct.refresh_from_db()
|
|
||||||
|
|
||||||
if not ct.file:
|
|
||||||
if now() - ct.created > timedelta(minutes=5):
|
|
||||||
getattr(generate, apply_method)(args=(pos.id, identifier))
|
|
||||||
if not generate_async:
|
|
||||||
ct.refresh_from_db()
|
|
||||||
return ct
|
|
||||||
|
|
||||||
|
|
||||||
def get_cachedticket_for_order(order, identifier, generate_async=True):
|
|
||||||
apply_method = 'apply_async' if generate_async else 'apply'
|
|
||||||
try:
|
|
||||||
ct = CachedCombinedTicket.objects.filter(
|
|
||||||
order=order, provider=identifier
|
|
||||||
).last()
|
|
||||||
except CachedCombinedTicket.DoesNotExist:
|
|
||||||
ct = None
|
|
||||||
|
|
||||||
if not ct:
|
|
||||||
ct = CachedCombinedTicket.objects.create(
|
|
||||||
order=order, provider=identifier,
|
|
||||||
extension='', type='', file=None)
|
|
||||||
getattr(generate_order, apply_method)(args=(order.id, identifier))
|
|
||||||
if not generate_async:
|
|
||||||
ct.refresh_from_db()
|
|
||||||
|
|
||||||
if not ct.file:
|
|
||||||
if now() - ct.created > timedelta(minutes=5):
|
|
||||||
getattr(generate_order, apply_method)(args=(order.id, identifier))
|
|
||||||
if not generate_async:
|
|
||||||
ct.refresh_from_db()
|
|
||||||
return ct
|
|
||||||
|
|
||||||
|
|
||||||
def get_tickets_for_order(order):
|
def get_tickets_for_order(order):
|
||||||
can_download = all([r for rr, r in allow_ticket_download.send(order.event, order=order)])
|
can_download = all([r for rr, r in allow_ticket_download.send(order.event, order=order)])
|
||||||
if not can_download:
|
if not can_download:
|
||||||
@@ -174,7 +117,14 @@ def get_tickets_for_order(order):
|
|||||||
|
|
||||||
if p.multi_download_enabled:
|
if p.multi_download_enabled:
|
||||||
try:
|
try:
|
||||||
ct = get_cachedticket_for_order(order, p.identifier, generate_async=False)
|
if len(list(order.positions_with_tickets)) == 0:
|
||||||
|
continue
|
||||||
|
ct = CachedCombinedTicket.objects.filter(
|
||||||
|
order=order, provider=p.identifier, file__isnull=False
|
||||||
|
).last()
|
||||||
|
if not ct or not ct.file:
|
||||||
|
retval = generate.apply(args=('order', order.pk, p.identifier))
|
||||||
|
ct = CachedCombinedTicket.objects.get(pk=retval.get())
|
||||||
tickets.append((
|
tickets.append((
|
||||||
"{}-{}-{}{}".format(
|
"{}-{}-{}{}".format(
|
||||||
order.event.slug.upper(), order.code, ct.provider, ct.extension,
|
order.event.slug.upper(), order.code, ct.provider, ct.extension,
|
||||||
@@ -184,13 +134,14 @@ def get_tickets_for_order(order):
|
|||||||
except:
|
except:
|
||||||
logger.exception('Failed to generate ticket.')
|
logger.exception('Failed to generate ticket.')
|
||||||
else:
|
else:
|
||||||
for pos in order.positions.all():
|
for pos in order.positions_with_tickets:
|
||||||
if pos.addon_to and not order.event.settings.ticket_download_addons:
|
|
||||||
continue
|
|
||||||
if not pos.item.admission and not order.event.settings.ticket_download_nonadm:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
ct = get_cachedticket_for_position(pos, p.identifier, generate_async=False)
|
ct = CachedTicket.objects.filter(
|
||||||
|
order_position=pos, provider=p.identifier, file__isnull=False
|
||||||
|
).last()
|
||||||
|
if not ct or not ct.file:
|
||||||
|
retval = generate.apply(args=('orderposition', pos.pk, p.identifier))
|
||||||
|
ct = CachedTicket.objects.get(pk=retval.get())
|
||||||
tickets.append((
|
tickets.append((
|
||||||
"{}-{}-{}-{}{}".format(
|
"{}-{}-{}-{}{}".format(
|
||||||
order.event.slug.upper(), order.code, pos.positionid, ct.provider, ct.extension,
|
order.event.slug.upper(), order.code, pos.positionid, ct.provider, ct.extension,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -64,6 +65,10 @@ DEFAULTS = {
|
|||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
},
|
},
|
||||||
|
'invoice_address_beneficiary': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
},
|
||||||
'invoice_address_vatid': {
|
'invoice_address_vatid': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -88,6 +93,10 @@ DEFAULTS = {
|
|||||||
'default': '30',
|
'default': '30',
|
||||||
'type': int
|
'type': int
|
||||||
},
|
},
|
||||||
|
'payment_explanation': {
|
||||||
|
'default': '',
|
||||||
|
'type': LazyI18nString
|
||||||
|
},
|
||||||
'payment_term_days': {
|
'payment_term_days': {
|
||||||
'default': '14',
|
'default': '14',
|
||||||
'type': int
|
'type': int
|
||||||
@@ -204,6 +213,10 @@ DEFAULTS = {
|
|||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool
|
'type': bool
|
||||||
},
|
},
|
||||||
|
'event_list_availability': {
|
||||||
|
'default': 'True',
|
||||||
|
'type': bool
|
||||||
|
},
|
||||||
'event_list_type': {
|
'event_list_type': {
|
||||||
'default': 'list',
|
'default': 'list',
|
||||||
'type': str
|
'type': str
|
||||||
@@ -216,6 +229,30 @@ DEFAULTS = {
|
|||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool
|
'type': bool
|
||||||
},
|
},
|
||||||
|
'cancel_allow_user_until': {
|
||||||
|
'default': None,
|
||||||
|
'type': RelativeDateWrapper,
|
||||||
|
},
|
||||||
|
'cancel_allow_user_paid': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
},
|
||||||
|
'cancel_allow_user_paid_keep': {
|
||||||
|
'default': '0.00',
|
||||||
|
'type': Decimal,
|
||||||
|
},
|
||||||
|
'cancel_allow_user_paid_keep_fees': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
},
|
||||||
|
'cancel_allow_user_paid_keep_percentage': {
|
||||||
|
'default': '0.00',
|
||||||
|
'type': Decimal,
|
||||||
|
},
|
||||||
|
'cancel_allow_user_paid_until': {
|
||||||
|
'default': None,
|
||||||
|
'type': RelativeDateWrapper,
|
||||||
|
},
|
||||||
'contact_mail': {
|
'contact_mail': {
|
||||||
'default': None,
|
'default': None,
|
||||||
'type': str
|
'type': str
|
||||||
@@ -483,6 +520,14 @@ Your {event} team"""))
|
|||||||
'default': '#8E44B3',
|
'default': '#8E44B3',
|
||||||
'type': str
|
'type': str
|
||||||
},
|
},
|
||||||
|
'theme_color_success': {
|
||||||
|
'default': '#50A167',
|
||||||
|
'type': str
|
||||||
|
},
|
||||||
|
'theme_color_danger': {
|
||||||
|
'default': '#D36060',
|
||||||
|
'type': str
|
||||||
|
},
|
||||||
'primary_font': {
|
'primary_font': {
|
||||||
'default': 'Open Sans',
|
'default': 'Open Sans',
|
||||||
'type': str
|
'type': str
|
||||||
@@ -515,6 +560,10 @@ Your {event} team"""))
|
|||||||
'default': '',
|
'default': '',
|
||||||
'type': LazyI18nString
|
'type': LazyI18nString
|
||||||
},
|
},
|
||||||
|
'voucher_explanation_text': {
|
||||||
|
'default': '',
|
||||||
|
'type': LazyI18nString
|
||||||
|
},
|
||||||
'organizer_info_text': {
|
'organizer_info_text': {
|
||||||
'default': '',
|
'default': '',
|
||||||
'type': LazyI18nString
|
'type': LazyI18nString
|
||||||
|
|||||||
@@ -133,12 +133,12 @@ class EmailAddressShredder(BaseDataShredder):
|
|||||||
}, indent=4)
|
}, indent=4)
|
||||||
yield 'emails-by-attendee.json', 'application/json', json.dumps({
|
yield 'emails-by-attendee.json', 'application/json', json.dumps({
|
||||||
'{}-{}'.format(op.order.code, op.positionid): op.attendee_email
|
'{}-{}'.format(op.order.code, op.positionid): op.attendee_email
|
||||||
for op in OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False)
|
for op in OrderPosition.all.filter(order__event=self.event, attendee_email__isnull=False)
|
||||||
}, indent=4)
|
}, indent=4)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def shred_data(self):
|
def shred_data(self):
|
||||||
OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None)
|
OrderPosition.all.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None)
|
||||||
|
|
||||||
for o in self.event.orders.all():
|
for o in self.event.orders.all():
|
||||||
o.email = None
|
o.email = None
|
||||||
@@ -202,7 +202,7 @@ class AttendeeNameShredder(BaseDataShredder):
|
|||||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||||
yield 'attendee-names.json', 'application/json', json.dumps({
|
yield 'attendee-names.json', 'application/json', json.dumps({
|
||||||
'{}-{}'.format(op.order.code, op.positionid): op.attendee_name
|
'{}-{}'.format(op.order.code, op.positionid): op.attendee_name
|
||||||
for op in OrderPosition.objects.filter(
|
for op in OrderPosition.all.filter(
|
||||||
order__event=self.event
|
order__event=self.event
|
||||||
).filter(
|
).filter(
|
||||||
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
|
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
|
||||||
@@ -211,7 +211,7 @@ class AttendeeNameShredder(BaseDataShredder):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def shred_data(self):
|
def shred_data(self):
|
||||||
OrderPosition.objects.filter(
|
OrderPosition.all.filter(
|
||||||
order__event=self.event
|
order__event=self.event
|
||||||
).filter(
|
).filter(
|
||||||
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
|
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
|
||||||
@@ -267,7 +267,7 @@ class QuestionAnswerShredder(BaseDataShredder):
|
|||||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||||
yield 'question-answers.json', 'application/json', json.dumps({
|
yield 'question-answers.json', 'application/json', json.dumps({
|
||||||
'{}-{}'.format(op.order.code, op.positionid): AnswerSerializer(op.answers.all(), many=True).data
|
'{}-{}'.format(op.order.code, op.positionid): AnswerSerializer(op.answers.all(), many=True).data
|
||||||
for op in OrderPosition.objects.filter(order__event=self.event).prefetch_related('answers')
|
for op in OrderPosition.all.filter(order__event=self.event).prefetch_related('answers')
|
||||||
}, indent=4)
|
}, indent=4)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ class EventPluginSignal(django.dispatch.Signal):
|
|||||||
if not app_cache:
|
if not app_cache:
|
||||||
_populate_app_cache()
|
_populate_app_cache()
|
||||||
|
|
||||||
for receiver in self._live_receivers(sender):
|
for receiver in self._sorted_receivers(sender):
|
||||||
if self._is_active(sender, receiver):
|
if self._is_active(sender, receiver):
|
||||||
response = receiver(signal=self, sender=sender, **named)
|
response = receiver(signal=self, sender=sender, **named)
|
||||||
responses.append((receiver, response))
|
responses.append((receiver, response))
|
||||||
return sorted(responses, key=lambda r: (receiver.__module__, receiver.__name__))
|
return responses
|
||||||
|
|
||||||
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
|
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -89,12 +89,55 @@ class EventPluginSignal(django.dispatch.Signal):
|
|||||||
if not app_cache:
|
if not app_cache:
|
||||||
_populate_app_cache()
|
_populate_app_cache()
|
||||||
|
|
||||||
for receiver in self._live_receivers(sender):
|
for receiver in self._sorted_receivers(sender):
|
||||||
if self._is_active(sender, receiver):
|
if self._is_active(sender, receiver):
|
||||||
named[chain_kwarg_name] = response
|
named[chain_kwarg_name] = response
|
||||||
response = receiver(signal=self, sender=sender, **named)
|
response = receiver(signal=self, sender=sender, **named)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def send_robust(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
|
||||||
|
"""
|
||||||
|
Send signal from sender to all connected receivers. If a receiver raises an exception
|
||||||
|
instead of returning a value, the exception is included as the result instead of
|
||||||
|
stopping the response chain at the offending receiver.
|
||||||
|
|
||||||
|
sender is required to be an instance of ``pretix.base.models.Event``.
|
||||||
|
"""
|
||||||
|
if sender and not isinstance(sender, Event):
|
||||||
|
raise ValueError("Sender needs to be an event.")
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
if (
|
||||||
|
not self.receivers
|
||||||
|
or self.sender_receivers_cache.get(sender) is NO_RECEIVERS
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not app_cache:
|
||||||
|
_populate_app_cache()
|
||||||
|
|
||||||
|
for receiver in self._sorted_receivers(sender):
|
||||||
|
if self._is_active(sender, receiver):
|
||||||
|
try:
|
||||||
|
response = receiver(signal=self, sender=sender, **named)
|
||||||
|
except Exception as err:
|
||||||
|
responses.append((receiver, err))
|
||||||
|
else:
|
||||||
|
responses.append((receiver, response))
|
||||||
|
return responses
|
||||||
|
|
||||||
|
def _sorted_receivers(self, sender):
|
||||||
|
orig_list = self._live_receivers(sender)
|
||||||
|
sorted_list = sorted(
|
||||||
|
orig_list,
|
||||||
|
key=lambda receiver: (
|
||||||
|
0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1,
|
||||||
|
receiver.__module__,
|
||||||
|
receiver.__name__,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted_list
|
||||||
|
|
||||||
|
|
||||||
class DeprecatedSignal(django.dispatch.Signal):
|
class DeprecatedSignal(django.dispatch.Signal):
|
||||||
|
|
||||||
@@ -178,6 +221,15 @@ however for this signal, the ``sender`` **may also be None** to allow creating t
|
|||||||
notification settings!
|
notification settings!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
register_sales_channels = django.dispatch.Signal(
|
||||||
|
providing_args=[]
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
This signal is sent out to get all known sales channels types. Receivers should return an
|
||||||
|
instance of a subclass of ``pretix.base.channels.SalesChannel`` or a list of such
|
||||||
|
instances.
|
||||||
|
"""
|
||||||
|
|
||||||
register_data_exporters = EventPluginSignal(
|
register_data_exporters = EventPluginSignal(
|
||||||
providing_args=[]
|
providing_args=[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
{% extends "error.html" %}
|
{% extends "error.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load staticfiles %}
|
||||||
{% block title %}{% trans "Bad Request" %}{% endblock %}
|
{% block title %}{% trans "Bad Request" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<i class="fa fa-frown-o big-icon"></i>
|
<i class="fa fa-frown-o fa-fw big-icon"></i>
|
||||||
<h1>{% trans "Bad Request" %}</h1>
|
<div class="error-details">
|
||||||
<p>{% trans "We were unable to parse your request." %}</p>
|
<h1>{% trans "Bad Request" %}</h1>
|
||||||
<p>{{ exception }}</p>
|
<p>{% trans "We were unable to parse your request." %}</p>
|
||||||
<p class="links">
|
<p>{{ exception }}</p>
|
||||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
<p class="links">
|
||||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||||
</p>
|
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||||
|
</p>
|
||||||
|
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
{% extends "error.html" %}
|
{% extends "error.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load staticfiles %}
|
||||||
{% block title %}{% trans "Permission denied" %}{% endblock %}
|
{% block title %}{% trans "Permission denied" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<i class="fa fa-lock big-icon"></i>
|
<i class="fa fa-fw fa-lock big-icon"></i>
|
||||||
<h1>{% trans "Permission denied" %}</h1>
|
<div class="error-details">
|
||||||
<p>{% trans "You do not have access to this page." %}</p>
|
<h1>{% trans "Permission denied" %}</h1>
|
||||||
<p>{{ exception }}</p>
|
<p>{% trans "You do not have access to this page." %}</p>
|
||||||
<p class="links">
|
<p>{{ exception }}</p>
|
||||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
<p class="links">
|
||||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||||
</p>
|
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||||
{% if request.user.is_staff and not staff_session %}
|
</p>
|
||||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
{% if request.user.is_staff and not staff_session %}
|
||||||
<p>
|
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||||
{% csrf_token %}
|
<p>
|
||||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
{% csrf_token %}
|
||||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||||
</button>
|
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||||
</p>
|
</button>
|
||||||
</form>
|
</p>
|
||||||
{% endif %}
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
{% extends "error.html" %}
|
{% extends "error.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load staticfiles %}
|
||||||
{% block title %}{% trans "Not found" %}{% endblock %}
|
{% block title %}{% trans "Not found" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<i class="fa fa-meh-o big-icon"></i>
|
<i class="fa fa-meh-o fa-fw big-icon"></i>
|
||||||
<h1>{% trans "Not found" %}</h1>
|
<div class="error-details">
|
||||||
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
|
<h1>{% trans "Not found" %}</h1>
|
||||||
<p>{{ exception }}</p>
|
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
|
||||||
<p class="links">
|
<p>{{ exception }}</p>
|
||||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
<p class="links">
|
||||||
</p>
|
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||||
{% if request.user.is_staff and not staff_session %}
|
</p>
|
||||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
{% if request.user.is_staff and not staff_session %}
|
||||||
<p>
|
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||||
{% csrf_token %}
|
<p>
|
||||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
{% csrf_token %}
|
||||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||||
</button>
|
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||||
</p>
|
</button>
|
||||||
</form>
|
</p>
|
||||||
{% endif %}
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
{% extends "error.html" %}
|
{% extends "error.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load staticfiles %}
|
||||||
{% block title %}{% trans "Internal Server Error" %}{% endblock %}
|
{% block title %}{% trans "Internal Server Error" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<i class="fa fa-bolt big-icon"></i>
|
<i class="fa fa-bolt big-icon fa-fw"></i>
|
||||||
<h1>{% trans "Internal Server Error" %}</h1>
|
<div class="error-details">
|
||||||
<p>{% trans "We had trouble processing your request." %}</p>
|
<h1>{% trans "Internal Server Error" %}</h1>
|
||||||
<p>{% trans "If this problem persists, please contact us." %}</p>
|
<p>{% trans "We had trouble processing your request." %}</p>
|
||||||
{% if request.sentry.id %}
|
<p>{% trans "If this problem persists, please contact us." %}</p>
|
||||||
<p>
|
{% if sentry_event_id %}
|
||||||
{% blocktrans trimmed %}
|
<p>
|
||||||
If you contact us, please send us the following code:
|
{% blocktrans trimmed %}
|
||||||
{% endblocktrans %}
|
If you contact us, please send us the following code:
|
||||||
<br>
|
{% endblocktrans %}
|
||||||
{{ request.sentry.id }}
|
<br>
|
||||||
|
{{ sentry_event_id }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>{{ exception }}</p>
|
||||||
|
<p class="links">
|
||||||
|
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||||
|
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||||
<p>{{ exception }}</p>
|
</div>
|
||||||
<p class="links">
|
|
||||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
|
||||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
{% extends "error.html" %}
|
{% extends "error.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load staticfiles %}
|
||||||
{% block title %}{% trans "Verification failed" %}{% endblock %}
|
{% block title %}{% trans "Verification failed" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<i class="fa fa-frown-o big-icon"></i>
|
<i class="fa fa-frown-o big-icon fa-fw"></i>
|
||||||
<h1>{% trans "Verification failed" %}</h1>
|
<div class="error-details">
|
||||||
<p>{% blocktrans trimmed %}
|
<h1>{% trans "Verification failed" %}</h1>
|
||||||
We could not verify that this request really was sent from you. For security reasons, we therefore cannot process it.
|
|
||||||
{% endblocktrans %}</p>
|
|
||||||
{% if no_referer %}
|
|
||||||
<p>{{ no_referer1 }}</p>
|
|
||||||
<p>{{ no_referer2 }}</p>
|
|
||||||
{% elif no_cookie %}
|
|
||||||
<p>{{ no_cookie1 }}</p>
|
|
||||||
<p>{{ no_cookie2 }}</p>
|
|
||||||
{% else %}
|
|
||||||
<p>{% blocktrans trimmed %}
|
<p>{% blocktrans trimmed %}
|
||||||
Please go back to the last page, refresh this page and then try again. If the problem persists, please get in touch with us.
|
We could not verify that this request really was sent from you. For security reasons, we therefore cannot
|
||||||
|
process it.
|
||||||
{% endblocktrans %}</p>
|
{% endblocktrans %}</p>
|
||||||
{% endif %}
|
{% if no_referer %}
|
||||||
<p class="links">
|
<p>{{ no_referer1 }}</p>
|
||||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
<p>{{ no_referer2 }}</p>
|
||||||
</p>
|
{% elif no_cookie %}
|
||||||
|
<p>{{ no_cookie1 }}</p>
|
||||||
|
<p>{{ no_cookie2 }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>{% blocktrans trimmed %}
|
||||||
|
Please go back to the last page, refresh this page and then try again. If the problem persists, please
|
||||||
|
get in touch with us.
|
||||||
|
{% endblocktrans %}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="links">
|
||||||
|
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||||
|
</p>
|
||||||
|
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% load compress %}
|
{% load compress %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load staticfiles %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -16,6 +16,6 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<script src="{% static "pretixbase/js/errors.js" %}"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
<script src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -70,14 +70,12 @@ class BaseTicketOutput:
|
|||||||
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
|
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
|
||||||
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
|
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
|
||||||
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
|
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
|
||||||
|
If you want, you can just iterate over ``order.positions_with_tickets`` which applies the
|
||||||
|
appropriate filters for you.
|
||||||
"""
|
"""
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||||
for pos in order.positions.all():
|
for pos in order.positions_with_tickets:
|
||||||
if pos.addon_to_id and not self.event.settings.ticket_download_addons:
|
|
||||||
continue
|
|
||||||
if not pos.item.admission and not self.event.settings.ticket_download_nonadm:
|
|
||||||
continue
|
|
||||||
fname, __, content = self.generate(pos)
|
fname, __, content = self.generate(pos)
|
||||||
zipf.writestr('{}-{}{}'.format(
|
zipf.writestr('{}-{}{}'.format(
|
||||||
order.code, pos.positionid, os.path.splitext(fname)[1]
|
order.code, pos.positionid, os.path.splitext(fname)[1]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from django.template.loader import get_template
|
|||||||
from django.utils.functional import Promise
|
from django.utils.functional import Promise
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.views.decorators.csrf import requires_csrf_token
|
from django.views.decorators.csrf import requires_csrf_token
|
||||||
|
from sentry_sdk import last_event_id
|
||||||
|
|
||||||
|
|
||||||
def csrf_failure(request, reason=""):
|
def csrf_failure(request, reason=""):
|
||||||
@@ -65,5 +66,6 @@ def server_error(request):
|
|||||||
except TemplateDoesNotExist:
|
except TemplateDoesNotExist:
|
||||||
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
||||||
return HttpResponseServerError(template.render({
|
return HttpResponseServerError(template.render({
|
||||||
'request': request
|
'request': request,
|
||||||
|
'sentry_event_id': last_event_id(),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
|||||||
invoice_form_class = BaseInvoiceAddressForm
|
invoice_form_class = BaseInvoiceAddressForm
|
||||||
invoice_name_form_class = BaseInvoiceNameForm
|
invoice_name_form_class = BaseInvoiceNameForm
|
||||||
only_user_visible = True
|
only_user_visible = True
|
||||||
|
all_optional = False
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def _positions_for_questions(self):
|
def _positions_for_questions(self):
|
||||||
@@ -189,12 +190,14 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
|||||||
return self.invoice_name_form_class(
|
return self.invoice_name_form_class(
|
||||||
data=self.request.POST if self.request.method == "POST" else None,
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
event=self.request.event,
|
event=self.request.event,
|
||||||
instance=self.invoice_address, validate_vat_id=False
|
instance=self.invoice_address, validate_vat_id=False,
|
||||||
|
all_optional=self.all_optional
|
||||||
)
|
)
|
||||||
return self.invoice_form_class(
|
return self.invoice_form_class(
|
||||||
data=self.request.POST if self.request.method == "POST" else None,
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
event=self.request.event,
|
event=self.request.event,
|
||||||
instance=self.invoice_address, validate_vat_id=False
|
instance=self.invoice_address, validate_vat_id=False,
|
||||||
|
all_optional=self.all_optional,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ from django.utils.translation import get_language
|
|||||||
|
|
||||||
from pretix.base.models.auth import StaffSession
|
from pretix.base.models.auth import StaffSession
|
||||||
from pretix.base.settings import GlobalSettingsObject
|
from pretix.base.settings import GlobalSettingsObject
|
||||||
|
from pretix.control.navigation import (
|
||||||
|
get_event_navigation, get_global_navigation, get_organizer_navigation,
|
||||||
|
)
|
||||||
|
|
||||||
from ..helpers.i18n import (
|
from ..helpers.i18n import (
|
||||||
get_javascript_format, get_javascript_output_format, get_moment_locale,
|
get_javascript_format, get_javascript_output_format, get_moment_locale,
|
||||||
)
|
)
|
||||||
from .signals import html_head, nav_event, nav_global, nav_topbar
|
from .signals import html_head, nav_topbar
|
||||||
|
|
||||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ def contextprocessor(request):
|
|||||||
ctx = {
|
ctx = {
|
||||||
'url_name': url.url_name,
|
'url_name': url.url_name,
|
||||||
'settings': settings,
|
'settings': settings,
|
||||||
|
'django_settings': settings,
|
||||||
'DEBUG': settings.DEBUG,
|
'DEBUG': settings.DEBUG,
|
||||||
}
|
}
|
||||||
_html_head = []
|
_html_head = []
|
||||||
@@ -40,10 +44,9 @@ def contextprocessor(request):
|
|||||||
ctx['html_head'] = "".join(_html_head)
|
ctx['html_head'] = "".join(_html_head)
|
||||||
|
|
||||||
_js_payment_weekdays_disabled = '[]'
|
_js_payment_weekdays_disabled = '[]'
|
||||||
_nav_event = []
|
|
||||||
if getattr(request, 'event', None) and hasattr(request, 'organizer') and request.user.is_authenticated:
|
if getattr(request, 'event', None) and hasattr(request, 'organizer') and request.user.is_authenticated:
|
||||||
for receiver, response in nav_event.send(request.event, request=request):
|
ctx['nav_items'] = get_event_navigation(request)
|
||||||
_nav_event += response
|
|
||||||
if request.event.settings.get('payment_term_weekdays'):
|
if request.event.settings.get('payment_term_weekdays'):
|
||||||
_js_payment_weekdays_disabled = '[0,6]'
|
_js_payment_weekdays_disabled = '[0,6]'
|
||||||
|
|
||||||
@@ -65,17 +68,13 @@ def contextprocessor(request):
|
|||||||
if request.GET.get('subevent', ''):
|
if request.GET.get('subevent', ''):
|
||||||
# Do not use .get() for lazy evaluation
|
# Do not use .get() for lazy evaluation
|
||||||
ctx['selected_subevents'] = request.event.subevents.filter(pk=request.GET.get('subevent'))
|
ctx['selected_subevents'] = request.event.subevents.filter(pk=request.GET.get('subevent'))
|
||||||
|
elif getattr(request, 'organizer', None) and request.user.is_authenticated:
|
||||||
|
ctx['nav_items'] = get_organizer_navigation(request)
|
||||||
|
elif request.user.is_authenticated:
|
||||||
|
ctx['nav_items'] = get_global_navigation(request)
|
||||||
|
|
||||||
ctx['nav_event'] = _nav_event
|
|
||||||
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
|
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
|
||||||
|
|
||||||
_nav_global = []
|
|
||||||
if not hasattr(request, 'event') and request.user.is_authenticated:
|
|
||||||
for receiver, response in nav_global.send(request, request=request):
|
|
||||||
_nav_global += response
|
|
||||||
|
|
||||||
ctx['nav_global'] = sorted(_nav_global, key=lambda n: n['label'])
|
|
||||||
|
|
||||||
_nav_topbar = []
|
_nav_topbar = []
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
for receiver, response in nav_topbar.send(request, request=request):
|
for receiver, response in nav_topbar.send(request, request=request):
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.forms import formset_factory
|
from django.forms import formset_factory
|
||||||
|
from django.utils.html import escape
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.timezone import get_current_timezone_name
|
from django.utils.timezone import get_current_timezone_name
|
||||||
from django.utils.translation import (
|
from django.utils.translation import (
|
||||||
pgettext, pgettext_lazy, ugettext_lazy as _,
|
pgettext, pgettext_lazy, ugettext_lazy as _,
|
||||||
@@ -159,6 +161,15 @@ class EventWizardBasicsForm(I18nModelForm):
|
|||||||
return slug
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
class EventChoiceField(forms.ModelChoiceField):
|
||||||
|
def label_from_instance(self, obj):
|
||||||
|
return mark_safe('{}<br /><span class="text-muted">{} · {}</span>'.format(
|
||||||
|
escape(str(obj)),
|
||||||
|
obj.get_date_range_display() if not obj.has_subevents else _("Event series"),
|
||||||
|
obj.slug
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
class EventWizardCopyForm(forms.Form):
|
class EventWizardCopyForm(forms.Form):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -177,7 +188,7 @@ class EventWizardCopyForm(forms.Form):
|
|||||||
kwargs.pop('has_subevents')
|
kwargs.pop('has_subevents')
|
||||||
self.user = kwargs.pop('user')
|
self.user = kwargs.pop('user')
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['copy_from_event'] = forms.ModelChoiceField(
|
self.fields['copy_from_event'] = EventChoiceField(
|
||||||
label=_("Copy configuration from"),
|
label=_("Copy configuration from"),
|
||||||
queryset=EventWizardCopyForm.copy_from_queryset(self.user),
|
queryset=EventWizardCopyForm.copy_from_queryset(self.user),
|
||||||
widget=forms.RadioSelect,
|
widget=forms.RadioSelect,
|
||||||
@@ -397,11 +408,6 @@ class EventSettingsForm(SettingsForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text=_("We'll show this publicly to allow attendees to contact you.")
|
help_text=_("We'll show this publicly to allow attendees to contact you.")
|
||||||
)
|
)
|
||||||
cancel_allow_user = forms.BooleanField(
|
|
||||||
label=_("Allow users to cancel unpaid orders"),
|
|
||||||
help_text=_("If checked, users can cancel orders by themselves as long as they are not yet paid."),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
data = super().clean()
|
data = super().clean()
|
||||||
@@ -435,6 +441,39 @@ class EventSettingsForm(SettingsForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CancelSettingsForm(SettingsForm):
|
||||||
|
cancel_allow_user = forms.BooleanField(
|
||||||
|
label=_("Customers can cancel their unpaid orders"),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
cancel_allow_user_until = RelativeDateTimeField(
|
||||||
|
label=_("Do not allow cancellations after"),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
cancel_allow_user_paid = forms.BooleanField(
|
||||||
|
label=_("Customers can cancel their paid orders"),
|
||||||
|
help_text=_("Paid money will be automatically paid back if the payment method allows it. "
|
||||||
|
"Otherwise, a manual refund will be created for you to process manually."),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
cancel_allow_user_paid_keep = forms.DecimalField(
|
||||||
|
label=_("Keep a fixed cancellation fee"),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
cancel_allow_user_paid_keep_fees = forms.BooleanField(
|
||||||
|
label=_("Keep payment, shipping and service fees"),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
cancel_allow_user_paid_keep_percentage = forms.DecimalField(
|
||||||
|
label=_("Keep a percentual cancellation fee"),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
cancel_allow_user_paid_until = RelativeDateTimeField(
|
||||||
|
label=_("Do not allow cancellations after"),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PaymentSettingsForm(SettingsForm):
|
class PaymentSettingsForm(SettingsForm):
|
||||||
payment_term_days = forms.IntegerField(
|
payment_term_days = forms.IntegerField(
|
||||||
label=_('Payment term in days'),
|
label=_('Payment term in days'),
|
||||||
@@ -478,6 +517,16 @@ class PaymentSettingsForm(SettingsForm):
|
|||||||
help_text=_("The tax rule that applies for additional fees you configured for single payment methods. This "
|
help_text=_("The tax rule that applies for additional fees you configured for single payment methods. This "
|
||||||
"will set the tax rate and reverse charge rules, other settings of the tax rule are ignored.")
|
"will set the tax rate and reverse charge rules, other settings of the tax rule are ignored.")
|
||||||
)
|
)
|
||||||
|
payment_explanation = I18nFormField(
|
||||||
|
widget=I18nTextarea,
|
||||||
|
widget_kwargs={'attrs': {
|
||||||
|
'rows': 3,
|
||||||
|
}},
|
||||||
|
required=False,
|
||||||
|
label=_("Guidance text"),
|
||||||
|
help_text=_("This text will be shown above the payment options. You can explain the choices to the user here, "
|
||||||
|
"if you want.")
|
||||||
|
)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
@@ -567,6 +616,11 @@ class InvoiceSettingsForm(SettingsForm):
|
|||||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
invoice_address_beneficiary = forms.BooleanField(
|
||||||
|
label=_("Ask for beneficiary"),
|
||||||
|
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
invoice_include_free = forms.BooleanField(
|
invoice_include_free = forms.BooleanField(
|
||||||
label=_("Show free products on invoices"),
|
label=_("Show free products on invoices"),
|
||||||
help_text=_("Note that invoices will never be generated for orders that contain only free "
|
help_text=_("Note that invoices will never be generated for orders that contain only free "
|
||||||
@@ -938,7 +992,27 @@ class DisplaySettingsForm(SettingsForm):
|
|||||||
required=False,
|
required=False,
|
||||||
validators=[
|
validators=[
|
||||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.'))
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||||
|
)
|
||||||
|
theme_color_success = forms.CharField(
|
||||||
|
label=_("Accent color for success"),
|
||||||
|
help_text=_("We strongly suggest to use a shade of green."),
|
||||||
|
required=False,
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||||
|
)
|
||||||
|
theme_color_danger = forms.CharField(
|
||||||
|
label=_("Accent color for errors"),
|
||||||
|
help_text=_("We strongly suggest to use a dark shade of red."),
|
||||||
|
required=False,
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
],
|
],
|
||||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||||
)
|
)
|
||||||
@@ -961,6 +1035,14 @@ class DisplaySettingsForm(SettingsForm):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=I18nTextarea
|
widget=I18nTextarea
|
||||||
)
|
)
|
||||||
|
voucher_explanation_text = I18nFormField(
|
||||||
|
label=_("Voucher explanation"),
|
||||||
|
required=False,
|
||||||
|
widget=I18nTextarea,
|
||||||
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
|
help_text=_("This text will be shown next to the input for a voucher code. You can use it e.g. to explain "
|
||||||
|
"how to obtain a voucher code.")
|
||||||
|
)
|
||||||
show_variations_expanded = forms.BooleanField(
|
show_variations_expanded = forms.BooleanField(
|
||||||
label=_("Show variations of a product expanded by default"),
|
label=_("Show variations of a product expanded by default"),
|
||||||
required=False
|
required=False
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
from datetime import datetime, time
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db.models import Exists, F, OuterRef, Q
|
from django.db.models import Exists, F, OuterRef, Q
|
||||||
from django.db.models.functions import Coalesce, ExtractWeekDay
|
from django.db.models.functions import Coalesce, ExtractWeekDay
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||||
|
|
||||||
|
from pretix.base.forms.widgets import DatePickerWidget
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Checkin, Event, Invoice, Item, Order, OrderPayment, OrderPosition,
|
Checkin, Event, Invoice, Item, Order, OrderPayment, OrderPosition,
|
||||||
OrderRefund, Organizer, Question, QuestionAnswer, SubEvent,
|
OrderRefund, Organizer, Question, QuestionAnswer, SubEvent,
|
||||||
@@ -97,14 +100,13 @@ class OrderFilterForm(FilterForm):
|
|||||||
label=_('Order status'),
|
label=_('Order status'),
|
||||||
choices=(
|
choices=(
|
||||||
('', _('All orders')),
|
('', _('All orders')),
|
||||||
('p', _('Paid')),
|
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||||
('n', _('Pending')),
|
(Order.STATUS_PENDING, _('Pending')),
|
||||||
('o', _('Pending (overdue)')),
|
('o', _('Pending (overdue)')),
|
||||||
('np', _('Pending or paid')),
|
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||||
('e', _('Expired')),
|
(Order.STATUS_EXPIRED, _('Expired')),
|
||||||
('ne', _('Pending or expired')),
|
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||||
('c', _('Canceled')),
|
(Order.STATUS_CANCELED, _('Canceled')),
|
||||||
('r', _('Refunded')),
|
|
||||||
),
|
),
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
@@ -116,8 +118,8 @@ class OrderFilterForm(FilterForm):
|
|||||||
u = fdata.get('query')
|
u = fdata.get('query')
|
||||||
|
|
||||||
if "-" in u:
|
if "-" in u:
|
||||||
code = (Q(event__slug__icontains=u.split("-")[0])
|
code = (Q(event__slug__icontains=u.rsplit("-", 1)[0])
|
||||||
& Q(code__icontains=Order.normalize_code(u.split("-")[1])))
|
& Q(code__icontains=Order.normalize_code(u.rsplit("-", 1)[1])))
|
||||||
else:
|
else:
|
||||||
code = Q(code__icontains=Order.normalize_code(u))
|
code = Q(code__icontains=Order.normalize_code(u))
|
||||||
|
|
||||||
@@ -173,7 +175,7 @@ class OrderFilterForm(FilterForm):
|
|||||||
|
|
||||||
class EventOrderFilterForm(OrderFilterForm):
|
class EventOrderFilterForm(OrderFilterForm):
|
||||||
orders = {'code': 'code', 'email': 'email', 'total': 'total',
|
orders = {'code': 'code', 'email': 'email', 'total': 'total',
|
||||||
'datetime': 'datetime', 'status': 'status', 'pcnt': 'pcnt'}
|
'datetime': 'datetime', 'status': 'status'}
|
||||||
|
|
||||||
item = forms.ModelChoiceField(
|
item = forms.ModelChoiceField(
|
||||||
label=_('Products'),
|
label=_('Products'),
|
||||||
@@ -198,18 +200,18 @@ class EventOrderFilterForm(OrderFilterForm):
|
|||||||
label=_('Order status'),
|
label=_('Order status'),
|
||||||
choices=(
|
choices=(
|
||||||
('', _('All orders')),
|
('', _('All orders')),
|
||||||
('p', _('Paid')),
|
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||||
('n', _('Pending')),
|
(Order.STATUS_PENDING, _('Pending')),
|
||||||
('o', _('Pending (overdue)')),
|
('o', _('Pending (overdue)')),
|
||||||
('np', _('Pending or paid')),
|
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||||
('e', _('Expired')),
|
(Order.STATUS_EXPIRED, _('Expired')),
|
||||||
('ne', _('Pending or expired')),
|
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||||
('c', _('Canceled')),
|
(Order.STATUS_CANCELED, _('Canceled')),
|
||||||
('r', _('Refunded')),
|
('cp', _('Canceled (or with paid fee)')),
|
||||||
('pa', _('Approval pending')),
|
('pa', _('Approval pending')),
|
||||||
('overpaid', _('Overpaid')),
|
('overpaid', _('Overpaid')),
|
||||||
('underpaid', _('Underpaid')),
|
('underpaid', _('Underpaid')),
|
||||||
('pendingpaid', _('Pending (but fully paid)'))
|
('pendingpaid', _('Pending (but fully paid)')),
|
||||||
),
|
),
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
@@ -243,10 +245,10 @@ class EventOrderFilterForm(OrderFilterForm):
|
|||||||
qs = super().filter_qs(qs)
|
qs = super().filter_qs(qs)
|
||||||
|
|
||||||
if fdata.get('item'):
|
if fdata.get('item'):
|
||||||
qs = qs.filter(positions__item=fdata.get('item'))
|
qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False).distinct()
|
||||||
|
|
||||||
if fdata.get('subevent'):
|
if fdata.get('subevent'):
|
||||||
qs = qs.filter(positions__subevent=fdata.get('subevent'))
|
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False).distinct()
|
||||||
|
|
||||||
if fdata.get('question') and fdata.get('answer') is not None:
|
if fdata.get('question') and fdata.get('answer') is not None:
|
||||||
q = fdata.get('question')
|
q = fdata.get('question')
|
||||||
@@ -274,16 +276,19 @@ class EventOrderFilterForm(OrderFilterForm):
|
|||||||
qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True)
|
qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True)
|
||||||
|
|
||||||
if fdata.get('status') == 'overpaid':
|
if fdata.get('status') == 'overpaid':
|
||||||
|
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0))
|
Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0))
|
||||||
| Q(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0))
|
| Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0))
|
||||||
)
|
)
|
||||||
elif fdata.get('status') == 'pendingpaid':
|
elif fdata.get('status') == 'pendingpaid':
|
||||||
|
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
||||||
& Q(require_approval=False)
|
& Q(require_approval=False)
|
||||||
)
|
)
|
||||||
elif fdata.get('status') == 'underpaid':
|
elif fdata.get('status') == 'underpaid':
|
||||||
|
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
status=Order.STATUS_PAID,
|
status=Order.STATUS_PAID,
|
||||||
pending_sum_t__gt=0
|
pending_sum_t__gt=0
|
||||||
@@ -293,13 +298,22 @@ class EventOrderFilterForm(OrderFilterForm):
|
|||||||
status=Order.STATUS_PENDING,
|
status=Order.STATUS_PENDING,
|
||||||
require_approval=True
|
require_approval=True
|
||||||
)
|
)
|
||||||
|
elif fdata.get('status') == 'cp':
|
||||||
|
s = OrderPosition.objects.filter(
|
||||||
|
order=OuterRef('pk')
|
||||||
|
)
|
||||||
|
qs = qs.annotate(
|
||||||
|
has_pc=Exists(s)
|
||||||
|
).filter(
|
||||||
|
Q(status=Order.STATUS_PAID, has_pc=False) | Q(status=Order.STATUS_CANCELED)
|
||||||
|
)
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class OrderSearchFilterForm(OrderFilterForm):
|
class OrderSearchFilterForm(OrderFilterForm):
|
||||||
orders = {'code': 'code', 'email': 'email', 'total': 'total',
|
orders = {'code': 'code', 'email': 'email', 'total': 'total',
|
||||||
'datetime': 'datetime', 'status': 'status', 'pcnt': 'pcnt',
|
'datetime': 'datetime', 'status': 'status',
|
||||||
'event': 'event'}
|
'event': 'event'}
|
||||||
|
|
||||||
organizer = forms.ModelChoiceField(
|
organizer = forms.ModelChoiceField(
|
||||||
@@ -355,6 +369,11 @@ class SubEventFilterForm(FilterForm):
|
|||||||
),
|
),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
date = forms.DateField(
|
||||||
|
label=_('Date'),
|
||||||
|
required=False,
|
||||||
|
widget=DatePickerWidget
|
||||||
|
)
|
||||||
weekday = forms.ChoiceField(
|
weekday = forms.ChoiceField(
|
||||||
label=_('Weekday'),
|
label=_('Weekday'),
|
||||||
choices=(
|
choices=(
|
||||||
@@ -378,6 +397,10 @@ class SubEventFilterForm(FilterForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['date'].widget = DatePickerWidget()
|
||||||
|
|
||||||
def filter_qs(self, qs):
|
def filter_qs(self, qs):
|
||||||
fdata = self.cleaned_data
|
fdata = self.cleaned_data
|
||||||
|
|
||||||
@@ -407,6 +430,20 @@ class SubEventFilterForm(FilterForm):
|
|||||||
Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query)
|
Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if fdata.get('date'):
|
||||||
|
date_start = make_aware(datetime.combine(
|
||||||
|
fdata.get('date'),
|
||||||
|
time(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
), get_current_timezone())
|
||||||
|
date_end = make_aware(datetime.combine(
|
||||||
|
fdata.get('date'),
|
||||||
|
time(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
), get_current_timezone())
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(date_to__isnull=True, date_from__gte=date_start, date_from__lte=date_end) |
|
||||||
|
Q(date_to__isnull=False, date_from__lte=date_end, date_to__gte=date_start)
|
||||||
|
)
|
||||||
|
|
||||||
if fdata.get('ordering'):
|
if fdata.get('ordering'):
|
||||||
qs = qs.order_by(self.get_order_by())
|
qs = qs.order_by(self.get_order_by())
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from django.utils.translation import (
|
|||||||
)
|
)
|
||||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||||
|
|
||||||
|
from pretix.base.channels import get_all_sales_channels
|
||||||
from pretix.base.forms import I18nFormSet, I18nModelForm
|
from pretix.base.forms import I18nFormSet, I18nModelForm
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
||||||
@@ -226,6 +227,10 @@ class ItemCreateForm(I18nModelForm):
|
|||||||
self.instance.checkin_attention = self.cleaned_data['copy_from'].checkin_attention
|
self.instance.checkin_attention = self.cleaned_data['copy_from'].checkin_attention
|
||||||
self.instance.free_price = self.cleaned_data['copy_from'].free_price
|
self.instance.free_price = self.cleaned_data['copy_from'].free_price
|
||||||
self.instance.original_price = self.cleaned_data['copy_from'].original_price
|
self.instance.original_price = self.cleaned_data['copy_from'].original_price
|
||||||
|
self.instance.sales_channels = self.cleaned_data['copy_from'].sales_channels
|
||||||
|
else:
|
||||||
|
# Add to all sales channels by default
|
||||||
|
self.instance.sales_channels = [k for k in get_all_sales_channels().keys()]
|
||||||
|
|
||||||
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
|
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
|
||||||
instance = super().save(*args, **kwargs)
|
instance = super().save(*args, **kwargs)
|
||||||
@@ -292,6 +297,16 @@ class ItemCreateForm(I18nModelForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TicketNullBooleanSelect(forms.NullBooleanSelect):
|
||||||
|
def __init__(self, attrs=None):
|
||||||
|
choices = (
|
||||||
|
('1', _('Choose automatically depending on event settings')),
|
||||||
|
('2', _('Yes, if ticket generation is enabled in general')),
|
||||||
|
('3', _('Never')),
|
||||||
|
)
|
||||||
|
super(forms.NullBooleanSelect, self).__init__(attrs, choices)
|
||||||
|
|
||||||
|
|
||||||
class ItemUpdateForm(I18nModelForm):
|
class ItemUpdateForm(I18nModelForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -302,6 +317,13 @@ class ItemUpdateForm(I18nModelForm):
|
|||||||
'over 65. This ticket includes access to all parts of the event, except the VIP '
|
'over 65. This ticket includes access to all parts of the event, except the VIP '
|
||||||
'area.'
|
'area.'
|
||||||
)
|
)
|
||||||
|
self.fields['sales_channels'] = forms.MultipleChoiceField(
|
||||||
|
label=_('Sales channels'),
|
||||||
|
choices=(
|
||||||
|
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
|
||||||
|
),
|
||||||
|
widget=forms.CheckboxSelectMultiple
|
||||||
|
)
|
||||||
change_decimal_field(self.fields['default_price'], self.event.currency)
|
change_decimal_field(self.fields['default_price'], self.event.currency)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -312,6 +334,7 @@ class ItemUpdateForm(I18nModelForm):
|
|||||||
'name',
|
'name',
|
||||||
'internal_name',
|
'internal_name',
|
||||||
'active',
|
'active',
|
||||||
|
'sales_channels',
|
||||||
'admission',
|
'admission',
|
||||||
'description',
|
'description',
|
||||||
'picture',
|
'picture',
|
||||||
@@ -327,6 +350,7 @@ class ItemUpdateForm(I18nModelForm):
|
|||||||
'max_per_order',
|
'max_per_order',
|
||||||
'min_per_order',
|
'min_per_order',
|
||||||
'checkin_attention',
|
'checkin_attention',
|
||||||
|
'generate_tickets',
|
||||||
'original_price'
|
'original_price'
|
||||||
]
|
]
|
||||||
field_classes = {
|
field_classes = {
|
||||||
@@ -336,6 +360,7 @@ class ItemUpdateForm(I18nModelForm):
|
|||||||
widgets = {
|
widgets = {
|
||||||
'available_from': SplitDateTimePickerWidget(),
|
'available_from': SplitDateTimePickerWidget(),
|
||||||
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
|
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
|
||||||
|
'generate_tickets': TicketNullBooleanSelect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django import forms
|
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
|
||||||
@@ -49,7 +51,7 @@ class ExtendForm(I18nModelForm):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class MarkPaidForm(forms.Form):
|
class ConfirmPaymentForm(forms.Form):
|
||||||
force = forms.BooleanField(
|
force = forms.BooleanField(
|
||||||
label=_('Overbook quota and ignore late payment'),
|
label=_('Overbook quota and ignore late payment'),
|
||||||
help_text=_('If you check this box, this operation will be performed even if it leads to an overbooked quota '
|
help_text=_('If you check this box, this operation will be performed even if it leads to an overbooked quota '
|
||||||
@@ -75,6 +77,54 @@ class MarkPaidForm(forms.Form):
|
|||||||
del self.fields['force']
|
del self.fields['force']
|
||||||
|
|
||||||
|
|
||||||
|
class CancelForm(ConfirmPaymentForm):
|
||||||
|
send_email = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_('Notify user by e-mail'),
|
||||||
|
initial=True
|
||||||
|
)
|
||||||
|
cancellation_fee = forms.DecimalField(
|
||||||
|
required=False,
|
||||||
|
max_digits=10, decimal_places=2,
|
||||||
|
localize=True,
|
||||||
|
label=_('Keep a cancellation fee of'),
|
||||||
|
help_text=_('If you keep a fee, all positions within this order will be canceled and the order will be reduced '
|
||||||
|
'to a paid cancellation fee. Payment and shipping fees will be canceled as well, so include them '
|
||||||
|
'in your cancellation fee if you want to keep them. Please always enter a gross value, '
|
||||||
|
'tax will be calculated automatically.'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
prs = self.instance.payment_refund_sum
|
||||||
|
if prs > 0:
|
||||||
|
change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency)
|
||||||
|
self.fields['cancellation_fee'].initial = Decimal('0.00')
|
||||||
|
self.fields['cancellation_fee'].max_value = prs
|
||||||
|
else:
|
||||||
|
del self.fields['cancellation_fee']
|
||||||
|
|
||||||
|
def clean_cancellation_fee(self):
|
||||||
|
val = self.cleaned_data['cancellation_fee']
|
||||||
|
if val > self.instance.payment_refund_sum:
|
||||||
|
raise ValidationError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
class MarkPaidForm(ConfirmPaymentForm):
|
||||||
|
amount = forms.DecimalField(
|
||||||
|
required=True,
|
||||||
|
max_digits=10, decimal_places=2,
|
||||||
|
localize=True,
|
||||||
|
label=_('Payment amount'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
change_decimal_field(self.fields['amount'], self.instance.event.currency)
|
||||||
|
self.fields['amount'].initial = max(Decimal('0.00'), self.instance.pending_sum)
|
||||||
|
|
||||||
|
|
||||||
class ExporterForm(forms.Form):
|
class ExporterForm(forms.Form):
|
||||||
def clean(self):
|
def clean(self):
|
||||||
data = super().clean()
|
data = super().clean()
|
||||||
@@ -127,6 +177,10 @@ class OtherOperationsForm(forms.Form):
|
|||||||
'Send an email to the customer notifying that their order has been changed.'
|
'Send an email to the customer notifying that their order has been changed.'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
ignore_quotas = forms.BooleanField(
|
||||||
|
label=_('Allow to overbook quotas when performing this operation'),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
kwargs.pop('order')
|
kwargs.pop('order')
|
||||||
@@ -236,6 +290,7 @@ class OrderPositionChangeForm(forms.Form):
|
|||||||
('secret', 'Regenerate secret'),
|
('secret', 'Regenerate secret'),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
change_product_keep_price = forms.BooleanField(required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
instance = kwargs.pop('instance')
|
instance = kwargs.pop('instance')
|
||||||
@@ -351,8 +406,7 @@ class OrderRefundForm(forms.Form):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=forms.RadioSelect,
|
widget=forms.RadioSelect,
|
||||||
choices=(
|
choices=(
|
||||||
('mark_refunded', _('Mark the complete order as refunded. The order will be canceled and all tickets will '
|
('mark_refunded', _('Cancel the order. All tickets will no longer work. This can not be reverted.')),
|
||||||
'no longer work. This can not be reverted.')),
|
|
||||||
('mark_pending', _('Mark the order as pending and allow the user to pay the open amount with another '
|
('mark_pending', _('Mark the order as pending and allow the user to pay the open amount with another '
|
||||||
'payment method.')),
|
'payment method.')),
|
||||||
('do_nothing', _('Do nothing and keep the order as it is.')),
|
('do_nothing', _('Do nothing and keep the order as it is.')),
|
||||||
@@ -375,7 +429,7 @@ class OrderRefundForm(forms.Form):
|
|||||||
self.order = kwargs.pop('order')
|
self.order = kwargs.pop('order')
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
change_decimal_field(self.fields['partial_amount'], self.order.event.currency)
|
change_decimal_field(self.fields['partial_amount'], self.order.event.currency)
|
||||||
if self.order.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
|
if self.order.status == Order.STATUS_CANCELED:
|
||||||
del self.fields['action']
|
del self.fields['action']
|
||||||
|
|
||||||
def clean_partial_amount(self):
|
def clean_partial_amount(self):
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django import forms
|
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
|
||||||
@@ -80,6 +82,19 @@ class OrganizerUpdateForm(OrganizerForm):
|
|||||||
help_text=_('You need to configure the custom domain in the webserver beforehand.')
|
help_text=_('You need to configure the custom domain in the webserver beforehand.')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def clean_domain(self):
|
||||||
|
d = self.cleaned_data['domain']
|
||||||
|
if d:
|
||||||
|
if d == urlparse(settings.SITE_URL).hostname:
|
||||||
|
raise ValidationError(
|
||||||
|
_('You cannot choose the base domain of this installation.')
|
||||||
|
)
|
||||||
|
if KnownDomain.objects.filter(domainname=d).exclude(organizer=self.instance.pk).exists():
|
||||||
|
raise ValidationError(
|
||||||
|
_('This domain is already in use for a different organizer.')
|
||||||
|
)
|
||||||
|
return d
|
||||||
|
|
||||||
def clean_slug(self):
|
def clean_slug(self):
|
||||||
if self.change_slug:
|
if self.change_slug:
|
||||||
return self.cleaned_data['slug']
|
return self.cleaned_data['slug']
|
||||||
@@ -178,7 +193,28 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
|||||||
required=False,
|
required=False,
|
||||||
validators=[
|
validators=[
|
||||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.'))
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||||
|
)
|
||||||
|
theme_color_success = forms.CharField(
|
||||||
|
label=_("Accent color for success"),
|
||||||
|
help_text=_("We strongly suggest to use a shade of green."),
|
||||||
|
required=False,
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
],
|
||||||
|
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||||
|
)
|
||||||
|
theme_color_danger = forms.CharField(
|
||||||
|
label=_("Accent color for errors"),
|
||||||
|
help_text=_("We strongly suggest to use a shade of red."),
|
||||||
|
required=False,
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||||
|
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||||
|
|
||||||
],
|
],
|
||||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||||
)
|
)
|
||||||
@@ -202,6 +238,13 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
|||||||
('calendar', _('Calendar'))
|
('calendar', _('Calendar'))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
event_list_availability = forms.BooleanField(
|
||||||
|
label=_('Show availability in event overviews'),
|
||||||
|
help_text=_('If checked, the list of events will show if events are sold out. This might '
|
||||||
|
'make for longer page loading times if you have lots of events and the shown status might be out '
|
||||||
|
'of date for up to two minutes.'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
organizer_link_back = forms.BooleanField(
|
organizer_link_back = forms.BooleanField(
|
||||||
label=_('Link back to organizer overview on all event pages'),
|
label=_('Link back to organizer overview on all event pages'),
|
||||||
required=False
|
required=False
|
||||||
@@ -219,6 +262,13 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
|||||||
],
|
],
|
||||||
help_text=_('Only respected by modern browsers.')
|
help_text=_('Only respected by modern browsers.')
|
||||||
)
|
)
|
||||||
|
favicon = ExtFileField(
|
||||||
|
label=_('Favicon'),
|
||||||
|
ext_whitelist=(".ico", ".png", ".jpg", ".gif", ".jpeg"),
|
||||||
|
required=False,
|
||||||
|
help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. '
|
||||||
|
'We recommend a size of at least 200x200px to accomodate most devices.')
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.forms import formset_factory
|
from django.forms import formset_factory
|
||||||
|
from django.utils.dates import MONTHS, WEEKDAYS
|
||||||
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 pgettext_lazy, ugettext_lazy as _
|
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||||
@@ -276,13 +277,13 @@ class RRuleForm(forms.Form):
|
|||||||
)
|
)
|
||||||
yearly_byweekday = forms.ChoiceField(
|
yearly_byweekday = forms.ChoiceField(
|
||||||
choices=[
|
choices=[
|
||||||
('MO', _('Monday')),
|
('MO', WEEKDAYS[0]),
|
||||||
('TU', _('Tuesday')),
|
('TU', WEEKDAYS[1]),
|
||||||
('WE', _('Wednesday')),
|
('WE', WEEKDAYS[2]),
|
||||||
('TH', _('Thursday')),
|
('TH', WEEKDAYS[3]),
|
||||||
('FR', _('Friday')),
|
('FR', WEEKDAYS[4]),
|
||||||
('SA', _('Saturday')),
|
('SA', WEEKDAYS[5]),
|
||||||
('SU', _('Sunday')),
|
('SU', WEEKDAYS[6]),
|
||||||
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
||||||
('MO,TU,WE,TH,FR', _('Weekday')),
|
('MO,TU,WE,TH,FR', _('Weekday')),
|
||||||
('SA,SU', _('Weekend day')),
|
('SA,SU', _('Weekend day')),
|
||||||
@@ -291,18 +292,7 @@ class RRuleForm(forms.Form):
|
|||||||
)
|
)
|
||||||
yearly_bymonth = forms.ChoiceField(
|
yearly_bymonth = forms.ChoiceField(
|
||||||
choices=[
|
choices=[
|
||||||
('1', _('January')),
|
(str(i), MONTHS[i]) for i in range(1, 13)
|
||||||
('2', _('February')),
|
|
||||||
('3', _('March')),
|
|
||||||
('4', _('April')),
|
|
||||||
('5', _('May')),
|
|
||||||
('6', _('June')),
|
|
||||||
('7', _('July')),
|
|
||||||
('8', _('August')),
|
|
||||||
('9', _('September')),
|
|
||||||
('10', _('October')),
|
|
||||||
('11', _('November')),
|
|
||||||
('12', _('December')),
|
|
||||||
],
|
],
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@@ -326,13 +316,13 @@ class RRuleForm(forms.Form):
|
|||||||
)
|
)
|
||||||
monthly_byweekday = forms.ChoiceField(
|
monthly_byweekday = forms.ChoiceField(
|
||||||
choices=[
|
choices=[
|
||||||
('MO', _('Monday')),
|
('MO', WEEKDAYS[0]),
|
||||||
('TU', _('Tuesday')),
|
('TU', WEEKDAYS[1]),
|
||||||
('WE', _('Wednesday')),
|
('WE', WEEKDAYS[2]),
|
||||||
('TH', _('Thursday')),
|
('TH', WEEKDAYS[3]),
|
||||||
('FR', _('Friday')),
|
('FR', WEEKDAYS[4]),
|
||||||
('SA', _('Saturday')),
|
('SA', WEEKDAYS[5]),
|
||||||
('SU', _('Sunday')),
|
('SU', WEEKDAYS[6]),
|
||||||
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
||||||
('MO,TU,WE,TH,FR', _('Weekday')),
|
('MO,TU,WE,TH,FR', _('Weekday')),
|
||||||
('SA,SU', _('Weekend day')),
|
('SA,SU', _('Weekend day')),
|
||||||
@@ -342,13 +332,13 @@ class RRuleForm(forms.Form):
|
|||||||
|
|
||||||
weekly_byweekday = forms.MultipleChoiceField(
|
weekly_byweekday = forms.MultipleChoiceField(
|
||||||
choices=[
|
choices=[
|
||||||
('MO', _('Monday')),
|
('MO', WEEKDAYS[0]),
|
||||||
('TU', _('Tuesday')),
|
('TU', WEEKDAYS[1]),
|
||||||
('WE', _('Wednesday')),
|
('WE', WEEKDAYS[2]),
|
||||||
('TH', _('Thursday')),
|
('TH', WEEKDAYS[3]),
|
||||||
('FR', _('Friday')),
|
('FR', WEEKDAYS[4]),
|
||||||
('SA', _('Saturday')),
|
('SA', WEEKDAYS[5]),
|
||||||
('SU', _('Sunday')),
|
('SU', WEEKDAYS[6]),
|
||||||
],
|
],
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.CheckboxSelectMultiple
|
widget=forms.CheckboxSelectMultiple
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class UserEditForm(forms.ModelForm):
|
|||||||
|
|
||||||
def clean_email(self):
|
def clean_email(self):
|
||||||
email = self.cleaned_data['email']
|
email = self.cleaned_data['email']
|
||||||
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
|
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['duplicate_identifier'],
|
self.error_messages['duplicate_identifier'],
|
||||||
code='duplicate_identifier',
|
code='duplicate_identifier',
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import bleach
|
|||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
import pytz
|
import pytz
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.html import escape
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
|
|
||||||
@@ -60,7 +63,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}) removed.').format(
|
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) canceled.').format(
|
||||||
posid=data.get('positionid', '?'),
|
posid=data.get('positionid', '?'),
|
||||||
old_item=old_item,
|
old_item=old_item,
|
||||||
old_price=money_filter(Decimal(data['old_price']), event.currency),
|
old_price=money_filter(Decimal(data['old_price']), event.currency),
|
||||||
@@ -91,12 +94,17 @@ 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}) split into new order: {order}').format(
|
url = reverse('control:event.order', kwargs={
|
||||||
old_item=old_item,
|
'event': event.slug,
|
||||||
|
'organizer': event.organizer.slug,
|
||||||
|
'code': data['new_order']
|
||||||
|
})
|
||||||
|
return mark_safe(escape(text) + ' ' + _('Position #{posid} ({old_item}, {old_price}) split into new order: {order}').format(
|
||||||
|
old_item=escape(old_item),
|
||||||
posid=data.get('positionid', '?'),
|
posid=data.get('positionid', '?'),
|
||||||
order=data['new_order'],
|
order='<a href="{}">{}</a>'.format(url, data['new_order']),
|
||||||
old_price=money_filter(Decimal(data['old_price']), event.currency),
|
old_price=money_filter(Decimal(data['old_price']), event.currency),
|
||||||
)
|
))
|
||||||
elif logentry.action_type == 'pretix.event.order.changed.split_from':
|
elif logentry.action_type == 'pretix.event.order.changed.split_from':
|
||||||
return _('This order has been created by splitting the order {order}').format(
|
return _('This order has been created by splitting the order {order}').format(
|
||||||
order=data['original_order'],
|
order=data['original_order'],
|
||||||
@@ -165,6 +173,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
|||||||
'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.placed.require_approval': _('The order requires approval before it can continue to be processed.'),
|
||||||
'pretix.event.order.approved': _('The order has been approved.'),
|
'pretix.event.order.approved': _('The order has been approved.'),
|
||||||
'pretix.event.order.denied': _('The order has been denied.'),
|
'pretix.event.order.denied': _('The order has been denied.'),
|
||||||
'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
|
'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
|
||||||
@@ -178,6 +187,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
|||||||
'toggled.'),
|
'toggled.'),
|
||||||
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
|
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
|
||||||
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
|
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
|
||||||
|
'pretix.event.order.email.error': _('Sending of an email has failed.'),
|
||||||
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
|
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
|
||||||
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '
|
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '
|
||||||
'is available for download.'),
|
'is available for download.'),
|
||||||
@@ -202,8 +212,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
|||||||
'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'),
|
'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'),
|
||||||
'pretix.event.order.refund.created': _('Refund {local_id} has been created.'),
|
'pretix.event.order.refund.created': _('Refund {local_id} has been created.'),
|
||||||
'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'),
|
'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'),
|
||||||
|
'pretix.event.order.refund.requested': _('The customer requested you to issue a refund.'),
|
||||||
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
|
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
|
||||||
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
|
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
|
||||||
|
'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'),
|
||||||
'pretix.control.auth.user.created': _('The user has been created.'),
|
'pretix.control.auth.user.created': _('The user has been created.'),
|
||||||
'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.'),
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
from django.http import HttpRequest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||||
|
|
||||||
|
from pretix.control.signals import (
|
||||||
|
nav_event, nav_event_settings, nav_global, nav_organizer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_navigation(request: HttpRequest):
|
||||||
|
url = request.resolver_match
|
||||||
|
if not url:
|
||||||
|
return []
|
||||||
|
nav = [
|
||||||
|
{
|
||||||
|
'label': _('Dashboard'),
|
||||||
|
'url': reverse('control:event.index', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': (url.url_name == 'event.index'),
|
||||||
|
'icon': 'dashboard',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if 'can_change_event_settings' in request.eventpermset:
|
||||||
|
event_settings = [
|
||||||
|
{
|
||||||
|
'label': _('General'),
|
||||||
|
'url': reverse('control:event.settings', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'event.settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Payment'),
|
||||||
|
'url': reverse('control:event.settings.payment', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'event.settings.payment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Plugins'),
|
||||||
|
'url': reverse('control:event.settings.plugins', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'event.settings.plugins',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Display'),
|
||||||
|
'url': reverse('control:event.settings.display', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'event.settings.display',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Tickets'),
|
||||||
|
'url': reverse('control:event.settings.tickets', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'event.settings.tickets',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('E-mail'),
|
||||||
|
'url': reverse('control:event.settings.mail', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'event.settings.mail',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Tax rules'),
|
||||||
|
'url': reverse('control:event.settings.tax', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'event.settings.tax',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Invoicing'),
|
||||||
|
'url': reverse('control:event.settings.invoice', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'event.settings.invoice',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': pgettext_lazy('action', 'Cancellation'),
|
||||||
|
'url': reverse('control:event.settings.cancel', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'event.settings.cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Widget'),
|
||||||
|
'url': reverse('control:event.settings.widget', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'event.settings.widget',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
event_settings += sorted(
|
||||||
|
sum((list(a[1]) for a in nav_event_settings.send(request.event, request=request)), []),
|
||||||
|
key=lambda r: r['label']
|
||||||
|
)
|
||||||
|
nav.append({
|
||||||
|
'label': _('Settings'),
|
||||||
|
'url': reverse('control:event.settings', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': False,
|
||||||
|
'icon': 'wrench',
|
||||||
|
'children': event_settings
|
||||||
|
})
|
||||||
|
if request.event.has_subevents:
|
||||||
|
nav.append({
|
||||||
|
'label': pgettext_lazy('subevent', 'Dates'),
|
||||||
|
'url': reverse('control:event.subevents', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': ('event.subevent' in url.url_name),
|
||||||
|
'icon': 'calendar',
|
||||||
|
})
|
||||||
|
|
||||||
|
if 'can_change_items' in request.eventpermset:
|
||||||
|
nav.append({
|
||||||
|
'label': _('Products'),
|
||||||
|
'url': reverse('control:event.items', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': False,
|
||||||
|
'icon': 'ticket',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'label': _('Products'),
|
||||||
|
'url': reverse('control:event.items', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name in (
|
||||||
|
'event.item', 'event.items.add', 'event.items') or "event.item." in url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Quotas'),
|
||||||
|
'url': reverse('control:event.items.quotas', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': 'event.items.quota' in url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Categories'),
|
||||||
|
'url': reverse('control:event.items.categories', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': 'event.items.categories' in url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Questions'),
|
||||||
|
'url': reverse('control:event.items.questions', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': 'event.items.questions' in url.url_name,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if 'can_view_orders' in request.eventpermset:
|
||||||
|
nav.append({
|
||||||
|
'label': _('Orders'),
|
||||||
|
'url': reverse('control:event.orders', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': False,
|
||||||
|
'icon': 'shopping-cart',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'label': _('All orders'),
|
||||||
|
'url': reverse('control:event.orders', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name in ('event.orders', 'event.order') or "event.order." in url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Overview'),
|
||||||
|
'url': reverse('control:event.orders.overview', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': 'event.orders.overview' in url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Refunds'),
|
||||||
|
'url': reverse('control:event.orders.refunds', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': 'event.orders.refunds' in url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Export'),
|
||||||
|
'url': reverse('control:event.orders.export', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': 'event.orders.export' in url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Waiting list'),
|
||||||
|
'url': reverse('control:event.orders.waitinglist', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': 'event.orders.waitinglist' in url.url_name,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if 'can_view_vouchers' in request.eventpermset:
|
||||||
|
nav.append({
|
||||||
|
'label': _('Vouchers'),
|
||||||
|
'url': reverse('control:event.vouchers', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': False,
|
||||||
|
'icon': 'tags',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'label': _('All vouchers'),
|
||||||
|
'url': reverse('control:event.vouchers', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': url.url_name != 'event.vouchers.tags' and "event.vouchers" in url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Tags'),
|
||||||
|
'url': reverse('control:event.vouchers.tags', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': 'event.vouchers.tags' in url.url_name,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if 'can_view_orders' in request.eventpermset:
|
||||||
|
nav.append({
|
||||||
|
'label': pgettext_lazy('navigation', 'Check-in'),
|
||||||
|
'url': reverse('control:event.orders.checkinlists', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': False,
|
||||||
|
'icon': 'check-square-o',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'label': _('Check-in lists'),
|
||||||
|
'url': reverse('control:event.orders.checkinlists', kwargs={
|
||||||
|
'event': request.event.slug,
|
||||||
|
'organizer': request.event.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': 'event.orders.checkin' in url.url_name,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
merge_in(nav, sorted(
|
||||||
|
sum((list(a[1]) for a in nav_event.send(request.event, request=request)), []),
|
||||||
|
key=lambda r: r['label']
|
||||||
|
))
|
||||||
|
|
||||||
|
return nav
|
||||||
|
|
||||||
|
|
||||||
|
def get_global_navigation(request):
|
||||||
|
url = request.resolver_match
|
||||||
|
if not url:
|
||||||
|
return []
|
||||||
|
has_staff_session = request.user.has_active_staff_session(request.session.session_key)
|
||||||
|
nav = [
|
||||||
|
{
|
||||||
|
'label': _('Dashboard'),
|
||||||
|
'url': reverse('control:index'),
|
||||||
|
'active': (url.url_name == 'index'),
|
||||||
|
'icon': 'dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Events'),
|
||||||
|
'url': reverse('control:events'),
|
||||||
|
'active': 'events' in url.url_name,
|
||||||
|
'icon': 'calendar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Organizers'),
|
||||||
|
'url': reverse('control:organizers'),
|
||||||
|
'active': 'organizers' in url.url_name,
|
||||||
|
'icon': 'group',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Order search'),
|
||||||
|
'url': reverse('control:search.orders'),
|
||||||
|
'active': 'search.orders' in url.url_name,
|
||||||
|
'icon': 'search',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('User settings'),
|
||||||
|
'url': reverse('control:user.settings'),
|
||||||
|
'active': False,
|
||||||
|
'icon': 'user',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'label': _('General'),
|
||||||
|
'url': reverse('control:user.settings'),
|
||||||
|
'active': 'user.settings' == url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Notifications'),
|
||||||
|
'url': reverse('control:user.settings.notifications'),
|
||||||
|
'active': 'user.settings.notifications' == url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('2FA'),
|
||||||
|
'url': reverse('control:user.settings.2fa'),
|
||||||
|
'active': 'user.settings.2fa' in url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Authorized apps'),
|
||||||
|
'url': reverse('control:user.settings.oauth.list'),
|
||||||
|
'active': 'user.settings.oauth' in url.url_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Account history'),
|
||||||
|
'url': reverse('control:user.settings.history'),
|
||||||
|
'active': 'user.settings.history' in url.url_name,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if has_staff_session:
|
||||||
|
nav.append({
|
||||||
|
'label': _('Users'),
|
||||||
|
'url': reverse('control:users'),
|
||||||
|
'active': False,
|
||||||
|
'icon': 'user',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'label': _('All users'),
|
||||||
|
'url': reverse('control:users'),
|
||||||
|
'active': ('users' in url.url_name),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Admin sessions'),
|
||||||
|
'url': reverse('control:user.sudo.list'),
|
||||||
|
'active': ('sudo' in url.url_name),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
nav.append({
|
||||||
|
'label': _('Global settings'),
|
||||||
|
'url': reverse('control:global.settings'),
|
||||||
|
'active': False,
|
||||||
|
'icon': 'wrench',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'label': _('Settings'),
|
||||||
|
'url': reverse('control:global.settings'),
|
||||||
|
'active': (url.url_name == 'global.settings'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Update check'),
|
||||||
|
'url': reverse('control:global.update'),
|
||||||
|
'active': (url.url_name == 'global.update'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
merge_in(nav, sorted(
|
||||||
|
sum((list(a[1]) for a in nav_global.send(request, request=request)), []),
|
||||||
|
key=lambda r: r['label']
|
||||||
|
))
|
||||||
|
return nav
|
||||||
|
|
||||||
|
|
||||||
|
def get_organizer_navigation(request):
|
||||||
|
url = request.resolver_match
|
||||||
|
if not url:
|
||||||
|
return []
|
||||||
|
nav = [
|
||||||
|
{
|
||||||
|
'label': _('Events'),
|
||||||
|
'url': reverse('control:organizer', kwargs={
|
||||||
|
'organizer': request.organizer.slug
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'organizer',
|
||||||
|
'icon': 'calendar',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if 'can_change_organizer_settings' in request.orgapermset:
|
||||||
|
nav.append({
|
||||||
|
'label': _('Settings'),
|
||||||
|
'url': reverse('control:organizer.edit', kwargs={
|
||||||
|
'organizer': request.organizer.slug
|
||||||
|
}),
|
||||||
|
'icon': 'wrench',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'label': _('General'),
|
||||||
|
'url': reverse('control:organizer.edit', kwargs={
|
||||||
|
'organizer': request.organizer.slug
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'organizer.edit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Display'),
|
||||||
|
'url': reverse('control:organizer.display', kwargs={
|
||||||
|
'organizer': request.organizer.slug
|
||||||
|
}),
|
||||||
|
'active': url.url_name == 'organizer.display',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if 'can_change_teams' in request.orgapermset:
|
||||||
|
nav.append({
|
||||||
|
'label': _('Teams'),
|
||||||
|
'url': reverse('control:organizer.teams', kwargs={
|
||||||
|
'organizer': request.organizer.slug
|
||||||
|
}),
|
||||||
|
'active': 'organizer.team' in url.url_name,
|
||||||
|
'icon': 'group',
|
||||||
|
})
|
||||||
|
if 'can_change_organizer_settings' in request.orgapermset:
|
||||||
|
nav.append({
|
||||||
|
'label': _('Devices'),
|
||||||
|
'url': reverse('control:organizer.devices', kwargs={
|
||||||
|
'organizer': request.organizer.slug
|
||||||
|
}),
|
||||||
|
'active': 'organizer.device' in url.url_name,
|
||||||
|
'icon': 'tablet',
|
||||||
|
})
|
||||||
|
nav.append({
|
||||||
|
'label': _('Webhooks'),
|
||||||
|
'url': reverse('control:organizer.webhooks', kwargs={
|
||||||
|
'organizer': request.organizer.slug
|
||||||
|
}),
|
||||||
|
'active': 'organizer.webhook' in url.url_name,
|
||||||
|
'icon': 'bolt',
|
||||||
|
})
|
||||||
|
|
||||||
|
merge_in(nav, sorted(
|
||||||
|
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
|
||||||
|
[]),
|
||||||
|
key=lambda r: r['label']
|
||||||
|
))
|
||||||
|
return nav
|
||||||
|
|
||||||
|
|
||||||
|
def merge_in(nav, newnav):
|
||||||
|
for item in newnav:
|
||||||
|
if 'parent' in item:
|
||||||
|
parents = [n for n in nav if n['url'] == item['parent']]
|
||||||
|
if parents:
|
||||||
|
parents[0]['children'].append(item)
|
||||||
|
else:
|
||||||
|
nav.append(item)
|
||||||
@@ -201,6 +201,16 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
|
|||||||
Additionally, the argument ``order`` and ``request`` are available.
|
Additionally, the argument ``order`` and ``request`` are available.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
order_position_buttons = EventPluginSignal(
|
||||||
|
providing_args=["order", "position", "request"]
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
This signal is sent out to display additional buttons for a single position of an order.
|
||||||
|
|
||||||
|
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
|
Additionally, the argument ``order`` and ``request`` are available.
|
||||||
|
"""
|
||||||
|
|
||||||
nav_event_settings = EventPluginSignal(
|
nav_event_settings = EventPluginSignal(
|
||||||
providing_args=['request']
|
providing_args=['request']
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{% load compress %}
|
{% load compress %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load staticfiles %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>{{ settings.PRETIX_INSTANCE_NAME }}</title>
|
<title>{{ django_settings.PRETIX_INSTANCE_NAME }}</title>
|
||||||
{% compress css %}
|
{% compress css %}
|
||||||
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixcontrol/scss/auth.scss" %}"/>
|
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixcontrol/scss/auth.scss" %}"/>
|
||||||
{% endcompress %}
|
{% endcompress %}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@
|
|||||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quicksetup.js" %}"></script>
|
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quicksetup.js" %}"></script>
|
||||||
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
|
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
|
||||||
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
|
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
|
||||||
<script type="text/javascript" src="{% static "pretixbase/js/asyncdownload.js" %}"></script>
|
|
||||||
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
|
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
|
||||||
<script type="text/javascript" src="{% static "fileupload/jquery.ui.widget.js" %}"></script>
|
<script type="text/javascript" src="{% static "fileupload/jquery.ui.widget.js" %}"></script>
|
||||||
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
|
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
|
||||||
@@ -65,6 +64,7 @@
|
|||||||
<meta name="msapplication-TileColor" content="#3b1c4a">
|
<meta name="msapplication-TileColor" content="#3b1c4a">
|
||||||
<meta name="msapplication-config" content="{% url "presale:browserconfig.xml" %}">
|
<meta name="msapplication-config" content="{% url "presale:browserconfig.xml" %}">
|
||||||
<meta name="theme-color" content="#3b1c4a">
|
<meta name="theme-color" content="#3b1c4a">
|
||||||
|
<meta name="referrer" content="origin">
|
||||||
|
|
||||||
{% block custom_header %}{% endblock %}
|
{% block custom_header %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
@@ -82,10 +82,6 @@
|
|||||||
<span class="icon-bar"></span>
|
<span class="icon-bar"></span>
|
||||||
<span class="icon-bar"></span>
|
<span class="icon-bar"></span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="navbar-toggle navbar-events"
|
|
||||||
data-toggle="collapse" data-target=".navbar-events-collapse">
|
|
||||||
<i class="fa fa-calendar"></i><span class="caret"></span>
|
|
||||||
</button>
|
|
||||||
{% if request.event %}
|
{% if request.event %}
|
||||||
{% if has_domain and not request.event.live %}
|
{% if has_domain and not request.event.live %}
|
||||||
<form action="{% eventurl request.event "presale:event.auth" %}" method="post"
|
<form action="{% eventurl request.event "presale:event.auth" %}" method="post"
|
||||||
@@ -108,24 +104,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav navbar-nav navbar-top-links navbar-left hidden-xs">
|
<ul class="nav navbar-nav navbar-top-links navbar-left hidden-xs">
|
||||||
<li class="dropdown">
|
|
||||||
<a href="#" class="dropdown-toggle event-dropdown-toggle" data-toggle="dropdown"><i class="fa fa-calendar"></i>
|
|
||||||
<div class="event-indicator">
|
|
||||||
<span class="event-name">{{ request.event }}</span>
|
|
||||||
<span class="event-daterange">{{ request.event.get_date_range_display }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="caret"></span></a>
|
|
||||||
<ul class="dropdown-menu event-dropdown" role="menu" data-event-typeahead
|
|
||||||
data-source="{% url "control:events.typeahead" %}">
|
|
||||||
<li class="query-holder">
|
|
||||||
<div class="form-box">
|
|
||||||
<input type="text" class="form-control"
|
|
||||||
placeholder="{% trans "Search for events" %}"
|
|
||||||
data-typeahead-query>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{% if request.event %}
|
{% if request.event %}
|
||||||
<li>
|
<li>
|
||||||
{% if has_domain and not request.event.live %}
|
{% if has_domain and not request.event.live %}
|
||||||
@@ -209,71 +187,56 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="navbar-default sidebar" role="navigation">
|
<div class="navbar-default sidebar" role="navigation">
|
||||||
<div class="sidebar-nav navbar-events-collapse navbar-collapse hidden-sm hidden-md hidden-lg mobile-event-dropdown">
|
|
||||||
<ul class="nav" data-event-typeahead data-source="{% url "control:events.typeahead" %}">
|
|
||||||
<li class="query-holder">
|
|
||||||
<div class="form-box">
|
|
||||||
<input type="text" class="form-control"
|
|
||||||
placeholder="{% trans "Search for events" %}"
|
|
||||||
data-typeahead-query>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-nav navbar-nav-collapse navbar-collapse">
|
<div class="sidebar-nav navbar-nav-collapse navbar-collapse">
|
||||||
|
<div class="dropdown context-selector">
|
||||||
|
{% if request.event %}
|
||||||
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||||
|
<span class="fa-stack fa-lg">
|
||||||
|
<i class="fa fa-circle fa-stack-2x"></i>
|
||||||
|
<i class="fa fa-calendar fa-stack-1x fa-inverse"></i>
|
||||||
|
</span>
|
||||||
|
<div class="context-indicator">
|
||||||
|
<span class="context-name">{{ request.event }}</span>
|
||||||
|
<span class="context-meta">{{ request.event.get_date_range_display }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="caret"></span></a>
|
||||||
|
{% elif request.organizer %}
|
||||||
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||||
|
<span class="fa-stack fa-lg">
|
||||||
|
<i class="fa fa-circle fa-stack-2x"></i>
|
||||||
|
<i class="fa fa-group fa-stack-1x fa-inverse"></i>
|
||||||
|
</span>
|
||||||
|
<div class="context-indicator">
|
||||||
|
<span class="context-name">{{ request.organizer }}</span>
|
||||||
|
<span class="context-meta">{% trans "Organizer account" %}</span>
|
||||||
|
</div>
|
||||||
|
<span class="caret"></span></a>
|
||||||
|
{% else %}
|
||||||
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||||
|
<span class="fa-stack fa-lg">
|
||||||
|
<i class="fa fa-circle fa-stack-2x"></i>
|
||||||
|
<i class="fa fa-user fa-stack-1x fa-inverse"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="context-indicator">
|
||||||
|
<span class="context-name">{{ request.user }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="caret"></span></a>
|
||||||
|
{% endif %}
|
||||||
|
<ul class="dropdown-menu event-dropdown" role="menu" data-event-typeahead
|
||||||
|
data-source="{% url "control:nav.typeahead" %}">
|
||||||
|
<li class="query-holder">
|
||||||
|
<div class="form-box">
|
||||||
|
<input type="text" class="form-control" id="event-dropdown-field"
|
||||||
|
placeholder="{% trans "Search for events" %}"
|
||||||
|
data-typeahead-query>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<ul class="nav" id="side-menu">
|
<ul class="nav" id="side-menu">
|
||||||
{% block nav %}
|
{% block nav %}
|
||||||
<li>
|
{% for nav in nav_items %}
|
||||||
<a href="{% url 'control:index' %}" {% if url_name == "index" %}class="active"{% endif %}>
|
|
||||||
<i class="fa fa-dashboard fa-fw"></i>
|
|
||||||
{% trans "Dashboard" %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% if staff_session %}
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'control:global.settings' %}"
|
|
||||||
{% if "global.settings" in url_name %}class="active"{% endif %}>
|
|
||||||
<i class="fa fa-wrench fa-fw"></i>
|
|
||||||
{% trans "Global settings" %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'control:events' %}" {% if "events" in url_name %}class="active"{% endif %}>
|
|
||||||
<i class="fa fa-calendar fa-fw"></i>
|
|
||||||
{% trans "Events" %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'control:organizers' %}" {% if "organizer" in url_name %}class="active"{% endif %}>
|
|
||||||
<i class="fa fa-users fa-fw"></i>
|
|
||||||
{% trans "Organizers" %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'control:search.orders' %}"
|
|
||||||
{% if url_name == "search.orders" %}class="active"{% endif %}>
|
|
||||||
<i class="fa fa-search fa-fw"></i>
|
|
||||||
{% trans "Order search" %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% if staff_session %}
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'control:users' %}"
|
|
||||||
{% if "users" in url_name %}class="active"{% endif %}>
|
|
||||||
<i class="fa fa-user fa-fw"></i>
|
|
||||||
{% trans "Users" %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'control:user.sudo.list' %}"
|
|
||||||
{% if "sudo" in url_name %}class="active"{% endif %}>
|
|
||||||
<i class="fa fa-id-card fa-fw"></i>
|
|
||||||
{% trans "Admin sessions" %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% for nav in nav_global %}
|
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
|
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
|
||||||
{% if nav.children %}class="has-children"{% endif %}>
|
{% if nav.children %}class="has-children"{% endif %}>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<span class="fa fa-download"></span>
|
<span class="fa fa-download"></span>
|
||||||
{% trans "PDF" %}
|
{% trans "PDF" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url "control:event.orders.export" event=request.event.slug organizer=request.event.organizer.slug %}?identifier=checkinlistcsv&checkinlistcsv-list={{ checkinlist.pk }}"
|
<a href="{% url "control:event.orders.export" event=request.event.slug organizer=request.event.organizer.slug %}?identifier=checkinlist&checkinlist-list={{ checkinlist.pk }}"
|
||||||
class="btn btn-default" target="_blank">
|
class="btn btn-default" target="_blank">
|
||||||
<span class="fa fa-download"></span>
|
<span class="fa fa-download"></span>
|
||||||
{% trans "CSV" %}
|
{% trans "CSV" %}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user