forked from CGM_Public/pretix_original
Compare commits
165 Commits
guest-hand
...
harmonize-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee97a2c29 | ||
|
|
7b301b6027 | ||
|
|
68430f01a3 | ||
|
|
363c62a6ca | ||
|
|
a7f32b8647 | ||
|
|
8786397910 | ||
|
|
d078a42250 | ||
|
|
f1a73cd440 | ||
|
|
8bba1a2ea6 | ||
|
|
aeb5c52bfe | ||
|
|
a684aca212 | ||
|
|
59d46ddded | ||
|
|
e4e7d50659 | ||
|
|
1d46a96821 | ||
|
|
8b81ef6f43 | ||
|
|
cb734510ac | ||
|
|
b29f5c69ed | ||
|
|
56ce37225c | ||
|
|
afae6fdd45 | ||
|
|
4cad8eae93 | ||
|
|
c5b7ff66b7 | ||
|
|
04e16bbb39 | ||
|
|
eea48af60a | ||
|
|
fc63f60960 | ||
|
|
3b94125471 | ||
|
|
00d901b04b | ||
|
|
a7f9e100d2 | ||
|
|
59f409b1c6 | ||
|
|
e03bebf5ab | ||
|
|
065e6d4024 | ||
|
|
f99e1dd5be | ||
|
|
25949c6c2b | ||
|
|
6fe33077e9 | ||
|
|
29b8ee8408 | ||
|
|
15273ba32e | ||
|
|
6ff5b4431c | ||
|
|
a82ce69633 | ||
|
|
53156a4181 | ||
|
|
30142b013e | ||
|
|
c4bdfe7537 | ||
|
|
0972123614 | ||
|
|
cf71c4ed2b | ||
|
|
31e5d00093 | ||
|
|
0eba0f5e3e | ||
|
|
ce79647289 | ||
|
|
acc34c29f7 | ||
|
|
ee6fbbf648 | ||
|
|
57fa29a0e9 | ||
|
|
5d42dc97c2 | ||
|
|
ddf0d551f3 | ||
|
|
a5570dc475 | ||
|
|
3c1f3a26cf | ||
|
|
8ca128912e | ||
|
|
b9d8429da8 | ||
|
|
034a32b048 | ||
|
|
9eb2d43016 | ||
|
|
f81b7bcf53 | ||
|
|
234f9d43c5 | ||
|
|
7f09b4c903 | ||
|
|
3bc8450d4f | ||
|
|
fdcad926f9 | ||
|
|
433262f6fc | ||
|
|
50596b7543 | ||
|
|
988188b00a | ||
|
|
fdc15a753c | ||
|
|
785cc49a2e | ||
|
|
863fd3065a | ||
|
|
ac361a8f47 | ||
|
|
56d928d5ec | ||
|
|
6c3e745d5d | ||
|
|
b29efb9694 | ||
|
|
5ee1213dbf | ||
|
|
c29dc49819 | ||
|
|
8b74f791f4 | ||
|
|
4d75438a11 | ||
|
|
781002b27e | ||
|
|
f7c0e8c8d0 | ||
|
|
70a3516725 | ||
|
|
3133e18b22 | ||
|
|
3257c59117 | ||
|
|
19d1a8de71 | ||
|
|
0bb5af191b | ||
|
|
8fe56b7278 | ||
|
|
df432b1958 | ||
|
|
54434f07a9 | ||
|
|
0ecbee48ae | ||
|
|
ff2fa43ba1 | ||
|
|
3a1cefbbe7 | ||
|
|
7aa433e9af | ||
|
|
c5a5d13158 | ||
|
|
2e256e30be | ||
|
|
0fbc0c3ffb | ||
|
|
93950d3fac | ||
|
|
e8269ed1bf | ||
|
|
3fa1fbf6e2 | ||
|
|
8114b47c8c | ||
|
|
dcf5e67196 | ||
|
|
bf4569b080 | ||
|
|
95979143d7 | ||
|
|
4c5e77c2ef | ||
|
|
95b4f08aeb | ||
|
|
d6605e668b | ||
|
|
6ee348548f | ||
|
|
fca8e48f6a | ||
|
|
5a295934f7 | ||
|
|
4385b41e8b | ||
|
|
92dacfb966 | ||
|
|
d1acbad181 | ||
|
|
d0676765a4 | ||
|
|
9dd3b12625 | ||
|
|
738301d2af | ||
|
|
f7f29e8a55 | ||
|
|
ad69ec293f | ||
|
|
3443296a28 | ||
|
|
7a69e00d39 | ||
|
|
bddc91d595 | ||
|
|
0c0d8b2c55 | ||
|
|
c018921a18 | ||
|
|
f33aa3fdba | ||
|
|
7b55f85663 | ||
|
|
fb9909ca83 | ||
|
|
35e8bab7a5 | ||
|
|
bf34e73121 | ||
|
|
39e2715f3c | ||
|
|
97d2b015cf | ||
|
|
ca30a07da3 | ||
|
|
81d31ce64c | ||
|
|
0ae66ab7f6 | ||
|
|
cb4af51c01 | ||
|
|
6b44cae607 | ||
|
|
1a4d4029c9 | ||
|
|
3563653d55 | ||
|
|
e4c9afa87a | ||
|
|
6938397a6a | ||
|
|
24e5b593ea | ||
|
|
cd237d4c19 | ||
|
|
9b1d7cc522 | ||
|
|
d07948613a | ||
|
|
eadc1b4812 | ||
|
|
787d4ec06b | ||
|
|
ca1d13421f | ||
|
|
495ae25b9e | ||
|
|
d98accdd2d | ||
|
|
746ced9e93 | ||
|
|
d72bbffc51 | ||
|
|
8503623472 | ||
|
|
4f097e279a | ||
|
|
603225d042 | ||
|
|
e5528f7784 | ||
|
|
2e702b87de | ||
|
|
59730ff501 | ||
|
|
280c24528f | ||
|
|
ff09ed422c | ||
|
|
b3be64b9f3 | ||
|
|
018c3d70e3 | ||
|
|
a2f2d25169 | ||
|
|
4747a4c480 | ||
|
|
ed9a9246e3 | ||
|
|
6e63d34932 | ||
|
|
db06ed132a | ||
|
|
ddbe38ca53 | ||
|
|
d3698b3e2f | ||
|
|
ff828ecc92 | ||
|
|
d0236572f0 | ||
|
|
8ea6f3bc7d |
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
@@ -26,10 +26,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
|
||||
8
.github/workflows/strings.yml
vendored
8
.github/workflows/strings.yml
vendored
@@ -24,10 +24,10 @@ jobs:
|
||||
name: Check gettext syntax
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
@@ -50,10 +50,10 @@ jobs:
|
||||
name: Spellcheck
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
|
||||
12
.github/workflows/style.yml
vendored
12
.github/workflows/style.yml
vendored
@@ -24,10 +24,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
@@ -45,10 +45,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
@@ -66,10 +66,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
- name: Install Dependencies
|
||||
run: pip3 install licenseheaders
|
||||
- name: Run licenseheaders
|
||||
|
||||
13
.github/workflows/tests.yml
vendored
13
.github/workflows/tests.yml
vendored
@@ -24,22 +24,22 @@ jobs:
|
||||
name: Tests
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.9", "3.10"]
|
||||
python-version: ["3.9", "3.10", "3.11"]
|
||||
database: [sqlite, postgres, mysql]
|
||||
exclude:
|
||||
- database: mysql
|
||||
python-version: "3.10"
|
||||
- database: mysql
|
||||
python-version: "3.9"
|
||||
- database: mysql
|
||||
python-version: "3.11"
|
||||
- database: sqlite
|
||||
python-version: "3.7"
|
||||
python-version: "3.9"
|
||||
- database: sqlite
|
||||
python-version: "3.10"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: getong/mariadb-action@v1.1
|
||||
with:
|
||||
mariadb version: '10.4'
|
||||
mariadb version: '10.10'
|
||||
mysql database: 'pretix'
|
||||
mysql root password: ''
|
||||
if: matrix.database == 'mysql'
|
||||
@@ -81,5 +81,6 @@ jobs:
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
file: src/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.10'
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.11'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.9-bullseye
|
||||
FROM python:3.11-bullseye
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -141,7 +141,7 @@ Database settings
|
||||
Example::
|
||||
|
||||
[database]
|
||||
backend=mysql
|
||||
backend=postgresql
|
||||
name=pretix
|
||||
user=pretix
|
||||
password=abcd
|
||||
@@ -149,7 +149,7 @@ Example::
|
||||
port=3306
|
||||
|
||||
``backend``
|
||||
One of ``mysql``, ``sqlite3``, ``oracle`` and ``postgresql``.
|
||||
One of ``mysql`` (deprecated), ``sqlite3`` and ``postgresql``.
|
||||
Default: ``sqlite3``.
|
||||
|
||||
If you use MySQL, be sure to create your database using
|
||||
@@ -163,7 +163,7 @@ Example::
|
||||
Connection details for the database connection. Empty by default.
|
||||
|
||||
``galera``
|
||||
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||
(Deprecated) Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||
turns on some optimizations/special case handlers. Default: ``False``
|
||||
|
||||
.. _`config-replica`:
|
||||
@@ -194,7 +194,7 @@ Example::
|
||||
|
||||
[urls]
|
||||
media=/media/
|
||||
static=/media/
|
||||
static=/static/
|
||||
|
||||
``media``
|
||||
The URL to be used to serve user-uploaded content. You should not need to modify
|
||||
|
||||
@@ -14,4 +14,5 @@ This documentation is for everyone who wants to install pretix on a server.
|
||||
maintainance
|
||||
scaling
|
||||
errors
|
||||
mysql2postgres
|
||||
indexes
|
||||
|
||||
@@ -14,7 +14,7 @@ This has some trade-offs in terms of performance and isolation but allows a rath
|
||||
get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
|
||||
offers at `pretix.eu`_.
|
||||
|
||||
We tested this guide on the Linux distribution **Debian 8.0** but it should work very similar on other
|
||||
We tested this guide on the Linux distribution **Debian 11.0** but it should work very similar on other
|
||||
modern distributions, especially on all systemd-based ones.
|
||||
|
||||
Requirements
|
||||
@@ -26,7 +26,7 @@ installation guides):
|
||||
* `Docker`_
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `PostgreSQL`_ 9.6+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `PostgreSQL`_ 9.6+ database server
|
||||
* A `redis`_ server
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
@@ -58,9 +58,6 @@ directory writable to the user that runs pretix inside the docker container::
|
||||
Database
|
||||
--------
|
||||
|
||||
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
|
||||
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
|
||||
|
||||
Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on
|
||||
our database's shell. Please make sure that UTF8 is used as encoding for the best compatibility. You can check this with
|
||||
the following command::
|
||||
@@ -86,13 +83,6 @@ Restart PostgreSQL after you changed these files::
|
||||
|
||||
If you have a firewall running, you should also make sure that port 5432 is reachable from the ``172.17.0.1/16`` subnet.
|
||||
|
||||
For MySQL, you can either also use network-based connections or mount the ``/var/run/mysqld/mysqld.sock`` socket into the docker container.
|
||||
When using MySQL, make sure you set the character set of the database to ``utf8mb4``, e.g. like this::
|
||||
|
||||
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
|
||||
|
||||
Redis
|
||||
-----
|
||||
|
||||
@@ -152,15 +142,13 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
trust_x_forwarded_proto=on
|
||||
|
||||
[database]
|
||||
; Replace postgresql with mysql for MySQL
|
||||
backend=postgresql
|
||||
name=pretix
|
||||
user=pretix
|
||||
; Replace with the password you chose above
|
||||
password=*********
|
||||
; In most docker setups, 172.17.0.1 is the address of the docker host. Adjust
|
||||
; this to wherever your database is running, e.g. the name of a linked container
|
||||
; or of a mounted MySQL socket.
|
||||
; this to wherever your database is running, e.g. the name of a linked container.
|
||||
host=172.17.0.1
|
||||
|
||||
[mail]
|
||||
@@ -212,8 +200,6 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
When using MySQL and socket mounting, you'll need the additional flag ``-v /var/run/mysqld:/var/run/mysqld`` in the command.
|
||||
|
||||
You can now run the following commands
|
||||
to enable and start the service::
|
||||
|
||||
@@ -339,7 +325,6 @@ workers, e.g. ``docker run … taskworker -Q notifications --concurrency 32``.
|
||||
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
.. _pretix.eu: https://pretix.eu/
|
||||
.. _MySQL: https://dev.mysql.com/doc/refman/5.7/en/linux-installation-apt-repo.html
|
||||
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
|
||||
.. _redis: https://blog.programster.org/debian-8-install-redis-server/
|
||||
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
|
||||
|
||||
@@ -12,7 +12,7 @@ solution with many things readily set-up, look at :ref:`dockersmallscale`.
|
||||
get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
|
||||
offers at `pretix.eu`_.
|
||||
|
||||
We tested this guide on the Linux distribution **Debian 10.0** but it should work very similar on other
|
||||
We tested this guide on the Linux distribution **Debian 11.6** but it should work very similar on other
|
||||
modern distributions, especially on all systemd-based ones.
|
||||
|
||||
Requirements
|
||||
@@ -23,7 +23,7 @@ installation guides):
|
||||
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `PostgreSQL`_ 9.6+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `PostgreSQL`_ 11+ database server
|
||||
* A `redis`_ server
|
||||
* A `nodejs`_ installation
|
||||
|
||||
@@ -47,9 +47,6 @@ In this guide, all code lines prepended with a ``#`` symbol are commands that yo
|
||||
Database
|
||||
--------
|
||||
|
||||
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
|
||||
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
|
||||
|
||||
Having the database server installed, we still need a database and a database user. We can create these with any kind
|
||||
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
|
||||
best compatibility. You can check this with the following command::
|
||||
@@ -61,12 +58,6 @@ For PostgreSQL database creation, we would do::
|
||||
# sudo -u postgres createuser pretix
|
||||
# sudo -u postgres createdb -O pretix pretix
|
||||
|
||||
When using MySQL, make sure you set the character set of the database to ``utf8mb4``, e.g. like this::
|
||||
|
||||
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
|
||||
|
||||
Package dependencies
|
||||
--------------------
|
||||
|
||||
@@ -74,7 +65,7 @@ To build and run pretix, you will need the following debian packages::
|
||||
|
||||
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \
|
||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||
gettext libpq-dev libmariadb-dev libjpeg-dev libopenjp2-7-dev
|
||||
gettext libpq-dev libjpeg-dev libopenjp2-7-dev
|
||||
|
||||
Config file
|
||||
-----------
|
||||
@@ -97,16 +88,12 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
trust_x_forwarded_proto=on
|
||||
|
||||
[database]
|
||||
; For MySQL, replace with "mysql"
|
||||
backend=postgresql
|
||||
name=pretix
|
||||
user=pretix
|
||||
; For MySQL, enter the user password. For PostgreSQL on the same host,
|
||||
; we don't need one because we can use peer authentification if our
|
||||
; PostgreSQL user matches our unix user.
|
||||
; For PostgreSQL on the same host, we don't need a password because we can use
|
||||
; peer authentication if our PostgreSQL user matches our unix user.
|
||||
password=
|
||||
; For MySQL, use local socket, e.g. /var/run/mysqld/mysqld.sock
|
||||
; For a remote host, supply an IP address
|
||||
; For local postgres authentication, you can leave it empty
|
||||
host=
|
||||
|
||||
@@ -140,11 +127,7 @@ We now install pretix, its direct dependencies and gunicorn::
|
||||
|
||||
(venv)$ pip3 install pretix gunicorn
|
||||
|
||||
If you're running MySQL, also install the client library::
|
||||
|
||||
(venv)$ pip3 install mysqlclient
|
||||
|
||||
Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``.
|
||||
Note that you need Python 3.9 or newer. You can find out your Python version using ``python -V``.
|
||||
|
||||
We also need to create a data directory::
|
||||
|
||||
@@ -344,7 +327,6 @@ Then, proceed like after any plugin installation::
|
||||
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
.. _pretix.eu: https://pretix.eu/
|
||||
.. _MySQL: https://dev.mysql.com/doc/refman/5.7/en/linux-installation-apt-repo.html
|
||||
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
|
||||
.. _redis: https://blog.programster.org/debian-8-install-redis-server/
|
||||
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
|
||||
|
||||
@@ -17,11 +17,11 @@ Backups
|
||||
There are essentially two things which you should create backups of:
|
||||
|
||||
Database
|
||||
Your SQL database (MySQL or PostgreSQL). This is critical and you should **absolutely
|
||||
always create automatic backups of your database**. There are tons of tutorials on the
|
||||
internet on how to do this, and the exact process depends on the choice of your database.
|
||||
For MySQL, see ``mysqldump`` and for PostgreSQL, see the ``pg_dump`` tool. You probably
|
||||
want to create a cronjob that does the backups for you on a regular schedule.
|
||||
Your SQL database. This is critical and you should **absolutely always create automatic
|
||||
backups of your database**. There are tons of tutorials on the internet on how to do this,
|
||||
and the exact process depends on the choice of your database. For PostgreSQL, see the
|
||||
``pg_dump`` tool. You probably want to create a cronjob that does the backups for you on a
|
||||
regular schedule.
|
||||
|
||||
Data directory
|
||||
The data directory of your pretix configuration might contain some things that you should
|
||||
|
||||
148
doc/admin/mysql2postgres.rst
Normal file
148
doc/admin/mysql2postgres.rst
Normal file
@@ -0,0 +1,148 @@
|
||||
.. highlight:: none
|
||||
|
||||
Migrating from MySQL/MariaDB to PostgreSQL
|
||||
==========================================
|
||||
|
||||
Our recommended database for all production installations is PostgreSQL. Support for MySQL/MariaDB will be removed in
|
||||
pretix 5.0.
|
||||
|
||||
In order to follow this guide, your pretix installation needs to be a version that fully supports MySQL/MariaDB. If you
|
||||
already upgraded to pretix 5.0, downgrade back to the last 4.x release using ``pip``.
|
||||
|
||||
.. note:: We have tested this guide carefully, but we can't assume any liability for its correctness. The data loss
|
||||
risk should be low as long as pretix is not running while you do the migration. If you are a pretix Enterprise
|
||||
customer, feel free to reach out in advance if you want us to support you along the way.
|
||||
|
||||
Update database schema
|
||||
----------------------
|
||||
|
||||
Before you start, make sure your database schema is up to date::
|
||||
|
||||
# sudo -u pretix -s
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ python -m pretix migrate
|
||||
|
||||
Install PostgreSQL
|
||||
------------------
|
||||
|
||||
Now, install and set up a PostgreSQL server. For a local installation on Debian or Ubuntu, use::
|
||||
|
||||
# apt install postgresql
|
||||
|
||||
Having the database server installed, we still need a database and a database user. We can create these with any kind
|
||||
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
|
||||
best compatibility. You can check this with the following command::
|
||||
|
||||
# sudo -u postgres psql -c 'SHOW SERVER_ENCODING'
|
||||
|
||||
Without Docker
|
||||
""""""""""""""
|
||||
|
||||
For our standard manual installation, create the database and user like this::
|
||||
|
||||
# sudo -u postgres createuser pretix
|
||||
# sudo -u postgres createdb -O pretix pretix
|
||||
|
||||
With Docker
|
||||
"""""""""""
|
||||
|
||||
For our standard docker installation, create the database and user like this::
|
||||
|
||||
# sudo -u postgres createuser -P pretix
|
||||
# sudo -u postgres createdb -O pretix pretix
|
||||
|
||||
Make sure that your database listens on the network. If PostgreSQL on the same same host as docker, but not inside a docker container, we recommend that you just listen on the Docker interface by changing the following line in ``/etc/postgresql/<version>/main/postgresql.conf``::
|
||||
|
||||
listen_addresses = 'localhost,172.17.0.1'
|
||||
|
||||
You also need to add a new line to ``/etc/postgresql/<version>/main/pg_hba.conf`` to allow network connections to this user and database::
|
||||
|
||||
host pretix pretix 172.17.0.1/16 md5
|
||||
|
||||
Restart PostgreSQL after you changed these files::
|
||||
|
||||
# systemctl restart postgresql
|
||||
|
||||
If you have a firewall running, you should also make sure that port 5432 is reachable from the ``172.17.0.1/16`` subnet.
|
||||
|
||||
Of course, instead of all this you can also run a PostgreSQL docker container and link it to the pretix container.
|
||||
|
||||
Stop pretix
|
||||
-----------
|
||||
|
||||
To prevent any more changes to your data, stop pretix from running::
|
||||
|
||||
# systemctl stop pretix-web pretix-worker
|
||||
|
||||
Change configuration
|
||||
--------------------
|
||||
|
||||
Change the database configuration in your ``/etc/pretix/pretix.cfg`` file::
|
||||
|
||||
[database]
|
||||
backend=postgresql
|
||||
name=pretix
|
||||
user=pretix
|
||||
password= ; only required for docker or remote database, can be kept empty for local auth
|
||||
host= ; set to 172.17.0.1 in docker setup, keep empty for local auth
|
||||
|
||||
|
||||
Create database schema
|
||||
-----------------------
|
||||
|
||||
To create the schema in your new PostgreSQL database, use the following commands::
|
||||
|
||||
# sudo -u pretix -s
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ python -m pretix migrate
|
||||
|
||||
|
||||
Migrate your data
|
||||
-----------------
|
||||
|
||||
Install ``pgloader``::
|
||||
|
||||
# apt install pgloader
|
||||
|
||||
Create a new file ``/tmp/pretix.load``, replacing the MySQL and PostgreSQL connection strings with the correct user names, passwords, and/or database names::
|
||||
|
||||
LOAD DATABASE
|
||||
FROM mysql://pretix:password@localhost/pretix -- replace with mysql://username:password@hostname/dbname
|
||||
INTO postgresql:///pretix -- replace with dbname
|
||||
|
||||
WITH data only, include no drop, truncate, disable triggers,
|
||||
create no indexes, drop indexes, reset sequences
|
||||
|
||||
ALTER SCHEMA 'pretix' RENAME TO 'public' -- replace pretix with the name of the MySQL database
|
||||
|
||||
ALTER TABLE NAMES MATCHING ~/.*/
|
||||
SET SCHEMA 'public'
|
||||
|
||||
SET timezone TO '+00:00'
|
||||
|
||||
SET PostgreSQL PARAMETERS
|
||||
maintenance_work_mem to '128MB',
|
||||
work_mem to '12MB';
|
||||
|
||||
Then, run::
|
||||
|
||||
# sudo -u postgres pgloader /tmp/pretix.load
|
||||
|
||||
The output should end with a table summarizing the results for every table. You can ignore warnings about type casts
|
||||
and missing constraints.
|
||||
|
||||
Afterwards, delete the file again::
|
||||
|
||||
# rm -rf /tmp/pretix.load
|
||||
|
||||
Start pretix
|
||||
------------
|
||||
|
||||
Now, restart pretix. Maybe stop your MySQL server as a verification step that you are no longer using it::
|
||||
|
||||
# systemctl stop mariadb
|
||||
# systemctl start pretix-web pretix-worker
|
||||
|
||||
And you're done! After you've verified everything has been copied correctly, you can delete the old MySQL database.
|
||||
|
||||
.. note:: Don't forget to update your backup process to back up your PostgreSQL database instead of your MySQL database now.
|
||||
@@ -42,7 +42,7 @@ A pretix installation usually consists of the following components which run per
|
||||
|
||||
* ``pretix-worker`` is a Celery-based application that processes tasks that should be run asynchronously outside of the web application process.
|
||||
|
||||
* A **SQL database** keeps all the important data and processes the actual transactions. We recommend using PostgreSQL, but MySQL/MariaDB works as well.
|
||||
* A **PostgreSQL database** keeps all the important data and processes the actual transactions.
|
||||
|
||||
* A **web server** that terminates TLS and HTTP connections and forwards them to ``pretix-web``. In some cases, e.g. when serving static files, the web servers might return a response directly. We recommend using ``nginx``.
|
||||
|
||||
@@ -74,7 +74,7 @@ We recommend reading up on tuning your web server for high concurrency. For ngin
|
||||
processes and the number of connections each worker process accepts. Double-check that TLS session caching works, because TLS
|
||||
handshakes can get really expensive.
|
||||
|
||||
During a traffic peak, your web server will be able to make us of more CPU resources, while memory usage will stay comparatively low,
|
||||
During a traffic peak, your web server will be able to make use of more CPU resources, while memory usage will stay comparatively low,
|
||||
so if you invest in more hardware here, invest in more and faster CPU cores.
|
||||
|
||||
Make sure that pretix' static files (such as CSS and JavaScript assets) as well as user-uploaded media files (event logos, etc)
|
||||
|
||||
@@ -192,6 +192,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
|
||||
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
|
||||
specifiers in requests
|
||||
(see below).
|
||||
Date range *either* two dates separated ``2022-03-18/2022-03-23``, ``2022-03-18/``,
|
||||
by ``/`` *or* the name of a ``/2022-03-23``, ``week_this``, ``week_next``,
|
||||
defined range. ``month_this``
|
||||
===================== ============================ ===================================
|
||||
|
||||
Query parameters
|
||||
|
||||
@@ -98,6 +98,8 @@ Endpoints
|
||||
:query string ends_after: Exclude all check-in lists attached to a sub-event that is already in the past at the given time.
|
||||
:query string expand: Expand a field into a full object. Currently only ``subevent`` is supported. Can be passed multiple times.
|
||||
:query string exclude: Exclude a field from the output, e.g. ``checkin_count``. Can be used as a performance optimization. Can be passed multiple times.
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``name``, and ``subevent__date_from``,
|
||||
Default: ``subevent__date_from,name``
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
|
||||
@@ -35,6 +35,12 @@ tax_rule integer The internal ID
|
||||
admission boolean ``true`` for items that grant admission to the event
|
||||
(such as primary tickets) and ``false`` for others
|
||||
(such as add-ons or merchandise).
|
||||
personalized boolean ``true`` for items that require personalization according
|
||||
to event settings. Only affects system-level fields, not
|
||||
custom questions. Currently only allowed for products with
|
||||
``admission`` set to ``true``. For backwards compatibility,
|
||||
when creating new items and this field is not given, it defaults
|
||||
to the same value as ``admission``.
|
||||
position integer An integer, used for sorting
|
||||
picture file A product picture to be displayed in the shop
|
||||
(can be ``null``).
|
||||
@@ -158,7 +164,7 @@ meta_data object Values set for
|
||||
|
||||
.. versionchanged:: 4.16
|
||||
|
||||
The ``variations[x].meta_data`` attribute has been added.
|
||||
The ``variations[x].meta_data`` attribute has been added. The ``personalized`` attribute has been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
@@ -213,6 +219,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
@@ -329,6 +336,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
@@ -426,6 +434,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
@@ -510,6 +519,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
@@ -626,6 +636,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
|
||||
@@ -76,6 +76,10 @@ The exporter class
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: description
|
||||
|
||||
.. autoattribute:: category
|
||||
|
||||
.. autoattribute:: export_form_fields
|
||||
|
||||
.. automethod:: render
|
||||
|
||||
@@ -17,9 +17,13 @@ Field Type Description
|
||||
id integer Internal layout ID
|
||||
name string Internal layout description
|
||||
default boolean ``true`` if this is the default layout
|
||||
layout object Layout specification for libpretixprint
|
||||
layout list Dynamic layout specification. Each list element
|
||||
corresponds to one dynamic element of the layout.
|
||||
The current version of the schema in use can be found
|
||||
`here`_.
|
||||
Submitting invalid content can lead to application errors.
|
||||
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 (currently read-only)
|
||||
├ sales_channel string Sales channel (defaults to ``web``).
|
||||
└ item integer Item ID
|
||||
===================================== ========================== =======================================================
|
||||
@@ -58,7 +62,7 @@ Endpoints
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": {…},
|
||||
"background": {},
|
||||
"background": null,
|
||||
"item_assignments": []
|
||||
}
|
||||
]
|
||||
@@ -96,7 +100,7 @@ Endpoints
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": {…},
|
||||
"background": {},
|
||||
"background": null,
|
||||
"item_assignments": []
|
||||
}
|
||||
|
||||
@@ -147,3 +151,122 @@ Endpoints
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/
|
||||
|
||||
Creates a new ticket layout
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": […],
|
||||
"background": null,
|
||||
"item_assignments": []
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": […],
|
||||
"background": null,
|
||||
"item_assignments": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create a layout for
|
||||
:param event: The ``slug`` field of the event to create a layout for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The layout could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/(id)/
|
||||
|
||||
Update a layout. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"name": "Default layout"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": […],
|
||||
"background": null,
|
||||
"item_assignments": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the layout to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The layout could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/(id)/
|
||||
|
||||
Delete a layout.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the layout to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
|
||||
|
||||
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/pdf-layout.schema.json
|
||||
|
||||
@@ -97,6 +97,7 @@ overpayment
|
||||
param
|
||||
passphrase
|
||||
percental
|
||||
personalization
|
||||
pluggable
|
||||
positionid
|
||||
pre
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "4.16.0.dev0"
|
||||
__version__ = "4.17.0.dev0"
|
||||
|
||||
@@ -32,6 +32,7 @@ from rest_framework import status
|
||||
|
||||
from pretix.api.models import ApiCall
|
||||
from pretix.base.models import Organizer
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
|
||||
class IdempotencyMiddleware:
|
||||
@@ -56,7 +57,7 @@ class IdempotencyMiddleware:
|
||||
idempotency_key = request.headers.get('X-Idempotency-Key', '')
|
||||
|
||||
with transaction.atomic():
|
||||
call, created = ApiCall.objects.select_for_update().get_or_create(
|
||||
call, created = ApiCall.objects.select_for_update(of=OF_SELF).get_or_create(
|
||||
auth_hash=auth_hash,
|
||||
idempotency_key=idempotency_key,
|
||||
defaults={
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
from django import forms
|
||||
from django.http import QueryDict
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
|
||||
|
||||
|
||||
class FormFieldWrapperField(serializers.Field):
|
||||
@@ -142,6 +144,12 @@ class JobRunSerializer(serializers.Serializer):
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
elif isinstance(v, DateFrameField):
|
||||
self.fields[k] = SerializerDateFrameField(
|
||||
required=v.required,
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
else:
|
||||
self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required)
|
||||
|
||||
@@ -151,5 +159,40 @@ class JobRunSerializer(serializers.Serializer):
|
||||
for k, v in self.fields.items():
|
||||
if isinstance(v, serializers.ManyRelatedField) and k not in data:
|
||||
data[k] = []
|
||||
|
||||
for fk in self.fields.keys():
|
||||
# Backwards compatibility for exports that used to take e.g. (date_from, date_to) or (event_date_from, event_date_to)
|
||||
# and now only take date_range.
|
||||
if fk.endswith("_range") and isinstance(self.fields[fk], SerializerDateFrameField) and fk not in data:
|
||||
if fk.replace("_range", "_from") in data:
|
||||
d_from = data.pop(fk.replace("_range", "_from"))
|
||||
if d_from:
|
||||
d_from = serializers.DateField().to_internal_value(d_from)
|
||||
else:
|
||||
d_from = None
|
||||
if fk.replace("_range", "_to") in data:
|
||||
d_to = data.pop(fk.replace("_range", "_to"))
|
||||
if d_to:
|
||||
d_to = serializers.DateField().to_internal_value(d_to)
|
||||
else:
|
||||
d_to = None
|
||||
data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}'
|
||||
|
||||
data = super().to_internal_value(data)
|
||||
return data
|
||||
|
||||
def is_valid(self, raise_exception=False):
|
||||
super().is_valid(raise_exception=raise_exception)
|
||||
|
||||
fields_keys = set(self.fields.keys())
|
||||
input_keys = set(self.initial_data.keys())
|
||||
|
||||
additional_fields = input_keys - fields_keys
|
||||
|
||||
if bool(additional_fields):
|
||||
self._errors['fields'] = ['Additional fields not allowed: {}.'.format(list(additional_fields))]
|
||||
|
||||
if self._errors and raise_exception:
|
||||
raise ValidationError(self.errors)
|
||||
|
||||
return not bool(self._errors)
|
||||
|
||||
@@ -95,8 +95,12 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
require_membership_types = validated_data.pop('require_membership_types', [])
|
||||
variation = ItemVariation.objects.create(**validated_data)
|
||||
|
||||
if require_membership_types:
|
||||
variation.require_membership_types.add(*require_membership_types)
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
for key, value in meta_data.items():
|
||||
@@ -230,7 +234,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Item
|
||||
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', 'personalized',
|
||||
'position', 'picture', 'available_from', 'available_until',
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||
@@ -258,6 +262,15 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
|
||||
Item.clean_available(data.get('available_from'), data.get('available_until'))
|
||||
|
||||
if data.get('personalized') and not data.get('admission'):
|
||||
raise ValidationError(_('Only admission products can currently be personalized.'))
|
||||
|
||||
if data.get('admission') and 'personalized' not in data and not self.instance:
|
||||
# Backwards compatibility
|
||||
data['personalized'] = True
|
||||
elif 'admission' in data and not data['admission']:
|
||||
data['personalized'] = False
|
||||
|
||||
if data.get('issue_giftcard'):
|
||||
if data.get('tax_rule') and data.get('tax_rule').rate > 0:
|
||||
raise ValidationError(
|
||||
|
||||
@@ -118,6 +118,10 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
{'name': ['Do not specify name if you specified name_parts.']}
|
||||
)
|
||||
|
||||
if data.get('name_parts') and not isinstance(data.get('name_parts'), dict):
|
||||
raise ValidationError({'name_parts': ['Invalid data type']})
|
||||
|
||||
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
|
||||
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
|
||||
@@ -841,6 +845,10 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
|
||||
if data.get('attendee_name_parts') and not isinstance(data.get('attendee_name_parts'), dict):
|
||||
raise ValidationError({'attendee_name_parts': ['Invalid data type']})
|
||||
|
||||
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
|
||||
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
|
||||
|
||||
@@ -158,12 +158,14 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
|
||||
a.question_id: a for a in instance.answers.all()
|
||||
}
|
||||
for answ_data in answers_data:
|
||||
if not answ_data.get('answer'):
|
||||
continue
|
||||
options = answ_data.pop('options', [])
|
||||
if answ_data['question'].pk in qs_seen:
|
||||
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
|
||||
if answ_data['question'].pk in answercache:
|
||||
a = answercache[answ_data['question'].pk]
|
||||
if isinstance(answ_data['answer'], File):
|
||||
if isinstance(answ_data.get('answer'), File):
|
||||
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
|
||||
a.answer = 'file://' + a.file.name
|
||||
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
|
||||
@@ -173,7 +175,7 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
|
||||
setattr(a, attr, value)
|
||||
a.save()
|
||||
else:
|
||||
if isinstance(answ_data['answer'], File):
|
||||
if isinstance(answ_data.get('answer'), File):
|
||||
an = answ_data.pop('answer')
|
||||
a = instance.answers.create(**answ_data, answer='')
|
||||
a.file.save(os.path.basename(an.name), an, save=False)
|
||||
|
||||
@@ -79,6 +79,13 @@ class CustomerSerializer(I18nAwareModelSerializer):
|
||||
validated_data['external_identifier'] = instance.external_identifier
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('name_parts') and not isinstance(data.get('name_parts'), dict):
|
||||
raise ValidationError({'name_parts': ['Invalid data type']})
|
||||
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
|
||||
data['name_parts']['_scheme'] = self.context['request'].organizer.settings.name_scheme
|
||||
return data
|
||||
|
||||
|
||||
class CustomerCreateSerializer(CustomerSerializer):
|
||||
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
|
||||
|
||||
@@ -93,8 +93,10 @@ with scopes_disabled():
|
||||
class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = CheckinListSerializer
|
||||
queryset = CheckinList.objects.none()
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
filterset_class = CheckinListFilter
|
||||
ordering = ('subevent__date_from', 'name', 'id')
|
||||
ordering_fields = ('subevent__date_from', 'id', 'name',)
|
||||
|
||||
def _get_permission_name(self, request):
|
||||
if request.path.endswith('/failed_checkins/'):
|
||||
@@ -682,7 +684,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = CheckinListOrderPositionSerializer
|
||||
queryset = OrderPosition.all.none()
|
||||
filter_backends = (ExtendedBackend, RichOrderingFilter)
|
||||
ordering = (F('attendee_name_cached').asc(nulls_last=True), 'positionid')
|
||||
ordering = (F('attendee_name_cached').asc(nulls_last=True), 'pk')
|
||||
ordering_fields = (
|
||||
'order__code', 'order__datetime', 'positionid', 'attendee_name',
|
||||
'last_checked_in', 'order__email',
|
||||
|
||||
@@ -51,6 +51,7 @@ from pretix.base.models import (
|
||||
User,
|
||||
)
|
||||
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
from pretix.presale.style import regenerate_organizer_css
|
||||
|
||||
@@ -178,7 +179,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
def perform_update(self, serializer):
|
||||
if 'include_accepted' in self.request.GET:
|
||||
raise PermissionDenied("Accepted gift cards cannot be updated, use transact instead.")
|
||||
GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
|
||||
GiftCard.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
|
||||
old_value = serializer.instance.value
|
||||
value = serializer.validated_data.pop('value')
|
||||
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
|
||||
@@ -196,7 +197,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=True, methods=["POST"])
|
||||
@transaction.atomic()
|
||||
def transact(self, request, **kwargs):
|
||||
gc = GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
|
||||
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
|
||||
value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||
request.data.get('value')
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#
|
||||
import datetime
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.timezone import now
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
@@ -33,6 +34,9 @@ from pretix.api.auth.device import DeviceTokenAuthentication
|
||||
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
|
||||
from pretix.api.auth.token import TeamTokenAuthentication
|
||||
from pretix.base.models import CachedFile
|
||||
from pretix.helpers.images import (
|
||||
IMAGE_TYPES, validate_uploaded_file_for_valid_image,
|
||||
)
|
||||
|
||||
ALLOWED_TYPES = {
|
||||
'image/gif': {'.gif'},
|
||||
@@ -61,6 +65,13 @@ class UploadView(APIView):
|
||||
name=file_obj.name,
|
||||
type=content_type
|
||||
))
|
||||
|
||||
if content_type in IMAGE_TYPES:
|
||||
try:
|
||||
validate_uploaded_file_for_valid_image(file_obj)
|
||||
except DjangoValidationError as e:
|
||||
raise ValidationError(e.message)
|
||||
|
||||
cf = CachedFile.objects.create(
|
||||
expires=now() + datetime.timedelta(days=1),
|
||||
date=now(),
|
||||
|
||||
@@ -42,6 +42,7 @@ from pretix.base.models import LogEntry
|
||||
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_ALL_EVENTS = None
|
||||
@@ -502,7 +503,8 @@ def manually_retry_all_calls(webhook_id: int):
|
||||
webhook = WebHook.objects.get(id=webhook_id)
|
||||
with scope(organizer=webhook.organizer), transaction.atomic():
|
||||
for whcr in webhook.retries.select_for_update(
|
||||
skip_locked=connection.features.has_select_for_update_skip_locked
|
||||
skip_locked=connection.features.has_select_for_update_skip_locked,
|
||||
of=OF_SELF
|
||||
):
|
||||
send_webhook.apply_async(
|
||||
args=(whcr.logentry_id, whcr.action_type, whcr.webhook_id, whcr.retry_count),
|
||||
@@ -515,7 +517,8 @@ def manually_retry_all_calls(webhook_id: int):
|
||||
def schedule_webhook_retries_on_celery(sender, **kwargs):
|
||||
with transaction.atomic():
|
||||
for whcr in WebHookCallRetry.objects.select_for_update(
|
||||
skip_locked=connection.features.has_select_for_update_skip_locked
|
||||
skip_locked=connection.features.has_select_for_update_skip_locked,
|
||||
of=OF_SELF
|
||||
).filter(retry_not_before__lt=now()):
|
||||
send_webhook.apply_async(
|
||||
args=(whcr.logentry_id, whcr.action_type, whcr.webhook_id, whcr.retry_count),
|
||||
|
||||
@@ -42,7 +42,6 @@ from localflavor.fr.forms import FRZipCodeField
|
||||
from localflavor.gb.forms import GBPostcodeField
|
||||
from localflavor.gr.forms import GRPostalCodeField
|
||||
from localflavor.hr.forms import HRPostalCodeField
|
||||
from localflavor.id_.forms import IDPostCodeField
|
||||
from localflavor.ie.forms import EircodeField
|
||||
from localflavor.il.forms import ILPostalCodeField
|
||||
from localflavor.in_.forms import INZipCodeField
|
||||
@@ -80,8 +79,8 @@ COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED = {
|
||||
# We don't presume this for countries we don't have knowledge about, there are countries in the
|
||||
# world e.g. without zipcodes
|
||||
'AR', 'AT', 'AU', 'BE', 'BR', 'CA', 'CH', 'CN', 'CU', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR',
|
||||
'GB', 'GR', 'HR', 'ID', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX',
|
||||
'NL', 'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
|
||||
'GB', 'GR', 'HR', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX', 'NL',
|
||||
'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +166,6 @@ _zip_code_fields = {
|
||||
'GB': GBPostcodeField,
|
||||
'GR': GRPostalCodeField,
|
||||
'HR': HRPostalCodeField,
|
||||
'ID': IDPostCodeField,
|
||||
'IE': EircodeField,
|
||||
'IL': ILPostalCodeField,
|
||||
'IN': INZipCodeField,
|
||||
|
||||
@@ -520,20 +520,20 @@ def base_placeholders(sender, **kwargs):
|
||||
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_remove', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: build_absolute_uri(
|
||||
'url_remove', ['waiting_list_voucher', 'event'],
|
||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
||||
event, 'presale:event.waitinglist.remove'
|
||||
) + '?voucher=' + waiting_list_entry.voucher.code,
|
||||
) + '?voucher=' + waiting_list_voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.waitinglist.remove',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: build_absolute_uri(
|
||||
'url', ['waiting_list_voucher', 'event'],
|
||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + waiting_list_entry.voucher.code,
|
||||
) + '?voucher=' + waiting_list_voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.redeem',
|
||||
@@ -588,7 +588,7 @@ def base_placeholders(sender, **kwargs):
|
||||
_('Sample Admission Ticket')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code,
|
||||
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
|
||||
'68CYU2H6ZTP3WLK5'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
|
||||
@@ -36,7 +36,7 @@ import io
|
||||
import tempfile
|
||||
from collections import OrderedDict, namedtuple
|
||||
from decimal import Decimal
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import pytz
|
||||
from defusedcsv import csv
|
||||
@@ -84,6 +84,27 @@ class BaseExporter:
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""
|
||||
A description for this exporter.
|
||||
"""
|
||||
return ""
|
||||
|
||||
@property
|
||||
def category(self) -> Optional[str]:
|
||||
"""
|
||||
A category name for this exporter, or ``None``.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def featured(self) -> bool:
|
||||
"""
|
||||
If ``True``, this exporter will be highlighted.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -39,7 +39,7 @@ from zipfile import ZipFile
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.models import QuestionAnswer
|
||||
|
||||
@@ -49,7 +49,10 @@ from ..signals import register_data_exporters
|
||||
|
||||
class AnswerFilesExporter(BaseExporter):
|
||||
identifier = 'answerfiles'
|
||||
verbose_name = _('Answers to file upload questions')
|
||||
verbose_name = _('Question answer file uploads')
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = _('Download a ZIP file including all files that have been uploaded by your customers while creating '
|
||||
'an order.')
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
|
||||
@@ -36,7 +36,7 @@ from collections import OrderedDict
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import get_current_timezone
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
|
||||
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
@@ -48,6 +48,8 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'customerlist'
|
||||
verbose_name = gettext_lazy('Customer accounts')
|
||||
organizer_required_permission = 'can_manage_customers'
|
||||
category = pgettext_lazy('export_category', 'Customer accounts')
|
||||
description = gettext_lazy('Download a spreadsheet of all currently registered customer accounts.')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
|
||||
@@ -23,22 +23,24 @@ import json
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
|
||||
import dateutil
|
||||
from django import forms
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext, gettext_lazy
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, gettext_lazy, pgettext_lazy
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Invoice, OrderPayment
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..signals import register_data_exporters
|
||||
from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
|
||||
|
||||
|
||||
class DekodiNREIExporter(BaseExporter):
|
||||
identifier = 'dekodi_nrei'
|
||||
verbose_name = 'dekodi NREI (JSON)'
|
||||
category = pgettext_lazy('export_category', 'Invoices')
|
||||
description = gettext_lazy("Download invoices in a format that can be used by the dekodi NREI conversion software.")
|
||||
|
||||
# Specification: http://manuals.dekodi.de/nexuspub/schnittstellenbuch/
|
||||
|
||||
@@ -113,7 +115,7 @@ class DekodiNREIExporter(BaseExporter):
|
||||
'PTNo14': p.info_data.get('reference') or '',
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
elif p.provider.startswith('stripe'):
|
||||
elif p.provider and p.provider.startswith('stripe'):
|
||||
src = p.info_data.get("source", p.info_data)
|
||||
payments.append({
|
||||
'PTID': '81',
|
||||
@@ -192,17 +194,12 @@ class DekodiNREIExporter(BaseExporter):
|
||||
def render(self, form_data):
|
||||
qs = self.event.invoices.select_related('order').prefetch_related('lines', 'lines__subevent')
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__gte=date_value)
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__lte=date_value)
|
||||
if form_data.get('date_range'):
|
||||
d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
|
||||
if d_start:
|
||||
qs = qs.filter(date__gte=d_start)
|
||||
if d_end:
|
||||
qs = qs.filter(date__lte=d_end)
|
||||
|
||||
jo = {
|
||||
'Format': 'NREI',
|
||||
@@ -218,22 +215,14 @@ class DekodiNREIExporter(BaseExporter):
|
||||
def export_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=gettext_lazy('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
('date_range',
|
||||
DateFrameField(
|
||||
label=gettext_lazy('Date range'),
|
||||
include_future_frames=False,
|
||||
required=False,
|
||||
help_text=gettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
|
||||
help_text=gettext_lazy('Only include invoices issued in this time frame. Note that the invoice date does '
|
||||
'not always correspond to the order or payment date.')
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=gettext_lazy('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=gettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
|
||||
'does not always correspond to the order or payment date.')
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from ...control.forms.filter import get_all_payment_providers
|
||||
from ..exporter import ListExporter
|
||||
@@ -45,6 +45,8 @@ from ..signals import register_multievent_data_exporters
|
||||
class EventDataExporter(ListExporter):
|
||||
identifier = 'eventdata'
|
||||
verbose_name = _('Event data')
|
||||
category = pgettext_lazy('export_category', 'Event data')
|
||||
description = _('Download a spreadsheet with information on all events in this organizer account.')
|
||||
|
||||
@cached_property
|
||||
def providers(self):
|
||||
|
||||
@@ -38,13 +38,15 @@ from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
from zipfile import ZipFile
|
||||
|
||||
import dateutil.parser
|
||||
from django import forms
|
||||
from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext, gettext_lazy as _, pgettext
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import (
|
||||
gettext, gettext_lazy as _, pgettext, pgettext_lazy,
|
||||
)
|
||||
|
||||
from pretix.base.models import Invoice, InvoiceLine, OrderPayment
|
||||
|
||||
@@ -57,30 +59,24 @@ from ..services.invoices import invoice_pdf_task
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
|
||||
|
||||
|
||||
class InvoiceExporterMixin:
|
||||
category = pgettext_lazy('export_category', 'Invoices')
|
||||
|
||||
@property
|
||||
def invoice_exporter_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
('date_range',
|
||||
DateFrameField(
|
||||
label=_('Date range'),
|
||||
include_future_frames=False,
|
||||
required=False,
|
||||
help_text=_('Only include invoices issued on or after this date. Note that the invoice date does '
|
||||
help_text=_('Only include invoices issued in this time frame. Note that the invoice date does '
|
||||
'not always correspond to the order or payment date.')
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=_('Only include invoices issued on or before this date. Note that the invoice date '
|
||||
'does not always correspond to the order or payment date.')
|
||||
)),
|
||||
('payment_provider',
|
||||
forms.ChoiceField(
|
||||
label=_('Payment provider'),
|
||||
@@ -112,16 +108,12 @@ class InvoiceExporterMixin:
|
||||
)
|
||||
)
|
||||
qs = qs.filter(has_payment_with_provider=1)
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__gte=date_value)
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(date__lte=date_value)
|
||||
if form_data.get('date_range'):
|
||||
d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
|
||||
if d_start:
|
||||
qs = qs.filter(date__gte=d_start)
|
||||
if d_end:
|
||||
qs = qs.filter(date__lte=d_end)
|
||||
|
||||
return qs
|
||||
|
||||
@@ -129,6 +121,7 @@ class InvoiceExporterMixin:
|
||||
class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
|
||||
identifier = 'invoices'
|
||||
verbose_name = _('All invoices')
|
||||
description = _('Download all invoices created by the system as a ZIP file of PDF files.')
|
||||
|
||||
def render(self, form_data: dict, output_file=None):
|
||||
qs = self.invoices_queryset(form_data).filter(shredded=False)
|
||||
@@ -180,6 +173,10 @@ class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
|
||||
class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
identifier = 'invoicedata'
|
||||
verbose_name = _('Invoice data')
|
||||
description = _('Download a spreadsheet with the data of all invoices created by the system. The spreadsheet '
|
||||
'includes two sheets, one with a line for every invoice, and one with a line for every position of '
|
||||
'every invoice.')
|
||||
featured = True
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from openpyxl.styles import Alignment
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
@@ -48,6 +48,8 @@ def _min(a1, a2):
|
||||
class ItemDataExporter(ListExporter):
|
||||
identifier = 'itemdata'
|
||||
verbose_name = _('Product data')
|
||||
category = pgettext_lazy('export_category', 'Product data')
|
||||
description = _('Download a spreadsheet with details about all products and variations.')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
locales = self.event.settings.locales
|
||||
@@ -73,6 +75,7 @@ class ItemDataExporter(ListExporter):
|
||||
_("Free price input"),
|
||||
_("Sales tax"),
|
||||
_("Is an admission ticket"),
|
||||
_("Personalized ticket"),
|
||||
_("Generate tickets"),
|
||||
_("Waiting list"),
|
||||
_("Available from"),
|
||||
@@ -144,6 +147,7 @@ class ItemDataExporter(ListExporter):
|
||||
_("Yes") if i.free_price else "",
|
||||
str(i.tax_rule) if i.tax_rule else "",
|
||||
_("Yes") if i.admission else "",
|
||||
_("Yes") if i.personalized else "",
|
||||
_("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
|
||||
_("Yes") if i.allow_waitinglist else "",
|
||||
date_format(_max(i.available_from, v.available_from).astimezone(self.timezone),
|
||||
@@ -187,6 +191,7 @@ class ItemDataExporter(ListExporter):
|
||||
_("Yes") if i.free_price else "",
|
||||
str(i.tax_rule) if i.tax_rule else "",
|
||||
_("Yes") if i.admission else "",
|
||||
_("Yes") if i.personalized else "",
|
||||
_("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
|
||||
_("Yes") if i.allow_waitinglist else "",
|
||||
date_format(i.available_from.astimezone(self.timezone),
|
||||
|
||||
@@ -38,6 +38,8 @@ from decimal import Decimal
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.translation import gettext, gettext_lazy, pgettext_lazy
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
|
||||
@@ -46,7 +48,10 @@ from ..signals import register_data_exporters
|
||||
|
||||
class JSONExporter(BaseExporter):
|
||||
identifier = 'json'
|
||||
verbose_name = 'Order data (JSON)'
|
||||
verbose_name = lazy(lambda *args: gettext('Order data') + ' (JSON)', str)()
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = gettext_lazy('Download a structured JSON representation of all orders. This might be useful for the '
|
||||
'import in third-party systems.')
|
||||
|
||||
def render(self, form_data):
|
||||
jo = {
|
||||
@@ -78,6 +83,7 @@ class JSONExporter(BaseExporter):
|
||||
'tax_rate': item.tax_rule.rate if item.tax_rule else Decimal('0.00'),
|
||||
'tax_name': str(item.tax_rule.name) if item.tax_rule else None,
|
||||
'admission': item.admission,
|
||||
'personalized': item.personalized,
|
||||
'active': item.active,
|
||||
'sales_channels': item.sales_channels,
|
||||
'description': str(item.description),
|
||||
|
||||
@@ -36,7 +36,7 @@ from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
|
||||
@@ -50,6 +50,8 @@ from ..signals import (
|
||||
class MailExporter(BaseExporter):
|
||||
identifier = 'mailaddrs'
|
||||
verbose_name = _('Email addresses (text file)')
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = _("Download a text file with all email addresses collected either from buyers or from ticket holders.")
|
||||
|
||||
def render(self, form_data: dict):
|
||||
qs = Order.objects.filter(event__in=self.events, status__in=form_data['status']).prefetch_related('event')
|
||||
|
||||
@@ -33,10 +33,8 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
|
||||
import dateutil
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.db.models import (
|
||||
@@ -46,8 +44,10 @@ from django.db.models import (
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import (
|
||||
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
|
||||
)
|
||||
|
||||
from pretix.base.models import (
|
||||
GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
|
||||
@@ -63,14 +63,24 @@ from ...helpers.iter import chunked_iterable
|
||||
from ..exporter import (
|
||||
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
|
||||
)
|
||||
from ..forms.widgets import SplitDateTimePickerWidget
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from ..timeframes import (
|
||||
DateFrameField,
|
||||
resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
|
||||
)
|
||||
|
||||
|
||||
class OrderListExporter(MultiSheetListExporter):
|
||||
identifier = 'orderlist'
|
||||
verbose_name = gettext_lazy('Order data')
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = gettext_lazy('Download a spreadsheet of all orders. The spreadsheet will include three sheets, one '
|
||||
'with a line for every order, one with a line for every order position, and one with '
|
||||
'a line for every additional fee charged in an order.')
|
||||
featured = True
|
||||
|
||||
@cached_property
|
||||
def providers(self):
|
||||
@@ -105,41 +115,25 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
initial=False,
|
||||
required=False
|
||||
)),
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
('date_range',
|
||||
DateFrameField(
|
||||
label=_('Date range'),
|
||||
include_future_frames=False,
|
||||
required=False,
|
||||
help_text=_('Only include orders created on or after this date.')
|
||||
help_text=_('Only include orders created within this date range.')
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
('event_date_range',
|
||||
DateFrameField(
|
||||
label=_('Event date'),
|
||||
include_future_frames=True,
|
||||
required=False,
|
||||
help_text=_('Only include orders created on or before this date.')
|
||||
)),
|
||||
('event_date_from',
|
||||
forms.DateField(
|
||||
label=_('Start event date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=_('Only include orders including at least one ticket for a date on or after this date. '
|
||||
'Will also include other dates in case of mixed orders!')
|
||||
)),
|
||||
('event_date_to',
|
||||
forms.DateField(
|
||||
label=_('End event date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=_('Only include orders including at least one ticket for a date on or before this date. '
|
||||
help_text=_('Only include orders including at least one ticket for a date in this range. '
|
||||
'Will also include other dates in case of mixed orders!')
|
||||
)),
|
||||
]
|
||||
d = OrderedDict(d)
|
||||
if not self.is_multievent and not self.event.has_subevents:
|
||||
del d['event_date_from']
|
||||
del d['event_date_to']
|
||||
del d['event_date_range']
|
||||
return d
|
||||
|
||||
def _get_all_payment_methods(self, qs):
|
||||
@@ -182,45 +176,27 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
annotations = {}
|
||||
filters = {}
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if not isinstance(date_value, date):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
if form_data.get('date_range'):
|
||||
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
|
||||
if dt_start:
|
||||
filters[f'{rel}datetime__gte'] = dt_start
|
||||
if dt_end:
|
||||
filters[f'{rel}datetime__lt'] = dt_end
|
||||
|
||||
filters[f'{rel}datetime__gte'] = datetime_value
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if not isinstance(date_value, date):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
|
||||
filters[f'{rel}datetime__lte'] = datetime_value
|
||||
|
||||
if form_data.get('event_date_from'):
|
||||
date_value = form_data.get('event_date_from')
|
||||
if not isinstance(date_value, date):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
|
||||
annotations['event_date_max'] = Case(
|
||||
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
|
||||
default=F(f'{rel}event__date_from'),
|
||||
)
|
||||
filters['event_date_max__gte'] = datetime_value
|
||||
|
||||
if form_data.get('event_date_to'):
|
||||
date_value = form_data.get('event_date_to')
|
||||
if not isinstance(date_value, date):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
|
||||
annotations['event_date_min'] = Case(
|
||||
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
|
||||
default=F(f'{rel}event__date_from'),
|
||||
)
|
||||
filters['event_date_min__lte'] = datetime_value
|
||||
if form_data.get('event_date_range'):
|
||||
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['event_date_range'], self.timezone)
|
||||
if dt_start:
|
||||
annotations['event_date_max'] = Case(
|
||||
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
|
||||
default=F(f'{rel}event__date_from'),
|
||||
)
|
||||
filters['event_date_max__gte'] = dt_start
|
||||
if dt_end:
|
||||
annotations['event_date_min'] = Case(
|
||||
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
|
||||
default=F(f'{rel}event__date_from'),
|
||||
)
|
||||
filters['event_date_min__lt'] = dt_end
|
||||
|
||||
if filters:
|
||||
return qs.annotate(**annotations).filter(**filters)
|
||||
@@ -776,7 +752,10 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
class PaymentListExporter(ListExporter):
|
||||
identifier = 'paymentlist'
|
||||
verbose_name = gettext_lazy('Order payments and refunds')
|
||||
verbose_name = gettext_lazy('Payments and refunds')
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = gettext_lazy('Download a spreadsheet of all payments or refunds of every order.')
|
||||
featured = True
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
@@ -855,6 +834,8 @@ class PaymentListExporter(ListExporter):
|
||||
class QuotaListExporter(ListExporter):
|
||||
identifier = 'quotalist'
|
||||
verbose_name = gettext_lazy('Quota availabilities')
|
||||
category = pgettext_lazy('export_category', 'Product data')
|
||||
description = gettext_lazy('Download a spreadsheet of all quotas including their current availability.')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
has_subevents = self.event.has_subevents
|
||||
@@ -908,21 +889,17 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'giftcardtransactionlist'
|
||||
verbose_name = gettext_lazy('Gift card transactions')
|
||||
organizer_required_permission = 'can_manage_gift_cards'
|
||||
category = pgettext_lazy('export_category', 'Gift cards')
|
||||
description = gettext_lazy('Download a spreadsheet of all gift card transactions.')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
d = [
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
('date_range',
|
||||
DateFrameField(
|
||||
label=_('Date range'),
|
||||
include_future_frames=False,
|
||||
required=False
|
||||
)),
|
||||
]
|
||||
d = OrderedDict(d)
|
||||
@@ -933,22 +910,12 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
card__issuer=self.organizer,
|
||||
).order_by('datetime').select_related('card', 'order', 'order__event')
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(
|
||||
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
)
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
|
||||
qs = qs.filter(
|
||||
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
)
|
||||
if form_data.get('date_range'):
|
||||
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
|
||||
if dt_start:
|
||||
qs = qs.filter(datetime__gte=dt_start)
|
||||
if dt_end:
|
||||
qs = qs.filter(datetime__lt=dt_end)
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
@@ -978,6 +945,8 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
class GiftcardRedemptionListExporter(ListExporter):
|
||||
identifier = 'giftcardredemptionlist'
|
||||
verbose_name = gettext_lazy('Gift card redemptions')
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = gettext_lazy('Download a spreadsheet of all payments or refunds that involve gift cards.')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
payments = OrderPayment.objects.filter(
|
||||
@@ -1023,14 +992,18 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'giftcardlist'
|
||||
verbose_name = gettext_lazy('Gift cards')
|
||||
organizer_required_permission = 'can_manage_gift_cards'
|
||||
category = pgettext_lazy('export_category', 'Gift cards')
|
||||
description = gettext_lazy('Download a spreadsheet of all gift cards including their current value.')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date', forms.DateTimeField(
|
||||
('date', forms.SplitDateTimeField(
|
||||
label=_('Show value at'),
|
||||
initial=now(),
|
||||
required=False,
|
||||
widget=SplitDateTimePickerWidget(),
|
||||
help_text=_('Defaults to the time of report.')
|
||||
)),
|
||||
('testmode', forms.ChoiceField(
|
||||
label=_('Test mode'),
|
||||
@@ -1058,12 +1031,13 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
)
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
d = form_data.get('date') or now()
|
||||
s = GiftCardTransaction.objects.filter(
|
||||
card=OuterRef('pk'),
|
||||
datetime__lte=form_data['date']
|
||||
datetime__lte=d
|
||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||
qs = self.organizer.issued_gift_cards.filter(
|
||||
issuance__lte=form_data['date']
|
||||
issuance__lte=d
|
||||
).annotate(
|
||||
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
|
||||
).order_by('issuance').prefetch_related(
|
||||
@@ -1078,11 +1052,11 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
if form_data.get('state') == 'empty':
|
||||
qs = qs.filter(cached_value=0)
|
||||
elif form_data.get('state') == 'valid_value':
|
||||
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
|
||||
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=d))
|
||||
elif form_data.get('state') == 'expired_value':
|
||||
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
|
||||
qs = qs.exclude(cached_value=0).filter(expires__lt=d)
|
||||
elif form_data.get('state') == 'expired':
|
||||
qs = qs.filter(expires__lt=form_data['date'])
|
||||
qs = qs.filter(expires__lt=d)
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
|
||||
@@ -39,6 +39,8 @@ from ..signals import (
|
||||
class WaitingListExporter(ListExporter):
|
||||
identifier = 'waitinglist'
|
||||
verbose_name = _('Waiting list')
|
||||
category = pgettext_lazy('export_category', 'Waiting list')
|
||||
description = _('Download a spread sheet with all your waiting list data.')
|
||||
|
||||
# map selected status to label and queryset-filter
|
||||
status_filters = [
|
||||
|
||||
@@ -531,7 +531,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
||||
code='aspect_ratio_not_3_by_4',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception('foo')
|
||||
logger.exception('Could not parse image')
|
||||
# Pillow doesn't recognize it as an image.
|
||||
if isinstance(exc, ValidationError):
|
||||
raise
|
||||
@@ -575,7 +575,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
|
||||
add_fields = {}
|
||||
|
||||
if item.admission and event.settings.attendee_names_asked:
|
||||
if item.ask_attendee_data and event.settings.attendee_names_asked:
|
||||
add_fields['attendee_name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.attendee_names_required and not self.all_optional,
|
||||
@@ -584,7 +584,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
label=_('Attendee name'),
|
||||
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
|
||||
)
|
||||
if item.admission and event.settings.attendee_emails_asked:
|
||||
if item.ask_attendee_data and event.settings.attendee_emails_asked:
|
||||
add_fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required and not self.all_optional,
|
||||
label=_('Attendee email'),
|
||||
@@ -595,7 +595,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
}
|
||||
)
|
||||
)
|
||||
if item.admission and event.settings.attendee_company_asked:
|
||||
if item.ask_attendee_data and event.settings.attendee_company_asked:
|
||||
add_fields['company'] = forms.CharField(
|
||||
required=event.settings.attendee_company_required and not self.all_optional,
|
||||
label=_('Company'),
|
||||
@@ -603,7 +603,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial=(cartpos.company if cartpos else orderpos.company),
|
||||
)
|
||||
|
||||
if item.admission and event.settings.attendee_addresses_asked:
|
||||
if item.ask_attendee_data and event.settings.attendee_addresses_asked:
|
||||
add_fields['street'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('Address'),
|
||||
|
||||
@@ -30,7 +30,6 @@ from django.urls import get_script_prefix
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.translation import LANGUAGE_SESSION_KEY
|
||||
from django.utils.translation.trans_real import (
|
||||
check_for_language, get_supported_language_variant, language_code_re,
|
||||
parse_accept_lang_header,
|
||||
@@ -128,12 +127,7 @@ def get_language_from_user_settings(request: HttpRequest) -> str:
|
||||
return lang_code
|
||||
|
||||
|
||||
def get_language_from_session_or_cookie(request: HttpRequest) -> str:
|
||||
if hasattr(request, 'session'):
|
||||
lang_code = request.session.get(LANGUAGE_SESSION_KEY)
|
||||
if lang_code in _supported and lang_code is not None and check_for_language(lang_code):
|
||||
return lang_code
|
||||
|
||||
def get_language_from_cookie(request: HttpRequest) -> str:
|
||||
lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
|
||||
try:
|
||||
return get_supported_language_variant(lang_code)
|
||||
@@ -187,14 +181,14 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
return (
|
||||
get_language_from_user_settings(request)
|
||||
or get_language_from_customer_settings(request)
|
||||
or get_language_from_session_or_cookie(request)
|
||||
or get_language_from_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
or get_default_language()
|
||||
)
|
||||
else:
|
||||
return (
|
||||
get_language_from_session_or_cookie(request)
|
||||
get_language_from_cookie(request)
|
||||
or get_language_from_customer_settings(request)
|
||||
or get_language_from_user_settings(request)
|
||||
or get_language_from_browser(request)
|
||||
|
||||
27
src/pretix/base/migrations/0227_item_personalized.py
Normal file
27
src/pretix/base/migrations/0227_item_personalized.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.2.16 on 2022-12-21 08:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def item_set_personalized(apps, schema_editor):
|
||||
# We cannot really know if a position was bundled or an add-on, but we can at least guess
|
||||
Item = apps.get_model("pretixbase", "Item")
|
||||
Item.objects.filter(admission=True).update(personalized=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0226_itemvariationmetavalue'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='personalized',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(
|
||||
item_set_personalized,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
# Generated by Django 3.2.16 on 2023-01-18 11:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0227_item_personalized'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ScheduledOrganizerExport',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('export_identifier', models.CharField(max_length=190)),
|
||||
('export_form_data', models.JSONField(default=dict)),
|
||||
('locale', models.CharField(max_length=250)),
|
||||
('mail_additional_recipients', models.TextField()),
|
||||
('mail_additional_recipients_cc', models.TextField()),
|
||||
('mail_additional_recipients_bcc', models.TextField()),
|
||||
('mail_subject', models.CharField(max_length=250)),
|
||||
('mail_template', models.TextField()),
|
||||
('schedule_rrule', models.TextField(null=True)),
|
||||
('schedule_rrule_time', models.TimeField()),
|
||||
('schedule_next_run', models.DateTimeField(blank=True, null=True)),
|
||||
('error_counter', models.IntegerField(default=0)),
|
||||
('error_last_message', models.TextField(null=True)),
|
||||
('timezone', models.CharField(default='UTC', max_length=100)),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_exports', to='pretixbase.organizer')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScheduledEventExport',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('export_identifier', models.CharField(max_length=190)),
|
||||
('export_form_data', models.JSONField(default=dict)),
|
||||
('locale', models.CharField(max_length=250)),
|
||||
('mail_additional_recipients', models.TextField()),
|
||||
('mail_additional_recipients_cc', models.TextField()),
|
||||
('mail_additional_recipients_bcc', models.TextField()),
|
||||
('mail_subject', models.CharField(max_length=250)),
|
||||
('mail_template', models.TextField()),
|
||||
('schedule_rrule', models.TextField(null=True)),
|
||||
('schedule_rrule_time', models.TimeField()),
|
||||
('schedule_next_run', models.DateTimeField(blank=True, null=True)),
|
||||
('error_counter', models.IntegerField(default=0)),
|
||||
('error_last_message', models.TextField(null=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_exports', to='pretixbase.event')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
]
|
||||
@@ -30,6 +30,7 @@ from .event import (
|
||||
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
||||
SubEvent, SubEventMetaValue, generate_invite_token,
|
||||
)
|
||||
from .exports import ScheduledEventExport, ScheduledOrganizerExport
|
||||
from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
|
||||
@@ -36,13 +36,17 @@ from datetime import timedelta
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Exists, F, Max, OuterRef, Q, Subquery
|
||||
from django.db.models import (
|
||||
Count, Exists, F, Max, OuterRef, Q, Subquery, Value, Window,
|
||||
)
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.helpers import PostgresWindowFrame
|
||||
|
||||
|
||||
class CheckinList(LoggedModel):
|
||||
@@ -95,15 +99,18 @@ class CheckinList(LoggedModel):
|
||||
class Meta:
|
||||
ordering = ('subevent__date_from', 'name')
|
||||
|
||||
@property
|
||||
def positions(self):
|
||||
def positions_query(self, ignore_status=False):
|
||||
from . import Order, OrderPosition
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
qs = OrderPosition.all.filter(
|
||||
order__event=self.event,
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [
|
||||
Order.STATUS_PAID],
|
||||
)
|
||||
if not ignore_status:
|
||||
qs = qs.filter(
|
||||
canceled=False,
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [Order.STATUS_PAID],
|
||||
)
|
||||
|
||||
if self.subevent_id:
|
||||
qs = qs.filter(subevent_id=self.subevent_id)
|
||||
if not self.all_products:
|
||||
@@ -111,36 +118,90 @@ class CheckinList(LoggedModel):
|
||||
return qs
|
||||
|
||||
@property
|
||||
def positions_inside(self):
|
||||
return self.positions.annotate(
|
||||
last_entry=Subquery(
|
||||
Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.pk,
|
||||
type=Checkin.TYPE_ENTRY,
|
||||
).order_by().values('position_id').annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
),
|
||||
last_exit=Subquery(
|
||||
Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.pk,
|
||||
type=Checkin.TYPE_EXIT,
|
||||
).order_by().values('position_id').annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
),
|
||||
).filter(
|
||||
Q(last_entry__isnull=False)
|
||||
& Q(
|
||||
Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry'))
|
||||
def positions(self):
|
||||
return self.positions_query(ignore_status=False)
|
||||
|
||||
@scopes_disabled()
|
||||
def positions_inside_query(self, ignore_status=False, at_time=None):
|
||||
if at_time is None:
|
||||
c_q = []
|
||||
else:
|
||||
c_q = [Q(datetime__lt=at_time)]
|
||||
|
||||
if "postgresql" not in settings.DATABASES["default"]["ENGINE"]:
|
||||
# Use a simple approach that works on all databases
|
||||
qs = self.positions_query(ignore_status=ignore_status).annotate(
|
||||
last_entry=Subquery(
|
||||
Checkin.objects.filter(
|
||||
*c_q,
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.pk,
|
||||
type=Checkin.TYPE_ENTRY,
|
||||
).order_by().values('position_id').annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
),
|
||||
last_exit=Subquery(
|
||||
Checkin.objects.filter(
|
||||
*c_q,
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.pk,
|
||||
type=Checkin.TYPE_EXIT,
|
||||
).order_by().values('position_id').annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
),
|
||||
).filter(
|
||||
Q(last_entry__isnull=False)
|
||||
& Q(
|
||||
Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry'))
|
||||
)
|
||||
)
|
||||
return qs
|
||||
|
||||
# Use the PostgreSQL-specific query using Window functions, which is a lot faster.
|
||||
# On a real-world example with ~100k tickets, of which ~17k are checked in, we observed
|
||||
# a speed-up from 29s (old) to a few hundred milliseconds (new)!
|
||||
# Why is this so much faster? The regular query get's PostgreSQL all busy with filtering
|
||||
# the tickets both by their belonging the event and checkin status at the same time, while
|
||||
# this query just iterates over all successful checkins on the list, and -- by the power
|
||||
# of window functions -- asks "is this an entry that is followed by no exit?". Then we
|
||||
# dedupliate by position and count it up.
|
||||
cl = self
|
||||
base_q, base_params = (
|
||||
Checkin.all.filter(*c_q, successful=True, list=cl)
|
||||
.annotate(
|
||||
cnt_exists_after=Window(
|
||||
expression=Count("position_id", filter=Q(type=Value("exit"))),
|
||||
partition_by=[F("position_id"), F("list_id")],
|
||||
order_by=F("datetime").asc(),
|
||||
frame=PostgresWindowFrame(
|
||||
"ROWS", start="1 following", end="unbounded following"
|
||||
),
|
||||
)
|
||||
)
|
||||
.values("position_id", "type", "datetime", "cnt_exists_after")
|
||||
.query.sql_with_params()
|
||||
)
|
||||
return self.positions_query(ignore_status=ignore_status).filter(
|
||||
pk__in=RawSQL(
|
||||
f"""
|
||||
SELECT "position_id"
|
||||
FROM ({str(base_q)}) s
|
||||
WHERE "type" = %s AND "cnt_exists_after" = 0
|
||||
GROUP BY "position_id"
|
||||
""",
|
||||
[*base_params, Checkin.TYPE_ENTRY]
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def positions_inside(self):
|
||||
return self.positions_inside_query(None)
|
||||
|
||||
@property
|
||||
def inside_count(self):
|
||||
return self.positions_inside.count()
|
||||
return self.positions_inside_query(None).count()
|
||||
|
||||
@property
|
||||
@scopes_disabled()
|
||||
|
||||
@@ -36,7 +36,7 @@ import logging
|
||||
import os
|
||||
import string
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from collections import Counter, OrderedDict, defaultdict
|
||||
from datetime import datetime, time, timedelta
|
||||
from operator import attrgetter
|
||||
from urllib.parse import urljoin
|
||||
@@ -340,64 +340,104 @@ class EventMixin:
|
||||
)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def best_availability_state(self):
|
||||
return self.best_availability[0]
|
||||
|
||||
@property
|
||||
def best_availability_is_low(self):
|
||||
"""
|
||||
Returns ``True`` if the availability of tickets in this event is lower than the percentage
|
||||
given in setting ``low_availability_percentage``.
|
||||
"""
|
||||
if not self.settings.low_availability_percentage:
|
||||
return False
|
||||
ba = self.best_availability
|
||||
if ba[1] is None or not ba[2]:
|
||||
return False
|
||||
|
||||
percentage = ba[1] / ba[2] * 100
|
||||
return percentage < self.settings.low_availability_percentage
|
||||
|
||||
@cached_property
|
||||
def best_availability(self):
|
||||
"""
|
||||
Returns a 3-tuple of
|
||||
|
||||
- The availability state of this event (one of the ``Quota.AVAILABILITY_*`` constants)
|
||||
- The number of tickets currently available (or ``None``)
|
||||
- The number of tickets "originally" available (or ``None``)
|
||||
|
||||
This can only be called on objects obtained through a queryset that has been passed through ``.annotated()``.
|
||||
"""
|
||||
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()
|
||||
items_disabled = set()
|
||||
vars_disabled = set()
|
||||
|
||||
if hasattr(self, 'disabled_items'): # SubEventItem
|
||||
items_disabled = set(self.disabled_items.split(","))
|
||||
else:
|
||||
items_disabled = set()
|
||||
|
||||
if hasattr(self, 'disabled_vars'): # SubEventItemVariation
|
||||
vars_disabled = set(self.disabled_vars.split(","))
|
||||
else:
|
||||
vars_disabled = set()
|
||||
|
||||
# Compute the availability of all quotas and build a item→quotas mapping with all non-disabled items
|
||||
r = getattr(self, '_quota_cache', {})
|
||||
quotas_for_item = defaultdict(list)
|
||||
quotas_for_variation = defaultdict(list)
|
||||
for q in self.active_quotas:
|
||||
res = r[q] if q in r else q.availability(allow_cache=True)
|
||||
if q not in r:
|
||||
r[q] = 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 q.active_items:
|
||||
for item_id in q.active_items.split(","):
|
||||
if item_id not in items_disabled:
|
||||
quotas_for_item[item_id].append(q)
|
||||
if q.active_variations:
|
||||
for var_id in q.active_variations.split(","):
|
||||
if var_id not in vars_disabled:
|
||||
quotas_for_variation[var_id].append(q)
|
||||
|
||||
items_available -= items_disabled
|
||||
items_reserved -= items_disabled
|
||||
items_gone -= items_disabled
|
||||
vars_available -= vars_disabled
|
||||
vars_reserved -= vars_disabled
|
||||
vars_gone -= vars_disabled
|
||||
if not self.active_quotas or (not quotas_for_item and not quotas_for_variation):
|
||||
# No item is enabled for this event, treat the event as "unknown"
|
||||
return None, None, None
|
||||
|
||||
if not self.active_quotas or (
|
||||
not items_available and not items_reserved and not items_gone and not vars_gone and not vars_available and not vars_reserved
|
||||
):
|
||||
return None
|
||||
# We iterate over all items and variations and keep track of
|
||||
# - `best_state_found` - the best availability state we have seen so far. If one item is available, the event is available!
|
||||
# - `num_tickets_found` - the number of tickets currently available in total. We sum up all the items and variations, but keep
|
||||
# track of them per-quota in `quota_used_for_found_tickets` to make sure we don't count the same tickets twice if two or more
|
||||
# items share the same quota
|
||||
# - `num_tickets_possible` - basically the same thing, just with the total size of quotas instead of their currently availability
|
||||
# since we need that for the percentage calculation
|
||||
best_state_found = Quota.AVAILABILITY_GONE
|
||||
num_tickets_found = 0
|
||||
num_tickets_possible = 0
|
||||
quota_used_for_found_tickets = Counter()
|
||||
quota_used_for_possible_tickets = Counter()
|
||||
for quota_list in list(quotas_for_item.values()) + list(quotas_for_variation.values()):
|
||||
worst_state_for_ticket = min(r[q][0] for q in quota_list)
|
||||
quotas_that_are_not_unlimited = [q for q in quota_list if q.size is not None]
|
||||
if not quotas_that_are_not_unlimited:
|
||||
# We found an unlimited ticket, no more need to do anything else
|
||||
return Quota.AVAILABILITY_OK, None, 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
|
||||
if worst_state_for_ticket == Quota.AVAILABILITY_OK:
|
||||
availability_of_this = min(max(0, r[q][1] - quota_used_for_found_tickets[q]) for q in quotas_that_are_not_unlimited)
|
||||
num_tickets_found += availability_of_this
|
||||
for q in quota_list:
|
||||
quota_used_for_found_tickets[q] += availability_of_this
|
||||
|
||||
possible_of_this = min(max(0, q.size - quota_used_for_possible_tickets[q]) for q in quotas_that_are_not_unlimited)
|
||||
num_tickets_possible += possible_of_this
|
||||
for q in quota_list:
|
||||
quota_used_for_possible_tickets[q] += possible_of_this
|
||||
|
||||
best_state_found = max(best_state_found, worst_state_for_ticket)
|
||||
return best_state_found, num_tickets_found, num_tickets_possible
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
qs_annotated = self._seats(ignore_voucher=ignore_voucher)
|
||||
@@ -591,6 +631,7 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.invoice_email_attachment = True
|
||||
self.settings.name_scheme = 'given_family'
|
||||
self.settings.payment_banktransfer_invoice_immediately = True
|
||||
self.settings.low_availability_percentage = 10
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
@@ -632,6 +673,7 @@ class Event(EventMixin, LoggedModel):
|
||||
return super().presale_has_ended
|
||||
|
||||
def delete_all_orders(self, really=False):
|
||||
from .checkin import Checkin
|
||||
from .orders import (
|
||||
OrderFee, OrderPayment, OrderPosition, OrderRefund, Transaction,
|
||||
)
|
||||
@@ -645,6 +687,7 @@ class Event(EventMixin, LoggedModel):
|
||||
OrderFee.objects.filter(order__event=self).delete()
|
||||
OrderRefund.objects.filter(order__event=self).delete()
|
||||
OrderPayment.objects.filter(order__event=self).delete()
|
||||
Checkin.objects.filter(list__event=self).delete()
|
||||
self.orders.all().delete()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
139
src/pretix/base/models/exports.py
Normal file
139
src/pretix/base/models/exports.py
Normal file
@@ -0,0 +1,139 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
from dateutil.rrule import rrulestr
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
from pretix.base.validators import RRuleValidator, multimail_validate
|
||||
|
||||
|
||||
class AbstractScheduledExport(LoggedModel):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
|
||||
export_identifier = models.CharField(
|
||||
max_length=190,
|
||||
verbose_name=_("Export"),
|
||||
)
|
||||
export_form_data = models.JSONField(
|
||||
default=dict,
|
||||
encoder=DjangoJSONEncoder,
|
||||
)
|
||||
|
||||
owner = models.ForeignKey(
|
||||
"pretixbase.User",
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
locale = models.CharField(
|
||||
verbose_name=_('Language'),
|
||||
max_length=250
|
||||
)
|
||||
|
||||
mail_additional_recipients = models.TextField(
|
||||
verbose_name=_('Additional recipients'),
|
||||
null=False, blank=True, validators=[multimail_validate],
|
||||
help_text=_("You can specify multiple recipients separated by commas.")
|
||||
)
|
||||
mail_additional_recipients_cc = models.TextField(
|
||||
verbose_name=_('Additional recipients (Cc)'),
|
||||
null=False, blank=True, validators=[multimail_validate],
|
||||
help_text=_("You can specify multiple recipients separated by commas.")
|
||||
)
|
||||
mail_additional_recipients_bcc = models.TextField(
|
||||
verbose_name=_('Additional recipients (Bcc)'),
|
||||
null=False, blank=True, validators=[multimail_validate],
|
||||
help_text=_("You can specify multiple recipients separated by commas.")
|
||||
)
|
||||
mail_subject = models.CharField(
|
||||
verbose_name=_('Subject'),
|
||||
max_length=250
|
||||
)
|
||||
mail_template = models.TextField(
|
||||
verbose_name=_('Message'),
|
||||
)
|
||||
|
||||
schedule_rrule = models.TextField(
|
||||
null=True, blank=True, validators=[RRuleValidator()]
|
||||
)
|
||||
schedule_rrule_time = models.TimeField(
|
||||
verbose_name=_("Requested start time"),
|
||||
help_text=_("The actual start time might be delayed depending on system load."),
|
||||
)
|
||||
schedule_next_run = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
error_counter = models.IntegerField(default=0)
|
||||
error_last_message = models.TextField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
return self.mail_subject
|
||||
|
||||
def compute_next_run(self):
|
||||
tz = self.tz
|
||||
r = rrulestr(self.schedule_rrule)
|
||||
|
||||
base_dt = now().astimezone(tz).replace(tzinfo=None)
|
||||
if now().astimezone(tz).time() < self.schedule_rrule_time:
|
||||
base_dt -= timedelta(days=1)
|
||||
|
||||
new_d = r.after(base_dt, inc=False)
|
||||
if not new_d:
|
||||
self.schedule_next_run = None
|
||||
return
|
||||
|
||||
try:
|
||||
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz)
|
||||
except pytz.exceptions.AmbiguousTimeError:
|
||||
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz, is_dst=False)
|
||||
except pytz.exceptions.NonExistentTimeError:
|
||||
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time) + timedelta(hours=1), tz)
|
||||
|
||||
|
||||
class ScheduledEventExport(AbstractScheduledExport):
|
||||
event = models.ForeignKey(
|
||||
"pretixbase.Event", on_delete=models.CASCADE, related_name="scheduled_exports"
|
||||
)
|
||||
|
||||
@property
|
||||
def tz(self):
|
||||
return self.event.timezone
|
||||
|
||||
|
||||
class ScheduledOrganizerExport(AbstractScheduledExport):
|
||||
organizer = models.ForeignKey(
|
||||
"pretixbase.Organizer", on_delete=models.CASCADE, related_name="scheduled_exports"
|
||||
)
|
||||
timezone = models.CharField(max_length=100,
|
||||
default=settings.TIME_ZONE,
|
||||
verbose_name=_('Timezone'))
|
||||
|
||||
@property
|
||||
def tz(self):
|
||||
return pytz.timezone(self.timezone)
|
||||
@@ -62,6 +62,7 @@ from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
|
||||
from ...helpers.images import ImageSizeValidator
|
||||
from .event import Event, SubEvent
|
||||
|
||||
|
||||
@@ -310,6 +311,8 @@ class Item(LoggedModel):
|
||||
:type tax_rate: decimal.Decimal
|
||||
:param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise)
|
||||
:type admission: bool
|
||||
:param personalized: ``True``, if attendee information should be collected for this ticket
|
||||
:type personalized: bool
|
||||
:param picture: A product picture to be shown next to the product description
|
||||
:type picture: File
|
||||
:param available_from: The date this product goes on sale
|
||||
@@ -396,8 +399,14 @@ class Item(LoggedModel):
|
||||
admission = models.BooleanField(
|
||||
verbose_name=_("Is an admission ticket"),
|
||||
help_text=_(
|
||||
'Whether or not buying this product allows a person to enter '
|
||||
'your event'
|
||||
'Whether or not buying this product allows a person to enter your event'
|
||||
),
|
||||
default=False
|
||||
)
|
||||
personalized = models.BooleanField(
|
||||
verbose_name=_("Is a personalized ticket"),
|
||||
help_text=_(
|
||||
'Whether or not buying this product allows to enter attendee information'
|
||||
),
|
||||
default=False
|
||||
)
|
||||
@@ -421,7 +430,8 @@ class Item(LoggedModel):
|
||||
picture = models.ImageField(
|
||||
verbose_name=_("Product picture"),
|
||||
null=True, blank=True, max_length=255,
|
||||
upload_to=itempicture_upload_to
|
||||
upload_to=itempicture_upload_to,
|
||||
validators=[ImageSizeValidator()]
|
||||
)
|
||||
available_from = models.DateTimeField(
|
||||
verbose_name=_("Available from"),
|
||||
@@ -578,6 +588,10 @@ class Item(LoggedModel):
|
||||
return self.event.settings.show_quota_left
|
||||
return self.show_quota_left
|
||||
|
||||
@property
|
||||
def ask_attendee_data(self):
|
||||
return self.admission and self.personalized
|
||||
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
|
||||
price = price if price is not None else self.default_price
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ from pretix.base.services.locking import LOCK_TIMEOUT, NoLockManager
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import order_gracefully_delete
|
||||
|
||||
from ...helpers import OF_SELF
|
||||
from ...helpers.countries import CachedCountries, FastCountryField
|
||||
from ...helpers.format import format_map
|
||||
from ._transactions import (
|
||||
@@ -808,7 +809,7 @@ class Order(LockModel, LoggedModel):
|
||||
return True
|
||||
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
|
||||
for cp in positions:
|
||||
if (cp.item.admission and ask_names) or cp.item.questions.all():
|
||||
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
|
||||
return True
|
||||
|
||||
return False # nothing there to modify
|
||||
@@ -1628,13 +1629,13 @@ class OrderPayment(models.Model):
|
||||
been marked as paid.
|
||||
"""
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
locked_instance = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
|
||||
if locked_instance.state not in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING):
|
||||
# Race condition detected, this payment is already confirmed
|
||||
logger.info('Failed payment {} but ignored due to likely race condition.'.format(
|
||||
self.full_id,
|
||||
))
|
||||
return
|
||||
return False
|
||||
|
||||
if isinstance(info, str):
|
||||
locked_instance.info = info
|
||||
@@ -1650,6 +1651,7 @@ class OrderPayment(models.Model):
|
||||
'info': info,
|
||||
'data': log_data,
|
||||
}, user=user, auth=auth)
|
||||
return True
|
||||
|
||||
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
|
||||
ignore_date=False, lock=True, payment_date=None, generate_invoice=True):
|
||||
@@ -1673,7 +1675,7 @@ class OrderPayment(models.Model):
|
||||
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
|
||||
"""
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
locked_instance = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
|
||||
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
|
||||
# Race condition detected, this payment is already confirmed
|
||||
logger.info('Confirmed payment {} but ignored due to likely race condition.'.format(
|
||||
@@ -2688,7 +2690,7 @@ class CartPosition(AbstractPosition):
|
||||
category_key = (self.item.category.position, self.item.category.id) if self.item.category_id is not None else (0, 0)
|
||||
item_key = self.item.position, self.item_id
|
||||
variation_key = (self.variation.position, self.variation.id) if self.variation_id is not None else (0, 0)
|
||||
line_key = (self.price, (self.voucher_id or 0), (self.seat.sorting_rank if self.seat_id else None), self.pk)
|
||||
line_key = (self.price, (self.voucher_id or 0), (self.seat.sorting_rank if self.seat_id else 0), self.pk)
|
||||
sort_key = subevent_key + category_key + item_key + variation_key + line_key
|
||||
|
||||
if self.addon_to_id:
|
||||
|
||||
@@ -23,6 +23,7 @@ import json
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
@@ -149,7 +150,15 @@ class TaxRule(LoggedModel):
|
||||
rate = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
verbose_name=_("Tax rate")
|
||||
validators=[
|
||||
MaxValueValidator(
|
||||
limit_value=Decimal("100.00"),
|
||||
),
|
||||
MinValueValidator(
|
||||
limit_value=Decimal("0.00"),
|
||||
),
|
||||
],
|
||||
verbose_name=_("Tax rate"),
|
||||
)
|
||||
price_includes_tax = models.BooleanField(
|
||||
verbose_name=_("The configured product prices include the tax amount"),
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db import models, transaction
|
||||
@@ -27,14 +28,16 @@ from django.db.models import F, Q, Sum
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Voucher
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.models import User, Voucher
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ...helpers.format import format_map
|
||||
from .base import LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation
|
||||
@@ -213,15 +216,74 @@ class WaitingListEntry(LoggedModel):
|
||||
self.voucher = v
|
||||
self.save()
|
||||
|
||||
self.send_mail(
|
||||
self.event.settings.mail_subject_waiting_list,
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
get_email_context(
|
||||
event=self.event,
|
||||
waiting_list_entry=self,
|
||||
waiting_list_voucher=v,
|
||||
event_or_subevent=self.subevent or self.event,
|
||||
),
|
||||
user=user,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.waitinglist.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, auth=None, auto_email=True,
|
||||
attach_other_files: list=None, attach_cached_files: list=None):
|
||||
"""
|
||||
Sends an email to the entry's contact address.
|
||||
|
||||
* Call ``pretix.base.services.mail.mail`` with useful values for the ``event``, ``locale``, and ``recipient``
|
||||
parameters.
|
||||
|
||||
* Create a ``LogEntry`` with the email contents.
|
||||
|
||||
:param subject: Subject of the email
|
||||
:param template: LazyI18nString or template filename, see ``pretix.base.services.mail.mail`` for more details
|
||||
:param context: Dictionary to use for rendering the template
|
||||
:param log_entry_type: Key to be used for the log entry
|
||||
:param user: Administrative user who triggered this mail to be sent
|
||||
:param headers: Dictionary with additional mail headers
|
||||
:param sender: Custom email sender.
|
||||
"""
|
||||
if not self.email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale, self.event.settings.region):
|
||||
mail(
|
||||
self.email,
|
||||
self.event.settings.mail_subject_waiting_list,
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
get_email_context(event=self.event, waiting_list_entry=self),
|
||||
self.event,
|
||||
locale=self.locale
|
||||
)
|
||||
recipient = self.email
|
||||
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
subject = format_map(subject, context)
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event,
|
||||
self.locale,
|
||||
headers=headers,
|
||||
sender=sender,
|
||||
auto_email=auto_email,
|
||||
attach_other_files=attach_other_files,
|
||||
attach_cached_files=attach_cached_files,
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
else:
|
||||
self.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'recipient': recipient,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def clean_itemvar(event, item, variation):
|
||||
|
||||
@@ -67,6 +67,7 @@ from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
@@ -1399,7 +1400,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
try:
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gcpk)
|
||||
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gcpk)
|
||||
except GiftCard.DoesNotExist:
|
||||
raise PaymentException(_("This gift card does not support this currency."))
|
||||
if gc.currency != self.event.currency: # noqa - just a safeguard
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -46,12 +47,15 @@ from collections import OrderedDict
|
||||
from functools import partial
|
||||
from io import BytesIO
|
||||
|
||||
import jsonschema
|
||||
from arabic_reshaper import ArabicReshaper
|
||||
from bidi.algorithm import get_display
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Max, Min
|
||||
from django.dispatch import receiver
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.html import conditional_escape
|
||||
@@ -740,9 +744,9 @@ class Renderer:
|
||||
|
||||
if o['content'] == 'other' or o['content'] == 'other_i18n':
|
||||
if o['content'] == 'other_i18n':
|
||||
text = str(LazyI18nString(o['text_i18n']))
|
||||
text = str(LazyI18nString(o.get('text_i18n', {})))
|
||||
else:
|
||||
text = o['text']
|
||||
text = o.get('text', '')
|
||||
|
||||
def replace(x):
|
||||
if x.group(1).startswith('itemmeta:'):
|
||||
@@ -858,6 +862,10 @@ class Renderer:
|
||||
self._get_text_content(op, order, o) or "",
|
||||
).replace("\n", "<br/>\n").replace("-", "- ")
|
||||
|
||||
# reportlab does not support unicode combination characters
|
||||
# It's important we do this before we use ArabicReshaper
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
|
||||
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
|
||||
# to resolve all ligatures and python-bidi to switch RTL texts.
|
||||
try:
|
||||
@@ -865,9 +873,6 @@ class Renderer:
|
||||
except:
|
||||
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
|
||||
|
||||
# reportlab does not support unicode combination characters
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
|
||||
p = Paragraph(text, style=style)
|
||||
w, h = p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
|
||||
# p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
|
||||
@@ -975,3 +980,22 @@ class Renderer:
|
||||
output.write(outbuffer)
|
||||
outbuffer.seek(0)
|
||||
return outbuffer
|
||||
|
||||
|
||||
@deconstructible
|
||||
class PdfLayoutValidator:
|
||||
def __call__(self, value):
|
||||
if not isinstance(value, dict):
|
||||
try:
|
||||
val = json.loads(value)
|
||||
except ValueError:
|
||||
raise ValidationError(_('Your layout file is not a valid JSON file.'))
|
||||
else:
|
||||
val = value
|
||||
with open(finders.find('schema/pdf-layout.schema.json'), 'r') as f:
|
||||
schema = json.loads(f.read())
|
||||
try:
|
||||
jsonschema.validate(val, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
e = str(e).replace('%', '%%')
|
||||
raise ValidationError(_('Your layout file is not a valid layout. Error message: {}').format(e))
|
||||
|
||||
@@ -59,10 +59,10 @@ class RelativeDateWrapper:
|
||||
def date(self, event) -> datetime.date:
|
||||
from .models import SubEvent
|
||||
|
||||
if isinstance(self.data, datetime.date):
|
||||
return self.data
|
||||
elif isinstance(self.data, datetime.datetime):
|
||||
if isinstance(self.data, datetime.datetime):
|
||||
return self.data.date()
|
||||
elif isinstance(self.data, datetime.date):
|
||||
return self.data
|
||||
else:
|
||||
if self.data.minutes_before is not None:
|
||||
raise ValueError('A minute-based relative datetime can not be used as a date')
|
||||
|
||||
@@ -41,6 +41,7 @@ from pretix.base.services.orders import (
|
||||
)
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.format import format_map
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -239,7 +240,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
|
||||
for o in orders_to_change.values_list('id', flat=True).iterator():
|
||||
with transaction.atomic():
|
||||
o = event.orders.select_for_update().get(pk=o)
|
||||
o = event.orders.select_for_update(of=OF_SELF).get(pk=o)
|
||||
total = Decimal('0.00')
|
||||
fee = Decimal('0.00')
|
||||
positions = []
|
||||
|
||||
@@ -43,7 +43,9 @@ from django.db import DatabaseError, transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext as _, pgettext_lazy
|
||||
from django.utils.translation import (
|
||||
gettext as _, gettext_lazy, ngettext_lazy, pgettext_lazy,
|
||||
)
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
@@ -81,74 +83,119 @@ class CartError(Exception):
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
msg = _(msg)
|
||||
# force msg to string to make sure lazy-translation is done in current locale-context
|
||||
# otherwise translation might happen in celery-context, which uses default-locale
|
||||
# also translate with _/gettext to keep it backwards compatible
|
||||
msg = _(str(msg))
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
error_messages = {
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'empty': _('You did not select any products.'),
|
||||
'unknown_position': _('Unknown cart position.'),
|
||||
'busy': gettext_lazy(
|
||||
'We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'
|
||||
),
|
||||
'empty': gettext_lazy('You did not select any products.'),
|
||||
'unknown_position': gettext_lazy('Unknown cart position.'),
|
||||
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
|
||||
'not_for_sale': _('You selected a product which is not available for sale.'),
|
||||
'unavailable': _('Some of the products you selected are no longer available. '
|
||||
'Please see below for details.'),
|
||||
'in_part': _('Some of the products you selected are no longer available in '
|
||||
'the quantity you selected. Please see below for details.'),
|
||||
'max_items': _("You cannot select more than %s items per order."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s."),
|
||||
'min_items_per_product': _("You need to select at least %(min)s items of the product %(product)s."),
|
||||
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
|
||||
"%(min)s items of it."),
|
||||
'not_started': _('The booking period for this event has not yet started.'),
|
||||
'ended': _('The booking period for this event has ended.'),
|
||||
'payment_ended': _('All payments for this event need to be confirmed already, so no new orders can be created.'),
|
||||
'some_subevent_not_started': _('The booking period for this event has not yet started. The affected positions '
|
||||
'have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'price_too_high': _('The entered price is to high.'),
|
||||
'voucher_invalid': _('This voucher code is not known in our database.'),
|
||||
'voucher_min_usages': _('The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
|
||||
'matching products.'),
|
||||
'voucher_min_usages_removed': _('The voucher code "%(voucher)s" can only be used if you select at least '
|
||||
'%(number)s matching products. We have therefore removed some positions from '
|
||||
'your cart that can no longer be purchased like this.'),
|
||||
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
|
||||
'voucher_redeemed_cart': _('This voucher code is currently locked since it is already contained in a cart. This '
|
||||
'might mean that someone else is redeeming this voucher right now, or that you tried '
|
||||
'to redeem it before but did not complete the checkout process. You can try to use it '
|
||||
'again in %d minutes.'),
|
||||
'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'),
|
||||
'voucher_double': _('You already used this voucher code. Remove the associated line from your '
|
||||
'cart if you want to use it for a different product.'),
|
||||
'voucher_expired': _('This voucher is expired.'),
|
||||
'voucher_invalid_item': _('This voucher is not valid for this product.'),
|
||||
'voucher_invalid_seat': _('This voucher is not valid for this seat.'),
|
||||
'voucher_no_match': _('We did not find any position in your cart that we could use this voucher for. If you want '
|
||||
'to add something new to your cart using that voucher, you can do so with the voucher '
|
||||
'redemption option on the bottom of the page.'),
|
||||
'voucher_item_not_available': _(
|
||||
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'),
|
||||
'unavailable': gettext_lazy(
|
||||
'Some of the products you selected are no longer available. '
|
||||
'Please see below for details.'
|
||||
),
|
||||
'in_part': gettext_lazy(
|
||||
'Some of the products you selected are no longer available in '
|
||||
'the quantity you selected. Please see below for details.'
|
||||
),
|
||||
'max_items': ngettext_lazy(
|
||||
"You cannot select more than %s item per order.",
|
||||
"You cannot select more than %s items per order."
|
||||
),
|
||||
'max_items_per_product': ngettext_lazy(
|
||||
"You cannot select more than %(max)s item of the product %(product)s.",
|
||||
"You cannot select more than %(max)s items of the product %(product)s.",
|
||||
"max"
|
||||
),
|
||||
'min_items_per_product': ngettext_lazy(
|
||||
"You need to select at least %(min)s item of the product %(product)s.",
|
||||
"You need to select at least %(min)s items of the product %(product)s.",
|
||||
"min"
|
||||
),
|
||||
'min_items_per_product_removed': ngettext_lazy(
|
||||
"We removed %(product)s from your cart as you can not buy less than %(min)s item of it.",
|
||||
"We removed %(product)s from your cart as you can not buy less than %(min)s items of it.",
|
||||
"min"
|
||||
),
|
||||
'not_started': gettext_lazy('The booking period for this event has not yet started.'),
|
||||
'ended': gettext_lazy('The booking period for this event has ended.'),
|
||||
'payment_ended': gettext_lazy('All payments for this event need to be confirmed already, so no new orders can be created.'),
|
||||
'some_subevent_not_started': gettext_lazy(
|
||||
'The booking period for this event has not yet started. The affected positions '
|
||||
'have been removed from your cart.'),
|
||||
'some_subevent_ended': gettext_lazy(
|
||||
'The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'price_too_high': gettext_lazy('The entered price is to high.'),
|
||||
'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'),
|
||||
'voucher_min_usages': gettext_lazy(
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
|
||||
'matching products.'
|
||||
),
|
||||
'voucher_min_usages_removed': ngettext_lazy(
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
|
||||
'We have therefore removed some positions from your cart that can no longer be purchased like this.',
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
|
||||
'We have therefore removed some positions from your cart that can no longer be purchased like this.',
|
||||
'number'
|
||||
),
|
||||
'voucher_redeemed': gettext_lazy('This voucher code has already been used the maximum number of times allowed.'),
|
||||
'voucher_redeemed_cart': gettext_lazy(
|
||||
'This voucher code is currently locked since it is already contained in a cart. This '
|
||||
'might mean that someone else is redeeming this voucher right now, or that you tried '
|
||||
'to redeem it before but did not complete the checkout process. You can try to use it '
|
||||
'again in %d minutes.'
|
||||
),
|
||||
'voucher_redeemed_partial': gettext_lazy('This voucher code can only be redeemed %d more times.'),
|
||||
'voucher_whole_cart_not_combined': gettext_lazy('Applying a voucher to the whole cart should not be combined with other operations.'),
|
||||
'voucher_double': gettext_lazy(
|
||||
'You already used this voucher code. Remove the associated line from your '
|
||||
'cart if you want to use it for a different product.'
|
||||
),
|
||||
'voucher_expired': gettext_lazy('This voucher is expired.'),
|
||||
'voucher_invalid_item': gettext_lazy('This voucher is not valid for this product.'),
|
||||
'voucher_invalid_seat': gettext_lazy('This voucher is not valid for this seat.'),
|
||||
'voucher_no_match': gettext_lazy(
|
||||
'We did not find any position in your cart that we could use this voucher for. If you want '
|
||||
'to add something new to your cart using that voucher, you can do so with the voucher '
|
||||
'redemption option on the bottom of the page.'
|
||||
),
|
||||
'voucher_item_not_available': gettext_lazy(
|
||||
'Your voucher is valid for a product that is currently not for sale.'),
|
||||
'voucher_invalid_subevent': pgettext_lazy('subevent', 'This voucher is not valid for this event date.'),
|
||||
'voucher_required': _('You need a valid voucher code to order this product.'),
|
||||
'voucher_required': gettext_lazy('You need a valid voucher code to order this product.'),
|
||||
'inactive_subevent': pgettext_lazy('subevent', 'The selected event date is not active.'),
|
||||
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
||||
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
||||
'product %(base)s.'),
|
||||
'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_only': _('One of the products you selected can only be bought as an add-on to another product.'),
|
||||
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
|
||||
'seat_required': _('You need to select a specific seat.'),
|
||||
'seat_invalid': _('Please select a valid seat.'),
|
||||
'seat_forbidden': _('You can not select a seat for this position.'),
|
||||
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
|
||||
'seat_multiple': _('You can not select the same seat multiple times.'),
|
||||
'gift_card': _("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
|
||||
'country_blocked': _('One of the selected products is not available in the selected country.'),
|
||||
'addon_invalid_base': gettext_lazy('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': gettext_lazy('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': ngettext_lazy(
|
||||
'You can select at most %(max)s add-on from the category %(cat)s for the product %(base)s.',
|
||||
'You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'max'
|
||||
),
|
||||
'addon_min_count': ngettext_lazy(
|
||||
'You need to select at least %(min)s add-on from the category %(cat)s for the product %(base)s.',
|
||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'min'
|
||||
),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_only': gettext_lazy('One of the products you selected can only be bought as an add-on to another product.'),
|
||||
'bundled_only': gettext_lazy('One of the products you selected can only be bought part of a bundle.'),
|
||||
'seat_required': gettext_lazy('You need to select a specific seat.'),
|
||||
'seat_invalid': gettext_lazy('Please select a valid seat.'),
|
||||
'seat_forbidden': gettext_lazy('You can not select a seat for this position.'),
|
||||
'seat_unavailable': gettext_lazy('The seat you selected has already been taken. Please select a different seat.'),
|
||||
'seat_multiple': gettext_lazy('You can not select the same seat multiple times.'),
|
||||
'gift_card': gettext_lazy("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
|
||||
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
|
||||
}
|
||||
|
||||
|
||||
@@ -320,8 +367,7 @@ class CartManager:
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
|
||||
not op.position.addon_to_id])
|
||||
if cartsize > int(self.event.settings.max_items_per_order):
|
||||
# TODO: i18n plurals
|
||||
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
|
||||
raise CartError(error_messages['max_items'] % self.event.settings.max_items_per_order)
|
||||
|
||||
def _check_item_constraints(self, op, current_ops=[]):
|
||||
if isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
||||
@@ -494,7 +540,7 @@ class CartManager:
|
||||
|
||||
def apply_voucher(self, voucher_code: str):
|
||||
if self._operations:
|
||||
raise CartError('Applying a voucher to the whole cart should not be combined with other operations.')
|
||||
raise CartError(error_messages['voucher_whole_cart_not_combined'])
|
||||
try:
|
||||
voucher = self.event.vouchers.get(code__iexact=voucher_code.strip())
|
||||
except Voucher.DoesNotExist:
|
||||
@@ -539,7 +585,7 @@ class CartManager:
|
||||
for voucher, cnt in list(voucher_use_diff.items()):
|
||||
if 0 < cnt < voucher.min_usages_remaining:
|
||||
raise CartError(
|
||||
_(error_messages['voucher_min_usages']) % {
|
||||
error_messages['voucher_min_usages'] % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
}
|
||||
@@ -843,22 +889,16 @@ class CartManager:
|
||||
for (i, v), c in selected.items():
|
||||
n_per_i[i] += c
|
||||
if sum(selected.values()) > iao.max_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise CartError(
|
||||
error_messages['addon_max_count'],
|
||||
{
|
||||
error_messages['addon_max_count'] % {
|
||||
'base': str(item.name),
|
||||
'max': iao.max_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
elif sum(selected.values()) < iao.min_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise CartError(
|
||||
error_messages['addon_min_count'],
|
||||
{
|
||||
error_messages['addon_min_count'] % {
|
||||
'base': str(item.name),
|
||||
'min': iao.min_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
@@ -866,8 +906,7 @@ class CartManager:
|
||||
)
|
||||
elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed:
|
||||
raise CartError(
|
||||
error_messages['addon_no_multi'],
|
||||
{
|
||||
error_messages['addon_no_multi'] % {
|
||||
'base': str(item.name),
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
@@ -931,7 +970,7 @@ class CartManager:
|
||||
|
||||
if item.max_per_order and count > item.max_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['max_items_per_product']) % {
|
||||
error_messages['max_items_per_product'] % {
|
||||
'max': item.max_per_order,
|
||||
'product': item.name
|
||||
}
|
||||
@@ -945,13 +984,13 @@ class CartManager:
|
||||
for p in self.positions:
|
||||
if p.item_id == item.pk and p.pk not in removals:
|
||||
self._operations.append(self.RemoveOperation(position=p))
|
||||
err = _(error_messages['min_items_per_product_removed']) % {
|
||||
err = error_messages['min_items_per_product_removed'] % {
|
||||
'min': item.min_per_order,
|
||||
'product': item.name
|
||||
}
|
||||
if not err:
|
||||
raise CartError(
|
||||
_(error_messages['min_items_per_product']) % {
|
||||
error_messages['min_items_per_product'] % {
|
||||
'min': item.min_per_order,
|
||||
'product': item.name
|
||||
}
|
||||
@@ -980,13 +1019,13 @@ class CartManager:
|
||||
for p in self.positions:
|
||||
if p.voucher_id == voucher.pk and p.pk not in removals:
|
||||
self._operations.append(self.RemoveOperation(position=p))
|
||||
err = _(error_messages['voucher_min_usages_removed']) % {
|
||||
err = error_messages['voucher_min_usages_removed'] % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
}
|
||||
if not err:
|
||||
raise CartError(
|
||||
_(error_messages['voucher_min_usages']) % {
|
||||
error_messages['voucher_min_usages'] % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
}
|
||||
@@ -1034,11 +1073,11 @@ class CartManager:
|
||||
|
||||
if voucher_available_count < 1:
|
||||
if op.voucher in self._voucher_depend_on_cart:
|
||||
err = err or _(error_messages['voucher_redeemed_cart']) % self.event.settings.reservation_time
|
||||
err = err or (error_messages['voucher_redeemed_cart'] % self.event.settings.reservation_time)
|
||||
else:
|
||||
err = err or error_messages['voucher_redeemed']
|
||||
elif voucher_available_count < requested_count:
|
||||
err = err or _(error_messages['voucher_redeemed_partial']) % voucher_available_count
|
||||
err = err or (error_messages['voucher_redeemed_partial'] % voucher_available_count)
|
||||
|
||||
available_count = min(quota_available_count, voucher_available_count)
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ from pretix.base.models import (
|
||||
Checkin, CheckinList, Device, Order, OrderPosition, QuestionOption,
|
||||
)
|
||||
from pretix.base.signals import checkin_created, order_placed, periodic_task
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.jsonlogic import Logic
|
||||
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
|
||||
from pretix.helpers.jsonlogic_query import (
|
||||
@@ -289,6 +290,11 @@ def _logic_explain(rules, ev, rule_data):
|
||||
p for i, p in enumerate(paths) if path_weights[i] == min_weight
|
||||
]
|
||||
|
||||
# Step 7: All things equal, prefer shorter explanations
|
||||
paths_with_min_weight.sort(
|
||||
key=lambda p: len([v for v in p if not _var_values[v]])
|
||||
)
|
||||
|
||||
# Finally, return the text for one of them
|
||||
return ', '.join(var_texts[v] for v in paths_with_min_weight[0] if not _var_values[v])
|
||||
|
||||
@@ -729,8 +735,11 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
_save_answers(op, answers, given_answers)
|
||||
|
||||
with transaction.atomic():
|
||||
# Lock order positions
|
||||
op = OrderPosition.all.select_for_update().get(pk=op.pk)
|
||||
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
|
||||
opqs = OrderPosition.all
|
||||
if type != Checkin.TYPE_EXIT:
|
||||
opqs = opqs.select_for_update(of=OF_SELF)
|
||||
op = opqs.get(pk=op.pk)
|
||||
|
||||
if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]:
|
||||
raise CheckInError(
|
||||
@@ -842,10 +851,7 @@ def process_exit_all(sender, **kwargs):
|
||||
exit_all_at__isnull=False
|
||||
).select_related('event', 'event__organizer')
|
||||
for cl in qs:
|
||||
positions = cl.positions_inside.filter(
|
||||
Q(last_exit__isnull=True) | Q(last_exit__lte=cl.exit_all_at),
|
||||
last_entry__lte=cl.exit_all_at,
|
||||
)
|
||||
positions = cl.positions_inside_query(ignore_status=True, at_time=cl.exit_all_at)
|
||||
for p in positions:
|
||||
with scope(organizer=cl.event.organizer):
|
||||
ci = Checkin.objects.create(
|
||||
|
||||
@@ -19,31 +19,50 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from typing import Any, Dict
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import override
|
||||
from django.db import connection, transaction
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now, override
|
||||
from django.utils.translation import gettext
|
||||
from django_scopes import scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Device, Event, Organizer, TeamAPIToken, User, cachedfile_name,
|
||||
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
|
||||
User, cachedfile_name,
|
||||
)
|
||||
from pretix.base.models.exports import ScheduledOrganizerExport
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.services.tasks import (
|
||||
ProfiledEventTask, ProfiledOrganizerUserTask,
|
||||
EventTask, OrganizerTask, ProfiledEventTask, ProfiledOrganizerUserTask,
|
||||
)
|
||||
from pretix.base.signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
periodic_task, register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExportError(LazyLocaleException):
|
||||
pass
|
||||
|
||||
|
||||
class ExportEmptyError(ExportError):
|
||||
pass
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, throws=(ExportError,), bind=True)
|
||||
def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
|
||||
def set_progress(val):
|
||||
@@ -56,7 +75,7 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
|
||||
file = CachedFile.objects.get(id=fileid)
|
||||
with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
|
||||
responses = register_data_exporters.send(event)
|
||||
for receiver, response in responses:
|
||||
for recv, response in responses:
|
||||
if not response:
|
||||
continue
|
||||
ex = response(event, event.organizer, set_progress)
|
||||
@@ -106,16 +125,16 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
|
||||
timezone = organizer.settings.timezone or settings.TIME_ZONE
|
||||
region = organizer.settings.region
|
||||
with language(locale, region), override(timezone):
|
||||
if form_data.get('events') is not None:
|
||||
if form_data.get('events') is not None and not form_data.get('all_events'):
|
||||
if isinstance(form_data['events'][0], str):
|
||||
events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer)
|
||||
else:
|
||||
events = allowed_events.filter(pk__in=form_data.get('events'))
|
||||
events = allowed_events.filter(pk__in=form_data.get('events'), organizer=organizer)
|
||||
else:
|
||||
events = allowed_events
|
||||
events = allowed_events.filter(organizer=organizer)
|
||||
responses = register_multievent_data_exporters.send(organizer)
|
||||
|
||||
for receiver, response in responses:
|
||||
for recv, response in responses:
|
||||
if not response:
|
||||
continue
|
||||
ex = response(events, organizer, set_progress)
|
||||
@@ -138,3 +157,210 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
|
||||
f = ContentFile(data)
|
||||
file.file.save(cachedfile_name(file, file.filename), f)
|
||||
return file.pk
|
||||
|
||||
|
||||
def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, config_url, retry_func, has_permission):
|
||||
with language(schedule.locale, context.settings.region), override(schedule.tz):
|
||||
file = CachedFile(web_download=False)
|
||||
file.date = now()
|
||||
file.expires = now() + timedelta(hours=24)
|
||||
file.save()
|
||||
|
||||
def _handle_error(msg, soft=False):
|
||||
context.log_action(
|
||||
'pretix.event.export.schedule.failed',
|
||||
data={
|
||||
'id': schedule.id,
|
||||
'export_identifier': schedule.export_identifier,
|
||||
'export_form_data': schedule.export_form_data,
|
||||
'reason': msg,
|
||||
'soft': soft,
|
||||
}
|
||||
)
|
||||
if schedule.owner.is_active:
|
||||
mail(
|
||||
email=schedule.owner.email,
|
||||
subject=gettext('Export failed'),
|
||||
template='pretixbase/email/export_failed.txt',
|
||||
context={
|
||||
'configuration_url': config_url,
|
||||
'reason': msg,
|
||||
'soft': soft,
|
||||
},
|
||||
event=context if isinstance(context, Event) else None,
|
||||
organizer=context.organizer if isinstance(context, Event) else context,
|
||||
locale=schedule.locale,
|
||||
)
|
||||
if not soft:
|
||||
schedule.error_counter += 1
|
||||
schedule.error_last_message = msg
|
||||
schedule.save(update_fields=['error_counter', 'error_last_message'])
|
||||
|
||||
if not has_permission:
|
||||
_handle_error(gettext('Permission denied.'))
|
||||
return
|
||||
|
||||
try:
|
||||
if not exporter:
|
||||
raise ExportError("Export type not found.")
|
||||
d = exporter.render(schedule.export_form_data)
|
||||
if d is None:
|
||||
raise ExportEmptyError(
|
||||
gettext('Your export did not contain any data.')
|
||||
)
|
||||
file.filename, file.type, data = d
|
||||
filesize = len(data)
|
||||
if filesize > 20 * 1024 * 1024: # 20 MB
|
||||
raise ExportError(
|
||||
gettext('Your exported data exceeded the size limit for scheduled exports.')
|
||||
)
|
||||
f = ContentFile(data)
|
||||
file.file.save(cachedfile_name(file, file.filename), f)
|
||||
except ExportEmptyError as e:
|
||||
_handle_error(str(e), soft=True)
|
||||
except ExportError as e:
|
||||
_handle_error(str(e), soft=False)
|
||||
except Exception:
|
||||
logger.exception("Scheduled export failed.")
|
||||
try:
|
||||
retry_func()
|
||||
except MaxRetriesExceededError:
|
||||
_handle_error('Internal Error')
|
||||
else:
|
||||
schedule.error_counter = 0
|
||||
schedule.save(update_fields=['error_counter'])
|
||||
to = [r for r in schedule.mail_additional_recipients.split(",") if r]
|
||||
cc = [r for r in schedule.mail_additional_recipients_cc.split(",") if r]
|
||||
bcc = [r for r in schedule.mail_additional_recipients_bcc.split(",") if r]
|
||||
if to:
|
||||
# If there is an explicit To, the owner is Cc. Otherwise, the owner is To. Yes, this is
|
||||
# purely cosmetical and has policital reasons.
|
||||
cc.append(schedule.owner.email)
|
||||
else:
|
||||
to.append(schedule.owner.email)
|
||||
|
||||
mail(
|
||||
email=to,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
subject=schedule.mail_subject,
|
||||
template=LazyI18nString(schedule.mail_template),
|
||||
context=get_email_context(event=context) if isinstance(context, Event) else {},
|
||||
event=context if isinstance(context, Event) else None,
|
||||
organizer=context.organizer if isinstance(context, Event) else context,
|
||||
locale=schedule.locale,
|
||||
attach_cached_files=[file],
|
||||
)
|
||||
context.log_action(
|
||||
'pretix.event.export.schedule.executed',
|
||||
data={
|
||||
'id': schedule.id,
|
||||
'export_identifier': schedule.export_identifier,
|
||||
'export_form_data': schedule.export_form_data,
|
||||
'result_file_size': filesize,
|
||||
'result_file_name': file.file.name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=OrganizerTask, bind=True, max_retries=5, default_retry_delay=120)
|
||||
def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> None:
|
||||
schedule = organizer.scheduled_exports.get(pk=schedule)
|
||||
|
||||
allowed_events = schedule.owner.get_events_with_permission('can_view_orders')
|
||||
if schedule.export_form_data.get('events') is not None and not schedule.export_form_data.get('all_events'):
|
||||
if isinstance(schedule.export_form_data['events'][0], str):
|
||||
events = allowed_events.filter(slug__in=schedule.export_form_data.get('events'), organizer=organizer)
|
||||
else:
|
||||
events = allowed_events.filter(pk__in=schedule.export_form_data.get('events'), organizer=organizer)
|
||||
else:
|
||||
events = allowed_events.filter(organizer=organizer)
|
||||
|
||||
responses = register_multievent_data_exporters.send(organizer)
|
||||
exporter = None
|
||||
for recv, response in responses:
|
||||
if not response:
|
||||
continue
|
||||
ex = response(events, organizer)
|
||||
if ex.identifier == schedule.export_identifier:
|
||||
exporter = ex
|
||||
break
|
||||
|
||||
has_permission = schedule.owner.is_active
|
||||
if isinstance(exporter, OrganizerLevelExportMixin):
|
||||
if not schedule.owner.has_organizer_permission(organizer, exporter.organizer_required_permission):
|
||||
has_permission = False
|
||||
|
||||
_run_scheduled_export(
|
||||
schedule,
|
||||
organizer,
|
||||
exporter,
|
||||
build_absolute_uri(
|
||||
'control:organizer.export',
|
||||
kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}
|
||||
) + f'?identifier={schedule.export_identifier}&scheduled={schedule.pk}',
|
||||
self.retry,
|
||||
has_permission,
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=EventTask, bind=True, max_retries=5, default_retry_delay=120)
|
||||
def scheduled_event_export(self, event: Event, schedule: int) -> None:
|
||||
schedule = event.scheduled_exports.get(pk=schedule)
|
||||
|
||||
responses = register_data_exporters.send(event)
|
||||
exporter = None
|
||||
for recv, response in responses:
|
||||
if not response:
|
||||
continue
|
||||
ex = response(event, event.organizer)
|
||||
if ex.identifier == schedule.export_identifier:
|
||||
exporter = ex
|
||||
break
|
||||
|
||||
has_permission = schedule.owner.is_active and schedule.owner.has_event_permission(event.organizer, event, 'can_view_orders')
|
||||
|
||||
_run_scheduled_export(
|
||||
schedule,
|
||||
event,
|
||||
exporter,
|
||||
build_absolute_uri(
|
||||
'control:event.orders.export',
|
||||
kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
}
|
||||
) + f'?identifier={schedule.export_identifier}&scheduled={schedule.pk}',
|
||||
self.retry,
|
||||
has_permission,
|
||||
)
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
@transaction.atomic
|
||||
def run_scheduled_exports(sender, **kwargs):
|
||||
qs = ScheduledEventExport.objects.filter(
|
||||
schedule_next_run__lt=now(),
|
||||
error_counter__lt=5,
|
||||
).select_for_update(skip_locked=connection.features.has_select_for_update_skip_locked, of=OF_SELF).select_related('event')
|
||||
for s in qs:
|
||||
scheduled_event_export.apply_async(kwargs={
|
||||
'event': s.event_id,
|
||||
'schedule': s.pk,
|
||||
})
|
||||
s.compute_next_run()
|
||||
s.save(update_fields=['schedule_next_run'])
|
||||
qs = ScheduledOrganizerExport.objects.filter(
|
||||
schedule_next_run__lt=now(),
|
||||
error_counter__lt=5,
|
||||
).select_for_update(skip_locked=connection.features.has_select_for_update_skip_locked, of=OF_SELF).select_related('organizer')
|
||||
for s in qs:
|
||||
scheduled_organizer_export.apply_async(kwargs={
|
||||
'organizer': s.organizer_id,
|
||||
'schedule': s.pk,
|
||||
})
|
||||
s.compute_next_run()
|
||||
s.save(update_fields=['schedule_next_run'])
|
||||
|
||||
@@ -63,7 +63,7 @@ from pretix.base.services.tasks import TransactionAwareTask
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.base.signals import invoice_line_text, periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
from pretix.helpers.database import OF_SELF, rolledback_transaction
|
||||
from pretix.helpers.models import modelcopy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -500,7 +500,7 @@ def send_invoices_to_organizer(sender, **kwargs):
|
||||
with transaction.atomic():
|
||||
qs = Invoice.objects.filter(
|
||||
sent_to_organizer__isnull=True
|
||||
).prefetch_related('event').select_for_update(skip_locked=connection.features.has_select_for_update_skip_locked)
|
||||
).prefetch_related('event').select_for_update(of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked)
|
||||
for i in qs[:batch_size]:
|
||||
if i.event.settings.invoice_email_organizer:
|
||||
with language(i.event.settings.locale):
|
||||
|
||||
@@ -100,7 +100,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
|
||||
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
|
||||
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None,
|
||||
plain_text_only=False, no_order_links=False):
|
||||
plain_text_only=False, no_order_links=False, cc: Sequence[str]=None, bcc: Sequence[str]=None):
|
||||
"""
|
||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||
|
||||
@@ -211,7 +211,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
subject = raw_subject = str(subject).replace('\n', ' ').replace('\r', '')[:900]
|
||||
signature = ""
|
||||
|
||||
bcc = []
|
||||
bcc = list(bcc or [])
|
||||
|
||||
settings_holder = event or organizer
|
||||
|
||||
@@ -305,6 +305,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
|
||||
send_task = mail_send_task.si(
|
||||
to=[email] if isinstance(email, str) else list(email),
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
subject=subject,
|
||||
body=body_plain,
|
||||
@@ -357,11 +358,11 @@ class CustomEmail(EmailMultiAlternatives):
|
||||
|
||||
@app.task(base=TransactionAwareTask, bind=True, acks_late=True)
|
||||
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
|
||||
event: int = None, position: int = None, headers: dict = None, cc: List[str] = None, bcc: List[str] = None,
|
||||
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
|
||||
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None,
|
||||
attach_other_files: List[str] = None) -> bool:
|
||||
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
email = CustomEmail(subject, body, sender, to=to, cc=cc, bcc=bcc, headers=headers)
|
||||
if html is not None:
|
||||
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
||||
html_with_cid, cid_images = replace_images_with_cid_paths(html)
|
||||
|
||||
@@ -32,6 +32,7 @@ from pretix.base.models import (
|
||||
AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition,
|
||||
SubEvent,
|
||||
)
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
|
||||
def membership_validity(item: Item, subevent: Optional[SubEvent], event: Event):
|
||||
@@ -118,7 +119,7 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
||||
base_qs = Membership.objects.with_usages(ignored_order=ignored_order)
|
||||
|
||||
if lock:
|
||||
base_qs = base_qs.select_for_update()
|
||||
base_qs = base_qs.select_for_update(of=OF_SELF)
|
||||
|
||||
membership_cache = base_qs\
|
||||
.select_related('membership_type')\
|
||||
|
||||
@@ -54,15 +54,13 @@ from django.db.transaction import get_connection
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.api.models import OAuthApplication
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import (
|
||||
LazyLocaleException, get_language_without_region, language,
|
||||
)
|
||||
from pretix.base.i18n import get_language_without_region, language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
|
||||
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
||||
@@ -97,50 +95,98 @@ from pretix.base.signals import (
|
||||
order_placed, order_split, periodic_task, validate_order,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.models import modelcopy
|
||||
from pretix.helpers.periodic import minimum_interval
|
||||
|
||||
|
||||
class OrderError(Exception):
|
||||
def __init__(self, *args):
|
||||
msg = args[0]
|
||||
msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
# force msg to string to make sure lazy-translation is done in current locale-context
|
||||
# otherwise translation might happen in celery-context, which uses default-locale
|
||||
# also translate with _/gettext to keep it backwards compatible
|
||||
msg = _(str(msg))
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
error_messages = {
|
||||
'unavailable': _('Some of the products you selected were no longer available. '
|
||||
'Please see below for details.'),
|
||||
'in_part': _('Some of the products you selected were no longer available in '
|
||||
'the quantity you selected. Please see below for details.'),
|
||||
'price_changed': _('The price of some of the items in your cart has changed in the '
|
||||
'meantime. Please see below for details.'),
|
||||
'internal': _("An internal error occurred, please try again."),
|
||||
'empty': _("Your cart is empty."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s. We removed the "
|
||||
"surplus items from your cart."),
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'not_started': _('The booking period for this event has not yet started.'),
|
||||
'ended': _('The booking period has ended.'),
|
||||
'voucher_min_usages': _('The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
|
||||
'matching products.'),
|
||||
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
|
||||
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
|
||||
'number of times allowed. We removed this item from your cart.'),
|
||||
'voucher_budget_used': _('The voucher code used for one of the items in your cart has already been too often. We '
|
||||
'adjusted the price of the item in your cart.'),
|
||||
'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item '
|
||||
'from your cart.'),
|
||||
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
|
||||
'removed this item from your cart.'),
|
||||
'voucher_required': _('You need a valid voucher code to order one of the products.'),
|
||||
'some_subevent_not_started': _('The booking period for one of the events in your cart has not yet started. The '
|
||||
'affected positions have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
|
||||
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
||||
'country_blocked': _('One of the selected products is not available in the selected country.'),
|
||||
'not_for_sale': _('You selected a product which is not available for sale.'),
|
||||
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
||||
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
||||
'product %(base)s.'),
|
||||
'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'unavailable': gettext_lazy(
|
||||
'Some of the products you selected were no longer available. '
|
||||
'Please see below for details.'
|
||||
),
|
||||
'in_part': gettext_lazy(
|
||||
'Some of the products you selected were no longer available in '
|
||||
'the quantity you selected. Please see below for details.'
|
||||
),
|
||||
'price_changed': gettext_lazy(
|
||||
'The price of some of the items in your cart has changed in the '
|
||||
'meantime. Please see below for details.'
|
||||
),
|
||||
'internal': gettext_lazy("An internal error occurred, please try again."),
|
||||
'empty': gettext_lazy("Your cart is empty."),
|
||||
'max_items_per_product': ngettext_lazy(
|
||||
"You cannot select more than %(max)s item of the product %(product)s. We removed the surplus items from your cart.",
|
||||
"You cannot select more than %(max)s items of the product %(product)s. We removed the surplus items from your cart.",
|
||||
"max"
|
||||
),
|
||||
'busy': gettext_lazy(
|
||||
'We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'
|
||||
),
|
||||
'not_started': gettext_lazy('The booking period for this event has not yet started.'),
|
||||
'ended': gettext_lazy('The booking period has ended.'),
|
||||
'voucher_min_usages': ngettext_lazy(
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
|
||||
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
|
||||
'number'
|
||||
),
|
||||
'voucher_invalid': gettext_lazy('The voucher code used for one of the items in your cart is not known in our database.'),
|
||||
'voucher_redeemed': gettext_lazy(
|
||||
'The voucher code used for one of the items in your cart has already been used the maximum '
|
||||
'number of times allowed. We removed this item from your cart.'
|
||||
),
|
||||
'voucher_budget_used': gettext_lazy(
|
||||
'The voucher code used for one of the items in your cart has already been too often. We '
|
||||
'adjusted the price of the item in your cart.'
|
||||
),
|
||||
'voucher_expired': gettext_lazy(
|
||||
'The voucher code used for one of the items in your cart is expired. We removed this item from your cart.'
|
||||
),
|
||||
'voucher_invalid_item': gettext_lazy(
|
||||
'The voucher code used for one of the items in your cart is not valid for this item. We removed this item from your cart.'
|
||||
),
|
||||
'voucher_required': gettext_lazy('You need a valid voucher code to order one of the products.'),
|
||||
'some_subevent_not_started': gettext_lazy(
|
||||
'The booking period for one of the events in your cart has not yet started. The '
|
||||
'affected positions have been removed from your cart.'
|
||||
),
|
||||
'some_subevent_ended': gettext_lazy(
|
||||
'The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'
|
||||
),
|
||||
'seat_invalid': gettext_lazy('One of the seats in your order was invalid, we removed the position from your cart.'),
|
||||
'seat_unavailable': gettext_lazy('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
||||
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
|
||||
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'),
|
||||
'addon_invalid_base': gettext_lazy('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': gettext_lazy('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': ngettext_lazy(
|
||||
'You can select at most %(max)s add-on from the category %(cat)s for the product %(base)s.',
|
||||
'You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'max'
|
||||
),
|
||||
'addon_min_count': ngettext_lazy(
|
||||
'You need to select at least %(min)s add-on from the category %(cat)s for the product %(base)s.',
|
||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'min'
|
||||
),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -156,7 +202,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
|
||||
enough quota.
|
||||
"""
|
||||
if order.status != Order.STATUS_CANCELED:
|
||||
raise OrderError('The order was not canceled.')
|
||||
raise OrderError(_('The order was not canceled.'))
|
||||
|
||||
with order.event.lock() as now_dt:
|
||||
is_available = order._is_still_available(now_dt, count_waitinglist=False, check_voucher_usage=True,
|
||||
@@ -184,7 +230,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') + 1))
|
||||
|
||||
for gc in position.issued_gift_cards.all():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gc.pk)
|
||||
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
|
||||
gc.transactions.create(value=position.price, order=order)
|
||||
break
|
||||
|
||||
@@ -397,7 +443,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
# If new actions are added to this function, make sure to add the reverse operation to reactivate_order()
|
||||
with transaction.atomic():
|
||||
if isinstance(order, int):
|
||||
order = Order.objects.select_for_update().get(pk=order)
|
||||
order = Order.objects.select_for_update(of=OF_SELF).get(pk=order)
|
||||
if isinstance(user, int):
|
||||
user = User.objects.get(pk=user)
|
||||
if isinstance(api_token, int):
|
||||
@@ -419,7 +465,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
|
||||
for position in order.positions.all():
|
||||
for gc in position.issued_gift_cards.all():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gc.pk)
|
||||
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
|
||||
if gc.value < position.price:
|
||||
raise OrderError(
|
||||
_('This order can not be canceled since the gift card {card} purchased in '
|
||||
@@ -537,18 +583,6 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
return order.pk
|
||||
|
||||
|
||||
class OrderError(LazyLocaleException):
|
||||
def __init__(self, *args):
|
||||
msg = args[0]
|
||||
msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
msg = _(msg)
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def _check_date(event: Event, now_dt: datetime):
|
||||
if event.presale_start and now_dt < event.presale_start:
|
||||
raise OrderError(error_messages['not_started'])
|
||||
@@ -569,7 +603,6 @@ def _check_date(event: Event, now_dt: datetime):
|
||||
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None,
|
||||
sales_channel='web', customer=None):
|
||||
err = None
|
||||
errargs = None
|
||||
_check_date(event, now_dt)
|
||||
|
||||
products_seen = Counter()
|
||||
@@ -606,9 +639,10 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
|
||||
products_seen[cp.item] += 1
|
||||
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
|
||||
err = error_messages['max_items_per_product']
|
||||
errargs = {'max': cp.item.max_per_order,
|
||||
'product': cp.item.name}
|
||||
err = error_messages['max_items_per_product'] % {
|
||||
'max': cp.item.max_per_order,
|
||||
'product': cp.item.name
|
||||
}
|
||||
delete(cp)
|
||||
break
|
||||
|
||||
@@ -727,7 +761,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
|
||||
for voucher, cnt in v_usages.items():
|
||||
if 0 < cnt < voucher.min_usages_remaining:
|
||||
raise OrderError(error_messages['voucher_min_usages'], {
|
||||
raise OrderError(error_messages['voucher_min_usages'] % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
})
|
||||
@@ -790,7 +824,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.save()
|
||||
|
||||
if err:
|
||||
raise OrderError(err, errargs)
|
||||
raise OrderError(err)
|
||||
|
||||
|
||||
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
|
||||
@@ -1068,6 +1102,8 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
|
||||
any_payment_failed = True
|
||||
except Exception:
|
||||
logger.exception('Error during payment attempt')
|
||||
else:
|
||||
order.refresh_from_db()
|
||||
|
||||
pending_sum = order.pending_sum
|
||||
free_order_flow = (
|
||||
@@ -1203,7 +1239,7 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
|
||||
if days and (o.expires - today).days <= days:
|
||||
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(of=OF_SELF).get(pk=o.pk)
|
||||
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
|
||||
# Race condition
|
||||
continue
|
||||
@@ -1262,7 +1298,7 @@ def send_download_reminders(sender, **kwargs):
|
||||
continue
|
||||
|
||||
with transaction.atomic():
|
||||
o = Order.objects.select_for_update().get(pk=o.pk)
|
||||
o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
|
||||
if o.download_reminder_sent:
|
||||
# Race condition
|
||||
continue
|
||||
@@ -1341,22 +1377,26 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
|
||||
|
||||
class OrderChangeManager:
|
||||
error_messages = {
|
||||
'product_without_variation': _('You need to select a variation of the product.'),
|
||||
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
|
||||
'quota_missing': _('There is no quota defined that allows this operation.'),
|
||||
'product_invalid': _('The selected product is not active or has no price set.'),
|
||||
'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'),
|
||||
'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
|
||||
'no quota is available.'),
|
||||
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
|
||||
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
|
||||
'subevent_required': _('You need to choose a subevent for the new position.'),
|
||||
'seat_unavailable': _('The selected seat "{seat}" is not available.'),
|
||||
'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'),
|
||||
'seat_required': _('The selected product requires you to select a seat.'),
|
||||
'seat_forbidden': _('The selected product does not allow to select a seat.'),
|
||||
'tax_rule_country_blocked': _('The selected country is blocked by your tax rule.'),
|
||||
'gift_card_change': _('You cannot change the price of a position that has been used to issue a gift card.'),
|
||||
'product_without_variation': gettext_lazy('You need to select a variation of the product.'),
|
||||
'quota': gettext_lazy('The quota {name} does not have enough capacity left to perform the operation.'),
|
||||
'quota_missing': gettext_lazy('There is no quota defined that allows this operation.'),
|
||||
'product_invalid': gettext_lazy('The selected product is not active or has no price set.'),
|
||||
'complete_cancel': gettext_lazy('This operation would leave the order empty. Please cancel the order itself instead.'),
|
||||
'paid_to_free_exceeded': gettext_lazy(
|
||||
'This operation would make the order free and therefore immediately paid, however '
|
||||
'no quota is available.'
|
||||
),
|
||||
'addon_to_required': gettext_lazy('This is an add-on product, please select the base position it should be added to.'),
|
||||
'addon_invalid': gettext_lazy('The selected base position does not allow you to add this product as an add-on.'),
|
||||
'subevent_required': gettext_lazy('You need to choose a subevent for the new position.'),
|
||||
'seat_unavailable': gettext_lazy('The selected seat "{seat}" is not available.'),
|
||||
'seat_subevent_mismatch': gettext_lazy(
|
||||
'You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'
|
||||
),
|
||||
'seat_required': gettext_lazy('The selected product requires you to select a seat.'),
|
||||
'seat_forbidden': gettext_lazy('The selected product does not allow to select a seat.'),
|
||||
'tax_rule_country_blocked': gettext_lazy('The selected country is blocked by your tax rule.'),
|
||||
'gift_card_change': gettext_lazy('You cannot change the price of a position that has been used to issue a gift card.'),
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
|
||||
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
|
||||
@@ -1763,22 +1803,16 @@ class OrderChangeManager:
|
||||
for (i, v), c in selected.items():
|
||||
n_per_i[i] += c
|
||||
if sum(selected.values()) > iao.max_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise OrderError(
|
||||
error_messages['addon_max_count'],
|
||||
{
|
||||
error_messages['addon_max_count'] % {
|
||||
'base': str(item.name),
|
||||
'max': iao.max_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
elif sum(selected.values()) < iao.min_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise OrderError(
|
||||
error_messages['addon_min_count'],
|
||||
{
|
||||
error_messages['addon_min_count'] % {
|
||||
'base': str(item.name),
|
||||
'min': iao.min_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
@@ -1786,8 +1820,7 @@ class OrderChangeManager:
|
||||
)
|
||||
elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed:
|
||||
raise OrderError(
|
||||
error_messages['addon_no_multi'],
|
||||
{
|
||||
error_messages['addon_no_multi'] % {
|
||||
'base': str(item.name),
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
@@ -2057,7 +2090,7 @@ class OrderChangeManager:
|
||||
op.fee.save(update_fields=['canceled'])
|
||||
elif isinstance(op, self.CancelOperation):
|
||||
for gc in op.position.issued_gift_cards.all():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gc.pk)
|
||||
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
|
||||
if gc.value < op.position.price:
|
||||
raise OrderError(_(
|
||||
'A position can not be canceled since the gift card {card} purchased in this order has '
|
||||
@@ -2073,7 +2106,7 @@ class OrderChangeManager:
|
||||
|
||||
for opa in op.position.addons.all():
|
||||
for gc in opa.issued_gift_cards.all():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gc.pk)
|
||||
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
|
||||
if gc.value < opa.position.price:
|
||||
raise OrderError(_(
|
||||
'A position can not be canceled since the gift card {card} purchased in this order has '
|
||||
@@ -2468,7 +2501,7 @@ def perform_order(self, event: Event, payments: List[dict], positions: List[str]
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise OrderError(str(error_messages['busy']))
|
||||
raise OrderError(error_messages['busy'])
|
||||
|
||||
|
||||
_unset = object()
|
||||
@@ -2646,9 +2679,9 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
||||
|
||||
open_payment = None
|
||||
if new_payment:
|
||||
lp = order.payments.select_for_update().exclude(pk=new_payment.pk).last()
|
||||
lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last()
|
||||
else:
|
||||
lp = order.payments.select_for_update().last()
|
||||
lp = order.payments.select_for_update(of=OF_SELF).last()
|
||||
|
||||
if lp and lp.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
|
||||
open_payment = lp
|
||||
|
||||
@@ -109,6 +109,31 @@ class EventTask(app.Task):
|
||||
return ret
|
||||
|
||||
|
||||
class OrganizerTask(app.Task):
|
||||
def __call__(self, *args, **kwargs):
|
||||
if 'organizer_id' in kwargs:
|
||||
organizer_id = kwargs.get('organizer_id')
|
||||
with scopes_disabled():
|
||||
organizer = Organizer.objects.get(pk=organizer_id)
|
||||
del kwargs['organizer_id']
|
||||
kwargs['organizer'] = organizer
|
||||
elif 'organizer' in kwargs:
|
||||
organizer_id = kwargs.get('organizer')
|
||||
with scopes_disabled():
|
||||
organizer = Organizer.objects.get(pk=organizer_id)
|
||||
kwargs['organizer'] = organizer
|
||||
else:
|
||||
args = list(args)
|
||||
organizer_id = args[0]
|
||||
with scopes_disabled():
|
||||
organizer = Organizer.objects.get(pk=organizer_id)
|
||||
args[0] = organizer
|
||||
|
||||
with scope(organizer=organizer):
|
||||
ret = super().__call__(*args, **kwargs)
|
||||
return ret
|
||||
|
||||
|
||||
class OrganizerUserTask(app.Task):
|
||||
def __call__(self, *args, **kwargs):
|
||||
organizer_id = kwargs['organizer']
|
||||
|
||||
@@ -206,7 +206,7 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for attendee names"),
|
||||
help_text=_("Ask for a name for all tickets which include admission to the event."),
|
||||
help_text=_("Ask for a name for all personalized tickets."),
|
||||
)
|
||||
},
|
||||
'attendee_names_required': {
|
||||
@@ -229,10 +229,10 @@ DEFAULTS = {
|
||||
label=_("Ask for email addresses per ticket"),
|
||||
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
|
||||
"only to that email address. If you enable this option, the system will additionally ask for "
|
||||
"individual email addresses for every admission ticket. This might be useful if you want to "
|
||||
"individual email addresses for every personalized ticket. This might be useful if you want to "
|
||||
"obtain individual addresses for every attendee even in case of group orders. However, "
|
||||
"pretix will send the order confirmation by default only to the one primary email address, not to "
|
||||
"the per-attendee addresses. You can however enable this in the E-mail settings."),
|
||||
"the per-attendee addresses. You can however enable this in the email settings."),
|
||||
)
|
||||
},
|
||||
'attendee_emails_required': {
|
||||
@@ -242,7 +242,7 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require email addresses per ticket"),
|
||||
help_text=_("Require customers to fill in individual e-mail addresses for all admission tickets. See the "
|
||||
help_text=_("Require customers to fill in individual email addresses for all personalized tickets. See the "
|
||||
"above option for more details. One email address for the order confirmation will always be "
|
||||
"required regardless of this setting."),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
|
||||
@@ -1308,6 +1308,25 @@ DEFAULTS = {
|
||||
"the email. Does not affect orders performed through other sales channels."),
|
||||
)
|
||||
},
|
||||
'low_availability_percentage': {
|
||||
'default': None,
|
||||
'type': int,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_kwargs': dict(
|
||||
min_value=0,
|
||||
max_value=100,
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
label=_('Low availability threshold'),
|
||||
help_text=_('If the availability of tickets falls below this percentage, the event (or a date, if it is an '
|
||||
'event series) will be highlighted to have low availability in the event list or calendar. If '
|
||||
'you keep this option empty, low availability will not be shown publicly.'),
|
||||
min_value=0,
|
||||
max_value=100,
|
||||
required=False
|
||||
)
|
||||
},
|
||||
'event_list_availability': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
@@ -2574,7 +2593,7 @@ Your {organizer} team"""))
|
||||
label=_("Attendee data explanation"),
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {'rows': '2'}},
|
||||
help_text=_("This text will be shown above the questions asked for every admission product. You can use it e.g. to explain "
|
||||
help_text=_("This text will be shown above the questions asked for every personalized product. You can use it e.g. to explain "
|
||||
"why you need information from them.")
|
||||
)
|
||||
},
|
||||
|
||||
@@ -218,8 +218,10 @@ class EmailAddressShredder(BaseDataShredder):
|
||||
o.meta_info = json.dumps(d)
|
||||
o.save(update_fields=['meta_info', 'email', 'customer'])
|
||||
|
||||
for le in self.event.logentry_set.filter(action_type__contains="order.email"):
|
||||
shred_log_fields(le, banlist=['recipient', 'message', 'subject'])
|
||||
for le in self.event.logentry_set.filter(
|
||||
Q(action_type__contains="order.email") | Q(action_type__contains="position.email"),
|
||||
):
|
||||
shred_log_fields(le, banlist=['recipient', 'message', 'subject', 'full_mail'])
|
||||
|
||||
for le in self.event.logentry_set.filter(action_type="pretix.event.order.contact.changed"):
|
||||
shred_log_fields(le, banlist=['old_email', 'new_email'])
|
||||
|
||||
12
src/pretix/base/templates/pretixbase/email/export_failed.txt
Normal file
12
src/pretix/base/templates/pretixbase/email/export_failed.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
{% load i18n %}
|
||||
{% trans "Your export failed." %}
|
||||
|
||||
{% trans "Reason:" %} {{ reason }}
|
||||
|
||||
{% if not soft %}
|
||||
{% trans "If your export fails five times in a row, it will no longer be sent." %}
|
||||
{% endif %}
|
||||
|
||||
{% trans "Configuration link:" %}
|
||||
|
||||
{{ configuration_url }}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% load i18n %}
|
||||
{% with widget.subwidgets.0 as widget %}
|
||||
{% include widget.template_name %}
|
||||
{% endwith %}
|
||||
<div class="row" data-display-dependency-value="custom" data-display-dependency="#{{ widget.subwidgets.0.attrs.id }}">
|
||||
<br>
|
||||
<div class="col-sm-6">
|
||||
{% with widget.subwidgets.1 as widget %}
|
||||
{% include widget.template_name %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
{% with widget.subwidgets.2 as widget %}
|
||||
{% include widget.template_name %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
{% spaceless %}{% for widget in widget.subwidgets %}{% endfor %}{% endspaceless %}
|
||||
433
src/pretix/base/timeframes.py
Normal file
433
src/pretix/base/timeframes.py
Normal file
@@ -0,0 +1,433 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import calendar
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from itertools import groupby
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext_lazy, pgettext_lazy
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.helpers.daterange import daterange
|
||||
|
||||
|
||||
def _quarter_start(ref_d):
|
||||
return ref_d.replace(day=1, month=1 + (ref_d.month - 1) // 3 * 3)
|
||||
|
||||
|
||||
def _week_start(ref_d):
|
||||
return ref_d - timedelta(ref_d.weekday())
|
||||
|
||||
|
||||
REPORTING_DATE_TIMEFRAMES = (
|
||||
# (identifier, label, start_inclusive, end_inclusive, includes_future, optgroup, describe)
|
||||
(
|
||||
'days_today',
|
||||
pgettext_lazy('reporting_timeframe', 'Today'),
|
||||
lambda ref_d: ref_d,
|
||||
lambda ref_d, start_d: start_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_yesterday',
|
||||
pgettext_lazy('reporting_timeframe', 'Yesterday'),
|
||||
lambda ref_d: ref_d - timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_last7',
|
||||
pgettext_lazy('reporting_timeframe', 'Last 7 days'),
|
||||
lambda ref_d: ref_d - timedelta(days=6),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=6),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_last14',
|
||||
pgettext_lazy('reporting_timeframe', 'Last 14 days'),
|
||||
lambda ref_d: ref_d - timedelta(days=13),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=13),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_tomorrow',
|
||||
pgettext_lazy('reporting_timeframe', 'Tomorrow'),
|
||||
lambda ref_d: ref_d + timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d,
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_next7',
|
||||
pgettext_lazy('reporting_timeframe', 'Next 7 days'),
|
||||
lambda ref_d: ref_d + timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=6),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_next14',
|
||||
pgettext_lazy('reporting_timeframe', 'Next 14 days'),
|
||||
lambda ref_d: ref_d + timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=13),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'week_this',
|
||||
pgettext_lazy('reporting_timeframe', 'Current week'),
|
||||
lambda ref_d: _week_start(ref_d),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=6),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by week'),
|
||||
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
|
||||
),
|
||||
(
|
||||
'week_to_date',
|
||||
pgettext_lazy('reporting_timeframe', 'Current week to date'),
|
||||
lambda ref_d: _week_start(ref_d),
|
||||
lambda ref_d, start_d: ref_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by week'),
|
||||
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
|
||||
),
|
||||
(
|
||||
'week_previous',
|
||||
pgettext_lazy('reporting_timeframe', 'Previous week'),
|
||||
lambda ref_d: _week_start(ref_d) - timedelta(days=7),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=6),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by week'),
|
||||
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
|
||||
),
|
||||
(
|
||||
'week_next',
|
||||
pgettext_lazy('reporting_timeframe', 'Next week'),
|
||||
lambda ref_d: _week_start(ref_d + timedelta(days=7)),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=6),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by week'),
|
||||
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
|
||||
),
|
||||
(
|
||||
'month_this',
|
||||
pgettext_lazy('reporting_timeframe', 'Current month'),
|
||||
lambda ref_d: ref_d.replace(day=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by month'),
|
||||
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
|
||||
),
|
||||
(
|
||||
'month_to_date',
|
||||
pgettext_lazy('reporting_timeframe', 'Current month to date'),
|
||||
lambda ref_d: ref_d.replace(day=1),
|
||||
lambda ref_d, start_d: ref_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by month'),
|
||||
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
|
||||
),
|
||||
(
|
||||
'month_previous',
|
||||
pgettext_lazy('reporting_timeframe', 'Previous month'),
|
||||
lambda ref_d: (ref_d.replace(day=1) - timedelta(days=1)).replace(day=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by month'),
|
||||
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
|
||||
),
|
||||
(
|
||||
'month_next',
|
||||
pgettext_lazy('reporting_timeframe', 'Next month'),
|
||||
lambda ref_d: ref_d.replace(day=calendar.monthrange(ref_d.year, ref_d.month)[1]) + timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by month'),
|
||||
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
|
||||
),
|
||||
(
|
||||
'quarter_this',
|
||||
pgettext_lazy('reporting_timeframe', 'Current quarter'),
|
||||
lambda ref_d: _quarter_start(ref_d),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by quarter'),
|
||||
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
|
||||
),
|
||||
(
|
||||
'quarter_to_date',
|
||||
pgettext_lazy('reporting_timeframe', 'Current quarter to date'),
|
||||
lambda ref_d: _quarter_start(ref_d),
|
||||
lambda ref_d, start_d: ref_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by quarter'),
|
||||
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
|
||||
),
|
||||
(
|
||||
'quarter_previous',
|
||||
pgettext_lazy('reporting_timeframe', 'Previous quarter'),
|
||||
lambda ref_d: _quarter_start(_quarter_start(ref_d) - timedelta(days=1)),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by quarter'),
|
||||
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
|
||||
),
|
||||
(
|
||||
'quarter_next',
|
||||
pgettext_lazy('reporting_timeframe', 'Next quarter'),
|
||||
lambda ref_d: ref_d.replace(
|
||||
day=calendar.monthrange(ref_d.year, _quarter_start(ref_d).month + 2)[1], month=_quarter_start(ref_d).month + 2
|
||||
) + timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by quarter'),
|
||||
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
|
||||
),
|
||||
(
|
||||
'year_this',
|
||||
pgettext_lazy('reporting_timeframe', 'Current year'),
|
||||
lambda ref_d: ref_d.replace(day=1, month=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=31, month=12),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by year'),
|
||||
lambda start_d, end_d: str(start_d.year),
|
||||
),
|
||||
(
|
||||
'year_to_date',
|
||||
pgettext_lazy('reporting_timeframe', 'Current year to date'),
|
||||
lambda ref_d: ref_d.replace(day=1, month=1),
|
||||
lambda ref_d, start_d: ref_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by year'),
|
||||
lambda start_d, end_d: str(start_d.year),
|
||||
),
|
||||
(
|
||||
'year_previous',
|
||||
pgettext_lazy('reporting_timeframe', 'Previous year'),
|
||||
lambda ref_d: (ref_d.replace(day=1, month=1) - timedelta(days=1)).replace(day=1, month=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=31, month=12),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by year'),
|
||||
lambda start_d, end_d: str(start_d.year),
|
||||
),
|
||||
(
|
||||
'year_next',
|
||||
pgettext_lazy('reporting_timeframe', 'Next year'),
|
||||
lambda ref_d: ref_d.replace(day=1, month=1, year=ref_d.year + 1),
|
||||
lambda ref_d, start_d: start_d.replace(day=31, month=12),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by year'),
|
||||
lambda start_d, end_d: str(start_d.year),
|
||||
),
|
||||
(
|
||||
'future',
|
||||
pgettext_lazy('reporting_timeframe', 'All future (excluding today)'),
|
||||
lambda ref_d: ref_d + timedelta(days=1),
|
||||
lambda ref_d, start_d: None,
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'Other'),
|
||||
lambda start_d, end_d: date_format(start_d, "SHORT_DATE_FORMAT") + ' – ',
|
||||
),
|
||||
(
|
||||
'past',
|
||||
pgettext_lazy('reporting_timeframe', 'All past (including today)'),
|
||||
lambda ref_d: None,
|
||||
lambda ref_d, start_d: ref_d,
|
||||
True, # technically false, but only makes sense to have in a selection that also allows the future, otherwise redundant
|
||||
pgettext_lazy('reporting_timeframe', 'Other'),
|
||||
lambda start_d, end_d: ' – ' + date_format(end_d, "SHORT_DATE_FORMAT"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DateFrameWidget(forms.MultiWidget):
|
||||
template_name = 'pretixbase/forms/widgets/dateframe.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.timeframe_choices = kwargs.pop('timeframe_choices')
|
||||
widgets = (
|
||||
forms.Select(choices=self.timeframe_choices),
|
||||
forms.DateInput(attrs={'class': 'datepickerfield', 'placeholder': pgettext_lazy('timeframe', 'Start')}),
|
||||
forms.DateInput(attrs={'class': 'datepickerfield', 'placeholder': pgettext_lazy('timeframe', 'End')}),
|
||||
)
|
||||
super().__init__(widgets=widgets, *args, **kwargs)
|
||||
|
||||
def decompress(self, value):
|
||||
if not value:
|
||||
return ['unset', None, None]
|
||||
if '/' in value:
|
||||
return [
|
||||
'custom',
|
||||
date.fromisoformat(value.split('/', 1)[0]),
|
||||
date.fromisoformat(value.split('/', 1)[-1]),
|
||||
]
|
||||
return [value, None, None]
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
ctx['required'] = self.timeframe_choices[0][0] == 'unset'
|
||||
return ctx
|
||||
|
||||
|
||||
def _describe_timeframe(label, start, end, future, describe):
|
||||
d_start = start(now())
|
||||
d_end = end(now(), d_start)
|
||||
details = describe(d_start, d_end)
|
||||
return f'{label} ({details})'
|
||||
|
||||
|
||||
class DateFrameField(forms.MultiValueField):
|
||||
default_error_messages = {
|
||||
**forms.MultiValueField.default_error_messages,
|
||||
'inconsistent': gettext_lazy('The end date must be after the start date.'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
include_future_frames = kwargs.pop('include_future_frames')
|
||||
|
||||
top_choices = [('custom', gettext_lazy('Custom timeframe'))]
|
||||
if not kwargs.get('required', True):
|
||||
top_choices.insert(0, ('unset', pgettext_lazy('reporting_timeframe', 'All time')))
|
||||
|
||||
_choices = []
|
||||
for grouper, group in groupby(REPORTING_DATE_TIMEFRAMES, key=lambda i: i[5]):
|
||||
options = [
|
||||
(identifier, _describe_timeframe(label, start, end, future, describe))
|
||||
for identifier, label, start, end, future, group, describe in group
|
||||
if include_future_frames or not future
|
||||
]
|
||||
if options:
|
||||
_choices.append((grouper, options))
|
||||
|
||||
timeframe_choices = [
|
||||
('', top_choices)
|
||||
] + _choices
|
||||
|
||||
fields = (
|
||||
forms.ChoiceField(
|
||||
choices=timeframe_choices,
|
||||
required=True
|
||||
),
|
||||
forms.DateField(
|
||||
required=False
|
||||
),
|
||||
forms.DateField(
|
||||
required=False
|
||||
),
|
||||
)
|
||||
if 'widget' not in kwargs:
|
||||
kwargs['widget'] = DateFrameWidget(timeframe_choices=timeframe_choices)
|
||||
kwargs.pop('max_length', 0)
|
||||
kwargs.pop('empty_value', 0)
|
||||
super().__init__(
|
||||
fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
|
||||
def compress(self, data_list):
|
||||
if not data_list:
|
||||
return None
|
||||
if data_list[0] == 'unset':
|
||||
return None
|
||||
elif data_list[0] == 'custom':
|
||||
return f'{data_list[1].isoformat() if data_list[1] else ""}/{data_list[2].isoformat() if data_list[2] else ""}'
|
||||
else:
|
||||
return data_list[0]
|
||||
|
||||
def has_changed(self, initial, data):
|
||||
if initial is None:
|
||||
initial = self.widget.decompress(initial)
|
||||
return super().has_changed(initial, data)
|
||||
|
||||
def clean(self, value):
|
||||
if not value:
|
||||
return None
|
||||
if value[0] == 'custom':
|
||||
if not value[1] and not value[2]:
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
if value[1] and value[2] and self.fields[2].to_python(value[2]) < self.fields[1].to_python(value[1]):
|
||||
raise ValidationError(self.error_messages['inconsistent'])
|
||||
return super().clean(value)
|
||||
|
||||
|
||||
class SerializerDateFrameField(serializers.CharField):
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data is None:
|
||||
return None
|
||||
try:
|
||||
resolve_timeframe_to_dates_inclusive(now(), data, pytz.UTC)
|
||||
except:
|
||||
raise ValidationError("Invalid date frame")
|
||||
|
||||
def to_representation(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]:
|
||||
"""
|
||||
Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of dates
|
||||
where the first element ist the first possible date value within the timeframe and the second
|
||||
element is the last possible date value in the timeframe.
|
||||
Both returned values may be ``None`` for an unlimited interval.
|
||||
"""
|
||||
if isinstance(ref_dt, datetime):
|
||||
ref_dt = ref_dt.astimezone(timezone).date()
|
||||
if "/" in frame:
|
||||
start, end = frame.split("/", 1)
|
||||
return date.fromisoformat(start) if start else None, date.fromisoformat(end) if end else None
|
||||
for idf, label, start, end, includes_future, *args in REPORTING_DATE_TIMEFRAMES:
|
||||
if frame == idf:
|
||||
d_start = start(ref_dt)
|
||||
d_end = end(ref_dt, d_start)
|
||||
return d_start, d_end
|
||||
raise ValueError(f"Invalid timeframe '{frame}'")
|
||||
|
||||
|
||||
def resolve_timeframe_to_datetime_start_inclusive_end_exclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]:
|
||||
"""
|
||||
Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of datetimes
|
||||
where the first element ist the first possible datetime within the timeframe and the second
|
||||
element is the first possible datetime value *not* in the timeframe.
|
||||
Both returned values may be ``None`` for an unlimited interval.
|
||||
"""
|
||||
d_start, d_end = resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone)
|
||||
dt_start = make_aware(datetime.combine(d_start, time(0, 0, 0)), timezone) if d_start else None
|
||||
dt_end = make_aware(datetime.combine(d_end + timedelta(days=1), time(0, 0, 0)), timezone) if d_end else None
|
||||
return dt_start, dt_end
|
||||
@@ -19,6 +19,12 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from dateutil.rrule import rrulestr
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
||||
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
@@ -32,11 +38,6 @@
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class BanlistValidator:
|
||||
|
||||
@@ -101,3 +102,18 @@ class EmailBanlistValidator(BanlistValidator):
|
||||
banlist = [
|
||||
settings.PRETIX_EMAIL_NONE_VALUE,
|
||||
]
|
||||
|
||||
|
||||
def multimail_validate(val):
|
||||
s = val.split(',')
|
||||
for part in s:
|
||||
validate_email(part.strip())
|
||||
return s
|
||||
|
||||
|
||||
class RRuleValidator:
|
||||
def __call__(self, value):
|
||||
try:
|
||||
rrulestr(value)
|
||||
except Exception:
|
||||
raise ValidationError("Not a valid rrule.")
|
||||
|
||||
@@ -78,7 +78,7 @@ class BaseQuestionsViewMixin:
|
||||
form.pos = cartpos or orderpos
|
||||
form.show_copy_answers_to_addon_button = form.pos.addon_to and (
|
||||
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
|
||||
(form.pos.addon_to.item.admission and form.pos.item.admission and (
|
||||
(form.pos.addon_to.item.ask_attendee_data and form.pos.item.ask_attendee_data and (
|
||||
self.request.event.settings.attendee_names_asked or
|
||||
self.request.event.settings.attendee_emails_asked or
|
||||
self.request.event.settings.attendee_company_asked or
|
||||
|
||||
@@ -28,7 +28,7 @@ from celery import states
|
||||
from celery.result import AsyncResult
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.http import HttpResponse, JsonResponse, QueryDict
|
||||
from django.shortcuts import redirect, render
|
||||
from django.test import RequestFactory
|
||||
@@ -149,6 +149,8 @@ class AsyncMixin:
|
||||
return redirect(self.get_success_url(value))
|
||||
|
||||
def error(self, exception):
|
||||
if isinstance(exception, PermissionDenied):
|
||||
raise exception
|
||||
messages.error(self.request, self.get_error_message(exception))
|
||||
if "ajax" in self.request.POST or "ajax" in self.request.GET:
|
||||
return JsonResponse({
|
||||
@@ -337,8 +339,8 @@ class AsyncPostView(AsyncMixin, View):
|
||||
depend on the request object unless specifically supported by this class. File upload is currently also
|
||||
not supported.
|
||||
"""
|
||||
known_errortypes = ['ValidationError']
|
||||
expected_exceptions = (ValidationError,)
|
||||
known_errortypes = ['ValidationError', 'PermissionDenied']
|
||||
expected_exceptions = (ValidationError, PermissionDenied)
|
||||
task_base = ProfiledEventTask
|
||||
|
||||
def async_set_progress(self, percentage):
|
||||
|
||||
@@ -51,6 +51,9 @@ from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
from pretix.helpers.hierarkey import clean_filename
|
||||
|
||||
from ...base.forms import I18nModelForm
|
||||
from ...helpers.images import (
|
||||
IMAGE_EXTS, validate_uploaded_file_for_valid_image,
|
||||
)
|
||||
|
||||
# Import for backwards compatibility with okd import paths
|
||||
from ...base.forms.widgets import ( # noqa
|
||||
@@ -214,12 +217,16 @@ class ExtValidationMixin:
|
||||
|
||||
def clean(self, *args, **kwargs):
|
||||
data = super().clean(*args, **kwargs)
|
||||
if isinstance(data, File):
|
||||
if isinstance(data, UploadedFile):
|
||||
filename = data.name
|
||||
ext = os.path.splitext(filename)[1]
|
||||
ext = ext.lower()
|
||||
if ext not in self.ext_whitelist:
|
||||
raise forms.ValidationError(_("Filetype not allowed!"))
|
||||
|
||||
if ext in IMAGE_EXTS:
|
||||
validate_uploaded_file_for_valid_image(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -34,12 +34,13 @@
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from decimal import Decimal
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, validate_email
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.db.models import Prefetch, Q, prefetch_related_objects
|
||||
from django.forms import (
|
||||
CheckboxSelectMultiple, formset_factory, inlineformset_factory,
|
||||
@@ -65,6 +66,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
from pretix.base.settings import (
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||
)
|
||||
from pretix.base.validators import multimail_validate
|
||||
from pretix.control.forms import (
|
||||
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||
SplitDateTimePickerWidget,
|
||||
@@ -135,6 +137,8 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate "
|
||||
"here in percent. If you have a more complicated tax situation, you can add more tax rates and "
|
||||
"detailed configuration later."),
|
||||
max_value=Decimal("100.00"),
|
||||
min_value=Decimal("0.00"),
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -503,6 +507,7 @@ class EventSettingsForm(SettingsForm):
|
||||
'meta_noindex',
|
||||
'redirect_to_checkout_directly',
|
||||
'frontpage_subevent_ordering',
|
||||
'low_availability_percentage',
|
||||
'event_list_type',
|
||||
'event_list_available_only',
|
||||
'frontpage_text',
|
||||
@@ -861,13 +866,6 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
return data
|
||||
|
||||
|
||||
def multimail_validate(val):
|
||||
s = val.split(',')
|
||||
for part in s:
|
||||
validate_email(part.strip())
|
||||
return s
|
||||
|
||||
|
||||
def contains_web_channel_validate(val):
|
||||
if "web" not in val:
|
||||
raise ValidationError(_("The online shop must be selected to receive these emails."))
|
||||
@@ -1206,8 +1204,8 @@ class MailSettingsForm(SettingsForm):
|
||||
'mail_text_resend_link': ['event', 'order'],
|
||||
'mail_subject_resend_link': ['event', 'order'],
|
||||
'mail_subject_resend_link_attendee': ['event', 'order'],
|
||||
'mail_text_waiting_list': ['event', 'waiting_list_entry'],
|
||||
'mail_subject_waiting_list': ['event', 'waiting_list_entry'],
|
||||
'mail_text_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
|
||||
'mail_subject_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
|
||||
'mail_text_resend_all_links': ['event', 'orders'],
|
||||
'mail_subject_resend_all_links': ['event', 'orders'],
|
||||
'mail_attach_ical_description': ['event', 'event_or_subevent'],
|
||||
|
||||
103
src/pretix/control/forms/exports.py
Normal file
103
src/pretix/control/forms/exports.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from pytz import common_timezones
|
||||
|
||||
from pretix.base.models import ScheduledEventExport
|
||||
from pretix.base.models.exports import ScheduledOrganizerExport
|
||||
|
||||
|
||||
class ScheduledEventExportForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ScheduledEventExport
|
||||
fields = ['mail_additional_recipients', 'mail_additional_recipients_cc', 'mail_additional_recipients_bcc',
|
||||
'mail_subject', 'mail_template', 'schedule_rrule_time', 'locale']
|
||||
widgets = {
|
||||
'mail_additional_recipients': forms.TextInput,
|
||||
'mail_additional_recipients_cc': forms.TextInput,
|
||||
'mail_additional_recipients_bcc': forms.TextInput,
|
||||
'schedule_rrule_time': forms.TimeInput(attrs={'class': 'timepickerfield', 'autocomplete': 'off'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
self.fields['locale'] = forms.ChoiceField(
|
||||
label=_('Language'),
|
||||
choices=[(a, locale_names[a]) for a in self.instance.event.settings.locales]
|
||||
)
|
||||
|
||||
def clean_mail_additional_recipients(self):
|
||||
d = self.cleaned_data['mail_additional_recipients'].replace(' ', '')
|
||||
if len(d.split(',')) > 25:
|
||||
raise ValidationError(_('Please enter less than 25 recipients.'))
|
||||
return d
|
||||
|
||||
def clean_mail_additional_recipients_cc(self):
|
||||
d = self.cleaned_data['mail_additional_recipients_cc'].replace(' ', '')
|
||||
if len(d.split(',')) > 25:
|
||||
raise ValidationError(_('Please enter less than 25 recipients.'))
|
||||
return d
|
||||
|
||||
def clean_mail_additional_recipients_bcc(self):
|
||||
d = self.cleaned_data['mail_additional_recipients_bcc'].replace(' ', '')
|
||||
if len(d.split(',')) > 25:
|
||||
raise ValidationError(_('Please enter less than 25 recipients.'))
|
||||
return d
|
||||
|
||||
|
||||
class ScheduledOrganizerExportForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ScheduledOrganizerExport
|
||||
fields = ['mail_additional_recipients', 'mail_additional_recipients_cc', 'mail_additional_recipients_bcc',
|
||||
'mail_subject', 'mail_template', 'schedule_rrule_time', 'locale', 'timezone']
|
||||
widgets = {
|
||||
'mail_additional_recipients': forms.TextInput,
|
||||
'mail_additional_recipients_cc': forms.TextInput,
|
||||
'mail_additional_recipients_bcc': forms.TextInput,
|
||||
'schedule_rrule_time': forms.TimeInput(attrs={'class': 'timepickerfield', 'autocomplete': 'off'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
self.fields['locale'] = forms.ChoiceField(
|
||||
label=_('Language'),
|
||||
choices=[(a, locale_names[a]) for a in self.instance.organizer.settings.locales]
|
||||
)
|
||||
self.fields['timezone'] = forms.ChoiceField(
|
||||
choices=((a, a) for a in common_timezones),
|
||||
label=_("Timezone"),
|
||||
)
|
||||
|
||||
def clean_mail_additional_recipients(self):
|
||||
return self.cleaned_data['mail_additional_recipients'].replace(' ', '')
|
||||
|
||||
def clean_mail_additional_recipients_cc(self):
|
||||
return self.cleaned_data['mail_additional_recipients_cc'].replace(' ', '')
|
||||
|
||||
def clean_mail_additional_recipients_bcc(self):
|
||||
return self.cleaned_data['mail_additional_recipients_bcc'].replace(' ', '')
|
||||
@@ -1703,9 +1703,7 @@ class CheckinListAttendeeFilterForm(FilterForm):
|
||||
if s == '1':
|
||||
qs = qs.filter(last_entry__isnull=False)
|
||||
elif s == '2':
|
||||
qs = qs.filter(last_entry__isnull=False).filter(
|
||||
Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry'))
|
||||
)
|
||||
qs = qs.filter(pk__in=self.list.positions_inside.values_list('pk'))
|
||||
elif s == '3':
|
||||
qs = qs.filter(last_entry__isnull=False).filter(
|
||||
Q(last_exit__isnull=False) & Q(last_exit__gte=F('last_entry'))
|
||||
@@ -2264,7 +2262,7 @@ class DeviceFilterForm(FilterForm):
|
||||
state = forms.ChoiceField(
|
||||
label=_('Device status'),
|
||||
choices=[
|
||||
('', _('All devices')),
|
||||
('all', _('All devices')),
|
||||
('active', _('Active devices')),
|
||||
('revoked', _('Revoked devices'))
|
||||
],
|
||||
|
||||
@@ -295,6 +295,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
self.user = kwargs.pop('user')
|
||||
kwargs.setdefault('initial', {})
|
||||
kwargs['initial'].setdefault('admission', True)
|
||||
kwargs['initial'].setdefault('personalized', True)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['category'].queryset = self.instance.event.categories.all()
|
||||
@@ -403,6 +404,8 @@ class ItemCreateForm(I18nModelForm):
|
||||
self.instance.sales_channels = list(get_all_sales_channels().keys())
|
||||
|
||||
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
|
||||
if not self.instance.admission:
|
||||
self.instance.personalized = False
|
||||
instance = super().save(*args, **kwargs)
|
||||
|
||||
if not self.event.has_subevents and not self.cleaned_data.get('has_variations'):
|
||||
@@ -494,6 +497,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
'internal_name',
|
||||
'category',
|
||||
'admission',
|
||||
'personalized',
|
||||
'default_price',
|
||||
'tax_rule',
|
||||
]
|
||||
@@ -588,13 +592,33 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'tax_rule',
|
||||
_("Gift card products should use a tax rule with a rate of 0 percent since sales tax will be applied when the gift card is redeemed.")
|
||||
)
|
||||
if d['admission']:
|
||||
if d.get('admission'):
|
||||
self.add_error(
|
||||
'admission',
|
||||
_(
|
||||
"Gift card products should not be admission products at the same time."
|
||||
)
|
||||
)
|
||||
|
||||
if d.get('require_membership') and not d.get('require_membership_types'):
|
||||
self.add_error(
|
||||
'require_membership_types',
|
||||
_(
|
||||
"If a valid membership is required, at least one valid membership type needs to be selected."
|
||||
)
|
||||
)
|
||||
|
||||
if not d.get('admission'):
|
||||
d['personalized'] = False
|
||||
|
||||
if d.get('grant_membership_type'):
|
||||
if not d['grant_membership_type'].transferable and not d['personalized']:
|
||||
self.add_error(
|
||||
'personalized' if d['admission'] else 'admission',
|
||||
_("Your product grants a non-transferable membership and should therefore be a personalized "
|
||||
"admission ticket. Otherwise customers might not be able to use the membership later. If you "
|
||||
"want the membership to be non-personalized, set the membership type to be transferable.")
|
||||
)
|
||||
return d
|
||||
|
||||
def clean_picture(self):
|
||||
@@ -615,6 +639,7 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'active',
|
||||
'sales_channels',
|
||||
'admission',
|
||||
'personalized',
|
||||
'description',
|
||||
'picture',
|
||||
'default_price',
|
||||
@@ -792,6 +817,17 @@ class ItemVariationForm(I18nModelForm):
|
||||
}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if d.get('require_membership') and not d.get('require_membership_types'):
|
||||
self.add_error(
|
||||
'require_membership_types',
|
||||
_(
|
||||
"If a valid membership is required, at least one valid membership type needs to be selected."
|
||||
)
|
||||
)
|
||||
return d
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit)
|
||||
self.meta_fields = []
|
||||
|
||||
@@ -227,6 +227,10 @@ class ExporterForm(forms.Form):
|
||||
elif isinstance(v, models.QuerySet):
|
||||
data[k] = [m.pk for m in v]
|
||||
|
||||
if 'all_events' in self.fields and 'events' in self.fields:
|
||||
if not data.get('all_events') and not data.get('events'):
|
||||
raise ValidationError(_('Please select some events.'))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -266,7 +270,7 @@ class OtherOperationsForm(forms.Form):
|
||||
notify = forms.BooleanField(
|
||||
label=_('Notify user'),
|
||||
required=False,
|
||||
initial=True,
|
||||
initial=False,
|
||||
help_text=_(
|
||||
'Send an email to the customer notifying that their order has been changed.'
|
||||
)
|
||||
|
||||
251
src/pretix/control/forms/rrule.py
Normal file
251
src/pretix/control/forms/rrule.py
Normal file
@@ -0,0 +1,251 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from datetime import timedelta
|
||||
|
||||
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr
|
||||
from django import forms
|
||||
from django.utils.dates import MONTHS, WEEKDAYS
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
|
||||
class RRuleForm(forms.Form):
|
||||
# TODO: calendar.setfirstweekday
|
||||
freq = forms.ChoiceField(
|
||||
choices=[
|
||||
('yearly', _('year(s)')),
|
||||
('monthly', _('month(s)')),
|
||||
('weekly', _('week(s)')),
|
||||
('daily', _('day(s)')),
|
||||
],
|
||||
initial='weekly'
|
||||
)
|
||||
interval = forms.IntegerField(
|
||||
label=_('Interval'),
|
||||
initial=1,
|
||||
min_value=1,
|
||||
widget=forms.NumberInput(attrs={'min': '1'})
|
||||
)
|
||||
dtstart = forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(
|
||||
attrs={
|
||||
'class': 'datepickerfield',
|
||||
'required': 'required'
|
||||
}
|
||||
),
|
||||
initial=lambda: now().astimezone(get_current_timezone()).date()
|
||||
)
|
||||
|
||||
end = forms.ChoiceField(
|
||||
choices=[
|
||||
('count', ''),
|
||||
('until', ''),
|
||||
('forever', ''),
|
||||
],
|
||||
initial='count',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
count = forms.IntegerField(
|
||||
label=_('Number of repetitions'),
|
||||
initial=10
|
||||
)
|
||||
until = forms.DateField(
|
||||
widget=forms.DateInput(
|
||||
attrs={
|
||||
'class': 'datepickerfield',
|
||||
'required': 'required'
|
||||
}
|
||||
),
|
||||
label=_('Last date'),
|
||||
required=True,
|
||||
initial=lambda: now() + timedelta(days=30)
|
||||
)
|
||||
|
||||
yearly_bysetpos = forms.ChoiceField(
|
||||
choices=[
|
||||
('1', pgettext_lazy('rrule', 'first')),
|
||||
('2', pgettext_lazy('rrule', 'second')),
|
||||
('3', pgettext_lazy('rrule', 'third')),
|
||||
('-1', pgettext_lazy('rrule', 'last')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
yearly_same = forms.ChoiceField(
|
||||
choices=[
|
||||
('on', ''),
|
||||
('off', ''),
|
||||
],
|
||||
initial='on',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
yearly_byweekday = forms.ChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
||||
('MO,TU,WE,TH,FR', _('Weekday')),
|
||||
('SA,SU', _('Weekend day')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
yearly_bymonth = forms.ChoiceField(
|
||||
choices=[
|
||||
(str(i), MONTHS[i]) for i in range(1, 13)
|
||||
],
|
||||
required=False
|
||||
)
|
||||
|
||||
monthly_same = forms.ChoiceField(
|
||||
choices=[
|
||||
('on', ''),
|
||||
('off', ''),
|
||||
],
|
||||
initial='on',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
monthly_bysetpos = forms.ChoiceField(
|
||||
choices=[
|
||||
('1', pgettext_lazy('rrule', 'first')),
|
||||
('2', pgettext_lazy('rrule', 'second')),
|
||||
('3', pgettext_lazy('rrule', 'third')),
|
||||
('-1', pgettext_lazy('rrule', 'last')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
monthly_byweekday = forms.ChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
||||
('MO,TU,WE,TH,FR', _('Weekday')),
|
||||
('SA,SU', _('Weekend day')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
|
||||
weekly_byweekday = forms.MultipleChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
],
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
|
||||
def parse_weekdays(self, value):
|
||||
m = {
|
||||
'MO': 0,
|
||||
'TU': 1,
|
||||
'WE': 2,
|
||||
'TH': 3,
|
||||
'FR': 4,
|
||||
'SA': 5,
|
||||
'SU': 6
|
||||
}
|
||||
if ',' in value:
|
||||
return [m.get(a) for a in value.split(',')]
|
||||
else:
|
||||
return m.get(value)
|
||||
|
||||
def to_rrule(self):
|
||||
rule_kwargs = {}
|
||||
rule_kwargs['dtstart'] = self.cleaned_data['dtstart']
|
||||
rule_kwargs['interval'] = self.cleaned_data['interval']
|
||||
|
||||
if self.cleaned_data['freq'] == 'yearly':
|
||||
freq = YEARLY
|
||||
if self.cleaned_data['yearly_same'] == "off":
|
||||
rule_kwargs['bysetpos'] = int(self.cleaned_data['yearly_bysetpos'])
|
||||
rule_kwargs['byweekday'] = self.parse_weekdays(self.cleaned_data['yearly_byweekday'])
|
||||
rule_kwargs['bymonth'] = int(self.cleaned_data['yearly_bymonth'])
|
||||
|
||||
elif self.cleaned_data['freq'] == 'monthly':
|
||||
freq = MONTHLY
|
||||
|
||||
if self.cleaned_data['monthly_same'] == "off":
|
||||
rule_kwargs['bysetpos'] = int(self.cleaned_data['monthly_bysetpos'])
|
||||
rule_kwargs['byweekday'] = self.parse_weekdays(self.cleaned_data['monthly_byweekday'])
|
||||
elif self.cleaned_data['freq'] == 'weekly':
|
||||
freq = WEEKLY
|
||||
|
||||
if self.cleaned_data['weekly_byweekday']:
|
||||
rule_kwargs['byweekday'] = [self.parse_weekdays(a) for a in self.cleaned_data['weekly_byweekday']]
|
||||
|
||||
elif self.cleaned_data['freq'] == 'daily':
|
||||
freq = DAILY
|
||||
|
||||
if self.cleaned_data['end'] == 'count':
|
||||
rule_kwargs['count'] = self.cleaned_data['count']
|
||||
elif self.cleaned_data['end'] == 'until':
|
||||
rule_kwargs['until'] = self.cleaned_data['until']
|
||||
return rrule(freq, **rule_kwargs)
|
||||
|
||||
@staticmethod
|
||||
def initial_from_rrule(rule: rrule):
|
||||
initial = {}
|
||||
if isinstance(rule, str):
|
||||
rule = rrulestr(rule)
|
||||
|
||||
_rule = rule._original_rule
|
||||
initial['dtstart'] = rule._dtstart
|
||||
initial['interval'] = rule._interval
|
||||
|
||||
if rule._freq == YEARLY:
|
||||
initial['freq'] = 'yearly'
|
||||
initial['yearly_bysetpos'] = _rule.get('bysetpos')
|
||||
initial['yearly_byweekday'] = _rule.get('byweekday')
|
||||
initial['yearly_bymonth'] = _rule.get('bymonth')
|
||||
elif rule._freq == MONTHLY:
|
||||
initial['freq'] = 'monthly'
|
||||
initial['monthly_bysetpos'] = _rule.get('bysetpos')
|
||||
initial['monthly_byweekday'] = _rule.get('byweekday')
|
||||
elif rule._freq == WEEKLY:
|
||||
initial['freq'] = 'weekly'
|
||||
initial['weekly_byweekday'] = _rule.get('byweekday')
|
||||
elif rule._freq == DAILY:
|
||||
initial['freq'] = 'daily'
|
||||
|
||||
if rule._count:
|
||||
initial['end'] = 'count'
|
||||
initial['count'] = rule._count
|
||||
elif rule._until:
|
||||
initial['end'] = 'until'
|
||||
initial['until'] = rule._until
|
||||
else:
|
||||
initial['end'] = 'forever'
|
||||
return initial
|
||||
@@ -19,17 +19,15 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django import forms
|
||||
from django.forms import formset_factory
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.urls import reverse
|
||||
from django.utils.dates import MONTHS, WEEKDAYS
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from i18nfield.forms import I18nInlineFormSet
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
@@ -39,6 +37,7 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
||||
from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||||
from pretix.control.forms.rrule import RRuleForm
|
||||
from pretix.helpers.money import change_decimal_field
|
||||
|
||||
|
||||
@@ -440,166 +439,15 @@ class CheckinListFormSet(I18nInlineFormSet):
|
||||
return form
|
||||
|
||||
|
||||
class RRuleForm(forms.Form):
|
||||
# TODO: calendar.setfirstweekday
|
||||
class RRuleFormSetForm(RRuleForm):
|
||||
exclude = forms.BooleanField(
|
||||
label=_('Exclude these dates instead of adding them.'),
|
||||
required=False
|
||||
)
|
||||
freq = forms.ChoiceField(
|
||||
choices=[
|
||||
('yearly', _('year(s)')),
|
||||
('monthly', _('month(s)')),
|
||||
('weekly', _('week(s)')),
|
||||
('daily', _('day(s)')),
|
||||
],
|
||||
initial='weekly'
|
||||
)
|
||||
interval = forms.IntegerField(
|
||||
label=_('Interval'),
|
||||
initial=1,
|
||||
min_value=1,
|
||||
widget=forms.NumberInput(attrs={'min': '1'})
|
||||
)
|
||||
dtstart = forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(
|
||||
attrs={
|
||||
'class': 'datepickerfield',
|
||||
'required': 'required'
|
||||
}
|
||||
),
|
||||
initial=lambda: now().date()
|
||||
)
|
||||
|
||||
end = forms.ChoiceField(
|
||||
choices=[
|
||||
('count', ''),
|
||||
('until', ''),
|
||||
],
|
||||
initial='count',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
count = forms.IntegerField(
|
||||
label=_('Number of repetitions'),
|
||||
initial=10
|
||||
)
|
||||
until = forms.DateField(
|
||||
widget=forms.DateInput(
|
||||
attrs={
|
||||
'class': 'datepickerfield',
|
||||
'required': 'required'
|
||||
}
|
||||
),
|
||||
label=_('Last date'),
|
||||
required=True,
|
||||
initial=lambda: now() + timedelta(days=30)
|
||||
)
|
||||
|
||||
yearly_bysetpos = forms.ChoiceField(
|
||||
choices=[
|
||||
('1', pgettext_lazy('rrule', 'first')),
|
||||
('2', pgettext_lazy('rrule', 'second')),
|
||||
('3', pgettext_lazy('rrule', 'third')),
|
||||
('-1', pgettext_lazy('rrule', 'last')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
yearly_same = forms.ChoiceField(
|
||||
choices=[
|
||||
('on', ''),
|
||||
('off', ''),
|
||||
],
|
||||
initial='on',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
yearly_byweekday = forms.ChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
||||
('MO,TU,WE,TH,FR', _('Weekday')),
|
||||
('SA,SU', _('Weekend day')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
yearly_bymonth = forms.ChoiceField(
|
||||
choices=[
|
||||
(str(i), MONTHS[i]) for i in range(1, 13)
|
||||
],
|
||||
required=False
|
||||
)
|
||||
|
||||
monthly_same = forms.ChoiceField(
|
||||
choices=[
|
||||
('on', ''),
|
||||
('off', ''),
|
||||
],
|
||||
initial='on',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
monthly_bysetpos = forms.ChoiceField(
|
||||
choices=[
|
||||
('1', pgettext_lazy('rrule', 'first')),
|
||||
('2', pgettext_lazy('rrule', 'second')),
|
||||
('3', pgettext_lazy('rrule', 'third')),
|
||||
('-1', pgettext_lazy('rrule', 'last')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
monthly_byweekday = forms.ChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
||||
('MO,TU,WE,TH,FR', _('Weekday')),
|
||||
('SA,SU', _('Weekend day')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
|
||||
weekly_byweekday = forms.MultipleChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
],
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
|
||||
def parse_weekdays(self, value):
|
||||
m = {
|
||||
'MO': 0,
|
||||
'TU': 1,
|
||||
'WE': 2,
|
||||
'TH': 3,
|
||||
'FR': 4,
|
||||
'SA': 5,
|
||||
'SU': 6
|
||||
}
|
||||
if ',' in value:
|
||||
return [m.get(a) for a in value.split(',')]
|
||||
else:
|
||||
return m.get(value)
|
||||
|
||||
|
||||
RRuleFormSet = formset_factory(
|
||||
RRuleForm,
|
||||
RRuleFormSetForm,
|
||||
can_order=False, can_delete=True, extra=1
|
||||
)
|
||||
|
||||
|
||||
@@ -315,6 +315,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.organizer.changed': _('The organizer has been changed.'),
|
||||
'pretix.organizer.settings': _('The organizer settings have been changed.'),
|
||||
'pretix.organizer.footerlinks.changed': _('The footer links have been changed.'),
|
||||
'pretix.organizer.export.schedule.added': _('A scheduled export has been added.'),
|
||||
'pretix.organizer.export.schedule.changed': _('A scheduled export has been changed.'),
|
||||
'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'),
|
||||
'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'),
|
||||
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
|
||||
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
|
||||
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
|
||||
'pretix.webhook.created': _('The webhook has been created.'),
|
||||
@@ -409,6 +414,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'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.failed': _('Refund {local_id} has failed.'),
|
||||
'pretix.event.export.schedule.added': _('A scheduled export has been added.'),
|
||||
'pretix.event.export.schedule.changed': _('A scheduled export has been changed.'),
|
||||
'pretix.event.export.schedule.deleted': _('A scheduled export has been deleted.'),
|
||||
'pretix.event.export.schedule.executed': _('A scheduled export has been executed.'),
|
||||
'pretix.event.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
|
||||
'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.disabled': _('Two-factor authentication has been disabled.'),
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/sb-admin-2.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/rrule.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/subevent.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/variations.js" %}"></script>
|
||||
@@ -422,6 +423,17 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if "mysql" in settings.DATABASES.default.ENGINE and not request.organizer %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
You are using MySQL or MariaDB as your database backend for pretix.
|
||||
Starting in pretix 5.0, these will no longer be supported and you will need to migrate to PostgreSQL.
|
||||
Please see the pretix administrator documentation for a migration guide, and the pretix 4.16
|
||||
release notes for more information.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if debug_warning %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "pretix is running in debug mode. For security reasons, please never run debug mode on a production instance." %}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<dt>{% trans "Receipt number" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.receiptNumber }}</dd>
|
||||
<dt>{% trans "Card type" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.cardName }}</dd>
|
||||
<dd>{{ payment_info.payment_data.cardName|default_if_none:payment_info.payment_data.cardType }}</dd>
|
||||
<dt>{% trans "Card expiration" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.expiry }}</dd>
|
||||
{% elif payment_info.payment_type == "sumup" %}
|
||||
@@ -98,5 +98,8 @@
|
||||
<dd>{{ payment_info.payment_data.posEntryMode }}</dd>
|
||||
<dt>{% trans "Result Code" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.posResultCode }}</dd>
|
||||
{% elif payment_info.payment_type == "cash" %}
|
||||
<dt>{% trans "Payment method" %}</dt>
|
||||
<dd>{% trans "Cash" %}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
@@ -82,8 +82,10 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label aria-label="{% trans "select all rows for batch-operation" %}"
|
||||
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
|
||||
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
|
||||
<label aria-label="{% trans "select all rows for batch-operation" %}"
|
||||
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
|
||||
{% endif %}
|
||||
</th>
|
||||
<th>{% trans "Order code" %} <a href="?{% url_replace request 'ordering' '-code'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
@@ -125,7 +127,7 @@
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
|
||||
<input type="checkbox" name="checkin" id="id_checkin" class="" value="{{ e.pk }}"/>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -198,7 +200,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
<span class="fa fa-sign-in" aria-hidden="true"></span>
|
||||
{% trans "Check-In selected attendees" %}
|
||||
@@ -207,6 +209,8 @@
|
||||
<span class="fa fa-sign-out" aria-hidden="true"></span>
|
||||
{% trans "Check-Out selected attendees" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<button type="submit" class="btn btn-danger btn-save" name="revert" value="true">
|
||||
<span class="fa fa-trash" aria-hidden="true"></span>
|
||||
{% trans "Delete all check-ins of selected attendees" %}
|
||||
|
||||
@@ -36,12 +36,16 @@
|
||||
</div>
|
||||
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
|
||||
{% if request.event %}
|
||||
{% for l in request.event.settings.locales %}
|
||||
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
||||
{% for l, n in settings.LANGUAGES %}
|
||||
{% if l in request.event.settings.locales %}
|
||||
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for l in request.organizer.settings.locales %}
|
||||
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
||||
{% for l, n in settings.LANGUAGES %}
|
||||
{% if l in request.organizer.settings.locales %}
|
||||
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>{% trans "Attendee data (once per admission ticket)" %}</h4>
|
||||
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
|
||||
|
||||
{% bootstrap_field sform.attendee_names_asked_required layout="control" %}
|
||||
{% bootstrap_field sform.attendee_emails_asked_required layout="control" %}
|
||||
@@ -314,7 +314,8 @@
|
||||
{% if sform.event_list_available_only %}
|
||||
{% bootstrap_field sform.event_list_available_only layout="control" %}
|
||||
{% endif %}
|
||||
|
||||
{% bootstrap_field sform.low_availability_percentage layout="control" %}
|
||||
|
||||
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
|
||||
{% propagated request.event org_url "meta_noindex" %}
|
||||
{% bootstrap_field sform.meta_noindex layout="control" %}
|
||||
|
||||
@@ -20,13 +20,19 @@
|
||||
<div class="col-md-9">
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>
|
||||
<span class="fa fa-user"></span>
|
||||
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %} id="admission_on">
|
||||
<span class="fa fa-fw fa-user"></span>
|
||||
<strong>{% trans "Admission product" %}</strong><br>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
Every purchase of this product represents one person who is allowed to enter your event.
|
||||
By default, pretix will only ask for attendee information and offer ticket downloads for these products.
|
||||
By default, we will only offer ticket downloads for these products.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
Only purchases of such products will be considered "attendees" for most statistical
|
||||
purposes or within some plugins.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
@@ -40,12 +46,12 @@
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="" name="{{ form.admission.html_name }}" {% if not form.admission.value %}checked{% endif %}>
|
||||
<span class="fa fa-cube"></span>
|
||||
<span class="fa fa-fw fa-cube"></span>
|
||||
<strong>{% trans "Non-admission product" %}</strong>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
A product that does not represent a person. By default, pretix will not ask for attendee information or offer
|
||||
ticket downloads.
|
||||
A product that does not represent a person. By default, we will not offer ticket downloads
|
||||
(but you can still enable ticket downloads in event settings or product settings).
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
@@ -58,6 +64,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-display-dependency="#admission_on">
|
||||
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
|
||||
<div class="col-md-9">
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-id-card-o"></span>
|
||||
<strong>{% trans "Personalized ticket" %}</strong><br>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
When this ticket is purchased, the system will ask for a name or other details according
|
||||
to your event settings.
|
||||
{% endblocktrans %}
|
||||
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
|
||||
<br>
|
||||
<span class="text-warning">
|
||||
<span class="fa fa-warning" aria-hidden="true"></span>
|
||||
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
|
||||
</span>
|
||||
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
|
||||
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-circle-o"></span>
|
||||
<strong>{% trans "Non-personalized ticket" %}</strong>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
The system will not ask for a name or other attendee details. This only affects
|
||||
system-provided fields, you can still add your own questions.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.category layout="control" %}
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -27,13 +27,19 @@
|
||||
{% endfor %}
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>
|
||||
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %} id="admission_on">
|
||||
<span class="fa fa-fw fa-user"></span>
|
||||
<strong>{% trans "Admission product" %}</strong><br>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
Every purchase of this product represents one person who is allowed to enter your event.
|
||||
By default, pretix will only ask for attendee information and offer ticket downloads for these products.
|
||||
By default, we will only offer ticket downloads for these products.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
Only purchases of such products will be considered "attendees" for most statistical
|
||||
purposes or within some plugins.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
@@ -51,8 +57,8 @@
|
||||
<strong>{% trans "Non-admission product" %}</strong>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
A product that does not represent a person. By default, pretix will not ask for attendee information or offer
|
||||
ticket downloads.
|
||||
A product that does not represent a person. By default, we will not offer ticket downloads
|
||||
(but you can still enable ticket downloads in event settings or product settings).
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
@@ -65,6 +71,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-display-dependency="#admission_on">
|
||||
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
|
||||
<div class="col-md-9">
|
||||
{% for e in form.errors.personalized %}
|
||||
<div class="alert alert-danger has-error">
|
||||
{{ e }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-id-card-o"></span>
|
||||
<strong>{% trans "Personalized ticket" %}</strong><br>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
When this ticket is purchased, the system will ask for a name or other details according
|
||||
to your event settings.
|
||||
{% endblocktrans %}
|
||||
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
|
||||
<br>
|
||||
<span class="text-warning">
|
||||
<span class="fa fa-warning" aria-hidden="true"></span>
|
||||
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
|
||||
</span>
|
||||
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
|
||||
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-file-text-o"></span>
|
||||
<strong>{% trans "Non-personalized ticket" %}</strong>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
The system will not ask for a name or other attendee details. This only affects
|
||||
system-provided fields, you can still add your own questions.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.description layout="control" %}
|
||||
{% bootstrap_field form.picture layout="control" %}
|
||||
{% bootstrap_field form.require_approval layout="control" %}
|
||||
|
||||
@@ -81,7 +81,11 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if i.admission %}
|
||||
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket" %}"></span>
|
||||
{% if i.personalized %}
|
||||
<span class="fa fa-id-card-o fa-fw text-muted" data-toggle="tooltip" title="{% trans "Personalized admission ticket" %}"></span>
|
||||
{% else %}
|
||||
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket without personalization" %}"></span>
|
||||
{% endif %}
|
||||
{% elif i.issue_giftcard %}
|
||||
<span class="fa fa-gift fa-fw text-muted" data-toggle="tooltip" title="{% trans "Gift card" %}"></span>
|
||||
{% endif %}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<small>{% trans "All admission products" %}</small>
|
||||
<small>{% trans "All personalized products" %}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="dnd-container">
|
||||
|
||||
@@ -184,9 +184,11 @@
|
||||
<dt>{% trans "Order locale" %}</dt>
|
||||
<dd>
|
||||
{{ display_locale }}
|
||||
<a href="{% url "control:event.order.locale" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.locale" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% if order.status == "n" %}
|
||||
<dt>{% trans "Expiry date" %}</dt>
|
||||
@@ -206,9 +208,11 @@
|
||||
{{ order.customer.identifier }} – {{ order.customer.email }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
<dt>{% trans "Contact email" %}</dt>
|
||||
@@ -217,21 +221,23 @@
|
||||
{% if order.email and order.email_known_to_work %}
|
||||
<span class="fa fa-check-circle text-success" data-toggle="tooltip" title="{% trans "We know that this email address works because the user clicked a link we sent them." %}"></span>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% if order.email %}
|
||||
<a href="{% url "control:event.order.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-envelope-o"></span>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% if order.status != "c" %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-xs">
|
||||
{% trans "Resend link" %}
|
||||
</button>
|
||||
</form>
|
||||
{% if order.email %}
|
||||
<a href="{% url "control:event.order.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-envelope-o"></span>
|
||||
</a>
|
||||
{% if order.status != "c" %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-xs">
|
||||
{% trans "Resend link" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
@@ -239,9 +245,11 @@
|
||||
<dt>{% trans "Phone number" %}</dt>
|
||||
<dd>
|
||||
{{ order.phone|default_if_none:""|phone_format }}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if invoices %}
|
||||
@@ -306,7 +314,7 @@
|
||||
{% trans "Email invoices" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_generate_invoice %}
|
||||
{% if can_generate_invoice and 'can_change_orders' in request.eventpermset %}
|
||||
<br/>
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.geninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
@@ -317,7 +325,7 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% elif can_generate_invoice %}
|
||||
{% elif can_generate_invoice and 'can_change_orders' in request.eventpermset %}
|
||||
<dt>{% trans "Invoices" %}</dt>
|
||||
<dd>
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
@@ -335,11 +343,11 @@
|
||||
<div class="panel panel-default items">
|
||||
<div class="panel-heading">
|
||||
<div class="pull-right flip">
|
||||
<a href="{% url "control:event.order.info" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change answers" %}
|
||||
</a>
|
||||
{% if 'can_change_orders' in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.info" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change answers" %}
|
||||
</a>
|
||||
· <a href="{% url "control:event.order.change" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change products" %}
|
||||
@@ -460,12 +468,12 @@
|
||||
{% endif %}
|
||||
{% if line.has_questions %}
|
||||
<dl>
|
||||
{% if line.item.admission and event.settings.attendee_names_asked %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_names_asked %}
|
||||
<dt>{% trans "Attendee name" %}</dt>
|
||||
<dd>{% if line.attendee_name %}{{ line.attendee_name }}{% else %}
|
||||
<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% if line.item.admission and event.settings.attendee_emails_asked %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_emails_asked %}
|
||||
<dt>{% trans "Attendee email" %}</dt>
|
||||
<dd>
|
||||
{% if line.attendee_email %}
|
||||
@@ -488,7 +496,7 @@
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if line.item.admission and event.settings.attendee_company_asked %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
|
||||
<dt>
|
||||
{% trans "Attendee company" %}
|
||||
</dt>
|
||||
@@ -496,7 +504,7 @@
|
||||
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if line.item.admission and event.settings.attendee_addresses_asked %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
|
||||
<dt>
|
||||
{% trans "Attendee address" %}
|
||||
</dt>
|
||||
@@ -754,7 +762,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if order.payment_refund_sum > 0 %}
|
||||
{% if order.payment_refund_sum > 0 and "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
{% trans "Create a refund" %}
|
||||
</a>
|
||||
@@ -942,9 +950,11 @@
|
||||
{% bootstrap_field comment_form.comment show_help=True show_label=False %}
|
||||
{% bootstrap_field comment_form.custom_followup_at %}
|
||||
{% bootstrap_field comment_form.checkin_attention show_help=True show_label=False %}
|
||||
<button class="btn btn-default">
|
||||
{% trans "Update comment" %}
|
||||
</button>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<button class="btn btn-default">
|
||||
{% trans "Update comment" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,35 +4,113 @@
|
||||
{% load order_overview %}
|
||||
{% block title %}{% trans "Data export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
<h1>
|
||||
{% trans "Data export" %}
|
||||
{% if "identifier" in request.GET %}
|
||||
<a href="?" class="btn btn-default">{% trans "Show all" %}</a>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% for e in exporters %}
|
||||
<details class="panel panel-default"
|
||||
{% if request.GET.identifier == e.identifier or request.POST.exporter == e.identifier %}open{% endif %}>
|
||||
<summary class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{{ e.verbose_name }}
|
||||
<i class="fa fa-angle-down collapse-indicator"></i>
|
||||
</h3>
|
||||
</summary>
|
||||
<div id="{{ e.identifier }}">
|
||||
<div class="panel-body">
|
||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ e.identifier }}" />
|
||||
{% bootstrap_form e.form layout='control' %}
|
||||
<button class="btn btn-primary pull-right flip" type="submit">
|
||||
<span class="icon icon-upload"></span> {% trans "Start export" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% if scheduled %}
|
||||
<h2>{% trans "Scheduled exports" %}</h2>
|
||||
<ul class="list-group">
|
||||
{% for s in scheduled %}
|
||||
<li class="list-group-item logentry">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 col-md-4 col-xs-12">
|
||||
<span class="fa fa-fw fa-folder"></span>
|
||||
{{ s.export_verbose_name }}
|
||||
<br>
|
||||
<span class="text-muted">
|
||||
<span class="fa fa-fw fa-user"></span>
|
||||
{{ s.owner.fullname|default:s.owner.email }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-5 col-md-6 col-xs-12">
|
||||
{% if s.schedule_next_run %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "Next run:" %}
|
||||
{{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% else %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "No next run scheduled" %}
|
||||
{% endif %}
|
||||
{% if s.export_verbose_name == "?" %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Exporter not found" %}
|
||||
</strong>
|
||||
{% elif s.error_counter >= 5 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Disabled due to multiple failures" %}
|
||||
</strong>
|
||||
{% elif s.error_counter > 0 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Failed recently" %}
|
||||
</strong>
|
||||
{% endif %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<span class="fa fa-fw fa-envelope-o"></span>
|
||||
{{ s.mail_subject }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
|
||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ s.export_identifier }}"/>
|
||||
<input type="hidden" name="scheduled" value="{{ s.pk }}"/>
|
||||
{% if s.export_verbose_name != "?" %}
|
||||
<button type="submit" class="btn btn-default" title="{% trans "Run export now" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-download"></span>
|
||||
</button>
|
||||
<button formaction="{% url "control:event.orders.export.scheduled.run" organizer=request.organizer.slug event=request.event.slug pk=s.pk %}"
|
||||
type="submit"
|
||||
title="{% trans "Run export and send via email now. This will not change the next scheduled execution." %}"
|
||||
data-toggle="tooltip" class="btn btn-default" data-no-asynctask>
|
||||
<span class="fa fa-play" aria-hidden="true"></span>
|
||||
</button>
|
||||
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-trash"></span>
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if is_paginated %}
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% regroup exporters by category as category_list %}
|
||||
{% for c, c_ex in category_list %}
|
||||
{% if c %}
|
||||
<h2>{{ c }}</h2>
|
||||
{% else %}
|
||||
<h2>{% trans "Other exports" %}</h2>
|
||||
{% endif %}
|
||||
<div class="list-group large-link-group">
|
||||
{% for e in c_ex %}
|
||||
<a class="list-group-item" href="?identifier={{ e.identifier }}">
|
||||
<h4>
|
||||
{{ e.verbose_name }}
|
||||
{% if e.featured %}
|
||||
<span class="fa fa-star text-success" data-toggle="tooltip"
|
||||
title="{% trans "Recommended for new users" %}" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
{% if e.description %}
|
||||
<p>
|
||||
{{ e.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Delete scheduled export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Delete scheduled export" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the scheduled export <strong>{{ export }}</strong>?{% endblocktrans %}</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:event.orders.export" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Data export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% trans "Data export" %}
|
||||
{% if exporter %}
|
||||
<small>
|
||||
{{ exporter.verbose_name }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if exporter.description %}
|
||||
<p class="help-block">{{ exporter.description }}</p>
|
||||
{% endif %}
|
||||
{% if schedule_form %}
|
||||
{% bootstrap_form_errors schedule_form layout='control' %}
|
||||
{% endif %}
|
||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ exporter.identifier }}"/>
|
||||
<fieldset>
|
||||
<legend>{% trans "Export options" %}</legend>
|
||||
{% bootstrap_form exporter.form layout='control' %}
|
||||
</fieldset>
|
||||
{% if schedule_form %}
|
||||
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
|
||||
<div class="form-group submit-group">
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
|
||||
class="btn btn-primary btn-save" data-no-asynctask>
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
{% trans "Start export" %}
|
||||
</button>
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="start" type="submit"
|
||||
class="btn btn-default btn-alternative" data-no-asynctask>
|
||||
<span class="fa fa-clock-o" aria-hidden="true"></span>
|
||||
{% trans "Schedule export" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load captureas %}
|
||||
|
||||
<fieldset>
|
||||
<legend>{% trans "Schedule" %}</legend>
|
||||
{% bootstrap_field schedule_form.schedule_rrule_time layout='control' %}
|
||||
{% if schedule_form.timezone %}
|
||||
{% bootstrap_field schedule_form.timezone layout='control' %}
|
||||
{% endif %}
|
||||
{% bootstrap_form_errors rrule_form layout='control' %}
|
||||
|
||||
{% bootstrap_field rrule_form.dtstart layout="control" %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">{% trans "Repetition schedule" %}</label>
|
||||
<div class="col-md-9">
|
||||
<div class="form-inline rrule-form">
|
||||
{% captureas ffield_freq %}
|
||||
{% bootstrap_field rrule_form.freq layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_interval %}
|
||||
{% bootstrap_field rrule_form.interval layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_yearly_bysetpos %}
|
||||
{% bootstrap_field rrule_form.yearly_bysetpos layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_yearly_byweekday %}
|
||||
{% bootstrap_field rrule_form.yearly_byweekday layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_yearly_bymonth %}
|
||||
{% bootstrap_field rrule_form.yearly_bymonth layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_monthly_bysetpos %}
|
||||
{% bootstrap_field rrule_form.monthly_bysetpos layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_monthly_byweekday %}
|
||||
{% bootstrap_field rrule_form.monthly_byweekday layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_count %}
|
||||
{% bootstrap_field rrule_form.count layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_until %}
|
||||
{% bootstrap_field rrule_form.until layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
|
||||
{% blocktrans trimmed with freq=ffield_freq interval=ffield_interval start=ffield_dtstart %}
|
||||
Repeat every {{ interval }} {{ freq }}
|
||||
{% endblocktrans %}<br>
|
||||
|
||||
<div class="repeat-yearly">
|
||||
<div class="radio">
|
||||
<label>
|
||||
{{ rrule_form.yearly_same.0 }}
|
||||
{% trans "At the same date every year" %}
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.yearly_same.1 }}
|
||||
{% blocktrans trimmed with setpos=ffield_yearly_bysetpos weekday=ffield_yearly_byweekday month=ffield_yearly_bymonth %}
|
||||
On the {{ setpos }} {{ weekday }} of {{ month }}
|
||||
{% endblocktrans %}<br>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repeat-monthly">
|
||||
<div class="radio">
|
||||
<label>
|
||||
{{ rrule_form.monthly_same.0 }}
|
||||
{% trans "At the same date every month" %}
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.monthly_same.1 }}
|
||||
{% blocktrans trimmed with setpos=ffield_monthly_bysetpos weekday=ffield_monthly_byweekday %}
|
||||
On the {{ setpos }} {{ weekday }}
|
||||
{% endblocktrans %}<br>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repeat-weekly">
|
||||
{% bootstrap_field rrule_form.weekly_byweekday layout="inline" %}
|
||||
</div>
|
||||
<div class="repeat-until">
|
||||
<div class="radio">
|
||||
<label>
|
||||
{{ rrule_form.end.0 }}
|
||||
{% blocktrans trimmed with count=ffield_count %}
|
||||
Repeat for {{ count }} times
|
||||
{% endblocktrans %}
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.end.1 }}
|
||||
{% blocktrans trimmed with until=ffield_until %}
|
||||
Repeat until {{ until }}
|
||||
{% endblocktrans %}<br>
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.end.2 }}
|
||||
{% blocktrans trimmed %}
|
||||
Forever
|
||||
{% endblocktrans %}<br>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Email" %}</legend>
|
||||
<div class="alert alert-info">
|
||||
{% trans "Every time your schedule is executed, the report will be sent via email." %}
|
||||
{% trans "Please note the following limitations:" %}
|
||||
<ul>
|
||||
<li>
|
||||
{% trans "Email is not a strongly encrypted medium. We only recommend using this for exports that output e.g. statistical data, not for reports that include sensitive personal data." %}
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Email is not made for large files. If your export ends up to be larger than 20 megabytes, it will not be sent." %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_schedule-owner">{% trans "Owner" %}</label>
|
||||
<div class="col-md-9">
|
||||
<input type="text" name="schedule-owner" value="{{ schedule_form.instance.owner.email }}" disabled
|
||||
class="form-control" title=""
|
||||
id="id_schedule-owner">
|
||||
<div class="help-block">
|
||||
{% trans "The export will be performed using the owner's permission level, i.e. if the owner loses access to the data, the report will stop." %}
|
||||
{% trans "The owner will receive the result as well as any error messages." %}
|
||||
{% trans "The additional recipients you add below will only receive an email if the report was successful." %}
|
||||
{% trans "All recipients of the export will be able to see who the owner of the report is." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% bootstrap_field schedule_form.mail_additional_recipients layout='control' %}
|
||||
{% bootstrap_field schedule_form.mail_additional_recipients_cc layout='control' %}
|
||||
{% bootstrap_field schedule_form.mail_additional_recipients_bcc layout='control' %}
|
||||
{% bootstrap_field schedule_form.locale layout='control' %}
|
||||
{% bootstrap_field schedule_form.mail_subject layout='control' %}
|
||||
{% bootstrap_field schedule_form.mail_template layout='control' %}
|
||||
</fieldset>
|
||||
@@ -1,37 +1,110 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load order_overview %}
|
||||
{% block title %}{% trans "Data export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
<h1>
|
||||
{% trans "Data export" %}
|
||||
{% if "identifier" in request.GET %}
|
||||
<a href="?" class="btn btn-default">{% trans "Show all" %}</a>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% for e in exporters %}
|
||||
<details class="panel panel-default" {% if "identifier" in request.GET or "exporter" in request.POST %}open{% endif %}>
|
||||
<summary class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{{ e.verbose_name }}
|
||||
<i class="fa fa-angle-down collapse-indicator"></i>
|
||||
</h3>
|
||||
</summary>
|
||||
<div id="{{ e.identifier }}">
|
||||
<div class="panel-body">
|
||||
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ e.identifier }}" />
|
||||
{% bootstrap_form e.form layout='control' %}
|
||||
<button class="btn btn-primary pull-right flip" type="submit">
|
||||
<span class="icon icon-upload"></span> {% trans "Start export" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% if scheduled %}
|
||||
<h2>{% trans "Scheduled exports" %}</h2>
|
||||
<ul class="list-group">
|
||||
{% for s in scheduled %}
|
||||
<li class="list-group-item logentry">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 col-md-4 col-xs-12">
|
||||
<span class="fa fa-fw fa-folder"></span>
|
||||
{{ s.export_verbose_name }}
|
||||
<br>
|
||||
<span class="text-muted">
|
||||
<span class="fa fa-fw fa-user"></span>
|
||||
{{ s.owner.fullname|default:s.owner.email }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-5 col-md-6 col-xs-12">
|
||||
{% if s.schedule_next_run %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "Next run:" %}
|
||||
{{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% else %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "No next run scheduled" %}
|
||||
{% endif %}
|
||||
{% if s.export_verbose_name == "?" %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Exporter not found" %}
|
||||
</strong>
|
||||
{% elif s.error_counter >= 5 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Disabled due to multiple failures" %}
|
||||
</strong>
|
||||
{% elif s.error_counter > 0 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Failed recently" %}
|
||||
</strong>
|
||||
{% endif %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<span class="fa fa-fw fa-envelope-o"></span>
|
||||
{{ s.mail_subject }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
|
||||
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ s.export_identifier }}"/>
|
||||
<input type="hidden" name="scheduled" value="{{ s.pk }}"/>
|
||||
{% if s.export_verbose_name != "?" %}
|
||||
<button type="submit" class="btn btn-default" title="{% trans "Run export now and download result" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-download"></span>
|
||||
</button>
|
||||
<button formaction="{% url "control:organizer.export.scheduled.run" organizer=request.organizer.slug pk=s.pk %}"
|
||||
type="submit"
|
||||
title="{% trans "Run export and send via email now. This will not change the next scheduled execution." %}"
|
||||
data-toggle="tooltip" class="btn btn-default" data-no-asynctask>
|
||||
<span class="fa fa-play" aria-hidden="true"></span>
|
||||
</button>
|
||||
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-trash"></span>
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if is_paginated %}
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% regroup exporters by category as category_list %}
|
||||
{% for c, c_ex in category_list %}
|
||||
{% if c %}
|
||||
<h2>{{ c }}</h2>
|
||||
{% else %}
|
||||
<h2>{% trans "Other exports" %}</h2>
|
||||
{% endif %}
|
||||
<div class="list-group large-link-group">
|
||||
{% for e in c_ex %}
|
||||
<a class="list-group-item" href="?identifier={{ e.identifier }}">
|
||||
<h4>{{ e.verbose_name }}</h4>
|
||||
{% if e.description %}
|
||||
<p>
|
||||
{{ e.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Delete scheduled export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Delete scheduled export" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the scheduled export <strong>{{ export }}</strong>?{% endblocktrans %}</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:organizer.export" organizer=request.organizer.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Data export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% trans "Data export" %}
|
||||
{% if exporter %}
|
||||
<small>
|
||||
{{ exporter.verbose_name }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if exporter.description %}
|
||||
<p class="help-block">{{ exporter.description }}</p>
|
||||
{% endif %}
|
||||
{% if schedule_form %}
|
||||
{% bootstrap_form_errors schedule_form layout='control' %}
|
||||
{% endif %}
|
||||
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ exporter.identifier }}"/>
|
||||
|
||||
<fieldset>
|
||||
<legend>{% trans "Export options" %}</legend>
|
||||
{% bootstrap_form exporter.form layout='control' %}
|
||||
</fieldset>
|
||||
{% if schedule_form %}
|
||||
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
|
||||
<div class="form-group submit-group">
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
|
||||
class="btn btn-primary btn-save" data-no-asynctask>
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
{% trans "Start export" %}
|
||||
</button>
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="start" type="submit"
|
||||
class="btn btn-default btn-alternative" data-no-asynctask>
|
||||
<span class="fa fa-clock-o" aria-hidden="true"></span>
|
||||
{% trans "Schedule export" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user