forked from CGM_Public/pretix_original
Compare commits
1 Commits
fix-checkb
...
django-oau
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16735e3cfe |
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -26,12 +26,12 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.11"]
|
python-version: ["3.11"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||||
|
|||||||
6
.github/workflows/docs.yml
vendored
6
.github/workflows/docs.yml
vendored
@@ -25,12 +25,12 @@ jobs:
|
|||||||
name: Spellcheck
|
name: Spellcheck
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||||
|
|||||||
12
.github/workflows/strings.yml
vendored
12
.github/workflows/strings.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
name: Check gettext syntax
|
name: Check gettext syntax
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||||
@@ -48,12 +48,12 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
name: Spellcheck
|
name: Spellcheck
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||||
|
|||||||
16
.github/workflows/style.yml
vendored
16
.github/workflows/style.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
|||||||
name: isort
|
name: isort
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||||
@@ -43,12 +43,12 @@ jobs:
|
|||||||
name: flake8
|
name: flake8
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||||
@@ -63,9 +63,9 @@ jobs:
|
|||||||
name: licenseheaders
|
name: licenseheaders
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
|
|||||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
|||||||
- database: sqlite
|
- database: sqlite
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
- uses: harmon758/postgresql-action@v1
|
- uses: harmon758/postgresql-action@v1
|
||||||
with:
|
with:
|
||||||
postgresql version: '15'
|
postgresql version: '15'
|
||||||
@@ -41,10 +41,10 @@ jobs:
|
|||||||
postgresql password: 'postgres'
|
postgresql password: 'postgres'
|
||||||
if: matrix.database == 'postgres'
|
if: matrix.database == 'postgres'
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
|
before_script:
|
||||||
tests:
|
tests:
|
||||||
image:
|
|
||||||
name: pretix/ci-image
|
|
||||||
stage: test
|
stage: test
|
||||||
before_script:
|
|
||||||
- pip install -U pip uv
|
|
||||||
- uv pip install --system -U wheel setuptools
|
|
||||||
script:
|
script:
|
||||||
- uv pip install --system -e ".[dev]"
|
- virtualenv env
|
||||||
|
- source env/bin/activate
|
||||||
|
- pip install -U pip wheel setuptools
|
||||||
|
- XDG_CACHE_HOME=/cache pip3 install -e ".[dev]"
|
||||||
- cd src
|
- cd src
|
||||||
- python manage.py check
|
- python manage.py check
|
||||||
- make all compress
|
- make all compress
|
||||||
- PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg py.test --reruns 3 -n 3 tests --maxfail=100
|
- py.test --reruns 3 -n 3 tests
|
||||||
|
tags:
|
||||||
|
- python3
|
||||||
except:
|
except:
|
||||||
- pypi
|
- pypi
|
||||||
pypi:
|
pypi:
|
||||||
stage: release
|
stage: release
|
||||||
image:
|
|
||||||
name: pretix/ci-image
|
|
||||||
before_script:
|
|
||||||
- cat $PYPIRC > ~/.pypirc
|
|
||||||
- pip install -U pip uv
|
|
||||||
- uv pip install --system -U wheel setuptools twine build pretix-plugin-build check-manifest
|
|
||||||
script:
|
script:
|
||||||
- uv pip install --system -e ".[dev]"
|
- cp /keys/.pypirc ~/.pypirc
|
||||||
|
- virtualenv env
|
||||||
|
- source env/bin/activate
|
||||||
|
- pip install -U pip wheel setuptools check-manifest twine
|
||||||
|
- XDG_CACHE_HOME=/cache pip3 install -e ".[dev]"
|
||||||
- python setup.py sdist
|
- python setup.py sdist
|
||||||
- uv pip install --system dist/pretix-*.tar.gz
|
- pip install dist/pretix-*.tar.gz
|
||||||
- python -m pretix migrate
|
- python -m pretix migrate
|
||||||
- python -m pretix check
|
- python -m pretix check
|
||||||
- cd src
|
- cd src
|
||||||
@@ -34,12 +33,13 @@ pypi:
|
|||||||
- python -m build
|
- python -m build
|
||||||
- twine check dist/*
|
- twine check dist/*
|
||||||
- twine upload dist/*
|
- twine upload dist/*
|
||||||
|
tags:
|
||||||
|
- python3
|
||||||
only:
|
only:
|
||||||
- pypi
|
- pypi
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- src/dist/
|
- src/dist/
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- test
|
- test
|
||||||
- build
|
- build
|
||||||
|
|||||||
@@ -52,18 +52,10 @@ Example::
|
|||||||
``currency``
|
``currency``
|
||||||
The default currency as a three-letter code. Defaults to ``EUR``.
|
The default currency as a three-letter code. Defaults to ``EUR``.
|
||||||
|
|
||||||
``cachedir``
|
|
||||||
The local path to a directory where temporary files will be stored.
|
|
||||||
Defaults to the ``cache`` directory below the ``datadir``.
|
|
||||||
|
|
||||||
``datadir``
|
``datadir``
|
||||||
The local path to a data directory that will be used for storing user uploads and similar
|
The local path to a data directory that will be used for storing user uploads and similar
|
||||||
data. Defaults to the value of the environment variable ``DATA_DIR`` or ``data``.
|
data. Defaults to the value of the environment variable ``DATA_DIR`` or ``data``.
|
||||||
|
|
||||||
``logdir``
|
|
||||||
The local path to a directory where log files will be stored.
|
|
||||||
Defaults to the ``logs`` directory below the ``datadir``.
|
|
||||||
|
|
||||||
``plugins_default``
|
``plugins_default``
|
||||||
A comma-separated list of plugins that are enabled by default for all new events.
|
A comma-separated list of plugins that are enabled by default for all new events.
|
||||||
Defaults to ``pretix.plugins.sendmail,pretix.plugins.statistics``.
|
Defaults to ``pretix.plugins.sendmail,pretix.plugins.statistics``.
|
||||||
@@ -97,9 +89,8 @@ Example::
|
|||||||
Defaults to ``off``.
|
Defaults to ``off``.
|
||||||
|
|
||||||
``obligatory_2fa``
|
``obligatory_2fa``
|
||||||
Enables or disables obligatory usage of two-factor authentication for users of the pretix backend.
|
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
|
||||||
Can be ``True`` to make two-factor authentication obligatory for all users or ``staff`` to make it only
|
Defaults to ``False``
|
||||||
obligatory to users with admin permissions. Defaults to ``False``.
|
|
||||||
|
|
||||||
``trust_x_forwarded_for``
|
``trust_x_forwarded_for``
|
||||||
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
|
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
|
||||||
@@ -158,7 +149,6 @@ Example::
|
|||||||
host=localhost
|
host=localhost
|
||||||
port=3306
|
port=3306
|
||||||
advisory_lock_index=1
|
advisory_lock_index=1
|
||||||
disable_server_side_cursors=0
|
|
||||||
sslmode=require
|
sslmode=require
|
||||||
sslrootcert=/etc/pretix/postgresql-ca.crt
|
sslrootcert=/etc/pretix/postgresql-ca.crt
|
||||||
sslcert=/etc/pretix/postgresql-client-crt.crt
|
sslcert=/etc/pretix/postgresql-client-crt.crt
|
||||||
@@ -179,11 +169,6 @@ Example::
|
|||||||
and are not scoped to a specific database. If you run multiple pretix applications with the same PostgreSQL server,
|
and are not scoped to a specific database. If you run multiple pretix applications with the same PostgreSQL server,
|
||||||
you should set separate values for this setting (integers up to 256).
|
you should set separate values for this setting (integers up to 256).
|
||||||
|
|
||||||
``disable_server_side_cursors``
|
|
||||||
On PostgreSQL pretix might use server side cursors for certain operations. This is generally fine but will break in
|
|
||||||
specific circumstances, for example when connecting to PostgreSQL through a PGBouncer configured with a transaction
|
|
||||||
pool mode. Off by default (i.e. by default server side cursors will be used).
|
|
||||||
|
|
||||||
``sslmode``, ``sslrootcert``
|
``sslmode``, ``sslrootcert``
|
||||||
Connection TLS details for the PostgreSQL database connection. Possible values of ``sslmode`` are ``disable``, ``allow``, ``prefer``, ``require``, ``verify-ca``, and ``verify-full``. ``sslrootcert`` should be the accessible path of the ca certificate. Both values are empty by default.
|
Connection TLS details for the PostgreSQL database connection. Possible values of ``sslmode`` are ``disable``, ``allow``, ``prefer``, ``require``, ``verify-ca``, and ``verify-full``. ``sslrootcert`` should be the accessible path of the ca certificate. Both values are empty by default.
|
||||||
|
|
||||||
@@ -360,7 +345,7 @@ to speed up various operations::
|
|||||||
The location of redis, as a URL of the form ``redis://[:password]@localhost:6379/0``
|
The location of redis, as a URL of the form ``redis://[:password]@localhost:6379/0``
|
||||||
or ``unix://[:password]@/path/to/socket.sock?db=0``
|
or ``unix://[:password]@/path/to/socket.sock?db=0``
|
||||||
|
|
||||||
``sessions``
|
``session``
|
||||||
When this is set to ``True``, redis will be used as the session storage.
|
When this is set to ``True``, redis will be used as the session storage.
|
||||||
|
|
||||||
``sentinels``
|
``sentinels``
|
||||||
@@ -536,4 +521,4 @@ pretix can optionally make use of a GeoIP database for some features. It needs a
|
|||||||
|
|
||||||
|
|
||||||
.. _GeoAcumen: https://github.com/geoacumen/geoacumen-country
|
.. _GeoAcumen: https://github.com/geoacumen/geoacumen-country
|
||||||
.. _GeoLite2: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
.. _GeoLite2: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
.. highlight:: none
|
|
||||||
|
|
||||||
.. _`community`:
|
|
||||||
|
|
||||||
Community install guides
|
|
||||||
========================
|
|
||||||
|
|
||||||
.. warning:: The guides are maintained by the community and not by the pretix core team. If you encounter any issues with the guides, please report them to the maintainers of the guides. The pretix core team can not provide support for installs using these guides.
|
|
||||||
|
|
||||||
Kubernetes
|
|
||||||
----------
|
|
||||||
|
|
||||||
- Helm Chart by techwolf12 - A Helm chart for deploying pretix on Kubernetes. The chart documentation is available on `ArtifactHub <https://artifacthub.io/packages/helm/techwolf12/pretix>`_ and the source code is available on `GitHub <https://github.com/Techwolf12/charts/tree/main/pretix-helm>`_.
|
|
||||||
|
|
||||||
Docker
|
|
||||||
------
|
|
||||||
|
|
||||||
- `docker compose setup <https://github.com/ZPascal/pretix-docker-compose>`_ by ZPascal
|
|
||||||
@@ -14,4 +14,3 @@ for your needs.
|
|||||||
manual_smallscale
|
manual_smallscale
|
||||||
dev_version
|
dev_version
|
||||||
enterprise
|
enterprise
|
||||||
community
|
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ Now we will install pretix itself. The following steps are to be executed as the
|
|||||||
actually install pretix, we will create a virtual environment to isolate the python packages from your global
|
actually install pretix, we will create a virtual environment to isolate the python packages from your global
|
||||||
python installation::
|
python installation::
|
||||||
|
|
||||||
# sudo -u pretix -s
|
|
||||||
$ python3 -m venv /var/pretix/venv
|
$ python3 -m venv /var/pretix/venv
|
||||||
$ source /var/pretix/venv/bin/activate
|
$ source /var/pretix/venv/bin/activate
|
||||||
(venv)$ pip3 install -U pip setuptools wheel
|
(venv)$ pip3 install -U pip setuptools wheel
|
||||||
@@ -280,7 +279,6 @@ Updates
|
|||||||
|
|
||||||
To upgrade to a new pretix release, pull the latest code changes and run the following commands::
|
To upgrade to a new pretix release, pull the latest code changes and run the following commands::
|
||||||
|
|
||||||
# sudo -u pretix -s
|
|
||||||
$ source /var/pretix/venv/bin/activate
|
$ source /var/pretix/venv/bin/activate
|
||||||
(venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn
|
(venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn
|
||||||
(venv)$ python -m pretix migrate
|
(venv)$ python -m pretix migrate
|
||||||
|
|||||||
@@ -103,12 +103,6 @@ pretix_celery_tasks_queued_count
|
|||||||
pretix_celery_tasks_queued_age_seconds
|
pretix_celery_tasks_queued_age_seconds
|
||||||
The age of the longest-waiting in the worker queue in seconds, labeled with ``queue``.
|
The age of the longest-waiting in the worker queue in seconds, labeled with ``queue``.
|
||||||
|
|
||||||
pretix_logins_successful
|
|
||||||
Counter. The number of successful backend logins.
|
|
||||||
|
|
||||||
pretix_logins_failed
|
|
||||||
Counter. The number of failed backend logins, labeled with ``reason``.
|
|
||||||
|
|
||||||
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
||||||
.. _Prometheus: https://prometheus.io/
|
.. _Prometheus: https://prometheus.io/
|
||||||
.. _cProfile: https://docs.python.org/3/library/profile.html
|
.. _cProfile: https://docs.python.org/3/library/profile.html
|
||||||
|
|||||||
@@ -249,10 +249,7 @@ You can get three response codes:
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"event": {
|
"event": "democon",
|
||||||
"name": "Demo Conference",
|
|
||||||
"slug": "democon"
|
|
||||||
},
|
|
||||||
"subevent": 23,
|
"subevent": 23,
|
||||||
"checkinlist": 5
|
"checkinlist": 5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,9 +94,7 @@ If you want the user to return to your application after the payment is complete
|
|||||||
"Plugins". Enable the plugin "Redirection from order page". Then, go to the new page "Settings", then "Redirection".
|
"Plugins". Enable the plugin "Redirection from order page". Then, go to the new page "Settings", then "Redirection".
|
||||||
Enter the base URL of your web application. This will allow you to redirect to pages under this base URL later on.
|
Enter the base URL of your web application. This will allow you to redirect to pages under this base URL later on.
|
||||||
For example, if you want users to be redirected to ``https://example.org/order/return?tx_id=1234``, you could now
|
For example, if you want users to be redirected to ``https://example.org/order/return?tx_id=1234``, you could now
|
||||||
either enter ``https://example.org/order/`` or ``https://example.org/``.
|
either enter ``https://example.org`` or ``https://example.org/order/``.
|
||||||
Please note that in the latter case the trailing slash is required, ``https://example.org`` is not allowed to prevent.
|
|
||||||
Only base URLs with a secure (``https://``) or local (``http://localhost``) origin are permitted.
|
|
||||||
|
|
||||||
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
||||||
**regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error
|
**regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ external_identifier string External ID of
|
|||||||
the API, but is read-only for customers created through a
|
the API, but is read-only for customers created through a
|
||||||
SSO integration.
|
SSO integration.
|
||||||
email string Customer email address
|
email string Customer email address
|
||||||
phone string Customer phone number
|
|
||||||
name string Name of this customer (or ``null``)
|
name string Name of this customer (or ``null``)
|
||||||
name_parts object of strings Decomposition of name (i.e. given name, family name)
|
name_parts object of strings Decomposition of name (i.e. given name, family name)
|
||||||
is_active boolean Whether this account is active
|
is_active boolean Whether this account is active
|
||||||
@@ -40,10 +39,6 @@ password string Can only be set
|
|||||||
|
|
||||||
Passwords can now be set through the API during customer creation.
|
Passwords can now be set through the API during customer creation.
|
||||||
|
|
||||||
.. versionchanged:: 2024.3
|
|
||||||
|
|
||||||
The attribute ``phone`` has been added.
|
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
|
|
||||||
@@ -76,7 +71,6 @@ Endpoints
|
|||||||
"identifier": "8WSAJCJ",
|
"identifier": "8WSAJCJ",
|
||||||
"external_identifier": null,
|
"external_identifier": null,
|
||||||
"email": "customer@example.org",
|
"email": "customer@example.org",
|
||||||
"phone": "+493012345678",
|
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"name_parts": {
|
"name_parts": {
|
||||||
"_scheme": "full",
|
"_scheme": "full",
|
||||||
@@ -124,7 +118,6 @@ Endpoints
|
|||||||
"identifier": "8WSAJCJ",
|
"identifier": "8WSAJCJ",
|
||||||
"external_identifier": null,
|
"external_identifier": null,
|
||||||
"email": "customer@example.org",
|
"email": "customer@example.org",
|
||||||
"phone": "+493012345678",
|
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
"name_parts": {
|
"name_parts": {
|
||||||
"_scheme": "full",
|
"_scheme": "full",
|
||||||
@@ -162,7 +155,6 @@ Endpoints
|
|||||||
|
|
||||||
{
|
{
|
||||||
"email": "test@example.org",
|
"email": "test@example.org",
|
||||||
"phone": "+493012345678",
|
|
||||||
"password": "verysecret",
|
"password": "verysecret",
|
||||||
"send_email": true
|
"send_email": true
|
||||||
}
|
}
|
||||||
@@ -179,7 +171,6 @@ Endpoints
|
|||||||
"identifier": "8WSAJCJ",
|
"identifier": "8WSAJCJ",
|
||||||
"external_identifier": null,
|
"external_identifier": null,
|
||||||
"email": "test@example.org",
|
"email": "test@example.org",
|
||||||
"phone": "+493012345678",
|
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +215,6 @@ Endpoints
|
|||||||
"identifier": "8WSAJCJ",
|
"identifier": "8WSAJCJ",
|
||||||
"external_identifier": null,
|
"external_identifier": null,
|
||||||
"email": "test@example.org",
|
"email": "test@example.org",
|
||||||
"phone": "+493012345678",
|
|
||||||
…
|
…
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +249,6 @@ Endpoints
|
|||||||
"identifier": "8WSAJCJ",
|
"identifier": "8WSAJCJ",
|
||||||
"external_identifier": null,
|
"external_identifier": null,
|
||||||
"email": null,
|
"email": null,
|
||||||
"phone": null,
|
|
||||||
…
|
…
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,11 +179,6 @@ country string Attendee countr
|
|||||||
state string Attendee state (ISO 3166-2 code). Only supported in
|
state string Attendee state (ISO 3166-2 code). Only supported in
|
||||||
AU, BR, CA, CN, MY, MX, and US, otherwise ``null``.
|
AU, BR, CA, CN, MY, MX, and US, otherwise ``null``.
|
||||||
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
||||||
voucher_budget_use money (string) Amount of money discounted by the voucher, corresponding
|
|
||||||
to how much of the ``budget`` of the voucher is consumed.
|
|
||||||
**Important:** Do not rely on this amount to be a useful
|
|
||||||
value if the position's price, product or voucher
|
|
||||||
are changed *after* the order was created. Can be ``null``.
|
|
||||||
tax_rate decimal (string) VAT rate applied for this position
|
tax_rate decimal (string) VAT rate applied for this position
|
||||||
tax_value money (string) VAT included in this position
|
tax_value money (string) VAT included in this position
|
||||||
tax_rule integer The ID of the used tax rule (or ``null``)
|
tax_rule integer The ID of the used tax rule (or ``null``)
|
||||||
@@ -372,7 +367,6 @@ List of all orders
|
|||||||
"country": "DE",
|
"country": "DE",
|
||||||
"state": null,
|
"state": null,
|
||||||
"voucher": null,
|
"voucher": null,
|
||||||
"voucher_budget_use": null,
|
|
||||||
"tax_rate": "0.00",
|
"tax_rate": "0.00",
|
||||||
"tax_value": "0.00",
|
"tax_value": "0.00",
|
||||||
"tax_rule": null,
|
"tax_rule": null,
|
||||||
@@ -595,7 +589,6 @@ Fetching individual orders
|
|||||||
"country": "DE",
|
"country": "DE",
|
||||||
"state": null,
|
"state": null,
|
||||||
"voucher": null,
|
"voucher": null,
|
||||||
"voucher_budget_use": null,
|
|
||||||
"tax_rate": "0.00",
|
"tax_rate": "0.00",
|
||||||
"tax_rule": null,
|
"tax_rule": null,
|
||||||
"tax_value": "0.00",
|
"tax_value": "0.00",
|
||||||
@@ -1548,7 +1541,6 @@ List of all order positions
|
|||||||
},
|
},
|
||||||
"attendee_email": null,
|
"attendee_email": null,
|
||||||
"voucher": null,
|
"voucher": null,
|
||||||
"voucher_budget_use": null,
|
|
||||||
"tax_rate": "0.00",
|
"tax_rate": "0.00",
|
||||||
"tax_rule": null,
|
"tax_rule": null,
|
||||||
"tax_value": "0.00",
|
"tax_value": "0.00",
|
||||||
@@ -1662,7 +1654,6 @@ Fetching individual positions
|
|||||||
},
|
},
|
||||||
"attendee_email": null,
|
"attendee_email": null,
|
||||||
"voucher": null,
|
"voucher": null,
|
||||||
"voucher_budget_use": null,
|
|
||||||
"tax_rate": "0.00",
|
"tax_rate": "0.00",
|
||||||
"tax_rule": null,
|
"tax_rule": null,
|
||||||
"tax_value": "0.00",
|
"tax_value": "0.00",
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ id integer Internal ID of
|
|||||||
name string Team name
|
name string Team name
|
||||||
all_events boolean Whether this team has access to all events
|
all_events boolean Whether this team has access to all events
|
||||||
limit_events list List of event slugs this team has access to
|
limit_events list List of event slugs this team has access to
|
||||||
require_2fa boolean Whether members of this team are required to use
|
|
||||||
two-factor authentication
|
|
||||||
can_create_events boolean
|
can_create_events boolean
|
||||||
can_change_teams boolean
|
can_change_teams boolean
|
||||||
can_change_organizer_settings boolean
|
can_change_organizer_settings boolean
|
||||||
@@ -124,7 +122,6 @@ Team endpoints
|
|||||||
"name": "Admin team",
|
"name": "Admin team",
|
||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
"require_2fa": true,
|
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -162,7 +159,6 @@ Team endpoints
|
|||||||
"name": "Admin team",
|
"name": "Admin team",
|
||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
"require_2fa": true,
|
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -190,7 +186,6 @@ Team endpoints
|
|||||||
"name": "Admin team",
|
"name": "Admin team",
|
||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
"require_2fa": true,
|
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -208,7 +203,6 @@ Team endpoints
|
|||||||
"name": "Admin team",
|
"name": "Admin team",
|
||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
"require_2fa": true,
|
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -252,7 +246,6 @@ Team endpoints
|
|||||||
"name": "Admin team",
|
"name": "Admin team",
|
||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
"require_2fa": true,
|
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,11 @@
|
|||||||
|
|
||||||
.. _`importcol`:
|
.. _`importcol`:
|
||||||
|
|
||||||
Extending the import process
|
Extending the order import process
|
||||||
============================
|
==================================
|
||||||
|
|
||||||
It's possible through the backend to import objects into pretix, for example orders from a legacy ticketing system. If
|
It's possible through the backend to import orders into pretix, for example from a legacy ticketing system. If your
|
||||||
your plugin defines additional data structures around those objects, it might be useful to make it possible to import
|
plugins defines additional data structures around orders, it might be useful to make it possible to import them as well.
|
||||||
them as well.
|
|
||||||
|
|
||||||
Import process
|
Import process
|
||||||
--------------
|
--------------
|
||||||
@@ -41,7 +40,7 @@ Column registration
|
|||||||
|
|
||||||
The import API does not make a lot of usage from signals, however, it
|
The import API does not make a lot of usage from signals, however, it
|
||||||
does use a signal to get a list of all available import columns. Your plugin
|
does use a signal to get a list of all available import columns. Your plugin
|
||||||
should listen for this signal and return the subclass of ``pretix.base.modelimport.ImportColumn``
|
should listen for this signal and return the subclass of ``pretix.base.orderimport.ImportColumn``
|
||||||
that we'll provide in this plugin:
|
that we'll provide in this plugin:
|
||||||
|
|
||||||
.. sourcecode:: python
|
.. sourcecode:: python
|
||||||
@@ -57,16 +56,10 @@ that we'll provide in this plugin:
|
|||||||
EmailColumn(sender),
|
EmailColumn(sender),
|
||||||
]
|
]
|
||||||
|
|
||||||
Similar signals exist for other objects:
|
|
||||||
|
|
||||||
.. automodule:: pretix.base.signals
|
|
||||||
:members: voucher_import_columns
|
|
||||||
|
|
||||||
|
|
||||||
The column class API
|
The column class API
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
.. class:: pretix.base.modelimport.ImportColumn
|
.. class:: pretix.base.orderimport.ImportColumn
|
||||||
|
|
||||||
The central object of each import extension is the subclass of ``ImportColumn``.
|
The central object of each import extension is the subclass of ``ImportColumn``.
|
||||||
|
|
||||||
|
|||||||
@@ -84,8 +84,6 @@ convenient to you:
|
|||||||
|
|
||||||
.. automethod:: _register_fonts
|
.. automethod:: _register_fonts
|
||||||
|
|
||||||
.. automethod:: _register_event_fonts
|
|
||||||
|
|
||||||
.. automethod:: _on_first_page
|
.. automethod:: _on_first_page
|
||||||
|
|
||||||
.. automethod:: _on_other_page
|
.. automethod:: _on_other_page
|
||||||
|
|||||||
@@ -19,4 +19,3 @@ Contents:
|
|||||||
permissions
|
permissions
|
||||||
logging
|
logging
|
||||||
locking
|
locking
|
||||||
timemachine
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ includes serializers for serializing the following types:
|
|||||||
* Built-in types: ``int``, ``float``, ``decimal.Decimal``, ``dict``, ``list``, ``bool``
|
* Built-in types: ``int``, ``float``, ``decimal.Decimal``, ``dict``, ``list``, ``bool``
|
||||||
* ``datetime.date``, ``datetime.datetime``, ``datetime.time``
|
* ``datetime.date``, ``datetime.datetime``, ``datetime.time``
|
||||||
* ``LazyI18nString``
|
* ``LazyI18nString``
|
||||||
* References to Django ``File`` objects that are already stored in a storage backend [#f1]_
|
* References to Django ``File`` objects that are already stored in a storage backend
|
||||||
* References to model instances
|
* References to model instances
|
||||||
|
|
||||||
In code, we recommend to always use the ``.get()`` method on the settings object to access a value, but for
|
In code, we recommend to always use the ``.get()`` method on the settings object to access a value, but for
|
||||||
@@ -55,9 +55,6 @@ You can simply use it like this:
|
|||||||
"preserve his reservation."),
|
"preserve his reservation."),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
.. _settings-defaults-in-plugins:
|
|
||||||
|
|
||||||
Defaults in plugins
|
Defaults in plugins
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
@@ -73,9 +70,3 @@ Make sure that you include this code in a module that is imported at app loading
|
|||||||
|
|
||||||
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
|
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
|
||||||
.. _documentation: https://django-hierarkey.readthedocs.io/en/latest/
|
.. _documentation: https://django-hierarkey.readthedocs.io/en/latest/
|
||||||
|
|
||||||
.. rubric:: Footnotes
|
|
||||||
|
|
||||||
.. [#f1] If you store ``File`` instances in per-event settings, make sure to always register them with ``add_default``
|
|
||||||
as described above in :ref:`settings-defaults-in-plugins`. Otherwise, the file won't get copied properly if the
|
|
||||||
user copies the settings of an existing event to a new one.
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
Time machine mode
|
|
||||||
=================
|
|
||||||
|
|
||||||
In test mode, pretix provides a "time machine" feature which allows event organizers
|
|
||||||
to test their shop as if it were a different date and time. To enable this feature, they can
|
|
||||||
click on the "time machine"-link in the test mode warning box on the event page.
|
|
||||||
|
|
||||||
Internally, this time machine mode is implemented by calling our custom :py:meth:`time_machine_now()`
|
|
||||||
function instead of :py:meth:`django.utils.timezone.now()` in all places where the fake time should be
|
|
||||||
taken into account. If you add code that uses the current date and time for checking whether some
|
|
||||||
product can be bought, you should use :py:meth:`time_machine_now`.
|
|
||||||
|
|
||||||
.. autofunction:: pretix.base.timemachine.time_machine_now
|
|
||||||
|
|
||||||
Background tasks
|
|
||||||
----------------
|
|
||||||
|
|
||||||
The time machine datetime is passed through the request flow via a thread-local variable (ContextVar).
|
|
||||||
Therefore, if you call a background task in the order process, where time_machine_now should be
|
|
||||||
respected, you need to pass it through manually as shown in the example below:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
@app.task()
|
|
||||||
def my_task(self, override_now_dt: datetime=None) -> None:
|
|
||||||
with time_machine_now_assigned(override_now_dt):
|
|
||||||
# ...do something that uses time_machine_now()
|
|
||||||
|
|
||||||
my_task.apply_async(kwargs={'override_now_dt': time_machine_now(default=None)})
|
|
||||||
|
|
||||||
|
|
||||||
.. autofunction:: pretix.base.timemachine.time_machine_now_assigned
|
|
||||||
@@ -90,10 +90,6 @@ as its first argument and can be used like this::
|
|||||||
<a href="{% eventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
|
<a href="{% eventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
|
||||||
<a href="{% abseventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
|
<a href="{% abseventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
|
||||||
|
|
||||||
To generate absolute URLs on the main domain, you can use the ``absurl`` template tag::
|
|
||||||
|
|
||||||
{% load eventurl %}
|
|
||||||
<a href="{% absmainurl "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}">Event settings</a>
|
|
||||||
|
|
||||||
Implementation details
|
Implementation details
|
||||||
----------------------
|
----------------------
|
||||||
|
|||||||
@@ -211,15 +211,5 @@ with the documentation a lot, you might find it useful to use sphinx-autobuild::
|
|||||||
Then, go to http://localhost:8081 for a version of the documentation that automatically re-builds
|
Then, go to http://localhost:8081 for a version of the documentation that automatically re-builds
|
||||||
whenever you change a source file.
|
whenever you change a source file.
|
||||||
|
|
||||||
Working with frontend assets
|
|
||||||
----------------------------
|
|
||||||
|
|
||||||
To update the frontend styles of shops with a custom styling, run the following commands inside
|
|
||||||
your virtual environment.::
|
|
||||||
|
|
||||||
python -m pretix collectstatic --noinput
|
|
||||||
python -m pretix updatestyles
|
|
||||||
|
|
||||||
|
|
||||||
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
|
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
|
||||||
.. _pretixdroid: https://github.com/pretix/pretixdroid
|
.. _pretixdroid: https://github.com/pretix/pretixdroid
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ pretix/
|
|||||||
Additional code implementing our customized :ref:`URL handling <urlconf>`.
|
Additional code implementing our customized :ref:`URL handling <urlconf>`.
|
||||||
|
|
||||||
static/
|
static/
|
||||||
Contains all static files (CSS/SASS, JavaScript, images) of pretix' core.
|
Contains all static files (CSS/SASS, JavaScript, images) of pretix' core
|
||||||
We use libsass as a preprocessor for CSS. Our own sass code is built in the same
|
We use libsass as a preprocessor for CSS. Our own sass code is built in the same
|
||||||
step as Bootstrap and FontAwesome, so their mixins etc. are fully available.
|
step as Bootstrap and FontAwesome, so their mixins etc. are fully available.
|
||||||
|
|
||||||
@@ -41,6 +41,6 @@ pretix/
|
|||||||
|
|
||||||
tests/
|
tests/
|
||||||
This is the root directory for all test codes. It includes subdirectories ``api``, ``base``,
|
This is the root directory for all test codes. It includes subdirectories ``api``, ``base``,
|
||||||
``control``, ``presale``, ``helpers``, ``multidomain`` and ``plugins`` to mirror the structure
|
``control``, ``presale``, ``helpers`, ``multidomain`` and ``plugins`` to mirror the structure
|
||||||
of the pretix source code as well as ``testdummy``, which is a pretix plugin used during
|
of the pretix source code as well as ``testdummy``, which is a pretix plugin used during
|
||||||
testing.
|
testing.
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ internal_id string Can be used for
|
|||||||
contact_name string Contact person (or ``null``)
|
contact_name string Contact person (or ``null``)
|
||||||
contact_name_parts object of strings Decomposition of contact name (i.e. given name, family name)
|
contact_name_parts object of strings Decomposition of contact name (i.e. given name, family name)
|
||||||
contact_email string Contact person email address (or ``null``)
|
contact_email string Contact person email address (or ``null``)
|
||||||
contact_cc_email string Copy email addresses, can be multiple separated by comma (or ``null``)
|
|
||||||
booth string Booth number (or ``null``). Maximum 100 characters.
|
booth string Booth number (or ``null``). Maximum 100 characters.
|
||||||
locale string Locale for communication with the exhibitor.
|
locale string Locale for communication with the exhibitor.
|
||||||
access_code string Access code for the exhibitor to access their data or use the lead scanning app (read-only).
|
access_code string Access code for the exhibitor to access their data or use the lead scanning app (read-only).
|
||||||
@@ -45,8 +44,6 @@ allow_voucher_access boolean Enables access
|
|||||||
lead_scanning_scope_by_device string Enables lead scanning to be handled as one lead per attendee
|
lead_scanning_scope_by_device string Enables lead scanning to be handled as one lead per attendee
|
||||||
per scanning device, instead of only per exhibitor.
|
per scanning device, instead of only per exhibitor.
|
||||||
comment string Internal comment, not shown to exhibitor
|
comment string Internal comment, not shown to exhibitor
|
||||||
exhibitor_tags list of strings Internal tags to categorize exhibitors, not shown to exhibitor.
|
|
||||||
The tags need to be created through the web interface currently.
|
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
You can also access the scanned leads through the API which contains the following public fields:
|
You can also access the scanned leads through the API which contains the following public fields:
|
||||||
@@ -112,7 +109,6 @@ Endpoints
|
|||||||
"title": "Dr"
|
"title": "Dr"
|
||||||
},
|
},
|
||||||
"contact_email": "johnson@as.example.org",
|
"contact_email": "johnson@as.example.org",
|
||||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
|
||||||
"booth": "A2",
|
"booth": "A2",
|
||||||
"locale": "de",
|
"locale": "de",
|
||||||
"access_code": "VKHZ2FU84",
|
"access_code": "VKHZ2FU84",
|
||||||
@@ -121,8 +117,7 @@ Endpoints
|
|||||||
"allow_lead_scanning": true,
|
"allow_lead_scanning": true,
|
||||||
"allow_lead_access": true,
|
"allow_lead_access": true,
|
||||||
"allow_voucher_access": true,
|
"allow_voucher_access": true,
|
||||||
"comment": "",
|
"comment": ""
|
||||||
"exhibitor_tags": []
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -167,7 +162,6 @@ Endpoints
|
|||||||
"title": "Dr"
|
"title": "Dr"
|
||||||
},
|
},
|
||||||
"contact_email": "johnson@as.example.org",
|
"contact_email": "johnson@as.example.org",
|
||||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
|
||||||
"booth": "A2",
|
"booth": "A2",
|
||||||
"locale": "de",
|
"locale": "de",
|
||||||
"access_code": "VKHZ2FU84",
|
"access_code": "VKHZ2FU84",
|
||||||
@@ -176,8 +170,7 @@ Endpoints
|
|||||||
"allow_lead_scanning": true,
|
"allow_lead_scanning": true,
|
||||||
"allow_lead_access": true,
|
"allow_lead_access": true,
|
||||||
"allow_voucher_access": true,
|
"allow_voucher_access": true,
|
||||||
"comment": "",
|
"comment": ""
|
||||||
"exhibitor_tags": []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to fetch
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
@@ -372,16 +365,12 @@ Endpoints
|
|||||||
"title": "Dr"
|
"title": "Dr"
|
||||||
},
|
},
|
||||||
"contact_email": "johnson@as.example.org",
|
"contact_email": "johnson@as.example.org",
|
||||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
|
||||||
"booth": "A2",
|
"booth": "A2",
|
||||||
"locale": "de",
|
"locale": "de",
|
||||||
"allow_lead_scanning": true,
|
"allow_lead_scanning": true,
|
||||||
"allow_lead_access": true,
|
"allow_lead_access": true,
|
||||||
"allow_voucher_access": true,
|
"allow_voucher_access": true,
|
||||||
"comment": "",
|
"comment": ""
|
||||||
"exhibitor_tags": [
|
|
||||||
"Gold Sponsor"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
**Example response**:
|
**Example response**:
|
||||||
@@ -405,7 +394,6 @@ Endpoints
|
|||||||
"title": "Dr"
|
"title": "Dr"
|
||||||
},
|
},
|
||||||
"contact_email": "johnson@as.example.org",
|
"contact_email": "johnson@as.example.org",
|
||||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
|
||||||
"booth": "A2",
|
"booth": "A2",
|
||||||
"locale": "de",
|
"locale": "de",
|
||||||
"access_code": "VKHZ2FU84",
|
"access_code": "VKHZ2FU84",
|
||||||
@@ -414,10 +402,7 @@ Endpoints
|
|||||||
"allow_lead_scanning": true,
|
"allow_lead_scanning": true,
|
||||||
"allow_lead_access": true,
|
"allow_lead_access": true,
|
||||||
"allow_voucher_access": true,
|
"allow_voucher_access": true,
|
||||||
"comment": "",
|
"comment": ""
|
||||||
"exhibitor_tags": [
|
|
||||||
"Gold Sponsor"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to create new exhibitor for
|
:param organizer: The ``slug`` field of the organizer to create new exhibitor for
|
||||||
@@ -469,7 +454,6 @@ Endpoints
|
|||||||
"title": "Dr"
|
"title": "Dr"
|
||||||
},
|
},
|
||||||
"contact_email": "johnson@as.example.org",
|
"contact_email": "johnson@as.example.org",
|
||||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
|
||||||
"booth": "A2",
|
"booth": "A2",
|
||||||
"locale": "de",
|
"locale": "de",
|
||||||
"access_code": "VKHZ2FU84",
|
"access_code": "VKHZ2FU84",
|
||||||
@@ -478,10 +462,7 @@ Endpoints
|
|||||||
"allow_lead_scanning": true,
|
"allow_lead_scanning": true,
|
||||||
"allow_lead_access": true,
|
"allow_lead_access": true,
|
||||||
"allow_voucher_access": true,
|
"allow_voucher_access": true,
|
||||||
"comment": "",
|
"comment": ""
|
||||||
"exhibitor_tags": [
|
|
||||||
"Gold Sponsor"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to modify
|
:param organizer: The ``slug`` field of the organizer to modify
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
sphinx==7.3.*
|
sphinx==7.0.*
|
||||||
jinja2==3.1.*
|
jinja2==3.1.*
|
||||||
sphinx-rtd-theme
|
sphinx-rtd-theme
|
||||||
sphinxcontrib-httpdomain
|
sphinxcontrib-httpdomain
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-e ../
|
-e ../
|
||||||
sphinx==7.3.*
|
sphinx==7.0.*
|
||||||
jinja2==3.1.*
|
jinja2==3.1.*
|
||||||
sphinx-rtd-theme
|
sphinx-rtd-theme
|
||||||
sphinxcontrib-httpdomain
|
sphinxcontrib-httpdomain
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
Android version support policy
|
|
||||||
==============================
|
|
||||||
|
|
||||||
Building software for Android always presents a struggle between keeping compatibility with older hardware to save cost
|
|
||||||
and utilizing feature of new Android versions to improve functionality, security and stability. To help you plan ahead,
|
|
||||||
we are publishing our intended schedule. This is to be understood as a minimum commitment, we will only drop support for
|
|
||||||
older versions if there is a technical reason to do so, not because the scheduled time has been reached.
|
|
||||||
|
|
||||||
.. warning:: This is a non-binding document. We will try our very best to not to deprecate support for Android versions
|
|
||||||
earlier than listed here, but for technical or economical reasons, it might become necessary to do so under
|
|
||||||
specific circumstances. Specifically, we might be forced to partially drop support for Android versions
|
|
||||||
earlier where we integrate third-party components into our software. Typical examples would be specific
|
|
||||||
payment terminal or printer types where we use a third-party component provided by the hardware vendor.
|
|
||||||
|
|
||||||
If we no longer support an Android version, it means that we will no longer publish new versions of the app supporting
|
|
||||||
that Android version. This means you are not getting new features or bug fixes, and at some point your app might stop
|
|
||||||
working with the pretix server.
|
|
||||||
|
|
||||||
pretixSCAN
|
|
||||||
----------
|
|
||||||
|
|
||||||
=========================== ==========================================================
|
|
||||||
Android Version Support schedule
|
|
||||||
=========================== ==========================================================
|
|
||||||
Android 14 Support planned until at least 12/2029.
|
|
||||||
Android 13 Support planned until at least 12/2028.
|
|
||||||
Android 12 Support planned until at least 12/2027.
|
|
||||||
Android 11 Support planned until at least 12/2026.
|
|
||||||
Android 10 Support planned until at least 12/2025.
|
|
||||||
Android 9 Support planned until at least 12/2025.
|
|
||||||
Android 8 Support planned until at least 12/2025.
|
|
||||||
Android 7 Support planned until at least 06/2025.
|
|
||||||
Android 6 Support planned until at least 06/2025.
|
|
||||||
Android 5 | Support planned until at least 06/2025.
|
|
||||||
| No support for COVID certificate verification.
|
|
||||||
Android 4 Support dropped.
|
|
||||||
=========================== ==========================================================
|
|
||||||
|
|
||||||
pretixPOS
|
|
||||||
---------
|
|
||||||
|
|
||||||
=========================== ==========================================================
|
|
||||||
Android Version Support schedule
|
|
||||||
=========================== ==========================================================
|
|
||||||
Android 14 | Support planned until at least 12/2029.
|
|
||||||
| Limited support for Swissbit microSD TSE (only tested devices).
|
|
||||||
Android 13 | Support planned until at least 12/2028.
|
|
||||||
| Limited support for Swissbit microSD TSE (only tested devices).
|
|
||||||
Android 12 | Support planned until at least 12/2027.
|
|
||||||
| Limited support for Swissbit microSD TSE (only tested devices).
|
|
||||||
Android 11 | Support planned until at least 12/2026.
|
|
||||||
| No support for Swissbit microSD TSE.
|
|
||||||
Android 10 Support planned until at least 12/2025.
|
|
||||||
Android 9 Support planned until at least 12/2025.
|
|
||||||
Android 8 | Support planned until at least 12/2025.
|
|
||||||
| Support for Stripe Terminal on some devices to be dropped 05/2024.
|
|
||||||
Android 7 | Support planned until at least 12/2024.
|
|
||||||
| Support for Stripe Terminal to be dropped 05/2024.
|
|
||||||
| No support for Cryptovision TSE.
|
|
||||||
Android 6 | Support planned until at least 12/2024.
|
|
||||||
| No support for Cryptovision TSE.
|
|
||||||
| No support for Fiskal Cloud.
|
|
||||||
| No support for Stripe Terminal.
|
|
||||||
Android 5 | Support planned until at least 12/2024.
|
|
||||||
| No support for Cryptovision TSE.
|
|
||||||
| No support for Fiskal Cloud.
|
|
||||||
| No support for Stripe Terminal.
|
|
||||||
| No support for SumUp.
|
|
||||||
| No support for COVID certificate verification.
|
|
||||||
Android 4 Support dropped.
|
|
||||||
=========================== ==========================================================
|
|
||||||
|
|
||||||
pretixPRINT
|
|
||||||
-----------
|
|
||||||
|
|
||||||
=========================== ==========================================================
|
|
||||||
Android Version Support schedule
|
|
||||||
=========================== ==========================================================
|
|
||||||
Android 14 Support planned until at least 12/2029.
|
|
||||||
Android 13 Support planned until at least 12/2028.
|
|
||||||
Android 12 Support planned until at least 12/2027.
|
|
||||||
Android 11 Support planned until at least 12/2026.
|
|
||||||
Android 10 Support planned until at least 12/2025.
|
|
||||||
Android 9 Support planned until at least 12/2025.
|
|
||||||
Android 8 Support planned until at least 12/2025.
|
|
||||||
Android 7 Support planned until at least 06/2025.
|
|
||||||
Android 6 Support planned until at least 06/2025.
|
|
||||||
Android 5 | Support planned until at least 06/2025.
|
|
||||||
| No support for Evolis printers on some devices.
|
|
||||||
Android 4.4 | Support planned until at least 06/2024.
|
|
||||||
| No support for USB printers.
|
|
||||||
| No support for Evolis printers.
|
|
||||||
Android 4 Support dropped.
|
|
||||||
=========================== ==========================================================
|
|
||||||
|
|
||||||
pretixLEAD
|
|
||||||
----------
|
|
||||||
|
|
||||||
=========================== ==========================================================
|
|
||||||
Android Version Support schedule
|
|
||||||
=========================== ==========================================================
|
|
||||||
Android 14 Support planned until at least 12/2029.
|
|
||||||
Android 13 Support planned until at least 12/2028.
|
|
||||||
Android 12 Support planned until at least 12/2027.
|
|
||||||
Android 11 Support planned until at least 12/2026.
|
|
||||||
Android 10 Support planned until at least 12/2025.
|
|
||||||
Android 9 Support planned until at least 12/2025.
|
|
||||||
Android 8 Support planned until at least 12/2025.
|
|
||||||
Android 7 Support planned until at least 12/2024.
|
|
||||||
Android 6 Support planned until at least 12/2024.
|
|
||||||
Android 5 Support planned until at least 12/2024.
|
|
||||||
Android 4 Support dropped.
|
|
||||||
=========================== ==========================================================
|
|
||||||
@@ -19,3 +19,4 @@ Then, head to the **Bundled products** tab of the "conference ticket" and add th
|
|||||||
|
|
||||||
Once a customer tries to buy the € 450 conference ticket, a sub-product will be added and the price will automatically be split into the two components, leading to a correct computation of taxes.
|
Once a customer tries to buy the € 450 conference ticket, a sub-product will be added and the price will automatically be split into the two components, leading to a correct computation of taxes.
|
||||||
|
|
||||||
|
You can find more use cases in these specialized guides:
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ and then click "Generate widget code".
|
|||||||
You will obtain two code snippets that look *roughly* like the following. The first should be embedded into the
|
You will obtain two code snippets that look *roughly* like the following. The first should be embedded into the
|
||||||
``<head>`` part of your website, if possible. If this inconvenient, you can put it in the ``<body>`` part as well::
|
``<head>`` part of your website, if possible. If this inconvenient, you can put it in the ``<body>`` part as well::
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="https://pretix.eu/demo/democon/widget/v1.css" crossorigin>
|
<link rel="stylesheet" type="text/css" href="https://pretix.eu/demo/democon/widget/v1.css">
|
||||||
<script type="text/javascript" src="https://pretix.eu/widget/v1.en.js" async crossorigin></script>
|
<script type="text/javascript" src="https://pretix.eu/widget/v1.en.js" async></script>
|
||||||
|
|
||||||
The second snippet should be embedded at the position where the widget should show up::
|
The second snippet should be embedded at the position where the widget should show up::
|
||||||
|
|
||||||
@@ -339,9 +339,9 @@ Currently, the following attributes are understood by pretix itself:
|
|||||||
``data-attendee-name``, which will pre-fill the last part of the name, whatever that is.
|
``data-attendee-name``, which will pre-fill the last part of the name, whatever that is.
|
||||||
|
|
||||||
* ``data-invoice-address-FIELD`` will pre-fill the corresponding field of the invoice address. Possible values for
|
* ``data-invoice-address-FIELD`` will pre-fill the corresponding field of the invoice address. Possible values for
|
||||||
``FIELD`` are ``company``, ``street``, ``zipcode``, ``city``, ``country``, ``internal-reference``, ``vat-id``, and
|
``FIELD`` are ``company``, ``street``, ``zipcode``, ``city`` and ``country``, as well as fields specified by the
|
||||||
``custom-field``, as well as fields specified by the naming scheme such as ``name-title`` or ``name-given-name``
|
naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character
|
||||||
(see above). ``country`` expects a two-character country code.
|
country code.
|
||||||
|
|
||||||
* If ``data-fix="true"`` is given, the user will not be able to change the other given values later. This currently
|
* If ``data-fix="true"`` is given, the user will not be able to change the other given values later. This currently
|
||||||
only works for the order email address as well as the invoice address. Attendee-level fields and questions can
|
only works for the order email address as well as the invoice address. Attendee-level fields and questions can
|
||||||
@@ -449,14 +449,5 @@ Further reading:
|
|||||||
|
|
||||||
* `Stripe Payment Method Domain registration`_
|
* `Stripe Payment Method Domain registration`_
|
||||||
|
|
||||||
Working with Cross-Origin-Embedder-Policy
|
|
||||||
-----------------------------------------
|
|
||||||
|
|
||||||
The pretix widget is unfortunately not compatible with ``Cross-Origin-Embedder-Policy: require-corp``. If you include
|
|
||||||
the ``crossorigin`` attributes on the ``<script>`` and ``<link>`` tag as shown above, the widget can show a calendar
|
|
||||||
or product list, but will not be able to open the checkout process in an iframe. If you also set
|
|
||||||
``Cross-Origin-Opener-Policy: same-origin``, the widget can auto-detect that it is running in an isolated enviroment
|
|
||||||
and will instead open the checkout process in a new tab.
|
|
||||||
|
|
||||||
.. _Let's Encrypt: https://letsencrypt.org/
|
.. _Let's Encrypt: https://letsencrypt.org/
|
||||||
.. _Stripe Payment Method Domain registration: https://stripe.com/docs/payments/payment-methods/pmd-registration
|
.. _Stripe Payment Method Domain registration: https://stripe.com/docs/payments/payment-methods/pmd-registration
|
||||||
|
|||||||
@@ -16,5 +16,4 @@ wanting to use pretix to sell tickets.
|
|||||||
events/giftcards
|
events/giftcards
|
||||||
faq
|
faq
|
||||||
markdown
|
markdown
|
||||||
android-version-support
|
|
||||||
glossary
|
glossary
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ In many places of your shop, like frontpage texts, product descriptions and emai
|
|||||||
since it is way easier to learn than languages like HTML but allows all basic formatting options required
|
since it is way easier to learn than languages like HTML but allows all basic formatting options required
|
||||||
for text in those places.
|
for text in those places.
|
||||||
|
|
||||||
.. note:: Some fields that are used in one-line context only allow formatting that refers to individual words
|
|
||||||
(such as bold or italic font or a link) but do not allow block-level formatting like lists or headlines.
|
|
||||||
|
|
||||||
Formatting rules
|
Formatting rules
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
|
|||||||
@@ -30,42 +30,42 @@ dependencies = [
|
|||||||
"babel",
|
"babel",
|
||||||
"BeautifulSoup4==4.12.*",
|
"BeautifulSoup4==4.12.*",
|
||||||
"bleach==5.0.*",
|
"bleach==5.0.*",
|
||||||
"celery==5.4.*",
|
"celery==5.3.*",
|
||||||
"chardet==5.2.*",
|
"chardet==5.1.*",
|
||||||
"cryptography>=3.4.2",
|
"cryptography>=3.4.2",
|
||||||
"css-inline==0.14.*",
|
"css-inline==0.8.*",
|
||||||
"defusedcsv>=1.1.0",
|
"defusedcsv>=1.1.0",
|
||||||
"dj-static",
|
"dj-static",
|
||||||
"Django[argon2]==4.2.*",
|
"Django==4.2.*",
|
||||||
"django-bootstrap3==24.2",
|
"django-bootstrap3==23.1.*",
|
||||||
"django-compressor==4.5",
|
"django-compressor==4.3.*",
|
||||||
"django-countries==7.6.*",
|
"django-countries==7.5.*",
|
||||||
"django-filter==24.2",
|
"django-filter==23.2",
|
||||||
"django-formset-js-improved==0.5.0.3",
|
"django-formset-js-improved==0.5.0.3",
|
||||||
"django-formtools==2.5.1",
|
"django-formtools==2.4.1",
|
||||||
"django-hierarkey==1.2.*",
|
"django-hierarkey==1.1.*",
|
||||||
"django-hijack==3.5.*",
|
"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-oauth-toolkit==2.3.*",
|
"django-oauth-toolkit==2.3.*",
|
||||||
"django-otp==1.5.*",
|
"django-otp==1.2.*",
|
||||||
"django-phonenumber-field==7.3.*",
|
"django-phonenumber-field==7.1.*",
|
||||||
"django-redis==5.4.*",
|
"django-redis==5.2.*",
|
||||||
"django-scopes==2.0.*",
|
"django-scopes==2.0.*",
|
||||||
"django-statici18n==2.5.*",
|
"django-statici18n==2.3.*",
|
||||||
"djangorestframework==3.15.*",
|
"djangorestframework==3.14.*",
|
||||||
"dnspython==2.6.*",
|
"dnspython==2.3.*",
|
||||||
"drf_ujson2==1.7.*",
|
"drf_ujson2==1.7.*",
|
||||||
"geoip2==4.*",
|
"geoip2==4.*",
|
||||||
"importlib_metadata==7.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
"importlib_metadata==7.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||||
"isoweek",
|
"isoweek",
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"kombu==5.3.*",
|
"kombu==5.3.*",
|
||||||
"libsass==0.23.*",
|
"libsass==0.22.*",
|
||||||
"lxml",
|
"lxml",
|
||||||
"markdown==3.6", # 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.30.*",
|
||||||
"oauthlib==3.2.*",
|
"oauthlib==3.2.*",
|
||||||
@@ -73,26 +73,27 @@ dependencies = [
|
|||||||
"packaging",
|
"packaging",
|
||||||
"paypalrestsdk==1.13.*",
|
"paypalrestsdk==1.13.*",
|
||||||
"paypal-checkout-serversdk==1.0.*",
|
"paypal-checkout-serversdk==1.0.*",
|
||||||
"PyJWT==2.8.*",
|
"PyJWT==2.7.*",
|
||||||
"phonenumberslite==8.13.*",
|
"phonenumberslite==8.13.*",
|
||||||
"Pillow==10.3.*",
|
"Pillow==9.5.*",
|
||||||
"pretix-plugin-build",
|
"pretix-plugin-build",
|
||||||
"protobuf==5.27.*",
|
"protobuf==4.23.*",
|
||||||
"psycopg2-binary",
|
"psycopg2-binary",
|
||||||
"pycountry",
|
"pycountry",
|
||||||
"pycparser==2.22",
|
"pycparser==2.21",
|
||||||
"pycryptodome==3.20.*",
|
"pycryptodome==3.18.*",
|
||||||
"pypdf==4.2.*",
|
"pypdf==3.9.*",
|
||||||
"python-bidi==0.4.*", # Support for Arabic in reportlab
|
"python-bidi==0.4.*", # Support for Arabic in reportlab
|
||||||
"python-dateutil==2.9.*",
|
"python-dateutil==2.8.*",
|
||||||
|
"python-u2flib-server==4.*",
|
||||||
"pytz",
|
"pytz",
|
||||||
"pytz-deprecation-shim==0.1.*",
|
"pytz-deprecation-shim==0.1.*",
|
||||||
"pyuca",
|
"pyuca",
|
||||||
"qrcode==7.4.*",
|
"qrcode==7.4.*",
|
||||||
"redis==5.0.*",
|
"redis==4.6.*",
|
||||||
"reportlab==4.2.*",
|
"reportlab==4.0.*",
|
||||||
"requests==2.31.*",
|
"requests==2.31.*",
|
||||||
"sentry-sdk==2.5.*",
|
"sentry-sdk==1.15.*",
|
||||||
"sepaxml==2.6.*",
|
"sepaxml==2.6.*",
|
||||||
"slimit",
|
"slimit",
|
||||||
"static3==0.7.*",
|
"static3==0.7.*",
|
||||||
@@ -100,34 +101,35 @@ dependencies = [
|
|||||||
"text-unidecode==1.*",
|
"text-unidecode==1.*",
|
||||||
"tlds>=2020041600",
|
"tlds>=2020041600",
|
||||||
"tqdm==4.*",
|
"tqdm==4.*",
|
||||||
"ua-parser==0.18.*",
|
|
||||||
"vat_moss_forked==2020.3.20.0.11.0",
|
"vat_moss_forked==2020.3.20.0.11.0",
|
||||||
"vobject==0.9.*",
|
"vobject==0.9.*",
|
||||||
"webauthn==2.1.*",
|
"webauthn==0.4.*",
|
||||||
"zeep==4.2.*"
|
"zeep==4.2.*"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
memcached = ["pylibmc"]
|
memcached = ["pylibmc"]
|
||||||
dev = [
|
dev = [
|
||||||
"aiohttp==3.9.*",
|
"aiohttp==3.8.*",
|
||||||
"coverage",
|
"coverage",
|
||||||
"coveralls",
|
"coveralls",
|
||||||
"fakeredis==2.23.*",
|
"fakeredis==2.18.*",
|
||||||
"flake8==7.1.*",
|
"flake8==6.0.*",
|
||||||
"freezegun",
|
"freezegun",
|
||||||
"isort==5.13.*",
|
"isort==5.12.*",
|
||||||
"pep8-naming==0.14.*",
|
"pep8-naming==0.13.*",
|
||||||
"potypo",
|
"potypo",
|
||||||
|
"pycodestyle==2.10.*",
|
||||||
|
"pyflakes==3.0.*",
|
||||||
"pytest-asyncio",
|
"pytest-asyncio",
|
||||||
"pytest-cache",
|
"pytest-cache",
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
"pytest-django==4.*",
|
"pytest-django==4.*",
|
||||||
"pytest-mock==3.14.*",
|
"pytest-mock==3.10.*",
|
||||||
"pytest-rerunfailures==14.*",
|
"pytest-rerunfailures==11.*",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist==3.6.*",
|
"pytest-xdist==3.3.*",
|
||||||
"pytest==8.2.*",
|
"pytest==7.3.*",
|
||||||
"responses",
|
"responses",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"ignore_dirs": ["node_modules", "data", "pretix/static", "pretix/locale", "pretix/static.dist"]
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ localecompile:
|
|||||||
./manage.py compilemessages
|
./manage.py compilemessages
|
||||||
|
|
||||||
localegen:
|
localegen:
|
||||||
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
|
./manage.py makemessages --keep-pot --ignore "pretix/helpers/*" --ignore "pretix/static/npm_dir/*" $(LNGS)
|
||||||
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
|
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
|
||||||
|
|
||||||
staticfiles: jsi18n
|
staticfiles: jsi18n
|
||||||
|
|||||||
@@ -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__ = "2024.6.0.dev0"
|
__version__ = "2024.2.0.dev0"
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ ALL_LANGUAGES = [
|
|||||||
('de', _('German')),
|
('de', _('German')),
|
||||||
('de-informal', _('German (informal)')),
|
('de-informal', _('German (informal)')),
|
||||||
('ar', _('Arabic')),
|
('ar', _('Arabic')),
|
||||||
('ca', _('Catalan')),
|
|
||||||
('zh-hans', _('Chinese (simplified)')),
|
('zh-hans', _('Chinese (simplified)')),
|
||||||
('zh-hant', _('Chinese (traditional)')),
|
('zh-hant', _('Chinese (traditional)')),
|
||||||
('cs', _('Czech')),
|
('cs', _('Czech')),
|
||||||
@@ -99,7 +98,6 @@ ALL_LANGUAGES = [
|
|||||||
('pt-br', _('Portuguese (Brazil)')),
|
('pt-br', _('Portuguese (Brazil)')),
|
||||||
('ro', _('Romanian')),
|
('ro', _('Romanian')),
|
||||||
('ru', _('Russian')),
|
('ru', _('Russian')),
|
||||||
('sk', _('Slovak')),
|
|
||||||
('es', _('Spanish')),
|
('es', _('Spanish')),
|
||||||
('tr', _('Turkish')),
|
('tr', _('Turkish')),
|
||||||
('uk', _('Ukrainian')),
|
('uk', _('Ukrainian')),
|
||||||
@@ -113,7 +111,6 @@ LANGUAGES_RTL = {
|
|||||||
LANGUAGES_INCUBATING = {
|
LANGUAGES_INCUBATING = {
|
||||||
'fi', 'pt-br', 'gl',
|
'fi', 'pt-br', 'gl',
|
||||||
}
|
}
|
||||||
LANGUAGES = ALL_LANGUAGES
|
|
||||||
LOCALE_PATHS = [
|
LOCALE_PATHS = [
|
||||||
os.path.join(os.path.dirname(__file__), 'locale'),
|
os.path.join(os.path.dirname(__file__), 'locale'),
|
||||||
]
|
]
|
||||||
@@ -237,12 +234,7 @@ COMPRESS_FILTERS = {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
CURRENCIES = [
|
CURRENCIES = list(currencies)
|
||||||
c for c in currencies
|
|
||||||
if c.alpha_3 not in {
|
|
||||||
'XAG', 'XAU', 'XBA', 'XBB', 'XBC', 'XBD', 'XDR', 'XPD', 'XPT', 'XSU', 'XTS', 'XUA',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
CURRENCY_PLACES = {
|
CURRENCY_PLACES = {
|
||||||
# default is 2
|
# default is 2
|
||||||
'BIF': 0,
|
'BIF': 0,
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ from pretix.base.models import Device, Event, User
|
|||||||
from pretix.base.models.auth import SuperuserPermissionSet
|
from pretix.base.models.auth import SuperuserPermissionSet
|
||||||
from pretix.base.models.organizer import TeamAPIToken
|
from pretix.base.models.organizer import TeamAPIToken
|
||||||
from pretix.helpers.security import (
|
from pretix.helpers.security import (
|
||||||
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
|
SessionInvalid, SessionReauthRequired, assert_session_valid,
|
||||||
SessionReauthRequired, assert_session_valid,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,10 +66,6 @@ class EventPermission(BasePermission):
|
|||||||
return False
|
return False
|
||||||
except SessionReauthRequired:
|
except SessionReauthRequired:
|
||||||
return False
|
return False
|
||||||
except Session2FASetupRequired:
|
|
||||||
return False
|
|
||||||
except SessionPasswordChangeRequired:
|
|
||||||
return False
|
|
||||||
|
|
||||||
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
|
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
|
||||||
else request.user)
|
else request.user)
|
||||||
@@ -149,10 +144,6 @@ class ProfilePermission(BasePermission):
|
|||||||
return False
|
return False
|
||||||
except SessionReauthRequired:
|
except SessionReauthRequired:
|
||||||
return False
|
return False
|
||||||
except Session2FASetupRequired:
|
|
||||||
return False
|
|
||||||
except SessionPasswordChangeRequired:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if isinstance(request.auth, OAuthAccessToken):
|
if isinstance(request.auth, OAuthAccessToken):
|
||||||
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
||||||
@@ -175,9 +166,5 @@ class AnyAuthenticatedClientPermission(BasePermission):
|
|||||||
return False
|
return False
|
||||||
except SessionReauthRequired:
|
except SessionReauthRequired:
|
||||||
return False
|
return False
|
||||||
except Session2FASetupRequired:
|
|
||||||
return False
|
|
||||||
except SessionPasswordChangeRequired:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -472,8 +472,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
|||||||
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
|
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
|
||||||
'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public',
|
'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public',
|
||||||
'frontpage_text', 'seating_plan', 'item_price_overrides', 'variation_price_overrides',
|
'frontpage_text', 'seating_plan', 'item_price_overrides', 'variation_price_overrides',
|
||||||
'meta_data', 'seat_category_mapping', 'last_modified', 'best_availability_state',
|
'meta_data', 'seat_category_mapping', 'last_modified', 'best_availability_state')
|
||||||
'comment')
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -684,12 +683,10 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'locales',
|
'locales',
|
||||||
'locale',
|
'locale',
|
||||||
'region',
|
'region',
|
||||||
'allow_modifications',
|
|
||||||
'allow_modifications_after_checkin',
|
|
||||||
'last_order_modification_date',
|
'last_order_modification_date',
|
||||||
|
'allow_modifications_after_checkin',
|
||||||
'show_quota_left',
|
'show_quota_left',
|
||||||
'waiting_list_enabled',
|
'waiting_list_enabled',
|
||||||
'waiting_list_auto_disable',
|
|
||||||
'waiting_list_hours',
|
'waiting_list_hours',
|
||||||
'waiting_list_auto',
|
'waiting_list_auto',
|
||||||
'waiting_list_names_asked',
|
'waiting_list_names_asked',
|
||||||
@@ -736,7 +733,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'payment_term_accept_late',
|
'payment_term_accept_late',
|
||||||
'payment_explanation',
|
'payment_explanation',
|
||||||
'payment_pending_hidden',
|
'payment_pending_hidden',
|
||||||
'payment_giftcard__enabled',
|
|
||||||
'mail_days_order_expire_warning',
|
'mail_days_order_expire_warning',
|
||||||
'ticket_download',
|
'ticket_download',
|
||||||
'ticket_download_date',
|
'ticket_download_date',
|
||||||
|
|||||||
@@ -486,11 +486,11 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
|||||||
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
|
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
|
||||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled',
|
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled',
|
||||||
'valid_from', 'valid_until', 'blocked', 'voucher_budget_use')
|
'valid_from', 'valid_until', 'blocked')
|
||||||
read_only_fields = (
|
read_only_fields = (
|
||||||
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
|
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
|
||||||
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
|
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
|
||||||
'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use'
|
'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked'
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -564,8 +564,6 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
|||||||
attendee_name = AttendeeNameField(source='*')
|
attendee_name = AttendeeNameField(source='*')
|
||||||
attendee_name_parts = AttendeeNamePartsField(source='*')
|
attendee_name_parts = AttendeeNamePartsField(source='*')
|
||||||
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
|
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
|
||||||
order__valid_if_pending = serializers.SlugRelatedField(read_only=True, slug_field='valid_if_pending', source='order')
|
|
||||||
order__require_approval = serializers.SlugRelatedField(read_only=True, slug_field='require_approval', source='order')
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = OrderPosition
|
model = OrderPosition
|
||||||
@@ -573,8 +571,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
|||||||
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
||||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
|
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
|
||||||
'order__status', 'order__valid_if_pending', 'order__require_approval', 'valid_from', 'valid_until',
|
'order__status', 'valid_from', 'valid_until', 'blocked')
|
||||||
'blocked')
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -1318,7 +1315,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
|
if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
|
||||||
valid_from, valid_until = pos_data['item'].compute_validity(
|
valid_from, valid_until = pos_data['item'].compute_validity(
|
||||||
requested_start=(
|
requested_start=(
|
||||||
requested_valid_from
|
max(requested_valid_from, now())
|
||||||
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
|
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
|
||||||
else now()
|
else now()
|
||||||
),
|
),
|
||||||
@@ -1442,7 +1439,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax:
|
if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax:
|
||||||
price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price))
|
price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price))
|
||||||
else:
|
else:
|
||||||
pos._calculate_tax(invoice_address=ia)
|
|
||||||
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
|
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
|
||||||
else:
|
else:
|
||||||
price_after_voucher = listed_price
|
price_after_voucher = listed_price
|
||||||
@@ -1470,7 +1466,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
answers_data = pos_data.pop('answers', [])
|
answers_data = pos_data.pop('answers', [])
|
||||||
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
|
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
|
||||||
pos = pos_data['__instance']
|
pos = pos_data['__instance']
|
||||||
pos._calculate_tax(invoice_address=ia)
|
pos._calculate_tax()
|
||||||
|
|
||||||
if simulate:
|
if simulate:
|
||||||
pos = WrappedModel(pos)
|
pos = WrappedModel(pos)
|
||||||
@@ -1589,10 +1585,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
|
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
|
||||||
payment_provider = 'free'
|
payment_provider = 'free'
|
||||||
|
|
||||||
if order.total != Decimal('0.00') and order.event.currency == "XXX":
|
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
|
||||||
raise ValidationError('Paid products not supported without a valid currency.')
|
|
||||||
|
|
||||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'):
|
|
||||||
order.status = Order.STATUS_PAID
|
order.status = Order.STATUS_PAID
|
||||||
order.save()
|
order.save()
|
||||||
order.payments.create(
|
order.payments.create(
|
||||||
@@ -1604,8 +1597,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
elif validated_data.get('status') == Order.STATUS_PAID:
|
elif validated_data.get('status') == Order.STATUS_PAID:
|
||||||
if not payment_provider:
|
if not payment_provider:
|
||||||
raise ValidationError('You cannot create a paid order without a payment provider.')
|
raise ValidationError('You cannot create a paid order without a payment provider.')
|
||||||
if validated_data.get('require_approval'):
|
|
||||||
raise ValidationError('You cannot create a paid order that requires approval.')
|
|
||||||
order.payments.create(
|
order.payments.create(
|
||||||
amount=order.total,
|
amount=order.total,
|
||||||
provider=payment_provider,
|
provider=payment_provider,
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ class CustomerSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Customer
|
model = Customer
|
||||||
fields = ('identifier', 'external_identifier', 'email', 'phone', 'name', 'name_parts', 'is_active',
|
fields = ('identifier', 'external_identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
|
||||||
'is_verified', 'last_login', 'date_joined', 'locale', 'last_modified', 'notes')
|
'locale', 'last_modified', 'notes')
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
if instance and instance.provider_id:
|
if instance and instance.provider_id:
|
||||||
@@ -239,7 +239,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Team
|
model = Team
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
||||||
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
||||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
||||||
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
|
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ class SettingsSerializer(serializers.Serializer):
|
|||||||
except OSError: # pragma: no cover
|
except OSError: # pragma: no cover
|
||||||
logger.error('Deleting file %s failed.' % fname.name)
|
logger.error('Deleting file %s failed.' % fname.name)
|
||||||
instance.delete(attr)
|
instance.delete(attr)
|
||||||
self.changed_data.append(attr)
|
|
||||||
else:
|
else:
|
||||||
# file is unchanged
|
# file is unchanged
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ from django.http import Http404
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
from packaging.version import parse
|
from packaging.version import parse
|
||||||
@@ -587,32 +586,6 @@ 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:
|
||||||
if media.linked_orderposition.order.event_id not in list_by_event:
|
|
||||||
# Medium exists but connected ticket is for the wrong event
|
|
||||||
if not simulate:
|
|
||||||
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
|
|
||||||
'datetime': datetime,
|
|
||||||
'type': checkin_type,
|
|
||||||
'list': checkinlists[0].pk,
|
|
||||||
'barcode': raw_barcode,
|
|
||||||
'searched_lists': [cl.pk for cl in checkinlists]
|
|
||||||
}, user=user, auth=auth)
|
|
||||||
Checkin.objects.create(
|
|
||||||
position=None,
|
|
||||||
successful=False,
|
|
||||||
error_reason=Checkin.REASON_INVALID,
|
|
||||||
error_explanation=gettext('Medium connected to other event'),
|
|
||||||
**common_checkin_args,
|
|
||||||
)
|
|
||||||
return Response({
|
|
||||||
'detail': 'Not found.', # for backwards compatibility
|
|
||||||
'status': 'error',
|
|
||||||
'reason': Checkin.REASON_INVALID,
|
|
||||||
'reason_explanation': gettext('Medium connected to other event'),
|
|
||||||
'require_attention': False,
|
|
||||||
'checkin_texts': [],
|
|
||||||
'list': MiniCheckinListSerializer(checkinlists[0]).data,
|
|
||||||
}, status=404)
|
|
||||||
op_candidates = [media.linked_orderposition]
|
op_candidates = [media.linked_orderposition]
|
||||||
if list_by_event[media.linked_orderposition.order.event_id].addon_match:
|
if list_by_event[media.linked_orderposition.order.event_id].addon_match:
|
||||||
op_candidates += list(media.linked_orderposition.addons.all())
|
op_candidates += list(media.linked_orderposition.addons.all())
|
||||||
|
|||||||
@@ -190,10 +190,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
|||||||
serializer = self.get_serializer(page, many=True)
|
serializer = self.get_serializer(page, many=True)
|
||||||
return self.get_paginated_response(serializer.data)
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
@transaction.atomic()
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
original_data = self.get_serializer(instance=serializer.instance).data
|
|
||||||
|
|
||||||
current_live_value = serializer.instance.live
|
current_live_value = serializer.instance.live
|
||||||
updated_live_value = serializer.validated_data.get('live', None)
|
updated_live_value = serializer.validated_data.get('live', None)
|
||||||
current_plugins_value = serializer.instance.get_plugins()
|
current_plugins_value = serializer.instance.get_plugins()
|
||||||
@@ -201,11 +198,6 @@ class EventViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
super().perform_update(serializer)
|
super().perform_update(serializer)
|
||||||
|
|
||||||
if serializer.data == original_data:
|
|
||||||
# Performance optimization: If nothing was changed, we do not need to save or log anything.
|
|
||||||
# This costs us a few cycles on save, but avoids thousands of lines in our log.
|
|
||||||
return
|
|
||||||
|
|
||||||
if updated_live_value is not None and updated_live_value != current_live_value:
|
if updated_live_value is not None and updated_live_value != current_live_value:
|
||||||
log_action = 'pretix.event.live.activated' if updated_live_value else 'pretix.event.live.deactivated'
|
log_action = 'pretix.event.live.activated' if updated_live_value else 'pretix.event.live.deactivated'
|
||||||
serializer.instance.log_action(
|
serializer.instance.log_action(
|
||||||
@@ -630,12 +622,11 @@ class EventSettingsView(views.APIView):
|
|||||||
s.is_valid(raise_exception=True)
|
s.is_valid(raise_exception=True)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
s.save()
|
s.save()
|
||||||
if s.changed_data:
|
self.request.event.log_action(
|
||||||
self.request.event.log_action(
|
'pretix.event.settings', user=self.request.user, auth=self.request.auth, data={
|
||||||
'pretix.event.settings', user=self.request.user, auth=self.request.auth, data={
|
k: v for k, v in s.validated_data.items()
|
||||||
k: v for k, v in s.validated_data.items()
|
}
|
||||||
}
|
)
|
||||||
)
|
|
||||||
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
|
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
|
||||||
regenerate_css.apply_async(args=(request.event.pk,))
|
regenerate_css.apply_async(args=(request.event.pk,))
|
||||||
s = EventSettingsSerializer(
|
s = EventSettingsSerializer(
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -28,7 +27,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import IntegrityError, 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,
|
||||||
)
|
)
|
||||||
@@ -97,9 +96,6 @@ from pretix.base.signals import (
|
|||||||
)
|
)
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
from pretix.control.signals import order_search_filter_q
|
from pretix.control.signals import order_search_filter_q
|
||||||
from pretix.helpers import OF_SELF
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
class OrderFilter(FilterSet):
|
class OrderFilter(FilterSet):
|
||||||
@@ -576,10 +572,8 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
|||||||
return self.retrieve(request, [], **kwargs)
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
@action(detail=True, methods=['POST'])
|
@action(detail=True, methods=['POST'])
|
||||||
@transaction.atomic()
|
|
||||||
def create_invoice(self, request, **kwargs):
|
def create_invoice(self, request, **kwargs):
|
||||||
order = self.get_object()
|
order = self.get_object()
|
||||||
order = Order.objects.select_for_update(of=OF_SELF).get(pk=order.pk)
|
|
||||||
has_inv = order.invoices.exists() and not (
|
has_inv = order.invoices.exists() and not (
|
||||||
order.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
|
order.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
|
||||||
and order.invoices.filter(is_cancellation=True).count() >= order.invoices.filter(is_cancellation=False).count()
|
and order.invoices.filter(is_cancellation=True).count() >= order.invoices.filter(is_cancellation=False).count()
|
||||||
@@ -906,11 +900,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
|||||||
order_modified.send(sender=serializer.instance.event, order=serializer.instance)
|
order_modified.send(sender=serializer.instance.event, order=serializer.instance)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
try:
|
serializer.save()
|
||||||
serializer.save()
|
|
||||||
except IntegrityError:
|
|
||||||
logger.exception("Integrity error while saving order")
|
|
||||||
raise ValidationError("Integrity error, possibly duplicate submission of same order.")
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
if not instance.testmode:
|
if not instance.testmode:
|
||||||
@@ -1908,7 +1898,6 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@action(detail=True, methods=['POST'])
|
@action(detail=True, methods=['POST'])
|
||||||
@transaction.atomic()
|
|
||||||
def reissue(self, request, **kwargs):
|
def reissue(self, request, **kwargs):
|
||||||
inv = self.get_object()
|
inv = self.get_object()
|
||||||
if inv.canceled:
|
if inv.canceled:
|
||||||
@@ -1916,10 +1905,9 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
elif inv.shredded:
|
elif inv.shredded:
|
||||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||||
else:
|
else:
|
||||||
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
|
|
||||||
c = generate_cancellation(inv)
|
c = generate_cancellation(inv)
|
||||||
if inv.order.status != Order.STATUS_CANCELED:
|
if inv.order.status != Order.STATUS_CANCELED:
|
||||||
inv = generate_invoice(order)
|
inv = generate_invoice(inv.order)
|
||||||
else:
|
else:
|
||||||
inv = c
|
inv = c
|
||||||
inv.order.log_action(
|
inv.order.log_action(
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class ParametrizedItemWebhookEvent(ParametrizedWebhookEvent):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ParametrizedOrderPositionCheckinWebhookEvent(ParametrizedOrderWebhookEvent):
|
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||||
|
|
||||||
def build_payload(self, logentry: LogEntry):
|
def build_payload(self, logentry: LogEntry):
|
||||||
d = super().build_payload(logentry)
|
d = super().build_payload(logentry)
|
||||||
@@ -185,7 +185,6 @@ class ParametrizedOrderPositionCheckinWebhookEvent(ParametrizedOrderWebhookEvent
|
|||||||
d['orderposition_id'] = logentry.parsed_data.get('position')
|
d['orderposition_id'] = logentry.parsed_data.get('position')
|
||||||
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
|
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
|
||||||
d['checkin_list'] = logentry.parsed_data.get('list')
|
d['checkin_list'] = logentry.parsed_data.get('list')
|
||||||
d['type'] = logentry.parsed_data.get('type')
|
|
||||||
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
|
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@@ -297,11 +296,11 @@ def register_default_webhook_events(sender, **kwargs):
|
|||||||
'pretix.event.order.denied',
|
'pretix.event.order.denied',
|
||||||
_('Order denied'),
|
_('Order denied'),
|
||||||
),
|
),
|
||||||
ParametrizedOrderPositionCheckinWebhookEvent(
|
ParametrizedOrderPositionWebhookEvent(
|
||||||
'pretix.event.checkin',
|
'pretix.event.checkin',
|
||||||
_('Ticket checked in'),
|
_('Ticket checked in'),
|
||||||
),
|
),
|
||||||
ParametrizedOrderPositionCheckinWebhookEvent(
|
ParametrizedOrderPositionWebhookEvent(
|
||||||
'pretix.event.checkin.reverted',
|
'pretix.event.checkin.reverted',
|
||||||
_('Ticket check-in reverted'),
|
_('Ticket check-in reverted'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class PretixBaseConfig(AppConfig):
|
|||||||
from . import invoice # NOQA
|
from . import invoice # NOQA
|
||||||
from . import notifications # NOQA
|
from . import notifications # NOQA
|
||||||
from . import email # NOQA
|
from . import email # NOQA
|
||||||
from .services import auth, checkin, currencies, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
from .services import auth, checkin, currencies, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||||
from .models import _transactions # NOQA
|
from .models import _transactions # NOQA
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
|||||||
tpl = get_template(self.template_name)
|
tpl = get_template(self.template_name)
|
||||||
body_html = tpl.render(htmlctx)
|
body_html = tpl.render(htmlctx)
|
||||||
|
|
||||||
inliner = css_inline.CSSInliner(keep_style_tags=False)
|
inliner = css_inline.CSSInliner(remove_style_tags=True)
|
||||||
body_html = inliner.inline(body_html)
|
body_html = inliner.inline(body_html)
|
||||||
|
|
||||||
return body_html
|
return body_html
|
||||||
|
|||||||
@@ -39,12 +39,10 @@ from zipfile import ZipFile
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
|
|
||||||
from pretix.base.models import QuestionAnswer
|
from pretix.base.models import QuestionAnswer
|
||||||
|
|
||||||
from ...control.forms.widgets import Select2
|
|
||||||
from ..exporter import BaseExporter
|
from ..exporter import BaseExporter
|
||||||
from ..signals import register_data_exporters
|
from ..signals import register_data_exporters
|
||||||
|
|
||||||
@@ -58,7 +56,7 @@ class AnswerFilesExporter(BaseExporter):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def export_form_fields(self):
|
def export_form_fields(self):
|
||||||
d = OrderedDict(
|
return OrderedDict(
|
||||||
[
|
[
|
||||||
('questions',
|
('questions',
|
||||||
forms.ModelMultipleChoiceField(
|
forms.ModelMultipleChoiceField(
|
||||||
@@ -71,32 +69,11 @@ class AnswerFilesExporter(BaseExporter):
|
|||||||
)),
|
)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if self.event.has_subevents:
|
|
||||||
d['subevent'] = forms.ModelChoiceField(
|
|
||||||
label=pgettext_lazy('subevent', 'Date'),
|
|
||||||
queryset=self.event.subevents.all(),
|
|
||||||
required=False,
|
|
||||||
empty_label=pgettext_lazy('subevent', 'All dates')
|
|
||||||
)
|
|
||||||
d['subevent'].widget = Select2(
|
|
||||||
attrs={
|
|
||||||
'data-model-select2': 'event',
|
|
||||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
|
||||||
'event': self.event.slug,
|
|
||||||
'organizer': self.event.organizer.slug,
|
|
||||||
}),
|
|
||||||
'data-placeholder': pgettext_lazy('subevent', 'All dates')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
d['subevent'].widget.choices = d['subevent'].choices
|
|
||||||
return d
|
|
||||||
|
|
||||||
def render(self, form_data: dict):
|
def render(self, form_data: dict):
|
||||||
qs = QuestionAnswer.objects.filter(
|
qs = QuestionAnswer.objects.filter(
|
||||||
orderposition__order__event=self.event,
|
orderposition__order__event=self.event,
|
||||||
).select_related('orderposition', 'orderposition__order', 'question')
|
).select_related('orderposition', 'orderposition__order', 'question')
|
||||||
if form_data.get('subevent'):
|
|
||||||
qs = qs.filter(orderposition__subevent=form_data.get('subevent'))
|
|
||||||
if form_data.get('questions'):
|
if form_data.get('questions'):
|
||||||
qs = qs.filter(question__in=form_data['questions'])
|
qs = qs.filter(question__in=form_data['questions'])
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
|||||||
@@ -116,29 +116,15 @@ class DekodiNREIExporter(BaseExporter):
|
|||||||
'PTNo15': p.full_id or '',
|
'PTNo15': p.full_id or '',
|
||||||
})
|
})
|
||||||
elif p.provider and p.provider.startswith('stripe'):
|
elif p.provider and p.provider.startswith('stripe'):
|
||||||
pi = p.info_data or {}
|
src = p.info_data.get("source", p.info_data)
|
||||||
try:
|
|
||||||
if "latest_charge" in pi and isinstance(pi.get("latest_charge"), dict):
|
|
||||||
details = pi["latest_charge"]["payment_method_details"]
|
|
||||||
card = details.get("card", {})
|
|
||||||
elif pi.get("charges") and pi["charges"]["data"]:
|
|
||||||
details = pi["charges"]["data"][0].get("payment_method_details", {})
|
|
||||||
card = details.get("card", {})
|
|
||||||
else:
|
|
||||||
details = pi["source"]
|
|
||||||
card = pi["source"]["card"]
|
|
||||||
except:
|
|
||||||
details = {}
|
|
||||||
card = {}
|
|
||||||
|
|
||||||
payments.append({
|
payments.append({
|
||||||
'PTID': '81',
|
'PTID': '81',
|
||||||
'PTN': 'Stripe',
|
'PTN': 'Stripe',
|
||||||
'PTNo1': pi.get("id") or '',
|
'PTNo1': p.info_data.get("id") or '',
|
||||||
'PTNo5': card.get("last4", ""),
|
'PTNo5': src.get("card", {}).get("last4") or '',
|
||||||
'PTNo7': round(float(p.amount), 2) or '',
|
'PTNo7': round(float(p.amount), 2) or '',
|
||||||
'PTNo8': str(self.event.currency) or '',
|
'PTNo8': str(self.event.currency) or '',
|
||||||
'PTNo10': details.get('owner', {}).get('verified_name') or details.get('owner', {}).get('name') or '',
|
'PTNo10': src.get('owner', {}).get('verified_name') or src.get('owner', {}).get('name') or '',
|
||||||
'PTNo15': p.full_id or '',
|
'PTNo15': p.full_id or '',
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ class InvoiceExporterMixin:
|
|||||||
('', _('All payment providers')),
|
('', _('All payment providers')),
|
||||||
] + [
|
] + [
|
||||||
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
|
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
|
||||||
if not v.is_meta
|
|
||||||
],
|
],
|
||||||
required=False,
|
required=False,
|
||||||
help_text=_('Only include invoices for orders that have at least one payment attempt '
|
help_text=_('Only include invoices for orders that have at least one payment attempt '
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
from collections import OrderedDict, defaultdict
|
from collections import OrderedDict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
@@ -257,8 +257,8 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
tax_rates = self._get_all_tax_rates(qs)
|
tax_rates = self._get_all_tax_rates(qs)
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
_('Event slug'), _('Event name'), _('Order code'), _('Order total'), _('Status'), _('Email'),
|
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Phone number'),
|
||||||
_('Phone number'), _('Order date'), _('Order time'), _('Company'), _('Name'),
|
_('Order date'), _('Order time'), _('Company'), _('Name'),
|
||||||
]
|
]
|
||||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
||||||
if name_scheme and len(name_scheme['fields']) > 1:
|
if name_scheme and len(name_scheme['fields']) > 1:
|
||||||
@@ -335,7 +335,6 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
|
|
||||||
row = [
|
row = [
|
||||||
self.event_object_cache[order.event_id].slug,
|
self.event_object_cache[order.event_id].slug,
|
||||||
str(self.event_object_cache[order.event_id].name),
|
|
||||||
order.code,
|
order.code,
|
||||||
order.total,
|
order.total,
|
||||||
order.get_extended_status_display(),
|
order.get_extended_status_display(),
|
||||||
@@ -437,7 +436,6 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
_('Event slug'),
|
_('Event slug'),
|
||||||
_('Event name'),
|
|
||||||
_('Order code'),
|
_('Order code'),
|
||||||
_('Status'),
|
_('Status'),
|
||||||
_('Email'),
|
_('Email'),
|
||||||
@@ -474,7 +472,6 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
tz = ZoneInfo(order.event.settings.timezone)
|
tz = ZoneInfo(order.event.settings.timezone)
|
||||||
row = [
|
row = [
|
||||||
self.event_object_cache[order.event_id].slug,
|
self.event_object_cache[order.event_id].slug,
|
||||||
str(self.event_object_cache[order.event_id].name),
|
|
||||||
order.code,
|
order.code,
|
||||||
_("canceled") if op.canceled else order.get_extended_status_display(),
|
_("canceled") if op.canceled else order.get_extended_status_display(),
|
||||||
order.email,
|
order.email,
|
||||||
@@ -553,7 +550,6 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
_('Event slug'),
|
_('Event slug'),
|
||||||
_('Event name'),
|
|
||||||
_('Order code'),
|
_('Order code'),
|
||||||
_('Position ID'),
|
_('Position ID'),
|
||||||
_('Status'),
|
_('Status'),
|
||||||
@@ -605,9 +601,10 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
]
|
]
|
||||||
|
|
||||||
questions = list(Question.objects.filter(event__in=self.events))
|
questions = list(Question.objects.filter(event__in=self.events))
|
||||||
options = defaultdict(list)
|
options = {}
|
||||||
for q in questions:
|
for q in questions:
|
||||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||||
|
options[q.pk] = []
|
||||||
if form_data['group_multiple_choice']:
|
if form_data['group_multiple_choice']:
|
||||||
for o in q.options.all():
|
for o in q.options.all():
|
||||||
options[q.pk].append(o)
|
options[q.pk].append(o)
|
||||||
@@ -617,9 +614,6 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
headers.append(str(q.question) + ' – ' + str(o.answer))
|
headers.append(str(q.question) + ' – ' + str(o.answer))
|
||||||
options[q.pk].append(o)
|
options[q.pk].append(o)
|
||||||
else:
|
else:
|
||||||
if q.type == Question.TYPE_CHOICE:
|
|
||||||
for o in q.options.all():
|
|
||||||
options[q.pk].append(o)
|
|
||||||
headers.append(str(q.question))
|
headers.append(str(q.question))
|
||||||
headers += [
|
headers += [
|
||||||
_('Company'),
|
_('Company'),
|
||||||
@@ -657,7 +651,6 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone)
|
tz = ZoneInfo(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,
|
||||||
str(self.event_object_cache[order.event_id].name),
|
|
||||||
order.code,
|
order.code,
|
||||||
op.positionid,
|
op.positionid,
|
||||||
_("canceled") if op.canceled else order.get_extended_status_display(),
|
_("canceled") if op.canceled else order.get_extended_status_display(),
|
||||||
@@ -729,7 +722,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
for a in op.answers.all():
|
for a in op.answers.all():
|
||||||
# We do not want to localize Date, Time and Datetime question answers, as those can lead
|
# We do not want to localize Date, Time and Datetime question answers, as those can lead
|
||||||
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
|
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
|
||||||
if a.question.type in (Question.TYPE_CHOICE_MULTIPLE, Question.TYPE_CHOICE):
|
if a.question.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||||
acache[a.question_id] = set(o.pk for o in a.options.all())
|
acache[a.question_id] = set(o.pk for o in a.options.all())
|
||||||
elif a.question.type in Question.UNLOCALIZED_TYPES:
|
elif a.question.type in Question.UNLOCALIZED_TYPES:
|
||||||
acache[a.question_id] = a.answer
|
acache[a.question_id] = a.answer
|
||||||
@@ -742,10 +735,6 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
else:
|
else:
|
||||||
for o in options[q.pk]:
|
for o in options[q.pk]:
|
||||||
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
|
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
|
||||||
elif q.type == Question.TYPE_CHOICE:
|
|
||||||
# Join is only necessary if the question type was modified but also keeps the code simpler here
|
|
||||||
# as we'd otherwise need some [0] and existance checks
|
|
||||||
row.append(", ".join(str(o.answer) for o in options[q.pk] if o.pk in acache.get(q.pk, set())))
|
|
||||||
else:
|
else:
|
||||||
row.append(acache.get(q.pk, ''))
|
row.append(acache.get(q.pk, ''))
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ from django import forms
|
|||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.forms.models import ModelFormMetaclass
|
from django.forms.models import ModelFormMetaclass
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from formtools.wizard.views import SessionWizardView
|
from formtools.wizard.views import SessionWizardView
|
||||||
from hierarkey.forms import HierarkeyForm
|
from hierarkey.forms import HierarkeyForm
|
||||||
@@ -86,43 +85,6 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class MarkdownTextarea(forms.Textarea):
|
|
||||||
|
|
||||||
def _render(self, template_name, context, renderer=None):
|
|
||||||
return mark_safe(
|
|
||||||
'<div class="i18n-form-group">%s<div class="i18n-field-markdown-note">%s</div></div>' % (
|
|
||||||
super()._render(template_name, context, renderer=None),
|
|
||||||
_("You can use {markup_name} in this field.").format(
|
|
||||||
markup_name='<a href="https://docs.pretix.eu/en/latest/user/markdown.html" target="_blank">Markdown</a>'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class I18nMarkdownTextarea(i18nfield.forms.I18nTextarea):
|
|
||||||
def format_output(self, rendered_widgets) -> str:
|
|
||||||
rendered_widgets = rendered_widgets + [
|
|
||||||
'<div class="i18n-field-markdown-note">%s</div>' % (
|
|
||||||
_("You can use {markup_name} in this field.").format(
|
|
||||||
markup_name='<a href="https://docs.pretix.eu/en/latest/user/markdown.html" target="_blank">Markdown</a>'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
return super().format_output(rendered_widgets)
|
|
||||||
|
|
||||||
|
|
||||||
class I18nMarkdownTextInput(i18nfield.forms.I18nTextInput):
|
|
||||||
def format_output(self, rendered_widgets) -> str:
|
|
||||||
rendered_widgets = rendered_widgets + [
|
|
||||||
'<div class="i18n-field-markdown-note">%s</div>' % (
|
|
||||||
_("You can use {markup_name} in this field.").format(
|
|
||||||
markup_name='<a href="https://docs.pretix.eu/en/latest/user/markdown.html" target="_blank">Markdown</a>'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
return super().format_output(rendered_widgets)
|
|
||||||
|
|
||||||
|
|
||||||
SECRET_REDACTED = '*****'
|
SECRET_REDACTED = '*****'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -45,13 +44,10 @@ from django.contrib.auth.password_validation import (
|
|||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from pretix.base.metrics import pretix_failed_logins
|
|
||||||
from pretix.base.models import User
|
from pretix.base.models import User
|
||||||
from pretix.helpers.dicts import move_to_end
|
from pretix.helpers.dicts import move_to_end
|
||||||
from pretix.helpers.http import get_client_ip
|
from pretix.helpers.http import get_client_ip
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(forms.Form):
|
class LoginForm(forms.Form):
|
||||||
"""
|
"""
|
||||||
@@ -59,7 +55,6 @@ class LoginForm(forms.Form):
|
|||||||
username/password logins.
|
username/password logins.
|
||||||
"""
|
"""
|
||||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||||
origin = forms.CharField(widget=forms.HiddenInput, required=False)
|
|
||||||
|
|
||||||
error_messages = {
|
error_messages = {
|
||||||
'invalid_login': _("This combination of credentials is not known to our system."),
|
'invalid_login': _("This combination of credentials is not known to our system."),
|
||||||
@@ -109,16 +104,12 @@ class LoginForm(forms.Form):
|
|||||||
rc = get_redis_connection("redis")
|
rc = get_redis_connection("redis")
|
||||||
cnt = rc.get(self.ratelimit_key)
|
cnt = rc.get(self.ratelimit_key)
|
||||||
if cnt and int(cnt) > 10:
|
if cnt and int(cnt) > 10:
|
||||||
pretix_failed_logins.inc(1, reason="ratelimit")
|
|
||||||
logger.info("Backend login rejected due to rate limit.")
|
|
||||||
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
|
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
|
||||||
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
||||||
if self.user_cache is None:
|
if self.user_cache is None:
|
||||||
if self.ratelimit_key:
|
if self.ratelimit_key:
|
||||||
rc.incr(self.ratelimit_key)
|
rc.incr(self.ratelimit_key)
|
||||||
rc.expire(self.ratelimit_key, 300)
|
rc.expire(self.ratelimit_key, 300)
|
||||||
logger.info("Backend login invalid.")
|
|
||||||
pretix_failed_logins.inc(1, reason="invalid")
|
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['invalid_login'],
|
self.error_messages['invalid_login'],
|
||||||
code='invalid_login'
|
code='invalid_login'
|
||||||
@@ -140,8 +131,6 @@ class LoginForm(forms.Form):
|
|||||||
If the given user may log in, this method should return None.
|
If the given user may log in, this method should return None.
|
||||||
"""
|
"""
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
logger.info("Backend login rejected due to user inactive.")
|
|
||||||
pretix_failed_logins.inc(1, reason="inactive")
|
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['inactive'],
|
self.error_messages['inactive'],
|
||||||
code='inactive',
|
code='inactive',
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
|||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.timezone import get_current_timezone
|
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
|
||||||
@@ -86,7 +86,6 @@ from pretix.base.settings import (
|
|||||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
|
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
|
||||||
)
|
)
|
||||||
from pretix.base.templatetags.rich_text import rich_text
|
from pretix.base.templatetags.rich_text import rich_text
|
||||||
from pretix.base.timemachine import time_machine_now
|
|
||||||
from pretix.control.forms import (
|
from pretix.control.forms import (
|
||||||
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
|
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
|
||||||
)
|
)
|
||||||
@@ -607,41 +606,30 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
|
|
||||||
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
|
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
|
||||||
if item.validity_dynamic_start_choice_day_limit:
|
if item.validity_dynamic_start_choice_day_limit:
|
||||||
max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
max_date = now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
||||||
else:
|
else:
|
||||||
max_date = None
|
max_date = None
|
||||||
min_date = time_machine_now()
|
|
||||||
initial = None
|
|
||||||
if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
|
|
||||||
if pos.used_membership.date_start >= time_machine_now():
|
|
||||||
initial = min_date = pos.used_membership.date_start
|
|
||||||
max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
|
|
||||||
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
|
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
|
||||||
attrs = {}
|
attrs = {}
|
||||||
if max_date:
|
if max_date:
|
||||||
attrs['data-max'] = max_date.date().isoformat()
|
attrs['data-max'] = max_date.date().isoformat()
|
||||||
if min_date:
|
|
||||||
attrs['data-min'] = min_date.date().isoformat()
|
|
||||||
self.fields['requested_valid_from'] = forms.DateField(
|
self.fields['requested_valid_from'] = forms.DateField(
|
||||||
label=_('Start date'),
|
label=_('Start date'),
|
||||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||||
required=bool(initial),
|
required=False,
|
||||||
initial=pos.requested_valid_from or initial,
|
|
||||||
widget=DatePickerWidget(attrs),
|
widget=DatePickerWidget(attrs),
|
||||||
validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())]
|
validators=[MaxDateValidator(max_date.date())] if max_date else []
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.fields['requested_valid_from'] = forms.SplitDateTimeField(
|
self.fields['requested_valid_from'] = forms.SplitDateTimeField(
|
||||||
label=_('Start date'),
|
label=_('Start date'),
|
||||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||||
required=bool(initial),
|
required=False,
|
||||||
initial=pos.requested_valid_from or initial,
|
|
||||||
widget=SplitDateTimePickerWidget(
|
widget=SplitDateTimePickerWidget(
|
||||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||||
min_date=min_date,
|
|
||||||
max_date=max_date
|
max_date=max_date
|
||||||
),
|
),
|
||||||
validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
|
validators=[MaxDateTimeValidator(max_date)] if max_date else []
|
||||||
)
|
)
|
||||||
|
|
||||||
add_fields = {}
|
add_fields = {}
|
||||||
@@ -1035,7 +1023,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
self.all_optional = kwargs.pop('all_optional', False)
|
self.all_optional = kwargs.pop('all_optional', False)
|
||||||
|
|
||||||
kwargs.setdefault('initial', {})
|
kwargs.setdefault('initial', {})
|
||||||
if (not kwargs.get('instance') or not kwargs['instance'].country) and not kwargs["initial"].get("country"):
|
if not kwargs.get('instance') or not kwargs['instance'].country:
|
||||||
kwargs['initial']['country'] = guess_country_from_request(self.request, self.event)
|
kwargs['initial']['country'] = guess_country_from_request(self.request, self.event)
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -1171,7 +1159,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
self.instance.vat_id_validated = False
|
self.instance.vat_id_validated = False
|
||||||
messages.warning(self.request, e.message)
|
messages.warning(self.request, e.message)
|
||||||
else:
|
else:
|
||||||
raise ValidationError({"vat_id": e.message})
|
raise ValidationError(e.message)
|
||||||
except VATIDTemporaryError as e:
|
except VATIDTemporaryError as e:
|
||||||
self.instance.vat_id_validated = False
|
self.instance.vat_id_validated = False
|
||||||
if self.request and self.vat_warning:
|
if self.request and self.vat_warning:
|
||||||
|
|||||||
@@ -1,112 +1,63 @@
|
|||||||
from bootstrap3.renderers import FieldRenderer
|
#
|
||||||
from bootstrap3.text import text_value
|
# This file is part of pretix (Community Edition).
|
||||||
from django.forms import CheckboxInput, CheckboxSelectMultiple, RadioSelect
|
#
|
||||||
from django.forms.utils import flatatt
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
from django.utils.html import format_html
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
from django.utils.safestring import mark_safe
|
#
|
||||||
from django.utils.translation import pgettext
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_label(content, label_for=None, label_class=None, label_title='', label_id='', optional=False, is_valid=None, attrs=None):
|
class FieldRenderer(BaseFieldRenderer):
|
||||||
"""
|
# Local application of https://github.com/zostera/django-bootstrap3/pull/859
|
||||||
Render a label with content
|
|
||||||
"""
|
|
||||||
attrs = attrs or {}
|
|
||||||
if label_for:
|
|
||||||
attrs['for'] = label_for
|
|
||||||
if label_class:
|
|
||||||
attrs['class'] = label_class
|
|
||||||
if label_title:
|
|
||||||
attrs['title'] = label_title
|
|
||||||
if label_id:
|
|
||||||
attrs['id'] = label_id
|
|
||||||
|
|
||||||
opt = ""
|
def post_widget_render(self, html):
|
||||||
|
if isinstance(self.widget, CheckboxSelectMultiple):
|
||||||
if is_valid is not None:
|
html = self.list_to_class(html, "checkbox")
|
||||||
if is_valid:
|
elif isinstance(self.widget, RadioSelect):
|
||||||
validation_text = pgettext('form', 'is valid')
|
html = self.list_to_class(html, "radio")
|
||||||
else:
|
elif isinstance(self.widget, SelectDateWidget):
|
||||||
validation_text = pgettext('form', 'has errors')
|
html = self.fix_date_select_input(html)
|
||||||
opt += '<strong class="sr-only"> {}</strong>'.format(validation_text)
|
elif isinstance(self.widget, ClearableFileInput):
|
||||||
|
html = self.fix_clearable_file_input(html)
|
||||||
if text_value(content) == ' ':
|
elif isinstance(self.widget, CheckboxInput):
|
||||||
# Empty label, e.g. checkbox
|
html = self.put_inside_label(html)
|
||||||
attrs.setdefault('class', '')
|
|
||||||
attrs['class'] += ' label-empty'
|
|
||||||
# usually checkboxes have overall empty labels and special labels per checkbox
|
|
||||||
# => remove for-attribute as well as "required"-text appended to label
|
|
||||||
if 'for' in attrs:
|
|
||||||
del attrs['for']
|
|
||||||
else:
|
|
||||||
opt += '<i class="sr-only label-required">, {}</i>'.format(pgettext('form', 'required')) if not optional else ''
|
|
||||||
|
|
||||||
builder = '<{tag}{attrs}>{content}{opt}</{tag}>'
|
|
||||||
return format_html(
|
|
||||||
builder,
|
|
||||||
tag='label',
|
|
||||||
attrs=mark_safe(flatatt(attrs)) if attrs else '',
|
|
||||||
opt=mark_safe(opt),
|
|
||||||
content=text_value(content),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PretixFieldRenderer(FieldRenderer):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
kwargs['layout'] = 'horizontal'
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.is_group_widget = isinstance(self.widget, (CheckboxSelectMultiple, RadioSelect, )) or (self.is_multi_widget and len(self.widget.widgets) > 1)
|
|
||||||
|
|
||||||
def add_label(self, html):
|
|
||||||
attrs = {}
|
|
||||||
label = self.get_label()
|
|
||||||
|
|
||||||
if hasattr(self.field.field, '_show_required'):
|
|
||||||
# e.g. payment settings forms where a field is only required if the payment provider is active
|
|
||||||
required = self.field.field._show_required
|
|
||||||
elif hasattr(self.field.field, '_required'):
|
|
||||||
# e.g. payment settings forms where a field is only required if the payment provider is active
|
|
||||||
required = self.field.field._required
|
|
||||||
else:
|
|
||||||
required = self.field.field.required
|
|
||||||
|
|
||||||
if self.field.form.is_bound:
|
|
||||||
is_valid = len(self.field.errors) == 0
|
|
||||||
else:
|
|
||||||
is_valid = None
|
|
||||||
|
|
||||||
if self.is_group_widget:
|
|
||||||
label_for = ""
|
|
||||||
label_id = "legend-{}".format(self.field.html_name)
|
|
||||||
else:
|
|
||||||
label_for = self.field.id_for_label
|
|
||||||
label_id = ""
|
|
||||||
|
|
||||||
if hasattr(self.field.field, 'question') and self.field.field.question.identifier:
|
|
||||||
attrs["data-identifier"] = self.field.field.question.identifier
|
|
||||||
|
|
||||||
html = render_label(
|
|
||||||
label,
|
|
||||||
label_for=label_for,
|
|
||||||
label_class=self.get_label_class(),
|
|
||||||
label_id=label_id,
|
|
||||||
attrs=attrs,
|
|
||||||
optional=not required and not isinstance(self.widget, CheckboxInput),
|
|
||||||
is_valid=is_valid
|
|
||||||
) + html
|
|
||||||
return html
|
return html
|
||||||
|
|
||||||
def wrap_label_and_field(self, html):
|
|
||||||
if self.is_group_widget:
|
|
||||||
attrs = ' role="group" aria-labelledby="legend-{}"'.format(self.field.html_name)
|
|
||||||
else:
|
|
||||||
attrs = ''
|
|
||||||
return '<div class="{klass}"{attrs}>{html}</div>'.format(klass=self.get_form_group_class(), html=html, attrs=attrs)
|
|
||||||
|
|
||||||
def wrap_widget(self, html):
|
class InlineFieldRenderer(BaseInlineFieldRenderer):
|
||||||
if isinstance(self.widget, CheckboxInput):
|
# Local application of https://github.com/zostera/django-bootstrap3/pull/859
|
||||||
css_class = "checkbox"
|
|
||||||
if self.field.field.disabled:
|
def post_widget_render(self, html):
|
||||||
css_class += " disabled"
|
if isinstance(self.widget, CheckboxSelectMultiple):
|
||||||
html = f'<div class="{css_class}">{html}</div>'
|
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
|
return html
|
||||||
|
|||||||
@@ -32,13 +32,13 @@
|
|||||||
# 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 re
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import BaseValidator
|
from django.core.validators import BaseValidator
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
|
|
||||||
from pretix.helpers.format import format_map
|
|
||||||
|
|
||||||
|
|
||||||
class PlaceholderValidator(BaseValidator):
|
class PlaceholderValidator(BaseValidator):
|
||||||
"""
|
"""
|
||||||
@@ -47,12 +47,6 @@ class PlaceholderValidator(BaseValidator):
|
|||||||
which are not presented in taken list.
|
which are not presented in taken list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
error_message = _(
|
|
||||||
'There is an error with your placeholder syntax. Please check that the opening "{" and closing "}" curly '
|
|
||||||
'brackets on your placeholders match up. '
|
|
||||||
'Please note: to use literal "{" or "}", you need to double them as "{{" and "}}".'
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, limit_value):
|
def __init__(self, limit_value):
|
||||||
super().__init__(limit_value)
|
super().__init__(limit_value)
|
||||||
self.limit_value = limit_value
|
self.limit_value = limit_value
|
||||||
@@ -63,15 +57,22 @@ class PlaceholderValidator(BaseValidator):
|
|||||||
self.__call__(v)
|
self.__call__(v)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
if value.count('{') != value.count('}'):
|
||||||
format_map(value, {key.strip('{}'): "" for key in self.limit_value}, raise_on_missing=True)
|
|
||||||
except ValueError:
|
|
||||||
raise ValidationError(self.error_message, code='invalid_placeholder_syntax')
|
|
||||||
except KeyError as e:
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('Invalid placeholder: {%(value)s}'),
|
_('Invalid placeholder syntax: You used a different number of "{" than of "}".'),
|
||||||
|
code='invalid_placeholder_syntax',
|
||||||
|
)
|
||||||
|
|
||||||
|
data_placeholders = list(re.findall(r'({[^}]*})', value, re.X))
|
||||||
|
invalid_placeholders = []
|
||||||
|
for placeholder in data_placeholders:
|
||||||
|
if placeholder not in self.limit_value:
|
||||||
|
invalid_placeholders.append(placeholder)
|
||||||
|
if invalid_placeholders:
|
||||||
|
raise ValidationError(
|
||||||
|
_('Invalid placeholder(s): %(value)s'),
|
||||||
code='invalid_placeholders',
|
code='invalid_placeholders',
|
||||||
params={'value': e.args[0]})
|
params={'value': ", ".join(invalid_placeholders,)})
|
||||||
|
|
||||||
def clean(self, x):
|
def clean(self, x):
|
||||||
return x
|
return x
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
# 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
|
from datetime import date
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.formats import get_format
|
from django.utils.formats import get_format
|
||||||
@@ -188,11 +188,11 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
|||||||
time_attrs['autocomplete'] = 'off'
|
time_attrs['autocomplete'] = 'off'
|
||||||
if min_date:
|
if min_date:
|
||||||
date_attrs['data-min'] = (
|
date_attrs['data-min'] = (
|
||||||
min_date if not isinstance(min_date, datetime) else min_date.astimezone(get_current_timezone()).date()
|
min_date if isinstance(min_date, date) else min_date.astimezone(get_current_timezone()).date()
|
||||||
).isoformat()
|
).isoformat()
|
||||||
if max_date:
|
if max_date:
|
||||||
date_attrs['data-max'] = (
|
date_attrs['data-max'] = (
|
||||||
max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date()
|
max_date if isinstance(max_date, date) else max_date.astimezone(get_current_timezone()).date()
|
||||||
).isoformat()
|
).isoformat()
|
||||||
|
|
||||||
def date_placeholder():
|
def date_placeholder():
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ 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(event=self.event, pdf_support_required=True).items():
|
for family, styles in get_fonts().items():
|
||||||
if family == self.event.settings.invoice_renderer_font:
|
if family == self.event.settings.invoice_renderer_font:
|
||||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
||||||
self.font_regular = family
|
self.font_regular = family
|
||||||
@@ -625,7 +625,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
)]
|
)]
|
||||||
else:
|
else:
|
||||||
tdata = [(
|
tdata = [(
|
||||||
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
|
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['BoldRight']),
|
||||||
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
|
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
|
||||||
Paragraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
|
Paragraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -268,10 +268,7 @@ def metric_values():
|
|||||||
dkey = key.decode("utf-8")
|
dkey = key.decode("utf-8")
|
||||||
splitted = dkey.split("{", 2)
|
splitted = dkey.split("{", 2)
|
||||||
value = float(value.decode("utf-8"))
|
value = float(value.decode("utf-8"))
|
||||||
if len(splitted) == 1:
|
metrics[splitted[0]]["{" + splitted[1]] = value
|
||||||
metrics[splitted[0]][""] = value
|
|
||||||
else:
|
|
||||||
metrics[splitted[0]]["{" + splitted[1]] = value
|
|
||||||
|
|
||||||
# Aliases
|
# Aliases
|
||||||
aliases = {
|
aliases = {
|
||||||
@@ -317,5 +314,3 @@ pretix_task_runs_total = Counter("pretix_task_runs_total", "Total calls to a cel
|
|||||||
["task_name", "status"])
|
["task_name", "status"])
|
||||||
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
|
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
|
||||||
["task_name"])
|
["task_name"])
|
||||||
pretix_successful_logins = Counter("pretix_logins_successful", "Successful logins", [])
|
|
||||||
pretix_failed_logins = Counter("pretix_logins_failed", "Failed logins", ["reason"])
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from urllib.parse import urlparse, urlsplit
|
from urllib.parse import urlsplit
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -40,7 +40,6 @@ from pretix.base.settings import global_settings_object
|
|||||||
from pretix.multidomain.urlreverse import (
|
from pretix.multidomain.urlreverse import (
|
||||||
get_event_domain, get_organizer_domain,
|
get_event_domain, get_organizer_domain,
|
||||||
)
|
)
|
||||||
from pretix.presale.style import get_fonts
|
|
||||||
|
|
||||||
_supported = None
|
_supported = None
|
||||||
|
|
||||||
@@ -241,14 +240,6 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def process_response(self, request, resp):
|
def process_response(self, request, resp):
|
||||||
def nested_dict_values(d):
|
|
||||||
for v in d.values():
|
|
||||||
if isinstance(v, dict):
|
|
||||||
yield from nested_dict_values(v)
|
|
||||||
else:
|
|
||||||
if isinstance(v, str):
|
|
||||||
yield v
|
|
||||||
|
|
||||||
url = resolve(request.path_info)
|
url = resolve(request.path_info)
|
||||||
|
|
||||||
if settings.DEBUG and resp.status_code >= 400:
|
if settings.DEBUG and resp.status_code >= 400:
|
||||||
@@ -268,14 +259,6 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
if gs.settings.leaflet_tiles:
|
if gs.settings.leaflet_tiles:
|
||||||
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
||||||
|
|
||||||
font_src = set()
|
|
||||||
if hasattr(request, 'event'):
|
|
||||||
for font in get_fonts(request.event, pdf_support_required=False).values():
|
|
||||||
for path in list(nested_dict_values(font)):
|
|
||||||
font_location = urlparse(path)
|
|
||||||
if font_location.scheme and font_location.netloc:
|
|
||||||
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
|
|
||||||
|
|
||||||
h = {
|
h = {
|
||||||
'default-src': ["{static}"],
|
'default-src': ["{static}"],
|
||||||
'script-src': ['{static}'],
|
'script-src': ['{static}'],
|
||||||
@@ -284,7 +267,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
|||||||
'style-src': ["{static}", "{media}"],
|
'style-src': ["{static}", "{media}"],
|
||||||
'connect-src': ["{dynamic}", "{media}"],
|
'connect-src': ["{dynamic}", "{media}"],
|
||||||
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
||||||
'font-src': ["{static}"] + list(font_src),
|
'font-src': ["{static}"],
|
||||||
'media-src': ["{static}", "data:"],
|
'media-src': ["{static}", "data:"],
|
||||||
# form-action is not only used to match on form actions, but also on URLs
|
# form-action is not only used to match on form actions, but also on URLs
|
||||||
# form-actions redirect to. In the context of e.g. payment providers or
|
# form-actions redirect to. In the context of e.g. payment providers or
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 4.2.9 on 2024-01-30 11:11
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("pretixbase", "0256_itemvariation_unavail_modes"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="item",
|
|
||||||
name="default_price",
|
|
||||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=13),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Generated by Django 4.2.10 on 2024-03-15 09:59
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("pretixbase", "0257_item_default_price_not_null"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="order",
|
|
||||||
name="organizer",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="orders",
|
|
||||||
to="pretixbase.organizer",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="orderposition",
|
|
||||||
name="organizer",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="order_positions",
|
|
||||||
to="pretixbase.organizer",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="order",
|
|
||||||
constraint=models.UniqueConstraint(
|
|
||||||
fields=("organizer", "code"), name="order_organizer_code_uniq"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="orderposition",
|
|
||||||
constraint=models.UniqueConstraint(
|
|
||||||
models.F("organizer"),
|
|
||||||
models.F("secret"),
|
|
||||||
name="orderposition_organizer_secret_uniq",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 4.2.10 on 2024-04-02 11:21
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("pretixbase", "0258_uniq_indx"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="team",
|
|
||||||
name="require_2fa",
|
|
||||||
field=models.BooleanField(default=False),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Generated by Django 4.2.10 on 2024-04-02 15:16
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("pretixbase", "0259_team_require_2fa"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterIndexTogether(
|
|
||||||
name="reusablemedium",
|
|
||||||
index_together=set(),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Generated by Django 4.2.10 on 2024-04-02 15:37
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
import pretix.helpers.countries
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("pretixbase", "0260_alter_reusablemedium_index_together"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="UserKnownLoginSource",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True, primary_key=True, serialize=False
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("agent_type", models.CharField(max_length=255, null=True)),
|
|
||||||
("device_type", models.CharField(max_length=255, null=True)),
|
|
||||||
("os_type", models.CharField(max_length=255, null=True)),
|
|
||||||
(
|
|
||||||
"country",
|
|
||||||
pretix.helpers.countries.FastCountryField(
|
|
||||||
countries=pretix.helpers.countries.CachedCountries,
|
|
||||||
max_length=2,
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("last_seen", models.DateTimeField()),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="known_login_sources",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 4.2.10 on 2024-04-19 14:44
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("pretixbase", "0261_userknownloginsource"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="subevent",
|
|
||||||
name="comment",
|
|
||||||
field=models.TextField(null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Generated by Django 4.2.10 on 2024-04-09 07:32
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
def change_currencies(apps, schema_editor):
|
|
||||||
Event = apps.get_model("pretixbase", "Event")
|
|
||||||
Event.objects.filter(
|
|
||||||
currency__in={
|
|
||||||
'XAG', 'XAU', 'XBA', 'XBB', 'XBC', 'XBD', 'XDR', 'XPD', 'XPT', 'XSU', 'XTS', 'XUA',
|
|
||||||
}
|
|
||||||
).update(currency='XXX')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("pretixbase", "0262_subevent_comment"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RunPython(
|
|
||||||
change_currencies, migrations.RunPython.noop
|
|
||||||
)
|
|
||||||
]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Generated by Django 4.2.11 on 2024-05-16 11:07
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
import pretix.base.models.orders
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("pretixbase", "0263_auto_20240409_0732"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="order",
|
|
||||||
name="internal_secret",
|
|
||||||
field=models.CharField(
|
|
||||||
default=None,
|
|
||||||
max_length=32,
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,287 +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/>.
|
|
||||||
#
|
|
||||||
import csv
|
|
||||||
import datetime
|
|
||||||
import io
|
|
||||||
import re
|
|
||||||
from decimal import Decimal, DecimalException
|
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.core.validators import validate_integer
|
|
||||||
from django.utils import formats
|
|
||||||
from django.utils.functional import cached_property
|
|
||||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
|
||||||
|
|
||||||
from pretix.base.i18n import LazyLocaleException
|
|
||||||
from pretix.base.models import SubEvent
|
|
||||||
|
|
||||||
|
|
||||||
class DataImportError(LazyLocaleException):
|
|
||||||
def __init__(self, *args):
|
|
||||||
msg = args[0]
|
|
||||||
msgargs = args[1] if len(args) > 1 else None
|
|
||||||
self.args = args
|
|
||||||
if msgargs:
|
|
||||||
msg = _(msg) % msgargs
|
|
||||||
else:
|
|
||||||
msg = _(msg)
|
|
||||||
super().__init__(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_csv(file, length=None, mode="strict", charset=None):
|
|
||||||
file.seek(0)
|
|
||||||
data = file.read(length)
|
|
||||||
if not charset:
|
|
||||||
try:
|
|
||||||
import chardet
|
|
||||||
charset = chardet.detect(data)['encoding']
|
|
||||||
except ImportError:
|
|
||||||
charset = file.charset
|
|
||||||
data = data.decode(charset or "utf-8", mode)
|
|
||||||
# If the file was modified on a Mac, it only contains \r as line breaks
|
|
||||||
if '\r' in data and '\n' not in data:
|
|
||||||
data = data.replace('\r', '\n')
|
|
||||||
|
|
||||||
try:
|
|
||||||
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
|
|
||||||
except csv.Error:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if dialect is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
|
|
||||||
return reader
|
|
||||||
|
|
||||||
|
|
||||||
class ImportColumn:
|
|
||||||
|
|
||||||
@property
|
|
||||||
def identifier(self):
|
|
||||||
"""
|
|
||||||
Unique, internal name of the column.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@property
|
|
||||||
def verbose_name(self):
|
|
||||||
"""
|
|
||||||
Human-readable description of the column
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@property
|
|
||||||
def initial(self):
|
|
||||||
"""
|
|
||||||
Initial value for the form component
|
|
||||||
"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def default_value(self):
|
|
||||||
"""
|
|
||||||
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
|
|
||||||
option.
|
|
||||||
"""
|
|
||||||
return 'empty'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def default_label(self):
|
|
||||||
"""
|
|
||||||
Human-readable description of the default assignment of this column, defaults to "Keep empty".
|
|
||||||
"""
|
|
||||||
return gettext_lazy('Keep empty')
|
|
||||||
|
|
||||||
def __init__(self, event):
|
|
||||||
self.event = event
|
|
||||||
|
|
||||||
def static_choices(self):
|
|
||||||
"""
|
|
||||||
This will be called when rendering the form component and allows you to return a list of values that can be
|
|
||||||
selected by the user statically during import.
|
|
||||||
|
|
||||||
:return: list of 2-tuples of strings
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
def resolve(self, settings, record):
|
|
||||||
"""
|
|
||||||
This method will be called to get the raw value for this field, usually by either using a static value or
|
|
||||||
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
|
|
||||||
the default should be fine.
|
|
||||||
"""
|
|
||||||
k = settings.get(self.identifier, self.default_value)
|
|
||||||
if k == self.default_value:
|
|
||||||
return None
|
|
||||||
elif k.startswith('csv:'):
|
|
||||||
return record.get(k[4:], None) or None
|
|
||||||
elif k.startswith('static:'):
|
|
||||||
return k[7:]
|
|
||||||
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
"""
|
|
||||||
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
|
|
||||||
You do not need to include the column or row name or value in the error message as it will automatically be
|
|
||||||
included.
|
|
||||||
|
|
||||||
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
|
|
||||||
e.g. if the column is empty or does not exist in this row.
|
|
||||||
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
|
|
||||||
"""
|
|
||||||
return value
|
|
||||||
|
|
||||||
def assign(self, value, obj, **kwargs):
|
|
||||||
"""
|
|
||||||
This will be called to perform the actual import. You are supposed to set attributes on the ``obj`` or other
|
|
||||||
related objects that get passed in based on the input ``value``. This is called *before* the actual database
|
|
||||||
transaction, so the input objects do not yet have a primary key. If you want to create related objects, you
|
|
||||||
need to place them into some sort of internal queue and persist them when ``save`` is called.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def save(self, obj):
|
|
||||||
"""
|
|
||||||
This will be called to perform the actual import. This is called inside the actual database transaction and the
|
|
||||||
input object ``obj`` has already been saved to the database.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def timezone(self):
|
|
||||||
return self.event.timezone
|
|
||||||
|
|
||||||
|
|
||||||
def i18n_flat(l):
|
|
||||||
if isinstance(l.data, dict):
|
|
||||||
return l.data.values()
|
|
||||||
return [l.data]
|
|
||||||
|
|
||||||
|
|
||||||
class BooleanColumnMixin:
|
|
||||||
default_value = None
|
|
||||||
initial = "static:false"
|
|
||||||
|
|
||||||
def static_choices(self):
|
|
||||||
return (
|
|
||||||
("false", _("No")),
|
|
||||||
("true", _("Yes")),
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if not value:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if value.lower() in ("true", "1", "yes", _("Yes").lower()):
|
|
||||||
return True
|
|
||||||
elif value.lower() in ("false", "0", "no", _("No").lower()):
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
raise ValidationError(_("Could not parse {value} as a yes/no value.").format(value=value))
|
|
||||||
|
|
||||||
|
|
||||||
class DatetimeColumnMixin:
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if not value:
|
|
||||||
return
|
|
||||||
|
|
||||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
|
||||||
for format in input_formats:
|
|
||||||
try:
|
|
||||||
d = datetime.datetime.strptime(value, format)
|
|
||||||
d = d.replace(tzinfo=self.timezone)
|
|
||||||
return d
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
|
|
||||||
|
|
||||||
|
|
||||||
class DecimalColumnMixin:
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if value not in (None, ''):
|
|
||||||
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
|
|
||||||
try:
|
|
||||||
value = Decimal(value)
|
|
||||||
except (DecimalException, TypeError):
|
|
||||||
raise ValidationError(_('You entered an invalid number.'))
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class IntegerColumnMixin:
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if value is not None:
|
|
||||||
validate_integer(value)
|
|
||||||
return int(value)
|
|
||||||
|
|
||||||
|
|
||||||
class SubeventColumnMixin:
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self._subevent_cache = {}
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def subevents(self):
|
|
||||||
return list(self.event.subevents.filter(active=True).order_by('date_from'))
|
|
||||||
|
|
||||||
def static_choices(self):
|
|
||||||
return [
|
|
||||||
(str(p.pk), str(p)) for p in self.subevents
|
|
||||||
]
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if value in self._subevent_cache:
|
|
||||||
return self._subevent_cache[value]
|
|
||||||
|
|
||||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
|
||||||
for format in input_formats:
|
|
||||||
try:
|
|
||||||
d = datetime.datetime.strptime(value, format)
|
|
||||||
d = d.replace(tzinfo=self.event.timezone)
|
|
||||||
try:
|
|
||||||
se = self.event.subevents.get(
|
|
||||||
active=True,
|
|
||||||
date_from__gt=d - datetime.timedelta(seconds=1),
|
|
||||||
date_from__lt=d + datetime.timedelta(seconds=1),
|
|
||||||
)
|
|
||||||
self._subevent_cache[value] = se
|
|
||||||
return se
|
|
||||||
except SubEvent.DoesNotExist:
|
|
||||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
|
||||||
except SubEvent.MultipleObjectsReturned:
|
|
||||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
matches = [
|
|
||||||
p for p in self.subevents
|
|
||||||
if str(p.pk) == value or any(
|
|
||||||
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
|
|
||||||
]
|
|
||||||
if len(matches) == 0:
|
|
||||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
|
||||||
if len(matches) > 1:
|
|
||||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
|
||||||
|
|
||||||
self._subevent_cache[value] = matches[0]
|
|
||||||
return matches[0]
|
|
||||||
@@ -1,385 +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 decimal import Decimal
|
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.core.validators import MinLengthValidator
|
|
||||||
from django.utils.functional import cached_property
|
|
||||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
|
|
||||||
|
|
||||||
from pretix.base.modelimport import (
|
|
||||||
BooleanColumnMixin, DatetimeColumnMixin, DecimalColumnMixin, ImportColumn,
|
|
||||||
IntegerColumnMixin, i18n_flat,
|
|
||||||
)
|
|
||||||
from pretix.base.models import ItemVariation, Quota, Seat, Voucher
|
|
||||||
from pretix.base.signals import voucher_import_columns
|
|
||||||
|
|
||||||
|
|
||||||
class CodeColumn(ImportColumn):
|
|
||||||
identifier = 'code'
|
|
||||||
verbose_name = gettext_lazy('Voucher code')
|
|
||||||
default_value = None
|
|
||||||
|
|
||||||
def __init__(self, *args):
|
|
||||||
self._cached = set()
|
|
||||||
super().__init__(*args)
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if value:
|
|
||||||
MinLengthValidator(5)(value)
|
|
||||||
if value and (value in self._cached or Voucher.objects.filter(event=self.event, code=value).exists()):
|
|
||||||
raise ValidationError(_('A voucher with this code already exists.'))
|
|
||||||
self._cached.add(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.code = value
|
|
||||||
|
|
||||||
|
|
||||||
class SubeventColumn(ImportColumn):
|
|
||||||
identifier = 'subevent'
|
|
||||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.subevent = value
|
|
||||||
|
|
||||||
|
|
||||||
class MaxUsagesColumn(IntegerColumnMixin, ImportColumn):
|
|
||||||
identifier = 'max_usages'
|
|
||||||
verbose_name = gettext_lazy('Maximum usages')
|
|
||||||
default_value = None
|
|
||||||
initial = "static:1"
|
|
||||||
|
|
||||||
def static_choices(self):
|
|
||||||
return [
|
|
||||||
("1", "1")
|
|
||||||
]
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if value is None and previous_values.get("code"):
|
|
||||||
raise ValidationError(_('The maximum number of usages must be set.'))
|
|
||||||
return super().clean(value, previous_values)
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.max_usages = value if value is not None else 1
|
|
||||||
|
|
||||||
|
|
||||||
class MinUsagesColumn(IntegerColumnMixin, ImportColumn):
|
|
||||||
identifier = 'min_usages'
|
|
||||||
verbose_name = gettext_lazy('Minimum usages')
|
|
||||||
default_value = None
|
|
||||||
initial = "static:1"
|
|
||||||
|
|
||||||
def static_choices(self):
|
|
||||||
return [
|
|
||||||
("1", "1")
|
|
||||||
]
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.min_usages = value if value is not None else 1
|
|
||||||
|
|
||||||
|
|
||||||
class BudgetColumn(DecimalColumnMixin, ImportColumn):
|
|
||||||
identifier = 'budget'
|
|
||||||
verbose_name = gettext_lazy('Maximum discount budget')
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.budget = value
|
|
||||||
|
|
||||||
|
|
||||||
class ValidUntilColumn(DatetimeColumnMixin, ImportColumn):
|
|
||||||
identifier = 'valid_until'
|
|
||||||
verbose_name = gettext_lazy('Valid until')
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.valid_until = value
|
|
||||||
|
|
||||||
|
|
||||||
class BlockQuotaColumn(BooleanColumnMixin, ImportColumn):
|
|
||||||
identifier = 'block_quota'
|
|
||||||
verbose_name = gettext_lazy('Reserve ticket from quota')
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.block_quota = value
|
|
||||||
|
|
||||||
|
|
||||||
class AllowIgnoreQuotaColumn(BooleanColumnMixin, ImportColumn):
|
|
||||||
identifier = 'allow_ignore_quota'
|
|
||||||
verbose_name = gettext_lazy('Allow to bypass quota')
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.allow_ignore_quota = value
|
|
||||||
|
|
||||||
|
|
||||||
class PriceModeColumn(ImportColumn):
|
|
||||||
identifier = 'price_mode'
|
|
||||||
verbose_name = gettext_lazy('Price mode')
|
|
||||||
default_value = None
|
|
||||||
initial = 'static:none'
|
|
||||||
|
|
||||||
def static_choices(self):
|
|
||||||
return Voucher.PRICE_MODES
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
d = dict(Voucher.PRICE_MODES)
|
|
||||||
reverse = {v: k for k, v in Voucher.PRICE_MODES}
|
|
||||||
if value in d:
|
|
||||||
return value
|
|
||||||
elif value in reverse:
|
|
||||||
return reverse[value]
|
|
||||||
else:
|
|
||||||
raise ValidationError(_("Could not parse {value} as a price mode, use one of {options}.").format(
|
|
||||||
value=value, options=', '.join(d.keys())
|
|
||||||
))
|
|
||||||
|
|
||||||
def assign(self, value, voucher: Voucher, **kwargs):
|
|
||||||
voucher.price_mode = value
|
|
||||||
|
|
||||||
|
|
||||||
class ValueColumn(DecimalColumnMixin, ImportColumn):
|
|
||||||
identifier = 'value'
|
|
||||||
verbose_name = gettext_lazy('Voucher value')
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
value = super().clean(value, previous_values)
|
|
||||||
if value and previous_values.get("price_mode") == "none":
|
|
||||||
raise ValidationError(_("It is pointless to set a value without a price mode."))
|
|
||||||
return value
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.value = value or Decimal("0.00")
|
|
||||||
|
|
||||||
|
|
||||||
class ItemColumn(ImportColumn):
|
|
||||||
identifier = 'item'
|
|
||||||
verbose_name = gettext_lazy('Product')
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def items(self):
|
|
||||||
return list(self.event.items.filter(active=True))
|
|
||||||
|
|
||||||
def static_choices(self):
|
|
||||||
return [
|
|
||||||
(str(p.pk), str(p)) for p in self.items
|
|
||||||
]
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if not value:
|
|
||||||
return
|
|
||||||
matches = [
|
|
||||||
p for p in self.items
|
|
||||||
if str(p.pk) == value or (p.internal_name and p.internal_name == value) or any(
|
|
||||||
(v and v == value) for v in i18n_flat(p.name))
|
|
||||||
]
|
|
||||||
if len(matches) == 0:
|
|
||||||
raise ValidationError(_("No matching product was found."))
|
|
||||||
if len(matches) > 1:
|
|
||||||
raise ValidationError(_("Multiple matching products were found."))
|
|
||||||
return matches[0]
|
|
||||||
|
|
||||||
def assign(self, value, voucher, **kwargs):
|
|
||||||
voucher.item = value
|
|
||||||
|
|
||||||
|
|
||||||
class VariationColumn(ImportColumn):
|
|
||||||
identifier = 'variation'
|
|
||||||
verbose_name = gettext_lazy('Product variation')
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def items(self):
|
|
||||||
return list(ItemVariation.objects.filter(
|
|
||||||
active=True, item__active=True, item__event=self.event
|
|
||||||
).select_related('item'))
|
|
||||||
|
|
||||||
def static_choices(self):
|
|
||||||
return [
|
|
||||||
(str(p.pk), '{} – {}'.format(p.item, p.value)) for p in self.items
|
|
||||||
]
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if value:
|
|
||||||
matches = [
|
|
||||||
p for p in self.items
|
|
||||||
if (str(p.pk) == value or any((v and v == value) for v in i18n_flat(p.value))) and p.item_id == previous_values['item'].pk
|
|
||||||
]
|
|
||||||
if len(matches) == 0:
|
|
||||||
raise ValidationError(_("No matching variation was found."))
|
|
||||||
if len(matches) > 1:
|
|
||||||
raise ValidationError(_("Multiple matching variations were found."))
|
|
||||||
return matches[0]
|
|
||||||
return value
|
|
||||||
|
|
||||||
def assign(self, value, voucher: Voucher, **kwargs):
|
|
||||||
voucher.variation = value
|
|
||||||
|
|
||||||
|
|
||||||
class QuotaColumn(ImportColumn):
|
|
||||||
identifier = 'quota'
|
|
||||||
verbose_name = gettext_lazy('Quota')
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def quotas(self):
|
|
||||||
return list(Quota.objects.filter(
|
|
||||||
event=self.event
|
|
||||||
))
|
|
||||||
|
|
||||||
def static_choices(self):
|
|
||||||
return [
|
|
||||||
(str(q.pk), q.name) for q in self.quotas
|
|
||||||
]
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if value:
|
|
||||||
if previous_values.get('item'):
|
|
||||||
raise ValidationError(_("You cannot specify a quota if you specified a product."))
|
|
||||||
matches = [
|
|
||||||
q for q in self.quotas
|
|
||||||
if str(q.pk) == value or q.name == value
|
|
||||||
]
|
|
||||||
if len(matches) == 0:
|
|
||||||
raise ValidationError(_("No matching variation was found."))
|
|
||||||
if len(matches) > 1:
|
|
||||||
raise ValidationError(_("Multiple matching variations were found."))
|
|
||||||
|
|
||||||
return matches[0]
|
|
||||||
return value
|
|
||||||
|
|
||||||
def assign(self, value, voucher: Voucher, **kwargs):
|
|
||||||
voucher.quota = value
|
|
||||||
|
|
||||||
|
|
||||||
class SeatColumn(ImportColumn):
|
|
||||||
identifier = 'seat'
|
|
||||||
verbose_name = gettext_lazy('Seat ID')
|
|
||||||
|
|
||||||
def __init__(self, *args):
|
|
||||||
self._cached = set()
|
|
||||||
super().__init__(*args)
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
|
||||||
if value:
|
|
||||||
if self.event.has_subevents:
|
|
||||||
if not previous_values.get('subevent'):
|
|
||||||
raise ValidationError(_('You need to choose a date if you select a seat.'))
|
|
||||||
|
|
||||||
try:
|
|
||||||
value = Seat.objects.get(
|
|
||||||
event=self.event,
|
|
||||||
seat_guid=value,
|
|
||||||
subevent=previous_values.get('subevent')
|
|
||||||
)
|
|
||||||
except Seat.MultipleObjectsReturned:
|
|
||||||
raise ValidationError(_('Multiple matching seats were found.'))
|
|
||||||
except Seat.DoesNotExist:
|
|
||||||
raise ValidationError(_('No matching seat was found.'))
|
|
||||||
if not value.is_available() or value in self._cached:
|
|
||||||
raise ValidationError(
|
|
||||||
_('The seat you selected has already been taken. Please select a different seat.'))
|
|
||||||
|
|
||||||
if previous_values.get("quota"):
|
|
||||||
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
|
|
||||||
|
|
||||||
if previous_values.get('max_usages', 1) > 1 or previous_values.get('min_usages', 1) > 1:
|
|
||||||
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
|
|
||||||
|
|
||||||
if previous_values.get("item") and value.product != previous_values.get("item"):
|
|
||||||
raise ValidationError(
|
|
||||||
_('You need to choose the product "{prod}" for this seat.').format(prod=value.product)
|
|
||||||
)
|
|
||||||
|
|
||||||
self._cached.add(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
def assign(self, value, voucher: Voucher, **kwargs):
|
|
||||||
voucher.seat = value
|
|
||||||
|
|
||||||
|
|
||||||
class TagColumn(ImportColumn):
|
|
||||||
identifier = 'tag'
|
|
||||||
verbose_name = gettext_lazy('Tag')
|
|
||||||
|
|
||||||
def assign(self, value, voucher: Voucher, **kwargs):
|
|
||||||
voucher.tag = value or ''
|
|
||||||
|
|
||||||
|
|
||||||
class CommentColumn(ImportColumn):
|
|
||||||
identifier = 'comment'
|
|
||||||
verbose_name = gettext_lazy('Comment')
|
|
||||||
|
|
||||||
def assign(self, value, voucher: Voucher, **kwargs):
|
|
||||||
voucher.comment = value or ''
|
|
||||||
|
|
||||||
|
|
||||||
class ShowHiddenItemsColumn(BooleanColumnMixin, ImportColumn):
|
|
||||||
identifier = 'show_hidden_items'
|
|
||||||
verbose_name = gettext_lazy('Shows hidden products that match this voucher')
|
|
||||||
initial = "static:true"
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.show_hidden_items = value
|
|
||||||
|
|
||||||
|
|
||||||
class AllAddonsIncludedColumn(BooleanColumnMixin, ImportColumn):
|
|
||||||
identifier = 'all_addons_included'
|
|
||||||
verbose_name = gettext_lazy('Offer all add-on products for free when redeeming this voucher')
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.all_addons_included = value
|
|
||||||
|
|
||||||
|
|
||||||
class AllBundlesIncludedColumn(BooleanColumnMixin, ImportColumn):
|
|
||||||
identifier = 'all_bundles_included'
|
|
||||||
verbose_name = gettext_lazy('Include all bundled products without a designated price when redeeming this voucher')
|
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
|
||||||
obj.all_bundles_included = value
|
|
||||||
|
|
||||||
|
|
||||||
def get_voucher_import_columns(event):
|
|
||||||
default = []
|
|
||||||
if event.has_subevents:
|
|
||||||
default.append(SubeventColumn(event))
|
|
||||||
default += [
|
|
||||||
CodeColumn(event),
|
|
||||||
MaxUsagesColumn(event),
|
|
||||||
MinUsagesColumn(event),
|
|
||||||
BudgetColumn(event),
|
|
||||||
ValidUntilColumn(event),
|
|
||||||
BlockQuotaColumn(event),
|
|
||||||
AllowIgnoreQuotaColumn(event),
|
|
||||||
PriceModeColumn(event),
|
|
||||||
ValueColumn(event),
|
|
||||||
ItemColumn(event),
|
|
||||||
VariationColumn(event),
|
|
||||||
QuotaColumn(event),
|
|
||||||
SeatColumn(event),
|
|
||||||
TagColumn(event),
|
|
||||||
CommentColumn(event),
|
|
||||||
ShowHiddenItemsColumn(event),
|
|
||||||
AllAddonsIncludedColumn(event),
|
|
||||||
AllBundlesIncludedColumn(event),
|
|
||||||
]
|
|
||||||
|
|
||||||
for recv, resp in voucher_import_columns.send(sender=event):
|
|
||||||
default += resp
|
|
||||||
|
|
||||||
return default
|
|
||||||
@@ -37,7 +37,9 @@ import json
|
|||||||
import operator
|
import operator
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import webauthn
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import (
|
from django.contrib.auth.models import (
|
||||||
AbstractBaseUser, BaseUserManager, PermissionsMixin,
|
AbstractBaseUser, BaseUserManager, PermissionsMixin,
|
||||||
@@ -51,13 +53,13 @@ from django.utils.timezone import now
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_otp.models import Device
|
from django_otp.models import Device
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
from u2flib_server.utils import (
|
||||||
|
pub_key_from_der, websafe_decode, websafe_encode,
|
||||||
|
)
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import build_absolute_uri
|
||||||
|
|
||||||
from ...helpers.countries import FastCountryField
|
|
||||||
from ...helpers.u2f import pub_key_from_der, websafe_decode
|
|
||||||
from .base import LoggingMixin
|
from .base import LoggingMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -418,22 +420,18 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
else:
|
else:
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
|
||||||
"""
|
"""
|
||||||
Checks if this user is part of any team that grants access of type ``perm_name``
|
Checks if this user is part of any team that grants access of type ``perm_name``
|
||||||
to the event ``event``.
|
to the event ``event``.
|
||||||
|
|
||||||
Either ``request`` or ``session_key`` are required to detect staff sessions properly.
|
|
||||||
|
|
||||||
:param organizer: The organizer of the event
|
:param organizer: The organizer of the event
|
||||||
:param event: The event to check
|
:param event: The event to check
|
||||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
:param perm_name: The permission, e.g. ``can_change_teams``
|
||||||
:param request: The current request (optional)
|
:param request: The current request (optional). Required to detect staff sessions properly.
|
||||||
:param session_key: The current session key (optional)
|
|
||||||
:return: bool
|
:return: bool
|
||||||
"""
|
"""
|
||||||
assert not (session_key and request)
|
if request and self.has_active_staff_session(request.session.session_key):
|
||||||
if (session_key or request) and self.has_active_staff_session(session_key or request.session.session_key):
|
|
||||||
return True
|
return True
|
||||||
teams = self._get_teams_for_event(organizer, event)
|
teams = self._get_teams_for_event(organizer, event)
|
||||||
if teams:
|
if teams:
|
||||||
@@ -584,15 +582,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
self.save(update_fields=['session_token'])
|
self.save(update_fields=['session_token'])
|
||||||
|
|
||||||
|
|
||||||
class UserKnownLoginSource(models.Model):
|
|
||||||
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
|
|
||||||
agent_type = models.CharField(max_length=255, null=True, blank=True)
|
|
||||||
device_type = models.CharField(max_length=255, null=True, blank=True)
|
|
||||||
os_type = models.CharField(max_length=255, null=True, blank=True)
|
|
||||||
country = FastCountryField(null=True, blank=True)
|
|
||||||
last_seen = models.DateTimeField()
|
|
||||||
|
|
||||||
|
|
||||||
class StaffSession(models.Model):
|
class StaffSession(models.Model):
|
||||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||||
date_start = models.DateTimeField(auto_now_add=True)
|
date_start = models.DateTimeField(auto_now_add=True)
|
||||||
@@ -619,12 +608,7 @@ class U2FDevice(Device):
|
|||||||
json_data = models.TextField()
|
json_data = models.TextField()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def webauthndevice(self):
|
def webauthnuser(self):
|
||||||
d = json.loads(self.json_data)
|
|
||||||
return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle']))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def webauthnpubkey(self):
|
|
||||||
d = json.loads(self.json_data)
|
d = json.loads(self.json_data)
|
||||||
# We manually need to convert the pubkey from DER format (used in our
|
# We manually need to convert the pubkey from DER format (used in our
|
||||||
# former U2F implementation) to the format required by webauthn. This
|
# former U2F implementation) to the format required by webauthn. This
|
||||||
@@ -636,7 +620,16 @@ class U2FDevice(Device):
|
|||||||
pub_key.public_numbers().x, pub_key.public_numbers().y
|
pub_key.public_numbers().x, pub_key.public_numbers().y
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return pub_key
|
return webauthn.WebAuthnUser(
|
||||||
|
d['keyHandle'],
|
||||||
|
self.user.email,
|
||||||
|
str(self.user),
|
||||||
|
settings.SITE_URL,
|
||||||
|
d['keyHandle'],
|
||||||
|
websafe_encode(pub_key),
|
||||||
|
1,
|
||||||
|
urlparse(settings.SITE_URL).netloc
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WebAuthnDevice(Device):
|
class WebAuthnDevice(Device):
|
||||||
@@ -648,9 +641,14 @@ class WebAuthnDevice(Device):
|
|||||||
sign_count = models.IntegerField(default=0)
|
sign_count = models.IntegerField(default=0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def webauthndevice(self):
|
def webauthnuser(self):
|
||||||
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id))
|
return webauthn.WebAuthnUser(
|
||||||
|
self.ukey,
|
||||||
@property
|
self.user.email,
|
||||||
def webauthnpubkey(self):
|
str(self.user),
|
||||||
return websafe_decode(self.pub_key)
|
settings.SITE_URL,
|
||||||
|
self.credential_id,
|
||||||
|
self.pub_key,
|
||||||
|
self.sign_count,
|
||||||
|
urlparse(settings.SITE_URL).netloc
|
||||||
|
)
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ class CheckinList(LoggedModel):
|
|||||||
}
|
}
|
||||||
allowed_vars = {
|
allowed_vars = {
|
||||||
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
|
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
|
||||||
'minutes_since_last_entry', 'minutes_since_first_entry', 'gate', 'entry_status',
|
'minutes_since_last_entry', 'minutes_since_first_entry', 'gate',
|
||||||
}
|
}
|
||||||
if not rules or not isinstance(rules, dict):
|
if not rules or not isinstance(rules, dict):
|
||||||
return rules
|
return rules
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
from math import ceil
|
|
||||||
from typing import Dict, Optional, Tuple
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@@ -273,7 +272,7 @@ class Discount(LoggedModel):
|
|||||||
|
|
||||||
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
||||||
# want to match multiples of 3
|
# want to match multiples of 3
|
||||||
n_groups = min(len(condition_idx_group) // self.condition_min_count, ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches))
|
n_groups = min(len(condition_idx_group) // self.condition_min_count, len(benefit_idx_group))
|
||||||
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
|
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
|
||||||
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
|
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ from zoneinfo import ZoneInfo
|
|||||||
import pytz_deprecation_shim
|
import pytz_deprecation_shim
|
||||||
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 import File
|
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.core.mail import get_connection
|
from django.core.mail import get_connection
|
||||||
from django.core.validators import (
|
from django.core.validators import (
|
||||||
@@ -68,7 +67,6 @@ from i18nfield.fields import I18nCharField, I18nTextField
|
|||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.models.fields import MultiStringField
|
from pretix.base.models.fields import MultiStringField
|
||||||
from pretix.base.reldate import RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateWrapper
|
||||||
from pretix.base.timemachine import time_machine_now
|
|
||||||
from pretix.base.validators import EventSlugBanlistValidator
|
from pretix.base.validators import EventSlugBanlistValidator
|
||||||
from pretix.helpers.database import GroupConcat
|
from pretix.helpers.database import GroupConcat
|
||||||
from pretix.helpers.daterange import daterange
|
from pretix.helpers.daterange import daterange
|
||||||
@@ -231,25 +229,17 @@ class EventMixin:
|
|||||||
else:
|
else:
|
||||||
return self.presale_end
|
return self.presale_end
|
||||||
|
|
||||||
@property
|
|
||||||
def waiting_list_active(self):
|
|
||||||
if not self.settings.waiting_list_enabled:
|
|
||||||
return False
|
|
||||||
if self.settings.waiting_list_auto_disable:
|
|
||||||
return self.settings.waiting_list_auto_disable.datetime(self) > time_machine_now()
|
|
||||||
return True
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def presale_has_ended(self):
|
def presale_has_ended(self):
|
||||||
"""
|
"""
|
||||||
Is true, when ``presale_end`` is set and in the past.
|
Is true, when ``presale_end`` is set and in the past.
|
||||||
"""
|
"""
|
||||||
if self.effective_presale_end:
|
if self.effective_presale_end:
|
||||||
return time_machine_now() > self.effective_presale_end
|
return now() > self.effective_presale_end
|
||||||
elif self.date_to:
|
elif self.date_to:
|
||||||
return time_machine_now() > self.date_to
|
return now() > self.date_to
|
||||||
else:
|
else:
|
||||||
return time_machine_now().astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date()
|
return now().astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def effective_presale_start(self):
|
def effective_presale_start(self):
|
||||||
@@ -269,15 +259,12 @@ class EventMixin:
|
|||||||
Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not
|
Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not
|
||||||
set or in the past.
|
set or in the past.
|
||||||
"""
|
"""
|
||||||
if self.effective_presale_start and time_machine_now() < self.effective_presale_start:
|
if self.effective_presale_start and now() < self.effective_presale_start:
|
||||||
return False
|
return False
|
||||||
return not self.presale_has_ended
|
return not self.presale_has_ended
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def event_microdata(self):
|
def event_microdata(self):
|
||||||
if self.settings.event_microdata:
|
|
||||||
return self.settings.event_microdata
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
eventdict = {
|
eventdict = {
|
||||||
@@ -317,11 +304,11 @@ class EventMixin:
|
|||||||
q_variation = (
|
q_variation = (
|
||||||
Q(active=True)
|
Q(active=True)
|
||||||
& Q(sales_channels__contains=channel)
|
& Q(sales_channels__contains=channel)
|
||||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()))
|
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()))
|
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||||
& Q(item__active=True)
|
& Q(item__active=True)
|
||||||
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=time_machine_now()))
|
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||||
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=time_machine_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__require_bundling=False)
|
& Q(item__require_bundling=False)
|
||||||
@@ -696,7 +683,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
@property
|
@property
|
||||||
def presale_has_ended(self):
|
def presale_has_ended(self):
|
||||||
if self.has_subevents:
|
if self.has_subevents:
|
||||||
return self.presale_end and time_machine_now() > self.presale_end
|
return self.presale_end and now() > self.presale_end
|
||||||
else:
|
else:
|
||||||
return super().presale_has_ended
|
return super().presale_has_ended
|
||||||
|
|
||||||
@@ -1027,7 +1014,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
|
|
||||||
s.object = self
|
s.object = self
|
||||||
s.pk = None
|
s.pk = None
|
||||||
if s.value.startswith('file://') and settings_hierarkey.get_declared_type(s.key) == File:
|
if s.value.startswith('file://'):
|
||||||
fi = default_storage.open(s.value[len('file://'):], 'rb')
|
fi = default_storage.open(s.value[len('file://'):], 'rb')
|
||||||
nonce = get_random_string(length=8)
|
nonce = get_random_string(length=8)
|
||||||
fname_base = clean_filename(os.path.basename(s.value))
|
fname_base = clean_filename(os.path.basename(s.value))
|
||||||
@@ -1189,8 +1176,8 @@ class Event(EventMixin, LoggedModel):
|
|||||||
)
|
)
|
||||||
).filter(
|
).filter(
|
||||||
Q(active=True) & Q(is_public=True) & (
|
Q(active=True) & Q(is_public=True) & (
|
||||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=time_machine_now() - timedelta(hours=24)))
|
Q(Q(date_to__isnull=True) & Q(date_from__gte=now() - timedelta(hours=24)))
|
||||||
| Q(date_to__gte=time_machine_now() - timedelta(hours=24))
|
| Q(date_to__gte=now() - timedelta(hours=24))
|
||||||
)
|
)
|
||||||
) # order_by doesn't make sense with I18nField
|
) # order_by doesn't make sense with I18nField
|
||||||
if ordering in ("date_ascending", "date_descending"):
|
if ordering in ("date_ascending", "date_descending"):
|
||||||
@@ -1239,9 +1226,6 @@ class Event(EventMixin, LoggedModel):
|
|||||||
if self.has_paid_things and not self.has_payment_provider:
|
if self.has_paid_things and not self.has_payment_provider:
|
||||||
issues.append(_('You have configured at least one paid product but have not enabled any payment methods.'))
|
issues.append(_('You have configured at least one paid product but have not enabled any payment methods.'))
|
||||||
|
|
||||||
if self.has_paid_things and self.currency == "XXX":
|
|
||||||
issues.append(_('You have configured at least one paid product but have not configured a currency.'))
|
|
||||||
|
|
||||||
if not self.quotas.exists():
|
if not self.quotas.exists():
|
||||||
issues.append(_('You need to configure at least one quota to sell anything.'))
|
issues.append(_('You need to configure at least one quota to sell anything.'))
|
||||||
|
|
||||||
@@ -1463,15 +1447,13 @@ class SubEvent(EventMixin, LoggedModel):
|
|||||||
)
|
)
|
||||||
frontpage_text = I18nTextField(
|
frontpage_text = I18nTextField(
|
||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
verbose_name=_("Frontpage text"),
|
verbose_name=_("Frontpage text")
|
||||||
)
|
)
|
||||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||||
related_name='subevents', verbose_name=_('Seating plan'))
|
related_name='subevents', verbose_name=_('Seating plan'))
|
||||||
|
|
||||||
comment = models.TextField(
|
items = models.ManyToManyField('Item', through='SubEventItem')
|
||||||
verbose_name=_("Internal comment"),
|
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
|
||||||
null=True, blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
last_modified = models.DateTimeField(
|
last_modified = models.DateTimeField(
|
||||||
auto_now=True, db_index=True
|
auto_now=True, db_index=True
|
||||||
@@ -1508,7 +1490,7 @@ class SubEvent(EventMixin, LoggedModel):
|
|||||||
disabled_items=Coalesce(
|
disabled_items=Coalesce(
|
||||||
Subquery(
|
Subquery(
|
||||||
SubEventItem.objects.filter(
|
SubEventItem.objects.filter(
|
||||||
Q(disabled=True) | Q(available_from__gt=time_machine_now()) | Q(available_until__lt=time_machine_now()),
|
Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()),
|
||||||
subevent=OuterRef('pk'),
|
subevent=OuterRef('pk'),
|
||||||
).order_by().values('subevent').annotate(items=GroupConcat('item_id', delimiter=',')).values('items'),
|
).order_by().values('subevent').annotate(items=GroupConcat('item_id', delimiter=',')).values('items'),
|
||||||
output_field=models.TextField(),
|
output_field=models.TextField(),
|
||||||
@@ -1519,7 +1501,7 @@ class SubEvent(EventMixin, LoggedModel):
|
|||||||
disabled_vars=Coalesce(
|
disabled_vars=Coalesce(
|
||||||
Subquery(
|
Subquery(
|
||||||
SubEventItemVariation.objects.filter(
|
SubEventItemVariation.objects.filter(
|
||||||
Q(disabled=True) | Q(available_from__gt=time_machine_now()) | Q(available_until__lt=time_machine_now()),
|
Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()),
|
||||||
subevent=OuterRef('pk'),
|
subevent=OuterRef('pk'),
|
||||||
).order_by().values('subevent').annotate(items=GroupConcat('variation_id', delimiter=',')).values('items'),
|
).order_by().values('subevent').annotate(items=GroupConcat('variation_id', delimiter=',')).values('items'),
|
||||||
output_field=models.TextField(),
|
output_field=models.TextField(),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ from django.db.models import Q
|
|||||||
from django.utils import formats
|
from django.utils import formats
|
||||||
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
|
||||||
from django.utils.timezone import is_naive, make_aware
|
from django.utils.timezone import is_naive, make_aware, now
|
||||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
from django_countries.fields import Country
|
from django_countries.fields import Country
|
||||||
from django_scopes import ScopedManager
|
from django_scopes import ScopedManager
|
||||||
@@ -65,7 +65,6 @@ from pretix.base.models import fields
|
|||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.models.fields import MultiStringField
|
from pretix.base.models.fields import MultiStringField
|
||||||
from pretix.base.models.tax import TaxedPrice
|
from pretix.base.models.tax import TaxedPrice
|
||||||
from pretix.base.timemachine import time_machine_now
|
|
||||||
|
|
||||||
from ...helpers.images import ImageSizeValidator
|
from ...helpers.images import ImageSizeValidator
|
||||||
from ..media import MEDIA_TYPES
|
from ..media import MEDIA_TYPES
|
||||||
@@ -193,7 +192,7 @@ class SubEventItem(models.Model):
|
|||||||
self.subevent.event.cache.clear()
|
self.subevent.event.cache.clear()
|
||||||
|
|
||||||
def is_available(self, now_dt: datetime=None) -> bool:
|
def is_available(self, now_dt: datetime=None) -> bool:
|
||||||
now_dt = now_dt or time_machine_now()
|
now_dt = now_dt or now()
|
||||||
if self.disabled:
|
if self.disabled:
|
||||||
return False
|
return False
|
||||||
if self.available_from and self.available_from > now_dt:
|
if self.available_from and self.available_from > now_dt:
|
||||||
@@ -249,7 +248,7 @@ class SubEventItemVariation(models.Model):
|
|||||||
self.subevent.event.cache.clear()
|
self.subevent.event.cache.clear()
|
||||||
|
|
||||||
def is_available(self, now_dt: datetime=None) -> bool:
|
def is_available(self, now_dt: datetime=None) -> bool:
|
||||||
now_dt = now_dt or time_machine_now()
|
now_dt = now_dt or now()
|
||||||
if self.disabled:
|
if self.disabled:
|
||||||
return False
|
return False
|
||||||
if self.available_from and self.available_from > now_dt:
|
if self.available_from and self.available_from > now_dt:
|
||||||
@@ -264,8 +263,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
|
|||||||
# IMPORTANT: If this is updated, also update the ItemVariation query
|
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||||
# in models/event.py: EventMixin.annotated()
|
# in models/event.py: EventMixin.annotated()
|
||||||
Q(active=True)
|
Q(active=True)
|
||||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info'))
|
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()) | Q(available_from_mode='info'))
|
||||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info'))
|
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()) | Q(available_until_mode='info'))
|
||||||
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
|
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
|
||||||
)
|
)
|
||||||
if not allow_addons:
|
if not allow_addons:
|
||||||
@@ -431,7 +430,7 @@ class Item(LoggedModel):
|
|||||||
help_text=_("If this product has multiple variations, you can set different prices for each of the "
|
help_text=_("If this product has multiple variations, you can set different prices for each of the "
|
||||||
"variations. If a variation does not have a special price or if you do not have variations, "
|
"variations. If a variation does not have a special price or if you do not have variations, "
|
||||||
"this price will be used."),
|
"this price will be used."),
|
||||||
max_digits=13, decimal_places=2,
|
max_digits=13, decimal_places=2, null=True
|
||||||
)
|
)
|
||||||
free_price = models.BooleanField(
|
free_price = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -444,8 +443,7 @@ class Item(LoggedModel):
|
|||||||
free_price_suggestion = models.DecimalField(
|
free_price_suggestion = models.DecimalField(
|
||||||
verbose_name=_("Suggested price"),
|
verbose_name=_("Suggested price"),
|
||||||
help_text=_("This price will be used as the default value of the input field. The user can choose a lower "
|
help_text=_("This price will be used as the default value of the input field. The user can choose a lower "
|
||||||
"value, but not lower than the price this product would have without the free price option. This "
|
"value, but not lower than the price this product would have without the free price option."),
|
||||||
"will be ignored if a voucher is used that lowers the price."),
|
|
||||||
max_digits=13, decimal_places=2, null=True, blank=True,
|
max_digits=13, decimal_places=2, null=True, blank=True,
|
||||||
)
|
)
|
||||||
tax_rule = models.ForeignKey(
|
tax_rule = models.ForeignKey(
|
||||||
@@ -784,7 +782,7 @@ class Item(LoggedModel):
|
|||||||
return t
|
return t
|
||||||
|
|
||||||
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
||||||
now_dt = now_dt or time_machine_now()
|
now_dt = now_dt or now()
|
||||||
if self.available_from and self.available_from > now_dt:
|
if self.available_from and self.available_from > now_dt:
|
||||||
return False
|
return False
|
||||||
if self.available_until and self.available_until < now_dt:
|
if self.available_until and self.available_until < now_dt:
|
||||||
@@ -796,13 +794,13 @@ class Item(LoggedModel):
|
|||||||
Returns whether this item is available according to its ``active`` flag
|
Returns whether this item is available according to its ``active`` flag
|
||||||
and its ``available_from`` and ``available_until`` fields
|
and its ``available_from`` and ``available_until`` fields
|
||||||
"""
|
"""
|
||||||
now_dt = now_dt or time_machine_now()
|
now_dt = now_dt or now()
|
||||||
if not self.active or not self.is_available_by_time(now_dt):
|
if not self.active or not self.is_available_by_time(now_dt):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
||||||
now_dt = now_dt or time_machine_now()
|
now_dt = now_dt or now()
|
||||||
subevent_item = subevent and subevent.item_overrides.get(self.pk)
|
subevent_item = subevent and subevent.item_overrides.get(self.pk)
|
||||||
if not self.active:
|
if not self.active:
|
||||||
return 'active'
|
return 'active'
|
||||||
@@ -959,11 +957,11 @@ class Item(LoggedModel):
|
|||||||
return self.validity_fixed_from, self.validity_fixed_until
|
return self.validity_fixed_from, self.validity_fixed_until
|
||||||
elif self.validity_mode == Item.VALIDITY_MODE_DYNAMIC:
|
elif self.validity_mode == Item.VALIDITY_MODE_DYNAMIC:
|
||||||
tz = override_tz or self.event.timezone
|
tz = override_tz or self.event.timezone
|
||||||
requested_start = requested_start or time_machine_now()
|
requested_start = requested_start or now()
|
||||||
if enforce_start_limit and not self.validity_dynamic_start_choice:
|
if enforce_start_limit and not self.validity_dynamic_start_choice:
|
||||||
requested_start = time_machine_now()
|
requested_start = now()
|
||||||
if enforce_start_limit and self.validity_dynamic_start_choice_day_limit is not None:
|
if enforce_start_limit and self.validity_dynamic_start_choice_day_limit is not None:
|
||||||
requested_start = min(requested_start, time_machine_now() + timedelta(days=self.validity_dynamic_start_choice_day_limit))
|
requested_start = min(requested_start, now() + timedelta(days=self.validity_dynamic_start_choice_day_limit))
|
||||||
|
|
||||||
valid_until = requested_start.astimezone(tz)
|
valid_until = requested_start.astimezone(tz)
|
||||||
|
|
||||||
@@ -1087,8 +1085,7 @@ class ItemVariation(models.Model):
|
|||||||
free_price_suggestion = models.DecimalField(
|
free_price_suggestion = models.DecimalField(
|
||||||
verbose_name=_("Suggested price"),
|
verbose_name=_("Suggested price"),
|
||||||
help_text=_("This price will be used as the default value of the input field. The user can choose a lower "
|
help_text=_("This price will be used as the default value of the input field. The user can choose a lower "
|
||||||
"value, but not lower than the price this product would have without the free price option. This "
|
"value, but not lower than the price this product would have without the free price option."),
|
||||||
"will be ignored if a voucher is used that lowers the price."),
|
|
||||||
max_digits=13, decimal_places=2, null=True, blank=True,
|
max_digits=13, decimal_places=2, null=True, blank=True,
|
||||||
)
|
)
|
||||||
require_approval = models.BooleanField(
|
require_approval = models.BooleanField(
|
||||||
@@ -1293,7 +1290,7 @@ class ItemVariation(models.Model):
|
|||||||
return ItemVariation.objects.filter(item=self.item).count() == 1
|
return ItemVariation.objects.filter(item=self.item).count() == 1
|
||||||
|
|
||||||
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
||||||
now_dt = now_dt or time_machine_now()
|
now_dt = now_dt or now()
|
||||||
if self.available_from and self.available_from > now_dt:
|
if self.available_from and self.available_from > now_dt:
|
||||||
return False
|
return False
|
||||||
if self.available_until and self.available_until < now_dt:
|
if self.available_until and self.available_until < now_dt:
|
||||||
@@ -1305,13 +1302,13 @@ class ItemVariation(models.Model):
|
|||||||
Returns whether this item is available according to its ``active`` flag
|
Returns whether this item is available according to its ``active`` flag
|
||||||
and its ``available_from`` and ``available_until`` fields
|
and its ``available_from`` and ``available_until`` fields
|
||||||
"""
|
"""
|
||||||
now_dt = now_dt or time_machine_now()
|
now_dt = now_dt or now()
|
||||||
if not self.active or not self.is_available_by_time(now_dt):
|
if not self.active or not self.is_available_by_time(now_dt):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
||||||
now_dt = now_dt or time_machine_now()
|
now_dt = now_dt or now()
|
||||||
subevent_var = subevent and subevent.var_overrides.get(self.pk)
|
subevent_var = subevent and subevent.var_overrides.get(self.pk)
|
||||||
if not self.active:
|
if not self.active:
|
||||||
return 'active'
|
return 'active'
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ class ReusableMedium(LoggedModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (("identifier", "type", "organizer"),)
|
unique_together = (("identifier", "type", "organizer"),)
|
||||||
indexes = [
|
indexes = [
|
||||||
|
models.Index(fields=("identifier", "type", "organizer")),
|
||||||
models.Index(fields=("updated", "id")),
|
models.Index(fields=("updated", "id")),
|
||||||
]
|
]
|
||||||
ordering = "identifier", "type", "organizer"
|
ordering = "identifier", "type", "organizer"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from django.db import models
|
|||||||
from django.db.models import Count, OuterRef, Subquery, Value
|
from django.db.models import Count, OuterRef, Subquery, Value
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_scopes import ScopedManager, scopes_disabled
|
from django_scopes import ScopedManager, scopes_disabled
|
||||||
from i18nfield.fields import I18nCharField
|
from i18nfield.fields import I18nCharField
|
||||||
@@ -30,7 +31,6 @@ 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.base.timemachine import time_machine_now
|
|
||||||
from pretix.helpers.names import build_name
|
from pretix.helpers.names import build_name
|
||||||
|
|
||||||
|
|
||||||
@@ -49,8 +49,7 @@ class MembershipType(LoggedModel):
|
|||||||
allow_parallel_usage = models.BooleanField(
|
allow_parallel_usage = models.BooleanField(
|
||||||
verbose_name=_('Parallel usage is allowed'),
|
verbose_name=_('Parallel usage is allowed'),
|
||||||
help_text=_('If this is selected, the membership can be used to purchase tickets for events happening at the same time. Note '
|
help_text=_('If this is selected, the membership can be used to purchase tickets for events happening at the same time. Note '
|
||||||
'that this will only check for an identical start time of the events, not for any overlap between events. An overlap '
|
'that this will only check for an identical start time of the events, not for any overlap between events.'),
|
||||||
'check will be performed if there is a product-level validity of the ticket.'),
|
|
||||||
default=False
|
default=False
|
||||||
)
|
)
|
||||||
max_usages = models.PositiveIntegerField(
|
max_usages = models.PositiveIntegerField(
|
||||||
@@ -163,15 +162,11 @@ class Membership(models.Model):
|
|||||||
def attendee_name(self):
|
def attendee_name(self):
|
||||||
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme)
|
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme)
|
||||||
|
|
||||||
def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False):
|
def is_valid(self, ev=None):
|
||||||
if valid_from_not_chosen:
|
if ev:
|
||||||
return not self.canceled and self.date_end >= time_machine_now()
|
|
||||||
elif ticket_valid_from:
|
|
||||||
dt = ticket_valid_from
|
|
||||||
elif ev:
|
|
||||||
dt = ev.date_from
|
dt = ev.date_from
|
||||||
else:
|
else:
|
||||||
dt = time_machine_now()
|
dt = now()
|
||||||
|
|
||||||
return not self.canceled and dt >= self.date_start and dt <= self.date_end
|
return not self.canceled and dt >= self.date_start and dt <= self.date_end
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import operator
|
import operator
|
||||||
@@ -60,7 +59,7 @@ from django.db.models.functions import Coalesce, Greatest
|
|||||||
from django.db.models.signals import post_delete
|
from django.db.models.signals import post_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.crypto import get_random_string, salted_hmac
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.encoding import escape_uri_path
|
from django.utils.encoding import escape_uri_path
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@@ -81,7 +80,6 @@ from pretix.base.models import Customer, User
|
|||||||
from pretix.base.reldate import RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateWrapper
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
|
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
|
||||||
from pretix.base.timemachine import time_machine_now
|
|
||||||
|
|
||||||
from ...helpers import OF_SELF
|
from ...helpers import OF_SELF
|
||||||
from ...helpers.countries import CachedCountries, FastCountryField
|
from ...helpers.countries import CachedCountries, FastCountryField
|
||||||
@@ -106,34 +104,6 @@ def generate_position_secret():
|
|||||||
raise TypeError("Function no longer exists, use secret generators")
|
raise TypeError("Function no longer exists, use secret generators")
|
||||||
|
|
||||||
|
|
||||||
class OrderQuerySet(models.QuerySet):
|
|
||||||
def get_with_secret_check(self, code, received_secret, tag, secret_length=64):
|
|
||||||
dummy = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"[:secret_length]
|
|
||||||
try:
|
|
||||||
order = self.get(code=code)
|
|
||||||
except Order.DoesNotExist:
|
|
||||||
# Do a hash comparison as well to harden against timing attacks
|
|
||||||
hmac.compare_digest(
|
|
||||||
salted_hmac(key_salt=b"", value=tag, algorithm="sha256",
|
|
||||||
secret=dummy).hexdigest()[:secret_length],
|
|
||||||
received_secret[:secret_length]
|
|
||||||
)
|
|
||||||
raise Order.DoesNotExist
|
|
||||||
|
|
||||||
if not hmac.compare_digest(
|
|
||||||
order.tagged_secret(tag, secret_length) if tag else order.secret,
|
|
||||||
received_secret[:secret_length].lower() if tag else received_secret.lower()
|
|
||||||
) and not (
|
|
||||||
# TODO: remove this clause after a while (compatibility with old secrets currently in flight)
|
|
||||||
tag and hmac.compare_digest(
|
|
||||||
hashlib.sha1(order.secret.lower().encode()).hexdigest(),
|
|
||||||
received_secret.lower()
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise Order.DoesNotExist
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
class Order(LockModel, LoggedModel):
|
class Order(LockModel, LoggedModel):
|
||||||
"""
|
"""
|
||||||
An order is created when a user clicks 'buy' on his cart. It holds
|
An order is created when a user clicks 'buy' on his cart. It holds
|
||||||
@@ -218,14 +188,6 @@ class Order(LockModel, LoggedModel):
|
|||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
testmode = models.BooleanField(default=False)
|
testmode = models.BooleanField(default=False)
|
||||||
organizer = models.ForeignKey(
|
|
||||||
# Redundant foreign key, but is required for a uniqueness constraint
|
|
||||||
"Organizer",
|
|
||||||
related_name="orders",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
Event,
|
Event,
|
||||||
verbose_name=_("Event"),
|
verbose_name=_("Event"),
|
||||||
@@ -252,7 +214,6 @@ class Order(LockModel, LoggedModel):
|
|||||||
verbose_name=_('Locale')
|
verbose_name=_('Locale')
|
||||||
)
|
)
|
||||||
secret = models.CharField(max_length=32, default=generate_secret)
|
secret = models.CharField(max_length=32, default=generate_secret)
|
||||||
internal_secret = models.CharField(null=True, blank=True, max_length=32, default=generate_secret)
|
|
||||||
datetime = models.DateTimeField(
|
datetime = models.DateTimeField(
|
||||||
verbose_name=_("Date"), db_index=False
|
verbose_name=_("Date"), db_index=False
|
||||||
)
|
)
|
||||||
@@ -315,7 +276,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
|
objects = ScopedManager(organizer='event__organizer')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Order")
|
verbose_name = _("Order")
|
||||||
@@ -325,9 +286,6 @@ class Order(LockModel, LoggedModel):
|
|||||||
models.Index(fields=["datetime", "id"]),
|
models.Index(fields=["datetime", "id"]),
|
||||||
models.Index(fields=["last_modified", "id"]),
|
models.Index(fields=["last_modified", "id"]),
|
||||||
]
|
]
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.full_code
|
return self.full_code
|
||||||
@@ -493,9 +451,9 @@ class Order(LockModel, LoggedModel):
|
|||||||
if results:
|
if results:
|
||||||
qs = qs.annotate(
|
qs = qs.annotate(
|
||||||
is_overpaid=Case(
|
is_overpaid=Case(
|
||||||
When(~Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_t__lt=-1e-8),
|
When(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=-1e-8),
|
||||||
then=Value(1)),
|
then=Value(1)),
|
||||||
When(Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_rc__lt=-1e-8),
|
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=-1e-8),
|
||||||
then=Value(1)),
|
then=Value(1)),
|
||||||
default=Value(0),
|
default=Value(0),
|
||||||
output_field=models.IntegerField()
|
output_field=models.IntegerField()
|
||||||
@@ -510,7 +468,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
is_underpaid=Case(
|
is_underpaid=Case(
|
||||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
|
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
|
||||||
then=Value(1)),
|
then=Value(1)),
|
||||||
When(Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_rc__gt=1e-8),
|
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__gt=1e-8),
|
||||||
then=Value(1)),
|
then=Value(1)),
|
||||||
default=Value(0),
|
default=Value(0),
|
||||||
output_field=models.IntegerField()
|
output_field=models.IntegerField()
|
||||||
@@ -541,10 +499,6 @@ class Order(LockModel, LoggedModel):
|
|||||||
self.set_expires()
|
self.set_expires()
|
||||||
if 'update_fields' in kwargs:
|
if 'update_fields' in kwargs:
|
||||||
kwargs['update_fields'] = {'expires'}.union(kwargs['update_fields'])
|
kwargs['update_fields'] = {'expires'}.union(kwargs['update_fields'])
|
||||||
if not self.organizer_id:
|
|
||||||
self.organizer_id = self.event.organizer_id
|
|
||||||
if 'update_fields' in kwargs:
|
|
||||||
kwargs['update_fields'] = {'organizer'}.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', [])
|
||||||
@@ -712,7 +666,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
for op in positions:
|
for op in positions:
|
||||||
if op.issued_gift_cards.all():
|
if op.issued_gift_cards.all():
|
||||||
return False
|
return False
|
||||||
if self.user_change_deadline and time_machine_now() > self.user_change_deadline:
|
if self.user_change_deadline and now() > self.user_change_deadline:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -744,7 +698,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
return False
|
return False
|
||||||
if op.granted_memberships.with_usages().filter(usages__gt=0):
|
if op.granted_memberships.with_usages().filter(usages__gt=0):
|
||||||
return False
|
return False
|
||||||
if self.user_cancel_deadline and time_machine_now() > self.user_cancel_deadline:
|
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.status == Order.STATUS_PAID:
|
if self.status == Order.STATUS_PAID:
|
||||||
@@ -881,11 +835,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
|
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.event.settings.allow_modifications not in ("order", "attendee"):
|
|
||||||
return False
|
|
||||||
|
|
||||||
modify_deadline = self.modify_deadline
|
modify_deadline = self.modify_deadline
|
||||||
if modify_deadline is not None and time_machine_now() > modify_deadline:
|
if modify_deadline is not None and now() > modify_deadline:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
positions = list(
|
positions = list(
|
||||||
@@ -937,7 +888,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
return self.event.settings.ticket_download and (
|
return self.event.settings.ticket_download and (
|
||||||
self.event.settings.ticket_download_date is None
|
self.event.settings.ticket_download_date is None
|
||||||
or self.ticket_download_date is None
|
or self.ticket_download_date is None
|
||||||
or time_machine_now() > self.ticket_download_date
|
or now() > self.ticket_download_date
|
||||||
) and (
|
) and (
|
||||||
self.status == Order.STATUS_PAID
|
self.status == Order.STATUS_PAID
|
||||||
or (
|
or (
|
||||||
@@ -1009,7 +960,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
return error_messages['require_approval']
|
return error_messages['require_approval']
|
||||||
term_last = self.payment_term_last
|
term_last = self.payment_term_last
|
||||||
if term_last and not ignore_date:
|
if term_last and not ignore_date:
|
||||||
if time_machine_now() > term_last:
|
if now() > term_last:
|
||||||
return error_messages['late_lastdate']
|
return error_messages['late_lastdate']
|
||||||
|
|
||||||
if self.status == self.STATUS_PENDING:
|
if self.status == self.STATUS_PENDING:
|
||||||
@@ -1032,7 +983,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
|
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
|
||||||
'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'),
|
'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'),
|
||||||
}
|
}
|
||||||
now_dt = now_dt or time_machine_now()
|
now_dt = now_dt or now()
|
||||||
positions = list(self.positions.all().select_related('item', 'variation', 'seat', 'voucher'))
|
positions = list(self.positions.all().select_related('item', 'variation', 'seat', 'voucher'))
|
||||||
quota_cache = {}
|
quota_cache = {}
|
||||||
v_budget = {}
|
v_budget = {}
|
||||||
@@ -1253,10 +1204,6 @@ class Order(LockModel, LoggedModel):
|
|||||||
_transactions_mark_order_clean(self.pk)
|
_transactions_mark_order_clean(self.pk)
|
||||||
return create
|
return create
|
||||||
|
|
||||||
def tagged_secret(self, tag, secret_length=64):
|
|
||||||
return salted_hmac(value=tag, key_salt=b"", algorithm="sha256",
|
|
||||||
secret=self.internal_secret or self.secret).hexdigest()[:secret_length]
|
|
||||||
|
|
||||||
|
|
||||||
def answerfile_name(instance, filename: str) -> str:
|
def answerfile_name(instance, filename: str) -> str:
|
||||||
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
||||||
@@ -2409,14 +2356,6 @@ class OrderPosition(AbstractPosition):
|
|||||||
"""
|
"""
|
||||||
positionid = models.PositiveIntegerField(default=1)
|
positionid = models.PositiveIntegerField(default=1)
|
||||||
|
|
||||||
organizer = models.ForeignKey(
|
|
||||||
# Redundant foreign key, but is required for a uniqueness constraint
|
|
||||||
"Organizer",
|
|
||||||
related_name="order_positions",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
Order,
|
Order,
|
||||||
verbose_name=_("Order"),
|
verbose_name=_("Order"),
|
||||||
@@ -2490,9 +2429,6 @@ class OrderPosition(AbstractPosition):
|
|||||||
verbose_name = _("Order position")
|
verbose_name = _("Order position")
|
||||||
verbose_name_plural = _("Order positions")
|
verbose_name_plural = _("Order positions")
|
||||||
ordering = ("positionid", "id")
|
ordering = ("positionid", "id")
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq")
|
|
||||||
]
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def sort_key(self):
|
def sort_key(self):
|
||||||
@@ -2551,43 +2487,6 @@ class OrderPosition(AbstractPosition):
|
|||||||
reasons[b] = b
|
reasons[b] = b
|
||||||
return reasons
|
return reasons
|
||||||
|
|
||||||
@property
|
|
||||||
def can_modify_answers(self) -> bool:
|
|
||||||
"""
|
|
||||||
``True`` if the user can change the question answers / attendee names that are
|
|
||||||
related to the position. This checks order status and modification deadlines. It also
|
|
||||||
returns ``False`` if there are no questions that can be answered.
|
|
||||||
"""
|
|
||||||
from .checkin import Checkin
|
|
||||||
|
|
||||||
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.event.settings.allow_modifications != "attendee":
|
|
||||||
return False
|
|
||||||
|
|
||||||
modify_deadline = self.order.modify_deadline
|
|
||||||
if modify_deadline is not None and now() > modify_deadline:
|
|
||||||
return False
|
|
||||||
|
|
||||||
positions = list(
|
|
||||||
self.order.positions.all().annotate(
|
|
||||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))
|
|
||||||
).select_related('item').prefetch_related('item__questions')
|
|
||||||
)
|
|
||||||
if not self.event.settings.allow_modifications_after_checkin:
|
|
||||||
for cp in positions:
|
|
||||||
if cp.has_checkin:
|
|
||||||
return False
|
|
||||||
|
|
||||||
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
|
|
||||||
for cp in positions:
|
|
||||||
if cp.pk == self.pk or cp.addon_to_id == self.pk:
|
|
||||||
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False # nothing there to modify
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def transform_cart_positions(cls, cp: List, order) -> list:
|
def transform_cart_positions(cls, cp: List, order) -> list:
|
||||||
from . import Voucher
|
from . import Voucher
|
||||||
@@ -2599,8 +2498,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
op = OrderPosition(order=order)
|
op = OrderPosition(order=order)
|
||||||
for f in AbstractPosition._meta.fields:
|
for f in AbstractPosition._meta.fields:
|
||||||
if f.name == 'addon_to':
|
if f.name == 'addon_to':
|
||||||
if cartpos.addon_to_id:
|
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
|
||||||
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
|
|
||||||
else:
|
else:
|
||||||
setattr(op, f.name, getattr(cartpos, f.name))
|
setattr(op, f.name, getattr(cartpos, f.name))
|
||||||
op._calculate_tax()
|
op._calculate_tax()
|
||||||
@@ -2610,9 +2508,9 @@ class OrderPosition(AbstractPosition):
|
|||||||
if cartpos.item.validity_mode:
|
if cartpos.item.validity_mode:
|
||||||
valid_from, valid_until = cartpos.item.compute_validity(
|
valid_from, valid_until = cartpos.item.compute_validity(
|
||||||
requested_start=(
|
requested_start=(
|
||||||
max(cartpos.requested_valid_from, time_machine_now())
|
max(cartpos.requested_valid_from, now())
|
||||||
if cartpos.requested_valid_from and cartpos.item.validity_dynamic_start_choice
|
if cartpos.requested_valid_from and cartpos.item.validity_dynamic_start_choice
|
||||||
else time_machine_now()
|
else now()
|
||||||
),
|
),
|
||||||
enforce_start_limit=True,
|
enforce_start_limit=True,
|
||||||
override_tz=order.event.timezone,
|
override_tz=order.event.timezone,
|
||||||
@@ -2620,9 +2518,6 @@ class OrderPosition(AbstractPosition):
|
|||||||
op.valid_from = valid_from
|
op.valid_from = valid_from
|
||||||
op.valid_until = valid_until
|
op.valid_until = valid_until
|
||||||
|
|
||||||
if op.is_bundled and not op.addon_to_id:
|
|
||||||
raise ValueError("Bundled cart position without parent does not make sense.")
|
|
||||||
|
|
||||||
op.positionid = i + 1
|
op.positionid = i + 1
|
||||||
op.save()
|
op.save()
|
||||||
ops.append(op)
|
ops.append(op)
|
||||||
@@ -2657,10 +2552,10 @@ class OrderPosition(AbstractPosition):
|
|||||||
self.item.id, self.variation.id if self.variation else 0, self.order_id
|
self.item.id, self.variation.id if self.variation else 0, self.order_id
|
||||||
)
|
)
|
||||||
|
|
||||||
def _calculate_tax(self, tax_rule=None, invoice_address=None):
|
def _calculate_tax(self, tax_rule=None):
|
||||||
self.tax_rule = tax_rule or self.item.tax_rule
|
self.tax_rule = tax_rule or self.item.tax_rule
|
||||||
try:
|
try:
|
||||||
ia = invoice_address or self.order.invoice_address
|
ia = self.order.invoice_address
|
||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
ia = None
|
ia = None
|
||||||
if self.tax_rule:
|
if self.tax_rule:
|
||||||
@@ -2690,10 +2585,6 @@ class OrderPosition(AbstractPosition):
|
|||||||
if 'update_fields' in kwargs:
|
if 'update_fields' in kwargs:
|
||||||
kwargs['update_fields'] = {'secret'}.union(kwargs['update_fields'])
|
kwargs['update_fields'] = {'secret'}.union(kwargs['update_fields'])
|
||||||
|
|
||||||
if not self.organizer_id:
|
|
||||||
self.organizer_id = self.order.event.organizer_id
|
|
||||||
if 'update_fields' in kwargs:
|
|
||||||
kwargs['update_fields'] = {'organizer'}.union(kwargs['update_fields'])
|
|
||||||
if not self.blocked and self.blocked is not None:
|
if not self.blocked and self.blocked is not None:
|
||||||
self.blocked = None
|
self.blocked = None
|
||||||
if 'update_fields' in kwargs:
|
if 'update_fields' in kwargs:
|
||||||
@@ -3041,14 +2932,6 @@ class CartPosition(AbstractPosition):
|
|||||||
self.item.id, self.variation.id if self.variation else 0, self.cart_id
|
self.item.id, self.variation.id if self.variation else 0, self.cart_id
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
# invalidate cached values of cached properties that likely have changed
|
|
||||||
try:
|
|
||||||
del self.sort_key
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tax_value(self):
|
def tax_value(self):
|
||||||
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
|
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
|
||||||
@@ -3138,9 +3021,9 @@ class CartPosition(AbstractPosition):
|
|||||||
def predicted_validity(self):
|
def predicted_validity(self):
|
||||||
return self.item.compute_validity(
|
return self.item.compute_validity(
|
||||||
requested_start=(
|
requested_start=(
|
||||||
max(self.requested_valid_from, time_machine_now())
|
max(self.requested_valid_from, now())
|
||||||
if self.requested_valid_from and self.item.validity_dynamic_start_choice
|
if self.requested_valid_from and self.item.validity_dynamic_start_choice
|
||||||
else time_machine_now()
|
else now()
|
||||||
),
|
),
|
||||||
override_tz=self.event.timezone,
|
override_tz=self.event.timezone,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -263,12 +263,6 @@ class Team(LoggedModel):
|
|||||||
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
|
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
|
||||||
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
|
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
|
||||||
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
|
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
|
||||||
require_2fa = models.BooleanField(
|
|
||||||
default=False, verbose_name=_("Require all members of this team to use two-factor authentication"),
|
|
||||||
help_text=_("If you turn this on, all members of the team will be required to either set up two-factor "
|
|
||||||
"authentication or leave the team. The setting may take a few minutes to become effective for "
|
|
||||||
"all users.")
|
|
||||||
)
|
|
||||||
|
|
||||||
can_create_events = models.BooleanField(
|
can_create_events = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@@ -351,6 +351,9 @@ class Voucher(LoggedModel):
|
|||||||
'variations.'))
|
'variations.'))
|
||||||
if variation and not item.variations.filter(pk=variation.pk).exists():
|
if variation and not item.variations.filter(pk=variation.pk).exists():
|
||||||
raise ValidationError(_('This variation does not belong to this product.'))
|
raise ValidationError(_('This variation does not belong to this product.'))
|
||||||
|
if item.has_variations and not variation and data.get('block_quota'):
|
||||||
|
raise ValidationError(_('You can only block quota if you specify a specific product variation. '
|
||||||
|
'Otherwise it might be unclear which quotas to block.'))
|
||||||
if item.category and item.category.is_addon:
|
if item.category and item.category.is_addon:
|
||||||
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
|
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
|
||||||
elif block_quota:
|
elif block_quota:
|
||||||
@@ -370,11 +373,10 @@ class Voucher(LoggedModel):
|
|||||||
'redeemed': redeemed
|
'redeemed': redeemed
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if data.get('min_usages') is not None:
|
if data.get('max_usages', 1) < data.get('min_usages', 1):
|
||||||
if data.get('max_usages', 1) < data.get('min_usages', 1):
|
raise ValidationError(
|
||||||
raise ValidationError(
|
_('The maximum number of usages may not be lower than the minimum number of usages.'),
|
||||||
_('The maximum number of usages may not be lower than the minimum number of usages.'),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clean_subevent(data, event):
|
def clean_subevent(data, event):
|
||||||
@@ -429,15 +431,7 @@ class Voucher(LoggedModel):
|
|||||||
elif old_instance.variation:
|
elif old_instance.variation:
|
||||||
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
|
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
|
||||||
elif old_instance.item:
|
elif old_instance.item:
|
||||||
if old_instance.item.has_variations:
|
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
|
||||||
quotas |= set(
|
|
||||||
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
|
|
||||||
itemvariation__item=old_instance.item,
|
|
||||||
quota__subevent=old_instance.subevent,
|
|
||||||
).values('quota_id'))
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
|
|
||||||
return quotas
|
return quotas
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -452,19 +446,13 @@ class Voucher(LoggedModel):
|
|||||||
|
|
||||||
if quota:
|
if quota:
|
||||||
new_quotas = {quota}
|
new_quotas = {quota}
|
||||||
|
elif item and item.has_variations and not variation:
|
||||||
|
raise ValidationError(_('You can only block quota if you specify a specific product variation. '
|
||||||
|
'Otherwise it might be unclear which quotas to block.'))
|
||||||
elif item and variation:
|
elif item and variation:
|
||||||
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
|
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
|
||||||
elif item and not item.has_variations:
|
elif item and not item.has_variations:
|
||||||
new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
|
new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
|
||||||
elif item and item.has_variations:
|
|
||||||
new_quotas = set(
|
|
||||||
Quota.objects.filter(
|
|
||||||
pk__in=Quota.variations.through.objects.filter(
|
|
||||||
itemvariation__item=item,
|
|
||||||
quota__subevent=data.get('subevent'),
|
|
||||||
).values('quota_id')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
|
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
|
||||||
'tickets.'))
|
'tickets.'))
|
||||||
@@ -518,6 +506,9 @@ class Voucher(LoggedModel):
|
|||||||
if item and seat.product != item:
|
if item and seat.product != item:
|
||||||
raise ValidationError(_('You need to choose the product "{prod}" for this seat.').format(prod=seat.product))
|
raise ValidationError(_('You need to choose the product "{prod}" for this seat.').format(prod=seat.product))
|
||||||
|
|
||||||
|
if not seat.is_available(ignore_voucher_id=pk):
|
||||||
|
raise ValidationError(_('The seat "{id}" is already sold or currently blocked.').format(id=seat.seat_guid))
|
||||||
|
|
||||||
return seat
|
return seat
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import datetime
|
import datetime
|
||||||
|
import re
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from decimal import Decimal, DecimalException
|
||||||
|
|
||||||
import pycountry
|
import pycountry
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -40,13 +42,9 @@ from phonenumbers import SUPPORTED_REGIONS
|
|||||||
|
|
||||||
from pretix.base.channels import get_all_sales_channels
|
from pretix.base.channels import get_all_sales_channels
|
||||||
from pretix.base.forms.questions import guess_country
|
from pretix.base.forms.questions import guess_country
|
||||||
from pretix.base.modelimport import (
|
|
||||||
DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin,
|
|
||||||
i18n_flat,
|
|
||||||
)
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Customer, ItemVariation, OrderPosition, Question, QuestionAnswer,
|
Customer, ItemVariation, OrderPosition, Question, QuestionAnswer,
|
||||||
QuestionOption, Seat,
|
QuestionOption, Seat, SubEvent,
|
||||||
)
|
)
|
||||||
from pretix.base.services.pricing import get_price
|
from pretix.base.services.pricing import get_price
|
||||||
from pretix.base.settings import (
|
from pretix.base.settings import (
|
||||||
@@ -55,6 +53,99 @@ from pretix.base.settings import (
|
|||||||
from pretix.base.signals import order_import_columns
|
from pretix.base.signals import order_import_columns
|
||||||
|
|
||||||
|
|
||||||
|
class ImportColumn:
|
||||||
|
@property
|
||||||
|
def identifier(self):
|
||||||
|
"""
|
||||||
|
Unique, internal name of the column.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def verbose_name(self):
|
||||||
|
"""
|
||||||
|
Human-readable description of the column
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initial(self):
|
||||||
|
"""
|
||||||
|
Initial value for the form component
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_value(self):
|
||||||
|
"""
|
||||||
|
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
|
||||||
|
option.
|
||||||
|
"""
|
||||||
|
return 'empty'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_label(self):
|
||||||
|
"""
|
||||||
|
Human-readable description of the default assignment of this column, defaults to "Keep empty".
|
||||||
|
"""
|
||||||
|
return gettext_lazy('Keep empty')
|
||||||
|
|
||||||
|
def __init__(self, event):
|
||||||
|
self.event = event
|
||||||
|
|
||||||
|
def static_choices(self):
|
||||||
|
"""
|
||||||
|
This will be called when rendering the form component and allows you to return a list of values that can be
|
||||||
|
selected by the user statically during import.
|
||||||
|
|
||||||
|
:return: list of 2-tuples of strings
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def resolve(self, settings, record):
|
||||||
|
"""
|
||||||
|
This method will be called to get the raw value for this field, usually by either using a static value or
|
||||||
|
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
|
||||||
|
the default should be fine.
|
||||||
|
"""
|
||||||
|
k = settings.get(self.identifier, self.default_value)
|
||||||
|
if k == self.default_value:
|
||||||
|
return None
|
||||||
|
elif k.startswith('csv:'):
|
||||||
|
return record.get(k[4:], None) or None
|
||||||
|
elif k.startswith('static:'):
|
||||||
|
return k[7:]
|
||||||
|
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
|
||||||
|
|
||||||
|
def clean(self, value, previous_values):
|
||||||
|
"""
|
||||||
|
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
|
||||||
|
You do not need to include the column or row name or value in the error message as it will automatically be
|
||||||
|
included.
|
||||||
|
|
||||||
|
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
|
||||||
|
e.g. if the column is empty or does not exist in this row.
|
||||||
|
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
|
||||||
|
"""
|
||||||
|
return value
|
||||||
|
|
||||||
|
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||||
|
"""
|
||||||
|
This will be called to perform the actual import. You are supposed to set attributes on the ``order``, ``position``,
|
||||||
|
or ``invoice_address`` objects based on the input ``value``. This is called *before* the actual database
|
||||||
|
transaction, so these three objects do not yet have a primary key. If you want to create related objects, you
|
||||||
|
need to place them into some sort of internal queue and persist them when ``save`` is called.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def save(self, order):
|
||||||
|
"""
|
||||||
|
This will be called to perform the actual import. This is called inside the actual database transaction and the
|
||||||
|
input object ``order`` has already been saved to the database.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class EmailColumn(ImportColumn):
|
class EmailColumn(ImportColumn):
|
||||||
identifier = 'email'
|
identifier = 'email'
|
||||||
verbose_name = gettext_lazy('E-mail address')
|
verbose_name = gettext_lazy('E-mail address')
|
||||||
@@ -91,20 +182,74 @@ class PhoneColumn(ImportColumn):
|
|||||||
order.phone = value
|
order.phone = value
|
||||||
|
|
||||||
|
|
||||||
class SubeventColumn(SubeventColumnMixin, ImportColumn):
|
class SubeventColumn(ImportColumn):
|
||||||
identifier = 'subevent'
|
identifier = 'subevent'
|
||||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
verbose_name = pgettext_lazy('subevents', 'Date')
|
||||||
default_value = None
|
default_value = None
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._subevent_cache = {}
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def subevents(self):
|
||||||
|
return list(self.event.subevents.filter(active=True).order_by('date_from'))
|
||||||
|
|
||||||
|
def static_choices(self):
|
||||||
|
return [
|
||||||
|
(str(p.pk), str(p)) for p in self.subevents
|
||||||
|
]
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
def clean(self, value, previous_values):
|
||||||
if not value:
|
if not value:
|
||||||
raise ValidationError(pgettext("subevent", "You need to select a date."))
|
raise ValidationError(pgettext("subevent", "You need to select a date."))
|
||||||
return super().clean(value, previous_values)
|
|
||||||
|
if value in self._subevent_cache:
|
||||||
|
return self._subevent_cache[value]
|
||||||
|
|
||||||
|
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||||
|
for format in input_formats:
|
||||||
|
try:
|
||||||
|
d = datetime.datetime.strptime(value, format)
|
||||||
|
d = d.replace(tzinfo=self.event.timezone)
|
||||||
|
try:
|
||||||
|
se = self.event.subevents.get(
|
||||||
|
active=True,
|
||||||
|
date_from__gt=d - datetime.timedelta(seconds=1),
|
||||||
|
date_from__lt=d + datetime.timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
self._subevent_cache[value] = se
|
||||||
|
return se
|
||||||
|
except SubEvent.DoesNotExist:
|
||||||
|
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||||
|
except SubEvent.MultipleObjectsReturned:
|
||||||
|
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
matches = [
|
||||||
|
p for p in self.subevents
|
||||||
|
if str(p.pk) == value or any(
|
||||||
|
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
|
||||||
|
]
|
||||||
|
if len(matches) == 0:
|
||||||
|
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||||
|
|
||||||
|
self._subevent_cache[value] = matches[0]
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||||
position.subevent = value
|
position.subevent = value
|
||||||
|
|
||||||
|
|
||||||
|
def i18n_flat(l):
|
||||||
|
if isinstance(l.data, dict):
|
||||||
|
return l.data.values()
|
||||||
|
return [l.data]
|
||||||
|
|
||||||
|
|
||||||
class ItemColumn(ImportColumn):
|
class ItemColumn(ImportColumn):
|
||||||
identifier = 'item'
|
identifier = 'item'
|
||||||
verbose_name = gettext_lazy('Product')
|
verbose_name = gettext_lazy('Product')
|
||||||
@@ -427,11 +572,20 @@ class AttendeeState(ImportColumn):
|
|||||||
position.state = value or ''
|
position.state = value or ''
|
||||||
|
|
||||||
|
|
||||||
class Price(DecimalColumnMixin, ImportColumn):
|
class Price(ImportColumn):
|
||||||
identifier = 'price'
|
identifier = 'price'
|
||||||
verbose_name = gettext_lazy('Price')
|
verbose_name = gettext_lazy('Price')
|
||||||
default_label = gettext_lazy('Calculate from product')
|
default_label = gettext_lazy('Calculate from product')
|
||||||
|
|
||||||
|
def clean(self, value, previous_values):
|
||||||
|
if value not in (None, ''):
|
||||||
|
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
|
||||||
|
try:
|
||||||
|
value = Decimal(value)
|
||||||
|
except (DecimalException, TypeError):
|
||||||
|
raise ValidationError(_('You entered an invalid number.'))
|
||||||
|
return value
|
||||||
|
|
||||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||||
if value is None:
|
if value is None:
|
||||||
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
|
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
|
||||||
@@ -495,44 +649,50 @@ class Locale(ImportColumn):
|
|||||||
order.locale = value
|
order.locale = value
|
||||||
|
|
||||||
|
|
||||||
class ValidFrom(DatetimeColumnMixin, ImportColumn):
|
class ValidFrom(ImportColumn):
|
||||||
identifier = 'valid_from'
|
identifier = 'valid_from'
|
||||||
verbose_name = gettext_lazy('Valid from')
|
verbose_name = gettext_lazy('Valid from')
|
||||||
|
|
||||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
|
||||||
position.valid_from = value
|
|
||||||
|
|
||||||
|
|
||||||
class ValidUntil(DatetimeColumnMixin, ImportColumn):
|
|
||||||
identifier = 'valid_until'
|
|
||||||
verbose_name = gettext_lazy('Valid until')
|
|
||||||
|
|
||||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
|
||||||
position.valid_until = value
|
|
||||||
|
|
||||||
|
|
||||||
class Expires(DatetimeColumnMixin, ImportColumn):
|
|
||||||
identifier = 'expires'
|
|
||||||
verbose_name = gettext_lazy('Expiry date')
|
|
||||||
|
|
||||||
def clean(self, value, previous_values):
|
def clean(self, value, previous_values):
|
||||||
if not value:
|
if not value:
|
||||||
return
|
return
|
||||||
|
|
||||||
input_formats = formats.get_format('DATE_INPUT_FORMATS', use_l10n=True)
|
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||||
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.timezone, hour=23, minute=59, second=59)
|
d = d.replace(tzinfo=self.event.timezone)
|
||||||
return d
|
return d
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
return super().clean(value, previous_values) # parse date
|
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
|
||||||
|
|
||||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||||
if value:
|
position.valid_from = value
|
||||||
order.expires = value
|
|
||||||
|
|
||||||
|
class ValidUntil(ImportColumn):
|
||||||
|
identifier = 'valid_until'
|
||||||
|
verbose_name = gettext_lazy('Valid until')
|
||||||
|
|
||||||
|
def clean(self, value, previous_values):
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
|
||||||
|
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||||
|
for format in input_formats:
|
||||||
|
try:
|
||||||
|
d = datetime.datetime.strptime(value, format)
|
||||||
|
d = d.replace(tzinfo=self.event.timezone)
|
||||||
|
return d
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
|
||||||
|
|
||||||
|
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||||
|
position.valid_until = value
|
||||||
|
|
||||||
|
|
||||||
class Saleschannel(ImportColumn):
|
class Saleschannel(ImportColumn):
|
||||||
@@ -689,7 +849,7 @@ class CustomerColumn(ImportColumn):
|
|||||||
order.customer = value
|
order.customer = value
|
||||||
|
|
||||||
|
|
||||||
def get_order_import_columns(event):
|
def get_all_columns(event):
|
||||||
default = []
|
default = []
|
||||||
if event.has_subevents:
|
if event.has_subevents:
|
||||||
default.append(SubeventColumn(event))
|
default.append(SubeventColumn(event))
|
||||||
@@ -728,13 +888,12 @@ def get_order_import_columns(event):
|
|||||||
AttendeeState(event),
|
AttendeeState(event),
|
||||||
Price(event),
|
Price(event),
|
||||||
Secret(event),
|
Secret(event),
|
||||||
SeatColumn(event),
|
|
||||||
ValidFrom(event),
|
|
||||||
ValidUntil(event),
|
|
||||||
Locale(event),
|
Locale(event),
|
||||||
Saleschannel(event),
|
Saleschannel(event),
|
||||||
Expires(event),
|
SeatColumn(event),
|
||||||
Comment(event),
|
Comment(event),
|
||||||
|
ValidFrom(event),
|
||||||
|
ValidUntil(event),
|
||||||
]
|
]
|
||||||
for q in event.questions.prefetch_related('options').exclude(type=Question.TYPE_FILE):
|
for q in event.questions.prefetch_related('options').exclude(type=Question.TYPE_FILE):
|
||||||
default.append(QuestionColumn(event, q))
|
default.append(QuestionColumn(event, q))
|
||||||
@@ -57,7 +57,7 @@ from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
|||||||
from i18nfield.strings import LazyI18nString
|
from i18nfield.strings import LazyI18nString
|
||||||
|
|
||||||
from pretix.base.channels import get_all_sales_channels
|
from pretix.base.channels import get_all_sales_channels
|
||||||
from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator
|
from pretix.base.forms import PlaceholderValidator
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
|
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
|
||||||
OrderRefund, Quota, TaxRule,
|
OrderRefund, Quota, TaxRule,
|
||||||
@@ -67,7 +67,6 @@ from pretix.base.settings import SettingsSandbox
|
|||||||
from pretix.base.signals import register_payment_providers
|
from pretix.base.signals import register_payment_providers
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
from pretix.base.templatetags.rich_text import rich_text
|
from pretix.base.templatetags.rich_text import rich_text
|
||||||
from pretix.base.timemachine import time_machine_now
|
|
||||||
from pretix.helpers import OF_SELF
|
from pretix.helpers import OF_SELF
|
||||||
from pretix.helpers.countries import CachedCountries
|
from pretix.helpers.countries import CachedCountries
|
||||||
from pretix.helpers.format import format_map
|
from pretix.helpers.format import format_map
|
||||||
@@ -1186,14 +1185,14 @@ class ManualPayment(BasePaymentProvider):
|
|||||||
label=_('Payment process description during checkout'),
|
label=_('Payment process description during checkout'),
|
||||||
help_text=_('This text will be shown during checkout when the user selects this payment method. '
|
help_text=_('This text will be shown during checkout when the user selects this payment method. '
|
||||||
'It should give a short explanation on this payment method.'),
|
'It should give a short explanation on this payment method.'),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
)),
|
)),
|
||||||
('email_instructions', I18nFormField(
|
('email_instructions', I18nFormField(
|
||||||
label=_('Payment process description in order confirmation emails'),
|
label=_('Payment process description in order confirmation emails'),
|
||||||
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
|
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
|
||||||
'mails. It should instruct the user on how to proceed with the payment. You can use '
|
'mails. It should instruct the user on how to proceed with the payment. You can use '
|
||||||
'the placeholders {order}, {amount}, {currency} and {amount_with_currency}.'),
|
'the placeholders {order}, {amount}, {currency} and {amount_with_currency}.'),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
|
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
|
||||||
)),
|
)),
|
||||||
('pending_description', I18nFormField(
|
('pending_description', I18nFormField(
|
||||||
@@ -1201,7 +1200,7 @@ class ManualPayment(BasePaymentProvider):
|
|||||||
help_text=_('This text will be shown on the order confirmation page for pending orders. '
|
help_text=_('This text will be shown on the order confirmation page for pending orders. '
|
||||||
'It should instruct the user on how to proceed with the payment. You can use '
|
'It should instruct the user on how to proceed with the payment. You can use '
|
||||||
'the placeholders {order}, {amount}, {currency} and {amount_with_currency}.'),
|
'the placeholders {order}, {amount}, {currency} and {amount_with_currency}.'),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
|
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
|
||||||
)),
|
)),
|
||||||
('invoice_immediately',
|
('invoice_immediately',
|
||||||
@@ -1312,7 +1311,9 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def public_name(self) -> str:
|
def public_name(self) -> str:
|
||||||
return str(self.settings.get("public_name", as_type=LazyI18nString) or _("Gift card"))
|
return str(self.settings.get("public_name", as_type=LazyI18nString)) or _(
|
||||||
|
"Gift card"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def settings_form_fields(self):
|
def settings_form_fields(self):
|
||||||
@@ -1326,7 +1327,7 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
(
|
(
|
||||||
"public_description",
|
"public_description",
|
||||||
I18nFormField(
|
I18nFormField(
|
||||||
label=_("Payment method description"), widget=I18nMarkdownTextarea, required=False
|
label=_("Payment method description"), widget=I18nTextarea, required=False
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -1442,7 +1443,7 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
if not gc.testmode and self.event.testmode:
|
if not gc.testmode and self.event.testmode:
|
||||||
messages.error(request, _("Only test gift cards can be used in test mode."))
|
messages.error(request, _("Only test gift cards can be used in test mode."))
|
||||||
return
|
return
|
||||||
if gc.expires and gc.expires < time_machine_now():
|
if gc.expires and gc.expires < now():
|
||||||
messages.error(request, _("This gift card is no longer valid."))
|
messages.error(request, _("This gift card is no longer valid."))
|
||||||
return
|
return
|
||||||
if gc.value <= Decimal("0.00"):
|
if gc.value <= Decimal("0.00"):
|
||||||
@@ -1492,7 +1493,7 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
if not gc.testmode and payment.order.testmode:
|
if not gc.testmode and payment.order.testmode:
|
||||||
messages.error(request, _("Only test gift cards can be used in test mode."))
|
messages.error(request, _("Only test gift cards can be used in test mode."))
|
||||||
return
|
return
|
||||||
if gc.expires and gc.expires < time_machine_now():
|
if gc.expires and gc.expires < now():
|
||||||
messages.error(request, _("This gift card is no longer valid."))
|
messages.error(request, _("This gift card is no longer valid."))
|
||||||
return
|
return
|
||||||
if gc.value <= Decimal("0.00"):
|
if gc.value <= Decimal("0.00"):
|
||||||
@@ -1540,7 +1541,7 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
raise PaymentException(_("This gift card can only be used in test mode."))
|
raise PaymentException(_("This gift card can only be used in test mode."))
|
||||||
if not gc.testmode and payment.order.testmode:
|
if not gc.testmode and payment.order.testmode:
|
||||||
raise PaymentException(_("Only test gift cards can be used in test mode."))
|
raise PaymentException(_("Only test gift cards can be used in test mode."))
|
||||||
if gc.expires and gc.expires < time_machine_now():
|
if gc.expires and gc.expires < now():
|
||||||
raise PaymentException(_("This gift card is no longer valid."))
|
raise PaymentException(_("This gift card is no longer valid."))
|
||||||
|
|
||||||
trans = gc.transactions.create(
|
trans = gc.transactions.create(
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ 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, PdfWriter
|
from pypdf import PdfReader, PdfWriter, Transformation
|
||||||
|
from pypdf.generic import RectangleObject
|
||||||
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
|
||||||
@@ -77,7 +78,7 @@ 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.models import Event, 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
|
||||||
@@ -407,30 +408,6 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
"TIME_FORMAT"
|
"TIME_FORMAT"
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
("purchase_date", {
|
|
||||||
"label": _("Purchase date"),
|
|
||||||
"editor_sample": _("2017-05-31"),
|
|
||||||
"evaluate": lambda op, order, ev: date_format(
|
|
||||||
order.datetime.astimezone(ev.timezone),
|
|
||||||
"SHORT_DATE_FORMAT"
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
("purchase_datetime", {
|
|
||||||
"label": _("Purchase date and time"),
|
|
||||||
"editor_sample": _("2017-05-31 19:00"),
|
|
||||||
"evaluate": lambda op, order, ev: date_format(
|
|
||||||
order.datetime.astimezone(ev.timezone),
|
|
||||||
"SHORT_DATETIME_FORMAT"
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
("purchase_time", {
|
|
||||||
"label": _("Purchase time"),
|
|
||||||
"editor_sample": _("19:00"),
|
|
||||||
"evaluate": lambda op, order, ev: date_format(
|
|
||||||
order.datetime.astimezone(ev.timezone),
|
|
||||||
"TIME_FORMAT"
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
("valid_from_date", {
|
("valid_from_date", {
|
||||||
"label": _("Validity start date"),
|
"label": _("Validity start date"),
|
||||||
"editor_sample": _("2017-05-31"),
|
"editor_sample": _("2017-05-31"),
|
||||||
@@ -761,10 +738,9 @@ class Renderer:
|
|||||||
else:
|
else:
|
||||||
self.bg_bytes = None
|
self.bg_bytes = None
|
||||||
self.bg_pdf = None
|
self.bg_pdf = None
|
||||||
self.event_fonts = list(get_fonts(event, pdf_support_required=True).keys()) + ['Open Sans']
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _register_fonts(cls, event: Event = None):
|
def _register_fonts(cls):
|
||||||
if hasattr(cls, '_fonts_registered'):
|
if hasattr(cls, '_fonts_registered'):
|
||||||
return
|
return
|
||||||
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
|
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
|
||||||
@@ -772,7 +748,7 @@ class Renderer:
|
|||||||
pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
|
pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
|
||||||
pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
|
pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
|
||||||
|
|
||||||
for family, styles in get_fonts(event, pdf_support_required=True).items():
|
for family, styles in get_fonts().items():
|
||||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
||||||
if 'italic' in styles:
|
if 'italic' in styles:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
|
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
|
||||||
@@ -958,13 +934,6 @@ class Renderer:
|
|||||||
|
|
||||||
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||||
font = o['fontfamily']
|
font = o['fontfamily']
|
||||||
|
|
||||||
# Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they
|
|
||||||
# should not have access to.
|
|
||||||
if font not in self.event_fonts:
|
|
||||||
logger.warning(f'Unauthorized use of font "{font}"')
|
|
||||||
font = 'Open Sans'
|
|
||||||
|
|
||||||
if o['bold']:
|
if o['bold']:
|
||||||
font += ' B'
|
font += ' B'
|
||||||
if o['italic']:
|
if o['italic']:
|
||||||
@@ -1068,71 +1037,56 @@ class Renderer:
|
|||||||
canvas.showPage()
|
canvas.showPage()
|
||||||
|
|
||||||
def render_background(self, buffer, title=_('Ticket')):
|
def render_background(self, buffer, title=_('Ticket')):
|
||||||
buffer.seek(0)
|
|
||||||
fg_pdf = PdfReader(buffer)
|
|
||||||
|
|
||||||
if settings.PDFTK:
|
if settings.PDFTK:
|
||||||
|
buffer.seek(0)
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
fg_filename = os.path.join(d, 'fg.pdf')
|
with open(os.path.join(d, 'back.pdf'), 'wb') as f:
|
||||||
bg_filename = os.path.join(d, 'bg.pdf')
|
f.write(self.bg_bytes)
|
||||||
out_filename = os.path.join(d, 'out.pdf')
|
with open(os.path.join(d, 'front.pdf'), 'wb') as f:
|
||||||
|
|
||||||
buffer.seek(0)
|
|
||||||
with open(fg_filename, 'wb') as f:
|
|
||||||
f.write(buffer.read())
|
f.write(buffer.read())
|
||||||
# pdf_header is a string like "%pdf-X.X"
|
subprocess.run([
|
||||||
if float(self.bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
|
settings.PDFTK,
|
||||||
# To fix issues with pdftk and background-PDF using pdf-version greater
|
os.path.join(d, 'front.pdf'),
|
||||||
# than foreground-PDF, we stamp front onto back instead.
|
'multibackground',
|
||||||
# Just changing PDF-version in fg.pdf to match the version of
|
os.path.join(d, 'back.pdf'),
|
||||||
# bg.pdf as we do with pypdf, does not work with pdftk.
|
'output',
|
||||||
#
|
os.path.join(d, 'out.pdf'),
|
||||||
# Make sure that bg.pdf matches the number of pages of fg.pdf
|
'compress'
|
||||||
# note: self.bg_pdf is a PdfReader(), not a PdfWriter()
|
], check=True)
|
||||||
fg_num_pages = fg_pdf.get_num_pages()
|
with open(os.path.join(d, 'out.pdf'), 'rb') as f:
|
||||||
bg_num_pages = self.bg_pdf.get_num_pages()
|
|
||||||
bg_pdf_to_merge = PdfWriter()
|
|
||||||
bg_pdf_to_merge.append(self.bg_pdf, pages=(0, min(bg_num_pages, fg_num_pages)))
|
|
||||||
if fg_num_pages > bg_num_pages:
|
|
||||||
# repeat last page in bg_pdf to match fg_pdf
|
|
||||||
bg_pdf_to_merge.append(bg_pdf_to_merge, pages=[bg_num_pages - 1] * (fg_num_pages - bg_num_pages))
|
|
||||||
|
|
||||||
bg_pdf_to_merge.write(bg_filename)
|
|
||||||
|
|
||||||
pdftk_cmd = [
|
|
||||||
settings.PDFTK,
|
|
||||||
bg_filename,
|
|
||||||
'multistamp',
|
|
||||||
fg_filename
|
|
||||||
]
|
|
||||||
|
|
||||||
else:
|
|
||||||
with open(bg_filename, 'wb') as f:
|
|
||||||
f.write(self.bg_bytes)
|
|
||||||
pdftk_cmd = [
|
|
||||||
settings.PDFTK,
|
|
||||||
fg_filename,
|
|
||||||
'multibackground',
|
|
||||||
bg_filename
|
|
||||||
]
|
|
||||||
|
|
||||||
pdftk_cmd.extend(('output', out_filename, 'compress'))
|
|
||||||
subprocess.run(pdftk_cmd, check=True)
|
|
||||||
with open(out_filename, 'rb') as f:
|
|
||||||
return BytesIO(f.read())
|
return BytesIO(f.read())
|
||||||
else:
|
else:
|
||||||
|
buffer.seek(0)
|
||||||
|
new_pdf = PdfReader(buffer)
|
||||||
output = PdfWriter()
|
output = PdfWriter()
|
||||||
|
|
||||||
for i, page in enumerate(fg_pdf.pages):
|
for i, page in enumerate(new_pdf.pages):
|
||||||
bg_page = self.bg_pdf.pages[i]
|
bg_page = copy.deepcopy(self.bg_pdf.pages[i])
|
||||||
if bg_page.rotation != 0:
|
bg_rotation = bg_page.get('/Rotate')
|
||||||
bg_page.transfer_rotation_to_content()
|
if bg_rotation:
|
||||||
page.merge_page(bg_page, over=False)
|
# /Rotate is clockwise, transformation.rotate is counter-clockwise
|
||||||
output.add_page(page)
|
t = Transformation().rotate(bg_rotation)
|
||||||
|
w = float(page.mediabox.getWidth())
|
||||||
# pdf_header is a string like "%pdf-X.X"
|
h = float(page.mediabox.getHeight())
|
||||||
if float(self.bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
|
if bg_rotation in (90, 270):
|
||||||
output.pdf_header = self.bg_pdf.pdf_header
|
# offset due to rotation base
|
||||||
|
if bg_rotation == 90:
|
||||||
|
t = t.translate(h, 0)
|
||||||
|
else:
|
||||||
|
t = t.translate(0, w)
|
||||||
|
# rotate mediabox as well
|
||||||
|
page.mediabox = RectangleObject((
|
||||||
|
page.mediabox.left.as_numeric(),
|
||||||
|
page.mediabox.bottom.as_numeric(),
|
||||||
|
page.mediabox.top.as_numeric(),
|
||||||
|
page.mediabox.right.as_numeric(),
|
||||||
|
))
|
||||||
|
page.trimbox = page.mediabox
|
||||||
|
elif bg_rotation == 180:
|
||||||
|
t = t.translate(w, h)
|
||||||
|
page.add_transformation(t)
|
||||||
|
bg_page.merge_page(page)
|
||||||
|
output.add_page(bg_page)
|
||||||
|
|
||||||
output.add_metadata({
|
output.add_metadata({
|
||||||
'/Title': str(title),
|
'/Title': str(title),
|
||||||
@@ -1144,61 +1098,54 @@ class Renderer:
|
|||||||
return outbuffer
|
return outbuffer
|
||||||
|
|
||||||
|
|
||||||
def merge_background(fg_pdf: PdfWriter, bg_pdf: PdfWriter, out_file, compress):
|
def merge_background(fg_pdf, bg_pdf, out_file, compress):
|
||||||
if settings.PDFTK:
|
if settings.PDFTK:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
fg_filename = os.path.join(d, 'fg.pdf')
|
fg_filename = os.path.join(d, 'fg.pdf')
|
||||||
bg_filename = os.path.join(d, 'bg.pdf')
|
bg_filename = os.path.join(d, 'bg.pdf')
|
||||||
|
|
||||||
# pdf_header is a string like "%pdf-X.X"
|
|
||||||
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
|
|
||||||
# To fix issues with pdftk and background-PDF using pdf-version greater
|
|
||||||
# than foreground-PDF, we stamp front onto back instead.
|
|
||||||
# Just changing PDF-version in fg.pdf to match the version of
|
|
||||||
# bg.pdf as we do with pypdf, does not work with pdftk.
|
|
||||||
|
|
||||||
# Make sure that bg.pdf matches the number of pages of fg.pdf
|
|
||||||
fg_num_pages = fg_pdf.get_num_pages()
|
|
||||||
bg_num_pages = bg_pdf.get_num_pages()
|
|
||||||
if fg_num_pages > bg_num_pages:
|
|
||||||
# repeat last page in bg_pdf to match fg_pdf
|
|
||||||
bg_pdf.append(bg_pdf, pages=[bg_num_pages - 1] * (fg_num_pages - bg_num_pages))
|
|
||||||
|
|
||||||
bg_pdf.write(bg_filename)
|
|
||||||
|
|
||||||
pdftk_cmd = [
|
|
||||||
settings.PDFTK,
|
|
||||||
bg_filename,
|
|
||||||
'multistamp',
|
|
||||||
fg_filename,
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
pdftk_cmd = [
|
|
||||||
settings.PDFTK,
|
|
||||||
fg_filename,
|
|
||||||
'multibackground',
|
|
||||||
bg_filename
|
|
||||||
]
|
|
||||||
|
|
||||||
pdftk_cmd.extend(('output', '-'))
|
|
||||||
if compress:
|
|
||||||
pdftk_cmd.append('compress')
|
|
||||||
|
|
||||||
fg_pdf.write(fg_filename)
|
fg_pdf.write(fg_filename)
|
||||||
bg_pdf.write(bg_filename)
|
bg_pdf.write(bg_filename)
|
||||||
|
pdftk_cmd = [
|
||||||
|
settings.PDFTK,
|
||||||
|
fg_filename,
|
||||||
|
'multibackground',
|
||||||
|
bg_filename,
|
||||||
|
'output',
|
||||||
|
'-',
|
||||||
|
]
|
||||||
|
if compress:
|
||||||
|
pdftk_cmd.append('compress')
|
||||||
subprocess.run(pdftk_cmd, check=True, stdout=out_file)
|
subprocess.run(pdftk_cmd, check=True, stdout=out_file)
|
||||||
else:
|
else:
|
||||||
|
output = PdfWriter()
|
||||||
for i, page in enumerate(fg_pdf.pages):
|
for i, page in enumerate(fg_pdf.pages):
|
||||||
bg_page = bg_pdf.pages[i]
|
bg_page = copy.deepcopy(bg_pdf.pages[i])
|
||||||
if bg_page.rotation != 0:
|
bg_rotation = bg_page.get('/Rotate')
|
||||||
bg_page.transfer_rotation_to_content()
|
if bg_rotation:
|
||||||
page.merge_page(bg_page, over=False)
|
# /Rotate is clockwise, transformation.rotate is counter-clockwise
|
||||||
|
t = Transformation().rotate(bg_rotation)
|
||||||
# pdf_header is a string like "%pdf-X.X"
|
w = float(page.mediabox.getWidth())
|
||||||
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
|
h = float(page.mediabox.getHeight())
|
||||||
fg_pdf.pdf_header = bg_pdf.pdf_header
|
if bg_rotation in (90, 270):
|
||||||
|
# offset due to rotation base
|
||||||
fg_pdf.write(out_file)
|
if bg_rotation == 90:
|
||||||
|
t = t.translate(h, 0)
|
||||||
|
else:
|
||||||
|
t = t.translate(0, w)
|
||||||
|
# rotate mediabox as well
|
||||||
|
page.mediabox = RectangleObject((
|
||||||
|
page.mediabox.left.as_numeric(),
|
||||||
|
page.mediabox.bottom.as_numeric(),
|
||||||
|
page.mediabox.top.as_numeric(),
|
||||||
|
page.mediabox.right.as_numeric(),
|
||||||
|
))
|
||||||
|
page.trimbox = page.mediabox
|
||||||
|
elif bg_rotation == 180:
|
||||||
|
t = t.translate(w, h)
|
||||||
|
page.add_transformation(t)
|
||||||
|
bg_page.merge_page(page)
|
||||||
|
output.add_page(bg_page)
|
||||||
|
output.write(out_file)
|
||||||
|
|
||||||
|
|
||||||
@deconstructible
|
@deconstructible
|
||||||
|
|||||||
@@ -257,8 +257,6 @@ def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_us
|
|||||||
kwargs['valid_from'] = position.valid_from
|
kwargs['valid_from'] = position.valid_from
|
||||||
if 'valid_until' in params:
|
if 'valid_until' in params:
|
||||||
kwargs['valid_until'] = position.valid_until
|
kwargs['valid_until'] = position.valid_until
|
||||||
if 'order_datetime' in params:
|
|
||||||
kwargs['order_datetime'] = position.order.datetime
|
|
||||||
secret = gen.generate_secret(
|
secret = gen.generate_secret(
|
||||||
item=position.item,
|
item=position.item,
|
||||||
variation=position.variation,
|
variation=position.variation,
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ from pretix.base.services.tasks import ProfiledEventTask
|
|||||||
from pretix.base.settings import PERSON_NAME_SCHEMES, LazyI18nStringList
|
from pretix.base.settings import PERSON_NAME_SCHEMES, LazyI18nStringList
|
||||||
from pretix.base.signals import validate_cart_addons
|
from pretix.base.signals import validate_cart_addons
|
||||||
from pretix.base.templatetags.rich_text import rich_text
|
from pretix.base.templatetags.rich_text import rich_text
|
||||||
from pretix.base.timemachine import time_machine_now, time_machine_now_assigned
|
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.presale.signals import (
|
from pretix.presale.signals import (
|
||||||
checkout_confirm_messages, fee_calculation_for_cart,
|
checkout_confirm_messages, fee_calculation_for_cart,
|
||||||
@@ -204,7 +203,7 @@ error_messages = {
|
|||||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||||
'min'
|
'min'
|
||||||
),
|
),
|
||||||
'addon_no_multi': gettext_lazy('You can select every add-on from the category %(cat)s for the product %(base)s at most once.'),
|
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||||
'addon_only': gettext_lazy('One of the products you selected can only be bought as an add-on to another product.'),
|
'addon_only': gettext_lazy('One of the products you selected can only be bought as an add-on to another product.'),
|
||||||
'bundled_only': gettext_lazy('One of the products you selected can only be bought part of a bundle.'),
|
'bundled_only': gettext_lazy('One of the products you selected can only be bought part of a bundle.'),
|
||||||
'seat_required': gettext_lazy('You need to select a specific seat.'),
|
'seat_required': gettext_lazy('You need to select a specific seat.'),
|
||||||
@@ -279,7 +278,7 @@ class CartManager:
|
|||||||
sales_channel='web'):
|
sales_channel='web'):
|
||||||
self.event = event
|
self.event = event
|
||||||
self.cart_id = cart_id
|
self.cart_id = cart_id
|
||||||
self.real_now_dt = now()
|
self.now_dt = now()
|
||||||
self._operations = []
|
self._operations = []
|
||||||
self._quota_diff = Counter()
|
self._quota_diff = Counter()
|
||||||
self._voucher_use_diff = Counter()
|
self._voucher_use_diff = Counter()
|
||||||
@@ -306,10 +305,10 @@ class CartManager:
|
|||||||
return self._seated_cache[item, subevent]
|
return self._seated_cache[item, subevent]
|
||||||
|
|
||||||
def _calculate_expiry(self):
|
def _calculate_expiry(self):
|
||||||
self._expiry = self.real_now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
||||||
|
|
||||||
def _check_presale_dates(self):
|
def _check_presale_dates(self):
|
||||||
if self.event.presale_start and time_machine_now(self.real_now_dt) < self.event.presale_start:
|
if self.event.presale_start and self.now_dt < self.event.presale_start:
|
||||||
raise CartError(error_messages['not_started'])
|
raise CartError(error_messages['not_started'])
|
||||||
if self.event.presale_has_ended:
|
if self.event.presale_has_ended:
|
||||||
raise CartError(error_messages['ended'])
|
raise CartError(error_messages['ended'])
|
||||||
@@ -320,13 +319,13 @@ class CartManager:
|
|||||||
tlv.datetime(self.event).date(),
|
tlv.datetime(self.event).date(),
|
||||||
time(hour=23, minute=59, second=59)
|
time(hour=23, minute=59, second=59)
|
||||||
), self.event.timezone)
|
), self.event.timezone)
|
||||||
if term_last < time_machine_now(self.real_now_dt):
|
if term_last < self.now_dt:
|
||||||
raise CartError(error_messages['payment_ended'])
|
raise CartError(error_messages['payment_ended'])
|
||||||
|
|
||||||
def _extend_expiry_of_valid_existing_positions(self):
|
def _extend_expiry_of_valid_existing_positions(self):
|
||||||
# Extend this user's cart session to ensure all items in the cart expire at the same time
|
# Extend this user's cart session to ensure all items in the cart expire at the same time
|
||||||
# We can extend the reservation of items which are not yet expired without risk
|
# We can extend the reservation of items which are not yet expired without risk
|
||||||
self.positions.filter(expires__gt=self.real_now_dt).update(expires=self._expiry)
|
self.positions.filter(expires__gt=self.now_dt).update(expires=self._expiry)
|
||||||
|
|
||||||
def _delete_out_of_timeframe(self):
|
def _delete_out_of_timeframe(self):
|
||||||
err = None
|
err = None
|
||||||
@@ -334,12 +333,12 @@ class CartManager:
|
|||||||
if not cp.pk:
|
if not cp.pk:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if cp.subevent and cp.subevent.presale_start and time_machine_now(self.real_now_dt) < cp.subevent.presale_start:
|
if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start:
|
||||||
err = error_messages['some_subevent_not_started']
|
err = error_messages['some_subevent_not_started']
|
||||||
cp.addons.all().delete()
|
cp.addons.all().delete()
|
||||||
cp.delete()
|
cp.delete()
|
||||||
|
|
||||||
if cp.subevent and cp.subevent.presale_end and time_machine_now(self.real_now_dt) > cp.subevent.presale_end:
|
if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end:
|
||||||
err = error_messages['some_subevent_ended']
|
err = error_messages['some_subevent_ended']
|
||||||
cp.addons.all().delete()
|
cp.addons.all().delete()
|
||||||
cp.delete()
|
cp.delete()
|
||||||
@@ -351,7 +350,7 @@ class CartManager:
|
|||||||
tlv.datetime(cp.subevent).date(),
|
tlv.datetime(cp.subevent).date(),
|
||||||
time(hour=23, minute=59, second=59)
|
time(hour=23, minute=59, second=59)
|
||||||
), self.event.timezone)
|
), self.event.timezone)
|
||||||
if term_last < time_machine_now(self.real_now_dt):
|
if term_last < self.now_dt:
|
||||||
err = error_messages['some_subevent_ended']
|
err = error_messages['some_subevent_ended']
|
||||||
cp.addons.all().delete()
|
cp.addons.all().delete()
|
||||||
cp.delete()
|
cp.delete()
|
||||||
@@ -450,7 +449,7 @@ class CartManager:
|
|||||||
if op.subevent and not op.subevent.active:
|
if op.subevent and not op.subevent.active:
|
||||||
raise CartError(error_messages['inactive_subevent'])
|
raise CartError(error_messages['inactive_subevent'])
|
||||||
|
|
||||||
if op.subevent and op.subevent.presale_start and time_machine_now(self.real_now_dt) < op.subevent.presale_start:
|
if op.subevent and op.subevent.presale_start and self.now_dt < op.subevent.presale_start:
|
||||||
raise CartError(error_messages['not_started'])
|
raise CartError(error_messages['not_started'])
|
||||||
|
|
||||||
if op.subevent and op.subevent.presale_has_ended:
|
if op.subevent and op.subevent.presale_has_ended:
|
||||||
@@ -473,7 +472,7 @@ class CartManager:
|
|||||||
tlv.datetime(op.subevent).date(),
|
tlv.datetime(op.subevent).date(),
|
||||||
time(hour=23, minute=59, second=59)
|
time(hour=23, minute=59, second=59)
|
||||||
), self.event.timezone)
|
), self.event.timezone)
|
||||||
if term_last < time_machine_now(self.real_now_dt):
|
if term_last < self.now_dt:
|
||||||
raise CartError(error_messages['payment_ended'])
|
raise CartError(error_messages['payment_ended'])
|
||||||
|
|
||||||
if isinstance(op, self.AddOperation):
|
if isinstance(op, self.AddOperation):
|
||||||
@@ -510,7 +509,7 @@ class CartManager:
|
|||||||
)
|
)
|
||||||
if not self.event.settings.seating_choice:
|
if not self.event.settings.seating_choice:
|
||||||
requires_seat = Value(0, output_field=IntegerField())
|
requires_seat = Value(0, output_field=IntegerField())
|
||||||
expired = self.positions.filter(expires__lte=self.real_now_dt).select_related(
|
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
||||||
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
|
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
|
||||||
).annotate(
|
).annotate(
|
||||||
requires_seat=requires_seat
|
requires_seat=requires_seat
|
||||||
@@ -691,7 +690,7 @@ class CartManager:
|
|||||||
# than either of the possible default assumptions.
|
# than either of the possible default assumptions.
|
||||||
predicted_redeemed_after = (
|
predicted_redeemed_after = (
|
||||||
voucher.redeemed +
|
voucher.redeemed +
|
||||||
CartPosition.objects.filter(voucher=voucher, expires__gte=self.real_now_dt).count() +
|
CartPosition.objects.filter(voucher=voucher, expires__gte=self.now_dt).count() +
|
||||||
self._voucher_use_diff[voucher] +
|
self._voucher_use_diff[voucher] +
|
||||||
voucher_use_diff[voucher]
|
voucher_use_diff[voucher]
|
||||||
)
|
)
|
||||||
@@ -983,7 +982,7 @@ class CartManager:
|
|||||||
current_num = len(current_addons[cp].get(k, []))
|
current_num = len(current_addons[cp].get(k, []))
|
||||||
if input_num < current_num:
|
if input_num < current_num:
|
||||||
for a in current_addons[cp][k][:current_num - input_num]:
|
for a in current_addons[cp][k][:current_num - input_num]:
|
||||||
if a.expires > self.real_now_dt:
|
if a.expires > self.now_dt:
|
||||||
quotas = list(a.quotas)
|
quotas = list(a.quotas)
|
||||||
|
|
||||||
for quota in quotas:
|
for quota in quotas:
|
||||||
@@ -997,7 +996,7 @@ class CartManager:
|
|||||||
|
|
||||||
def _get_voucher_availability(self):
|
def _get_voucher_availability(self):
|
||||||
vouchers_ok, self._voucher_depend_on_cart = _get_voucher_availability(
|
vouchers_ok, self._voucher_depend_on_cart = _get_voucher_availability(
|
||||||
self.event, self._voucher_use_diff, self.real_now_dt,
|
self.event, self._voucher_use_diff, self.now_dt,
|
||||||
exclude_position_ids=[
|
exclude_position_ids=[
|
||||||
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
|
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||||
]
|
]
|
||||||
@@ -1102,7 +1101,7 @@ class CartManager:
|
|||||||
shared_lock_objects=[self.event]
|
shared_lock_objects=[self.event]
|
||||||
)
|
)
|
||||||
vouchers_ok = self._get_voucher_availability()
|
vouchers_ok = self._get_voucher_availability()
|
||||||
quotas_ok = _get_quota_availability(self._quota_diff, self.real_now_dt)
|
quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt)
|
||||||
err = None
|
err = None
|
||||||
new_cart_positions = []
|
new_cart_positions = []
|
||||||
deleted_positions = set()
|
deleted_positions = set()
|
||||||
@@ -1119,7 +1118,7 @@ class CartManager:
|
|||||||
|
|
||||||
for iop, op in enumerate(self._operations):
|
for iop, op in enumerate(self._operations):
|
||||||
if isinstance(op, self.RemoveOperation):
|
if isinstance(op, self.RemoveOperation):
|
||||||
if op.position.expires > self.real_now_dt:
|
if op.position.expires > self.now_dt:
|
||||||
for q in op.position.quotas:
|
for q in op.position.quotas:
|
||||||
quotas_ok[q] += 1
|
quotas_ok[q] += 1
|
||||||
addons = op.position.addons.all()
|
addons = op.position.addons.all()
|
||||||
@@ -1359,7 +1358,7 @@ class CartManager:
|
|||||||
return err
|
return err
|
||||||
|
|
||||||
def recompute_final_prices_and_taxes(self):
|
def recompute_final_prices_and_taxes(self):
|
||||||
positions = sorted(list(self.positions), key=lambda cp: (-(cp.addon_to_id or 0), cp.pk))
|
positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0))
|
||||||
diff = Decimal('0.00')
|
diff = Decimal('0.00')
|
||||||
for cp in positions:
|
for cp in positions:
|
||||||
if cp.listed_price is None:
|
if cp.listed_price is None:
|
||||||
@@ -1396,7 +1395,7 @@ class CartManager:
|
|||||||
err = self.extend_expired_positions() or err
|
err = self.extend_expired_positions() or err
|
||||||
err = err or self._check_min_per_voucher()
|
err = err or self._check_min_per_voucher()
|
||||||
|
|
||||||
self.real_now_dt = now()
|
self.now_dt = now()
|
||||||
|
|
||||||
self._extend_expiry_of_valid_existing_positions()
|
self._extend_expiry_of_valid_existing_positions()
|
||||||
err = self._perform_operations() or err
|
err = self._perform_operations() or err
|
||||||
@@ -1488,7 +1487,7 @@ def get_fees(event, request, total, invoice_address, payments, positions):
|
|||||||
|
|
||||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
|
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
|
||||||
invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None) -> None:
|
invoice_address: int=None, widget_data=None, sales_channel='web') -> None:
|
||||||
"""
|
"""
|
||||||
Adds a list of items to a user's cart.
|
Adds a list of items to a user's cart.
|
||||||
:param event: The event ID in question
|
:param event: The event ID in question
|
||||||
@@ -1496,7 +1495,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
|||||||
:param cart_id: Session ID of a guest
|
:param cart_id: Session ID of a guest
|
||||||
:raises CartError: On any error that occurred
|
:raises CartError: On any error that occurred
|
||||||
"""
|
"""
|
||||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
with language(locale):
|
||||||
ia = False
|
ia = False
|
||||||
if invoice_address:
|
if invoice_address:
|
||||||
try:
|
try:
|
||||||
@@ -1518,14 +1517,14 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
|||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||||
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web') -> None:
|
||||||
"""
|
"""
|
||||||
Removes a list of items from a user's cart.
|
Removes a list of items from a user's cart.
|
||||||
:param event: The event ID in question
|
:param event: The event ID in question
|
||||||
:param voucher: A voucher code
|
:param voucher: A voucher code
|
||||||
:param session: Session ID of a guest
|
:param session: Session ID of a guest
|
||||||
"""
|
"""
|
||||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
with language(locale):
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||||
@@ -1538,14 +1537,14 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
|
|||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||||
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web') -> None:
|
||||||
"""
|
"""
|
||||||
Removes a list of items from a user's cart.
|
Removes a list of items from a user's cart.
|
||||||
:param event: The event ID in question
|
:param event: The event ID in question
|
||||||
:param position: A cart position ID
|
:param position: A cart position ID
|
||||||
:param session: Session ID of a guest
|
:param session: Session ID of a guest
|
||||||
"""
|
"""
|
||||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
with language(locale):
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||||
@@ -1558,13 +1557,13 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
|
|||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||||
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web') -> None:
|
||||||
"""
|
"""
|
||||||
Removes a list of items from a user's cart.
|
Removes a list of items from a user's cart.
|
||||||
:param event: The event ID in question
|
:param event: The event ID in question
|
||||||
:param session: Session ID of a guest
|
:param session: Session ID of a guest
|
||||||
"""
|
"""
|
||||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
with language(locale):
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||||
@@ -1578,14 +1577,14 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
|
|||||||
|
|
||||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||||
def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en',
|
def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en',
|
||||||
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:
|
invoice_address: int=None, sales_channel='web') -> None:
|
||||||
"""
|
"""
|
||||||
Removes a list of items from a user's cart.
|
Removes a list of items from a user's cart.
|
||||||
:param event: The event ID in question
|
:param event: The event ID in question
|
||||||
:param addons: A list of dicts with the keys addon_to, item, variation
|
:param addons: A list of dicts with the keys addon_to, item, variation
|
||||||
:param session: Session ID of a guest
|
:param session: Session ID of a guest
|
||||||
"""
|
"""
|
||||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
with language(locale):
|
||||||
ia = False
|
ia = False
|
||||||
if invoice_address:
|
if invoice_address:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ from dateutil.tz import datetime_exists
|
|||||||
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 (
|
||||||
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
|
BooleanField, Count, ExpressionWrapper, F, IntegerField, Max, Min,
|
||||||
OuterRef, Q, Subquery, TextField, Value, When,
|
OuterRef, Q, Subquery, Value,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce, TruncDate
|
from django.db.models.functions import Coalesce, TruncDate
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -273,14 +273,6 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
|
|||||||
var_texts[vname] = _('Only allowed before {datetime}').format(datetime=compare_to_text)
|
var_texts[vname] = _('Only allowed before {datetime}').format(datetime=compare_to_text)
|
||||||
elif operator == 'isAfter':
|
elif operator == 'isAfter':
|
||||||
var_texts[vname] = _('Only allowed after {datetime}').format(datetime=compare_to_text)
|
var_texts[vname] = _('Only allowed after {datetime}').format(datetime=compare_to_text)
|
||||||
elif var == 'entry_status':
|
|
||||||
var_weights[vname] = (20, 0)
|
|
||||||
if operator == '==' and rhs[0] == 'present':
|
|
||||||
var_texts[vname] = _('Attendee is checked out')
|
|
||||||
elif operator == '==' and rhs[0] == 'absent':
|
|
||||||
var_texts[vname] = _('Attendee is already checked in')
|
|
||||||
else:
|
|
||||||
var_texts[vname] = f'{var} not {operator} {rhs}'
|
|
||||||
elif var == 'product' or var == 'variation':
|
elif var == 'product' or var == 'variation':
|
||||||
var_weights[vname] = (1000, 0)
|
var_weights[vname] = (1000, 0)
|
||||||
var_texts[vname] = _('Ticket type not allowed')
|
var_texts[vname] = _('Ticket type not allowed')
|
||||||
@@ -515,13 +507,6 @@ class LazyRuleVars:
|
|||||||
day=TruncDate('datetime', tzinfo=tz)
|
day=TruncDate('datetime', tzinfo=tz)
|
||||||
).values('day').distinct().count()
|
).values('day').distinct().count()
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def entry_status(self):
|
|
||||||
last_checkin = self._position.checkins.filter(list=self._clist).order_by('datetime').last()
|
|
||||||
if not last_checkin or last_checkin.type == Checkin.TYPE_EXIT:
|
|
||||||
return "absent"
|
|
||||||
return "present"
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def minutes_since_last_entry(self):
|
def minutes_since_last_entry(self):
|
||||||
tz = self._clist.event.timezone
|
tz = self._clist.event.timezone
|
||||||
@@ -584,8 +569,6 @@ class SQLLogic:
|
|||||||
'entries_days_since', 'entries_days_before'}
|
'entries_days_since', 'entries_days_before'}
|
||||||
|
|
||||||
def operation_to_expression(self, rule):
|
def operation_to_expression(self, rule):
|
||||||
if isinstance(rule, str):
|
|
||||||
return Value(rule)
|
|
||||||
if not isinstance(rule, dict):
|
if not isinstance(rule, dict):
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
@@ -787,25 +770,6 @@ class SQLLogic:
|
|||||||
Value(-1),
|
Value(-1),
|
||||||
output_field=IntegerField()
|
output_field=IntegerField()
|
||||||
)
|
)
|
||||||
elif values[0] == 'entry_status':
|
|
||||||
sq_last_checkin = Subquery(
|
|
||||||
Checkin.objects.filter(
|
|
||||||
position_id=OuterRef('pk'),
|
|
||||||
list_id=self.list.pk,
|
|
||||||
).order_by('-datetime').values('type')[:1]
|
|
||||||
)
|
|
||||||
|
|
||||||
return Case(
|
|
||||||
When(
|
|
||||||
condition=Equal(
|
|
||||||
sq_last_checkin,
|
|
||||||
Value(Checkin.TYPE_ENTRY)
|
|
||||||
),
|
|
||||||
then=Value("present"),
|
|
||||||
),
|
|
||||||
default=Value("absent"),
|
|
||||||
output_field=TextField()
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'Unknown operator {operator}')
|
raise ValueError(f'Unknown operator {operator}')
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from pretix.base.models import CachedCombinedTicket, CachedTicket
|
|||||||
from pretix.base.models.customers import CustomerSSOGrant
|
from pretix.base.models.customers import CustomerSSOGrant
|
||||||
|
|
||||||
from ..models import CachedFile, CartPosition, InvoiceAddress
|
from ..models import CachedFile, CartPosition, InvoiceAddress
|
||||||
from ..models.auth import UserKnownLoginSource
|
|
||||||
from ..signals import periodic_task
|
from ..signals import periodic_task
|
||||||
|
|
||||||
|
|
||||||
@@ -76,9 +75,3 @@ def clearsessions(sender, **kwargs):
|
|||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
def clear_oidc_data(sender, **kwargs):
|
def clear_oidc_data(sender, **kwargs):
|
||||||
CustomerSSOGrant.objects.filter(expires__lt=now() - timedelta(days=14)).delete()
|
CustomerSSOGrant.objects.filter(expires__lt=now() - timedelta(days=14)).delete()
|
||||||
|
|
||||||
|
|
||||||
@receiver(signal=periodic_task)
|
|
||||||
@scopes_disabled()
|
|
||||||
def clear_old_login_sources(sender, **kwargs):
|
|
||||||
UserKnownLoginSource.objects.filter(last_seen__lt=now() - timedelta(days=365)).delete()
|
|
||||||
|
|||||||
@@ -104,10 +104,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
|
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
|
||||||
)
|
)
|
||||||
|
|
||||||
invoice.introductory_text = str(introductory).replace('\n', '<br />').replace('\r', '')
|
invoice.introductory_text = str(introductory).replace('\n', '<br />')
|
||||||
invoice.additional_text = str(additional).replace('\n', '<br />').replace('\r', '')
|
invoice.additional_text = str(additional).replace('\n', '<br />')
|
||||||
invoice.footer_text = str(footer)
|
invoice.footer_text = str(footer)
|
||||||
invoice.payment_provider_text = str(payment).replace('\n', '<br />').replace('\r', '')
|
invoice.payment_provider_text = str(payment).replace('\n', '<br />')
|
||||||
invoice.payment_provider_stamp = str(payment_stamp) if payment_stamp else None
|
invoice.payment_provider_stamp = str(payment_stamp) if payment_stamp else None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -462,10 +462,10 @@ def build_preview_invoice_pdf(event):
|
|||||||
footer = event.settings.get('invoice_footer_text', as_type=LazyI18nString)
|
footer = event.settings.get('invoice_footer_text', as_type=LazyI18nString)
|
||||||
payment = _("A payment provider specific text might appear here.")
|
payment = _("A payment provider specific text might appear here.")
|
||||||
|
|
||||||
invoice.introductory_text = str(introductory).replace('\n', '<br />').replace('\r', '')
|
invoice.introductory_text = str(introductory).replace('\n', '<br />')
|
||||||
invoice.additional_text = str(additional).replace('\n', '<br />').replace('\r', '')
|
invoice.additional_text = str(additional).replace('\n', '<br />')
|
||||||
invoice.footer_text = str(footer)
|
invoice.footer_text = str(footer)
|
||||||
invoice.payment_provider_text = str(payment).replace('\n', '<br />').replace('\r', '')
|
invoice.payment_provider_text = str(payment).replace('\n', '<br />')
|
||||||
invoice.payment_provider_stamp = _('paid')
|
invoice.payment_provider_stamp = _('paid')
|
||||||
invoice.invoice_to_name = _("John Doe")
|
invoice.invoice_to_name = _("John Doe")
|
||||||
invoice.invoice_to_street = _("214th Example Street")
|
invoice.invoice_to_street = _("214th Example Street")
|
||||||
@@ -488,7 +488,7 @@ def build_preview_invoice_pdf(event):
|
|||||||
InvoiceLine.objects.create(
|
InvoiceLine.objects.create(
|
||||||
invoice=invoice, description=_("Sample product {}").format(i + 1),
|
invoice=invoice, description=_("Sample product {}").format(i + 1),
|
||||||
gross_value=tax.gross, tax_value=tax.tax,
|
gross_value=tax.gross, tax_value=tax.tax,
|
||||||
tax_rate=tax.rate, tax_name=tax.name
|
tax_rate=tax.rate
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
|
|||||||
@@ -383,7 +383,6 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
|||||||
if event:
|
if event:
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
event = Event.objects.get(id=event)
|
event = Event.objects.get(id=event)
|
||||||
organizer = event.organizer
|
|
||||||
backend = event.get_mail_backend()
|
backend = event.get_mail_backend()
|
||||||
cm = lambda: scope(organizer=event.organizer) # noqa
|
cm = lambda: scope(organizer=event.organizer) # noqa
|
||||||
elif organizer:
|
elif organizer:
|
||||||
@@ -470,8 +469,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
|||||||
# just "ABC-123.pdf", but we only do so if our currently selected language allows to do this
|
# just "ABC-123.pdf", but we only do so if our currently selected language allows to do this
|
||||||
# as ASCII text. For example, we would not want a "فاتورة_" prefix for our filename since this
|
# as ASCII text. For example, we would not want a "فاتورة_" prefix for our filename since this
|
||||||
# has shown to cause deliverability problems of the email and deliverability wins.
|
# has shown to cause deliverability problems of the email and deliverability wins.
|
||||||
with language(order.locale if order else inv.locale, event.settings.region if event else None):
|
filename = pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf'
|
||||||
filename = pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf'
|
|
||||||
if not re.match("^[a-zA-Z0-9-_%./,&:# ]+$", filename):
|
if not re.match("^[a-zA-Z0-9-_%./,&:# ]+$", filename):
|
||||||
filename = inv.number.replace(' ', '_') + '.pdf'
|
filename = inv.number.replace(' ', '_') + '.pdf'
|
||||||
filename = re.sub("[^a-zA-Z0-9-_.]+", "_", filename)
|
filename = re.sub("[^a-zA-Z0-9-_.]+", "_", filename)
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ from typing import List, Optional
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
AbstractPosition, CartPosition, Customer, Event, Item, Membership, Order,
|
AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition,
|
||||||
OrderPosition, SubEvent,
|
SubEvent,
|
||||||
)
|
)
|
||||||
from pretix.base.timemachine import time_machine_now
|
|
||||||
from pretix.helpers import OF_SELF
|
from pretix.helpers import OF_SELF
|
||||||
|
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ def membership_validity(item: Item, subevent: Optional[SubEvent], event: Event):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# Always start at start of day
|
# Always start at start of day
|
||||||
date_start = time_machine_now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
|
date_start = now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
date_end = date_start
|
date_end = date_start
|
||||||
|
|
||||||
if item.grant_membership_duration_months:
|
if item.grant_membership_duration_months:
|
||||||
@@ -82,8 +82,7 @@ def create_membership(customer: Customer, position: OrderPosition):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False,
|
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False):
|
||||||
valid_from_not_chosen=False):
|
|
||||||
"""
|
"""
|
||||||
Validate that a set of cart or order positions. This currently does not validate
|
Validate that a set of cart or order positions. This currently does not validate
|
||||||
|
|
||||||
@@ -93,8 +92,6 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
|||||||
:param lock: Whether to place a SELECT FOR UPDATE lock on the selected memberships
|
:param lock: Whether to place a SELECT FOR UPDATE lock on the selected memberships
|
||||||
:param ignored_order: An order that should be ignored for usage counting
|
:param ignored_order: An order that should be ignored for usage counting
|
||||||
:param testmode: If ``True``, only test mode memberships are allowed. If ``False``, test mode memberships are not allowed.
|
:param testmode: If ``True``, only test mode memberships are allowed. If ``False``, test mode memberships are not allowed.
|
||||||
:param valid_from_not_chosen: Set to ``True`` to indicate that the customer is in an early step of the checkout flow
|
|
||||||
where the valid_from date is not selected yet. In this case, the valid_from date is not checked.
|
|
||||||
"""
|
"""
|
||||||
tz = event.timezone
|
tz = event.timezone
|
||||||
applicable_positions = [
|
applicable_positions = [
|
||||||
@@ -135,11 +132,7 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
|||||||
qs = qs.exclude(order_id=ignored_order.pk)
|
qs = qs.exclude(order_id=ignored_order.pk)
|
||||||
m._used_at_dates = [
|
m._used_at_dates = [
|
||||||
(op.subevent or op.order.event).date_from
|
(op.subevent or op.order.event).date_from
|
||||||
for op in qs if not op.valid_from or not op.valid_until
|
for op in qs
|
||||||
]
|
|
||||||
m._used_for_ranges = [
|
|
||||||
(op.valid_from, op.valid_until)
|
|
||||||
for op in qs if op.valid_from or op.valid_until
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for p in applicable_positions:
|
for p in applicable_positions:
|
||||||
@@ -154,44 +147,22 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
|||||||
_('You selected membership that has been canceled.')
|
_('You selected membership that has been canceled.')
|
||||||
)
|
)
|
||||||
|
|
||||||
if m.testmode and not testmode:
|
if m.testmode != testmode:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_('You can not use a test mode membership for tickets that are not in test mode.')
|
_('You can only use a test mode membership for test mode tickets.')
|
||||||
)
|
|
||||||
elif not m.testmode and testmode:
|
|
||||||
raise ValidationError(
|
|
||||||
_('You need to add a test mode membership to the customer account to use it in test mode.')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ev = p.subevent or event
|
ev = p.subevent or event
|
||||||
|
|
||||||
if isinstance(p, (OrderPosition, CartPosition)):
|
if not m.is_valid(ev):
|
||||||
# override_ variants are for usage of fake cart in OrderChangeManager
|
raise ValidationError(
|
||||||
valid_from = getattr(p, 'override_valid_from', p.valid_from)
|
_('You selected a membership that is valid from {start} to {end}, but selected an event '
|
||||||
valid_until = getattr(p, 'override_valid_until', p.valid_until)
|
'taking place at {date}.').format(
|
||||||
else: # future safety, not technically defined on AbstractPosition
|
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||||
valid_from = None
|
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||||
valid_until = None
|
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||||
|
|
||||||
if not m.is_valid(ev, valid_from, valid_from_not_chosen=p.item.validity_dynamic_start_choice and valid_from_not_chosen):
|
|
||||||
if valid_from:
|
|
||||||
raise ValidationError(
|
|
||||||
_('You selected a membership that is valid from {start} to {end}, but selected a ticket that '
|
|
||||||
'starts to be valid on {date}.').format(
|
|
||||||
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
|
||||||
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
|
||||||
date=date_format(valid_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValidationError(
|
|
||||||
_('You selected a membership that is valid from {start} to {end}, but selected an event '
|
|
||||||
'taking place at {date}.').format(
|
|
||||||
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
|
||||||
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
|
||||||
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if p.variation and p.variation.require_membership:
|
if p.variation and p.variation.require_membership:
|
||||||
types = p.variation.require_membership_types.all()
|
types = p.variation.require_membership_types.all()
|
||||||
@@ -217,34 +188,13 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
|||||||
m.usages += 1
|
m.usages += 1
|
||||||
|
|
||||||
if not m.membership_type.allow_parallel_usage:
|
if not m.membership_type.allow_parallel_usage:
|
||||||
if (valid_from or valid_until) and not (p.item.validity_dynamic_start_choice and valid_from_not_chosen):
|
df = ev.date_from
|
||||||
for used_range in m._used_for_ranges:
|
if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates):
|
||||||
if valid_from and valid_from > used_range[1]:
|
raise ValidationError(
|
||||||
continue
|
_('You are trying to use a membership of type "{type}" for an event taking place at {date}, '
|
||||||
if valid_until and valid_until < used_range[0]:
|
'however you already used the same membership for a different ticket at the same time.').format(
|
||||||
continue
|
type=m.membership_type.name,
|
||||||
raise ValidationError(
|
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||||
_('You are trying to use a membership of type "{type}" for a ticket valid from {valid_from} '
|
|
||||||
'until {valid_until}, however you already used the same membership for a different ticket '
|
|
||||||
'that overlaps with this time frame ({conflict_from} – {conflict_until}).').format(
|
|
||||||
type=m.membership_type.name,
|
|
||||||
valid_from=date_format(valid_from.astimezone(tz), 'SHORT_DATETIME_FORMAT') if valid_from else _('start'),
|
|
||||||
valid_until=date_format(valid_until.astimezone(tz), 'SHORT_DATETIME_FORMAT') if valid_until else _('open end'),
|
|
||||||
conflict_from=date_format(used_range[0].astimezone(tz), 'SHORT_DATETIME_FORMAT') if used_range[0] else _('start'),
|
|
||||||
conflict_until=date_format(used_range[1].astimezone(tz), 'SHORT_DATETIME_FORMAT') if used_range[1] else _('open end'),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
m._used_for_ranges.append((p.valid_from, p.valid_until))
|
m._used_at_dates.append(ev.date_from)
|
||||||
|
|
||||||
if not valid_from or not valid_until:
|
|
||||||
df = ev.date_from
|
|
||||||
if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates):
|
|
||||||
raise ValidationError(
|
|
||||||
_('You are trying to use a membership of type "{type}" for an event taking place at {date}, '
|
|
||||||
'however you already used the same membership for a different ticket at the same time.').format(
|
|
||||||
type=m.membership_type.name,
|
|
||||||
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
m._used_at_dates.append(ev.date_from)
|
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ def send_notification_mail(notification: Notification, user: User):
|
|||||||
tpl_html = get_template('pretixbase/email/notification.html')
|
tpl_html = get_template('pretixbase/email/notification.html')
|
||||||
|
|
||||||
body_html = tpl_html.render(ctx)
|
body_html = tpl_html.render(ctx)
|
||||||
inliner = css_inline.CSSInliner(keep_style_tags=False)
|
inliner = css_inline.CSSInliner(remove_style_tags=True)
|
||||||
body_html = inliner.inline(body_html)
|
body_html = inliner.inline(body_html)
|
||||||
|
|
||||||
tpl_plain = get_template('pretixbase/email/notification.txt')
|
tpl_plain = get_template('pretixbase/email/notification.txt')
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
# 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 csv
|
||||||
|
import io
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from django.conf import settings as django_settings
|
from django.conf import settings as django_settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@@ -28,15 +29,13 @@ from django.db import transaction
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import LazyLocaleException, language
|
||||||
from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv
|
|
||||||
from pretix.base.modelimport_orders import get_order_import_columns
|
|
||||||
from pretix.base.modelimport_vouchers import get_voucher_import_columns
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition,
|
CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition,
|
||||||
User, Voucher,
|
User,
|
||||||
)
|
)
|
||||||
from pretix.base.models.orders import Transaction
|
from pretix.base.models.orders import Transaction
|
||||||
|
from pretix.base.orderimport import get_all_columns
|
||||||
from pretix.base.services.invoices import generate_invoice, invoice_qualified
|
from pretix.base.services.invoices import generate_invoice, invoice_qualified
|
||||||
from pretix.base.services.locking import lock_objects
|
from pretix.base.services.locking import lock_objects
|
||||||
from pretix.base.services.tasks import ProfiledEventTask
|
from pretix.base.services.tasks import ProfiledEventTask
|
||||||
@@ -44,36 +43,47 @@ from pretix.base.signals import order_paid, order_placed
|
|||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
|
|
||||||
|
|
||||||
def _validate(cf: CachedFile, charset: str, cols: List[ImportColumn], settings: dict):
|
class DataImportError(LazyLocaleException):
|
||||||
|
def __init__(self, *args):
|
||||||
|
msg = args[0]
|
||||||
|
msgargs = args[1] if len(args) > 1 else None
|
||||||
|
self.args = args
|
||||||
|
if msgargs:
|
||||||
|
msg = _(msg) % msgargs
|
||||||
|
else:
|
||||||
|
msg = _(msg)
|
||||||
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv(file, length=None, mode="strict", charset=None):
|
||||||
|
file.seek(0)
|
||||||
|
data = file.read(length)
|
||||||
|
if not charset:
|
||||||
|
try:
|
||||||
|
import chardet
|
||||||
|
charset = chardet.detect(data)['encoding']
|
||||||
|
except ImportError:
|
||||||
|
charset = file.charset
|
||||||
|
data = data.decode(charset or "utf-8", mode)
|
||||||
|
# If the file was modified on a Mac, it only contains \r as line breaks
|
||||||
|
if '\r' in data and '\n' not in data:
|
||||||
|
data = data.replace('\r', '\n')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = parse_csv(cf.file, charset=charset)
|
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
|
||||||
except UnicodeDecodeError as e:
|
except csv.Error:
|
||||||
raise DataImportError(
|
return None
|
||||||
_(
|
|
||||||
'Error decoding special characters in your file: {message}').format(
|
if dialect is None:
|
||||||
message=str(e)
|
return None
|
||||||
)
|
|
||||||
)
|
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
|
||||||
data = []
|
return reader
|
||||||
for i, record in enumerate(parsed):
|
|
||||||
if not any(record.values()):
|
|
||||||
continue
|
def setif(record, obj, attr, setting):
|
||||||
values = {}
|
if setting.startswith('csv:'):
|
||||||
for c in cols:
|
setattr(obj, attr, record[setting[4:]] or '')
|
||||||
val = c.resolve(settings, record)
|
|
||||||
if isinstance(val, str):
|
|
||||||
val = val.strip()
|
|
||||||
try:
|
|
||||||
values[c.identifier] = c.clean(val, values)
|
|
||||||
except ValidationError as e:
|
|
||||||
raise DataImportError(
|
|
||||||
_(
|
|
||||||
'Error while importing value "{value}" for column "{column}" in line "{line}": {message}').format(
|
|
||||||
value=val if val is not None else '', column=c.verbose_name, line=i + 1, message=e.message
|
|
||||||
)
|
|
||||||
)
|
|
||||||
data.append(values)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
|
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
|
||||||
@@ -81,17 +91,45 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
|||||||
cf = CachedFile.objects.get(id=fileid)
|
cf = CachedFile.objects.get(id=fileid)
|
||||||
user = User.objects.get(pk=user)
|
user = User.objects.get(pk=user)
|
||||||
with language(locale, event.settings.region):
|
with language(locale, event.settings.region):
|
||||||
cols = get_order_import_columns(event)
|
cols = get_all_columns(event)
|
||||||
data = _validate(cf, charset, cols, settings)
|
try:
|
||||||
|
parsed = parse_csv(cf.file, charset=charset)
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
raise DataImportError(
|
||||||
|
_(
|
||||||
|
'Error decoding special characters in your file: {message}').format(
|
||||||
|
message=str(e)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
orders = []
|
||||||
|
order = None
|
||||||
|
data = []
|
||||||
|
|
||||||
|
# Run validation
|
||||||
|
for i, record in enumerate(parsed):
|
||||||
|
if not any(record.values()):
|
||||||
|
continue
|
||||||
|
values = {}
|
||||||
|
for c in cols:
|
||||||
|
val = c.resolve(settings, record)
|
||||||
|
if isinstance(val, str):
|
||||||
|
val = val.strip()
|
||||||
|
try:
|
||||||
|
values[c.identifier] = c.clean(val, values)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise DataImportError(
|
||||||
|
_(
|
||||||
|
'Error while importing value "{value}" for column "{column}" in line "{line}": {message}').format(
|
||||||
|
value=val if val is not None else '', column=c.verbose_name, line=i + 1, message=e.message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
data.append(values)
|
||||||
|
|
||||||
if settings['orders'] == 'one' and len(data) > django_settings.PRETIX_MAX_ORDER_SIZE:
|
if settings['orders'] == 'one' and len(data) > django_settings.PRETIX_MAX_ORDER_SIZE:
|
||||||
raise DataImportError(
|
raise DataImportError(
|
||||||
_('Orders cannot have more than %(max)s positions.') % {'max': django_settings.PRETIX_MAX_ORDER_SIZE}
|
_('Orders cannot have more than %(max)s positions.') % {'max': django_settings.PRETIX_MAX_ORDER_SIZE}
|
||||||
)
|
)
|
||||||
|
|
||||||
orders = []
|
|
||||||
order = None
|
|
||||||
|
|
||||||
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
|
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
|
||||||
# shorter. We'll see what works better in reality…
|
# shorter. We'll see what works better in reality…
|
||||||
lock_seats = []
|
lock_seats = []
|
||||||
@@ -111,16 +149,16 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
|||||||
position = OrderPosition(positionid=len(order._positions) + 1)
|
position = OrderPosition(positionid=len(order._positions) + 1)
|
||||||
position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
|
position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
|
||||||
position.meta_info = {}
|
position.meta_info = {}
|
||||||
|
if position.seat is not None:
|
||||||
|
lock_seats.append(position.seat)
|
||||||
order._positions.append(position)
|
order._positions.append(position)
|
||||||
position.assign_pseudonymization_id()
|
position.assign_pseudonymization_id()
|
||||||
|
|
||||||
for c in cols:
|
for c in cols:
|
||||||
c.assign(record.get(c.identifier), order, position, order._address)
|
c.assign(record.get(c.identifier), order, position, order._address)
|
||||||
|
|
||||||
if position.seat is not None:
|
except ImportError as e:
|
||||||
lock_seats.append(position.seat)
|
raise ImportError(
|
||||||
except (ValidationError, ImportError) as e:
|
|
||||||
raise DataImportError(
|
|
||||||
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
|
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,7 +169,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
|||||||
lock_objects(lock_seats, shared_lock_objects=[event])
|
lock_objects(lock_seats, shared_lock_objects=[event])
|
||||||
for s in lock_seats:
|
for s in lock_seats:
|
||||||
if not s.is_available():
|
if not s.is_available():
|
||||||
raise DataImportError(_('The seat you selected has already been taken. Please select a different seat.'))
|
raise ImportError(_('The seat you selected has already been taken. Please select a different seat.'))
|
||||||
|
|
||||||
save_transactions = []
|
save_transactions = []
|
||||||
for o in orders:
|
for o in orders:
|
||||||
@@ -194,64 +232,3 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
|||||||
raise ValidationError(_('We were not able to process your request completely as the server was too busy. '
|
raise ValidationError(_('We were not able to process your request completely as the server was too busy. '
|
||||||
'Please try again.'))
|
'Please try again.'))
|
||||||
cf.delete()
|
cf.delete()
|
||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
|
|
||||||
def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user, charset=None) -> None:
|
|
||||||
cf = CachedFile.objects.get(id=fileid)
|
|
||||||
user = User.objects.get(pk=user)
|
|
||||||
with language(locale, event.settings.region):
|
|
||||||
cols = get_voucher_import_columns(event)
|
|
||||||
data = _validate(cf, charset, cols, settings)
|
|
||||||
|
|
||||||
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
|
|
||||||
# shorter. We'll see what works better in reality…
|
|
||||||
vouchers = []
|
|
||||||
lock_seats = []
|
|
||||||
for i, record in enumerate(data):
|
|
||||||
try:
|
|
||||||
voucher = Voucher(event=event)
|
|
||||||
vouchers.append(voucher)
|
|
||||||
|
|
||||||
if not record.get("code"):
|
|
||||||
raise ValidationError(_('A voucher cannot be created without a code.'))
|
|
||||||
Voucher.clean_item_properties(
|
|
||||||
record,
|
|
||||||
event,
|
|
||||||
record.get('quota'),
|
|
||||||
record.get('item'),
|
|
||||||
record.get('variation'),
|
|
||||||
block_quota=record.get('block_quota')
|
|
||||||
)
|
|
||||||
Voucher.clean_subevent(record, event)
|
|
||||||
Voucher.clean_max_usages(record, 0)
|
|
||||||
|
|
||||||
for c in cols:
|
|
||||||
c.assign(record.get(c.identifier), voucher)
|
|
||||||
|
|
||||||
if voucher.seat is not None:
|
|
||||||
lock_seats.append(voucher.seat)
|
|
||||||
except (ValidationError, ImportError) as e:
|
|
||||||
raise DataImportError(
|
|
||||||
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
|
|
||||||
)
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
# We don't support quotas here, so we only need to lock if seats are in use
|
|
||||||
if lock_seats:
|
|
||||||
lock_objects(lock_seats, shared_lock_objects=[event])
|
|
||||||
for s in lock_seats:
|
|
||||||
if not s.is_available():
|
|
||||||
raise DataImportError(
|
|
||||||
_('The seat you selected has already been taken. Please select a different seat.'))
|
|
||||||
|
|
||||||
for v in vouchers:
|
|
||||||
v.save()
|
|
||||||
v.log_action(
|
|
||||||
'pretix.voucher.added',
|
|
||||||
user=user,
|
|
||||||
data={'source': 'import'}
|
|
||||||
)
|
|
||||||
for c in cols:
|
|
||||||
c.save(v)
|
|
||||||
cf.delete()
|
|
||||||
@@ -99,10 +99,9 @@ from pretix.base.services.quotas import QuotaAvailability
|
|||||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
||||||
from pretix.base.signals import (
|
from pretix.base.signals import (
|
||||||
order_approved, order_canceled, order_changed, order_denied, order_expired,
|
order_approved, order_canceled, order_changed, order_denied, order_expired,
|
||||||
order_fee_calculation, order_paid, order_placed, order_reactivated,
|
order_fee_calculation, order_paid, order_placed, order_split,
|
||||||
order_split, order_valid_if_pending, periodic_task, validate_order,
|
order_valid_if_pending, periodic_task, validate_order,
|
||||||
)
|
)
|
||||||
from pretix.base.timemachine import time_machine_now, time_machine_now_assigned
|
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.helpers import OF_SELF
|
from pretix.helpers import OF_SELF
|
||||||
from pretix.helpers.models import modelcopy
|
from pretix.helpers.models import modelcopy
|
||||||
@@ -198,9 +197,8 @@ error_messages = {
|
|||||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||||
'min'
|
'min'
|
||||||
),
|
),
|
||||||
'addon_no_multi': gettext_lazy('You can select every add-on from the category %(cat)s for the product %(base)s at most once.'),
|
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||||
'addon_already_checked_in': gettext_lazy('You cannot remove the position %(addon)s since it has already been checked in.'),
|
'addon_already_checked_in': gettext_lazy('You cannot remove the position %(addon)s since it has already been checked in.'),
|
||||||
'currency_XXX': gettext_lazy('Paid products not supported without a valid currency.'),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -222,7 +220,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
|
|||||||
is_available = order._is_still_available(now(), count_waitinglist=False, check_voucher_usage=True,
|
is_available = order._is_still_available(now(), count_waitinglist=False, check_voucher_usage=True,
|
||||||
check_memberships=True, lock=True, force=force)
|
check_memberships=True, lock=True, force=force)
|
||||||
if is_available is True:
|
if is_available is True:
|
||||||
if order.payment_refund_sum >= order.total and not order.require_approval:
|
if order.payment_refund_sum >= order.total:
|
||||||
order.status = Order.STATUS_PAID
|
order.status = Order.STATUS_PAID
|
||||||
else:
|
else:
|
||||||
order.status = Order.STATUS_PENDING
|
order.status = Order.STATUS_PENDING
|
||||||
@@ -254,7 +252,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
|
|||||||
else:
|
else:
|
||||||
raise OrderError(is_available)
|
raise OrderError(is_available)
|
||||||
|
|
||||||
order_reactivated.send(order.event, order=order)
|
order_approved.send(order.event, order=order)
|
||||||
if order.status == Order.STATUS_PAID:
|
if order.status == Order.STATUS_PAID:
|
||||||
order_paid.send(order.event, order=order)
|
order_paid.send(order.event, order=order)
|
||||||
|
|
||||||
@@ -299,7 +297,6 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, valid_if_p
|
|||||||
auth=auth,
|
auth=auth,
|
||||||
data={
|
data={
|
||||||
'expires': order.expires,
|
'expires': order.expires,
|
||||||
'force': force,
|
|
||||||
'state_change': was_expired
|
'state_change': was_expired
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -415,11 +412,6 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
|||||||
email_subject, email_template, email_context,
|
email_subject, email_template, email_context,
|
||||||
'pretix.event.order.email.order_approved', user,
|
'pretix.event.order.email.order_approved', user,
|
||||||
attach_tickets=True,
|
attach_tickets=True,
|
||||||
attach_ical=order.event.settings.mail_attach_ical and (
|
|
||||||
not order.event.settings.mail_attach_ical_paid_only or
|
|
||||||
order.total == Decimal('0.00') or
|
|
||||||
order.valid_if_pending
|
|
||||||
),
|
|
||||||
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
|
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
@@ -649,11 +641,10 @@ def _check_date(event: Event, now_dt: datetime):
|
|||||||
raise OrderError(error_messages['ended'])
|
raise OrderError(error_messages['ended'])
|
||||||
|
|
||||||
|
|
||||||
def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: datetime, positions: List[CartPosition],
|
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None,
|
||||||
address: InvoiceAddress = None,
|
|
||||||
sales_channel='web', customer=None):
|
sales_channel='web', customer=None):
|
||||||
err = None
|
err = None
|
||||||
_check_date(event, time_machine_now_dt)
|
_check_date(event, now_dt)
|
||||||
|
|
||||||
products_seen = Counter()
|
products_seen = Counter()
|
||||||
q_avail = Counter()
|
q_avail = Counter()
|
||||||
@@ -674,7 +665,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
deleted_positions.add(cp.pk)
|
deleted_positions.add(cp.pk)
|
||||||
cp.delete()
|
cp.delete()
|
||||||
|
|
||||||
sorted_positions = sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))
|
sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled))
|
||||||
|
|
||||||
for cp in sorted_positions:
|
for cp in sorted_positions:
|
||||||
cp._cached_quotas = list(cp.quotas)
|
cp._cached_quotas = list(cp.quotas)
|
||||||
@@ -731,7 +722,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
delete(cp)
|
delete(cp)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if cp.subevent and cp.subevent.presale_start and time_machine_now_dt < cp.subevent.presale_start:
|
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
|
||||||
err = err or error_messages['some_subevent_not_started']
|
err = err or error_messages['some_subevent_not_started']
|
||||||
delete(cp)
|
delete(cp)
|
||||||
break
|
break
|
||||||
@@ -743,7 +734,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
tlv.datetime(cp.subevent).date(),
|
tlv.datetime(cp.subevent).date(),
|
||||||
time(hour=23, minute=59, second=59)
|
time(hour=23, minute=59, second=59)
|
||||||
), event.timezone)
|
), event.timezone)
|
||||||
if term_last < time_machine_now_dt:
|
if term_last < now_dt:
|
||||||
err = err or error_messages['some_subevent_ended']
|
err = err or error_messages['some_subevent_ended']
|
||||||
delete(cp)
|
delete(cp)
|
||||||
break
|
break
|
||||||
@@ -789,19 +780,19 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
delete(cp)
|
delete(cp)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if cp.subevent and cp.item.pk in cp.subevent.item_overrides and not cp.subevent.item_overrides[cp.item.pk].is_available(time_machine_now_dt):
|
if cp.subevent and cp.item.pk in cp.subevent.item_overrides and not cp.subevent.item_overrides[cp.item.pk].is_available(now_dt):
|
||||||
err = err or error_messages['unavailable']
|
err = err or error_messages['unavailable']
|
||||||
delete(cp)
|
delete(cp)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and \
|
if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and \
|
||||||
not cp.subevent.var_overrides[cp.variation.pk].is_available(time_machine_now_dt):
|
not cp.subevent.var_overrides[cp.variation.pk].is_available(now_dt):
|
||||||
err = err or error_messages['unavailable']
|
err = err or error_messages['unavailable']
|
||||||
delete(cp)
|
delete(cp)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if cp.voucher:
|
if cp.voucher:
|
||||||
if cp.voucher.valid_until and cp.voucher.valid_until < time_machine_now_dt:
|
if cp.voucher.valid_until and cp.voucher.valid_until < now_dt:
|
||||||
err = err or error_messages['voucher_expired']
|
err = err or error_messages['voucher_expired']
|
||||||
delete(cp)
|
delete(cp)
|
||||||
continue
|
continue
|
||||||
@@ -885,13 +876,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
cp.discount = discount
|
cp.discount = discount
|
||||||
cp.save(update_fields=['price', 'discount'])
|
cp.save(update_fields=['price', 'discount'])
|
||||||
|
|
||||||
# After applying discounts, add-on positions might still have a reference to the *old* version of the
|
|
||||||
# parent position, which can screw up ordering later since the system sees inconsistent data.
|
|
||||||
by_id = {cp.pk: cp for cp in sorted_positions}
|
|
||||||
for cp in sorted_positions:
|
|
||||||
if cp.addon_to_id:
|
|
||||||
cp.addon_to = by_id[cp.addon_to_id]
|
|
||||||
|
|
||||||
new_total = sum(cp.price for cp in sorted_positions)
|
new_total = sum(cp.price for cp in sorted_positions)
|
||||||
if old_total != new_total:
|
if old_total != new_total:
|
||||||
err = err or error_messages['price_changed']
|
err = err or error_messages['price_changed']
|
||||||
@@ -900,7 +884,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
for cp in sorted_positions:
|
for cp in sorted_positions:
|
||||||
cp.expires = now_dt + timedelta(
|
cp.expires = now_dt + timedelta(
|
||||||
minutes=event.settings.get('reservation_time', as_type=int))
|
minutes=event.settings.get('reservation_time', as_type=int))
|
||||||
cp.save(update_fields=['expires'])
|
cp.save()
|
||||||
|
|
||||||
if err:
|
if err:
|
||||||
raise OrderError(err)
|
raise OrderError(err)
|
||||||
@@ -1061,11 +1045,7 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp
|
|||||||
log_entry,
|
log_entry,
|
||||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||||
attach_tickets=True,
|
attach_tickets=True,
|
||||||
attach_ical=event.settings.mail_attach_ical and (
|
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
|
||||||
not event.settings.mail_attach_ical_paid_only or
|
|
||||||
is_free or
|
|
||||||
order.valid_if_pending
|
|
||||||
),
|
|
||||||
attach_other_files=[a for a in [
|
attach_other_files=[a for a in [
|
||||||
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||||
] if a],
|
] if a],
|
||||||
@@ -1084,11 +1064,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
|||||||
log_entry,
|
log_entry,
|
||||||
invoices=[],
|
invoices=[],
|
||||||
attach_tickets=True,
|
attach_tickets=True,
|
||||||
attach_ical=event.settings.mail_attach_ical and (
|
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
|
||||||
not event.settings.mail_attach_ical_paid_only or
|
|
||||||
is_free or
|
|
||||||
order.valid_if_pending
|
|
||||||
),
|
|
||||||
attach_other_files=[a for a in [
|
attach_other_files=[a for a in [
|
||||||
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||||
] if a],
|
] if a],
|
||||||
@@ -1133,9 +1109,6 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
|
|||||||
id__in=position_ids, event=event
|
id__in=position_ids, event=event
|
||||||
)
|
)
|
||||||
|
|
||||||
if shown_total is not None and Decimal(shown_total) > Decimal("0.00") and event.currency == "XXX":
|
|
||||||
raise OrderError(error_messages['currency_XXX'])
|
|
||||||
|
|
||||||
validate_order.send(
|
validate_order.send(
|
||||||
event,
|
event,
|
||||||
payment_provider=payment_requests[0]['provider'] if payment_requests else None, # only for backwards compatibility
|
payment_provider=payment_requests[0]['provider'] if payment_requests else None, # only for backwards compatibility
|
||||||
@@ -1165,28 +1138,26 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
|
|||||||
warnings = []
|
warnings = []
|
||||||
any_payment_failed = False
|
any_payment_failed = False
|
||||||
|
|
||||||
real_now_dt = now()
|
now_dt = now()
|
||||||
time_machine_now_dt = time_machine_now(real_now_dt)
|
|
||||||
err_out = None
|
err_out = None
|
||||||
with transaction.atomic(durable=True):
|
with transaction.atomic(durable=True):
|
||||||
positions = list(
|
positions = list(
|
||||||
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
|
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
|
||||||
)
|
)
|
||||||
positions.sort(key=lambda c: c.sort_key)
|
positions.sort(key=lambda k: position_ids.index(k.pk))
|
||||||
if len(positions) == 0:
|
if len(positions) == 0:
|
||||||
raise OrderError(error_messages['empty'])
|
raise OrderError(error_messages['empty'])
|
||||||
if len(position_ids) != len(positions):
|
if len(position_ids) != len(positions):
|
||||||
raise OrderError(error_messages['internal'])
|
raise OrderError(error_messages['internal'])
|
||||||
try:
|
try:
|
||||||
_check_positions(event, real_now_dt, time_machine_now_dt, positions,
|
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel, customer=customer)
|
||||||
address=addr, sales_channel=sales_channel, customer=customer)
|
|
||||||
except OrderError as e:
|
except OrderError as e:
|
||||||
err_out = e # Don't raise directly to make sure transaction is committed, as it might have deleted things
|
err_out = e # Don't raise directly to make sure transaction is committed, as it might have deleted things
|
||||||
else:
|
else:
|
||||||
if 'sleep-after-quota-check' in debugflags_var.get():
|
if 'sleep-after-quota-check' in debugflags_var.get():
|
||||||
sleep(2)
|
sleep(2)
|
||||||
|
|
||||||
order, payment_objs = _create_order(event, email, positions, real_now_dt, payment_requests,
|
order, payment_objs = _create_order(event, email, positions, now_dt, payment_requests,
|
||||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
|
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
|
||||||
shown_total=shown_total, customer=customer, valid_if_pending=valid_if_pending)
|
shown_total=shown_total, customer=customer, valid_if_pending=valid_if_pending)
|
||||||
|
|
||||||
@@ -2017,20 +1988,6 @@ class OrderChangeManager:
|
|||||||
for a in current_addons[cp][k][:current_num - input_num]:
|
for a in current_addons[cp][k][:current_num - input_num]:
|
||||||
if a.canceled:
|
if a.canceled:
|
||||||
continue
|
continue
|
||||||
is_unavailable = (
|
|
||||||
# If an item is no longer available due to time, it should usually also be no longer
|
|
||||||
# user-removable, because e.g. the stock has already been ordered.
|
|
||||||
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
|
|
||||||
# not mean it should be unremovable for others.
|
|
||||||
# This also prevents accidental removal through the UI because a hidden product will no longer
|
|
||||||
# be part of the input.
|
|
||||||
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
|
|
||||||
or (a.variation and self.order.sales_channel not in a.variation.sales_channels)
|
|
||||||
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
|
|
||||||
or self.order.sales_channel not in item.sales_channels
|
|
||||||
)
|
|
||||||
if is_unavailable:
|
|
||||||
continue
|
|
||||||
if a.checkins.filter(list__consider_tickets_used=True).exists():
|
if a.checkins.filter(list__consider_tickets_used=True).exists():
|
||||||
raise OrderError(
|
raise OrderError(
|
||||||
error_messages['addon_already_checked_in'] % {
|
error_messages['addon_already_checked_in'] % {
|
||||||
@@ -2148,9 +2105,6 @@ class OrderChangeManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _check_paid_to_free(self):
|
def _check_paid_to_free(self):
|
||||||
if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"):
|
|
||||||
raise OrderError(error_messages['currency_XXX'])
|
|
||||||
|
|
||||||
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
|
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
|
||||||
if not self.order.fees.exists() and not self.order.positions.exists():
|
if not self.order.fees.exists() and not self.order.positions.exists():
|
||||||
# The order is completely empty now, so we cancel it.
|
# The order is completely empty now, so we cancel it.
|
||||||
@@ -2546,7 +2500,7 @@ class OrderChangeManager:
|
|||||||
|
|
||||||
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
|
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
|
||||||
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
|
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
|
||||||
if offset_amount >= split_order.total and not split_order.require_approval:
|
if offset_amount >= split_order.total:
|
||||||
split_order.status = Order.STATUS_PAID
|
split_order.status = Order.STATUS_PAID
|
||||||
else:
|
else:
|
||||||
split_order.status = Order.STATUS_PENDING
|
split_order.status = Order.STATUS_PENDING
|
||||||
@@ -2717,7 +2671,6 @@ class OrderChangeManager:
|
|||||||
|
|
||||||
for p in self.order.positions.all():
|
for p in self.order.positions.all():
|
||||||
cp = CartPosition(
|
cp = CartPosition(
|
||||||
event=self.event,
|
|
||||||
item=p.item,
|
item=p.item,
|
||||||
variation=p.variation,
|
variation=p.variation,
|
||||||
attendee_name_parts=p.attendee_name_parts,
|
attendee_name_parts=p.attendee_name_parts,
|
||||||
@@ -2738,23 +2691,16 @@ class OrderChangeManager:
|
|||||||
positions_to_fake_cart[op.position].seat = op.seat
|
positions_to_fake_cart[op.position].seat = op.seat
|
||||||
elif isinstance(op, self.MembershipOperation):
|
elif isinstance(op, self.MembershipOperation):
|
||||||
positions_to_fake_cart[op.position].used_membership = op.membership
|
positions_to_fake_cart[op.position].used_membership = op.membership
|
||||||
elif isinstance(op, self.ChangeValidFromOperation):
|
|
||||||
positions_to_fake_cart[op.position].override_valid_from = op.valid_from
|
|
||||||
elif isinstance(op, self.ChangeValidUntilOperation):
|
|
||||||
positions_to_fake_cart[op.position].override_valid_until = op.valid_until
|
|
||||||
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
|
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
|
||||||
fake_cart.remove(positions_to_fake_cart[op.position])
|
fake_cart.remove(positions_to_fake_cart[op.position])
|
||||||
elif isinstance(op, self.AddOperation):
|
elif isinstance(op, self.AddOperation):
|
||||||
cp = CartPosition(
|
cp = CartPosition(
|
||||||
event=self.event,
|
|
||||||
item=op.item,
|
item=op.item,
|
||||||
variation=op.variation,
|
variation=op.variation,
|
||||||
used_membership=op.membership,
|
used_membership=op.membership,
|
||||||
subevent=op.subevent,
|
subevent=op.subevent,
|
||||||
seat=op.seat,
|
seat=op.seat,
|
||||||
)
|
)
|
||||||
cp.override_valid_from = op.valid_from
|
|
||||||
cp.override_valid_until = op.valid_until
|
|
||||||
fake_cart.append(cp)
|
fake_cart.append(cp)
|
||||||
try:
|
try:
|
||||||
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
|
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
|
||||||
@@ -2853,8 +2799,8 @@ class OrderChangeManager:
|
|||||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||||
def perform_order(self, event: Event, payments: List[dict], positions: List[str],
|
def perform_order(self, event: Event, payments: List[dict], positions: List[str],
|
||||||
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
|
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
|
||||||
sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None):
|
sales_channel: str='web', shown_total=None, customer=None):
|
||||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
with language(locale):
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
return _perform_order(event, payments, positions, email, locale, address, meta_info,
|
return _perform_order(event, payments, positions, email, locale, address, meta_info,
|
||||||
|
|||||||
@@ -337,40 +337,6 @@ def base_placeholders(sender, **kwargs):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
|
||||||
'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri(
|
|
||||||
event,
|
|
||||||
'presale:event.order.position.modify', kwargs={
|
|
||||||
'order': position.order.code,
|
|
||||||
'secret': position.web_secret,
|
|
||||||
'position': position.positionid
|
|
||||||
}
|
|
||||||
), lambda event: build_absolute_uri(
|
|
||||||
event,
|
|
||||||
'presale:event.order.position.modify', kwargs={
|
|
||||||
'order': 'F8VVL',
|
|
||||||
'secret': '6zzjnumtsx136ddy',
|
|
||||||
'position': '123',
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SimpleFunctionalTextPlaceholder(
|
|
||||||
'url_products_change', ['position', 'event'], lambda position, event: build_absolute_uri(
|
|
||||||
event,
|
|
||||||
'presale:event.order.position.change', kwargs={
|
|
||||||
'order': position.order.code,
|
|
||||||
'secret': position.web_secret,
|
|
||||||
'position': position.positionid
|
|
||||||
}
|
|
||||||
), lambda event: build_absolute_uri(
|
|
||||||
event,
|
|
||||||
'presale:event.order.position.change', kwargs={
|
|
||||||
'order': 'F8VVL',
|
|
||||||
'secret': '6zzjnumtsx136ddy',
|
|
||||||
'position': '123'
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'order_modification_deadline_date_and_time', ['order', 'event'],
|
'order_modification_deadline_date_and_time', ['order', 'event'],
|
||||||
lambda order, event:
|
lambda order, event:
|
||||||
@@ -548,7 +514,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
ph.append(SimpleFunctionalTextPlaceholder(
|
ph.append(SimpleFunctionalTextPlaceholder(
|
||||||
"name_for_salutation", ["waiting_list_entry"],
|
"name_for_salutation", ["waiting_list_entry"],
|
||||||
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
|
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
|
||||||
lambda event: concatenation_for_salutation(name_scheme['sample']),
|
_("Mr Doe"),
|
||||||
))
|
))
|
||||||
ph.append(SimpleFunctionalTextPlaceholder(
|
ph.append(SimpleFunctionalTextPlaceholder(
|
||||||
"name", ["waiting_list_entry"],
|
"name", ["waiting_list_entry"],
|
||||||
@@ -558,7 +524,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
ph.append(SimpleFunctionalTextPlaceholder(
|
ph.append(SimpleFunctionalTextPlaceholder(
|
||||||
"name_for_salutation", ["position_or_address"],
|
"name_for_salutation", ["position_or_address"],
|
||||||
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
|
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
|
||||||
lambda event: concatenation_for_salutation(name_scheme['sample']),
|
_("Mr Doe"),
|
||||||
))
|
))
|
||||||
|
|
||||||
for f, l, w in name_scheme['fields']:
|
for f, l, w in name_scheme['fields']:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from typing import List, Optional, Tuple
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils.timezone import now
|
||||||
|
|
||||||
from pretix.base.decimal import round_decimal
|
from pretix.base.decimal import round_decimal
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
@@ -32,7 +33,6 @@ from pretix.base.models import (
|
|||||||
)
|
)
|
||||||
from pretix.base.models.event import Event, SubEvent
|
from pretix.base.models.event import Event, SubEvent
|
||||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||||
from pretix.base.timemachine import time_machine_now
|
|
||||||
|
|
||||||
|
|
||||||
def get_price(item: Item, variation: ItemVariation = None,
|
def get_price(item: Item, variation: ItemVariation = None,
|
||||||
@@ -167,8 +167,8 @@ def apply_discounts(event: Event, sales_channel: str,
|
|||||||
new_prices = {}
|
new_prices = {}
|
||||||
|
|
||||||
discount_qs = event.discounts.filter(
|
discount_qs = event.discounts.filter(
|
||||||
Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
|
Q(available_from__isnull=True) | Q(available_from__lte=now()),
|
||||||
Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()),
|
Q(available_until__isnull=True) | Q(available_until__gte=now()),
|
||||||
sales_channels__contains=sales_channel,
|
sales_channels__contains=sales_channel,
|
||||||
active=True,
|
active=True,
|
||||||
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When,
|
Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When,
|
||||||
prefetch_related_objects,
|
|
||||||
)
|
)
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
|
||||||
@@ -91,8 +90,8 @@ class QuotaAvailability:
|
|||||||
self._count_waitinglist = count_waitinglist
|
self._count_waitinglist = count_waitinglist
|
||||||
self._ignore_closed = ignore_closed
|
self._ignore_closed = ignore_closed
|
||||||
self._full_results = full_results
|
self._full_results = full_results
|
||||||
self._item_to_quotas = defaultdict(set)
|
self._item_to_quotas = defaultdict(list)
|
||||||
self._var_to_quotas = defaultdict(set)
|
self._var_to_quotas = defaultdict(list)
|
||||||
self._early_out = early_out
|
self._early_out = early_out
|
||||||
self._quota_objects = {}
|
self._quota_objects = {}
|
||||||
self.results = {}
|
self.results = {}
|
||||||
@@ -244,16 +243,13 @@ class QuotaAvailability:
|
|||||||
quota_id__in=[q.pk for q in quotas]
|
quota_id__in=[q.pk for q in quotas]
|
||||||
).values('quota_id', 'item_id')
|
).values('quota_id', 'item_id')
|
||||||
for m in q_items:
|
for m in q_items:
|
||||||
self._item_to_quotas[m['item_id']].add(self._quota_objects[m['quota_id']])
|
self._item_to_quotas[m['item_id']].append(self._quota_objects[m['quota_id']])
|
||||||
|
|
||||||
q_vars = Quota.variations.through.objects.filter(
|
q_vars = Quota.variations.through.objects.filter(
|
||||||
quota_id__in=[q.pk for q in quotas]
|
quota_id__in=[q.pk for q in quotas]
|
||||||
).values('quota_id', 'itemvariation_id', 'itemvariation__item_id')
|
).values('quota_id', 'itemvariation_id')
|
||||||
for m in q_vars:
|
for m in q_vars:
|
||||||
self._var_to_quotas[m['itemvariation_id']].add(self._quota_objects[m['quota_id']])
|
self._var_to_quotas[m['itemvariation_id']].append(self._quota_objects[m['quota_id']])
|
||||||
# We can't be 100% certain that a quota, when it is connected to a variation, is also always connected to
|
|
||||||
# the parent item, so we double-check here just to be sure.
|
|
||||||
self._item_to_quotas[m['itemvariation__item_id']].add(self._quota_objects[m['quota_id']])
|
|
||||||
|
|
||||||
self._compute_orders(quotas, q_items, q_vars, size_left)
|
self._compute_orders(quotas, q_items, q_vars, size_left)
|
||||||
|
|
||||||
@@ -382,10 +378,7 @@ class QuotaAvailability:
|
|||||||
Q(
|
Q(
|
||||||
Q(
|
Q(
|
||||||
Q(variation_id__isnull=True) &
|
Q(variation_id__isnull=True) &
|
||||||
Q(item_id__in=(
|
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||||
{i['item_id'] for i in q_items if i['quota_id'] in quota_ids} |
|
|
||||||
{i['itemvariation__item_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
|
||||||
))
|
|
||||||
) | Q(
|
) | Q(
|
||||||
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
||||||
) | Q(
|
) | Q(
|
||||||
@@ -447,12 +440,6 @@ class QuotaAvailability:
|
|||||||
self.results[q] = Quota.AVAILABILITY_RESERVED, 0
|
self.results[q] = Quota.AVAILABILITY_RESERVED, 0
|
||||||
|
|
||||||
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
|
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
|
||||||
prefetch_related_objects(quotas, "event", "event__organizer")
|
|
||||||
quotas = [
|
|
||||||
q for q in quotas
|
|
||||||
if not q.event.settings.waiting_list_auto_disable or q.event.settings.waiting_list_auto_disable.datetime(q.subevent or q.event) > now()
|
|
||||||
]
|
|
||||||
|
|
||||||
events = {q.event_id for q in quotas}
|
events = {q.event_id for q in quotas}
|
||||||
subevents = {q.subevent_id for q in quotas}
|
subevents = {q.subevent_id for q in quotas}
|
||||||
quota_ids = {q.pk for q in quotas}
|
quota_ids = {q.pk for q in quotas}
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ class VATIDTemporaryError(VATIDError):
|
|||||||
|
|
||||||
def _validate_vat_id_NO(vat_id, country_code):
|
def _validate_vat_id_NO(vat_id, country_code):
|
||||||
# Inspired by vat_moss library
|
# Inspired by vat_moss library
|
||||||
try:
|
vat_id = vat_moss.id.normalize(vat_id)
|
||||||
vat_id = vat_moss.id.normalize(vat_id)
|
|
||||||
except ValueError:
|
|
||||||
raise VATIDFinalError(error_messages['invalid'])
|
|
||||||
|
|
||||||
if not vat_id or len(vat_id) < 3 or not re.match('^\\d{9}MVA$', vat_id[2:]):
|
if not vat_id or len(vat_id) < 3 or not re.match('^\\d{9}MVA$', vat_id[2:]):
|
||||||
raise VATIDFinalError(error_messages['invalid'])
|
raise VATIDFinalError(error_messages['invalid'])
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import sys
|
import sys
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@@ -50,28 +49,19 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
|||||||
|
|
||||||
quota_cache = {}
|
quota_cache = {}
|
||||||
gone = set()
|
gone = set()
|
||||||
_seats_available_cache = {}
|
seats_available = {}
|
||||||
seats_used = defaultdict(int)
|
|
||||||
|
|
||||||
seated_product_set = set(
|
for m in SeatCategoryMapping.objects.filter(event=event).select_related('subevent'):
|
||||||
SeatCategoryMapping.objects.filter(event=event).values_list('product_id', 'subevent_id')
|
|
||||||
)
|
|
||||||
|
|
||||||
def _seats_available(item, subevent):
|
|
||||||
# See comment in WaitingListEntry.send_voucher() for rationale
|
# See comment in WaitingListEntry.send_voucher() for rationale
|
||||||
subevent_id = subevent.pk if subevent else None
|
num_free_seets_for_product = (m.subevent or event).free_seats().filter(product_id=m.product_id).count()
|
||||||
if (item.pk, subevent_id) not in _seats_available_cache:
|
num_valid_vouchers_for_product = event.vouchers.filter(
|
||||||
num_free_seats_for_product = (subevent or event).free_seats().filter(product_id=item.pk).count()
|
Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
|
||||||
num_valid_vouchers_for_product = event.vouchers.filter(
|
block_quota=True,
|
||||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
|
item_id=m.product_id,
|
||||||
block_quota=True,
|
subevent_id=m.subevent_id,
|
||||||
item_id=item.pk,
|
waitinglistentries__isnull=False
|
||||||
subevent_id=subevent_id,
|
).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0
|
||||||
waitinglistentries__isnull=False
|
seats_available[(m.product_id, m.subevent_id)] = num_free_seets_for_product - num_valid_vouchers_for_product
|
||||||
).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0
|
|
||||||
_seats_available_cache[item.pk, subevent_id] = num_free_seats_for_product - num_valid_vouchers_for_product
|
|
||||||
|
|
||||||
return _seats_available_cache[item.pk, subevent_id] - seats_used[item.pk, subevent_id]
|
|
||||||
|
|
||||||
prefetch_related_objects(
|
prefetch_related_objects(
|
||||||
[event.organizer],
|
[event.organizer],
|
||||||
@@ -113,23 +103,20 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
|||||||
|
|
||||||
lock_objects(quotas, shared_lock_objects=[event])
|
lock_objects(quotas, shared_lock_objects=[event])
|
||||||
for wle in qs:
|
for wle in qs:
|
||||||
if (wle.item_id, wle.variation_id, wle.subevent_id) in gone:
|
if (wle.item, wle.variation, wle.subevent) in gone:
|
||||||
continue
|
continue
|
||||||
ev = (wle.subevent or event)
|
ev = (wle.subevent or event)
|
||||||
if not ev.presale_is_running or (wle.subevent and not wle.subevent.active):
|
if not ev.presale_is_running or (wle.subevent and not wle.subevent.active):
|
||||||
continue
|
continue
|
||||||
if wle.subevent and not wle.subevent.presale_is_running:
|
if wle.subevent and not wle.subevent.presale_is_running:
|
||||||
continue
|
continue
|
||||||
if event.settings.waiting_list_auto_disable and event.settings.waiting_list_auto_disable.datetime(wle.subevent or event) <= now():
|
|
||||||
gone.add((wle.item_id, wle.variation_id, wle.subevent_id))
|
|
||||||
continue
|
|
||||||
if not wle.item.is_available():
|
if not wle.item.is_available():
|
||||||
gone.add((wle.item_id, wle.variation_id, wle.subevent_id))
|
gone.add((wle.item, wle.variation, wle.subevent))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if (wle.item_id, wle.subevent_id) in seated_product_set:
|
if (wle.item_id, wle.subevent_id) in seats_available:
|
||||||
if _seats_available(wle.item, wle.subevent) < 1:
|
if seats_available[wle.item_id, wle.subevent_id] < 1:
|
||||||
gone.add((wle.item_id, wle.variation_id, wle.subevent_id))
|
gone.add((wle.item, wle.variation, wle.subevent))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
availability = (
|
availability = (
|
||||||
@@ -151,10 +138,10 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
|||||||
quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize
|
quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize
|
||||||
)
|
)
|
||||||
|
|
||||||
if (wle.item_id, wle.subevent_id) in seated_product_set:
|
if (wle.item_id, wle.subevent_id) in seats_available:
|
||||||
seats_used[wle.item_id, wle.subevent_id] += 1
|
seats_available[wle.item_id, wle.subevent_id] -= 1
|
||||||
else:
|
else:
|
||||||
gone.add((wle.item_id, wle.variation_id, wle.subevent_id))
|
gone.add((wle.item, wle.variation, wle.subevent))
|
||||||
|
|
||||||
return sent
|
return sent
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ from pretix.api.serializers.fields import (
|
|||||||
ListMultipleChoiceField, UploadedFileField,
|
ListMultipleChoiceField, UploadedFileField,
|
||||||
)
|
)
|
||||||
from pretix.api.serializers.i18n import I18nField, I18nURLField
|
from pretix.api.serializers.i18n import I18nField, I18nURLField
|
||||||
from pretix.base.forms import I18nMarkdownTextarea, I18nURLFormField
|
from pretix.base.forms import I18nURLFormField
|
||||||
from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule
|
from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule
|
||||||
from pretix.base.reldate import (
|
from pretix.base.reldate import (
|
||||||
RelativeDateField, RelativeDateTimeField, RelativeDateWrapper,
|
RelativeDateField, RelativeDateTimeField, RelativeDateWrapper,
|
||||||
@@ -89,7 +89,7 @@ def primary_font_kwargs():
|
|||||||
|
|
||||||
choices = [('Open Sans', 'Open Sans')]
|
choices = [('Open Sans', 'Open Sans')]
|
||||||
choices += sorted([
|
choices += sorted([
|
||||||
(a, {"title": a, "data": v}) for a, v in get_fonts(pdf_support_required=False).items()
|
(a, {"title": a, "data": v}) for a, v in get_fonts().items() if not v.get('pdf_only', False)
|
||||||
], key=lambda a: a[0])
|
], key=lambda a: a[0])
|
||||||
return {
|
return {
|
||||||
'choices': choices,
|
'choices': choices,
|
||||||
@@ -602,7 +602,7 @@ DEFAULTS = {
|
|||||||
'serializer_class': I18nField,
|
'serializer_class': I18nField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Invoice address explanation"),
|
label=_("Invoice address explanation"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
help_text=_("This text will be shown above the invoice address form during checkout.")
|
help_text=_("This text will be shown above the invoice address form during checkout.")
|
||||||
)
|
)
|
||||||
@@ -801,7 +801,7 @@ DEFAULTS = {
|
|||||||
'serializer_class': I18nField,
|
'serializer_class': I18nField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("End of presale text"),
|
label=_("End of presale text"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
help_text=_("This text will be shown above the ticket shop once the designated sales timeframe for this event "
|
help_text=_("This text will be shown above the ticket shop once the designated sales timeframe for this event "
|
||||||
"is over. You can use it to describe other options to get a ticket, such as a box office.")
|
"is over. You can use it to describe other options to get a ticket, such as a box office.")
|
||||||
@@ -813,7 +813,7 @@ DEFAULTS = {
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'serializer_class': I18nField,
|
'serializer_class': I18nField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {
|
widget_kwargs={'attrs': {
|
||||||
'rows': 3,
|
'rows': 3,
|
||||||
}},
|
}},
|
||||||
@@ -971,8 +971,7 @@ DEFAULTS = {
|
|||||||
},
|
},
|
||||||
'payment_giftcard__enabled': {
|
'payment_giftcard__enabled': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool,
|
'type': bool
|
||||||
'serializer_class': serializers.BooleanField,
|
|
||||||
},
|
},
|
||||||
'payment_giftcard_public_name': {
|
'payment_giftcard_public_name': {
|
||||||
'default': LazyI18nString.from_gettext(gettext_noop('Gift card')),
|
'default': LazyI18nString.from_gettext(gettext_noop('Gift card')),
|
||||||
@@ -1398,19 +1397,6 @@ DEFAULTS = {
|
|||||||
widget=forms.NumberInput(),
|
widget=forms.NumberInput(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'waiting_list_auto_disable': {
|
|
||||||
'default': None,
|
|
||||||
'type': RelativeDateWrapper,
|
|
||||||
'form_class': RelativeDateTimeField,
|
|
||||||
'serializer_class': SerializerRelativeDateTimeField,
|
|
||||||
'form_kwargs': dict(
|
|
||||||
label=_("Disable waiting list"),
|
|
||||||
help_text=_("The waiting list will be fully disabled after this date. This means that nobody can add "
|
|
||||||
"themselves to the waiting list any more, but also that tickets will be available for sale "
|
|
||||||
"again if quota permits, even if there are still people on the waiting list. Vouchers that "
|
|
||||||
"have already been sent remain active."),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
'waiting_list_names_asked': {
|
'waiting_list_names_asked': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -1460,7 +1446,7 @@ DEFAULTS = {
|
|||||||
'serializer_class': I18nField,
|
'serializer_class': I18nField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Phone number explanation"),
|
label=_("Phone number explanation"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
help_text=_("If you ask for a phone number, explain why you do so and what you will use the phone number for.")
|
help_text=_("If you ask for a phone number, explain why you do so and what you will use the phone number for.")
|
||||||
)
|
)
|
||||||
@@ -1654,28 +1640,6 @@ DEFAULTS = {
|
|||||||
"calendar.")
|
"calendar.")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'allow_modifications': {
|
|
||||||
'default': 'order',
|
|
||||||
'type': str,
|
|
||||||
'form_class': forms.ChoiceField,
|
|
||||||
'serializer_class': serializers.ChoiceField,
|
|
||||||
'serializer_kwargs': dict(
|
|
||||||
choices=(
|
|
||||||
('no', _('No modifications after order was submitted')),
|
|
||||||
('order', _('Only the person who ordered can make changes')),
|
|
||||||
('attendee', _('Both the attendee and the person who ordered can make changes')),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
'form_kwargs': dict(
|
|
||||||
label=_("Allow customers to modify their information"),
|
|
||||||
widget=forms.RadioSelect,
|
|
||||||
choices=(
|
|
||||||
('no', _('No modifications after order was submitted')),
|
|
||||||
('order', _('Only the person who ordered can make changes')),
|
|
||||||
('attendee', _('Both the attendee and the person who ordered can make changes')),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
'allow_modifications_after_checkin': {
|
'allow_modifications_after_checkin': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -1683,8 +1647,6 @@ DEFAULTS = {
|
|||||||
'serializer_class': serializers.BooleanField,
|
'serializer_class': serializers.BooleanField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Allow customers to modify their information after they checked in."),
|
label=_("Allow customers to modify their information after they checked in."),
|
||||||
help_text=_("By default, no more modifications are possible for an order as soon as one of the tickets "
|
|
||||||
"in the order has been checked in.")
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'last_order_modification_date': {
|
'last_order_modification_date': {
|
||||||
@@ -1901,7 +1863,7 @@ DEFAULTS = {
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Voluntary lower refund explanation"),
|
label=_("Voluntary lower refund explanation"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
help_text=_("This text will be shown in between the explanation of how the refunds work and the slider "
|
help_text=_("This text will be shown in between the explanation of how the refunds work and the slider "
|
||||||
"which your customers can use to choose the amount they would like to receive. You can use it "
|
"which your customers can use to choose the amount they would like to receive. You can use it "
|
||||||
@@ -1983,7 +1945,7 @@ DEFAULTS = {
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Terms of cancellation"),
|
label=_("Terms of cancellation"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
help_text=_("This text will be shown when cancellation is allowed for a paid order. Leave empty if you "
|
help_text=_("This text will be shown when cancellation is allowed for a paid order. Leave empty if you "
|
||||||
"want pretix to automatically generate the terms of cancellation based on your settings.")
|
"want pretix to automatically generate the terms of cancellation based on your settings.")
|
||||||
@@ -1996,7 +1958,7 @@ DEFAULTS = {
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Terms of cancellation"),
|
label=_("Terms of cancellation"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
help_text=_("This text will be shown when cancellation is allowed for an unpaid or free order. Leave empty "
|
help_text=_("This text will be shown when cancellation is allowed for an unpaid or free order. Leave empty "
|
||||||
"if you want pretix to automatically generate the terms of cancellation based on your settings.")
|
"if you want pretix to automatically generate the terms of cancellation based on your settings.")
|
||||||
@@ -2085,7 +2047,7 @@ DEFAULTS = {
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Event description"),
|
label=_("Event description"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"You can use this to share information with your attendees, such as travel information or the link to a digital event. "
|
"You can use this to share information with your attendees, such as travel information or the link to a digital event. "
|
||||||
"If you keep it empty, we will put a link to the event shop, the admission time, and your organizer name in there. "
|
"If you keep it empty, we will put a link to the event shop, the admission time, and your organizer name in there. "
|
||||||
@@ -3016,7 +2978,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Frontpage text"),
|
label=_("Frontpage text"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'event_info_text': {
|
'event_info_text': {
|
||||||
@@ -3038,7 +3000,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Banner text (top)"),
|
label=_("Banner text (top)"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
help_text=_("This text will be shown above every page of your shop. Please only use this for "
|
help_text=_("This text will be shown above every page of your shop. Please only use this for "
|
||||||
"very important messages.")
|
"very important messages.")
|
||||||
@@ -3051,7 +3013,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Banner text (bottom)"),
|
label=_("Banner text (bottom)"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
help_text=_("This text will be shown below every page of your shop. Please only use this for "
|
help_text=_("This text will be shown below every page of your shop. Please only use this for "
|
||||||
"very important messages.")
|
"very important messages.")
|
||||||
@@ -3064,7 +3026,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Voucher explanation"),
|
label=_("Voucher explanation"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
help_text=_("This text will be shown next to the input for a voucher code. You can use it e.g. to explain "
|
help_text=_("This text will be shown next to the input for a voucher code. You can use it e.g. to explain "
|
||||||
"how to obtain a voucher code.")
|
"how to obtain a voucher code.")
|
||||||
@@ -3077,7 +3039,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Attendee data explanation"),
|
label=_("Attendee data explanation"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
help_text=_("This text will be shown above the questions asked for every personalized product. You can use it e.g. to explain "
|
help_text=_("This text will be shown above the questions asked for every personalized product. You can use it e.g. to explain "
|
||||||
"why you need information from them.")
|
"why you need information from them.")
|
||||||
@@ -3093,7 +3055,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
help_text=_("This message will be shown after an order has been created successfully. It will be shown in additional "
|
help_text=_("This message will be shown after an order has been created successfully. It will be shown in additional "
|
||||||
"to the default text."),
|
"to the default text."),
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'checkout_phone_helptext': {
|
'checkout_phone_helptext': {
|
||||||
@@ -3104,7 +3066,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Help text of the phone number field"),
|
label=_("Help text of the phone number field"),
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'checkout_email_helptext': {
|
'checkout_email_helptext': {
|
||||||
@@ -3118,7 +3080,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Help text of the email field"),
|
label=_("Help text of the email field"),
|
||||||
widget_kwargs={'attrs': {'rows': '2'}},
|
widget_kwargs={'attrs': {'rows': '2'}},
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'order_import_settings': {
|
'order_import_settings': {
|
||||||
@@ -3248,7 +3210,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_('Homepage text'),
|
label=_('Homepage text'),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
help_text=_('This will be displayed on the organizer homepage.')
|
help_text=_('This will be displayed on the organizer homepage.')
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -3305,7 +3267,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Dialog text"),
|
label=_("Dialog text"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
|
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -3320,7 +3282,7 @@ Your {organizer} team""")) # noqa: W291
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Secondary dialog text"),
|
label=_("Secondary dialog text"),
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
|
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -3444,7 +3406,7 @@ def concatenation_for_salutation(d):
|
|||||||
salutation = pgettext("person_name_salutation", salutation)
|
salutation = pgettext("person_name_salutation", salutation)
|
||||||
given_name = None
|
given_name = None
|
||||||
|
|
||||||
return " ".join(str(p) for p in filter(None, (salutation, title, given_name, family_name)))
|
return " ".join(filter(None, (salutation, title, given_name, family_name)))
|
||||||
|
|
||||||
|
|
||||||
def get_name_parts_localized(name_parts, key):
|
def get_name_parts_localized(name_parts, key):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user