Compare commits

..

2 Commits

Author SHA1 Message Date
Raphael Michel
fb0ffeeff3 Fix #3281 -- Docker build broken 2023-05-02 10:14:03 +02:00
Raphael Michel
78711f9884 Update .gitlab-ci.yml release script 2023-04-26 15:39:50 +02:00
407 changed files with 127626 additions and 245840 deletions

View File

@@ -1,49 +0,0 @@
name: Build
on:
push:
branches: [ master ]
paths-ignore:
- 'doc/**'
- 'src/pretix/locale/**'
pull_request:
branches: [ master ]
paths-ignore:
- 'doc/**'
- 'src/pretix/locale/**'
permissions:
contents: read # to fetch code (actions/checkout)
env:
FORCE_COLOR: 1
jobs:
test:
runs-on: ubuntu-22.04
name: Packaging
strategy:
matrix:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v1
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install system dependencies
run: sudo apt update && sudo apt install gettext unzip
- name: Install Python dependencies
run: pip3 install -U setuptools build pip check-manifest
- name: Run check-manifest
run: check-manifest
- name: Run build
run: python -m build
- name: Check files
run: unzip -l dist/pretix*whl | grep node_modules || exit 1

View File

@@ -35,7 +35,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
- name: Install Dependencies - name: Install Dependencies
run: pip3 install -e ".[dev]" psycopg2-binary run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
- name: Run isort - name: Run isort
run: isort -c . run: isort -c .
working-directory: ./src working-directory: ./src
@@ -55,7 +55,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
- name: Install Dependencies - name: Install Dependencies
run: pip3 install -e ".[dev]" psycopg2-binary run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
- name: Run flake8 - name: Run flake8
run: flake8 . run: flake8 .
working-directory: ./src working-directory: ./src

View File

@@ -25,14 +25,24 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: ["3.9", "3.10", "3.11"] python-version: ["3.9", "3.10", "3.11"]
database: [sqlite, postgres] database: [sqlite, postgres, mysql]
exclude: exclude:
- database: mysql
python-version: "3.9"
- database: mysql
python-version: "3.11"
- database: sqlite - database: sqlite
python-version: "3.9" python-version: "3.9"
- database: sqlite - database: sqlite
python-version: "3.10" python-version: "3.10"
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: getong/mariadb-action@v1.1
with:
mariadb version: '10.10'
mysql database: 'pretix'
mysql root password: ''
if: matrix.database == 'mysql'
- uses: harmon758/postgresql-action@v1 - uses: harmon758/postgresql-action@v1
with: with:
postgresql version: '11' postgresql version: '11'
@@ -51,9 +61,9 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
- name: Install system dependencies - name: Install system dependencies
run: sudo apt update && sudo apt install gettext run: sudo apt update && sudo apt install gettext mariadb-client
- name: Install Python dependencies - name: Install Python dependencies
run: pip3 install --ignore-requires-python -e ".[dev]" psycopg2-binary # We ignore that flake8 needs newer python as we don't run flake8 during tests run: pip3 install --ignore-requires-python -e ".[dev]" mysqlclient psycopg2-binary # We ignore that flake8 needs newer python as we don't run flake8 during tests
- name: Run checks - name: Run checks
run: python manage.py check run: python manage.py check
working-directory: ./src working-directory: ./src

View File

@@ -30,7 +30,7 @@ pypi:
- make npminstall - make npminstall
- cd .. - cd ..
- check-manifest - check-manifest
- python -m build - python setup.py sdist bdist_wheel
- twine check dist/* - twine check dist/*
- twine upload dist/* - twine upload dist/*
tags: tags:

View File

@@ -3,6 +3,7 @@ FROM python:3.11-bullseye
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential \ build-essential \
libmariadb-dev \
gettext \ gettext \
git \ git \
libffi-dev \ libffi-dev \
@@ -48,7 +49,6 @@ COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
COPY deployment/docker/nginx-max-body-size.conf /etc/nginx/conf.d/nginx-max-body-size.conf COPY deployment/docker/nginx-max-body-size.conf /etc/nginx/conf.d/nginx-max-body-size.conf
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
COPY pyproject.toml /pretix/pyproject.toml COPY pyproject.toml /pretix/pyproject.toml
COPY _build /pretix/_build
COPY src /pretix/src COPY src /pretix/src
RUN pip3 install -U \ RUN pip3 install -U \
@@ -57,7 +57,7 @@ RUN pip3 install -U \
wheel && \ wheel && \
cd /pretix && \ cd /pretix && \
PRETIX_DOCKER_BUILD=TRUE pip3 install \ PRETIX_DOCKER_BUILD=TRUE pip3 install \
-e ".[memcached]" \ -e ".[memcached,mysql]" \
gunicorn django-extensions ipython && \ gunicorn django-extensions ipython && \
rm -rf ~/.cache/pip rm -rf ~/.cache/pip

View File

@@ -1,7 +1,6 @@
include LICENSE include LICENSE
include README.rst include README.rst
include src/Makefile include src/Makefile
include _build/backend.py
global-include *.proto global-include *.proto
recursive-include src/pretix/static * recursive-include src/pretix/static *
recursive-include src/pretix/static.dist * recursive-include src/pretix/static.dist *
@@ -33,15 +32,15 @@ recursive-include src/pretix/plugins/returnurl/templates *
recursive-include src/pretix/plugins/returnurl/static * recursive-include src/pretix/plugins/returnurl/static *
recursive-include src/pretix/plugins/webcheckin/templates * recursive-include src/pretix/plugins/webcheckin/templates *
recursive-include src/pretix/plugins/webcheckin/static * recursive-include src/pretix/plugins/webcheckin/static *
recursive-include src *.cfg recursive-include src *.cfg
recursive-include src *.csv recursive-include src *.csv
recursive-include src *.gitkeep recursive-include src *.gitkeep
recursive-include src *.jpg recursive-include src *.jpg
recursive-include src *.json recursive-include src *.json
recursive-include src *.py recursive-include src *.py
recursive-include src *.svg recursive-include src *.svg
recursive-include src *.txt recursive-include src *.txt
recursive-include src Makefile recursive-include src Makefile
recursive-exclude doc * recursive-exclude doc *
recursive-exclude deployment * recursive-exclude deployment *

View File

@@ -1,12 +0,0 @@
import tomli
from setuptools import build_meta as _orig
from setuptools.build_meta import *
def get_requires_for_build_wheel(config_settings=None):
with open("pyproject.toml", "rb") as f:
p = tomli.load(f)
return [
*_orig.get_requires_for_build_wheel(config_settings),
*p['project']['dependencies']
]

View File

@@ -154,15 +154,23 @@ Example::
port=3306 port=3306
``backend`` ``backend``
One of ``sqlite3`` and ``postgresql``. One of ``mysql`` (deprecated), ``sqlite3`` and ``postgresql``.
Default: ``sqlite3``. Default: ``sqlite3``.
If you use MySQL, be sure to create your database using
``CREATE DATABASE <dbname> CHARACTER SET utf8;``. Otherwise, Unicode
support will not properly work.
``name`` ``name``
The database's name. Default: ``db.sqlite3``. The database's name. Default: ``db.sqlite3``.
``user``, ``password``, ``host``, ``port`` ``user``, ``password``, ``host``, ``port``
Connection details for the database connection. Empty by default. Connection details for the database connection. Empty by default.
``galera``
(Deprecated) Indicates if the database backend is a MySQL/MariaDB Galera cluster and
turns on some optimizations/special case handlers. Default: ``False``
.. _`config-replica`: .. _`config-replica`:
Database replica settings Database replica settings

View File

@@ -26,7 +26,7 @@ installation guides):
* `Docker`_ * `Docker`_
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for * 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 HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
* A `PostgreSQL`_ 11+ database server * A `PostgreSQL`_ 9.6+ database server
* A `redis`_ 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 We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
@@ -321,11 +321,11 @@ workers, e.g. ``docker run … taskworker -Q notifications --concurrency 32``.
.. _Docker: https://docs.docker.com/engine/installation/linux/debian/ .. _Docker: https://docs.docker.com/engine/installation/linux/debian/
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-22-04 .. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/ .. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
.. _Let's Encrypt: https://letsencrypt.org/ .. _Let's Encrypt: https://letsencrypt.org/
.. _pretix.eu: https://pretix.eu/ .. _pretix.eu: https://pretix.eu/
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-22-04 .. _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/ .. _redis: https://blog.programster.org/debian-8-install-redis-server/
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall .. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
.. _redis website: https://redis.io/topics/security .. _redis website: https://redis.io/topics/security

View File

@@ -16,11 +16,14 @@ To use pretix, you will need the following things:
* A periodic task runner, e.g. ``cron`` * A periodic task runner, e.g. ``cron``
* **A database**. This needs to be a SQL-based that is supported by Django. We highly recommend to either * **A database**. This needs to be a SQL-based that is supported by Django. We highly recommend to either
go for **PostgreSQL**. If you do not provide one, pretix will run on SQLite, which is useful go for **PostgreSQL** or **MySQL/MariaDB**. If you do not provide one, pretix will run on SQLite, which is useful
for evaluation and development purposes. for evaluation and development purposes.
.. warning:: Do not ever use SQLite in production. It will break. .. warning:: Do not ever use SQLite in production. It will break.
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
**MariaDB 10.2.7 or newer**.
* A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix * A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix
is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much
faster. Also, you need a proxying web server in front to provide SSL encryption. faster. Also, you need a proxying web server in front to provide SSL encryption.

View File

@@ -21,7 +21,6 @@ Requirements
Please set up the following systems beforehand, we'll not explain them here in detail (but see these links for external Please set up the following systems beforehand, we'll not explain them here in detail (but see these links for external
installation guides): installation guides):
* A python 3.9+ installation
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for * 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 HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
* A `PostgreSQL`_ 11+ database server * A `PostgreSQL`_ 11+ database server
@@ -324,11 +323,11 @@ Then, proceed like after any plugin installation::
(venv)$ python -m pretix updatestyles (venv)$ python -m pretix updatestyles
# systemctl restart pretix-web pretix-worker # systemctl restart pretix-web pretix-worker
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-22-04 .. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/ .. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
.. _Let's Encrypt: https://letsencrypt.org/ .. _Let's Encrypt: https://letsencrypt.org/
.. _pretix.eu: https://pretix.eu/ .. _pretix.eu: https://pretix.eu/
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-22-04 .. _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/ .. _redis: https://blog.programster.org/debian-8-install-redis-server/
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall .. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall
.. _strong encryption settings: https://mozilla.github.io/server-side-tls/ssl-config-generator/ .. _strong encryption settings: https://mozilla.github.io/server-side-tls/ssl-config-generator/

View File

@@ -3,11 +3,11 @@
Migrating from MySQL/MariaDB to PostgreSQL Migrating from MySQL/MariaDB to PostgreSQL
========================================== ==========================================
Our recommended database for all production installations is PostgreSQL. Support for MySQL/MariaDB has been removed Our recommended database for all production installations is PostgreSQL. Support for MySQL/MariaDB will be removed in
in newer pretix releases. pretix 5.0.
In order to follow this guide, your pretix installation needs to be a version that fully supports MySQL/MariaDB. If you 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 or later, downgrade back to the last 4.x release using ``pip``. 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 .. 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 risk should be low as long as pretix is not running while you do the migration. If you are a pretix Enterprise
@@ -51,7 +51,7 @@ For our standard docker installation, create the database and user like this::
# sudo -u postgres createuser -P pretix # sudo -u postgres createuser -P pretix
# sudo -u postgres createdb -O pretix 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 listen on the Docker interface by changing the following line in ``/etc/postgresql/<version>/main/postgresql.conf``:: 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' listen_addresses = 'localhost,172.17.0.1'
@@ -153,89 +153,4 @@ And you're done! After you've verified everything has been copied correctly, you
.. note:: Don't forget to update your backup process to back up your PostgreSQL database instead of your MySQL database now. .. note:: Don't forget to update your backup process to back up your PostgreSQL database instead of your MySQL database now.
Troubleshooting
---------------
Peer authentication failed
""""""""""""""""""""""""""
Sometimes you might see an error message like this::
django.db.utils.OperationalError: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: Peer authentication failed for user "pretix"
It is important to understand that PostgreSQL by default offers two types of authentication:
- **Peer authentication**, which works automatically based on the Linux user you are working as. This requires that
the connection is made through a local socket (empty ``host=`` in ``pretix.cfg``) and the name of the PostgreSQL user
and the Linux user are identical.
- Typically, you might run into this error if you accidentally execute ``python -m pretix`` commands as root instead
of the ``pretix`` user.
- **Password authentication**, which requires a username and password and works over network connections. To force
password authentication instead of peer authentication, set ``host=127.0.0.1`` in ``pretix.cfg``.
- You can alter the password on a PostgreSQL shell using the command ``ALTER USER pretix WITH PASSWORD '***';``.
When creating a user with the ``createuser`` command, pass option ``-P`` to set a new password.
- Even with password authentication, PostgreSQL by default only allows local connections. To allow remote connections,
you need to adjust both the ``listen_address`` configuration parameter as well as the ``pg_hba.conf`` file (see above
for an example with the docker networking setup).
Database error: relation does not exist
"""""""""""""""""""""""""""""""""""""""
If you see an error like this::
2023-04-17T19:20:47.744023Z ERROR Database error 42P01: relation "public.pretix_foobar" does not exist
QUERY: ALTER TABLE public.pretix_foobar DROP CONSTRAINT IF EXISTS pretix_foobar_order_id_57e2cb41_fk_pretixbas CASCADE;
2023-04-17T19:20:47.744023Z FATAL Failed to create the schema, see above.
The reason is most likely that in the past, you installed a pretix plugin that you no longer have installed. However,
the database still contains tables of that plugin. If you want to keep the data, reinstall the plugin and re-run the
``migrate`` step from above. If you want to get rid of the data, manually drop the table mentioned in the error message
from your MySQL database::
# mysql -u root pretix
mysql> DROP TABLE pretix_foobar;
Then, retry. You might see a new error message with a new table, which you can handle the same way.
Cleaning out a failed attempt
"""""""""""""""""""""""""""""
You might want to clean your PostgreSQL database before you try again after an error. You can do so like this::
# sudo -u postgres psql pretix
pretix=# DROP SCHEMA public CASCADE;
pretix=# CREATE SCHEMA public;
pretix=# ALTER SCHEMA public OWNER TO pretix;
``pgloader`` crashes with heap exhaustion error
"""""""""""""""""""""""""""""""""""""""""""""""
On some larger databases, we've seen ``pgloader`` crash with error messages similar to this::
Heap exhausted during garbage collection: 16 bytes available, 48 requested.
Or this::
2021-01-04T21:31:17.367000Z ERROR A SB-KERNEL::HEAP-EXHAUSTED-ERROR condition without bindings for heap statistics. (If
you did not expect to see this message, please report it.
2021-01-04T21:31:17.382000Z ERROR The value
NIL
is not of type
NUMBER
when binding SB-KERNEL::X
The ``pgloader`` version distributed for Debian and Ubuntu is compiled with the ``SBCL`` compiler. If compiled with
``CCL``, these bugs go away. Unfortunately, it is pretty hard to compile ``pgloader`` manually with ``CCL``. If you
run into this, we therefore recommend using the docker container provided by the ``pgloader`` maintainers::
sudo docker run --rm -v /tmp:/tmp --network host -it dimitri/pgloader:ccl.latest pgloader /tmp/pretix.load
As peer authentication is not available from inside the container, this requires you to use password-based authentication
in PostgreSQL (see above).
.. _PostgreSQL repositories: https://wiki.postgresql.org/wiki/Apt .. _PostgreSQL repositories: https://wiki.postgresql.org/wiki/Apt

View File

@@ -70,11 +70,6 @@ Endpoints
The ``public_url`` field has been added. The ``public_url`` field has been added.
.. versionchanged:: 5.0
The ``date_from_before``, ``date_from_after``, ``date_to_before``, and ``date_to_after`` query parameters have been
added.
.. http:get:: /api/v1/organizers/(organizer)/events/ .. http:get:: /api/v1/organizers/(organizer)/events/
Returns a list of all events within a given organizer the authenticated user/token has access to. Returns a list of all events within a given organizer the authenticated user/token has access to.
@@ -146,10 +141,6 @@ Endpoints
:query has_subevents: If set to ``true``/``false``, only events with a matching value of ``has_subevents`` are returned. :query has_subevents: If set to ``true``/``false``, only events with a matching value of ``has_subevents`` are returned.
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned. :query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned. :query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
:query date_from_after: If set to a date and time, only events that start at or after the given time are returned.
:query date_from_before: If set to a date and time, only events that start at or before the given time are returned.
:query date_to_after: If set to a date and time, only events that have an end date and end at or after the given time are returned.
:query date_to_before: If set to a date and time, only events that have an end date and end at or before the given time are returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned. :query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_from`` and :query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_from`` and
``slug``. Keep in mind that ``date_from`` of event series does not really tell you anything. ``slug``. Keep in mind that ``date_from`` of event series does not really tell you anything.

View File

@@ -111,7 +111,7 @@ Listing available exporters
"input_parameters": [ "input_parameters": [
{ {
"name": "events", "name": "events",
"required": false "required": true
}, },
{ {
"name": "_format", "name": "_format",

View File

@@ -20,12 +20,6 @@ currency string Currency of the
testmode boolean Whether this is a test gift card testmode boolean Whether this is a test gift card
expires datetime Expiry date (or ``null``) expires datetime Expiry date (or ``null``)
conditions string Special terms and conditions for this card (or ``null``) conditions string Special terms and conditions for this card (or ``null``)
owner_ticket integer Internal ID of an order position that is the "owner" of
this gift card and can view all transactions. When setting
this field, you can also give the ``secret`` of an order
position.
issuer string Organizer slug of the organizer who created this gift
card and is responsible for it.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
The gift card transaction resource contains the following public fields: The gift card transaction resource contains the following public fields:
@@ -41,17 +35,8 @@ value money (string) Transaction amo
event string Event slug, if the gift card was used in the web shop (or ``null``) event string Event slug, if the gift card was used in the web shop (or ``null``)
order string Order code, if the gift card was used in the web shop (or ``null``) order string Order code, if the gift card was used in the web shop (or ``null``)
text string Custom text of the transaction (or ``null``) text string Custom text of the transaction (or ``null``)
info object Additional data about the transaction (or ``null``)
acceptor string Organizer slug of the organizer who created this transaction
(can be ``null`` for all transactions performed before
this field was added.)
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 4.20
The ``owner_ticket`` and ``issuer`` attributes of the gift card and the ``info`` and ``acceptor`` attributes of the
gift card transaction resource have been added.
Endpoints Endpoints
--------- ---------
@@ -87,8 +72,6 @@ Endpoints
"testmode": false, "testmode": false,
"expires": null, "expires": null,
"conditions": null, "conditions": null,
"owner_ticket": null,
"issuer": "bigevents",
"value": "13.37" "value": "13.37"
} }
] ]
@@ -98,10 +81,6 @@ Endpoints
:query string secret: Only show gift cards with the given secret. :query string secret: Only show gift cards with the given secret.
:query boolean testmode: Filter for gift cards that are (not) in test mode. :query boolean testmode: Filter for gift cards that are (not) in test mode.
:query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer. :query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer.
:query string expand: If you pass ``"owner_ticket"``, the respective field will be shown as a nested value instead of just an ID.
The nested objects are identical to the respective resources, except that the ``owner_ticket``
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
matching easier. The parameter can be given multiple times.
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -134,8 +113,6 @@ Endpoints
"testmode": false, "testmode": false,
"expires": null, "expires": null,
"conditions": null, "conditions": null,
"owner_ticket": null,
"issuer": "bigevents",
"value": "13.37" "value": "13.37"
} }
@@ -180,16 +157,10 @@ Endpoints
"currency": "EUR", "currency": "EUR",
"expires": null, "expires": null,
"conditions": null, "conditions": null,
"owner_ticket": null,
"issuer": "bigevents",
"value": "13.37" "value": "13.37"
} }
:param organizer: The ``slug`` field of the organizer to create a gift card for :param organizer: The ``slug`` field of the organizer to create a gift card for
:query string expand: If you pass ``"owner_ticket"``, the respective field will be shown as a nested value instead of just an ID.
The nested objects are identical to the respective resources, except that the ``owner_ticket``
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
matching easier. The parameter can be given multiple times.
:statuscode 201: no error :statuscode 201: no error
:statuscode 400: The gift card could not be created due to invalid submitted data. :statuscode 400: The gift card could not be created due to invalid submitted data.
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -234,8 +205,6 @@ Endpoints
"currency": "EUR", "currency": "EUR",
"expires": null, "expires": null,
"conditions": null, "conditions": null,
"owner_ticket": null,
"issuer": "bigevents",
"value": "14.00" "value": "14.00"
} }
@@ -281,8 +250,6 @@ Endpoints
"testmode": false, "testmode": false,
"expires": null, "expires": null,
"conditions": null, "conditions": null,
"owner_ticket": null,
"issuer": "bigevents",
"value": "15.37" "value": "15.37"
} }
@@ -326,11 +293,7 @@ Endpoints
"value": "50.00", "value": "50.00",
"event": "democon", "event": "democon",
"order": "FXQYW", "order": "FXQYW",
"text": null, "text": null
"acceptor": "bigevents",
"info": {
"created_by": "plugin1"
}
} }
] ]
} }

View File

@@ -18,7 +18,6 @@ at :ref:`plugin-docs`.
item_variations item_variations
item_bundles item_bundles
item_add-ons item_add-ons
item_meta_properties
questions questions
question_options question_options
quotas quotas

View File

@@ -1,211 +0,0 @@
Item Meta Properties
====================
Resource description
--------------------
An Item Meta Property is used to include (event internally relevant) meta information with every item (product). This
could be internal categories like booking positions.
The Item Meta Properties resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Unique ID for this property
name string Name of the property
default string Value of the default option
required boolean If ``true``, this property will have to be assigned a
value in all items of the related event
allowed_values list List of all permitted values for this property,
or ``null`` for no limitation
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/item_meta_properties/
Returns a list of all Item Meta Properties within a given event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/item_meta_properties/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"name": "Color",
"default": "red",
"required": true,
"allowed_values": ["red", "green", "blue"]
}
]
}
:param organizer: The ``slug`` field of the organizer
:param event: The ``slug`` field of the event
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/item_meta_properties/(id)/
Returns information on one property, identified by its id.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/item_meta_properties/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
{
"id": 1,
"name": "Color",
"default": "red",
"required": true,
"allowed_values": ["red", "green", "blue"]
}
:param organizer: The ``slug`` field of the organizer
:param event: The ``slug`` field of the event
:param id: The ``id`` field of the item meta property to retrieve
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/item_meta_properties/
Creates a new item meta property
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/item_meta_properties/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "ref-code",
"default": "abcde",
"required": true,
"allowed_values": null
}
**Example response**:
.. sourcecode:: http
{
"id": 2,
"name": "ref-code",
"default": "abcde",
"required": true,
"allowed_values": null
}
:param organizer: The ``slug`` field of the organizer
:param event: The ``slug`` field of the event
:statuscode 201: no error
:statuscode 400: The item meta property could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/item_meta_properties/(id)/
Update an item meta property. 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.
You can change all fields of the resource except the ``id`` field.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/item_meta_properties/2/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"required": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 2,
"name": "ref-code",
"default": "abcde",
"required": false,
"allowed_values": []
}
:param organizer: The ``slug`` field of the organizer
:param event: The ``slug`` field of the event
:param id: The ``id`` field of the item meta property to modify
:statuscode 200: no error
:statuscode 400: The property could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/item_meta_properties/(id)/
Delete an item meta property.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/item_meta_properties/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
:param event: The ``slug`` field of the event
:param id: The ``id`` field of the item meta property to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.

View File

@@ -19,7 +19,6 @@ Field Type Description
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
id integer Internal ID of the medium id integer Internal ID of the medium
type string Type of medium, e.g. ``"barcode"`` or ``"nfc_uid"``. type string Type of medium, e.g. ``"barcode"`` or ``"nfc_uid"``.
organizer string Organizer slug of the organizer who "owns" this medium.
identifier string Unique identifier of the medium. The format depends on the ``type``. identifier string Unique identifier of the medium. The format depends on the ``type``.
active boolean Whether this medium may be used. active boolean Whether this medium may be used.
created datetime Date of creation created datetime Date of creation
@@ -68,7 +67,6 @@ Endpoints
"results": [ "results": [
{ {
"id": 1, "id": 1,
"organizer": "bigevents",
"identifier": "ABCDEFGH", "identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z", "created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z", "updated": "2021-04-06T13:44:22.809377Z",
@@ -93,11 +91,11 @@ Endpoints
:query string updated_since: Only show media updated since a given date. :query string updated_since: Only show media updated since a given date.
:query integer linked_orderposition: Only show media linked to the given ticket. :query integer linked_orderposition: Only show media linked to the given ticket.
:query integer linked_giftcard: Only show media linked to the given gift card. :query integer linked_giftcard: Only show media linked to the given gift card.
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``, :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
or ``"customer"``, the respective field will be shown as a nested value instead of just an ID. field will be shown as a nested value instead of just an ID. The nested objects are identical to
The nested objects are identical to the respective resources, except that order positions the respective resources, except that the ``linked_orderposition`` will have an attribute of the
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
matching easier. The parameter can be given multiple times. can be given multiple times.
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -125,7 +123,6 @@ Endpoints
{ {
"id": 1, "id": 1,
"organizer": "bigevents",
"identifier": "ABCDEFGH", "identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z", "created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z", "updated": "2021-04-06T13:44:22.809377Z",
@@ -141,11 +138,11 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the medium to fetch :param id: The ``id`` field of the medium to fetch
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``, :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
or ``"customer"``, the respective field will be shown as a nested value instead of just an ID. field will be shown as a nested value instead of just an ID. The nested objects are identical to
The nested objects are identical to the respective resources, except that order positions the respective resources, except that the ``linked_orderposition`` will have an attribute of the
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
matching easier. The parameter can be given multiple times. can be given multiple times.
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
@@ -155,9 +152,6 @@ Endpoints
Look up a new reusable medium by its identifier. In some cases, this might lead to the automatic creation of a new Look up a new reusable medium by its identifier. In some cases, this might lead to the automatic creation of a new
medium behind the scenes. medium behind the scenes.
This endpoint, and this endpoint only, might return media from a different organizer if there is a cross-acceptance
agreement. In this case, only linked gift cards will be returned, no order position or customer records,
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -182,7 +176,6 @@ Endpoints
{ {
"id": 1, "id": 1,
"organizer": "bigevents",
"identifier": "ABCDEFGH", "identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z", "created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z", "updated": "2021-04-06T13:44:22.809377Z",
@@ -242,7 +235,6 @@ Endpoints
{ {
"id": 1, "id": 1,
"organizer": "bigevents",
"identifier": "ABCDEFGH", "identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z", "created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z", "updated": "2021-04-06T13:44:22.809377Z",
@@ -299,7 +291,6 @@ Endpoints
{ {
"id": 1, "id": 1,
"organizer": "bigevents",
"identifier": "ABCDEFGH", "identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z", "created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z", "updated": "2021-04-06T13:44:22.809377Z",

View File

@@ -63,11 +63,6 @@ last_modified datetime Last modificati
The ``search`` query parameter has been added to filter sub-events by their name or location in any language. The ``search`` query parameter has been added to filter sub-events by their name or location in any language.
.. versionchanged:: 5.0
The ``date_from_before``, ``date_from_after``, ``date_to_before``, and ``date_to_after`` query parameters have been
added.
Endpoints Endpoints
--------- ---------
@@ -135,10 +130,6 @@ Endpoints
:query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned. :query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned.
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. :query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. :query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
:query date_from_after: If set to a date and time, only events that start at or after the given time are returned.
:query date_from_before: If set to a date and time, only events that start at or before the given time are returned.
:query date_to_after: If set to a date and time, only events that have an end date and end at or after the given time are returned.
:query date_to_before: If set to a date and time, only events that have an end date and end at or before the given time are returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. :query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
:query search: Only return events matching a given search query. :query search: Only return events matching a given search query.
:param organizer: The ``slug`` field of a valid organizer :param organizer: The ``slug`` field of a valid organizer
@@ -467,10 +458,6 @@ Endpoints
:query event__live: If set to ``true``/``false``, only events with a matching value of ``live`` on the parent event are returned. :query event__live: If set to ``true``/``false``, only events with a matching value of ``live`` on the parent event are returned.
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. :query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. :query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
:query date_from_after: If set to a date and time, only events that start at or after the given time are returned.
:query date_from_before: If set to a date and time, only events that start at or before the given time are returned.
:query date_to_after: If set to a date and time, only events that have an end date and end at or after the given time are returned.
:query date_to_before: If set to a date and time, only events that have an end date and end at or before the given time are returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. :query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
:query sales_channel: If set to a sales channel identifier, the response will only contain subevents from events available on this sales channel. :query sales_channel: If set to a sales channel identifier, the response will only contain subevents from events available on this sales channel.
:param organizer: The ``slug`` field of a valid organizer :param organizer: The ``slug`` field of a valid organizer

View File

@@ -20,16 +20,11 @@ internal_name string An optional nam
rate decimal (string) Tax rate in percent rate decimal (string) Tax rate in percent
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
the specified product price the specified product price
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied. Will eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
be ignored if custom rules are set.
home_country string Merchant country (required for reverse charge), can be home_country string Merchant country (required for reverse charge), can be
``null`` or empty string ``null`` or empty string
keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom
rules keep the gross price constant (default is ``false``) rules keep the gross price constant (default is ``false``)
custom_rules object Dynamic rules specification. Each list element
corresponds to one rule that will be processed in order.
The current version of the schema in use can be found
`here`_.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -37,10 +32,6 @@ custom_rules object Dynamic rules s
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added. The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
.. versionchanged:: 2023.6
The ``custom_rules`` attribute has been added.
Endpoints Endpoints
--------- ---------
@@ -77,7 +68,6 @@ Endpoints
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
"keep_gross_if_rate_changes": false, "keep_gross_if_rate_changes": false,
"custom_rules": null,
"home_country": "DE" "home_country": "DE"
} }
] ]
@@ -118,7 +108,6 @@ Endpoints
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
"keep_gross_if_rate_changes": false, "keep_gross_if_rate_changes": false,
"custom_rules": null,
"home_country": "DE" "home_country": "DE"
} }
@@ -167,7 +156,6 @@ Endpoints
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
"keep_gross_if_rate_changes": false, "keep_gross_if_rate_changes": false,
"custom_rules": null,
"home_country": "DE" "home_country": "DE"
} }
@@ -215,7 +203,6 @@ Endpoints
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
"keep_gross_if_rate_changes": false, "keep_gross_if_rate_changes": false,
"custom_rules": null,
"home_country": "DE" "home_country": "DE"
} }
@@ -255,5 +242,3 @@ Endpoints
:statuscode 204: no error :statuscode 204: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this tax rule cannot be deleted since it is currently in use. :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this tax rule cannot be deleted since it is currently in use.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/tax-rules-custom.schema.json

View File

@@ -47,8 +47,6 @@ tag string A string that i
comment string An internal comment on the voucher comment string An internal comment on the voucher
subevent integer ID of the date inside an event series this voucher belongs to (or ``null``). subevent integer ID of the date inside an event series this voucher belongs to (or ``null``).
show_hidden_items boolean Only if set to ``true``, this voucher allows to buy products with the property ``hide_without_voucher``. Defaults to ``true``. show_hidden_items boolean Only if set to ``true``, this voucher allows to buy products with the property ``hide_without_voucher``. Defaults to ``true``.
all_addons_included boolean If set to ``true``, all add-on products for the product purchased with this voucher are included in the base price.
all_bundles_included boolean If set to ``true``, all bundled products for the product purchased with this voucher are added without their designated price.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -97,9 +95,6 @@ Endpoints
"comment": "", "comment": "",
"seat": null, "seat": null,
"subevent": null, "subevent": null,
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false
} }
] ]
} }
@@ -166,10 +161,7 @@ Endpoints
"tag": "testvoucher", "tag": "testvoucher",
"comment": "", "comment": "",
"seat": null, "seat": null,
"subevent": null, "subevent": null
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -206,10 +198,7 @@ Endpoints
"quota": null, "quota": null,
"tag": "testvoucher", "tag": "testvoucher",
"comment": "", "comment": "",
"subevent": null, "subevent": null
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false
} }
**Example response**: **Example response**:
@@ -236,10 +225,7 @@ Endpoints
"tag": "testvoucher", "tag": "testvoucher",
"comment": "", "comment": "",
"seat": null, "seat": null,
"subevent": null, "subevent": null
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false
} }
:param organizer: The ``slug`` field of the organizer to create a voucher for :param organizer: The ``slug`` field of the organizer to create a voucher for
@@ -278,10 +264,7 @@ Endpoints
"quota": null, "quota": null,
"tag": "testvoucher", "tag": "testvoucher",
"comment": "", "comment": "",
"subevent": null, "subevent": null
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false
}, },
{ {
"code": "ASDKLJCYXCASDASD", "code": "ASDKLJCYXCASDASD",
@@ -296,10 +279,7 @@ Endpoints
"quota": null, "quota": null,
"tag": "testvoucher", "tag": "testvoucher",
"comment": "", "comment": "",
"subevent": null, "subevent": null
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false
}, },
**Example response**: **Example response**:
@@ -373,10 +353,7 @@ Endpoints
"tag": "testvoucher", "tag": "testvoucher",
"comment": "", "comment": "",
"seat": null, "seat": null,
"subevent": null, "subevent": null
"show_hidden_items": false,
"all_addons_included": false,
"all_bundles_included": false
} }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify

View File

@@ -50,10 +50,6 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.order.payment.confirmed`` * ``pretix.event.order.payment.confirmed``
* ``pretix.event.order.approved`` * ``pretix.event.order.approved``
* ``pretix.event.order.denied`` * ``pretix.event.order.denied``
* ``pretix.event.orders.waitinglist.added``
* ``pretix.event.orders.waitinglist.changed``
* ``pretix.event.orders.waitinglist.deleted``
* ``pretix.event.orders.waitinglist.voucher_assigned``
* ``pretix.event.checkin`` * ``pretix.event.checkin``
* ``pretix.event.checkin.reverted`` * ``pretix.event.checkin.reverted``
* ``pretix.event.added`` * ``pretix.event.added``

View File

@@ -18,13 +18,13 @@ If you want to add a custom view to the control area of an event, just register
.. code-block:: python .. code-block:: python
from django.urls import re_path from django.conf.urls import url
from . import views from . import views
urlpatterns = [ urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/mypluginname/', url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/mypluginname/',
views.admin_view, name='backend'), views.admin_view, name='backend'),
] ]
It is required that your URL parameters are called ``organizer`` and ``event``. If you want to It is required that your URL parameters are called ``organizer`` and ``event``. If you want to

View File

@@ -13,7 +13,7 @@ Core
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter, item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter,
register_ticket_secret_generators, gift_card_transaction_display register_ticket_secret_generators
Order events Order events
"""""""""""" """"""""""""
@@ -21,7 +21,7 @@ Order events
There are multiple signals that will be sent out in the ordering cycle: There are multiple signals that will be sent out in the ordering cycle:
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: validate_cart, validate_cart_addons, validate_order, order_valid_if_pending, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text :members: validate_cart, validate_cart_addons, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text
Check-ins Check-ins
""""""""" """""""""

View File

@@ -35,13 +35,13 @@ automatically and should be provided by any plugin that provides any view.
A very basic example that provides one view in the admin panel and one view in the frontend A very basic example that provides one view in the admin panel and one view in the frontend
could look like this:: could look like this::
from django.urls import re_path from django.conf.urls import url
from . import views from . import views
urlpatterns = [ urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/mypluginname/', url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/mypluginname/',
views.AdminView.as_view(), name='backend'), views.AdminView.as_view(), name='backend'),
] ]
event_patterns = [ event_patterns = [

View File

@@ -1,143 +0,0 @@
ePayBL
======
.. note::
Since ePayBL is only available to german federal, provincial and communal entities, the following page is also
only provided in german. Should you require assistance with ePayBL and do not speak this language, please feel free
reach out to support@pretix.eu.
Einführung
----------
.. note::
Sollten Sie lediglich schnell entscheiden wollen, welcher Kontierungsmodus in den Einstellungen des pretix
ePayBL-plugins gewählt werden soll, so springen Sie direkt zur Sektion :ref:`Kontierungsmodus`.
`ePayBL`_ - das ePayment-System von Bund und Länder - ist das am weitesten verbreitete Zahlungssystem für Bundes-, Länder-
sowie kommunale Aufgabenträger. Während es nur wie eines von vielen anderen Zahlungssystemen scheint, so bietet es
seinen Nutzern besondere Vorteile, wie die automatische Erfassung von Zahlungsbelegen, dem Übertragen von Buchungen in
Haushaltskassen/-systeme sowie die automatische Erfassung von Kontierungen und Steuermerkmalen.
Rein technisch gesehen ist ePayBL hierbei nicht ein eigenständiger Zahlungsdienstleister sondern nur ein eine Komponente
im komplexen System, dass die Zahlungsabwicklung für Kommunen und Behörden ist.
Im folgenden der schematische Aufbau einer Umgebung, in welcher ePayBL zum Einsatz kommt:
.. figure:: img/epaybl_flowchart.png
:class: screenshot
Quelle: Integrationshandbuch ePayBL-Konnektor, DResearch Digital Media Systems GmbH
In diesem Schaubild stellt pretix, bzw. die von Ihnen als Veranstalter angelegten Ticketshops, das Fachverfahren dar.
ePayBL stellt das Bindeglied zwischen den Fachverfahren, Haushaltssystemen und dem eigentlichen Zahlungsdienstleister,
dem sog. ZV-Provider dar. Dieser ZV-Provider ist die Stelle, welche die eigentlichen Kundengelder einzieht und an den
Händler auszahlt. Das Gros der Zahlungsdienstleister unterstützt pretix hierbei auch direkt; sprich: Sollten Sie die
Anbindung an Ihre Haushaltssysteme nicht benötigen, kann eine direkte Anbindung in der Regel ebenso - und dies bei meist
vermindertem Aufwand - vorgenommen werden.
In der Vergangenheit zeigte sich jedoch schnell, dass nicht jeder IT-Dienstleister immer sofort die neueste Version von
ePayBL seinen Nutzern angeboten hat. Die Gründe hierfür sind mannigfaltig: Von fest vorgegebenen Update-Zyklen bis hin
zu Systeme mit speziellen Anpassungen, kann leider nicht davon ausgegangen werden, dass alle ePayBL-Systeme exakt gleich
ansprechbar sind - auch wenn es sich dabei eigentlich um einen standardisierten Dienst handelt.
Aus diesem Grund gibt es mit dem ePayBL-Konnektor eine weitere Abstraktionsschicht welche optional zwischen den
Fachverfahren und dem ePayBL-Server sitzt. Dieser Konnektor wird so gepflegt, dass er zum einen eine dauerhaft
gleichartige Schnittstelle den Fachverfahren bietet aber gleichzeitig auch mit jeder Version des ePayBL-Servers
kommunizieren kann - egal wie neu oder alt, wie regulär oder angepasst diese ist.
Im Grunde müsste daher eigentlich immer gesagt werden, dass pretix eine Anbindung an den ePayBL-Konnektor bietet; nicht
an "ePayBL" oder den "ePayBL-Server". Diese Unterscheidung kann bei der Ersteinrichtung und Anforderung von Zugangsdaten
von Relevanz sein. Da in der Praxis jedoch beide Begriffe gleichbedeutend genutzt werden, wird im Folgenden auch nur von
einer ePayBL-Anbindung die Rede sein - auch wenn explizit der Konnektor gemeint ist.
.. _`Kontierungsmodus`:
Kontierungsmodus
----------------
ePayBL ist ein Produkt, welches für die Abwicklung von Online-Zahlungsvorgängen in der Verwaltung geschaffen wurde. Ein
Umfeld, in dem klar definiert ist, was ein Kunde gerade bezahlt und wohin das Geld genau fließt. Diese Annahmen lassen
sich in einem Ticketshop wie pretix jedoch nur teilweise genauso abbilden.
Die ePayBL-Integration für pretix bietet daher zwei unterschiedliche Modi an, wie Buchungen erfasst und an ePayBL und
damit auch an die dahinterliegenden Haushaltssysteme gemeldet werden können.
Kontierung pro Position/Artikel
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Dieser Modus versucht den klassischen, behördentypischen ePayBL-Zahlungsvorgang abzubilden: Jede einzelne Position, die
ein Kunde in den Warenkorb legt, wird auch genauso 1:1 an ePayBL und die Hintergrundsysteme übermittelt.
Hierbei muss zwingend auch für jede Position ein Kennzeichen für Haushaltsstelle und Objektnummer, sowie optional ein
Kontierungsobjekt (``HREF``; bspw. ``stsl=Steuerschlüssel;psp=gsb:Geschäftsbereich,auft:Innenauftrag,kst:Kostenstelle;``
) übermittelt werden.
Diese Daten sind vom Veranstalter entsprechend für jeden in der Veranstaltung angelegten Artikel innerhalb des Tabs
"Zusätzliche Einstellungen" der Produkteinstellungen zu hinterlegen.
Während diese Einstellung eine größtmögliche Menge an Kontierungsdaten überträgt und auch ein separates Verbuchen von
Leistungen auf unterschiedliche Haushaltsstellen erlaubt, so hat diese Option auch einen großen Nachteil: Der Kunde kann
nur eine Zahlung für seine Bestellung leisten.
Während sich dies nicht nach einem großen Problem anhört, so kann dies beim Kunden zu Frust führen. pretix bietet die
Option an, dass ein Veranstalter eine Bestellung jederzeit verändern kann: Ändern von Preisen von Positionen in einer
aufgegebenen Bestellung, Zubuchen und Entfernen von Bestellpositionen, etc. Hat der Kunde seine ursprüngliche Bestellung
jedoch schon bezahlt, kann pretix nicht mehr die komplette Bestellung mit den passenden Kontierungen übertragen - es
müsste nur ein Differenz-Abbild zwischen Ursprungsbestellung und aktueller Bestellung übertragen werden. Aber auch wenn
eine "Nachmeldung" möglich wäre, so wäre ein konkretes Auflösen für was jetzt genau gezahlt wird, nicht mehr möglich.
Daher gilt bei der Nutzung der Kontierung pro Position/Artikel: Der Kunde kann nur eine (erfolgreiche) Zahlung auf seine
Bestellung leisten.
Eine weitere Einschränkung dieses Modus ist, dass aktuell keine Gebühren-Positionen (Versandkosten, Zahlungs-, Storno-
oder Servicegebühren) in diesem Modus übertragen werden können. Bitte wenden Sie sich an uns, wenn Sie diese
Funktionalität benötigen.
Kontierung pro Zahlvorgang
^^^^^^^^^^^^^^^^^^^^^^^^^^
Dieser Modus verabschiedet sich vom behördlichen "Jede Position gehört genau zu einem Haushaltskonto und muss genau
zugeordnet werden". Stattdessen werden alle Bestellpositionen - inklusive eventuell definierter Gebühren - vermengt und
nur als ein großer Warenkorb, genauer gesagt: eine einzige Position an ePayBL sowie die Hintergrundsysteme gemeldet.
Während im "pro Postion/Artikel"-Modus jeder Artikel einzeln übermittelt wird und damit auch korrekt pro Artikel der
jeweilige Brutto- und Nettopreis, sowie der anfallende Steuerbetrag und ein Steuerkennzeichen (mit Hilfe des optionalen
``HREF``-Attributs) übermittelt werden, ist dies im "pro Zahlvorgang"-Modus nicht möglich.
Stattdessen übermittelt pretix nur einen Betrag für den gesamten Warenkorb: Bruttopreis == Nettopreis. Der Steuerbetrag
wird hierbei als 0 übermittelt.
Die Angabe einer Haushaltsstelle und Objektnummer, sowie optional der ``HREF``-Kontierungsinformationen ist jedoch
weiterhin notwendig - allerdings nicht mehr individuell für jeden Artikel/jede Position sondern nur für die gesamte
Bestellung. Diese Daten sind direkt in den ePayBL-Einstellungen der Veranstaltung unter Einstellungen -> Zahlung ->
ePayBL vorzunehmen
In der Praxis bedeutet dies, dass in einem angeschlossenen Haushaltssystem nicht nachvollzogen kann, welche Positionen
konkret erworben und bezahlt wurden - stattdessen kann nur der Fakt, dass etwas verkauft wurde erfasst werden.
Je nach Aufbau und Vorgaben der Finanzbuchhaltung kann dies jedoch ausreichend sein - wenn bspw. eine Ferienfahrt
angeboten wird und seitens der Haushaltssysteme nicht erfasst werden muss, wie viel vom Gesamtbetrag einer Bestellung
auf die Ferienfahrt an sich, auf einen Zubringerbus und einen Satz Bettwäsche entfallen ist, sondern (vereinfacht
gesagt) es ausreichend ist, dass "Eine Summe X für die Haushaltsstelle/Objektnummer geflossen ist".
Dieser Modus der Kontierung bietet Ihnen auch als Vorteil gegenüber dem vorhergehenden an, dass die Bestellungen der
Kunden jederzeit erweitert und verändert werden können - auch wenn die Ursprungsbestellung schon bezahlt wurde und nur
noch eine Differenz gezahlt wird.
Einschränkungen
---------------
Zum aktuellen Zeitpunkt erlaubt die pretix-Anbindung an ePayBL nicht das durchführen von Erstattungen von bereits
geleisteten Zahlungen. Der Prozess hierfür unterscheidet sich von Behörde zu Behörde und muss daher händisch
durchgeführt werden.
.. _ePayBL: https://www.epaybl.de/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -18,7 +18,6 @@ If you want to **create** a plugin, please go to the
campaigns campaigns
certificates certificates
digital digital
epaybl
exhibitors exhibitors
shipping shipping
imported_secrets imported_secrets

View File

@@ -1,4 +1,4 @@
sphinx==7.0.* sphinx==6.2.*
jinja2==3.1.* jinja2==3.1.*
sphinx-rtd-theme sphinx-rtd-theme
sphinxcontrib-httpdomain sphinxcontrib-httpdomain

View File

@@ -1,5 +1,5 @@
-e ../ -e ../
sphinx==7.0.* sphinx==6.2.*
jinja2==3.1.* jinja2==3.1.*
sphinx-rtd-theme sphinx-rtd-theme
sphinxcontrib-httpdomain sphinxcontrib-httpdomain

View File

@@ -201,10 +201,6 @@ record for the subdomain ``pretix._domainkey`` with the following contents::
Then, please contact support@pretix.eu and we will enable DKIM for your domain on our mail servers. Then, please contact support@pretix.eu and we will enable DKIM for your domain on our mail servers.
.. note:: Many SMTP servers impose rate limits on the sent emails, such as a maximum number of emails sent per hour.
These SMTP servers are often not suitable for use with pretix, in case you want to send an email to many
hundreds or thousands of ticket buyers. Depending on how the rate limit is implemented, emails might be lost
in this case, as pretix only retries email delivery for a certain time period.
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework .. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax .. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax

View File

@@ -22,10 +22,11 @@ classifiers = [
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Framework :: Django :: 4.1", "Framework :: Django :: 3.2",
] ]
dependencies = [ dependencies = [
# Note that many of these are repeated as build-time dependencies down below -- change them too in case of updates!
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab "arabic-reshaper==3.0.0", # Support for Arabic in reportlab
"babel", "babel",
"BeautifulSoup4==4.12.*", "BeautifulSoup4==4.12.*",
@@ -36,27 +37,28 @@ dependencies = [
"css-inline==0.8.*", "css-inline==0.8.*",
"defusedcsv>=1.1.0", "defusedcsv>=1.1.0",
"dj-static", "dj-static",
"Django==4.1.*", "Django==3.2.*,>=3.2.18",
"django-bootstrap3==23.1.*", "django-bootstrap3==23.1.*",
"django-compressor==4.3.*", "django-compressor==4.3.*",
"django-countries==7.5.*", "django-countries==7.5.*",
"django-filter==23.2", "django-filter==23.1",
"django-formset-js-improved==0.5.0.3", "django-formset-js-improved==0.5.0.3",
"django-formtools==2.4.1", "django-formtools==2.4",
"django-hierarkey==1.1.*", "django-hierarkey==1.1.*",
"django-hijack==3.3.*", "django-hijack==3.3.*",
"django-i18nfield==1.9.*,>=1.9.4", "django-i18nfield==1.9.*,>=1.9.4",
"django-libsass==0.9", "django-libsass==0.9",
"django-localflavor==4.0", "django-localflavor==4.0",
"django-markup", "django-markup",
"django-mysql",
"django-oauth-toolkit==2.2.*", "django-oauth-toolkit==2.2.*",
"django-otp==1.2.*", "django-otp==1.1.*",
"django-phonenumber-field==7.1.*", "django-phonenumber-field==7.0.*",
"django-redis==5.2.*", "django-redis==5.2.*",
"django-scopes==2.0.*", "django-scopes==1.2.*",
"django-statici18n==2.3.*", "django-statici18n==2.3.*",
"djangorestframework==3.14.*", "djangorestframework==3.14.*",
"dnspython==2.3.*", "dnspython==2.2.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
"geoip2==4.*", "geoip2==4.*",
"importlib_metadata==6.6.*", # Polyfill, we can probably drop this once we require Python 3.10+ "importlib_metadata==6.6.*", # Polyfill, we can probably drop this once we require Python 3.10+
@@ -67,32 +69,30 @@ dependencies = [
"lxml", "lxml",
"markdown==3.4.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3. "markdown==3.4.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7 # We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*", "mt-940==4.23.*",
"oauthlib==3.2.*", "oauthlib==3.2.*",
"openpyxl==3.1.*", "openpyxl==3.1.*",
"packaging", "packaging",
"paypalrestsdk==1.13.*", "paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.7.*", "PyJWT==2.6.*",
"phonenumberslite==8.13.*", "phonenumberslite==8.13.*",
"Pillow==9.5.*", "Pillow==9.5.*",
"pretix-plugin-build", "protobuf==4.22.*",
"protobuf==4.23.*",
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==2.21", "pycparser==2.21",
"pycryptodome==3.18.*", "pycryptodome==3.17.*",
"pypdf==3.9.*", "pypdf==3.8.*",
"python-bidi==0.4.*", # Support for Arabic in reportlab "python-bidi==0.4.*", # Support for Arabic in reportlab
"python-dateutil==2.8.*", "python-dateutil==2.8.*",
"python-u2flib-server==4.*", "python-u2flib-server==4.*",
"pytz", "pytz",
"pytz-deprecation-shim==0.1.*",
"pyuca", "pyuca",
"qrcode==7.4.*", "qrcode==7.4.*",
"redis==4.5.*,>=4.5.4", "redis==4.5.*,>=4.5.4",
"reportlab==4.0.*", "reportlab==3.6.*",
"requests==2.31.*", "requests==2.28.*",
"sentry-sdk==1.15.*", "sentry-sdk==1.15.*",
"sepaxml==2.6.*", "sepaxml==2.6.*",
"slimit", "slimit",
@@ -109,12 +109,17 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
memcached = ["pylibmc"] memcached = ["pylibmc"]
mysql = ["mysqlclient"]
dev = [ dev = [
"coverage", "coverage",
"coveralls", "coveralls",
"django-debug-toolbar==4.0.*",
"django-formset-js-improved==0.5.0.3",
"django-oauth-toolkit==2.2.*",
"flake8==6.0.*", "flake8==6.0.*",
"freezegun", "freezegun",
"isort==5.12.*", "isort==5.12.*",
"oauthlib==3.2.*",
"pep8-naming==0.13.*", "pep8-naming==0.13.*",
"potypo", "potypo",
"pycodestyle==2.10.*", "pycodestyle==2.10.*",
@@ -125,7 +130,7 @@ dev = [
"pytest-mock==3.10.*", "pytest-mock==3.10.*",
"pytest-rerunfailures==11.*", "pytest-rerunfailures==11.*",
"pytest-sugar", "pytest-sugar",
"pytest-xdist==3.3.*", "pytest-xdist==3.2.*",
"pytest==7.3.*", "pytest==7.3.*",
"responses", "responses",
] ]
@@ -135,14 +140,31 @@ build = "pretix._build:CustomBuild"
build_ext = "pretix._build:CustomBuildExt" build_ext = "pretix._build:CustomBuildExt"
[build-system] [build-system]
build-backend = "backend"
backend-path = ["_build"]
requires = [ requires = [
"setuptools", "setuptools",
"setuptools-rust", "setuptools-rust",
"wheel", "wheel",
"importlib_metadata", "importlib_metadata",
"tomli",
# These are runtime dependencies that we unfortunately need to be import in the step that generates
# all CSS and JS asset files. We should keep their versions in sync with the definition above.
"babel",
"Django==3.2.*,>=3.2.18",
"django-bootstrap3==23.1.*",
"django-compressor==4.3.*",
"django-countries==7.5.*",
"django-formtools==2.4",
"django-hierarkey==1.1.*",
"django-i18nfield==1.9.*,>=1.9.4",
"django-libsass==0.9",
"django-phonenumber-field==7.0.*",
"django-statici18n==2.3.*",
"djangorestframework==3.14.*",
"libsass==0.22.*",
"phonenumberslite==8.13.*",
"pycountry",
"pyuca",
"slimit",
] ]
[project.urls] [project.urls]
@@ -160,4 +182,4 @@ version = {attr = "pretix.__version__"}
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
include = ["pretix*"] include = ["pretix*"]
namespaces = false namespaces = false

View File

@@ -19,31 +19,8 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import sys
from pathlib import Path
import setuptools import setuptools
sys.path.append(str(Path.cwd() / 'src'))
def _CustomBuild(*args, **kwargs):
from pretix._build import CustomBuild
return CustomBuild(*args, **kwargs)
def _CustomBuildExt(*args, **kwargs):
from pretix._build import CustomBuildExt
return CustomBuildExt(*args, **kwargs)
cmdclass = {
'build': _CustomBuild,
'build_ext': _CustomBuildExt,
}
if __name__ == "__main__": if __name__ == "__main__":
setuptools.setup( setuptools.setup()
cmdclass=cmdclass,
)

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "4.21.0.dev0" __version__ = "4.19.0"

View File

@@ -30,6 +30,7 @@ from django.utils.translation import gettext_lazy as _ # NOQA
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) BASE_DIR = os.path.dirname(os.path.dirname(__file__))
USE_I18N = True USE_I18N = True
USE_L10N = True
USE_TZ = True USE_TZ = True
INSTALLED_APPS = [ INSTALLED_APPS = [
@@ -67,7 +68,6 @@ INSTALLED_APPS = [
'oauth2_provider', 'oauth2_provider',
'phonenumber_field', 'phonenumber_field',
'statici18n', 'statici18n',
'django.forms', # after pretix.base for overrides
] ]
FORMAT_MODULE_PATH = [ FORMAT_MODULE_PATH = [
@@ -80,7 +80,6 @@ ALL_LANGUAGES = [
('de-informal', _('German (informal)')), ('de-informal', _('German (informal)')),
('ar', _('Arabic')), ('ar', _('Arabic')),
('zh-hans', _('Chinese (simplified)')), ('zh-hans', _('Chinese (simplified)')),
('zh-hant', _('Chinese (traditional)')),
('cs', _('Czech')), ('cs', _('Czech')),
('da', _('Danish')), ('da', _('Danish')),
('nl', _('Dutch')), ('nl', _('Dutch')),
@@ -180,8 +179,6 @@ TEMPLATES = [
}, },
] ]
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
STATIC_ROOT = os.path.join(os.path.dirname(__file__), 'static.dist') STATIC_ROOT = os.path.join(os.path.dirname(__file__), 'static.dist')
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (

View File

@@ -45,10 +45,6 @@ def npm_install():
class CustomBuild(build): class CustomBuild(build):
def run(self): def run(self):
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
# Only run this command on the pretix module, not on other modules even if it's registered globally
# in some cases
return build.run(self)
if "PRETIX_DOCKER_BUILD" in os.environ: if "PRETIX_DOCKER_BUILD" in os.environ:
return # this is a hack to allow calling this file early in our docker build to make use of caching return # this is a hack to allow calling this file early in our docker build to make use of caching
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix._build_settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix._build_settings")
@@ -72,10 +68,6 @@ class CustomBuild(build):
class CustomBuildExt(build_ext): class CustomBuildExt(build_ext):
def run(self): def run(self):
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
# Only run this command on the pretix module, not on other modules even if it's registered globally
# in some cases
return build_ext.run(self)
if "PRETIX_DOCKER_BUILD" in os.environ: if "PRETIX_DOCKER_BUILD" in os.environ:
return # this is a hack to allow calling this file early in our docker build to make use of caching return # this is a hack to allow calling this file early in our docker build to make use of caching
npm_install() npm_install()

View File

@@ -201,7 +201,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('DELETE', 'api-v1:cartposition-detail'), ('DELETE', 'api-v1:cartposition-detail'),
('GET', 'api-v1:giftcard-list'), ('GET', 'api-v1:giftcard-list'),
('POST', 'api-v1:giftcard-transact'), ('POST', 'api-v1:giftcard-transact'),
('PATCH', 'api-v1:giftcard-detail'),
('GET', 'plugins:pretix_posbackend:posclosing-list'), ('GET', 'plugins:pretix_posbackend:posclosing-list'),
('POST', 'plugins:pretix_posbackend:posreceipt-list'), ('POST', 'plugins:pretix_posbackend:posreceipt-list'),
('POST', 'plugins:pretix_posbackend:posclosing-list'), ('POST', 'plugins:pretix_posbackend:posclosing-list'),

View File

@@ -19,45 +19,3 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import json
from rest_framework import serializers
class AsymmetricField(serializers.Field):
def __init__(self, read, write, **kwargs):
self.read = read
self.write = write
super().__init__(
required=self.write.required,
default=self.write.default,
initial=self.write.initial,
source=self.write.source if self.write.source != self.write.field_name else None,
label=self.write.label,
allow_null=self.write.allow_null,
error_messages=self.write.error_messages,
validators=self.write.validators,
**kwargs
)
def to_internal_value(self, data):
return self.write.to_internal_value(data)
def to_representation(self, value):
return self.read.to_representation(value)
def run_validation(self, data=serializers.empty):
return self.write.run_validation(data)
class CompatibleJSONField(serializers.JSONField):
def to_internal_value(self, data):
try:
return json.dumps(data)
except (TypeError, ValueError):
self.fail('invalid')
def to_representation(self, value):
if value:
return json.loads(value)
return value

View File

@@ -46,15 +46,11 @@ from rest_framework import serializers
from rest_framework.fields import ChoiceField, Field from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.settings import SettingsSerializer from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.models import Device, Event, TaxRule, TeamAPIToken from pretix.base.models import Device, Event, TaxRule, TeamAPIToken
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.items import ( from pretix.base.models.items import SubEventItem, SubEventItemVariation
ItemMetaProperty, SubEventItem, SubEventItemVariation,
)
from pretix.base.models.tax import CustomRulesValidator
from pretix.base.services.seating import ( from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change, SeatProtected, generate_seats, validate_plan_change,
) )
@@ -652,16 +648,9 @@ class SubEventSerializer(I18nAwareModelSerializer):
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer): class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
custom_rules = CompatibleJSONField(
validators=[CustomRulesValidator()],
required=False,
allow_null=True,
)
class Meta: class Meta:
model = TaxRule model = TaxRule
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
'keep_gross_if_rate_changes', 'custom_rules')
class EventSettingsSerializer(SettingsSerializer): class EventSettingsSerializer(SettingsSerializer):
@@ -694,7 +683,6 @@ class EventSettingsSerializer(SettingsSerializer):
'waiting_list_phones_asked', 'waiting_list_phones_asked',
'waiting_list_phones_required', 'waiting_list_phones_required',
'waiting_list_phones_explanation_text', 'waiting_list_phones_explanation_text',
'waiting_list_limit_per_user',
'max_items_per_order', 'max_items_per_order',
'reservation_time', 'reservation_time',
'contact_mail', 'contact_mail',
@@ -777,7 +765,6 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_footer_text', 'invoice_footer_text',
'invoice_eu_currencies', 'invoice_eu_currencies',
'invoice_logo_image', 'invoice_logo_image',
'invoice_renderer_highlight_order_code',
'cancel_allow_user', 'cancel_allow_user',
'cancel_allow_user_until', 'cancel_allow_user_until',
'cancel_allow_user_unpaid_keep', 'cancel_allow_user_unpaid_keep',
@@ -915,23 +902,3 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
else [] else []
) )
) )
class MultiLineStringField(serializers.Field):
def to_representation(self, value):
return [v.strip() for v in value.splitlines()]
def to_internal_value(self, data):
if isinstance(data, list) and len(data) > 0:
return "\n".join(data)
else:
raise ValidationError('Invalid data type.')
class ItemMetaPropertiesSerializer(I18nAwareModelSerializer):
allowed_values = MultiLineStringField(allow_null=True)
class Meta:
model = ItemMetaProperty
fields = ('id', 'name', 'default', 'required', 'allowed_values')

View File

@@ -93,7 +93,7 @@ class JobRunSerializer(serializers.Serializer):
if events is not None and not isinstance(ex, OrganizerLevelExportMixin): if events is not None and not isinstance(ex, OrganizerLevelExportMixin):
self.fields["events"] = serializers.SlugRelatedField( self.fields["events"] = serializers.SlugRelatedField(
queryset=events, queryset=events,
required=False, required=True,
allow_empty=False, allow_empty=False,
slug_field='slug', slug_field='slug',
many=True many=True
@@ -156,9 +156,8 @@ class JobRunSerializer(serializers.Serializer):
def to_internal_value(self, data): def to_internal_value(self, data):
if isinstance(data, QueryDict): if isinstance(data, QueryDict):
data = data.copy() data = data.copy()
for k, v in self.fields.items(): for k, v in self.fields.items():
if isinstance(v, serializers.ManyRelatedField) and k not in data and k != "events": if isinstance(v, serializers.ManyRelatedField) and k not in data:
data[k] = [] data[k] = []
for fk in self.fields.keys(): for fk in self.fields.keys():

View File

@@ -60,15 +60,11 @@ class NestedGiftCardSerializer(GiftCardSerializer):
class ReusableMediaSerializer(I18nAwareModelSerializer): class ReusableMediaSerializer(I18nAwareModelSerializer):
organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'): if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context) self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True)
if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
else: else:
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField( self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
required=False, required=False,
@@ -113,7 +109,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
model = ReusableMedium model = ReusableMedium
fields = ( fields = (
'id', 'id',
'organizer',
'created', 'created',
'updated', 'updated',
'type', 'type',

View File

@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import json
import logging import logging
import os import os
from collections import Counter, defaultdict from collections import Counter, defaultdict
@@ -38,7 +39,6 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import ( from pretix.api.serializers.item import (
@@ -535,9 +535,8 @@ class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2 # TODO: Remove after pretix 2.2
def to_representation(self, instance: Order): def to_representation(self, instance: Order):
t = None t = None
if instance.pk: for p in instance.payments.all():
for p in instance.payments.all(): t = p.provider
t = p.provider
return t return t
@@ -545,10 +544,10 @@ class OrderPaymentDateField(serializers.DateField):
# TODO: Remove after pretix 2.2 # TODO: Remove after pretix 2.2
def to_representation(self, instance: Order): def to_representation(self, instance: Order):
t = None t = None
if instance.pk: for p in instance.payments.all():
for p in instance.payments.all(): t = p.payment_date or t
t = p.payment_date or t
if t: if t:
return super().to_representation(t.date()) return super().to_representation(t.date())
@@ -896,6 +895,19 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
return data return data
class CompatibleJSONField(serializers.JSONField):
def to_internal_value(self, data):
try:
return json.dumps(data)
except (TypeError, ValueError):
self.fail('invalid')
def to_representation(self, value):
if value:
return json.loads(value)
return value
class WrappedList: class WrappedList:
def __init__(self, data): def __init__(self, data):
self._data = data self._data = data
@@ -1351,7 +1363,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answers.append(answ) answers.append(answ)
pos.answers = answers pos.answers = answers
pos.pseudonymization_id = "PREVIEW" pos.pseudonymization_id = "PREVIEW"
pos.checkins = []
pos_map[pos.positionid] = pos pos_map[pos.positionid] = pos
else: else:
if pos.voucher: if pos.voucher:
@@ -1448,8 +1459,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if simulate: if simulate:
order.fees = fees order.fees = fees
order.positions = pos_map.values() order.positions = pos_map.values()
order.payments = []
order.refunds = []
return order # ignore payments return order # ignore payments
else: else:
order.save(update_fields=['total']) order.save(update_fields=['total'])

View File

@@ -70,8 +70,6 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
if 'order' in self.context:
data['order'] = self.context['order']
if data.get('addon_to'): if data.get('addon_to'):
try: try:
data['addon_to'] = data['order'].positions.get(positionid=data['addon_to']) data['addon_to'] = data['order'].positions.get(positionid=data['addon_to'])

View File

@@ -22,23 +22,21 @@
import logging import logging
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q from django.db.models import Q
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from pretix.api.serializers import AsymmetricField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField from pretix.api.serializers.order import CompatibleJSONField
from pretix.api.serializers.settings import SettingsSerializer from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.auth import get_auth_backends from pretix.base.auth import get_auth_backends
from pretix.base.i18n import get_language_without_region from pretix.base.i18n import get_language_without_region
from pretix.base.models import ( from pretix.base.models import (
Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction, Customer, Device, GiftCard, GiftCardTransaction, Membership,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
SeatingPlan, Team, TeamAPIToken, TeamInvite, User, User,
) )
from pretix.base.models.seating import SeatingPlanLayoutValidator from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.services.mail import SendMailException, mail from pretix.base.services.mail import SendMailException, mail
@@ -129,52 +127,8 @@ class MembershipSerializer(I18nAwareModelSerializer):
return super().update(instance, validated_data) return super().update(instance, validated_data)
class FlexibleTicketRelatedField(serializers.PrimaryKeyRelatedField):
def to_internal_value(self, data):
queryset = self.get_queryset()
if isinstance(data, int):
try:
return queryset.get(pk=data)
except ObjectDoesNotExist:
self.fail('does_not_exist', pk_value=data)
elif isinstance(data, str):
try:
return queryset.get(
Q(secret=data)
| Q(pseudonymization_id=data)
| Q(pk__in=ReusableMedium.objects.filter(
organizer=self.context['organizer'],
type='barcode',
identifier=data
))
)
except ObjectDoesNotExist:
self.fail('does_not_exist', pk_value=data)
self.fail('incorrect_type', data_type=type(data).__name__)
class GiftCardSerializer(I18nAwareModelSerializer): class GiftCardSerializer(I18nAwareModelSerializer):
value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00')) value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00'))
owner_ticket = FlexibleTicketRelatedField(required=False, allow_null=True, queryset=OrderPosition.all.none())
issuer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['owner_ticket'].queryset = OrderPosition.objects.filter(order__event__organizer=self.context['organizer'])
if 'owner_ticket' in self.context['request'].query_params.getlist('expand'):
from pretix.api.serializers.media import (
NestedOrderPositionSerializer,
)
self.fields['owner_ticket'] = AsymmetricField(
NestedOrderPositionSerializer(read_only=True, context=self.context),
self.fields['owner_ticket'],
)
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -183,11 +137,8 @@ class GiftCardSerializer(I18nAwareModelSerializer):
qs = GiftCard.objects.filter( qs = GiftCard.objects.filter(
secret=s secret=s
).filter( ).filter(
Q(issuer=self.context["organizer"]) | Q(issuer=self.context["organizer"]) | Q(
Q(issuer__in=GiftCardAcceptance.objects.filter( issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
acceptor=self.context["organizer"],
active=True,
).values_list('issuer', flat=True))
) )
if self.instance: if self.instance:
qs = qs.exclude(pk=self.instance.pk) qs = qs.exclude(pk=self.instance.pk)
@@ -200,8 +151,7 @@ class GiftCardSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = GiftCard model = GiftCard
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket', fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions')
'issuer')
class OrderEventSlugField(serializers.RelatedField): class OrderEventSlugField(serializers.RelatedField):
@@ -212,12 +162,11 @@ class OrderEventSlugField(serializers.RelatedField):
class GiftCardTransactionSerializer(I18nAwareModelSerializer): class GiftCardTransactionSerializer(I18nAwareModelSerializer):
order = serializers.SlugRelatedField(slug_field='code', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True)
acceptor = serializers.SlugRelatedField(slug_field='slug', read_only=True)
event = OrderEventSlugField(source='order', read_only=True) event = OrderEventSlugField(source='order', read_only=True)
class Meta: class Meta:
model = GiftCardTransaction model = GiftCardTransaction
fields = ('id', 'datetime', 'value', 'event', 'order', 'text', 'info', 'acceptor') fields = ('id', 'datetime', 'value', 'event', 'order', 'text')
class EventSlugField(serializers.SlugRelatedField): class EventSlugField(serializers.SlugRelatedField):

View File

@@ -63,8 +63,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
model = Voucher model = Voucher
fields = ('id', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota', fields = ('id', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota', 'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent', 'show_hidden_items', 'seat', 'all_addons_included', 'tag', 'comment', 'subevent', 'show_hidden_items', 'seat')
'all_bundles_included')
read_only_fields = ('id', 'redeemed') read_only_fields = ('id', 'redeemed')
list_serializer_class = VoucherListSerializer list_serializer_class = VoucherListSerializer

View File

@@ -39,7 +39,7 @@ class WaitingListSerializer(I18nAwareModelSerializer):
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data) full_data.update(data)
WaitingListEntry.clean_duplicate(event, full_data.get('email'), full_data.get('item'), full_data.get('variation'), WaitingListEntry.clean_duplicate(full_data.get('email'), full_data.get('item'), full_data.get('variation'),
full_data.get('subevent'), self.instance.pk if self.instance else None) full_data.get('subevent'), self.instance.pk if self.instance else None)
WaitingListEntry.clean_itemvar(event, full_data.get('item'), full_data.get('variation')) WaitingListEntry.clean_itemvar(event, full_data.get('item'), full_data.get('variation'))
WaitingListEntry.clean_subevent(event, full_data.get('subevent')) WaitingListEntry.clean_subevent(event, full_data.get('subevent'))

View File

@@ -35,7 +35,8 @@
import importlib import importlib
from django.apps import apps from django.apps import apps
from django.urls import include, re_path from django.conf.urls import re_path
from django.urls import include
from rest_framework import routers from rest_framework import routers
from pretix.api.views import cart from pretix.api.views import cart
@@ -88,7 +89,6 @@ event_router.register(r'checkinlists', checkin.CheckinListViewSet)
event_router.register(r'cartpositions', cart.CartPositionViewSet) event_router.register(r'cartpositions', cart.CartPositionViewSet)
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters') event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
event_router.register(r'shredders', shredders.EventShreddersViewSet, basename='shredders') event_router.register(r'shredders', shredders.EventShreddersViewSet, basename='shredders')
event_router.register(r'item_meta_properties', event.ItemMetaPropertiesViewSet)
checkinlist_router = routers.DefaultRouter() checkinlist_router = routers.DefaultRouter()
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos') checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')

View File

@@ -396,7 +396,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce, def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported, untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
source_type='barcode', legacy_url_support=False, simulate=False): source_type='barcode', legacy_url_support=False):
if not checkinlists: if not checkinlists:
raise ValidationError('No check-in list passed.') raise ValidationError('No check-in list passed.')
@@ -433,8 +433,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
) )
raw_barcode_for_checkin = None raw_barcode_for_checkin = None
from_revoked_secret = False from_revoked_secret = False
if simulate:
common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
# 1. Gather a list of positions that could be the one we looking for, either from their ID, secret or # 1. Gather a list of positions that could be the one we looking for, either from their ID, secret or
# parent secret # parent secret
@@ -474,14 +472,13 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
revoked_matches = list( revoked_matches = list(
RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode)) RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode))
if len(revoked_matches) == 0: if len(revoked_matches) == 0:
if not simulate: checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={ 'datetime': datetime,
'datetime': datetime, 'type': checkin_type,
'type': checkin_type, 'list': checkinlists[0].pk,
'list': checkinlists[0].pk, 'barcode': raw_barcode,
'barcode': raw_barcode, 'searched_lists': [cl.pk for cl in checkinlists]
'searched_lists': [cl.pk for cl in checkinlists] }, user=user, auth=auth)
}, user=user, auth=auth)
for cl in checkinlists: for cl in checkinlists:
for k, s in cl.event.ticket_secret_generators.items(): for k, s in cl.event.ticket_secret_generators.items():
@@ -495,13 +492,12 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
except: except:
pass pass
if not simulate: Checkin.objects.create(
Checkin.objects.create( position=None,
position=None, successful=False,
successful=False, error_reason=Checkin.REASON_INVALID,
error_reason=Checkin.REASON_INVALID, **common_checkin_args,
**common_checkin_args, )
)
if force and legacy_url_support and isinstance(auth, Device): if force and legacy_url_support and isinstance(auth, Device):
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was # There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
@@ -543,20 +539,19 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
from_revoked_secret = True from_revoked_secret = True
else: else:
op = revoked_matches[0].position op = revoked_matches[0].position
if not simulate: op.order.log_action('pretix.event.checkin.revoked', data={
op.order.log_action('pretix.event.checkin.revoked', data={ 'datetime': datetime,
'datetime': datetime, 'type': checkin_type,
'type': checkin_type, 'list': list_by_event[revoked_matches[0].event_id].pk,
'list': list_by_event[revoked_matches[0].event_id].pk, 'barcode': raw_barcode
'barcode': raw_barcode }, user=user, auth=auth)
}, user=user, auth=auth) common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id] Checkin.objects.create(
Checkin.objects.create( position=op,
position=op, successful=False,
successful=False, error_reason=Checkin.REASON_REVOKED,
error_reason=Checkin.REASON_REVOKED, **common_checkin_args
**common_checkin_args )
)
return Response({ return Response({
'status': 'error', 'status': 'error',
'reason': Checkin.REASON_REVOKED, 'reason': Checkin.REASON_REVOKED,
@@ -567,9 +562,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400) }, status=400)
else: else:
op_candidates = [media.linked_orderposition] op_candidates = [media.linked_orderposition] + list(media.linked_orderposition.addons.all())
if list_by_event[media.linked_orderposition.order.event_id].addon_match:
op_candidates += list(media.linked_orderposition.addons.all())
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary # 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
# key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out # key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
@@ -593,25 +586,24 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# We choose the first match (regardless of product) for the logging since it's most likely to be the # We choose the first match (regardless of product) for the logging since it's most likely to be the
# base product according to our order_by above. # base product according to our order_by above.
op = op_candidates[0] op = op_candidates[0]
if not simulate: op.order.log_action('pretix.event.checkin.denied', data={
op.order.log_action('pretix.event.checkin.denied', data={ 'position': op.id,
'position': op.id, 'positionid': op.positionid,
'positionid': op.positionid, 'errorcode': Checkin.REASON_AMBIGUOUS,
'errorcode': Checkin.REASON_AMBIGUOUS, 'reason_explanation': None,
'reason_explanation': None, 'force': force,
'force': force, 'datetime': datetime,
'datetime': datetime, 'type': checkin_type,
'type': checkin_type, 'list': list_by_event[op.order.event_id].pk,
'list': list_by_event[op.order.event_id].pk, }, user=user, auth=auth)
}, user=user, auth=auth) common_checkin_args['list'] = list_by_event[op.order.event_id]
common_checkin_args['list'] = list_by_event[op.order.event_id] Checkin.objects.create(
Checkin.objects.create( position=op,
position=op, successful=False,
successful=False, error_reason=Checkin.REASON_AMBIGUOUS,
error_reason=Checkin.REASON_AMBIGUOUS, error_explanation=None,
error_explanation=None, **common_checkin_args,
**common_checkin_args, )
)
return Response({ return Response({
'status': 'error', 'status': 'error',
'reason': Checkin.REASON_AMBIGUOUS, 'reason': Checkin.REASON_AMBIGUOUS,
@@ -658,7 +650,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
raw_barcode=raw_barcode_for_checkin, raw_barcode=raw_barcode_for_checkin,
raw_source_type=source_type, raw_source_type=source_type,
from_revoked_secret=from_revoked_secret, from_revoked_secret=from_revoked_secret,
simulate=simulate,
) )
except RequiredQuestionsError as e: except RequiredQuestionsError as e:
return Response({ return Response({
@@ -671,24 +662,23 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400) }, status=400)
except CheckInError as e: except CheckInError as e:
if not simulate: op.order.log_action('pretix.event.checkin.denied', data={
op.order.log_action('pretix.event.checkin.denied', data={ 'position': op.id,
'position': op.id, 'positionid': op.positionid,
'positionid': op.positionid, 'errorcode': e.code,
'errorcode': e.code, 'reason_explanation': e.reason,
'reason_explanation': e.reason, 'force': force,
'force': force, 'datetime': datetime,
'datetime': datetime, 'type': checkin_type,
'type': checkin_type, 'list': list_by_event[op.order.event_id].pk,
'list': list_by_event[op.order.event_id].pk, }, user=user, auth=auth)
}, user=user, auth=auth) Checkin.objects.create(
Checkin.objects.create( position=op,
position=op, successful=False,
successful=False, error_reason=e.code,
error_reason=e.code, error_explanation=e.reason,
error_explanation=e.reason, **common_checkin_args,
**common_checkin_args, )
)
return Response({ return Response({
'status': 'error', 'status': 'error',
'reason': e.code, 'reason': e.code,

View File

@@ -93,9 +93,6 @@ class InitializeView(APIView):
if device.initialized: if device.initialized:
raise ValidationError({'token': ['This initialization token has already been used.']}) raise ValidationError({'token': ['This initialization token has already been used.']})
if device.revoked:
raise ValidationError({'token': ['This initialization token has been revoked.']})
device.initialized = now() device.initialized = now()
device.hardware_brand = serializer.validated_data.get('hardware_brand') device.hardware_brand = serializer.validated_data.get('hardware_brand')
device.hardware_model = serializer.validated_data.get('hardware_model') device.hardware_model = serializer.validated_data.get('hardware_model')

View File

@@ -47,13 +47,11 @@ from pretix.api.auth.permission import EventCRUDPermission
from pretix.api.pagination import TotalOrderingFilter from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.event import ( from pretix.api.serializers.event import (
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer, CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
EventSettingsSerializer, ItemMetaPropertiesSerializer, SubEventSerializer, EventSettingsSerializer, SubEventSerializer, TaxRuleSerializer,
TaxRuleSerializer,
) )
from pretix.api.views import ConditionalListView from pretix.api.views import ConditionalListView
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, ItemMetaProperty, SeatCategoryMapping, CartPosition, Device, Event, SeatCategoryMapping, TaxRule, TeamAPIToken,
TaxRule, TeamAPIToken,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
@@ -71,8 +69,6 @@ with scopes_disabled():
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs') ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs') sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
search = django_filters.rest_framework.CharFilter(method='search_qs') search = django_filters.rest_framework.CharFilter(method='search_qs')
date_from = django_filters.rest_framework.IsoDateTimeFromToRangeFilter()
date_to = django_filters.rest_framework.IsoDateTimeFromToRangeFilter()
class Meta: class Meta:
model = Event model = Event
@@ -338,8 +334,6 @@ with scopes_disabled():
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte') modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs') sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
search = django_filters.rest_framework.CharFilter(method='search_qs') search = django_filters.rest_framework.CharFilter(method='search_qs')
date_from = django_filters.rest_framework.IsoDateTimeFromToRangeFilter()
date_to = django_filters.rest_framework.IsoDateTimeFromToRangeFilter()
class Meta: class Meta:
model = SubEvent model = SubEvent
@@ -528,54 +522,6 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
super().perform_destroy(instance) super().perform_destroy(instance)
class ItemMetaPropertiesViewSet(viewsets.ModelViewSet):
serializer_class = ItemMetaPropertiesSerializer
queryset = ItemMetaProperty.objects.none()
write_permission = 'can_change_event_settings'
def get_queryset(self):
qs = self.request.event.item_meta_properties.all()
return qs
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
ctx['event'] = self.request.event
return ctx
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.item_meta_property.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.item_meta_property.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.item_meta_property.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
class EventSettingsView(views.APIView): class EventSettingsView(views.APIView):
permission = None permission = None
write_permission = 'can_change_event_settings' write_permission = 'can_change_event_settings'

View File

@@ -133,12 +133,7 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
def exporters(self): def exporters(self):
exporters = [] exporters = []
responses = register_data_exporters.send(self.request.event) responses = register_data_exporters.send(self.request.event)
raw_exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response] for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
raw_exporters = [
ex for ex in raw_exporters
if ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None)
]
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex) ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex) exporters.append(ex)
return exporters return exporters
@@ -171,7 +166,7 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
if ( if (
not isinstance(ex, OrganizerLevelExportMixin) or not isinstance(ex, OrganizerLevelExportMixin) or
perm_holder.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request) perm_holder.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request)
) and ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None) )
] ]
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex, events=events) ex._serializer = JobRunSerializer(exporter=ex, events=events)

View File

@@ -39,8 +39,7 @@ from pretix.api.serializers.media import (
) )
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
Checkin, GiftCard, GiftCardAcceptance, GiftCardTransaction, OrderPosition, Checkin, GiftCard, GiftCardTransaction, OrderPosition, ReusableMedium,
ReusableMedium,
) )
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
@@ -136,28 +135,12 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
s = self.get_serializer(m) s = self.get_serializer(m)
return Response({"result": s.data}) return Response({"result": s.data})
except ReusableMedium.DoesNotExist: except ReusableMedium.DoesNotExist:
try: mt = MEDIA_TYPES.get(s.validated_data["type"])
with scopes_disabled(): if mt:
m = ReusableMedium.objects.get( m = mt.handle_unknown(request.organizer, s.validated_data["identifier"], request.user, request.auth)
organizer__in=GiftCardAcceptance.objects.filter( if m:
acceptor=request.organizer, s = self.get_serializer(m)
active=True, return Response({"result": s.data})
reusable_media=True,
).values_list('issuer', flat=True),
type=s.validated_data["type"],
identifier=s.validated_data["identifier"],
)
m.linked_orderposition = None # not relevant for cross-organizer
m.customer = None # not relevant for cross-organizer
s = self.get_serializer(m)
return Response({"result": s.data})
except ReusableMedium.DoesNotExist:
mt = MEDIA_TYPES.get(s.validated_data["type"])
if mt:
m = mt.handle_unknown(request.organizer, s.validated_data["identifier"], request.user, request.auth)
if m:
s = self.get_serializer(m)
return Response({"result": s.data})
return Response({"result": None}) return Response({"result": None})

View File

@@ -23,9 +23,9 @@ import datetime
import mimetypes import mimetypes
import os import os
from decimal import Decimal from decimal import Decimal
from zoneinfo import ZoneInfo
import django_filters import django_filters
import pytz
from django.db import transaction from django.db import transaction
from django.db.models import ( from django.db.models import (
Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects, Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects,
@@ -612,7 +612,7 @@ class OrderViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
tz = ZoneInfo(self.request.event.settings.timezone) tz = pytz.timezone(self.request.event.settings.timezone)
new_date = make_aware(datetime.datetime.combine( new_date = make_aware(datetime.datetime.combine(
new_date, new_date,
datetime.time(hour=23, minute=59, second=59) datetime.time(hour=23, minute=59, second=59)
@@ -661,16 +661,7 @@ class OrderViewSet(viewsets.ModelViewSet):
with language(order.locale, self.request.event.settings.region): with language(order.locale, self.request.event.settings.region):
payment = order.payments.last() payment = order.payments.last()
# OrderCreateSerializer creates at most one payment
if payment and payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
order.log_action(
'pretix.event.order.payment.confirmed', {
'local_id': payment.local_id,
'provider': payment.provider,
},
user=request.user if request.user.is_authenticated else None,
auth=request.auth,
)
order_placed.send(self.request.event, order=order) order_placed.send(self.request.event, order=order)
if order.status == Order.STATUS_PAID: if order.status == Order.STATUS_PAID:
order_paid.send(self.request.event, order=order) order_paid.send(self.request.event, order=order)

View File

@@ -155,9 +155,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
qs = self.request.organizer.accepted_gift_cards qs = self.request.organizer.accepted_gift_cards
else: else:
qs = self.request.organizer.issued_gift_cards.all() qs = self.request.organizer.issued_gift_cards.all()
return qs.prefetch_related( return qs
'issuer'
)
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
@@ -168,7 +166,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
value = serializer.validated_data.pop('value') value = serializer.validated_data.pop('value')
inst = serializer.save(issuer=self.request.organizer) inst = serializer.save(issuer=self.request.organizer)
inst.transactions.create(value=value, acceptor=self.request.organizer) inst.transactions.create(value=value)
inst.log_action( inst.log_action(
'pretix.giftcards.transaction.manual', 'pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
@@ -181,32 +179,18 @@ class GiftCardViewSet(viewsets.ModelViewSet):
if 'include_accepted' in self.request.GET: if 'include_accepted' in self.request.GET:
raise PermissionDenied("Accepted gift cards cannot be updated, use transact instead.") raise PermissionDenied("Accepted gift cards cannot be updated, use transact instead.")
GiftCard.objects.select_for_update(of=OF_SELF).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', None) value = serializer.validated_data.pop('value')
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
if any(k != 'value' for k in self.request.data): testmode=serializer.instance.testmode)
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency, diff = value - old_value
testmode=serializer.instance.testmode) inst.transactions.create(value=diff)
inst.log_action( inst.log_action(
'pretix.giftcards.modified', 'pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data=self.request.data, data={'value': diff}
) )
else:
inst = serializer.instance
if 'value' in self.request.data and value is not None:
old_value = serializer.instance.value
diff = value - old_value
inst.transactions.create(value=diff, acceptor=self.request.organizer)
inst.log_action(
'pretix.giftcards.transaction.manual',
user=self.request.user,
auth=self.request.auth,
data={'value': diff}
)
return inst return inst
@action(detail=True, methods=["POST"]) @action(detail=True, methods=["POST"])
@@ -219,21 +203,18 @@ class GiftCardViewSet(viewsets.ModelViewSet):
text = serializers.CharField(allow_blank=True, allow_null=True).to_internal_value( text = serializers.CharField(allow_blank=True, allow_null=True).to_internal_value(
request.data.get('text', '') request.data.get('text', '')
) )
info = serializers.JSONField(required=False, allow_null=True).to_internal_value(
request.data.get('info', {})
)
if gc.value + value < Decimal('0.00'): if gc.value + value < Decimal('0.00'):
return Response({ return Response({
'value': ['The gift card does not have sufficient credit for this operation.'] 'value': ['The gift card does not have sufficient credit for this operation.']
}, status=status.HTTP_409_CONFLICT) }, status=status.HTTP_409_CONFLICT)
gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer) gc.transactions.create(value=value, text=text)
gc.log_action( gc.log_action(
'pretix.giftcards.transaction.manual', 'pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={'value': value, 'text': text} data={'value': value, 'text': text}
) )
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK) return Response(GiftCardSerializer(gc).data, status=status.HTTP_200_OK)
def perform_destroy(self, instance): def perform_destroy(self, instance):
raise MethodNotAllowed("Gift cards cannot be deleted.") raise MethodNotAllowed("Gift cards cannot be deleted.")
@@ -254,7 +235,7 @@ class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet):
return get_object_or_404(qs, pk=self.kwargs.get('giftcard')) return get_object_or_404(qs, pk=self.kwargs.get('giftcard'))
def get_queryset(self): def get_queryset(self):
return self.giftcard.transactions.select_related('order', 'order__event').prefetch_related('acceptor') return self.giftcard.transactions.select_related('order', 'order__event')
class TeamViewSet(viewsets.ModelViewSet): class TeamViewSet(viewsets.ModelViewSet):

View File

@@ -189,19 +189,6 @@ class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
return d return d
class ParametrizedWaitingListEntryWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
# do not use content_object, this is also called in deletion
return {
'notification_id': logentry.pk,
'organizer': logentry.event.organizer.slug,
'event': logentry.event.slug,
'waitinglistentry': logentry.object_id,
'action': logentry.action_type,
}
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events") @receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
def register_default_webhook_events(sender, **kwargs): def register_default_webhook_events(sender, **kwargs):
return ( return (
@@ -334,22 +321,6 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.testmode.deactivated', 'pretix.event.testmode.deactivated',
_('Test-Mode of shop has been deactivated'), _('Test-Mode of shop has been deactivated'),
), ),
ParametrizedWaitingListEntryWebhookEvent(
'pretix.event.orders.waitinglist.added',
_('Waiting list entry added'),
),
ParametrizedWaitingListEntryWebhookEvent(
'pretix.event.orders.waitinglist.changed',
_('Waiting list entry changed'),
),
ParametrizedWaitingListEntryWebhookEvent(
'pretix.event.orders.waitinglist.deleted',
_('Waiting list entry deleted'),
),
ParametrizedWaitingListEntryWebhookEvent(
'pretix.event.orders.waitinglist.voucher_assigned',
_('Waiting list entry received voucher'),
),
) )

View File

@@ -117,15 +117,13 @@ def oidc_validate_and_complete_config(config):
scopes=", ".join(provider_config.get("scopes_supported", [])) scopes=", ".join(provider_config.get("scopes_supported", []))
)) ))
if "claims_supported" in provider_config: for k, v in config.items():
claims_supported = provider_config.get("claims_supported", []) if k.endswith('_field') and v:
for k, v in config.items(): if v not in provider_config.get("claims_supported", []): # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
if k.endswith('_field') and v: raise ValidationError(_('You are requesting field "{field}" but provider only supports these: {fields}.').format(
if v not in claims_supported: # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo field=v,
raise ValidationError(_('You are requesting field "{field}" but provider only supports these: {fields}.').format( fields=", ".join(provider_config.get("claims_supported", []))
field=v, ))
fields=", ".join(provider_config.get("claims_supported", []))
))
config['provider_config'] = provider_config config['provider_config'] = provider_config
return config return config

View File

@@ -37,8 +37,8 @@ import tempfile
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple
from decimal import Decimal from decimal import Decimal
from typing import Optional, Tuple from typing import Optional, Tuple
from zoneinfo import ZoneInfo
import pytz
from defusedcsv import csv from defusedcsv import csv
from django import forms from django import forms
from django.conf import settings from django.conf import settings
@@ -68,7 +68,7 @@ class BaseExporter:
self.events = event self.events = event
self.event = None self.event = None
e = self.events.first() e = self.events.first()
self.timezone = e.timezone if e else ZoneInfo(settings.TIME_ZONE) self.timezone = e.timezone if e else pytz.timezone(settings.TIME_ZONE)
else: else:
self.events = Event.objects.filter(pk=event.pk) self.events = Event.objects.filter(pk=event.pk)
self.timezone = event.timezone self.timezone = event.timezone
@@ -157,13 +157,6 @@ class BaseExporter:
""" """
raise NotImplementedError() # NOQA raise NotImplementedError() # NOQA
def available_for_user(self, user) -> bool:
"""
Allows to do additional checks whether an exporter is available based on the user who calls it. Note that
``user`` may be ``None`` e.g. during API usage.
"""
return True
class OrganizerLevelExportMixin: class OrganizerLevelExportMixin:
@property @property

View File

@@ -58,7 +58,6 @@ class EventDataExporter(ListExporter):
_("Short form"), _("Short form"),
_("Shop is live"), _("Shop is live"),
_("Event currency"), _("Event currency"),
_("Timezone"),
_("Event start time"), _("Event start time"),
_("Event end time"), _("Event end time"),
_("Admission time"), _("Admission time"),
@@ -76,18 +75,16 @@ class EventDataExporter(ListExporter):
for e in self.events.all(): for e in self.events.all():
m = e.meta_data m = e.meta_data
tz = e.timezone
yield [ yield [
str(e.name), str(e.name),
e.slug, e.slug,
_('Yes') if e.live else _('No'), _('Yes') if e.live else _('No'),
e.currency, e.currency,
str(e.timezone), date_format(e.date_from, 'SHORT_DATETIME_FORMAT'),
date_format(e.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'), date_format(e.date_to, 'SHORT_DATETIME_FORMAT') if e.date_to else '',
date_format(e.date_to.astimezone(tz), 'SHORT_DATETIME_FORMAT') if e.date_to else '', date_format(e.date_admission, 'SHORT_DATETIME_FORMAT') if e.date_admission else '',
date_format(e.date_admission.astimezone(tz), 'SHORT_DATETIME_FORMAT') if e.date_admission else '', date_format(e.presale_start, 'SHORT_DATETIME_FORMAT') if e.presale_start else '',
date_format(e.presale_start.astimezone(tz), 'SHORT_DATETIME_FORMAT') if e.presale_start else '', date_format(e.presale_end, 'SHORT_DATETIME_FORMAT') if e.presale_end else '',
date_format(e.presale_end.astimezone(tz), 'SHORT_DATETIME_FORMAT') if e.presale_end else '',
str(e.location), str(e.location),
e.geo_lat or '', e.geo_lat or '',
e.geo_lon or '', e.geo_lon or '',

View File

@@ -103,9 +103,7 @@ class InvoiceExporterMixin:
qs = qs.annotate( qs = qs.annotate(
has_payment_with_provider=Exists( has_payment_with_provider=Exists(
OrderPayment.objects.filter( OrderPayment.objects.filter(
Q(order=OuterRef('order_id')) & Q(provider=form_data.get('payment_provider')), Q(order=OuterRef('order_id')) & Q(provider=form_data.get('payment_provider'))
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
) )
) )
) )

View File

@@ -34,8 +34,8 @@
from collections import OrderedDict from collections import OrderedDict
from decimal import Decimal from decimal import Decimal
from zoneinfo import ZoneInfo
import pytz
from django import forms from django import forms
from django.db.models import ( from django.db.models import (
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef, Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
@@ -49,24 +49,18 @@ from django.utils.timezone import get_current_timezone, now
from django.utils.translation import ( from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy, gettext as _, gettext_lazy, pgettext, pgettext_lazy,
) )
from openpyxl.cell import WriteOnlyCell
from openpyxl.comments import Comment
from openpyxl.styles import Font, PatternFill
from pretix.base.models import ( from pretix.base.models import (
GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order, GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
OrderPosition, Question, OrderPosition, Question,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
OrderFee, OrderPayment, OrderRefund, Transaction,
)
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from ...control.forms.filter import get_all_payment_providers from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat from ...helpers import GroupConcat
from ...helpers.iter import chunked_iterable from ...helpers.iter import chunked_iterable
from ...helpers.safe_openpyxl import remove_invalid_excel_chars
from ..exporter import ( from ..exporter import (
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin, ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
) )
@@ -326,7 +320,7 @@ class OrderListExporter(MultiSheetListExporter):
yield self.ProgressSetTotal(total=qs.count()) yield self.ProgressSetTotal(total=qs.count())
for order in qs.order_by('datetime').iterator(): for order in qs.order_by('datetime').iterator():
tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone) tz = pytz.timezone(self.event_object_cache[order.event_id].settings.timezone)
row = [ row = [
self.event_object_cache[order.event_id].slug, self.event_object_cache[order.event_id].slug,
@@ -459,7 +453,7 @@ class OrderListExporter(MultiSheetListExporter):
yield self.ProgressSetTotal(total=qs.count()) yield self.ProgressSetTotal(total=qs.count())
for op in qs.order_by('order__datetime').iterator(): for op in qs.order_by('order__datetime').iterator():
order = op.order order = op.order
tz = ZoneInfo(order.event.settings.timezone) tz = pytz.timezone(order.event.settings.timezone)
row = [ row = [
self.event_object_cache[order.event_id].slug, self.event_object_cache[order.event_id].slug,
order.code, order.code,
@@ -631,7 +625,7 @@ class OrderListExporter(MultiSheetListExporter):
for op in ops: for op in ops:
order = op.order order = op.order
tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone) tz = pytz.timezone(self.event_object_cache[order.event_id].settings.timezone)
row = [ row = [
self.event_object_cache[order.event_id].slug, self.event_object_cache[order.event_id].slug,
order.code, order.code,
@@ -765,185 +759,6 @@ class OrderListExporter(MultiSheetListExporter):
return '{}_orders'.format(self.event.slug) return '{}_orders'.format(self.event.slug)
class TransactionListExporter(ListExporter):
identifier = 'transactions'
verbose_name = gettext_lazy('Order transaction data')
category = pgettext_lazy('export_category', 'Order data')
description = gettext_lazy('Download a spreadsheet of all substantial changes to orders, i.e. all changes to '
'products, prices or tax rates. The information is only accurate for changes made with '
'pretix versions released after October 2021.')
@cached_property
def providers(self):
return dict(get_all_payment_providers())
@property
def additional_form_fields(self):
d = [
('date_range',
DateFrameField(
label=_('Date range'),
include_future_frames=False,
required=False,
help_text=_('Only include transactions created within this date range.')
)),
]
d = OrderedDict(d)
return d
@cached_property
def event_object_cache(self):
return {e.pk: e for e in self.events}
def get_filename(self):
if self.is_multievent:
return '{}_transactions'.format(self.organizer.slug)
else:
return '{}_transactions'.format(self.event.slug)
def iterate_list(self, form_data):
qs = Transaction.objects.filter(
order__event__in=self.events,
)
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)
qs = qs.select_related(
'order', 'order__event', 'item', 'variation', 'subevent',
).order_by(
'datetime', 'id',
)
headers = [
_('Event'),
_('Event slug'),
_('Currency'),
_('Order code'),
_('Order date'),
_('Order time'),
_('Transaction date'),
_('Transaction time'),
_('Old data'),
_('Position ID'),
_('Quantity'),
_('Product ID'),
_('Product'),
_('Variation ID'),
_('Variation'),
_('Fee type'),
_('Internal fee type'),
pgettext('subevent', 'Date ID'),
pgettext('subevent', 'Date'),
_('Price'),
_('Tax rate'),
_('Tax rule ID'),
_('Tax rule'),
_('Tax value'),
_('Gross total'),
_('Tax total'),
]
if form_data.get('_format') == 'xlsx':
for i in range(len(headers)):
headers[i] = WriteOnlyCell(self.__ws, value=headers[i])
if i in (0, 12, 14, 18, 22):
headers[i].fill = PatternFill(start_color="FFB419", end_color="FFB419", fill_type="solid")
headers[i].comment = Comment(
text=_(
"This value is supplied for informational purposes, it is not part of the original transaction "
"data and might have changed since the transaction."
),
author='system'
)
headers[i].font = Font(bold=True)
yield headers
yield self.ProgressSetTotal(total=qs.count())
for t in qs.iterator():
row = [
str(t.order.event.name),
t.order.event.slug,
t.order.event.currency,
t.order.code,
t.order.datetime.astimezone(self.timezone).strftime('%Y-%m-%d'),
t.order.datetime.astimezone(self.timezone).strftime('%H:%M:%S'),
t.datetime.astimezone(self.timezone).strftime('%Y-%m-%d'),
t.datetime.astimezone(self.timezone).strftime('%H:%M:%S'),
_('Converted from legacy version') if t.migrated else '',
t.positionid,
t.count,
t.item_id,
str(t.item),
t.variation_id or '',
str(t.variation) if t.variation_id else '',
t.fee_type,
t.internal_type,
t.subevent_id or '',
str(t.subevent) if t.subevent else '',
t.price,
t.tax_rate,
t.tax_rule_id or '',
str(t.tax_rule.internal_name or t.tax_rule.name) if t.tax_rule_id else '',
t.tax_value,
t.price * t.count,
t.tax_value * t.count,
]
if form_data.get('_format') == 'xlsx':
for i in range(len(row)):
if t.order.testmode:
row[i] = WriteOnlyCell(self.__ws, value=remove_invalid_excel_chars(row[i]))
row[i].fill = PatternFill(start_color="FFB419", end_color="FFB419", fill_type="solid")
yield row
def prepare_xlsx_sheet(self, ws):
self.__ws = ws
ws.freeze_panes = 'A2'
ws.column_dimensions['A'].width = 25
ws.column_dimensions['B'].width = 10
ws.column_dimensions['C'].width = 10
ws.column_dimensions['D'].width = 10
ws.column_dimensions['E'].width = 15
ws.column_dimensions['F'].width = 15
ws.column_dimensions['G'].width = 15
ws.column_dimensions['H'].width = 15
ws.column_dimensions['I'].width = 15
ws.column_dimensions['J'].width = 10
ws.column_dimensions['K'].width = 10
ws.column_dimensions['L'].width = 10
ws.column_dimensions['M'].width = 25
ws.column_dimensions['N'].width = 10
ws.column_dimensions['O'].width = 25
ws.column_dimensions['P'].width = 20
ws.column_dimensions['Q'].width = 20
ws.column_dimensions['R'].width = 10
ws.column_dimensions['S'].width = 25
ws.column_dimensions['T'].width = 15
ws.column_dimensions['U'].width = 10
ws.column_dimensions['V'].width = 10
ws.column_dimensions['W'].width = 20
ws.column_dimensions['X'].width = 15
class PaymentListExporter(ListExporter): class PaymentListExporter(ListExporter):
identifier = 'paymentlist' identifier = 'paymentlist'
verbose_name = gettext_lazy('Payments and refunds') verbose_name = gettext_lazy('Payments and refunds')
@@ -1028,7 +843,7 @@ class PaymentListExporter(ListExporter):
yield self.ProgressSetTotal(total=len(objs)) yield self.ProgressSetTotal(total=len(objs))
for obj in objs: for obj in objs:
tz = ZoneInfo(obj.order.event.settings.timezone) tz = pytz.timezone(obj.order.event.settings.timezone)
if isinstance(obj, OrderPayment) and obj.payment_date: if isinstance(obj, OrderPayment) and obj.payment_date:
d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d') d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d')
elif isinstance(obj, OrderRefund) and obj.execution_date: elif isinstance(obj, OrderRefund) and obj.execution_date:
@@ -1147,7 +962,7 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
def iterate_list(self, form_data): def iterate_list(self, form_data):
qs = GiftCardTransaction.objects.filter( qs = GiftCardTransaction.objects.filter(
card__issuer=self.organizer, card__issuer=self.organizer,
).order_by('datetime').select_related('card', 'order', 'order__event', 'acceptor') ).order_by('datetime').select_related('card', 'order', 'order__event')
if form_data.get('date_range'): 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) dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
@@ -1163,7 +978,6 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
_('Amount'), _('Amount'),
_('Currency'), _('Currency'),
_('Order'), _('Order'),
_('Organizer'),
] ]
yield headers yield headers
@@ -1175,7 +989,6 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
obj.value, obj.value,
obj.card.currency, obj.card.currency,
obj.order.full_code if obj.order else None, obj.order.full_code if obj.order else None,
str(obj.acceptor or ""),
] ]
yield row yield row
@@ -1209,7 +1022,7 @@ class GiftcardRedemptionListExporter(ListExporter):
yield headers yield headers
for obj in objs: for obj in objs:
tz = ZoneInfo(obj.order.event.settings.timezone) tz = pytz.timezone(obj.order.event.settings.timezone)
gc = GiftCard.objects.get(pk=obj.info_data.get('gift_card')) gc = GiftCard.objects.get(pk=obj.info_data.get('gift_card'))
row = [ row = [
obj.order.event.slug, obj.order.event.slug,
@@ -1352,16 +1165,6 @@ def register_multievent_orderlist_exporter(sender, **kwargs):
return OrderListExporter return OrderListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_ordertransactionlist")
def register_ordertransactionlist_exporter(sender, **kwargs):
return TransactionListExporter
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_ordertransactionlist")
def register_multievent_ordertransactionlist_exporter(sender, **kwargs):
return TransactionListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_paymentlist") @receiver(register_data_exporters, dispatch_uid="exporter_paymentlist")
def register_paymentlist_exporter(sender, **kwargs): def register_paymentlist_exporter(sender, **kwargs):
return PaymentListExporter return PaymentListExporter

View File

@@ -20,8 +20,8 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from collections import OrderedDict from collections import OrderedDict
from zoneinfo import ZoneInfo
import pytz
from django import forms from django import forms
from django.db.models import F, Q from django.db.models import F, Q
from django.dispatch import receiver from django.dispatch import receiver
@@ -137,7 +137,7 @@ class WaitingListExporter(ListExporter):
# which event should be used to output dates in columns "Start date" and "End date" # which event should be used to output dates in columns "Start date" and "End date"
event_for_date_columns = entry.subevent if entry.subevent else entry.event event_for_date_columns = entry.subevent if entry.subevent else entry.event
tz = ZoneInfo(entry.event.settings.timezone) tz = pytz.timezone(entry.event.settings.timezone)
datetime_format = '%Y-%m-%d %H:%M:%S' datetime_format = '%Y-%m-%d %H:%M:%S'
row = [ row = [

View File

@@ -167,7 +167,6 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
class PrefixForm(forms.Form): class PrefixForm(forms.Form):
prefix = forms.CharField(widget=forms.HiddenInput) prefix = forms.CharField(widget=forms.HiddenInput)
template_name = "django/forms/table.html"
class SafeSessionWizardView(SessionWizardView): class SafeSessionWizardView(SessionWizardView):

View File

@@ -38,10 +38,10 @@ import logging
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from io import BytesIO from io import BytesIO
from zoneinfo import ZoneInfo
import dateutil.parser import dateutil.parser
import pycountry import pycountry
import pytz
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@@ -61,7 +61,6 @@ from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries import countries from django_countries import countries
from django_countries.fields import Country, CountryField from django_countries.fields import Country, CountryField
from geoip2.errors import AddressNotFoundError
from phonenumber_field.formfields import PhoneNumberField from phonenumber_field.formfields import PhoneNumberField
from phonenumber_field.phonenumber import PhoneNumber from phonenumber_field.phonenumber import PhoneNumber
from phonenumber_field.widgets import PhoneNumberPrefixWidget from phonenumber_field.widgets import PhoneNumberPrefixWidget
@@ -357,12 +356,9 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def guess_country_from_request(request, event): def guess_country_from_request(request, event):
if settings.HAS_GEOIP: if settings.HAS_GEOIP:
g = GeoIP2() g = GeoIP2()
try: res = g.country(get_client_ip(request))
res = g.country(get_client_ip(request)) if res['country_code'] and len(res['country_code']) == 2:
if res['country_code'] and len(res['country_code']) == 2: return Country(res['country_code'])
return Country(res['country_code'])
except AddressNotFoundError:
pass
return guess_country(event) return guess_country(event)
@@ -577,7 +573,6 @@ class BaseQuestionsForm(forms.Form):
the attendee name for admission tickets, if the corresponding setting is enabled, the attendee name for admission tickets, if the corresponding setting is enabled,
as well as additional questions defined by the organizer. as well as additional questions defined by the organizer.
""" """
address_validation = False
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
""" """
@@ -737,7 +732,7 @@ class BaseQuestionsForm(forms.Form):
initial = answers[0] initial = answers[0]
else: else:
initial = None initial = None
tz = ZoneInfo(event.settings.timezone) tz = pytz.timezone(event.settings.timezone)
help_text = rich_text(q.help_text) help_text = rich_text(q.help_text)
label = escape(q.question) # django-bootstrap3 calls mark_safe label = escape(q.question) # django-bootstrap3 calls mark_safe
required = q.required and not self.all_optional required = q.required and not self.all_optional
@@ -925,14 +920,8 @@ class BaseQuestionsForm(forms.Form):
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + v.widget.attrs.get('autocomplete', '') v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + v.widget.attrs.get('autocomplete', '')
def clean(self): def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
d = super().clean() d = super().clean()
if self.address_validation:
self.cleaned_data = d = validate_address(d, True)
if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS: if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not d.get('state'): if not d.get('state'):
self.add_error('state', _('This field is required.')) self.add_error('state', _('This field is required.'))

View File

@@ -1,63 +0,0 @@
#
# 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 bootstrap3.renderers import (
FieldRenderer as BaseFieldRenderer,
InlineFieldRenderer as BaseInlineFieldRenderer,
)
from django.forms import (
CheckboxInput, CheckboxSelectMultiple, ClearableFileInput, RadioSelect,
SelectDateWidget,
)
class FieldRenderer(BaseFieldRenderer):
# Local application of https://github.com/zostera/django-bootstrap3/pull/859
def post_widget_render(self, html):
if isinstance(self.widget, CheckboxSelectMultiple):
html = self.list_to_class(html, "checkbox")
elif isinstance(self.widget, RadioSelect):
html = self.list_to_class(html, "radio")
elif isinstance(self.widget, SelectDateWidget):
html = self.fix_date_select_input(html)
elif isinstance(self.widget, ClearableFileInput):
html = self.fix_clearable_file_input(html)
elif isinstance(self.widget, CheckboxInput):
html = self.put_inside_label(html)
return html
class InlineFieldRenderer(BaseInlineFieldRenderer):
# Local application of https://github.com/zostera/django-bootstrap3/pull/859
def post_widget_render(self, html):
if isinstance(self.widget, CheckboxSelectMultiple):
html = self.list_to_class(html, "checkbox")
elif isinstance(self.widget, RadioSelect):
html = self.list_to_class(html, "radio")
elif isinstance(self.widget, SelectDateWidget):
html = self.fix_date_select_input(html)
elif isinstance(self.widget, ClearableFileInput):
html = self.fix_clearable_file_input(html)
elif isinstance(self.widget, CheckboxInput):
html = self.put_inside_label(html)
return html

View File

@@ -20,8 +20,6 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging import logging
import re
import unicodedata
from collections import defaultdict from collections import defaultdict
from decimal import Decimal from decimal import Decimal
from io import BytesIO from io import BytesIO
@@ -30,7 +28,6 @@ from typing import Tuple
import bleach import bleach
import vat_moss.exchange_rates import vat_moss.exchange_rates
from bidi.algorithm import get_display
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.db.models import Sum from django.db.models import Sum
from django.dispatch import receiver from django.dispatch import receiver
@@ -56,8 +53,7 @@ from pretix.base.models import Event, Invoice, Order, OrderPayment
from pretix.base.services.currencies import SOURCE_NAMES from pretix.base.services.currencies import SOURCE_NAMES
from pretix.base.signals import register_invoice_renderers from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper from pretix.helpers.reportlab import ThumbnailingImageReader
from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -83,12 +79,7 @@ class NumberedCanvas(Canvas):
def draw_page_number(self, page_count): def draw_page_number(self, page_count):
self.saveState() self.saveState()
self.setFont(self.font_regular, 8) self.setFont(self.font_regular, 8)
text = pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,) self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,))
try:
text = get_display(reshaper.reshape(text))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, text)
self.restoreState() self.restoreState()
@@ -148,8 +139,8 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
""" """
Initialize the renderer. By default, this registers fonts and sets ``self.stylesheet``. Initialize the renderer. By default, this registers fonts and sets ``self.stylesheet``.
""" """
self._register_fonts()
self.stylesheet = self._get_stylesheet() self.stylesheet = self._get_stylesheet()
self._register_fonts()
def _get_stylesheet(self): def _get_stylesheet(self):
""" """
@@ -157,10 +148,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
""" """
stylesheet = StyleSheet1() stylesheet = StyleSheet1()
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12)) stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
stylesheet.add(ParagraphStyle(name='Bold', fontName=self.font_bold, fontSize=10, leading=12))
stylesheet.add(ParagraphStyle(name='BoldRight', fontName=self.font_bold, fontSize=10, leading=12, alignment=TA_RIGHT))
stylesheet.add(ParagraphStyle(name='BoldRightNoSplit', fontName=self.font_bold, fontSize=10, leading=12, alignment=TA_RIGHT,
splitLongWords=False))
stylesheet.add(ParagraphStyle(name='NormalRight', fontName=self.font_regular, fontSize=10, leading=12, alignment=TA_RIGHT)) stylesheet.add(ParagraphStyle(name='NormalRight', fontName=self.font_regular, fontSize=10, leading=12, alignment=TA_RIGHT))
stylesheet.add(ParagraphStyle(name='BoldInverseCenter', fontName=self.font_bold, fontSize=10, leading=12, stylesheet.add(ParagraphStyle(name='BoldInverseCenter', fontName=self.font_bold, fontSize=10, leading=12,
textColor=colors.white, alignment=TA_CENTER)) textColor=colors.white, alignment=TA_CENTER))
@@ -168,7 +155,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2)) stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2))
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12)) stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10)) stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10))
stylesheet.add(ParagraphStyle(name='FineprintRight', fontName=self.font_regular, fontSize=8, leading=10, alignment=TA_RIGHT))
return stylesheet return stylesheet
def _register_fonts(self): def _register_fonts(self):
@@ -182,32 +168,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd', pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
italic='OpenSansIt', boldItalic='OpenSansBI') italic='OpenSansIt', boldItalic='OpenSansBI')
for family, styles in get_fonts().items():
if family == self.event.settings.invoice_renderer_font:
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
self.font_regular = family
if 'italic' in styles:
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
if 'bold' in styles:
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
self.font_bold = family + ' B'
if 'bolditalic' in styles:
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
def _normalize(self, text):
# 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:
text = "<br />".join(get_display(reshaper.reshape(l)) for l in re.split("<br ?/>", text))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
return text
def _upper(self, val): def _upper(self, val):
# We uppercase labels, but not in every language # We uppercase labels, but not in every language
if get_language().startswith('el'): if get_language().startswith('el'):
@@ -287,10 +247,10 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
return 'invoice.pdf', 'application/pdf', buffer.read() return 'invoice.pdf', 'application/pdf', buffer.read()
def _clean_text(self, text, tags=None): def _clean_text(self, text, tags=None):
return self._normalize(bleach.clean( return bleach.clean(
text, text,
tags=tags or [] tags=tags or []
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')) ).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
class PaidMarker(Flowable): class PaidMarker(Flowable):
@@ -331,7 +291,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
canvas.setFont(self.font_regular, 8) canvas.setFont(self.font_regular, 8)
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, self._normalize(line.strip())) canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
canvas.restoreState() canvas.restoreState()
@@ -364,13 +324,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_invoice_from_label(self, canvas): def _draw_invoice_from_label(self, canvas):
textobject = canvas.beginText(25 * mm, (297 - 15) * mm) textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice from')))) textobject.textLine(self._upper(pgettext('invoice', 'Invoice from')))
canvas.drawText(textobject) canvas.drawText(textobject)
def _draw_invoice_to_label(self, canvas): def _draw_invoice_to_label(self, canvas):
textobject = canvas.beginText(25 * mm, (297 - 50) * mm) textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice to')))) textobject.textLine(self._upper(pgettext('invoice', 'Invoice to')))
canvas.drawText(textobject) canvas.drawText(textobject)
logo_width = 25 * mm logo_width = 25 * mm
@@ -398,51 +358,51 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_metadata(self, canvas): def _draw_metadata(self, canvas):
textobject = canvas.beginText(125 * mm, (297 - 38) * mm) textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Order code')))) textobject.textLine(self._upper(pgettext('invoice', 'Order code')))
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10) textobject.setFont(self.font_regular, 10)
textobject.textLine(self._normalize(self.invoice.order.full_code)) textobject.textLine(self.invoice.order.full_code)
canvas.drawText(textobject) canvas.drawText(textobject)
textobject = canvas.beginText(125 * mm, (297 - 50) * mm) textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
if self.invoice.is_cancellation: if self.invoice.is_cancellation:
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Cancellation number')))) textobject.textLine(self._upper(pgettext('invoice', 'Cancellation number')))
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10) textobject.setFont(self.font_regular, 10)
textobject.textLine(self._normalize(self.invoice.number)) textobject.textLine(self.invoice.number)
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Original invoice')))) textobject.textLine(self._upper(pgettext('invoice', 'Original invoice')))
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10) textobject.setFont(self.font_regular, 10)
textobject.textLine(self._normalize(self.invoice.refers.number)) textobject.textLine(self.invoice.refers.number)
else: else:
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice number')))) textobject.textLine(self._upper(pgettext('invoice', 'Invoice number')))
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10) textobject.setFont(self.font_regular, 10)
textobject.textLine(self._normalize(self.invoice.number)) textobject.textLine(self.invoice.number)
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
if self.invoice.is_cancellation: if self.invoice.is_cancellation:
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Cancellation date')))) textobject.textLine(self._upper(pgettext('invoice', 'Cancellation date')))
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10) textobject.setFont(self.font_regular, 10)
textobject.textLine(self._normalize(date_format(self.invoice.date, "DATE_FORMAT"))) textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Original invoice date')))) textobject.textLine(self._upper(pgettext('invoice', 'Original invoice date')))
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10) textobject.setFont(self.font_regular, 10)
textobject.textLine(self._normalize(date_format(self.invoice.refers.date, "DATE_FORMAT"))) textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT"))
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
else: else:
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice date')))) textobject.textLine(self._upper(pgettext('invoice', 'Invoice date')))
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10) textobject.setFont(self.font_regular, 10)
textobject.textLine(self._normalize(date_format(self.invoice.date, "DATE_FORMAT"))) textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
canvas.drawText(textobject) canvas.drawText(textobject)
@@ -455,19 +415,19 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_event_label(self, canvas): def _draw_event_label(self, canvas):
textobject = canvas.beginText(125 * mm, (297 - 15) * mm) textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Event')))) textobject.textLine(self._upper(pgettext('invoice', 'Event')))
canvas.drawText(textobject) canvas.drawText(textobject)
def _draw_event(self, canvas): def _draw_event(self, canvas):
def shorten(txt): def shorten(txt):
txt = str(txt) txt = str(txt)
txt = bleach.clean(txt, tags=[]).strip() txt = bleach.clean(txt, tags=[]).strip()
p = Paragraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal']) p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height)
while p_size[1] > 2 * self.stylesheet['Normal'].leading: while p_size[1] > 2 * self.stylesheet['Normal'].leading:
txt = ' '.join(txt.replace('', '').split()[:-1]) + '' txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
p = Paragraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal']) p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height)
return txt return txt
@@ -493,7 +453,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
else: else:
p_str = shorten(self.invoice.event.name) p_str = shorten(self.invoice.event.name)
p = Paragraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal']) p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.event_width, self.event_height) p.wrapOn(canvas, self.event_width, self.event_height)
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height)
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1]) p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
@@ -502,14 +462,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_footer(self, canvas): def _draw_footer(self, canvas):
canvas.setFont(self.font_regular, 8) canvas.setFont(self.font_regular, 8)
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, self._normalize(line.strip())) canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
def _draw_testmode(self, canvas): def _draw_testmode(self, canvas):
if self.invoice.order.testmode: if self.invoice.order.testmode:
canvas.saveState() canvas.saveState()
canvas.setFont(self.font_bold, 30) canvas.setFont('OpenSansBd', 30)
canvas.setFillColorRGB(32, 0, 0) canvas.setFillColorRGB(32, 0, 0)
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, self._normalize(gettext('TEST MODE'))) canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, gettext('TEST MODE'))
canvas.restoreState() canvas.restoreState()
def _on_first_page(self, canvas: Canvas, doc): def _on_first_page(self, canvas: Canvas, doc):
@@ -557,22 +517,22 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.internal_reference: if self.invoice.internal_reference:
story.append(Paragraph( story.append(Paragraph(
self._normalize(pgettext('invoice', 'Customer reference: {reference}').format( pgettext('invoice', 'Customer reference: {reference}').format(
reference=self._clean_text(self.invoice.internal_reference), reference=self._clean_text(self.invoice.internal_reference),
)), ),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.invoice_to_vat_id: if self.invoice.invoice_to_vat_id:
story.append(Paragraph( story.append(Paragraph(
self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' + pgettext('invoice', 'Customer VAT ID') + ': ' +
self._clean_text(self.invoice.invoice_to_vat_id), self._clean_text(self.invoice.invoice_to_vat_id),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.invoice_to_beneficiary: if self.invoice.invoice_to_beneficiary:
story.append(Paragraph( story.append(Paragraph(
self._normalize(pgettext('invoice', 'Beneficiary')) + ':<br />' + pgettext('invoice', 'Beneficiary') + ':<br />' +
self._clean_text(self.invoice.invoice_to_beneficiary), self._clean_text(self.invoice.invoice_to_beneficiary),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
@@ -592,10 +552,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story = [ story = [
NextPageTemplate('FirstPage'), NextPageTemplate('FirstPage'),
Paragraph( Paragraph(
self._normalize( (
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU' pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
else pgettext('invoice', 'Invoice') else pgettext('invoice', 'Invoice')
) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')), ) if not self.invoice.is_cancellation else pgettext('invoice', 'Cancellation'),
self.stylesheet['Heading1'] self.stylesheet['Heading1']
), ),
Spacer(1, 5 * mm), Spacer(1, 5 * mm),
@@ -617,17 +577,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
] ]
if has_taxes: if has_taxes:
tdata = [( tdata = [(
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']), pgettext('invoice', 'Description'),
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']), pgettext('invoice', 'Qty'),
Paragraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']), pgettext('invoice', 'Tax rate'),
Paragraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']), pgettext('invoice', 'Net'),
Paragraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']), pgettext('invoice', 'Gross'),
)] )]
else: else:
tdata = [( tdata = [(
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['BoldRight']), pgettext('invoice', 'Description'),
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']), pgettext('invoice', 'Qty'),
Paragraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']), pgettext('invoice', 'Amount'),
)] )]
def _group_key(line): def _group_key(line):
@@ -674,13 +634,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if has_taxes: if has_taxes:
tdata.append([ tdata.append([
Paragraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '', pgettext('invoice', 'Invoice total'), '', '', '',
money_filter(total, self.invoice.event.currency) money_filter(total, self.invoice.event.currency)
]) ])
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)] colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else: else:
tdata.append([ tdata.append([
Paragraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', pgettext('invoice', 'Invoice total'), '',
money_filter(total, self.invoice.event.currency) money_filter(total, self.invoice.event.currency)
]) ])
colwidths = [a * doc.width for a in (.65, .20, .15)] colwidths = [a * doc.width for a in (.65, .20, .15)]
@@ -689,16 +649,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING: if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:
pending_sum = self.invoice.order.pending_sum pending_sum = self.invoice.order.pending_sum
if pending_sum != total: if pending_sum != total:
tdata.append( tdata.append([pgettext('invoice', 'Received payments')] + (['', '', ''] if has_taxes else ['']) + [
[Paragraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] + money_filter(pending_sum - total, self.invoice.event.currency)
(['', '', ''] if has_taxes else ['']) + ])
[money_filter(pending_sum - total, self.invoice.event.currency)] tdata.append([pgettext('invoice', 'Outstanding payments')] + (['', '', ''] if has_taxes else ['']) + [
) money_filter(pending_sum, self.invoice.event.currency)
tdata.append( ])
[Paragraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum, self.invoice.event.currency)]
)
tstyledata += [ tstyledata += [
('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold), ('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold),
] ]
@@ -711,24 +667,19 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
).aggregate( ).aggregate(
s=Sum('amount') s=Sum('amount')
)['s'] or Decimal('0.00') )['s'] or Decimal('0.00')
tdata.append( tdata.append([pgettext('invoice', 'Paid by gift card')] + (['', '', ''] if has_taxes else ['']) + [
[Paragraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] + money_filter(giftcard_sum, self.invoice.event.currency)
(['', '', ''] if has_taxes else ['']) + ])
[money_filter(giftcard_sum, self.invoice.event.currency)] tdata.append([pgettext('invoice', 'Remaining amount')] + (['', '', ''] if has_taxes else ['']) + [
) money_filter(total - giftcard_sum, self.invoice.event.currency)
tdata.append( ])
[Paragraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(total - giftcard_sum, self.invoice.event.currency)]
)
tstyledata += [ tstyledata += [
('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold), ('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold),
] ]
elif self.invoice.payment_provider_stamp: elif self.invoice.payment_provider_stamp:
pm = PaidMarker( pm = PaidMarker(
text=self._normalize(self.invoice.payment_provider_stamp), text=self.invoice.payment_provider_stamp,
color=colors.HexColor(self.event.settings.theme_color_success), color=colors.HexColor(self.event.settings.theme_color_success),
font=self.font_bold,
size=16 size=16
) )
tdata[-1][-2] = pm tdata[-1][-2] = pm
@@ -741,7 +692,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.payment_provider_text: if self.invoice.payment_provider_text:
story.append(Paragraph( story.append(Paragraph(
self._normalize(self.invoice.payment_provider_text), self.invoice.payment_provider_text,
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
@@ -765,10 +716,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
('FONTNAME', (0, 0), (-1, -1), self.font_regular), ('FONTNAME', (0, 0), (-1, -1), self.font_regular),
] ]
thead = [ thead = [
Paragraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']), pgettext('invoice', 'Tax rate'),
Paragraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']), pgettext('invoice', 'Net value'),
Paragraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']), pgettext('invoice', 'Gross value'),
Paragraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']), pgettext('invoice', 'Tax'),
'' ''
] ]
tdata = [thead] tdata = [thead]
@@ -779,7 +730,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
continue continue
tax = taxvalue_map[idx] tax = taxvalue_map[idx]
tdata.append([ tdata.append([
Paragraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']), localize(rate) + " % " + name,
money_filter(gross - tax, self.invoice.event.currency), money_filter(gross - tax, self.invoice.event.currency),
money_filter(gross, self.invoice.event.currency), money_filter(gross, self.invoice.event.currency),
money_filter(tax, self.invoice.event.currency), money_filter(tax, self.invoice.event.currency),
@@ -798,7 +749,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
table.setStyle(TableStyle(tstyledata)) table.setStyle(TableStyle(tstyledata))
story.append(Spacer(5 * mm, 5 * mm)) story.append(Spacer(5 * mm, 5 * mm))
story.append(KeepTogether([ story.append(KeepTogether([
Paragraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']), Paragraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
table table
])) ]))
@@ -815,7 +766,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net = gross - tax net = gross - tax
tdata.append([ tdata.append([
Paragraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']), localize(rate) + " % " + name,
fmt(net), fmt(gross), fmt(tax), '' fmt(net), fmt(gross), fmt(tax), ''
]) ])
@@ -825,12 +776,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(KeepTogether([ story.append(KeepTogether([
Spacer(1, height=2 * mm), Spacer(1, height=2 * mm),
Paragraph( Paragraph(
self._normalize(pgettext( pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, this corresponds to:' '{date}, this corresponds to:'
).format(rate=localize(self.invoice.foreign_currency_rate), ).format(rate=localize(self.invoice.foreign_currency_rate),
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))), date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT")),
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
), ),
Spacer(1, height=3 * mm), Spacer(1, height=3 * mm),
@@ -839,14 +790,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate: elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate) foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
story.append(Spacer(1, 5 * mm)) story.append(Spacer(1, 5 * mm))
story.append(Paragraph(self._normalize( story.append(Paragraph(
pgettext( pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, the invoice total corresponds to {total}.' '{date}, the invoice total corresponds to {total}.'
).format(rate=localize(self.invoice.foreign_currency_rate), ).format(rate=localize(self.invoice.foreign_currency_rate),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"), date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
total=fmt(foreign_total))), total=fmt(foreign_total)),
self.stylesheet['Fineprint'] self.stylesheet['Fineprint']
)) ))
@@ -892,7 +843,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
self._clean_text(l) self._clean_text(l)
for l in self.invoice.address_invoice_from.strip().split('\n') for l in self.invoice.address_invoice_from.strip().split('\n')
] ]
p = Paragraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender']) p = Paragraph(' · '.join(c), style=self.stylesheet['Sender'])
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm) p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm) p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
super()._draw_invoice_from(canvas) super()._draw_invoice_from(canvas)
@@ -908,12 +859,8 @@ class Modern1Renderer(ClassicInvoiceRenderer):
def _get_first_page_frames(self, doc): def _get_first_page_frames(self, doc):
footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm
if self.event.settings.invoice_renderer_highlight_order_code:
margin_top = 100 * mm
else:
margin_top = 95 * mm
return [ return [
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - margin_top, Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 95 * mm,
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length,
id='normal') id='normal')
] ]
@@ -924,35 +871,25 @@ class Modern1Renderer(ClassicInvoiceRenderer):
# the font size until it fits. # the font size until it fits.
begin_top = 100 * mm begin_top = 100 * mm
def _draw(label, value, value_size, x, width, bold=False, sublabel=None): def _draw(label, value, value_size, x, width):
if canvas.stringWidth(value, self.font_regular, value_size) > width and value_size > 6: if canvas.stringWidth(value, self.font_regular, value_size) > width and value_size > 6:
return False return False
textobject = canvas.beginText(x, self.pagesize[1] - begin_top) textobject = canvas.beginText(x, self.pagesize[1] - begin_top)
textobject.setFont(self.font_regular, 8) textobject.setFont(self.font_regular, 8)
textobject.textLine(self._normalize(label)) textobject.textLine(label)
textobject.moveCursor(0, 5) textobject.moveCursor(0, 5)
textobject.setFont(self.font_bold if bold else self.font_regular, value_size) textobject.setFont(self.font_regular, value_size)
textobject.textLine(self._normalize(value)) textobject.textLine(value)
if sublabel:
textobject.moveCursor(0, 1)
textobject.setFont(self.font_regular, 8)
textobject.textLine(self._normalize(sublabel))
return textobject return textobject
value_size = 10 value_size = 10
while value_size >= 5: while value_size >= 5:
if self.event.settings.invoice_renderer_highlight_order_code:
kwargs = dict(bold=True, sublabel=pgettext('invoice', '(Please quote at all times.)'))
else:
kwargs = {}
objects = [ objects = [
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs) _draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm)
] ]
p = Paragraph( p = Paragraph(
self._normalize(date_format(self.invoice.date, "DATE_FORMAT")), date_format(self.invoice.date, "DATE_FORMAT"),
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2) style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
) )
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize) w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
@@ -983,9 +920,9 @@ class Modern1Renderer(ClassicInvoiceRenderer):
textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top) textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top)
textobject.setFont(self.font_regular, 8) textobject.setFont(self.font_regular, 8)
if self.invoice.is_cancellation: if self.invoice.is_cancellation:
textobject.textLine(self._normalize(pgettext('invoice', 'Cancellation date'))) textobject.textLine(pgettext('invoice', 'Cancellation date'))
else: else:
textobject.textLine(self._normalize(pgettext('invoice', 'Invoice date'))) textobject.textLine(pgettext('invoice', 'Invoice date'))
canvas.drawText(textobject) canvas.drawText(textobject)

View File

@@ -24,7 +24,7 @@ Django, for theoretically very valid reasons, creates migrations for *every sing
we change on a model. Even the `help_text`! This makes sense, as we don't know if any we change on a model. Even the `help_text`! This makes sense, as we don't know if any
database backend unknown to us might actually use this information for its database schema. database backend unknown to us might actually use this information for its database schema.
However, pretix only supports PostgreSQL and SQLite and we can be pretty However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
certain that some changes to models will never require a change to the database. In this case, certain that some changes to models will never require a change to the database. In this case,
not creating a migration for certain changes will save us some performance while applying them not creating a migration for certain changes will save us some performance while applying them
*and* allow for a cleaner git history. Win-win! *and* allow for a cleaner git history. Win-win!

View File

@@ -22,7 +22,7 @@
import json import json
import sys import sys
import pytz_deprecation_shim import pytz
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.timezone import override from django.utils.timezone import override
from django_scopes import scope from django_scopes import scope
@@ -60,7 +60,7 @@ class Command(BaseCommand):
sys.exit(1) sys.exit(1)
locale = options.get("locale", None) locale = options.get("locale", None)
timezone = pytz_deprecation_shim.timezone(options['timezone']) if options.get('timezone') else None timezone = pytz.timezone(options['timezone']) if options.get('timezone') else None
with scope(organizer=o): with scope(organizer=o):
if options['event_slug']: if options['event_slug']:

View File

@@ -51,7 +51,7 @@ class Command(BaseCommand):
del options['skip_checks'] del options['skip_checks']
del options['print_sql'] del options['print_sql']
if options.get('print_sql'): if options['print_sql']:
connection.force_debug_cursor = True connection.force_debug_cursor = True
logger = logging.getLogger("django.db.backends") logger = logging.getLogger("django.db.backends")
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)

View File

@@ -21,8 +21,8 @@
# #
from collections import OrderedDict from collections import OrderedDict
from urllib.parse import urlsplit from urllib.parse import urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytz
from django.conf import settings from django.conf import settings
from django.http import Http404, HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.middleware.common import CommonMiddleware from django.middleware.common import CommonMiddleware
@@ -98,9 +98,9 @@ class LocaleMiddleware(MiddlewareMixin):
tzname = request.user.timezone tzname = request.user.timezone
if tzname: if tzname:
try: try:
timezone.activate(ZoneInfo(tzname)) timezone.activate(pytz.timezone(tzname))
request.timezone = tzname request.timezone = tzname
except ZoneInfoNotFoundError: except pytz.UnknownTimeZoneError:
pass pass
else: else:
timezone.deactivate() timezone.deactivate()

View File

@@ -2,8 +2,6 @@
# Generated by Django 1.10.4 on 2017-02-03 14:21 # Generated by Django 1.10.4 on 2017-02-03 14:21
from __future__ import unicode_literals from __future__ import unicode_literals
from zoneinfo import ZoneInfo
import django.core.validators import django.core.validators
import django.db.migrations.operations.special import django.db.migrations.operations.special
import django.db.models.deletion import django.db.models.deletion
@@ -28,7 +26,7 @@ def forwards42(apps, schema_editor):
for s in EventSetting.objects.filter(key='timezone').values('object_id', 'value') for s in EventSetting.objects.filter(key='timezone').values('object_id', 'value')
} }
for order in Order.objects.all(): for order in Order.objects.all():
tz = ZoneInfo(etz.get(order.event_id, 'UTC')) tz = pytz.timezone(etz.get(order.event_id, 'UTC'))
order.expires = order.expires.astimezone(tz).replace(hour=23, minute=59, second=59) order.expires = order.expires.astimezone(tz).replace(hour=23, minute=59, second=59)
order.save() order.save()

View File

@@ -2,9 +2,9 @@
# Generated by Django 1.10.2 on 2016-10-19 17:57 # Generated by Django 1.10.2 on 2016-10-19 17:57
from __future__ import unicode_literals from __future__ import unicode_literals
from zoneinfo import ZoneInfo import pytz
from django.db import migrations from django.db import migrations
from django.utils import timezone
def forwards(apps, schema_editor): def forwards(apps, schema_editor):
@@ -15,7 +15,7 @@ def forwards(apps, schema_editor):
for s in EventSetting.objects.filter(key='timezone').values('object_id', 'value') for s in EventSetting.objects.filter(key='timezone').values('object_id', 'value')
} }
for order in Order.objects.all(): for order in Order.objects.all():
tz = ZoneInfo(etz.get(order.event_id, 'UTC')) tz = pytz.timezone(etz.get(order.event_id, 'UTC'))
order.expires = order.expires.astimezone(tz).replace(hour=23, minute=59, second=59) order.expires = order.expires.astimezone(tz).replace(hour=23, minute=59, second=59)
order.save() order.save()

View File

@@ -3,6 +3,7 @@
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import migrations, models from django.db import migrations, models
from django_mysql.checks import mysql_connections
def set_attendee_name_parts(apps, schema_editor): def set_attendee_name_parts(apps, schema_editor):
@@ -23,12 +24,40 @@ def set_attendee_name_parts(apps, schema_editor):
ia.save(update_fields=['name_parts']) ia.save(update_fields=['name_parts'])
def check_mysqlversion(apps, schema_editor):
errors = []
any_conn_works = False
conns = list(mysql_connections())
found = 'Unknown version'
for alias, conn in conns:
if hasattr(conn, 'mysql_is_mariadb') and conn.mysql_is_mariadb and hasattr(conn, 'mysql_version'):
if conn.mysql_version >= (10, 2, 7):
any_conn_works = True
else:
found = 'MariaDB ' + '.'.join(str(v) for v in conn.mysql_version)
elif hasattr(conn, 'mysql_version'):
if conn.mysql_version >= (5, 7):
any_conn_works = True
else:
found = 'MySQL ' + '.'.join(str(v) for v in conn.mysql_version)
if conns and not any_conn_works:
raise ImproperlyConfigured(
'As of pretix 2.2, you need MySQL 5.7+ or MariaDB 10.2.7+ to run pretix. However, we detected a '
'database connection to {}'.format(found)
)
return errors
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('pretixbase', '0101_auto_20181025_2255'), ('pretixbase', '0101_auto_20181025_2255'),
] ]
operations = [ operations = [
migrations.RunPython(
check_mysqlversion, migrations.RunPython.noop
),
migrations.RenameField( migrations.RenameField(
model_name='cartposition', model_name='cartposition',
old_name='attendee_name', old_name='attendee_name',

View File

@@ -1,7 +1,8 @@
# Generated by Django 3.2.4 on 2021-09-30 10:25 # Generated by Django 3.2.4 on 2021-09-30 10:25
from datetime import datetime, timezone from datetime import datetime
from django.db import migrations, models from django.db import migrations, models
from pytz import UTC
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -14,7 +15,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='invoice', model_name='invoice',
name='sent_to_customer', name='sent_to_customer',
field=models.DateTimeField(blank=True, null=True, default=datetime(1970, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc)), field=models.DateTimeField(blank=True, null=True, default=UTC.localize(datetime(1970, 1, 1, 0, 0, 0, 0))),
preserve_default=False, preserve_default=False,
), ),
] ]

View File

@@ -50,6 +50,6 @@ class Migration(migrations.Migration):
], ],
options={ options={
'unique_together': {('event', 'secret')}, 'unique_together': {('event', 'secret')},
} } if 'mysql' not in settings.DATABASES['default']['ENGINE'] else {}
), ),
] ]

View File

@@ -1,19 +0,0 @@
# Generated by Django 3.2.18 on 2023-05-04 12:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0237_question_valid_string_length'),
]
operations = [
migrations.AddField(
model_name='giftcard',
name='owner_ticket',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_gift_cards', to='pretixbase.orderposition'),
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 3.2.18 on 2023-05-11 11:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0238_giftcard_owner_ticket'),
]
operations = [
migrations.AddField(
model_name='giftcardtransaction',
name='acceptor',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='gift_card_transactions', to='pretixbase.organizer'),
),
migrations.AddField(
model_name='giftcardtransaction',
name='info',
field=models.JSONField(null=True),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 3.2.18 on 2023-05-16 11:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0239_giftcard_info'),
]
operations = [
migrations.AddField(
model_name='voucher',
name='all_addons_included',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='voucher',
name='all_bundles_included',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 3.2.19 on 2023-05-25 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0240_auto_20230516_1119'),
]
operations = [
migrations.AddField(
model_name='itemmetaproperty',
name='allowed_values',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='itemmetaproperty',
name='required',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,38 +0,0 @@
# Generated by Django 3.2.18 on 2023-05-12 10:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0241_itemmetaproperties_required_values'),
]
operations = [
migrations.RenameField(
model_name='giftcardacceptance',
old_name='collector',
new_name='acceptor',
),
migrations.AddField(
model_name='giftcardacceptance',
name='active',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='giftcardacceptance',
name='reusable_media',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='giftcardacceptance',
name='issuer',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gift_card_acceptor_acceptance', to='pretixbase.organizer'),
),
migrations.AlterUniqueTogether(
name='giftcardacceptance',
unique_together={('issuer', 'acceptor')},
),
]

View File

@@ -178,7 +178,7 @@ class LoggedModel(models.Model, LoggingMixin):
return LogEntry.objects.filter( return LogEntry.objects.filter(
content_type=self.logs_content_type, object_id=self.pk content_type=self.logs_content_type, object_id=self.pk
).select_related('user', 'event', 'event__organizer', 'oauth_application', 'api_token', 'device') ).select_related('user', 'event', 'oauth_application', 'api_token', 'device')
class LockModel: class LockModel:

View File

@@ -39,7 +39,6 @@ from pretix.base.models.fields import MultiStringField
from pretix.base.models.organizer import Organizer from pretix.base.models.organizer import Organizer
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.helpers.countries import FastCountryField from pretix.helpers.countries import FastCountryField
from pretix.helpers.names import build_name
class CustomerSSOProvider(LoggedModel): class CustomerSSOProvider(LoggedModel):
@@ -121,23 +120,14 @@ class Customer(LoggedModel):
if self.email: if self.email:
self.email = self.email.lower() self.email = self.email.lower()
if 'update_fields' in kwargs and 'last_modified' not in kwargs['update_fields']: if 'update_fields' in kwargs and 'last_modified' not in kwargs['update_fields']:
kwargs['update_fields'] = {'last_modified'}.union(kwargs['update_fields']) kwargs['update_fields'] = list(kwargs['update_fields']) + ['last_modified']
if not self.identifier: if not self.identifier:
self.assign_identifier() self.assign_identifier()
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'identifier'}.union(kwargs['update_fields'])
if self.name_parts: if self.name_parts:
name = self.name self.name_cached = self.name
if self.name_cached != name:
self.name_cached = name
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'name_cached'}.union(kwargs['update_fields'])
else: else:
if self.name_cached != "" or self.name_parts != {}: self.name_cached = ""
self.name_cached = "" self.name_parts = {}
self.name_parts = {}
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'name_cached', 'name_parts'}.union(kwargs['update_fields'])
super().save(**kwargs) super().save(**kwargs)
def anonymize(self): def anonymize(self):
@@ -181,11 +171,15 @@ class Customer(LoggedModel):
@property @property
def name(self): def name(self):
return build_name(self.name_parts, fallback_scheme=lambda: self.organizer.settings.name_scheme) or "" if not self.name_parts:
return ""
@property if '_legacy' in self.name_parts:
def name_all_components(self): return self.name_parts['_legacy']
return build_name(self.name_parts, "concatenation_all_components", fallback_scheme=lambda: self.organizer.settings.name_scheme) or "" if '_scheme' in self.name_parts:
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
else:
raise TypeError("Invalid name given.")
return scheme['concatenation'](self.name_parts).strip()
def __str__(self): def __str__(self):
s = f'#{self.identifier}' s = f'#{self.identifier}'
@@ -308,11 +302,15 @@ class AttendeeProfile(models.Model):
@property @property
def attendee_name(self): def attendee_name(self):
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme) if not self.attendee_name_parts:
return None
@property if '_legacy' in self.attendee_name_parts:
def attendee_name_all_components(self): return self.attendee_name_parts['_legacy']
return build_name(self.attendee_name_parts, "concatenation_all_components", fallback_scheme=lambda: self.customer.organizer.settings.name_scheme) if '_scheme' in self.attendee_name_parts:
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
else:
scheme = PERSON_NAME_SCHEMES[self.customer.organizer.settings.name_scheme]
return scheme['concatenation'](self.attendee_name_parts).strip()
@property @property
def state_name(self): def state_name(self):

View File

@@ -98,8 +98,6 @@ class Gate(LoggedModel):
if not Gate.objects.filter(organizer=self.organizer, identifier=code).exists(): if not Gate.objects.filter(organizer=self.organizer, identifier=code).exists():
self.identifier = code self.identifier = code
break break
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'identifier'}.union(kwargs['update_fields'])
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@@ -175,8 +173,6 @@ class Device(LoggedModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.device_id: if not self.device_id:
self.device_id = (self.organizer.devices.aggregate(m=Max('device_id'))['m'] or 0) + 1 self.device_id = (self.organizer.devices.aggregate(m=Max('device_id'))['m'] or 0) + 1
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'device_id'}.union(kwargs['update_fields'])
super().save(*args, **kwargs) super().save(*args, **kwargs)
def permission_set(self) -> set: def permission_set(self) -> set:

View File

@@ -40,9 +40,8 @@ from collections import Counter, OrderedDict, defaultdict
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from operator import attrgetter from operator import attrgetter
from urllib.parse import urljoin from urllib.parse import urljoin
from zoneinfo import ZoneInfo
import pytz_deprecation_shim import pytz
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
@@ -215,7 +214,7 @@ class EventMixin:
@property @property
def timezone(self): def timezone(self):
return pytz_deprecation_shim.timezone(self.settings.timezone) return pytz.timezone(self.settings.timezone)
@property @property
def effective_presale_end(self): def effective_presale_end(self):
@@ -291,19 +290,19 @@ class EventMixin:
return safe_string(json.dumps(eventdict)) return safe_string(json.dumps(eventdict))
@classmethod @classmethod
def annotated(cls, qs, channel='web', voucher=None): def annotated(cls, qs, channel='web'):
from pretix.base.models import Item, ItemVariation, Quota from pretix.base.models import Item, ItemVariation, Quota
sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).filter( sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel).filter(
Q(variations__isnull=True) Q(variations__isnull=True)
& Q(quotas__pk=OuterRef('pk')) & Q(quotas__pk=OuterRef('pk'))
).order_by().values_list('quotas__pk').annotate( ).order_by().values_list('quotas__pk').annotate(
items=GroupConcat('pk', delimiter=',') items=GroupConcat('pk', delimiter=',')
).values('items') ).values('items')
sq_active_variation = ItemVariation.objects.filter(
q_variation = (
Q(active=True) Q(active=True)
& Q(sales_channels__contains=channel) & Q(sales_channels__contains=channel)
& Q(hide_without_voucher=False)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) & Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) & Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(item__active=True) & Q(item__active=True)
@@ -311,23 +310,10 @@ class EventMixin:
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now())) & Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False)) & Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
& Q(item__sales_channels__contains=channel) & Q(item__sales_channels__contains=channel)
& Q(item__hide_without_voucher=False)
& Q(item__require_bundling=False) & Q(item__require_bundling=False)
& Q(quotas__pk=OuterRef('pk')) & Q(quotas__pk=OuterRef('pk'))
) ).order_by().values_list('quotas__pk').annotate(
if voucher:
if voucher.variation_id:
q_variation &= Q(pk=voucher.variation_id)
elif voucher.item_id:
q_variation &= Q(item_id=voucher.item_id)
elif voucher.quota_id:
q_variation &= Q(quotas__in=[voucher.quota_id])
if not voucher or not voucher.show_hidden_items:
q_variation &= Q(hide_without_voucher=False)
q_variation &= Q(item__hide_without_voucher=False)
sq_active_variation = ItemVariation.objects.filter(q_variation).order_by().values_list('quotas__pk').annotate(
items=GroupConcat('pk', delimiter=',') items=GroupConcat('pk', delimiter=',')
).values('items') ).values('items')
quota_base_qs = Quota.objects.using(settings.DATABASE_REPLICA).filter( quota_base_qs = Quota.objects.using(settings.DATABASE_REPLICA).filter(
@@ -639,7 +625,6 @@ class Event(EventMixin, LoggedModel):
""" """
self.settings.invoice_renderer = 'modern1' self.settings.invoice_renderer = 'modern1'
self.settings.invoice_include_expire_date = True self.settings.invoice_include_expire_date = True
self.settings.invoice_renderer_highlight_order_code = True
self.settings.ticketoutput_pdf__enabled = True self.settings.ticketoutput_pdf__enabled = True
self.settings.ticketoutput_passbook__enabled = True self.settings.ticketoutput_passbook__enabled = True
self.settings.event_list_type = 'calendar' self.settings.event_list_type = 'calendar'
@@ -774,7 +759,7 @@ class Event(EventMixin, LoggedModel):
""" """
The last datetime of payments for this event. The last datetime of payments for this event.
""" """
tz = ZoneInfo(self.settings.timezone) tz = pytz.timezone(self.settings.timezone)
return make_aware(datetime.combine( return make_aware(datetime.combine(
self.settings.get('payment_term_last', as_type=RelativeDateWrapper).datetime(self).date(), self.settings.get('payment_term_last', as_type=RelativeDateWrapper).datetime(self).date(),
time(hour=23, minute=59, second=59) time(hour=23, minute=59, second=59)
@@ -1147,8 +1132,8 @@ class Event(EventMixin, LoggedModel):
irs = self.get_invoice_renderers() irs = self.get_invoice_renderers()
return irs[self.settings.invoice_renderer] return irs[self.settings.invoice_renderer]
def subevents_annotated(self, channel, voucher=None): def subevents_annotated(self, channel):
return SubEvent.annotated(self.subevents, channel, voucher) return SubEvent.annotated(self.subevents, channel)
def subevents_sorted(self, queryset): def subevents_sorted(self, queryset):
ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str) ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
@@ -1468,10 +1453,10 @@ class SubEvent(EventMixin, LoggedModel):
return qs_annotated return qs_annotated
@classmethod @classmethod
def annotated(cls, qs, channel='web', voucher=None): def annotated(cls, qs, channel='web'):
from .items import SubEventItem, SubEventItemVariation from .items import SubEventItem, SubEventItemVariation
qs = super().annotated(qs, channel, voucher=voucher) qs = super().annotated(qs, channel)
qs = qs.annotate( qs = qs.annotate(
disabled_items=Coalesce( disabled_items=Coalesce(
Subquery( Subquery(

View File

@@ -19,11 +19,10 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import zoneinfo
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytz
from dateutil.rrule import rrulestr from dateutil.rrule import rrulestr
from dateutil.tz import datetime_exists
from django.conf import settings from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db import models from django.db import models
@@ -109,9 +108,12 @@ class AbstractScheduledExport(LoggedModel):
self.schedule_next_run = None self.schedule_next_run = None
return return
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz) try:
if not datetime_exists(self.schedule_next_run): self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz)
self.schedule_next_run += timedelta(hours=1) 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): class ScheduledEventExport(AbstractScheduledExport):
@@ -134,4 +136,4 @@ class ScheduledOrganizerExport(AbstractScheduledExport):
@property @property
def tz(self): def tz(self):
return zoneinfo.ZoneInfo(self.timezone) return pytz.timezone(self.timezone)

View File

@@ -25,9 +25,7 @@ from django.conf import settings
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
from django.urls import reverse
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.html import format_html
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
@@ -46,19 +44,14 @@ def gen_giftcard_secret(length=8):
class GiftCardAcceptance(models.Model): class GiftCardAcceptance(models.Model):
issuer = models.ForeignKey( issuer = models.ForeignKey(
'Organizer', 'Organizer',
related_name='gift_card_acceptor_acceptance', related_name='gift_card_collector_acceptance',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
acceptor = models.ForeignKey( collector = models.ForeignKey(
'Organizer', 'Organizer',
related_name='gift_card_issuer_acceptance', related_name='gift_card_issuer_acceptance',
on_delete=models.CASCADE on_delete=models.CASCADE
) )
active = models.BooleanField(default=True)
reusable_media = models.BooleanField(default=False)
class Meta:
unique_together = (('issuer', 'acceptor'),)
class GiftCard(LoggedModel): class GiftCard(LoggedModel):
@@ -73,13 +66,6 @@ class GiftCard(LoggedModel):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, blank=True null=True, blank=True
) )
owner_ticket = models.ForeignKey(
'OrderPosition',
related_name='owned_gift_cards',
on_delete=models.PROTECT,
null=True, blank=True,
verbose_name=_('Owned by ticket holder')
)
issuance = models.DateTimeField( issuance = models.DateTimeField(
auto_now_add=True, auto_now_add=True,
) )
@@ -119,7 +105,7 @@ class GiftCard(LoggedModel):
return self.transactions.aggregate(s=Sum('value'))['s'] or Decimal('0.00') return self.transactions.aggregate(s=Sum('value'))['s'] or Decimal('0.00')
def accepted_by(self, organizer): def accepted_by(self, organizer):
return self.issuer == organizer or GiftCardAcceptance.objects.filter(issuer=self.issuer, acceptor=organizer, active=True).exists() return self.issuer == organizer or GiftCardAcceptance.objects.filter(issuer=self.issuer, collector=organizer).exists()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.secret: if not self.secret:
@@ -167,61 +153,6 @@ class GiftCardTransaction(models.Model):
on_delete=models.PROTECT on_delete=models.PROTECT
) )
text = models.TextField(blank=True, null=True) text = models.TextField(blank=True, null=True)
info = models.JSONField(
null=True, blank=True,
)
acceptor = models.ForeignKey(
'Organizer',
related_name='gift_card_transactions',
on_delete=models.PROTECT,
null=True, blank=True
)
class Meta: class Meta:
ordering = ("datetime",) ordering = ("datetime",)
def save(self, *args, **kwargs):
if not self.pk and not self.acceptor:
raise ValueError("`acceptor` should be set on all new gift card transactions.")
super().save(*args, **kwargs)
def display(self, customer_facing=True):
from ..signals import gift_card_transaction_display
for receiver, response in gift_card_transaction_display.send(self, transaction=self, customer_facing=customer_facing):
if response:
return response
if self.order_id:
if not self.text:
if not customer_facing:
return format_html(
'<a href="{}">{}</a>',
reverse(
"control:event.order",
kwargs={
"event": self.order.event.slug,
"organizer": self.order.event.organizer.slug,
"code": self.order.code,
}
),
self.order.full_code
)
return self.order.full_code
else:
return self.text
else:
if self.text:
return format_html(
'<em>{}:</em> {}',
_('Manual transaction'),
self.text,
)
else:
return _('Manual transaction')
def display_backend(self):
return self.display(customer_facing=False)
def display_presale(self):
return self.display(customer_facing=True)

View File

@@ -251,20 +251,14 @@ class Invoice(models.Model):
raise ValueError('Every invoice needs to be connected to an order') raise ValueError('Every invoice needs to be connected to an order')
if not self.event: if not self.event:
self.event = self.order.event self.event = self.order.event
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'event'}.union(kwargs['update_fields'])
if not self.organizer: if not self.organizer:
self.organizer = self.order.event.organizer self.organizer = self.order.event.organizer
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'organizer'}.union(kwargs['update_fields'])
if not self.prefix: if not self.prefix:
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-') self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
if self.is_cancellation: if self.is_cancellation:
self.prefix = self.event.settings.invoice_numbers_prefix_cancellations or self.prefix self.prefix = self.event.settings.invoice_numbers_prefix_cancellations or self.prefix
if '%' in self.prefix: if '%' in self.prefix:
self.prefix = self.date.strftime(self.prefix) self.prefix = self.date.strftime(self.prefix)
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'prefix'}.union(kwargs['update_fields'])
if not self.invoice_no: if not self.invoice_no:
if self.order.testmode: if self.order.testmode:
@@ -282,13 +276,8 @@ class Invoice(models.Model):
# Suppress duplicate key errors and try again # Suppress duplicate key errors and try again
if i == 9: if i == 9:
raise raise
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'invoice_no'}.union(kwargs['update_fields'])
if self.full_invoice_no != self.prefix + self.invoice_no: self.full_invoice_no = self.prefix + self.invoice_no
self.full_invoice_no = self.prefix + self.invoice_no
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'full_invoice_no'}.union(kwargs['update_fields'])
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):

View File

@@ -40,10 +40,9 @@ from collections import Counter, OrderedDict
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from decimal import Decimal, DecimalException from decimal import Decimal, DecimalException
from typing import Optional, Tuple from typing import Optional, Tuple
from zoneinfo import ZoneInfo
import dateutil.parser import dateutil.parser
from dateutil.tz import datetime_exists import pytz
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import ( from django.core.validators import (
@@ -928,22 +927,22 @@ class Item(LoggedModel):
) )
if self.validity_dynamic_duration_days: if self.validity_dynamic_duration_days:
replace_date += timedelta(days=self.validity_dynamic_duration_days) replace_date += timedelta(days=self.validity_dynamic_duration_days)
valid_until = valid_until.replace( valid_until = tz.localize(valid_until.replace(
year=replace_date.year, year=replace_date.year,
month=replace_date.month, month=replace_date.month,
day=replace_date.day, day=replace_date.day,
hour=23, minute=59, second=59, microsecond=0, hour=23, minute=59, second=59, microsecond=0,
tzinfo=tz, tzinfo=None,
) ))
elif self.validity_dynamic_duration_days: elif self.validity_dynamic_duration_days:
replace_date = valid_until.date() + timedelta(days=self.validity_dynamic_duration_days - 1) replace_date = valid_until.date() + timedelta(days=self.validity_dynamic_duration_days - 1)
valid_until = valid_until.replace( valid_until = tz.localize(valid_until.replace(
year=replace_date.year, year=replace_date.year,
month=replace_date.month, month=replace_date.month,
day=replace_date.day, day=replace_date.day,
hour=23, minute=59, second=59, microsecond=0, hour=23, minute=59, second=59, microsecond=0,
tzinfo=tz tzinfo=None
) ))
if self.validity_dynamic_duration_hours: if self.validity_dynamic_duration_hours:
valid_until += timedelta(hours=self.validity_dynamic_duration_hours) valid_until += timedelta(hours=self.validity_dynamic_duration_hours)
@@ -951,9 +950,6 @@ class Item(LoggedModel):
if self.validity_dynamic_duration_minutes: if self.validity_dynamic_duration_minutes:
valid_until += timedelta(minutes=self.validity_dynamic_duration_minutes) valid_until += timedelta(minutes=self.validity_dynamic_duration_minutes)
if not datetime_exists(valid_until):
valid_until += timedelta(hours=1)
return requested_start, valid_until return requested_start, valid_until
else: else:
@@ -1593,8 +1589,6 @@ class Question(LoggedModel):
if not Question.objects.filter(event=self.event, identifier=code).exists(): if not Question.objects.filter(event=self.event, identifier=code).exists():
self.identifier = code self.identifier = code
break break
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'identifier'}.union(kwargs['update_fields'])
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.event: if self.event:
self.event.cache.clear() self.event.cache.clear()
@@ -1684,7 +1678,7 @@ class Question(LoggedModel):
try: try:
dt = dateutil.parser.parse(answer) dt = dateutil.parser.parse(answer)
if is_naive(dt): if is_naive(dt):
dt = make_aware(dt, ZoneInfo(self.event.settings.timezone)) dt = make_aware(dt, pytz.timezone(self.event.settings.timezone))
except: except:
raise ValidationError(_('Invalid datetime input.')) raise ValidationError(_('Invalid datetime input.'))
else: else:
@@ -1742,8 +1736,6 @@ class QuestionOption(models.Model):
if not QuestionOption.objects.filter(question__event=self.question.event, identifier=code).exists(): if not QuestionOption.objects.filter(question__event=self.question.event, identifier=code).exists():
self.identifier = code self.identifier = code
break break
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'identifier'}.union(kwargs['update_fields'])
super().save(*args, **kwargs) super().save(*args, **kwargs)
@staticmethod @staticmethod
@@ -2009,15 +2001,6 @@ class ItemMetaProperty(LoggedModel):
verbose_name=_("Name"), verbose_name=_("Name"),
) )
default = models.TextField(blank=True) default = models.TextField(blank=True)
required = models.BooleanField(
default=False, verbose_name=_("Required for products"),
help_text=_("If checked, this property must be set in each product. Does not apply if a default value is set.")
)
allowed_values = models.TextField(
null=True, blank=True,
verbose_name=_("Valid values"),
help_text=_("If you keep this empty, any value is allowed. Otherwise, enter one possible value per line.")
)
class Meta: class Meta:
ordering = ("name",) ordering = ("name",)

View File

@@ -31,7 +31,7 @@ from i18nfield.fields import I18nCharField
from pretix.base.models import Customer from pretix.base.models import Customer
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.models.organizer import Organizer from pretix.base.models.organizer import Organizer
from pretix.helpers.names import build_name from pretix.base.settings import PERSON_NAME_SCHEMES
class MembershipType(LoggedModel): class MembershipType(LoggedModel):
@@ -160,7 +160,15 @@ class Membership(models.Model):
@property @property
def attendee_name(self): def attendee_name(self):
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme) if not self.attendee_name_parts:
return None
if '_legacy' in self.attendee_name_parts:
return self.attendee_name_parts['_legacy']
if '_scheme' in self.attendee_name_parts:
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
else:
scheme = PERSON_NAME_SCHEMES[self.customer.organizer.settings.name_scheme]
return scheme['concatenation'](self.attendee_name_parts).strip()
def is_valid(self, ev=None): def is_valid(self, ev=None):
if ev: if ev:

View File

@@ -42,10 +42,10 @@ from collections import Counter
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Any, Dict, List, Union from typing import Any, Dict, List, Union
from zoneinfo import ZoneInfo
import dateutil import dateutil
import pycountry import pycountry
import pytz
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models, transaction from django.db import models, transaction
@@ -82,7 +82,6 @@ from pretix.base.signals import order_gracefully_delete
from ...helpers import OF_SELF from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import format_map from ...helpers.format import format_map
from ...helpers.names import build_name
from ._transactions import ( from ._transactions import (
_fail, _transactions_mark_order_clean, _transactions_mark_order_dirty, _fail, _transactions_mark_order_clean, _transactions_mark_order_dirty,
) )
@@ -461,20 +460,14 @@ class Order(LockModel, LoggedModel):
return '{event}-{code}'.format(event=self.event.slug.upper(), code=self.code) return '{event}-{code}'.format(event=self.event.slug.upper(), code=self.code)
def save(self, **kwargs): def save(self, **kwargs):
if 'update_fields' in kwargs: if 'update_fields' in kwargs and 'last_modified' not in kwargs['update_fields']:
kwargs['update_fields'] = {'last_modified'}.union(kwargs['update_fields']) kwargs['update_fields'] = list(kwargs['update_fields']) + ['last_modified']
if not self.code: if not self.code:
self.assign_code() self.assign_code()
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'code'}.union(kwargs['update_fields'])
if not self.datetime: if not self.datetime:
self.datetime = now() self.datetime = now()
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'datetime'}.union(kwargs['update_fields'])
if not self.expires: if not self.expires:
self.set_expires() self.set_expires()
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'expires'}.union(kwargs['update_fields'])
is_new = not self.pk is_new = not self.pk
update_fields = kwargs.get('update_fields', []) update_fields = kwargs.get('update_fields', [])
@@ -502,7 +495,7 @@ class Order(LockModel, LoggedModel):
def set_expires(self, now_dt=None, subevents=None): def set_expires(self, now_dt=None, subevents=None):
now_dt = now_dt or now() now_dt = now_dt or now()
tz = ZoneInfo(self.event.settings.timezone) tz = pytz.timezone(self.event.settings.timezone)
mode = self.event.settings.get('payment_term_mode') mode = self.event.settings.get('payment_term_mode')
if mode == 'days': if mode == 'days':
exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get('payment_term_days', as_type=int)) exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get('payment_term_days', as_type=int))
@@ -837,7 +830,7 @@ class Order(LockModel, LoggedModel):
@property @property
def is_expired_by_time(self): def is_expired_by_time(self):
return ( return (
self.status == Order.STATUS_PENDING and not self.require_approval and self.expires < now() self.status == Order.STATUS_PENDING and self.expires < now()
and not self.event.settings.get('payment_term_expire_automatically') and not self.event.settings.get('payment_term_expire_automatically')
) )
@@ -876,7 +869,7 @@ class Order(LockModel, LoggedModel):
@property @property
def payment_term_last(self): def payment_term_last(self):
tz = ZoneInfo(self.event.settings.timezone) tz = pytz.timezone(self.event.settings.timezone)
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last: if term_last:
if self.event.has_subevents: if self.event.has_subevents:
@@ -1236,7 +1229,7 @@ class QuestionAnswer(models.Model):
try: try:
d = dateutil.parser.parse(self.answer) d = dateutil.parser.parse(self.answer)
if self.orderposition: if self.orderposition:
tz = ZoneInfo(self.orderposition.order.event.settings.timezone) tz = pytz.timezone(self.orderposition.order.event.settings.timezone)
d = d.astimezone(tz) d = d.astimezone(tz)
return date_format(d, "SHORT_DATETIME_FORMAT") return date_format(d, "SHORT_DATETIME_FORMAT")
except ValueError: except ValueError:
@@ -1448,29 +1441,25 @@ class AbstractPosition(models.Model):
else self.variation.quotas.filter(subevent=self.subevent)) else self.variation.quotas.filter(subevent=self.subevent))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields', set()) update_fields = kwargs.get('update_fields', [])
if 'attendee_name_parts' in update_fields: if 'attendee_name_parts' in update_fields:
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields']) update_fields.append('attendee_name_cached')
self.attendee_name_cached = self.attendee_name
name = self.attendee_name
if name != self.attendee_name_cached:
self.attendee_name_cached = name
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
if self.attendee_name_parts is None: if self.attendee_name_parts is None:
self.attendee_name_parts = {} self.attendee_name_parts = {}
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'attendee_name_parts'}.union(kwargs['update_fields'])
super().save(*args, **kwargs) super().save(*args, **kwargs)
@property @property
def attendee_name(self): def attendee_name(self):
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.event.settings.name_scheme) if not self.attendee_name_parts:
return None
@property if '_legacy' in self.attendee_name_parts:
def attendee_name_all_components(self): return self.attendee_name_parts['_legacy']
return build_name(self.attendee_name_parts, "concatenation_all_components", fallback_scheme=lambda: self.event.settings.name_scheme) if '_scheme' in self.attendee_name_parts:
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
else:
scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
return scheme['concatenation'](self.attendee_name_parts).strip()
@property @property
def state_name(self): def state_name(self):
@@ -1841,8 +1830,6 @@ class OrderPayment(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.local_id: if not self.local_id:
self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1 self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'local_id'}.union(kwargs['update_fields'])
super().save(*args, **kwargs) super().save(*args, **kwargs)
def create_external_refund(self, amount=None, execution_date=None, info='{}'): def create_external_refund(self, amount=None, execution_date=None, info='{}'):
@@ -2041,8 +2028,6 @@ class OrderRefund(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.local_id: if not self.local_id:
self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1 self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'local_id'}.union(kwargs['update_fields'])
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -2461,20 +2446,14 @@ class OrderPosition(AbstractPosition):
assign_ticket_secret( assign_ticket_secret(
event=self.order.event, position=self, force_invalidate=True, save=False event=self.order.event, position=self, force_invalidate=True, save=False
) )
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'secret'}.union(kwargs['update_fields'])
if not self.blocked and self.blocked is not None: if not self.blocked:
self.blocked = None self.blocked = None
if 'update_fields' in kwargs: elif not isinstance(self.blocked, list) or any(not isinstance(b, str) for b in self.blocked):
kwargs['update_fields'] = {'blocked'}.union(kwargs['update_fields'])
elif self.blocked and (not isinstance(self.blocked, list) or any(not isinstance(b, str) for b in self.blocked)):
raise TypeError("blocked needs to be a list of strings") raise TypeError("blocked needs to be a list of strings")
if not self.pseudonymization_id: if not self.pseudonymization_id:
self.assign_pseudonymization_id() self.assign_pseudonymization_id()
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'pseudonymization_id'}.union(kwargs['update_fields'])
if not self.get_deferred_fields(): if not self.get_deferred_fields():
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk: if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
@@ -2855,12 +2834,8 @@ class CartPosition(AbstractPosition):
if self.is_bundled: if self.is_bundled:
bundle = self.addon_to.item.bundles.filter(bundled_item=self.item, bundled_variation=self.variation).first() bundle = self.addon_to.item.bundles.filter(bundled_item=self.item, bundled_variation=self.variation).first()
if bundle: if bundle:
if self.addon_to.voucher_id and self.addon_to.voucher.all_bundles_included: listed_price = bundle.designated_price
listed_price = Decimal('0.00') price_after_voucher = bundle.designated_price
price_after_voucher = Decimal('0.00')
else:
listed_price = bundle.designated_price
price_after_voucher = bundle.designated_price
if listed_price != self.listed_price or price_after_voucher != self.price_after_voucher: if listed_price != self.listed_price or price_after_voucher != self.price_after_voucher:
self.listed_price = listed_price self.listed_price = listed_price
@@ -2960,17 +2935,10 @@ class InvoiceAddress(models.Model):
self.order.touch() self.order.touch()
if self.name_parts: if self.name_parts:
name = self.name self.name_cached = self.name
if self.name_cached != name:
self.name_cached = self.name
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'name_cached'}.union(kwargs['update_fields'])
else: else:
if self.name_cached != "" or self.name_parts != {}: self.name_cached = ""
self.name_cached = "" self.name_parts = {}
self.name_parts = {}
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'name_cached', 'name_parts'}.union(kwargs['update_fields'])
super().save(**kwargs) super().save(**kwargs)
def describe(self): def describe(self):
@@ -3012,11 +2980,15 @@ class InvoiceAddress(models.Model):
@property @property
def name(self): def name(self):
return build_name(self.name_parts, fallback_scheme=lambda: self.order.event.settings.name_scheme) or "" if not self.name_parts:
return ""
@property if '_legacy' in self.name_parts:
def name_all_components(self): return self.name_parts['_legacy']
return build_name(self.name_parts, "concatenation_all_components", fallback_scheme=lambda: self.order.event.settings.name_scheme) or "" if '_scheme' in self.name_parts:
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
else:
raise TypeError("Invalid name given.")
return scheme['concatenation'](self.name_parts).strip()
def for_js(self): def for_js(self):
d = {} d = {}
@@ -3116,7 +3088,11 @@ class BlockedTicketSecret(models.Model):
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
class Meta: class Meta:
unique_together = (('event', 'secret'),) if 'mysql' not in settings.DATABASES['default']['ENGINE']:
# MySQL does not support indexes on TextField(). Django knows this and just ignores db_index, but it will
# not silently ignore the UNIQUE index, causing this table to fail. I'm so glad we're deprecating MySQL
# in a few months, so we'll just live without an unique index until then.
unique_together = (('event', 'secret'),)
@receiver(post_delete, sender=CachedTicket) @receiver(post_delete, sender=CachedTicket)

View File

@@ -35,12 +35,12 @@
import string import string
from datetime import date, datetime, time from datetime import date, datetime, time
import pytz_deprecation_shim import pytz
from django.conf import settings from django.conf import settings
from django.core.mail import get_connection from django.core.mail import get_connection
from django.core.validators import MinLengthValidator, RegexValidator from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Exists, OuterRef, Q
from django.urls import reverse from django.urls import reverse
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -102,7 +102,6 @@ class Organizer(LoggedModel):
is_new = not self.pk is_new = not self.pk
obj = super().save(*args, **kwargs) obj = super().save(*args, **kwargs)
if is_new: if is_new:
kwargs.pop('update_fields', None) # does not make sense here
self.set_defaults() self.set_defaults()
else: else:
self.get_cache().clear() self.get_cache().clear()
@@ -141,7 +140,7 @@ class Organizer(LoggedModel):
@property @property
def timezone(self): def timezone(self):
return pytz_deprecation_shim.timezone(self.settings.timezone) return pytz.timezone(self.settings.timezone)
@cached_property @cached_property
def all_logentries_link(self): def all_logentries_link(self):
@@ -157,19 +156,17 @@ class Organizer(LoggedModel):
return self.cache.get_or_set( return self.cache.get_or_set(
key='has_gift_cards', key='has_gift_cards',
timeout=15, timeout=15,
default=lambda: self.issued_gift_cards.exists() or self.gift_card_issuer_acceptance.filter(active=True).exists() default=lambda: self.issued_gift_cards.exists() or self.gift_card_issuer_acceptance.exists()
) )
@property @property
def accepted_gift_cards(self): def accepted_gift_cards(self):
from .giftcards import GiftCard, GiftCardAcceptance from .giftcards import GiftCard, GiftCardAcceptance
return GiftCard.objects.filter( return GiftCard.objects.annotate(
Q(issuer=self) | accepted=Exists(GiftCardAcceptance.objects.filter(issuer=OuterRef('issuer'), collector=self))
Q(issuer__in=GiftCardAcceptance.objects.filter( ).filter(
acceptor=self, Q(issuer=self) | Q(accepted=True)
active=True,
).values_list('issuer', flat=True))
) )
@property @property

View File

@@ -22,12 +22,9 @@
import json import json
from decimal import Decimal from decimal import Decimal
import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.deconstruct import deconstructible
from django.utils.formats import localize from django.utils.formats import localize
from django.utils.translation import gettext_lazy as _, pgettext from django.utils.translation import gettext_lazy as _, pgettext
from i18nfield.fields import I18nCharField from i18nfield.fields import I18nCharField
@@ -138,25 +135,6 @@ def cc_to_vat_prefix(country_code):
return country_code return country_code
@deconstructible
class CustomRulesValidator:
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/tax-rules-custom.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 set of rules is not valid. Error message: {}').format(e))
class TaxRule(LoggedModel): class TaxRule(LoggedModel):
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE) event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
internal_name = models.CharField( internal_name = models.CharField(

View File

@@ -296,14 +296,6 @@ class Voucher(LoggedModel):
verbose_name=_("Shows hidden products that match this voucher"), verbose_name=_("Shows hidden products that match this voucher"),
default=True default=True
) )
all_addons_included = models.BooleanField(
verbose_name=_("Offer all add-on products for free when redeeming this voucher"),
default=False
)
all_bundles_included = models.BooleanField(
verbose_name=_("Include all bundled products without a designated price when redeeming this voucher"),
default=False
)
objects = ScopedManager(organizer='event__organizer') objects = ScopedManager(organizer='event__organizer')
@@ -502,10 +494,7 @@ class Voucher(LoggedModel):
return seat return seat
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.code != self.code.upper(): self.code = self.code.upper()
self.code = self.code.upper()
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'code'}.union(kwargs['update_fields'])
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.event.cache.set('vouchers_exist', True) self.event.cache.set('vouchers_exist', True)

View File

@@ -35,9 +35,9 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import User, Voucher from pretix.base.models import User, Voucher
from pretix.base.services.mail import SendMailException, mail, render_mail 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 ...helpers.format import format_map
from ...helpers.names import build_name
from .base import LoggedModel from .base import LoggedModel
from .event import Event, SubEvent from .event import Event, SubEvent
from .items import Item, ItemVariation from .items import Item, ItemVariation
@@ -119,35 +119,32 @@ class WaitingListEntry(LoggedModel):
def clean(self): def clean(self):
try: try:
WaitingListEntry.clean_duplicate(self.event, self.email, self.item, self.variation, self.subevent, self.pk) WaitingListEntry.clean_duplicate(self.email, self.item, self.variation, self.subevent, self.pk)
WaitingListEntry.clean_itemvar(self.event, self.item, self.variation) WaitingListEntry.clean_itemvar(self.event, self.item, self.variation)
WaitingListEntry.clean_subevent(self.event, self.subevent) WaitingListEntry.clean_subevent(self.event, self.subevent)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise ValidationError('Invalid input') raise ValidationError('Invalid input')
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields', set()) update_fields = kwargs.get('update_fields', [])
if 'name_parts' in update_fields: if 'name_parts' in update_fields:
kwargs['update_fields'] = {'name_cached'}.union(kwargs['update_fields']) update_fields.append('name_cached')
name = self.name self.name_cached = self.name
if name != self.name_cached:
self.name_cached = name
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'name_cached'}.union(kwargs['update_fields'])
if self.name_parts is None: if self.name_parts is None:
self.name_parts = {} self.name_parts = {}
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'name_parts'}.union(kwargs['update_fields'])
super().save(*args, **kwargs) super().save(*args, **kwargs)
@property @property
def name(self): def name(self):
return build_name(self.name_parts, fallback_scheme=lambda: self.event.settings.name_scheme) if not self.name_parts:
return None
@property if '_legacy' in self.name_parts:
def name_all_components(self): return self.name_parts['_legacy']
return build_name(self.name_parts, "concatenation_all_components", fallback_scheme=lambda: self.event.settings.name_scheme) if '_scheme' in self.name_parts:
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
else:
scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
return scheme['concatenation'](self.name_parts).strip()
def send_voucher(self, quota_cache=None, user=None, auth=None): def send_voucher(self, quota_cache=None, user=None, auth=None):
availability = ( availability = (
@@ -218,7 +215,7 @@ class WaitingListEntry(LoggedModel):
'waitinglistentry': self.pk, 'waitinglistentry': self.pk,
'subevent': self.subevent.pk if self.subevent else None, 'subevent': self.subevent.pk if self.subevent else None,
}, user=user, auth=auth) }, user=user, auth=auth)
self.log_action('pretix.event.orders.waitinglist.voucher_assigned', user=user, auth=auth) self.log_action('pretix.waitinglist.voucher', user=user, auth=auth)
self.voucher = v self.voucher = v
self.save() self.save()
@@ -311,9 +308,9 @@ class WaitingListEntry(LoggedModel):
raise ValidationError(_('The subevent does not belong to this event.')) raise ValidationError(_('The subevent does not belong to this event.'))
@staticmethod @staticmethod
def clean_duplicate(event, email, item, variation, subevent, pk): def clean_duplicate(email, item, variation, subevent, pk):
if WaitingListEntry.objects.filter( if WaitingListEntry.objects.filter(
item=item, variation=variation, email__iexact=email, voucher__isnull=True, subevent=subevent item=item, variation=variation, email__iexact=email, voucher__isnull=True, subevent=subevent
).exclude(pk=pk).count() >= event.settings.waiting_list_limit_per_user: ).exclude(pk=pk).exists():
raise ValidationError(_('You are already on this waiting list! We will notify ' raise ValidationError(_('You are already on this waiting list! We will notify '
'you as soon as we have a ticket available for you.')) 'you as soon as we have a ticket available for you.'))

View File

@@ -210,7 +210,7 @@ class SubeventColumn(ImportColumn):
for format in input_formats: for format in input_formats:
try: try:
d = datetime.datetime.strptime(value, format) d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone) d = self.event.timezone.localize(d)
try: try:
se = self.event.subevents.get( se = self.event.subevents.get(
active=True, active=True,
@@ -660,7 +660,7 @@ class ValidFrom(ImportColumn):
for format in input_formats: for format in input_formats:
try: try:
d = datetime.datetime.strptime(value, format) d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone) d = self.event.timezone.localize(d)
return d return d
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@@ -683,7 +683,7 @@ class ValidUntil(ImportColumn):
for format in input_formats: for format in input_formats:
try: try:
d = datetime.datetime.strptime(value, format) d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.event.timezone) d = self.event.timezone.localize(d)
return d return d
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass

View File

@@ -39,8 +39,8 @@ import logging
from collections import OrderedDict from collections import OrderedDict
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Union from typing import Any, Dict, Union
from zoneinfo import ZoneInfo
import pytz
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@@ -518,7 +518,7 @@ class BasePaymentProvider:
def _is_still_available(self, now_dt=None, cart_id=None, order=None): def _is_still_available(self, now_dt=None, cart_id=None, order=None):
now_dt = now_dt or now() now_dt = now_dt or now()
tz = ZoneInfo(self.event.settings.timezone) tz = pytz.timezone(self.event.settings.timezone)
availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper) availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper)
if availability_date: if availability_date:
@@ -1469,8 +1469,7 @@ class GiftCardPayment(BasePaymentProvider):
trans = gc.transactions.create( trans = gc.transactions.create(
value=-1 * payment.amount, value=-1 * payment.amount,
order=payment.order, order=payment.order,
payment=payment, payment=payment
acceptor=self.event.organizer,
) )
payment.info_data = { payment.info_data = {
'gift_card': gc.pk, 'gift_card': gc.pk,
@@ -1491,8 +1490,7 @@ class GiftCardPayment(BasePaymentProvider):
trans = gc.transactions.create( trans = gc.transactions.create(
value=refund.amount, value=refund.amount,
order=refund.order, order=refund.order,
refund=refund, refund=refund
acceptor=self.event.organizer,
) )
refund.info_data = { refund.info_data = {
'gift_card': gc.pk, 'gift_card': gc.pk,

View File

@@ -48,6 +48,7 @@ from functools import partial
from io import BytesIO from io import BytesIO
import jsonschema import jsonschema
from arabic_reshaper import ArabicReshaper
from bidi.algorithm import get_display from bidi.algorithm import get_display
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
@@ -56,11 +57,13 @@ from django.db.models import Max, Min
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import SimpleLazyObject
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext from django.utils.translation import gettext_lazy as _, pgettext
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pypdf import PdfReader from pypdf import PdfReader
from pytz import timezone
from reportlab.graphics import renderPDF from reportlab.graphics import renderPDF
from reportlab.graphics.barcode.qr import QrCodeWidget from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.graphics.shapes import Drawing from reportlab.graphics.shapes import Drawing
@@ -75,12 +78,12 @@ from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph from reportlab.platypus import Paragraph
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.invoice import ThumbnailingImageReader
from pretix.base.models import Order, OrderPosition, Question from pretix.base.models import Order, OrderPosition, Question
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format from pretix.base.templatetags.phone_format import phone_format
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -236,7 +239,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Event begin date and time"), "label": _("Event begin date and time"),
"editor_sample": _("2017-05-31 20:00"), "editor_sample": _("2017-05-31 20:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
ev.date_from.astimezone(ev.timezone), ev.date_from.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT" "SHORT_DATETIME_FORMAT"
) if ev.date_from else "" ) if ev.date_from else ""
}), }),
@@ -244,7 +247,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Event begin date"), "label": _("Event begin date"),
"editor_sample": _("2017-05-31"), "editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
ev.date_from.astimezone(ev.timezone), ev.date_from.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATE_FORMAT" "SHORT_DATE_FORMAT"
) if ev.date_from else "" ) if ev.date_from else ""
}), }),
@@ -262,7 +265,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Event end date and time"), "label": _("Event end date and time"),
"editor_sample": _("2017-05-31 22:00"), "editor_sample": _("2017-05-31 22:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
ev.date_to.astimezone(ev.timezone), ev.date_to.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT" "SHORT_DATETIME_FORMAT"
) if ev.date_to else "" ) if ev.date_to else ""
}), }),
@@ -270,7 +273,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Event end date"), "label": _("Event end date"),
"editor_sample": _("2017-05-31"), "editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
ev.date_to.astimezone(ev.timezone), ev.date_to.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATE_FORMAT" "SHORT_DATE_FORMAT"
) if ev.date_to else "" ) if ev.date_to else ""
}), }),
@@ -278,7 +281,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Event end time"), "label": _("Event end time"),
"editor_sample": _("22:00"), "editor_sample": _("22:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
ev.date_to.astimezone(ev.timezone), ev.date_to.astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT" "TIME_FORMAT"
) if ev.date_to else "" ) if ev.date_to else ""
}), }),
@@ -291,7 +294,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Event admission date and time"), "label": _("Event admission date and time"),
"editor_sample": _("2017-05-31 19:00"), "editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
ev.date_admission.astimezone(ev.timezone), ev.date_admission.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT" "SHORT_DATETIME_FORMAT"
) if ev.date_admission else "" ) if ev.date_admission else ""
}), }),
@@ -299,7 +302,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Event admission time"), "label": _("Event admission time"),
"editor_sample": _("19:00"), "editor_sample": _("19:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
ev.date_admission.astimezone(ev.timezone), ev.date_admission.astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT" "TIME_FORMAT"
) if ev.date_admission else "" ) if ev.date_admission else ""
}), }),
@@ -384,7 +387,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Printing date"), "label": _("Printing date"),
"editor_sample": _("2017-05-31"), "editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
now().astimezone(ev.timezone), now().astimezone(timezone(ev.settings.timezone)),
"SHORT_DATE_FORMAT" "SHORT_DATE_FORMAT"
) )
}), }),
@@ -392,7 +395,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Printing date and time"), "label": _("Printing date and time"),
"editor_sample": _("2017-05-31 19:00"), "editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
now().astimezone(ev.timezone), now().astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT" "SHORT_DATETIME_FORMAT"
) )
}), }),
@@ -400,7 +403,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Printing time"), "label": _("Printing time"),
"editor_sample": _("19:00"), "editor_sample": _("19:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
now().astimezone(ev.timezone), now().astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT" "TIME_FORMAT"
) )
}), }),
@@ -408,7 +411,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Validity start date"), "label": _("Validity start date"),
"editor_sample": _("2017-05-31"), "editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
op.valid_from.astimezone(ev.timezone), op.valid_from.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATE_FORMAT" "SHORT_DATE_FORMAT"
) if op.valid_from else "" ) if op.valid_from else ""
}), }),
@@ -416,7 +419,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Validity start date and time"), "label": _("Validity start date and time"),
"editor_sample": _("2017-05-31 19:00"), "editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
op.valid_from.astimezone(ev.timezone), op.valid_from.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT" "SHORT_DATETIME_FORMAT"
) if op.valid_from else "" ) if op.valid_from else ""
}), }),
@@ -424,7 +427,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Validity start time"), "label": _("Validity start time"),
"editor_sample": _("19:00"), "editor_sample": _("19:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
op.valid_from.astimezone(ev.timezone), op.valid_from.astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT" "TIME_FORMAT"
) if op.valid_from else "" ) if op.valid_from else ""
}), }),
@@ -432,7 +435,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Validity end date"), "label": _("Validity end date"),
"editor_sample": _("2017-05-31"), "editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
op.valid_until.astimezone(ev.timezone), op.valid_until.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATE_FORMAT" "SHORT_DATE_FORMAT"
) if op.valid_until else "" ) if op.valid_until else ""
}), }),
@@ -440,7 +443,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Validity end date and time"), "label": _("Validity end date and time"),
"editor_sample": _("2017-05-31 19:00"), "editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
op.valid_until.astimezone(ev.timezone), op.valid_until.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT" "SHORT_DATETIME_FORMAT"
) if op.valid_until else "" ) if op.valid_until else ""
}), }),
@@ -448,7 +451,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("Validity end time"), "label": _("Validity end time"),
"editor_sample": _("19:00"), "editor_sample": _("19:00"),
"evaluate": lambda op, order, ev: date_format( "evaluate": lambda op, order, ev: date_format(
op.valid_until.astimezone(ev.timezone), op.valid_until.astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT" "TIME_FORMAT"
) if op.valid_until else "" ) if op.valid_until else ""
}), }),
@@ -696,6 +699,12 @@ def get_seat(op: OrderPosition):
return None return None
reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
'delete_harakat': True,
'support_ligatures': False,
}))
class Renderer: class Renderer:
def __init__(self, event, layout, background_file): def __init__(self, event, layout, background_file):

View File

@@ -22,8 +22,8 @@
import datetime import datetime
from collections import namedtuple from collections import namedtuple
from typing import Union from typing import Union
from zoneinfo import ZoneInfo
import pytz
from dateutil import parser from dateutil import parser
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -67,7 +67,7 @@ class RelativeDateWrapper:
if self.data.minutes_before is not None: if self.data.minutes_before is not None:
raise ValueError('A minute-based relative datetime can not be used as a date') raise ValueError('A minute-based relative datetime can not be used as a date')
tz = ZoneInfo(event.settings.timezone) tz = pytz.timezone(event.settings.timezone)
if isinstance(event, SubEvent): if isinstance(event, SubEvent):
base_date = ( base_date = (
getattr(event, self.data.base_date_name) getattr(event, self.data.base_date_name)
@@ -86,7 +86,7 @@ class RelativeDateWrapper:
if isinstance(self.data, (datetime.datetime, datetime.date)): if isinstance(self.data, (datetime.datetime, datetime.date)):
return self.data return self.data
else: else:
tz = ZoneInfo(event.settings.timezone) tz = pytz.timezone(event.settings.timezone)
if isinstance(event, SubEvent): if isinstance(event, SubEvent):
base_date = ( base_date = (
getattr(event, self.data.base_date_name) getattr(event, self.data.base_date_name)
@@ -99,7 +99,8 @@ class RelativeDateWrapper:
if self.data.minutes_before is not None: if self.data.minutes_before is not None:
return base_date.astimezone(tz) - datetime.timedelta(minutes=self.data.minutes_before) return base_date.astimezone(tz) - datetime.timedelta(minutes=self.data.minutes_before)
else: else:
new_date = (base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)).astimezone(tz) oldoffset = base_date.astimezone(tz).utcoffset()
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)
if self.data.time: if self.data.time:
new_date = new_date.replace( new_date = new_date.replace(
hour=self.data.time.hour, hour=self.data.time.hour,
@@ -107,6 +108,8 @@ class RelativeDateWrapper:
second=self.data.time.second second=self.data.time.second
) )
new_date = new_date.astimezone(tz) new_date = new_date.astimezone(tz)
new_offset = new_date.utcoffset()
new_date += oldoffset - new_offset
return new_date return new_date
def to_string(self) -> str: def to_string(self) -> str:

View File

@@ -512,10 +512,7 @@ class CartManager:
if cp.is_bundled: if cp.is_bundled:
bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first() bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first()
if bundle: if bundle:
if cp.addon_to.voucher_id and cp.addon_to.voucher.all_bundles_included: listed_price = bundle.designated_price or Decimal('0.00')
listed_price = Decimal('0.00')
else:
listed_price = bundle.designated_price
else: else:
listed_price = cp.price listed_price = cp.price
price_after_voucher = listed_price price_after_voucher = listed_price
@@ -715,11 +712,6 @@ class CartManager:
else: else:
bundle_quotas = [] bundle_quotas = []
if voucher and voucher.all_bundles_included:
bundled_price = Decimal('0.00')
else:
bundled_price = bundle.designated_price
bop = self.AddOperation( bop = self.AddOperation(
count=bundle.count, count=bundle.count,
item=bitem, item=bitem,
@@ -730,8 +722,8 @@ class CartManager:
subevent=subevent, subevent=subevent,
bundled=[], bundled=[],
seat=None, seat=None,
listed_price=bundled_price, listed_price=bundle.designated_price,
price_after_voucher=bundled_price, price_after_voucher=bundle.designated_price,
custom_price_input=None, custom_price_input=None,
custom_price_input_is_net=False, custom_price_input_is_net=False,
voucher_ignored=False, voucher_ignored=False,
@@ -817,6 +809,7 @@ class CartManager:
quota_diff = Counter() # Quota -> Number of usages quota_diff = Counter() # Quota -> Number of usages
operations = [] operations = []
available_categories = defaultdict(set) # CartPos -> Category IDs to choose from available_categories = defaultdict(set) # CartPos -> Category IDs to choose from
price_included = defaultdict(dict) # CartPos -> CategoryID -> bool(price is included)
toplevel_cp = self.positions.filter( toplevel_cp = self.positions.filter(
addon_to__isnull=True addon_to__isnull=True
).prefetch_related( ).prefetch_related(
@@ -826,6 +819,7 @@ class CartManager:
# Prefill some of the cache containers # Prefill some of the cache containers
for cp in toplevel_cp: for cp in toplevel_cp:
available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()} available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()}
price_included[cp.pk] = {iao.addon_category_id: iao.price_included for iao in cp.item.addons.all()}
cpcache[cp.pk] = cp cpcache[cp.pk] = cp
for a in cp.addons.all(): for a in cp.addons.all():
if not a.is_bundled: if not a.is_bundled:

View File

@@ -32,12 +32,12 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # 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. # License for the specific language governing permissions and limitations under the License.
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from functools import partial, reduce from functools import partial, reduce
import dateutil import dateutil
import dateutil.parser import dateutil.parser
from dateutil.tz import datetime_exists import pytz
from django.core.files import File from django.core.files import File
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import ( from django.db.models import (
@@ -53,8 +53,7 @@ from django.utils.translation import gettext as _
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
from pretix.base.models import ( from pretix.base.models import (
Checkin, CheckinList, Device, Event, ItemVariation, Order, OrderPosition, Checkin, CheckinList, Device, Order, OrderPosition, QuestionOption,
QuestionOption,
) )
from pretix.base.signals import checkin_created, order_placed, periodic_task from pretix.base.signals import checkin_created, order_placed, periodic_task
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
@@ -66,13 +65,12 @@ from pretix.helpers.jsonlogic_query import (
) )
def _build_time(t=None, value=None, ev=None, now_dt=None): def _build_time(t=None, value=None, ev=None):
now_dt = now_dt or now()
if t == "custom": if t == "custom":
return dateutil.parser.parse(value) return dateutil.parser.parse(value)
elif t == "customtime": elif t == "customtime":
parsed = dateutil.parser.parse(value) parsed = dateutil.parser.parse(value)
return now_dt.astimezone(ev.timezone).replace( return now().astimezone(ev.timezone).replace(
hour=parsed.hour, hour=parsed.hour,
minute=parsed.minute, minute=parsed.minute,
second=parsed.second, second=parsed.second,
@@ -86,42 +84,7 @@ def _build_time(t=None, value=None, ev=None, now_dt=None):
return ev.date_admission or ev.date_from return ev.date_admission or ev.date_from
def _logic_annotate_for_graphic_explain(rules, ev, rule_data): def _logic_explain(rules, ev, rule_data):
logic_environment = _get_logic_environment(ev)
event = ev if isinstance(ev, Event) else ev.event
def _evaluate_inners(r):
if not isinstance(r, dict):
return r
operator = list(r.keys())[0]
values = r[operator]
if operator in ("and", "or"):
return {operator: [_evaluate_inners(v) for v in values]}
result = logic_environment.apply(r, rule_data)
return {**r, '__result': result}
def _add_var_values(r):
if not isinstance(r, dict):
return r
operator = [k for k in r.keys() if not k.startswith("__")][0]
values = r[operator]
if operator == "var":
var = values[0] if isinstance(values, list) else values
val = rule_data[var]
if var == "product":
val = str(event.items.get(pk=val))
elif var == "variation":
val = str(ItemVariation.objects.get(item__event=event, pk=val))
elif isinstance(val, datetime):
val = date_format(val.astimezone(ev.timezone), "SHORT_DATETIME_FORMAT")
return {"var": var, "__result": val}
else:
return {**r, operator: [_add_var_values(v) for v in values]}
return _add_var_values(_evaluate_inners(rules))
def _logic_explain(rules, ev, rule_data, now_dt=None):
""" """
Explains when the logic denied the check-in. Only works for a denied check-in. Explains when the logic denied the check-in. Only works for a denied check-in.
@@ -151,7 +114,6 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
Additionally, we favor a "close failure". Therefore, in the above example, we'd show "You can only Additionally, we favor a "close failure". Therefore, in the above example, we'd show "You can only
get in before 17:00". In the middle of the night it would switch to "You can only get in after 09:00". get in before 17:00". In the middle of the night it would switch to "You can only get in after 09:00".
""" """
now_dt = now_dt or now()
logic_environment = _get_logic_environment(ev) logic_environment = _get_logic_environment(ev)
_var_values = {'False': False, 'True': True} _var_values = {'False': False, 'True': True}
_var_explanations = {} _var_explanations = {}
@@ -236,9 +198,9 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
else: else:
compare_to -= tolerance compare_to -= tolerance
var_weights[vname] = (200, abs(now_dt - compare_to).total_seconds()) var_weights[vname] = (200, abs(now() - compare_to).total_seconds())
if abs(now_dt - compare_to) < timedelta(hours=12): if abs(now() - compare_to) < timedelta(hours=12):
compare_to_text = date_format(compare_to, 'TIME_FORMAT') compare_to_text = date_format(compare_to, 'TIME_FORMAT')
else: else:
compare_to_text = date_format(compare_to, 'SHORT_DATETIME_FORMAT') compare_to_text = date_format(compare_to, 'SHORT_DATETIME_FORMAT')
@@ -395,7 +357,7 @@ class LazyRuleVars:
@cached_property @cached_property
def entries_today(self): def entries_today(self):
tz = self._clist.event.timezone tz = self._clist.event.timezone
midnight = self._dt.astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0) midnight = now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
return self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__gte=midnight).count() return self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__gte=midnight).count()
@cached_property @cached_property
@@ -416,7 +378,7 @@ class LazyRuleVars:
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least # between platforms (None<1 is true on some, but not all), we rather choose something that is at least
# consistent. # consistent.
return -1 return -1
return (self._dt - last_entry.datetime).total_seconds() // 60 return (now() - last_entry.datetime).total_seconds() // 60
@cached_property @cached_property
def minutes_since_first_entry(self): def minutes_since_first_entry(self):
@@ -428,7 +390,7 @@ class LazyRuleVars:
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least # between platforms (None<1 is true on some, but not all), we rather choose something that is at least
# consistent. # consistent.
return -1 return -1
return (self._dt - last_entry.datetime).total_seconds() // 60 return (now() - last_entry.datetime).total_seconds() // 60
class SQLLogic: class SQLLogic:
@@ -477,7 +439,7 @@ class SQLLogic:
if operator == 'buildTime': if operator == 'buildTime':
if values[0] == "custom": if values[0] == "custom":
return Value(dateutil.parser.parse(values[1]).astimezone(timezone.utc)) return Value(dateutil.parser.parse(values[1]).astimezone(pytz.UTC))
elif values[0] == "customtime": elif values[0] == "customtime":
parsed = dateutil.parser.parse(values[1]) parsed = dateutil.parser.parse(values[1])
return Value(now().astimezone(self.list.event.timezone).replace( return Value(now().astimezone(self.list.event.timezone).replace(
@@ -485,7 +447,7 @@ class SQLLogic:
minute=parsed.minute, minute=parsed.minute,
second=parsed.second, second=parsed.second,
microsecond=parsed.microsecond, microsecond=parsed.microsecond,
).astimezone(timezone.utc)) ).astimezone(pytz.UTC))
elif values[0] == 'date_from': elif values[0] == 'date_from':
return Coalesce( return Coalesce(
F('subevent__date_from'), F('subevent__date_from'),
@@ -513,7 +475,7 @@ class SQLLogic:
return int(values[1]) return int(values[1])
elif operator == 'var': elif operator == 'var':
if values[0] == 'now': if values[0] == 'now':
return Value(now().astimezone(timezone.utc)) return Value(now().astimezone(pytz.UTC))
elif values[0] == 'now_isoweekday': elif values[0] == 'now_isoweekday':
return Value(now().astimezone(self.list.event.timezone).isoweekday()) return Value(now().astimezone(self.list.event.timezone).isoweekday())
elif values[0] == 'product': elif values[0] == 'product':
@@ -731,7 +693,7 @@ def _save_answers(op, answers, given_answers):
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False, def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False): raw_barcode=None, raw_source_type=None, from_revoked_secret=False):
""" """
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time. not valid at this time.
@@ -745,7 +707,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
:param questions_supported: When set to False, questions are ignored :param questions_supported: When set to False, questions are ignored
:param nonce: A random nonce to prevent race conditions. :param nonce: A random nonce to prevent race conditions.
:param datetime: The datetime of the checkin, defaults to now. :param datetime: The datetime of the checkin, defaults to now.
:param simulate: If true, the check-in is not saved.
""" """
# !!!!!!!!! # !!!!!!!!!
@@ -773,7 +734,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'blocked' 'blocked'
) )
if type != Checkin.TYPE_EXIT and op.valid_from and op.valid_from > dt: if type != Checkin.TYPE_EXIT and op.valid_from and op.valid_from > now():
if force: if force:
force_used = True force_used = True
else: else:
@@ -787,7 +748,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
), ),
) )
if type != Checkin.TYPE_EXIT and op.valid_until and op.valid_until < dt: if type != Checkin.TYPE_EXIT and op.valid_until and op.valid_until < now():
if force: if force:
force_used = True force_used = True
else: else:
@@ -812,8 +773,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
if q not in given_answers and q not in answers: if q not in given_answers and q not in answers:
require_answers.append(q) require_answers.append(q)
if not simulate: _save_answers(op, answers, given_answers)
_save_answers(op, answers, given_answers)
with transaction.atomic(): with transaction.atomic():
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic # Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
@@ -899,33 +859,30 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
return return
if entry_allowed or force: if entry_allowed or force:
if simulate: ci = Checkin.objects.create(
return True position=op,
else: type=type,
ci = Checkin.objects.create( list=clist,
position=op, datetime=dt,
type=type, device=device,
list=clist, gate=device.gate if device else None,
datetime=dt, nonce=nonce,
device=device, forced=force and (not entry_allowed or from_revoked_secret or force_used),
gate=device.gate if device else None, force_sent=force,
nonce=nonce, raw_barcode=raw_barcode,
forced=force and (not entry_allowed or from_revoked_secret or force_used), raw_source_type=raw_source_type,
force_sent=force, )
raw_barcode=raw_barcode, op.order.log_action('pretix.event.checkin', data={
raw_source_type=raw_source_type, 'position': op.id,
) 'positionid': op.positionid,
op.order.log_action('pretix.event.checkin', data={ 'first': True,
'position': op.id, 'forced': force or op.order.status != Order.STATUS_PAID,
'positionid': op.positionid, 'datetime': dt,
'first': True, 'type': type,
'forced': force or op.order.status != Order.STATUS_PAID, 'answers': {k.pk: str(v) for k, v in given_answers.items()},
'datetime': dt, 'list': clist.pk
'type': type, }, user=user, auth=auth)
'answers': {k.pk: str(v) for k, v in given_answers.items()}, checkin_created.send(op.order.event, checkin=ci)
'list': clist.pk
}, user=user, auth=auth)
checkin_created.send(op.order.event, checkin=ci)
else: else:
raise CheckInError( raise CheckInError(
_('This ticket has already been redeemed.'), _('This ticket has already been redeemed.'),
@@ -969,11 +926,14 @@ def process_exit_all(sender, **kwargs):
if cl.event.settings.get(f'autocheckin_dst_hack_{cl.pk}'): # move time back if yesterday was DST switch if cl.event.settings.get(f'autocheckin_dst_hack_{cl.pk}'): # move time back if yesterday was DST switch
d -= timedelta(hours=1) d -= timedelta(hours=1)
cl.event.settings.delete(f'autocheckin_dst_hack_{cl.pk}') cl.event.settings.delete(f'autocheckin_dst_hack_{cl.pk}')
try:
cl.exit_all_at = make_aware(datetime.combine(d.date() + timedelta(days=1), d.time().replace(fold=1)), cl.event.timezone) cl.exit_all_at = make_aware(datetime.combine(d.date() + timedelta(days=1), d.time()), cl.event.timezone)
if not datetime_exists(cl.exit_all_at): except pytz.exceptions.AmbiguousTimeError:
cl.exit_all_at = make_aware(datetime.combine(d.date() + timedelta(days=1), d.time()), cl.event.timezone,
is_dst=False)
except pytz.exceptions.NonExistentTimeError:
cl.event.settings.set(f'autocheckin_dst_hack_{cl.pk}', True) cl.event.settings.set(f'autocheckin_dst_hack_{cl.pk}', True)
d += timedelta(hours=1) d += timedelta(hours=1)
cl.exit_all_at = make_aware(datetime.combine(d.date() + timedelta(days=1), d.time().replace(fold=1)), cl.event.timezone) cl.exit_all_at = make_aware(datetime.combine(d.date() + timedelta(days=1), d.time()), cl.event.timezone)
# AmbiguousTimeError shouldn't be possible since d.time() includes fold=0 # AmbiguousTimeError shouldn't be possible since d.time() includes fold=0
cl.save(update_fields=['exit_all_at']) cl.save(update_fields=['exit_all_at'])

View File

@@ -290,8 +290,6 @@ def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> Non
if isinstance(exporter, OrganizerLevelExportMixin): if isinstance(exporter, OrganizerLevelExportMixin):
if not schedule.owner.has_organizer_permission(organizer, exporter.organizer_required_permission): if not schedule.owner.has_organizer_permission(organizer, exporter.organizer_required_permission):
has_permission = False has_permission = False
if exporter and not exporter.available_for_user(schedule.owner):
has_permission = False
_run_scheduled_export( _run_scheduled_export(
schedule, schedule,

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