forked from CGM_Public/pretix_original
Compare commits
1 Commits
fix-countr
...
scan-debug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fa715ac4b |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -10,9 +10,7 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
versioning-strategy: increase
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/src/pretix/static/npm_dir"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -26,12 +26,12 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.11"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
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
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
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
|
||||
name: Check gettext syntax
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -48,12 +48,12 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
name: Spellcheck
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
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
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -43,12 +43,12 @@ jobs:
|
||||
name: flake8
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -63,9 +63,9 @@ jobs:
|
||||
name: licenseheaders
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Install Dependencies
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
- database: sqlite
|
||||
python-version: "3.10"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
- uses: harmon758/postgresql-action@v1
|
||||
with:
|
||||
postgresql version: '15'
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
postgresql password: 'postgres'
|
||||
if: matrix.database == 'postgres'
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
|
||||
@@ -42,6 +42,7 @@ Example::
|
||||
currency=EUR
|
||||
datadir=/data
|
||||
plugins_default=pretix.plugins.sendmail,pretix.plugins.statistics
|
||||
cookie_domain=.pretix.de
|
||||
|
||||
``instance_name``
|
||||
The name of this installation. Default: ``pretix.de``
|
||||
@@ -52,18 +53,10 @@ Example::
|
||||
``currency``
|
||||
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``
|
||||
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``.
|
||||
|
||||
``logdir``
|
||||
The local path to a directory where log files will be stored.
|
||||
Defaults to the ``logs`` directory below the ``datadir``.
|
||||
|
||||
``plugins_default``
|
||||
A comma-separated list of plugins that are enabled by default for all new events.
|
||||
Defaults to ``pretix.plugins.sendmail,pretix.plugins.statistics``.
|
||||
@@ -78,6 +71,9 @@ Example::
|
||||
``auth_backends``
|
||||
A comma-separated list of available auth backends. Defaults to ``pretix.base.auth.NativeAuthBackend``.
|
||||
|
||||
``cookie_domain``
|
||||
The cookie domain to be set. Defaults to ``None``.
|
||||
|
||||
``registration``
|
||||
Enables or disables the registration of new admin users. Defaults to ``off``.
|
||||
|
||||
@@ -97,9 +93,8 @@ Example::
|
||||
Defaults to ``off``.
|
||||
|
||||
``obligatory_2fa``
|
||||
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
|
||||
obligatory to users with admin permissions. Defaults to ``False``.
|
||||
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
|
||||
Defaults to ``False``
|
||||
|
||||
``trust_x_forwarded_for``
|
||||
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
|
||||
@@ -158,7 +153,6 @@ Example::
|
||||
host=localhost
|
||||
port=3306
|
||||
advisory_lock_index=1
|
||||
disable_server_side_cursors=0
|
||||
sslmode=require
|
||||
sslrootcert=/etc/pretix/postgresql-ca.crt
|
||||
sslcert=/etc/pretix/postgresql-client-crt.crt
|
||||
@@ -179,11 +173,6 @@ Example::
|
||||
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).
|
||||
|
||||
``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``
|
||||
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 +349,7 @@ to speed up various operations::
|
||||
The location of redis, as a URL of the form ``redis://[:password]@localhost:6379/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.
|
||||
|
||||
``sentinels``
|
||||
@@ -536,4 +525,4 @@ pretix can optionally make use of a GeoIP database for some features. It needs a
|
||||
|
||||
|
||||
.. _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
|
||||
@@ -249,10 +249,7 @@ You can get three response codes:
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"event": {
|
||||
"name": "Demo Conference",
|
||||
"slug": "democon"
|
||||
},
|
||||
"event": "democon",
|
||||
"subevent": 23,
|
||||
"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".
|
||||
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
|
||||
either enter ``https://example.org/order/`` or ``https://example.org/``.
|
||||
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.
|
||||
either enter ``https://example.org`` or ``https://example.org/order/``.
|
||||
|
||||
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
|
||||
|
||||
@@ -19,7 +19,6 @@ external_identifier string External ID of
|
||||
the API, but is read-only for customers created through a
|
||||
SSO integration.
|
||||
email string Customer email address
|
||||
phone string Customer phone number
|
||||
name string Name of this customer (or ``null``)
|
||||
name_parts object of strings Decomposition of name (i.e. given name, family name)
|
||||
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.
|
||||
|
||||
.. versionchanged:: 2024.3
|
||||
|
||||
The attribute ``phone`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -76,7 +71,6 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "customer@example.org",
|
||||
"phone": "+493012345678",
|
||||
"name": "John Doe",
|
||||
"name_parts": {
|
||||
"_scheme": "full",
|
||||
@@ -124,7 +118,6 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "customer@example.org",
|
||||
"phone": "+493012345678",
|
||||
"name": "John Doe",
|
||||
"name_parts": {
|
||||
"_scheme": "full",
|
||||
@@ -162,7 +155,6 @@ Endpoints
|
||||
|
||||
{
|
||||
"email": "test@example.org",
|
||||
"phone": "+493012345678",
|
||||
"password": "verysecret",
|
||||
"send_email": true
|
||||
}
|
||||
@@ -179,7 +171,6 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "test@example.org",
|
||||
"phone": "+493012345678",
|
||||
...
|
||||
}
|
||||
|
||||
@@ -224,7 +215,6 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "test@example.org",
|
||||
"phone": "+493012345678",
|
||||
…
|
||||
}
|
||||
|
||||
@@ -259,7 +249,6 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": null,
|
||||
"phone": null,
|
||||
…
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,6 @@ geo_lon float Longitude of th
|
||||
has_subevents boolean ``true`` if the event series feature is active for this
|
||||
event. Cannot change after event is created.
|
||||
meta_data object Values set for organizer-specific meta data parameters.
|
||||
The allowed keys need to be set up as meta properties
|
||||
in the organizer configuration.
|
||||
plugins list A list of package names of the enabled plugins for this
|
||||
event.
|
||||
seating_plan integer If reserved seating is in use, the ID of a seating
|
||||
@@ -345,8 +343,8 @@ Endpoints
|
||||
Creates a new event with properties as set in the request body. The properties that are copied are: ``is_public``,
|
||||
``testmode``, ``has_subevents``, settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
|
||||
|
||||
If the ``plugins``, ``has_subevents``, ``meta_data`` and/or ``is_public`` fields are present in the post body this will
|
||||
determine their value. Otherwise their value will be copied from the existing event.
|
||||
If the ``plugins``, ``has_subevents`` and/or ``is_public`` fields are present in the post body this will determine their
|
||||
value. Otherwise their value will be copied from the existing event.
|
||||
|
||||
Please note that you can only copy from events under the same organizer this way. Use the ``clone_from`` parameter
|
||||
when creating a new event for this instead.
|
||||
|
||||
@@ -45,16 +45,8 @@ sales_channels list of strings Sales channels
|
||||
available.
|
||||
available_from datetime The first date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
available_from_mode string If ``hide`` (the default), this variation is hidden in the shop
|
||||
if unavailable due to the available_from setting.
|
||||
If ``info``, the variation is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
available_until datetime The last date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
available_until_mode string If ``hide`` (the default), this variation is hidden in the shop
|
||||
if unavailable due to the available_until setting.
|
||||
If ``info``, the variation is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
|
||||
redemption process, but not in the normal shop
|
||||
frontend.
|
||||
@@ -113,9 +105,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": {
|
||||
"en": "Test2"
|
||||
@@ -141,9 +131,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": {},
|
||||
"position": 1,
|
||||
@@ -204,9 +192,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0,
|
||||
@@ -246,9 +232,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0,
|
||||
@@ -279,9 +263,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 0,
|
||||
@@ -343,9 +325,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"position": 1,
|
||||
|
||||
@@ -50,16 +50,8 @@ sales_channels list of strings Sales channel
|
||||
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
|
||||
available_from datetime The first date time at which this item can be bought
|
||||
(or ``null``).
|
||||
available_from_mode string If ``hide`` (the default), this item is hidden in the shop
|
||||
if unavailable due to the ``available_from`` setting.
|
||||
If ``info``, the item is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
available_until datetime The last date time at which this item can be bought
|
||||
(or ``null``).
|
||||
available_until_mode string If ``hide`` (the default), this item is hidden in the shop
|
||||
if unavailable due to the ``available_until`` setting.
|
||||
If ``info``, the item is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
hidden_if_available integer **DEPRECATED** The internal ID of a quota object, or ``null``. If
|
||||
set, this item won't be shown publicly as long as this
|
||||
quota is available.
|
||||
@@ -164,16 +156,8 @@ variations list of objects A list with o
|
||||
available.
|
||||
├ available_from datetime The first date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
├ available_from_mode string If ``hide`` (the default), this variation is hidden in the shop
|
||||
if unavailable due to the ``available_from`` setting.
|
||||
If ``info``, the variation is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
├ available_until datetime The last date time at which this variation can be bought
|
||||
(or ``null``).
|
||||
├ available_until_mode string If ``hide`` (the default), this variation is hidden in the shop
|
||||
if unavailable due to the ``available_until`` setting.
|
||||
If ``info``, the variation is visible, but can't be purchased,
|
||||
and a note explaining the unavailability is displayed.
|
||||
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
|
||||
redemption process, but not in the normal shop
|
||||
frontend.
|
||||
@@ -295,9 +279,7 @@ Endpoints
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hidden_if_available": null,
|
||||
"hidden_if_item_available": null,
|
||||
"require_voucher": false,
|
||||
@@ -342,9 +324,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -364,9 +344,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -439,9 +417,7 @@ Endpoints
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hidden_if_available": null,
|
||||
"hidden_if_item_available": null,
|
||||
"require_voucher": false,
|
||||
@@ -487,9 +463,7 @@ Endpoints
|
||||
"description": null,
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"meta_data": {},
|
||||
"position": 0
|
||||
@@ -508,9 +482,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -564,9 +536,7 @@ Endpoints
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hidden_if_available": null,
|
||||
"hidden_if_item_available": null,
|
||||
"require_voucher": false,
|
||||
@@ -610,9 +580,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -632,9 +600,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -676,9 +642,7 @@ Endpoints
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hidden_if_available": null,
|
||||
"hidden_if_item_available": null,
|
||||
"require_voucher": false,
|
||||
@@ -723,9 +687,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -745,9 +707,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -820,9 +780,7 @@ Endpoints
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hidden_if_available": null,
|
||||
"hidden_if_item_available": null,
|
||||
"require_voucher": false,
|
||||
@@ -867,9 +825,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
@@ -889,9 +845,7 @@ Endpoints
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_from_mode": "hide",
|
||||
"available_until": null,
|
||||
"available_until_mode": "hide",
|
||||
"hide_without_voucher": false,
|
||||
"description": null,
|
||||
"meta_data": {},
|
||||
|
||||
@@ -137,17 +137,13 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``event`` attribute has been added. The organizer-level endpoint has been added.
|
||||
|
||||
.. versionchanged:: 2023.9
|
||||
|
||||
The ``customer`` query parameter has been added.
|
||||
|
||||
.. versionchanged:: 2023.10
|
||||
|
||||
The ``checkin_text`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2024.1
|
||||
.. versionchanged:: 2023.9
|
||||
|
||||
The ``expires`` attribute can now be passed during order creation.
|
||||
The ``customer`` query parameter has been added.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
@@ -179,11 +175,6 @@ country string Attendee countr
|
||||
state string Attendee state (ISO 3166-2 code). Only supported in
|
||||
AU, BR, CA, CN, MY, MX, and US, otherwise ``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_value money (string) VAT included in this position
|
||||
tax_rule integer The ID of the used tax rule (or ``null``)
|
||||
@@ -372,7 +363,6 @@ List of all orders
|
||||
"country": "DE",
|
||||
"state": null,
|
||||
"voucher": null,
|
||||
"voucher_budget_use": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_value": "0.00",
|
||||
"tax_rule": null,
|
||||
@@ -595,7 +585,6 @@ Fetching individual orders
|
||||
"country": "DE",
|
||||
"state": null,
|
||||
"voucher": null,
|
||||
"voucher_budget_use": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": null,
|
||||
"tax_value": "0.00",
|
||||
@@ -740,8 +729,6 @@ Updating order fields
|
||||
|
||||
* ``valid_if_pending``
|
||||
|
||||
* ``expires``
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -1548,7 +1535,6 @@ List of all order positions
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"voucher_budget_use": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": null,
|
||||
"tax_value": "0.00",
|
||||
@@ -1662,7 +1648,6 @@ Fetching individual positions
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"voucher_budget_use": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": null,
|
||||
"tax_value": "0.00",
|
||||
|
||||
@@ -22,8 +22,6 @@ id integer Internal ID of
|
||||
name string Team name
|
||||
all_events boolean Whether this team has access to all events
|
||||
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_change_teams boolean
|
||||
can_change_organizer_settings boolean
|
||||
@@ -124,7 +122,6 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -162,7 +159,6 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -190,7 +186,6 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -208,7 +203,6 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -252,7 +246,6 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ Core
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification,
|
||||
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter,
|
||||
register_ticket_secret_generators, gift_card_transaction_display,
|
||||
register_text_placeholders, register_mail_placeholders
|
||||
register_ticket_secret_generators, gift_card_transaction_display
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
|
||||
.. _`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
|
||||
your plugin defines additional data structures around those objects, it might be useful to make it possible to import
|
||||
them as well.
|
||||
It's possible through the backend to import orders into pretix, for example from a legacy ticketing system. If your
|
||||
plugins defines additional data structures around orders, it might be useful to make it possible to import them as well.
|
||||
|
||||
Import process
|
||||
--------------
|
||||
@@ -41,7 +40,7 @@ Column registration
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
.. sourcecode:: python
|
||||
@@ -57,16 +56,10 @@ that we'll provide in this plugin:
|
||||
EmailColumn(sender),
|
||||
]
|
||||
|
||||
Similar signals exist for other objects:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: voucher_import_columns
|
||||
|
||||
|
||||
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``.
|
||||
|
||||
|
||||
@@ -84,8 +84,6 @@ convenient to you:
|
||||
|
||||
.. automethod:: _register_fonts
|
||||
|
||||
.. automethod:: _register_event_fonts
|
||||
|
||||
.. automethod:: _on_first_page
|
||||
|
||||
.. automethod:: _on_other_page
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
Writing a template placeholder plugin
|
||||
=====================================
|
||||
Writing an e-mail placeholder plugin
|
||||
====================================
|
||||
|
||||
A template placeholder is a dynamic value that pretix users can use in their email templates and in other
|
||||
configurable texts.
|
||||
An email placeholder is a dynamic value that pretix users can use in their email templates.
|
||||
|
||||
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
|
||||
|
||||
@@ -13,31 +12,31 @@ Placeholder registration
|
||||
------------------------
|
||||
|
||||
The placeholder API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available placeholders. Your plugin
|
||||
should listen for this signal and return an instance of a subclass of ``pretix.base.services.placeholders.BaseTextPlaceholder``:
|
||||
does use a signal to get a list of all available email placeholders. Your plugin
|
||||
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import register_text_placeholders
|
||||
from pretix.base.signals import register_mail_placeholders
|
||||
|
||||
|
||||
@receiver(register_text_placeholders, dispatch_uid="placeholder_custom")
|
||||
def register_placeholder_renderers(sender, **kwargs):
|
||||
from .placeholders import MyPlaceholderClass
|
||||
@receiver(register_mail_placeholders, dispatch_uid="placeholder_custom")
|
||||
def register_mail_renderers(sender, **kwargs):
|
||||
from .email import MyPlaceholderClass
|
||||
return MyPlaceholder()
|
||||
|
||||
|
||||
Context mechanism
|
||||
-----------------
|
||||
|
||||
Templates are used in different "contexts" within pretix. For example, many emails are rendered from
|
||||
templates in the context of an order, but some are not, such as the notification of a waiting list voucher.
|
||||
Emails are sent in different "contexts" within pretix. For example, many emails are sent in the
|
||||
the context of an order, but some are not, such as the notification of a waiting list voucher.
|
||||
|
||||
Not all placeholders make sense everywhere, and placeholders usually depend on some parameters
|
||||
Not all placeholders make sense in every email, and placeholders usually depend some parameters
|
||||
themselves, such as the ``Order`` object. Therefore, placeholders are expected to explicitly declare
|
||||
what values they depend on and they will only be available in a context where all those dependencies are
|
||||
what values they depend on and they will only be available in an email if all those dependencies are
|
||||
met. Currently, placeholders can depend on the following context parameters:
|
||||
|
||||
* ``event``
|
||||
@@ -52,7 +51,7 @@ There are a few more that are only to be used internally but not by plugins.
|
||||
The placeholder class
|
||||
---------------------
|
||||
|
||||
.. class:: pretix.base.services.placeholders.BaseTextPlaceholder
|
||||
.. class:: pretix.base.email.BaseMailTextPlaceholder
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
@@ -78,15 +77,7 @@ functions:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
placeholder = SimpleFunctionalTextPlaceholder(
|
||||
placeholder = SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['order'], lambda order: order.code, sample='F8VVL'
|
||||
)
|
||||
|
||||
Signals
|
||||
-------
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: register_text_placeholders
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: register_mail_placeholders
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ transactions list of objects Transactions in
|
||||
├ checksum string Checksum computed from payer, reference, amount and
|
||||
date
|
||||
├ payer string Payment source
|
||||
├ external_id string Unique ID of the payment from an external source
|
||||
├ reference string Payment reference
|
||||
├ amount string Payment amount
|
||||
├ iban string Payment IBAN
|
||||
@@ -86,7 +85,6 @@ Endpoints
|
||||
"date": "26.06.2017",
|
||||
"payer": "John Doe",
|
||||
"order": null,
|
||||
"external_id": null,
|
||||
"iban": "",
|
||||
"bic": "",
|
||||
"checksum": "5de03a601644dfa63420dacfd285565f8375a8f2",
|
||||
@@ -141,7 +139,6 @@ Endpoints
|
||||
"iban": "",
|
||||
"bic": "",
|
||||
"order": null,
|
||||
"external_id": null,
|
||||
"checksum": "5de03a601644dfa63420dacfd285565f8375a8f2",
|
||||
"reference": "GUTSCHRIFT\r\nSAMPLECONF-NAB12 EREF: SAMPLECONF-NAB12\r\nIBAN: DE1234556…",
|
||||
"state": "nomatch",
|
||||
|
||||
@@ -34,16 +34,12 @@ internal_id string Can be used for
|
||||
contact_name string Contact person (or ``null``)
|
||||
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_cc_email string Copy email addresses, can be multiple separated by comma (or ``null``)
|
||||
booth string Booth number (or ``null``). Maximum 100 characters.
|
||||
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).
|
||||
lead_scanning_access_code string Access code for the exhibitor to use the lead scanning app but not access data (read-only).
|
||||
allow_lead_scanning boolean Enables lead scanning app
|
||||
allow_lead_access boolean Enables access to data gathered by the lead scanning app
|
||||
allow_voucher_access boolean Enables access to data gathered by exhibitor vouchers
|
||||
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.
|
||||
comment string Internal comment, not shown to exhibitor
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
@@ -66,7 +62,6 @@ data list of objects Attendee data s
|
||||
except in a few cases where it contains an additional list of objects
|
||||
with ``value`` and ``label`` keys (e.g. splitting of names).
|
||||
device_name string User-defined name for the device used for scanning (or ``null``).
|
||||
device_uuid string UUID of device used for scanning (or ``null``).
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
@@ -110,12 +105,9 @@ Endpoints
|
||||
"title": "Dr"
|
||||
},
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"access_code": "VKHZ2FU84",
|
||||
"lead_scanning_access_code": "WVK2B8PZ",
|
||||
"lead_scanning_scope_by_device": false,
|
||||
"access_code": "VKHZ2FU8",
|
||||
"allow_lead_scanning": true,
|
||||
"allow_lead_access": true,
|
||||
"allow_voucher_access": true,
|
||||
@@ -164,12 +156,9 @@ Endpoints
|
||||
"title": "Dr"
|
||||
},
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"access_code": "VKHZ2FU84",
|
||||
"lead_scanning_access_code": "WVK2B8PZ",
|
||||
"lead_scanning_scope_by_device": false,
|
||||
"access_code": "VKHZ2FU8",
|
||||
"allow_lead_scanning": true,
|
||||
"allow_lead_access": true,
|
||||
"allow_voucher_access": true,
|
||||
@@ -368,7 +357,6 @@ Endpoints
|
||||
"title": "Dr"
|
||||
},
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"allow_lead_scanning": true,
|
||||
@@ -398,12 +386,9 @@ Endpoints
|
||||
"title": "Dr"
|
||||
},
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"access_code": "VKHZ2FU84",
|
||||
"lead_scanning_access_code": "WVK2B8PZ",
|
||||
"lead_scanning_scope_by_device": false,
|
||||
"access_code": "VKHZ2FU8",
|
||||
"allow_lead_scanning": true,
|
||||
"allow_lead_access": true,
|
||||
"allow_voucher_access": true,
|
||||
@@ -459,12 +444,9 @@ Endpoints
|
||||
"title": "Dr"
|
||||
},
|
||||
"contact_email": "johnson@as.example.org",
|
||||
"contact_cc_email": "miller@as.example.org,smith@as.example.org",
|
||||
"booth": "A2",
|
||||
"locale": "de",
|
||||
"access_code": "VKHZ2FU84",
|
||||
"lead_scanning_access_code": "WVK2B8PZ",
|
||||
"lead_scanning_scope_by_device": false,
|
||||
"access_code": "VKHZ2FU8",
|
||||
"allow_lead_scanning": true,
|
||||
"allow_lead_access": true,
|
||||
"allow_voucher_access": true,
|
||||
@@ -579,7 +561,6 @@ name string Exhibitor name
|
||||
booth string Booth number (or ``null``)
|
||||
event object Object describing the event
|
||||
├ name multi-lingual string Event name
|
||||
├ end_date datetime End date of the event. After this time, the app could show a warning that the event is over.
|
||||
├ imprint_url string URL to legal notice page. If not ``null``, a button in the app should link to this page.
|
||||
├ privacy_url string URL to privacy notice page. If not ``null``, a button in the app should link to this page.
|
||||
├ help_url string URL to help page. If not ``null``, a button in the app should link to this page.
|
||||
@@ -615,7 +596,6 @@ scan_types list of objects Only used for a
|
||||
"booth": "A2",
|
||||
"event": {
|
||||
"name": {"en": "Sample conference", "de": "Beispielkonferenz"},
|
||||
"end_date": "2017-12-28T10:00:00+00:00",
|
||||
"slug": "bigevents",
|
||||
"imprint_url": null,
|
||||
"privacy_url": null,
|
||||
@@ -654,7 +634,6 @@ On the request, you should set the following properties:
|
||||
* ``tags`` with the list of selected tags
|
||||
* ``rating`` with the rating assigned by the exhibitor
|
||||
* ``device_name`` with a user-specified name of the device used for scanning (max. 190 characters), or ``null``
|
||||
* ``device_uuid`` with a auto-generated UUID of the device used for scanning, or ``null``
|
||||
|
||||
If you submit ``tags`` and ``rating`` to be ``null`` and ``notes`` to be ``""``, the server
|
||||
responds with the previously saved information and will not delete that information. If you
|
||||
@@ -689,8 +668,7 @@ The request for this looks like this:
|
||||
"scan_type": "lead",
|
||||
"tags": ["foo"],
|
||||
"rating": 4,
|
||||
"device_name": "DEV1",
|
||||
"device_uuid": "d8c2ec53-d602-4a08-882d-db4cf54344a2"
|
||||
"device_name": "DEV1"
|
||||
}
|
||||
|
||||
**Example response:**
|
||||
@@ -723,9 +701,7 @@ The request for this looks like this:
|
||||
},
|
||||
"rating": 4,
|
||||
"tags": ["foo"],
|
||||
"notes": "Great customer, wants our newsletter",
|
||||
"device_name": "DEV1",
|
||||
"device_uuid": "d8c2ec53-d602-4a08-882d-db4cf54344a2"
|
||||
"notes": "Great customer, wants our newsletter"
|
||||
}
|
||||
|
||||
:statuscode 200: No error, leads was not scanned for the first time
|
||||
@@ -780,9 +756,7 @@ You can also fetch existing leads (if you are authorized to do so):
|
||||
},
|
||||
"rating": 4,
|
||||
"tags": ["foo"],
|
||||
"notes": "Great customer, wants our newsletter",
|
||||
"device_name": "DEV1",
|
||||
"device_uuid": "d8c2ec53-d602-4a08-882d-db4cf54344a2"
|
||||
"notes": "Great customer, wants our newsletter"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
sphinx==7.2.*
|
||||
sphinx==7.0.*
|
||||
jinja2==3.1.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-e ../
|
||||
sphinx==7.2.*
|
||||
sphinx==7.0.*
|
||||
jinja2==3.1.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
|
||||
@@ -194,23 +194,17 @@ A complete record could look like this::
|
||||
|
||||
v=spf1 a mx include:_spf.pretix.eu ~all
|
||||
|
||||
Make sure to read up on the `SPF specification`_.
|
||||
Make sure to read up on the `SPF specification`_. If you want to authenticate your emails with DKIM, set up a DNS TXT
|
||||
record for the subdomain ``pretix._domainkey`` with the following contents::
|
||||
|
||||
If you want to authenticate your emails with `DKIM`_, set up a ``CNAME`` record for the subdomain ``pretix._domainkey``
|
||||
pointing to ``dkim.pretix.eu``::
|
||||
|
||||
pretix._domainkey.mydomain.com. CNAME dkim.pretix.eu.
|
||||
v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDXrDk6lwOWX00e2MbiiJac6huI+gnzLf9N4G1FnBv3PXq8fz3i2q1szH72OF5mAlKm3zXO4cl/uxx+lfidS1ERbX6Bn9BRstBTQUKWC4JFj8Yk9+fwT7LWehDURazLdTzfsIjJFudLLvxtOKSaOCtMhbPX05DIhziaqVCBqgz/NQIDAQAB
|
||||
|
||||
Then, please contact support@pretix.eu and we will enable DKIM for your domain on our mail servers.
|
||||
|
||||
For senders with larger volumes, Google Mail also requires you to have a `DMARC`_ policy (that may however be ``p=none``).
|
||||
|
||||
.. note:: Many SMTP servers impose rate limits on the sent emails, such as a maximum number of emails sent per hour.
|
||||
These SMTP servers are often not suitable for use with pretix, in case you want to send an email to many
|
||||
hundreds or thousands of ticket buyers. Depending on how the rate limit is implemented, emails might be lost
|
||||
in this case, as pretix only retries email delivery for a certain time period.
|
||||
|
||||
.. _DKIM: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
|
||||
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
||||
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax
|
||||
.. _DMARC: https://en.wikipedia.org/wiki/DMARC
|
||||
@@ -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.
|
||||
|
||||
You can find more use cases in these specialized guides:
|
||||
|
||||
@@ -138,7 +138,7 @@ the button-style of that checkbox with the one in the pretix shop, you can use t
|
||||
|
||||
.. note::
|
||||
|
||||
Due to compatibility with existing widget installations, the default value for ``single-item-select``
|
||||
Due to compatibilty with existing widget installations, the default value for ``single-item-select``
|
||||
is ``checkbox``. This might change in the future, so make sure, to set the attribute to
|
||||
``single-item-select="checkbox"`` if you need it.
|
||||
|
||||
@@ -196,7 +196,7 @@ settings. For example, if you set up a meta data property called "Promoted" that
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/series/" list-type="list" filter="attr[Promoted]=Yes"></pretix-widget>
|
||||
|
||||
If you have enabled public filters in your meta data attribute configuration, a filter-form shows up. To disable, use::
|
||||
If you have enabled public filters in your meta data attribute configuration, a filter formshows up. To disable, use::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" disable-filters></pretix-widget>
|
||||
|
||||
@@ -429,25 +429,4 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Offering wallet payments (Apple Pay, Google Pay) within the widget
|
||||
------------------------------------------------------------------
|
||||
|
||||
Some payment providers (such as Stripe) also offer Apple or Google Pay. But in order to use them, the domain of the
|
||||
payment needs to be approved first. As of right now, pretix will take care of the domain verification process for you
|
||||
automatically, when using Stripe. However, pretix can only validate the domain that is being used for your default,
|
||||
"stand-alone" shop (such as https://pretix.eu/demo/democon/ ).
|
||||
|
||||
When embedding the widget on your website, the domain of the embedding page will also need to be validated in order to
|
||||
be able to use it for wallet payments.
|
||||
|
||||
The details might vary from payment provider to payment provider, but generally speaking, it will either involve just
|
||||
telling your payment provider the domain name and (for Apple Pay) placing an
|
||||
``apple-developer-merchantid-domain-association``-file into the ``.well-known``-directory of your domain.
|
||||
|
||||
Further reading:
|
||||
|
||||
* `Stripe Payment Method Domain registration`_
|
||||
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
.. _Stripe Payment Method Domain registration: https://stripe.com/docs/payments/payment-methods/pmd-registration
|
||||
|
||||
@@ -145,7 +145,7 @@ to get a better plain text representation of your text. Note however, that for
|
||||
security reasons you can only use the following HTML elements::
|
||||
|
||||
a, abbr, acronym, b, br, code, div, em, h1, h2,
|
||||
h3, h4, h5, h6, hr, i, li, ol, p, pre, s, span, strong,
|
||||
h3, h4, h5, h6, hr, i, li, ol, p, pre, span, strong,
|
||||
table, tbody, td, thead, tr, ul
|
||||
|
||||
Additionally, only the following attributes are allowed on them::
|
||||
|
||||
@@ -31,41 +31,41 @@ dependencies = [
|
||||
"BeautifulSoup4==4.12.*",
|
||||
"bleach==5.0.*",
|
||||
"celery==5.3.*",
|
||||
"chardet==5.2.*",
|
||||
"chardet==5.1.*",
|
||||
"cryptography>=3.4.2",
|
||||
"css-inline==0.14.*",
|
||||
"css-inline==0.8.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"dj-static",
|
||||
"Django==4.2.*",
|
||||
"django-bootstrap3==23.6.*",
|
||||
"django-compressor==4.4",
|
||||
"django-countries==7.6.*",
|
||||
"django-filter==24.2",
|
||||
"django-bootstrap3==23.1.*",
|
||||
"django-compressor==4.3.*",
|
||||
"django-countries==7.5.*",
|
||||
"django-filter==23.2",
|
||||
"django-formset-js-improved==0.5.0.3",
|
||||
"django-formtools==2.5.1",
|
||||
"django-formtools==2.4.1",
|
||||
"django-hierarkey==1.1.*",
|
||||
"django-hijack==3.4.*",
|
||||
"django-hijack==3.3.*",
|
||||
"django-i18nfield==1.9.*,>=1.9.4",
|
||||
"django-libsass==0.9",
|
||||
"django-localflavor==4.0",
|
||||
"django-markup",
|
||||
"django-oauth-toolkit==2.3.*",
|
||||
"django-otp==1.4.*",
|
||||
"django-phonenumber-field==7.3.*",
|
||||
"django-redis==5.4.*",
|
||||
"django-oauth-toolkit==2.2.*",
|
||||
"django-otp==1.2.*",
|
||||
"django-phonenumber-field==7.1.*",
|
||||
"django-redis==5.2.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-statici18n==2.4.*",
|
||||
"django-statici18n==2.3.*",
|
||||
"djangorestframework==3.14.*",
|
||||
"dnspython==2.6.*",
|
||||
"dnspython==2.3.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==4.*",
|
||||
"importlib_metadata==7.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"importlib_metadata==6.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"isoweek",
|
||||
"jsonschema",
|
||||
"kombu==5.3.*",
|
||||
"libsass==0.23.*",
|
||||
"libsass==0.22.*",
|
||||
"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
|
||||
"mt-940==4.30.*",
|
||||
"oauthlib==3.2.*",
|
||||
@@ -73,61 +73,63 @@ dependencies = [
|
||||
"packaging",
|
||||
"paypalrestsdk==1.13.*",
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.8.*",
|
||||
"PyJWT==2.7.*",
|
||||
"phonenumberslite==8.13.*",
|
||||
"Pillow==10.3.*",
|
||||
"Pillow==9.5.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==5.26.*",
|
||||
"protobuf==4.23.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==2.22",
|
||||
"pycryptodome==3.20.*",
|
||||
"pycparser==2.21",
|
||||
"pycryptodome==3.18.*",
|
||||
"pypdf==3.9.*",
|
||||
"python-bidi==0.4.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.9.*",
|
||||
"python-dateutil==2.8.*",
|
||||
"python-u2flib-server==4.*",
|
||||
"pytz",
|
||||
"pytz-deprecation-shim==0.1.*",
|
||||
"pyuca",
|
||||
"qrcode==7.4.*",
|
||||
"redis==5.0.*",
|
||||
"reportlab==4.1.*",
|
||||
"redis==4.6.*",
|
||||
"reportlab==4.0.*",
|
||||
"requests==2.31.*",
|
||||
"sentry-sdk==1.45.*",
|
||||
"sentry-sdk==1.15.*",
|
||||
"sepaxml==2.6.*",
|
||||
"slimit",
|
||||
"static3==0.7.*",
|
||||
"stripe==7.9.*",
|
||||
"stripe==5.4.*",
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2020041600",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==0.18.*",
|
||||
"vat_moss_forked==2020.3.20.0.11.0",
|
||||
"vobject==0.9.*",
|
||||
"webauthn==2.1.*",
|
||||
"webauthn==0.4.*",
|
||||
"zeep==4.2.*"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
memcached = ["pylibmc"]
|
||||
dev = [
|
||||
"aiohttp==3.9.*",
|
||||
"aiohttp==3.8.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.21.*",
|
||||
"flake8==7.0.*",
|
||||
"fakeredis==2.18.*",
|
||||
"flake8==6.0.*",
|
||||
"freezegun",
|
||||
"isort==5.13.*",
|
||||
"isort==5.12.*",
|
||||
"pep8-naming==0.13.*",
|
||||
"potypo",
|
||||
"pycodestyle==2.10.*",
|
||||
"pyflakes==3.0.*",
|
||||
"pytest-asyncio",
|
||||
"pytest-cache",
|
||||
"pytest-cov",
|
||||
"pytest-django==4.*",
|
||||
"pytest-mock==3.14.*",
|
||||
"pytest-rerunfailures==14.*",
|
||||
"pytest-mock==3.10.*",
|
||||
"pytest-rerunfailures==11.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.5.*",
|
||||
"pytest==8.1.*",
|
||||
"pytest-xdist==3.3.*",
|
||||
"pytest==7.3.*",
|
||||
"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
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2024.4.0.dev0"
|
||||
__version__ = "2023.11.0.dev0"
|
||||
|
||||
@@ -111,7 +111,6 @@ LANGUAGES_RTL = {
|
||||
LANGUAGES_INCUBATING = {
|
||||
'fi', 'pt-br', 'gl',
|
||||
}
|
||||
LANGUAGES = ALL_LANGUAGES
|
||||
LOCALE_PATHS = [
|
||||
os.path.join(os.path.dirname(__file__), 'locale'),
|
||||
]
|
||||
@@ -268,10 +267,9 @@ CACHE_LARGE_VALUES_ALIAS = 'default'
|
||||
FILE_UPLOAD_EXTENSIONS_IMAGE = (".png", ".jpg", ".gif", ".jpeg")
|
||||
PILLOW_FORMATS_IMAGE = ('PNG', 'GIF', 'JPEG')
|
||||
|
||||
FILE_UPLOAD_EXTENSIONS_FAVICON = (".ico", ".png", ".jpg", ".gif", ".jpeg")
|
||||
PILLOW_FORMATS_QUESTIONS_FAVICON = ('PNG', 'GIF', 'JPEG', 'ICO')
|
||||
FILE_UPLOAD_EXTENSIONS_FAVICON = (".ico", ".png", "jpg", ".gif", ".jpeg")
|
||||
|
||||
FILE_UPLOAD_EXTENSIONS_QUESTION_IMAGE = (".png", ".jpg", ".gif", ".jpeg", ".bmp", ".tif", ".tiff", ".jfif")
|
||||
FILE_UPLOAD_EXTENSIONS_QUESTION_IMAGE = (".png", "jpg", ".gif", ".jpeg", ".bmp", ".tif", ".tiff", ".jfif")
|
||||
PILLOW_FORMATS_QUESTIONS_IMAGE = ('PNG', 'GIF', 'JPEG', 'BMP', 'TIFF')
|
||||
|
||||
FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT = (
|
||||
@@ -280,5 +278,3 @@ FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT = (
|
||||
".bmp", ".tif", ".tiff"
|
||||
)
|
||||
FILE_UPLOAD_EXTENSIONS_OTHER = FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT
|
||||
|
||||
PRETIX_MAX_ORDER_SIZE = 500
|
||||
|
||||
@@ -38,7 +38,6 @@ MAIL_FROM_ORGANIZERS = 'invalid@invalid'
|
||||
FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT = 10
|
||||
FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT = 10
|
||||
FILE_UPLOAD_MAX_SIZE_IMAGE = 10
|
||||
FILE_UPLOAD_MAX_SIZE_FAVICON = 10
|
||||
DEFAULT_CURRENCY = 'EUR'
|
||||
SECRET_KEY = "build-time-secret-key"
|
||||
HAS_REDIS = False
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
# 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 logging
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import exceptions
|
||||
@@ -31,8 +29,6 @@ from pretix.api.auth.devicesecurity import (
|
||||
)
|
||||
from pretix.base.models import Device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeviceTokenAuthentication(TokenAuthentication):
|
||||
model = Device
|
||||
@@ -50,7 +46,6 @@ class DeviceTokenAuthentication(TokenAuthentication):
|
||||
raise exceptions.AuthenticationFailed('Device has not been initialized.')
|
||||
|
||||
if device.revoked:
|
||||
logging.warning(f'Connection attempt of revoked device {device.pk}.')
|
||||
raise exceptions.AuthenticationFailed('Device access has been revoked.')
|
||||
|
||||
return AnonymousUser(), device
|
||||
|
||||
@@ -185,7 +185,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:order-detail'),
|
||||
('DELETE', 'api-v1:orderposition-detail'),
|
||||
('PATCH', 'api-v1:orderposition-detail'),
|
||||
('GET', 'api-v1:orderposition-list'),
|
||||
('GET', 'api-v1:orderposition-answer'),
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('POST', 'api-v1:order-mark-canceled'),
|
||||
@@ -224,7 +223,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
('POST', 'api-v1:reusablemedium-lookup'),
|
||||
('GET', 'api-v1:reusablemedium-list'),
|
||||
('POST', 'api-v1:reusablemedium-list'),
|
||||
)
|
||||
|
||||
|
||||
@@ -39,8 +39,7 @@ from pretix.base.models import Device, Event, User
|
||||
from pretix.base.models.auth import SuperuserPermissionSet
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.security import (
|
||||
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
|
||||
SessionReauthRequired, assert_session_valid,
|
||||
SessionInvalid, SessionReauthRequired, assert_session_valid,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,10 +66,6 @@ class EventPermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
|
||||
else request.user)
|
||||
@@ -149,10 +144,6 @@ class ProfilePermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
if isinstance(request.auth, OAuthAccessToken):
|
||||
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
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,49 +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 rest_framework import exceptions
|
||||
from rest_framework.authentication import (
|
||||
SessionAuthentication as BaseSessionAuthentication,
|
||||
)
|
||||
|
||||
from pretix.multidomain.middlewares import CsrfViewMiddleware
|
||||
|
||||
|
||||
class CustomCSRFCheck(CsrfViewMiddleware):
|
||||
def _reject(self, request, reason):
|
||||
# Return the failure reason instead of an HttpResponse
|
||||
return reason
|
||||
|
||||
|
||||
class SessionAuthentication(BaseSessionAuthentication):
|
||||
# Override from DRF to user our custom CSRF middleware
|
||||
|
||||
def enforce_csrf(self, request):
|
||||
def dummy_get_response(request): # pragma: no cover
|
||||
return None
|
||||
|
||||
check = CustomCSRFCheck(dummy_get_response)
|
||||
# populates request.META['CSRF_COOKIE'], which is used in process_view()
|
||||
check.process_request(request)
|
||||
reason = check.process_view(request, None, (), {})
|
||||
if reason:
|
||||
# CSRF failed, bail with explicit error message
|
||||
raise exceptions.PermissionDenied('CSRF Failed: %s' % reason)
|
||||
@@ -54,7 +54,7 @@ class IdempotencyMiddleware:
|
||||
|
||||
auth_hash_parts = '{}:{}'.format(
|
||||
request.headers.get('Authorization', ''),
|
||||
request.COOKIES.get('__Host-' + settings.SESSION_COOKIE_NAME, request.COOKIES.get(settings.SESSION_COOKIE_NAME, ''))
|
||||
request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
|
||||
)
|
||||
auth_hash = sha1(auth_hash_parts.encode()).hexdigest()
|
||||
idempotency_key = request.headers.get('X-Idempotency-Key', '')
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 4.2.10 on 2024-02-12 11:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixapi", "0011_bigint"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="oauthapplication",
|
||||
name="post_logout_redirect_uris",
|
||||
field=models.TextField(default=""),
|
||||
),
|
||||
]
|
||||
@@ -42,11 +42,6 @@ class OAuthApplication(AbstractApplication):
|
||||
verbose_name=_("Redirection URIs"),
|
||||
help_text=_("Allowed URIs list, space separated")
|
||||
)
|
||||
post_logout_redirect_uris = models.TextField(
|
||||
blank=True, validators=[URIValidator],
|
||||
help_text=_("Allowed Post Logout URIs list, space separated"),
|
||||
default="",
|
||||
)
|
||||
client_id = models.CharField(
|
||||
verbose_name=_("Client ID"),
|
||||
max_length=100, unique=True, default=generate_client_id, db_index=True
|
||||
|
||||
@@ -424,7 +424,7 @@ class CloneEventSerializer(EventSerializer):
|
||||
new_event = super().create({**validated_data, 'plugins': None})
|
||||
|
||||
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
|
||||
new_event.copy_data_from(event, skip_meta_data='meta_data' in validated_data)
|
||||
new_event.copy_data_from(event)
|
||||
|
||||
if plugins is not None:
|
||||
new_event.set_active_plugins(plugins)
|
||||
@@ -687,7 +687,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'allow_modifications_after_checkin',
|
||||
'show_quota_left',
|
||||
'waiting_list_enabled',
|
||||
'waiting_list_auto_disable',
|
||||
'waiting_list_hours',
|
||||
'waiting_list_auto',
|
||||
'waiting_list_names_asked',
|
||||
|
||||
@@ -61,8 +61,7 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden',
|
||||
'checkin_attention', 'checkin_text',
|
||||
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
|
||||
'checkin_attention', 'checkin_text', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher', 'meta_data')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -86,8 +85,7 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden',
|
||||
'checkin_attention', 'checkin_text',
|
||||
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
|
||||
'checkin_attention', 'checkin_text', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher', 'meta_data')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -237,8 +235,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
model = Item
|
||||
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
|
||||
'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission',
|
||||
'personalized', 'position', 'picture',
|
||||
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
|
||||
'personalized', 'position', 'picture', 'available_from', 'available_until',
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||
|
||||
@@ -486,11 +486,11 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'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 = (
|
||||
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
|
||||
'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):
|
||||
@@ -1035,14 +1035,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
|
||||
self.fields['customer'].queryset = self.context['event'].organizer.customers.all()
|
||||
self.fields['expires'].required = False
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
|
||||
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
|
||||
'require_approval', 'valid_if_pending', 'expires')
|
||||
'require_approval', 'valid_if_pending')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp is None:
|
||||
@@ -1051,11 +1050,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError('The given payment provider is not known.')
|
||||
return pp
|
||||
|
||||
def validate_expires(self, expires):
|
||||
if expires < now():
|
||||
raise ValidationError('Expiration date must be in the future.')
|
||||
return expires
|
||||
|
||||
def validate_sales_channel(self, channel):
|
||||
if channel not in get_all_sales_channels():
|
||||
raise ValidationError('Unknown sales channel.')
|
||||
@@ -1077,10 +1071,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
'An order cannot be empty.'
|
||||
)
|
||||
if len(data) > settings.PRETIX_MAX_ORDER_SIZE:
|
||||
raise ValidationError(
|
||||
'Orders cannot have more than %(max)s positions.' % {'max': settings.PRETIX_MAX_ORDER_SIZE}
|
||||
)
|
||||
errs = [{} for p in data]
|
||||
if any([p.get('positionid') for p in data]):
|
||||
if not all([p.get('positionid') for p in data]):
|
||||
@@ -1315,7 +1305,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
|
||||
valid_from, valid_until = pos_data['item'].compute_validity(
|
||||
requested_start=(
|
||||
requested_valid_from
|
||||
max(requested_valid_from, now())
|
||||
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
|
||||
else now()
|
||||
),
|
||||
@@ -1366,8 +1356,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if validated_data.get('locale', None) is None:
|
||||
validated_data['locale'] = self.context['event'].settings.locale
|
||||
order = Order(event=self.context['event'], **validated_data)
|
||||
if not validated_data.get('expires'):
|
||||
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||
order.meta_info = "{}"
|
||||
order.total = Decimal('0.00')
|
||||
if validated_data.get('require_approval') is not None:
|
||||
@@ -1439,7 +1428,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
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))
|
||||
else:
|
||||
pos._calculate_tax(invoice_address=ia)
|
||||
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
@@ -1467,7 +1455,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
|
||||
pos = pos_data['__instance']
|
||||
pos._calculate_tax(invoice_address=ia)
|
||||
pos._calculate_tax()
|
||||
|
||||
if simulate:
|
||||
pos = WrappedModel(pos)
|
||||
@@ -1586,7 +1574,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
|
||||
payment_provider = 'free'
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'):
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
order.payments.create(
|
||||
@@ -1598,8 +1586,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
elif validated_data.get('status') == Order.STATUS_PAID:
|
||||
if not 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(
|
||||
amount=order.total,
|
||||
provider=payment_provider,
|
||||
|
||||
@@ -79,8 +79,8 @@ class CustomerSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = ('identifier', 'external_identifier', 'email', 'phone', 'name', 'name_parts', 'is_active',
|
||||
'is_verified', 'last_login', 'date_joined', 'locale', 'last_modified', 'notes')
|
||||
fields = ('identifier', 'external_identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
|
||||
'locale', 'last_modified', 'notes')
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if instance and instance.provider_id:
|
||||
@@ -239,7 +239,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Team
|
||||
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_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
||||
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
|
||||
|
||||
@@ -35,7 +35,6 @@ from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from packaging.version import parse
|
||||
@@ -153,6 +152,11 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=True, methods=['POST'], url_name='failed_checkins')
|
||||
@transaction.atomic()
|
||||
def failed_checkins(self, *args, **kwargs):
|
||||
additional_log_data = {}
|
||||
if 'debug_data' in self.request.data:
|
||||
# Intentionally undocumented, might be removed again
|
||||
additional_log_data['debug_data'] = self.request.data.pop('debug_data')
|
||||
|
||||
serializer = FailedCheckinSerializer(
|
||||
data=self.request.data,
|
||||
context={'event': self.request.event}
|
||||
@@ -195,14 +199,16 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
'reason_explanation': c.error_explanation,
|
||||
'datetime': c.datetime,
|
||||
'type': c.type,
|
||||
'list': c.list.pk
|
||||
'list': c.list.pk,
|
||||
**additional_log_data,
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
else:
|
||||
self.request.event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': c.datetime,
|
||||
'type': c.type,
|
||||
'list': c.list.pk,
|
||||
'barcode': c.raw_barcode
|
||||
'barcode': c.raw_barcode,
|
||||
**additional_log_data,
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
|
||||
return Response(serializer.data, status=201)
|
||||
@@ -286,8 +292,6 @@ with scopes_disabled():
|
||||
return queryset.filter(last_checked_in__isnull=not value)
|
||||
|
||||
def check_rules_qs(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
if not self.checkinlist.rules:
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
@@ -587,32 +591,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
|
||||
}, status=400)
|
||||
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]
|
||||
if list_by_event[media.linked_orderposition.order.event_id].addon_match:
|
||||
op_candidates += list(media.linked_orderposition.addons.all())
|
||||
|
||||
@@ -254,7 +254,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
new_event = serializer.save(organizer=self.request.organizer)
|
||||
|
||||
if copy_from:
|
||||
new_event.copy_data_from(copy_from, skip_meta_data='meta_data' in serializer.validated_data)
|
||||
new_event.copy_data_from(copy_from)
|
||||
|
||||
if plugins is not None:
|
||||
new_event.set_active_plugins(plugins)
|
||||
@@ -291,7 +291,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance.organizer.log_action(
|
||||
'pretix.event.deleted', user=self.request.user, auth=self.request.auth,
|
||||
'pretix.event.deleted', user=self.request.user,
|
||||
data={
|
||||
'event_id': instance.pk,
|
||||
'name': str(instance.name),
|
||||
|
||||
@@ -42,7 +42,7 @@ class IdempotencyQueryView(APIView):
|
||||
idempotency_key = request.GET.get("key")
|
||||
auth_hash_parts = '{}:{}'.format(
|
||||
request.headers.get('Authorization', ''),
|
||||
request.COOKIES.get('__Host-' + settings.SESSION_COOKIE_NAME, request.COOKIES.get(settings.SESSION_COOKIE_NAME, ''))
|
||||
request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
|
||||
)
|
||||
auth_hash = sha1(auth_hash_parts.encode()).hexdigest()
|
||||
if not idempotency_key:
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import datetime
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from decimal import Decimal
|
||||
@@ -28,7 +27,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects,
|
||||
)
|
||||
@@ -98,8 +97,6 @@ from pretix.base.signals import (
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.control.signals import order_search_filter_q
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with scopes_disabled():
|
||||
class OrderFilter(FilterSet):
|
||||
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
|
||||
@@ -225,8 +222,6 @@ class OrderViewSetMixin:
|
||||
qs = qs.prefetch_related('refunds', 'refunds__payment')
|
||||
if 'invoice_address' not in self.request.GET.getlist('exclude'):
|
||||
qs = qs.select_related('invoice_address')
|
||||
if 'customer' not in self.request.GET.getlist('exclude'):
|
||||
qs = qs.select_related('customer')
|
||||
|
||||
qs = qs.prefetch_related(self._positions_prefetch(self.request))
|
||||
return qs
|
||||
@@ -903,11 +898,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
order_modified.send(sender=serializer.instance.event, order=serializer.instance)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
try:
|
||||
serializer.save()
|
||||
except IntegrityError:
|
||||
logger.exception("Integrity error while saving order")
|
||||
raise ValidationError("Integrity error, possibly duplicate submission of same order.")
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.testmode:
|
||||
|
||||
@@ -176,7 +176,7 @@ class ParametrizedItemWebhookEvent(ParametrizedWebhookEvent):
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedOrderPositionCheckinWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
d = super().build_payload(logentry)
|
||||
@@ -185,7 +185,6 @@ class ParametrizedOrderPositionCheckinWebhookEvent(ParametrizedOrderWebhookEvent
|
||||
d['orderposition_id'] = logentry.parsed_data.get('position')
|
||||
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
|
||||
d['checkin_list'] = logentry.parsed_data.get('list')
|
||||
d['type'] = logentry.parsed_data.get('type')
|
||||
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
|
||||
return d
|
||||
|
||||
@@ -297,11 +296,11 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.event.order.denied',
|
||||
_('Order denied'),
|
||||
),
|
||||
ParametrizedOrderPositionCheckinWebhookEvent(
|
||||
ParametrizedOrderPositionWebhookEvent(
|
||||
'pretix.event.checkin',
|
||||
_('Ticket checked in'),
|
||||
),
|
||||
ParametrizedOrderPositionCheckinWebhookEvent(
|
||||
ParametrizedOrderPositionWebhookEvent(
|
||||
'pretix.event.checkin.reverted',
|
||||
_('Ticket check-in reverted'),
|
||||
),
|
||||
@@ -385,7 +384,7 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
def notify_webhooks(logentry_ids: list):
|
||||
if not isinstance(logentry_ids, list):
|
||||
logentry_ids = [logentry_ids]
|
||||
qs = LogEntry.all.select_related('event', 'event__organizer', 'organizer').filter(id__in=logentry_ids)
|
||||
qs = LogEntry.all.select_related('event', 'event__organizer', 'organizer_link').filter(id__in=logentry_ids)
|
||||
_org, _at, webhooks = None, None, None
|
||||
for logentry in qs:
|
||||
if not logentry.organizer:
|
||||
|
||||
@@ -46,7 +46,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import invoice # NOQA
|
||||
from . import notifications # 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 django.conf import settings
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
# 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 inspect
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from smtplib import SMTPResponseException
|
||||
from typing import TypeVar
|
||||
@@ -30,21 +33,21 @@ from django.core.mail.backends.smtp import EmailBackend
|
||||
from django.db.models import Count
|
||||
from django.dispatch import receiver
|
||||
from django.template.loader import get_template
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import get_language, gettext_lazy as _
|
||||
|
||||
from pretix.base.i18n import (
|
||||
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
|
||||
)
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.signals import register_html_mail_renderers
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
|
||||
from pretix.base.signals import (
|
||||
register_html_mail_renderers, register_mail_placeholders,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
|
||||
from pretix.base.services.placeholders import ( # noqa
|
||||
get_available_placeholders, PlaceholderContext
|
||||
)
|
||||
from pretix.base.services.placeholders import ( # noqa
|
||||
BaseTextPlaceholder as BaseMailTextPlaceholder,
|
||||
SimpleFunctionalTextPlaceholder as SimpleFunctionalMailTextPlaceholder,
|
||||
)
|
||||
from pretix.base.settings import get_name_parts_localized # noqa
|
||||
|
||||
logger = logging.getLogger('pretix.base.email')
|
||||
|
||||
T = TypeVar("T", bound=EmailBackend)
|
||||
@@ -189,7 +192,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
tpl = get_template(self.template_name)
|
||||
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)
|
||||
|
||||
return body_html
|
||||
@@ -214,5 +217,495 @@ def base_renderers(sender, **kwargs):
|
||||
return [ClassicMailRenderer, UnembellishedMailRenderer]
|
||||
|
||||
|
||||
class BaseMailTextPlaceholder:
|
||||
"""
|
||||
This is the base class for for all email text placeholders.
|
||||
"""
|
||||
|
||||
@property
|
||||
def required_context(self):
|
||||
"""
|
||||
This property should return a list of all attribute names that need to be
|
||||
contained in the base context so that this placeholder is available. By default,
|
||||
it returns a list containing the string "event".
|
||||
"""
|
||||
return ["event"]
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
This should return the identifier of this placeholder in the email.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render(self, context):
|
||||
"""
|
||||
This method is called to generate the actual text that is being
|
||||
used in the email. You will be passed a context dictionary with the
|
||||
base context attributes specified in ``required_context``. You are
|
||||
expected to return a plain-text string.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, event):
|
||||
"""
|
||||
This method is called to generate a text to be used in email previews.
|
||||
This may only depend on the event.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class SimpleFunctionalMailTextPlaceholder(BaseMailTextPlaceholder):
|
||||
def __init__(self, identifier, args, func, sample):
|
||||
self._identifier = identifier
|
||||
self._args = args
|
||||
self._func = func
|
||||
self._sample = sample
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def required_context(self):
|
||||
return self._args
|
||||
|
||||
def render(self, context):
|
||||
return self._func(**{k: context[k] for k in self._args})
|
||||
|
||||
def render_sample(self, event):
|
||||
if callable(self._sample):
|
||||
return self._sample(event)
|
||||
else:
|
||||
return self._sample
|
||||
|
||||
|
||||
def get_available_placeholders(event, base_parameters):
|
||||
if 'order' in base_parameters:
|
||||
base_parameters.append('invoice_address')
|
||||
base_parameters.append('position_or_address')
|
||||
params = {}
|
||||
for r, val in register_mail_placeholders.send(sender=event):
|
||||
if not isinstance(val, (list, tuple)):
|
||||
val = [val]
|
||||
for v in val:
|
||||
if all(rp in base_parameters for rp in v.required_context):
|
||||
params[v.identifier] = v
|
||||
return params
|
||||
|
||||
|
||||
def get_email_context(**kwargs):
|
||||
return PlaceholderContext(**kwargs).render_all()
|
||||
from pretix.base.models import InvoiceAddress
|
||||
|
||||
event = kwargs['event']
|
||||
if 'position' in kwargs:
|
||||
kwargs.setdefault("position_or_address", kwargs['position'])
|
||||
if 'order' in kwargs:
|
||||
try:
|
||||
if not kwargs.get('invoice_address'):
|
||||
kwargs['invoice_address'] = kwargs['order'].invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
kwargs['invoice_address'] = InvoiceAddress(order=kwargs['order'])
|
||||
finally:
|
||||
kwargs.setdefault("position_or_address", kwargs['invoice_address'])
|
||||
ctx = {}
|
||||
for r, val in register_mail_placeholders.send(sender=event):
|
||||
if not isinstance(val, (list, tuple)):
|
||||
val = [val]
|
||||
for v in val:
|
||||
if all(rp in kwargs for rp in v.required_context):
|
||||
try:
|
||||
ctx[v.identifier] = v.render(kwargs)
|
||||
except:
|
||||
ctx[v.identifier] = '(error)'
|
||||
logger.exception(f'Failed to process email placeholder {v.identifier}.')
|
||||
return ctx
|
||||
|
||||
|
||||
def _placeholder_payments(order, payments):
|
||||
d = []
|
||||
for payment in payments:
|
||||
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
|
||||
d.append(str(payment.payment_provider.order_pending_mail_render(order, payment)))
|
||||
else:
|
||||
d.append(str(payment.payment_provider.order_pending_mail_render(order)))
|
||||
d = [line for line in d if line.strip()]
|
||||
if d:
|
||||
return '\n\n'.join(d)
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
def get_best_name(position_or_address, parts=False):
|
||||
"""
|
||||
Return the best name we got for either an invoice address or an order position, falling back to the respective other
|
||||
"""
|
||||
from pretix.base.models import InvoiceAddress, OrderPosition
|
||||
if isinstance(position_or_address, InvoiceAddress):
|
||||
if position_or_address.name:
|
||||
return position_or_address.name_parts if parts else position_or_address.name
|
||||
elif position_or_address.order:
|
||||
position_or_address = position_or_address.order.positions.exclude(attendee_name_cached="").exclude(attendee_name_cached__isnull=True).first()
|
||||
|
||||
if isinstance(position_or_address, OrderPosition):
|
||||
if position_or_address.attendee_name:
|
||||
return position_or_address.attendee_name_parts if parts else position_or_address.attendee_name
|
||||
elif position_or_address.order:
|
||||
try:
|
||||
return position_or_address.order.invoice_address.name_parts if parts else position_or_address.order.invoice_address.name
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
return {} if parts else ""
|
||||
|
||||
|
||||
@receiver(register_mail_placeholders, dispatch_uid="pretixbase_register_mail_placeholders")
|
||||
def base_placeholders(sender, **kwargs):
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
ph = [
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event', ['event'], lambda event: event.name, lambda event: event.name
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
|
||||
lambda event_or_subevent: event_or_subevent.name
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['order'], lambda order: order.code, 'F8VVL'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'total', ['order'], lambda order: LazyNumber(order.total), lambda event: LazyNumber(Decimal('42.23'))
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'order_email', ['order'], lambda order: order.email, 'john@example.org'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'invoice_number', ['invoice'],
|
||||
lambda invoice: invoice.full_invoice_no,
|
||||
f'{sender.settings.invoice_numbers_prefix or (sender.slug.upper() + "-")}00000'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'refund_amount', ['event_or_subevent', 'refund_amount'],
|
||||
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
|
||||
lambda event_or_subevent: LazyCurrencyNumber(Decimal('42.23'), event_or_subevent.currency)
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'pending_sum', ['event', 'pending_sum'],
|
||||
lambda event, pending_sum: LazyCurrencyNumber(pending_sum, event.currency),
|
||||
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
|
||||
event.currency),
|
||||
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'expire_date', ['event', 'order'], lambda event, order: LazyExpiresDate(order.expires.astimezone(event.timezone)),
|
||||
lambda event: LazyDate(now() + timedelta(days=15))
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.open', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
'hash': '98kusd8ofsj8dnkd'
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.modify', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.modify', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.change', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.change', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.cancel', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.cancel', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.position',
|
||||
kwargs={
|
||||
'order': position.order.code,
|
||||
'secret': position.web_secret,
|
||||
'position': position.positionid
|
||||
}
|
||||
),
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.position', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
'position': '123'
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'order_modification_deadline_date_and_time', ['order', 'event'],
|
||||
lambda order, event:
|
||||
date_format(order.modify_deadline.astimezone(event.timezone), 'SHORT_DATETIME_FORMAT')
|
||||
if order.modify_deadline
|
||||
else '',
|
||||
lambda event: date_format(
|
||||
event.settings.get(
|
||||
'last_order_modification_date', as_type=RelativeDateWrapper
|
||||
).datetime(event).astimezone(event.timezone),
|
||||
'SHORT_DATETIME_FORMAT'
|
||||
) if event.settings.get('last_order_modification_date') else '',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
|
||||
lambda event: str(event.location or ''),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_admission_time', ['event_or_subevent'],
|
||||
lambda event_or_subevent:
|
||||
date_format(event_or_subevent.date_admission.astimezone(event_or_subevent.timezone), 'TIME_FORMAT')
|
||||
if event_or_subevent.date_admission
|
||||
else '',
|
||||
lambda event: date_format(event.date_admission.astimezone(event.timezone), 'TIME_FORMAT') if event.date_admission else '',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'subevent', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
|
||||
lambda event: str(event if not event.has_subevents or not event.subevents.exists() else event.subevents.first())
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'subevent_date_from', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(),
|
||||
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_remove', ['waiting_list_voucher', 'event'],
|
||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
||||
event, 'presale:event.waitinglist.remove'
|
||||
) + '?voucher=' + waiting_list_voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.waitinglist.remove',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['waiting_list_voucher', 'event'],
|
||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + waiting_list_voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.redeem',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'invoice_name', ['invoice_address'], lambda invoice_address: invoice_address.name or '',
|
||||
_('John Doe')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
|
||||
_('Sample Corporation')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
order.full_code,
|
||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash(),
|
||||
}),
|
||||
)
|
||||
for order in orders
|
||||
), lambda event: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
'{}-{}'.format(event.slug.upper(), order['code']),
|
||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order['code'],
|
||||
'secret': order['secret'],
|
||||
'hash': order['hash'],
|
||||
}),
|
||||
)
|
||||
for order in [
|
||||
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': 'abcdefghi'},
|
||||
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd', 'hash': 'jklmnopqr'},
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
|
||||
]
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
|
||||
event.settings.waiting_list_hours,
|
||||
lambda event: event.settings.waiting_list_hours
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'product', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.item.name,
|
||||
_('Sample Admission Ticket')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
|
||||
'68CYU2H6ZTP3WLK5'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
||||
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_url_list', ['event', 'voucher_list'],
|
||||
lambda event, voucher_list: ' \n'.join([
|
||||
build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + c
|
||||
for c in voucher_list
|
||||
]),
|
||||
lambda event: ' \n'.join([
|
||||
build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + c
|
||||
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
||||
]),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
}), lambda event: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
})
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'name', ['name'], lambda name: name,
|
||||
_('John Doe')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'comment', ['comment'], lambda comment: comment,
|
||||
_('An individual text with a reason can be inserted here.'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'payment_info', ['order', 'payments'], _placeholder_payments,
|
||||
_('The amount has been charged to your card.'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'payment_info', ['payment_info'], lambda payment_info: payment_info,
|
||||
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'attendee_name', ['position'], lambda position: position.attendee_name,
|
||||
_('John Doe'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'positionid', ['position'], lambda position: str(position.positionid),
|
||||
'1'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'name', ['position_or_address'],
|
||||
get_best_name,
|
||||
_('John Doe'),
|
||||
),
|
||||
]
|
||||
|
||||
name_scheme = PERSON_NAME_SCHEMES[sender.settings.name_scheme]
|
||||
if "concatenation_for_salutation" in name_scheme:
|
||||
concatenation_for_salutation = name_scheme["concatenation_for_salutation"]
|
||||
else:
|
||||
concatenation_for_salutation = name_scheme["concatenation"]
|
||||
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
"name_for_salutation", ["waiting_list_entry"],
|
||||
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
|
||||
_("Mr Doe"),
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
"name", ["waiting_list_entry"],
|
||||
lambda waiting_list_entry: waiting_list_entry.name or "",
|
||||
_("Mr Doe"),
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
"name_for_salutation", ["position_or_address"],
|
||||
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
|
||||
_("Mr Doe"),
|
||||
))
|
||||
|
||||
for f, l, w in name_scheme['fields']:
|
||||
if f == 'full_name':
|
||||
continue
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'name_%s' % f, ['waiting_list_entry'], lambda waiting_list_entry, f=f: get_name_parts_localized(waiting_list_entry.name_parts, f),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'attendee_name_%s' % f, ['position'], lambda position, f=f: get_name_parts_localized(position.attendee_name_parts, f),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'name_%s' % f, ['position_or_address'],
|
||||
lambda position_or_address, f=f: get_name_parts_localized(get_best_name(position_or_address, parts=True), f),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
|
||||
for k, v in sender.meta_data.items():
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
||||
v
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
|
||||
v
|
||||
))
|
||||
|
||||
return ph
|
||||
|
||||
@@ -28,5 +28,4 @@ from .items import * # noqa
|
||||
from .json import * # noqa
|
||||
from .mail import * # noqa
|
||||
from .orderlist import * # noqa
|
||||
from .reusablemedia import * # noqa
|
||||
from .waitinglist import * # noqa
|
||||
|
||||
@@ -116,29 +116,15 @@ class DekodiNREIExporter(BaseExporter):
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
elif p.provider and p.provider.startswith('stripe'):
|
||||
pi = p.info_data or {}
|
||||
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 = {}
|
||||
|
||||
src = p.info_data.get("source", p.info_data)
|
||||
payments.append({
|
||||
'PTID': '81',
|
||||
'PTN': 'Stripe',
|
||||
'PTNo1': pi.get("id") or '',
|
||||
'PTNo5': card.get("last4", ""),
|
||||
'PTNo1': p.info_data.get("id") or '',
|
||||
'PTNo5': src.get("card", {}).get("last4") or '',
|
||||
'PTNo7': round(float(p.amount), 2) 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 '',
|
||||
})
|
||||
else:
|
||||
|
||||
@@ -86,7 +86,6 @@ class InvoiceExporterMixin:
|
||||
('', _('All payment providers')),
|
||||
] + [
|
||||
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
|
||||
if not v.is_meta
|
||||
],
|
||||
required=False,
|
||||
help_text=_('Only include invoices for orders that have at least one payment attempt '
|
||||
|
||||
@@ -209,7 +209,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
return qs.annotate(**annotations).filter(**filters)
|
||||
return qs
|
||||
|
||||
def orders_qs(self, form_data):
|
||||
def iterate_orders(self, form_data: dict):
|
||||
p_date = OrderPayment.objects.filter(
|
||||
order=OuterRef('pk'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
@@ -250,15 +250,11 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(status=Order.STATUS_PAID)
|
||||
return qs
|
||||
|
||||
def iterate_orders(self, form_data: dict):
|
||||
qs = self.orders_qs(form_data)
|
||||
tax_rates = self._get_all_tax_rates(qs)
|
||||
|
||||
headers = [
|
||||
_('Event slug'), _('Event name'), _('Order code'), _('Order total'), _('Status'), _('Email'),
|
||||
_('Phone number'), _('Order date'), _('Order time'), _('Company'), _('Name'),
|
||||
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Phone number'),
|
||||
_('Order date'), _('Order time'), _('Company'), _('Name'),
|
||||
]
|
||||
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:
|
||||
@@ -335,7 +331,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
str(self.event_object_cache[order.event_id].name),
|
||||
order.code,
|
||||
order.total,
|
||||
order.get_extended_status_display(),
|
||||
@@ -411,7 +406,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
row += self.event_object_cache[order.event_id].meta_data.values()
|
||||
yield row
|
||||
|
||||
def fees_qs(self, form_data):
|
||||
def iterate_fees(self, form_data: dict):
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
@@ -430,14 +425,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||
|
||||
qs = self._date_filter(qs, form_data, rel='order__')
|
||||
return qs
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
qs = self.fees_qs(form_data)
|
||||
|
||||
headers = [
|
||||
_('Event slug'),
|
||||
_('Event name'),
|
||||
_('Order code'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
@@ -474,7 +464,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
tz = ZoneInfo(order.event.settings.timezone)
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
str(self.event_object_cache[order.event_id].name),
|
||||
order.code,
|
||||
_("canceled") if op.canceled else order.get_extended_status_display(),
|
||||
order.email,
|
||||
@@ -517,19 +506,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
row += self.event_object_cache[order.event_id].meta_data.values()
|
||||
yield row
|
||||
|
||||
def positions_qs(self, form_data: dict):
|
||||
qs = OrderPosition.all.filter(
|
||||
order__event__in=self.events,
|
||||
)
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||
|
||||
qs = self._date_filter(qs, form_data, rel='order__')
|
||||
return qs
|
||||
|
||||
def iterate_positions(self, form_data: dict):
|
||||
base_qs = self.positions_qs(form_data)
|
||||
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
@@ -539,6 +516,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
base_qs = OrderPosition.all.filter(
|
||||
order__event__in=self.events,
|
||||
)
|
||||
qs = base_qs.annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
).select_related(
|
||||
@@ -548,12 +528,15 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
'subevent', 'subevent__meta_values',
|
||||
'answers', 'answers__question', 'answers__options'
|
||||
)
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||
|
||||
qs = self._date_filter(qs, form_data, rel='order__')
|
||||
|
||||
has_subevents = self.events.filter(has_subevents=True).exists()
|
||||
|
||||
headers = [
|
||||
_('Event slug'),
|
||||
_('Event name'),
|
||||
_('Order code'),
|
||||
_('Position ID'),
|
||||
_('Status'),
|
||||
@@ -655,7 +638,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone)
|
||||
row = [
|
||||
self.event_object_cache[order.event_id].slug,
|
||||
str(self.event_object_cache[order.event_id].name),
|
||||
order.code,
|
||||
op.positionid,
|
||||
_("canceled") if op.canceled else order.get_extended_status_display(),
|
||||
|
||||
@@ -1,78 +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 django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy
|
||||
|
||||
from ..exporter import ListExporter, OrganizerLevelExportMixin
|
||||
from ..models import ReusableMedium
|
||||
from ..signals import register_multievent_data_exporters
|
||||
|
||||
|
||||
class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'reusablemedia'
|
||||
verbose_name = _('Reusable media')
|
||||
category = pgettext_lazy('export_category', 'Reusable media')
|
||||
description = _('Download a spread sheet with the data of all reusable medias on your account.')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
media = ReusableMedium.objects.filter(
|
||||
organizer=self.organizer,
|
||||
).select_related(
|
||||
'customer', 'linked_orderposition', 'linked_giftcard',
|
||||
).order_by('created')
|
||||
|
||||
headers = [
|
||||
pgettext('reusable_medium', 'Media type'),
|
||||
pgettext('reusable_medium', 'Identifier'),
|
||||
_('Active'),
|
||||
_('Expiration date'),
|
||||
_('Customer account'),
|
||||
_('Linked ticket'),
|
||||
_('Linked gift card'),
|
||||
_('Notes'),
|
||||
]
|
||||
|
||||
yield headers
|
||||
yield self.ProgressSetTotal(total=media.count())
|
||||
|
||||
for medium in media.iterator(chunk_size=1000):
|
||||
row = [
|
||||
medium.type,
|
||||
medium.identifier,
|
||||
_('Yes') if medium.active else _('No'),
|
||||
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
|
||||
medium.customer.identifier if medium.customer_id else '',
|
||||
f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '',
|
||||
medium.linked_giftcard.secret if medium.linked_giftcard_id else '',
|
||||
medium.notes,
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return f'{self.organizer.slug}_media'
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_reusablemedia")
|
||||
def register_multievent_i_reusable_media_exporter(sender, **kwargs):
|
||||
return ReusableMediaExporter
|
||||
@@ -35,7 +35,6 @@
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
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.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.metrics import pretix_failed_logins
|
||||
from pretix.base.models import User
|
||||
from pretix.helpers.dicts import move_to_end
|
||||
from pretix.helpers.http import get_client_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
"""
|
||||
@@ -59,7 +55,6 @@ class LoginForm(forms.Form):
|
||||
username/password logins.
|
||||
"""
|
||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||
origin = forms.CharField(widget=forms.HiddenInput, required=False)
|
||||
|
||||
error_messages = {
|
||||
'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")
|
||||
cnt = rc.get(self.ratelimit_key)
|
||||
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')
|
||||
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
||||
if self.user_cache is None:
|
||||
if self.ratelimit_key:
|
||||
rc.incr(self.ratelimit_key)
|
||||
rc.expire(self.ratelimit_key, 300)
|
||||
logger.info("Backend login invalid.")
|
||||
pretix_failed_logins.inc(1, reason="invalid")
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['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 not user.is_active:
|
||||
logger.info("Backend login rejected due to user inactive.")
|
||||
pretix_failed_logins.inc(1, reason="inactive")
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['inactive'],
|
||||
code='inactive',
|
||||
|
||||
@@ -125,7 +125,7 @@ class NamePartsWidget(forms.MultiWidget):
|
||||
if fname == 'title' and self.titles:
|
||||
widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]]))
|
||||
elif fname == 'salutation':
|
||||
widgets.append(Select(attrs=a, choices=[('', '---'), ('empty', '')] + PERSON_NAME_SALUTATIONS))
|
||||
widgets.append(Select(attrs=a, choices=[('', '---')] + PERSON_NAME_SALUTATIONS))
|
||||
else:
|
||||
widgets.append(self.widget(attrs=a))
|
||||
super().__init__(widgets, attrs)
|
||||
@@ -136,10 +136,7 @@ class NamePartsWidget(forms.MultiWidget):
|
||||
data = []
|
||||
for i, field in enumerate(self.scheme['fields']):
|
||||
fname, label, size = field
|
||||
fval = value.get(fname, "")
|
||||
if fname == "salutation" and fname in value and fval == "":
|
||||
fval = "empty"
|
||||
data.append(fval)
|
||||
data.append(value.get(fname, ""))
|
||||
if '_legacy' in value and not data[-1]:
|
||||
data[-1] = value.get('_legacy', '')
|
||||
elif not any(d for d in data) and '_scheme' in value:
|
||||
@@ -193,8 +190,7 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
data = {}
|
||||
data['_scheme'] = self.scheme_name
|
||||
for i, value in enumerate(data_list):
|
||||
key = self.scheme['fields'][i][0]
|
||||
data[key] = value or ''
|
||||
data[self.scheme['fields'][i][0]] = value or ''
|
||||
return data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -243,7 +239,7 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
d.pop('validators', None)
|
||||
field = forms.ChoiceField(
|
||||
**d,
|
||||
choices=[('', '---'), ('empty', '')] + PERSON_NAME_SALUTATIONS
|
||||
choices=[('', '---')] + PERSON_NAME_SALUTATIONS
|
||||
)
|
||||
else:
|
||||
field = forms.CharField(**defaults)
|
||||
@@ -269,9 +265,6 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
if sum(len(v) for v in value.values() if v) > 250:
|
||||
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
|
||||
|
||||
if value.get("salutation") == "empty":
|
||||
value["salutation"] = ""
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@@ -609,38 +602,27 @@ class BaseQuestionsForm(forms.Form):
|
||||
max_date = now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
||||
else:
|
||||
max_date = None
|
||||
min_date = 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 >= 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:
|
||||
attrs = {}
|
||||
if max_date:
|
||||
attrs['data-max'] = max_date.date().isoformat()
|
||||
if min_date:
|
||||
attrs['data-min'] = min_date.date().isoformat()
|
||||
self.fields['requested_valid_from'] = forms.DateField(
|
||||
label=_('Start date'),
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=False,
|
||||
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:
|
||||
self.fields['requested_valid_from'] = forms.SplitDateTimeField(
|
||||
label=_('Start date'),
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=False,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=min_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 = {}
|
||||
|
||||
63
src/pretix/base/forms/renderers.py
Normal file
63
src/pretix/base/forms/renderers.py
Normal file
@@ -0,0 +1,63 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from bootstrap3.renderers import (
|
||||
FieldRenderer as BaseFieldRenderer,
|
||||
InlineFieldRenderer as BaseInlineFieldRenderer,
|
||||
)
|
||||
from django.forms import (
|
||||
CheckboxInput, CheckboxSelectMultiple, ClearableFileInput, RadioSelect,
|
||||
SelectDateWidget,
|
||||
)
|
||||
|
||||
|
||||
class FieldRenderer(BaseFieldRenderer):
|
||||
# Local application of https://github.com/zostera/django-bootstrap3/pull/859
|
||||
|
||||
def post_widget_render(self, html):
|
||||
if isinstance(self.widget, CheckboxSelectMultiple):
|
||||
html = self.list_to_class(html, "checkbox")
|
||||
elif isinstance(self.widget, RadioSelect):
|
||||
html = self.list_to_class(html, "radio")
|
||||
elif isinstance(self.widget, SelectDateWidget):
|
||||
html = self.fix_date_select_input(html)
|
||||
elif isinstance(self.widget, ClearableFileInput):
|
||||
html = self.fix_clearable_file_input(html)
|
||||
elif isinstance(self.widget, CheckboxInput):
|
||||
html = self.put_inside_label(html)
|
||||
return html
|
||||
|
||||
|
||||
class InlineFieldRenderer(BaseInlineFieldRenderer):
|
||||
# Local application of https://github.com/zostera/django-bootstrap3/pull/859
|
||||
|
||||
def post_widget_render(self, html):
|
||||
if isinstance(self.widget, CheckboxSelectMultiple):
|
||||
html = self.list_to_class(html, "checkbox")
|
||||
elif isinstance(self.widget, RadioSelect):
|
||||
html = self.list_to_class(html, "radio")
|
||||
elif isinstance(self.widget, SelectDateWidget):
|
||||
html = self.fix_date_select_input(html)
|
||||
elif isinstance(self.widget, ClearableFileInput):
|
||||
html = self.fix_clearable_file_input(html)
|
||||
elif isinstance(self.widget, CheckboxInput):
|
||||
html = self.put_inside_label(html)
|
||||
return html
|
||||
@@ -33,7 +33,7 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from datetime import date
|
||||
|
||||
from django import forms
|
||||
from django.utils.formats import get_format
|
||||
@@ -188,11 +188,11 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
time_attrs['autocomplete'] = 'off'
|
||||
if min_date:
|
||||
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()
|
||||
if max_date:
|
||||
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()
|
||||
|
||||
def date_placeholder():
|
||||
@@ -209,10 +209,7 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
|
||||
date_attrs['placeholder'] = lazy(date_placeholder, str)
|
||||
time_attrs['placeholder'] = lazy(time_placeholder, str)
|
||||
date_attrs['aria-label'] = _('Date')
|
||||
time_attrs['aria-label'] = _('Time')
|
||||
if 'aria-label' in attrs:
|
||||
del attrs['aria-label']
|
||||
|
||||
widgets = (
|
||||
forms.DateInput(attrs=date_attrs, format=date_format),
|
||||
forms.TimeInput(attrs=time_attrs, format=time_format),
|
||||
|
||||
@@ -182,7 +182,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
|
||||
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:
|
||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
||||
self.font_regular = family
|
||||
@@ -625,7 +625,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
)]
|
||||
else:
|
||||
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', 'Amount')), self.stylesheet['BoldRightNoSplit']),
|
||||
)]
|
||||
@@ -855,7 +855,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
identifier = 'modern1'
|
||||
verbose_name = gettext_lazy('Default invoice renderer (European-style letter)')
|
||||
verbose_name = gettext_lazy('Modern Invoice Renderer (pretix 2.7)')
|
||||
bottom_margin = 16.9 * mm
|
||||
top_margin = 16.9 * mm
|
||||
right_margin = 20 * mm
|
||||
@@ -989,37 +989,6 @@ class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
canvas.drawText(textobject)
|
||||
|
||||
|
||||
class Modern1SimplifiedRenderer(Modern1Renderer):
|
||||
identifier = 'modern1simplified'
|
||||
verbose_name = gettext_lazy('Simplified invoice renderer')
|
||||
|
||||
logo_left = Modern1Renderer.left_margin
|
||||
logo_width = pagesizes.A4[0] - Modern1Renderer.right_margin - logo_left
|
||||
logo_height = 25 * mm
|
||||
logo_top = 13 * mm
|
||||
logo_anchor = 'nw'
|
||||
|
||||
def _draw_invoice_from(self, canvas):
|
||||
super(Modern1Renderer, self)._draw_invoice_from(canvas)
|
||||
|
||||
def _draw_event(self, canvas):
|
||||
pass
|
||||
|
||||
def _get_intro(self):
|
||||
i = []
|
||||
|
||||
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
|
||||
i.append(Paragraph(
|
||||
pgettext('invoice', 'Event date: {date_range}').format(
|
||||
date_range=self.invoice.event.get_date_range_display(),
|
||||
),
|
||||
self.stylesheet['Normal'],
|
||||
))
|
||||
i.append(Spacer(2 * mm, 2 * mm))
|
||||
|
||||
return i + super()._get_intro()
|
||||
|
||||
|
||||
@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic")
|
||||
def recv_classic(sender, **kwargs):
|
||||
return [ClassicInvoiceRenderer, Modern1Renderer, Modern1SimplifiedRenderer]
|
||||
return [ClassicInvoiceRenderer, Modern1Renderer]
|
||||
|
||||
@@ -268,10 +268,7 @@ def metric_values():
|
||||
dkey = key.decode("utf-8")
|
||||
splitted = dkey.split("{", 2)
|
||||
value = float(value.decode("utf-8"))
|
||||
if len(splitted) == 1:
|
||||
metrics[splitted[0]][""] = value
|
||||
else:
|
||||
metrics[splitted[0]]["{" + splitted[1]] = value
|
||||
metrics[splitted[0]]["{" + splitted[1]] = value
|
||||
|
||||
# Aliases
|
||||
aliases = {
|
||||
@@ -317,5 +314,3 @@ pretix_task_runs_total = Counter("pretix_task_runs_total", "Total calls to a cel
|
||||
["task_name", "status"])
|
||||
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
|
||||
["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/>.
|
||||
#
|
||||
from collections import OrderedDict
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
from urllib.parse import urlsplit
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
@@ -40,30 +40,10 @@ from pretix.base.settings import global_settings_object
|
||||
from pretix.multidomain.urlreverse import (
|
||||
get_event_domain, get_organizer_domain,
|
||||
)
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
_supported = None
|
||||
|
||||
|
||||
def get_supported_language(requested_language, allowed_languages, default_language):
|
||||
language = requested_language
|
||||
if language not in allowed_languages:
|
||||
firstpart = language.split('-')[0]
|
||||
if firstpart in allowed_languages:
|
||||
language = firstpart
|
||||
else:
|
||||
language = default_language
|
||||
for lang in allowed_languages:
|
||||
if lang.startswith(firstpart + '-'):
|
||||
language = lang
|
||||
break
|
||||
if language not in allowed_languages:
|
||||
# This seems redundant, but can happen in the rare edge case that settings.locale is (wrongfully)
|
||||
# not part of settings.locales
|
||||
language = allowed_languages[0]
|
||||
return language
|
||||
|
||||
|
||||
class LocaleMiddleware(MiddlewareMixin):
|
||||
|
||||
"""
|
||||
@@ -85,11 +65,20 @@ class LocaleMiddleware(MiddlewareMixin):
|
||||
settings_holder = None
|
||||
|
||||
if settings_holder:
|
||||
language = get_supported_language(
|
||||
language,
|
||||
settings_holder.settings.locales,
|
||||
settings_holder.settings.locale,
|
||||
)
|
||||
if language not in settings_holder.settings.locales:
|
||||
firstpart = language.split('-')[0]
|
||||
if firstpart in settings_holder.settings.locales:
|
||||
language = firstpart
|
||||
else:
|
||||
language = settings_holder.settings.locale
|
||||
for lang in settings_holder.settings.locales:
|
||||
if lang.startswith(firstpart + '-'):
|
||||
language = lang
|
||||
break
|
||||
if language not in settings_holder.settings.locales:
|
||||
# This seems redundant, but can happen in the rare edge case that settings.locale is (wrongfully)
|
||||
# not part of settings.locales
|
||||
language = settings_holder.settings.locales[0]
|
||||
if '-' not in language and settings_holder.settings.region:
|
||||
language += '-' + settings_holder.settings.region
|
||||
else:
|
||||
@@ -241,14 +230,6 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
if settings.DEBUG and resp.status_code >= 400:
|
||||
@@ -268,14 +249,6 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
if gs.settings.leaflet_tiles:
|
||||
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 = {
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}'],
|
||||
@@ -284,7 +257,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}"],
|
||||
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
||||
'font-src': ["{static}"] + list(font_src),
|
||||
'font-src': ["{static}"],
|
||||
'media-src': ["{static}", "data:"],
|
||||
# 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
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Generated by Django 4.2.4 on 2023-12-06 14:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0253_checkin_info"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="organizer_link",
|
||||
field=models.ForeignKey(
|
||||
db_column="organizer_link_id",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="pretixbase.organizer",
|
||||
),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="logentry",
|
||||
old_name="organizer_link",
|
||||
new_name="organizer",
|
||||
),
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 4.2.4 on 2023-11-22 20:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0254_alter_logentry_organizer_link_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="item",
|
||||
name="available_from_mode",
|
||||
field=models.CharField(default="hide", max_length=16),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="item",
|
||||
name="available_until_mode",
|
||||
field=models.CharField(default="hide", max_length=16),
|
||||
)
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 4.2.4 on 2024-01-11 15:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0255_item_unavail_modes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="itemvariation",
|
||||
name="available_from_mode",
|
||||
field=models.CharField(default="hide", max_length=16),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="itemvariation",
|
||||
name="available_until_mode",
|
||||
field=models.CharField(default="hide", max_length=16),
|
||||
),
|
||||
]
|
||||
@@ -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,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,378 +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')
|
||||
initial = "static:1"
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
("1", "1")
|
||||
]
|
||||
|
||||
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')
|
||||
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 any((v and v == value) for v in i18n_flat(q.name))
|
||||
]
|
||||
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
|
||||
from datetime import timedelta
|
||||
from functools import reduce
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import webauthn
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser, BaseUserManager, PermissionsMixin,
|
||||
@@ -51,13 +53,13 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
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.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
|
||||
|
||||
|
||||
@@ -580,15 +582,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
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):
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
date_start = models.DateTimeField(auto_now_add=True)
|
||||
@@ -615,12 +608,7 @@ class U2FDevice(Device):
|
||||
json_data = models.TextField()
|
||||
|
||||
@property
|
||||
def webauthndevice(self):
|
||||
d = json.loads(self.json_data)
|
||||
return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle']))
|
||||
|
||||
@property
|
||||
def webauthnpubkey(self):
|
||||
def webauthnuser(self):
|
||||
d = json.loads(self.json_data)
|
||||
# We manually need to convert the pubkey from DER format (used in our
|
||||
# former U2F implementation) to the format required by webauthn. This
|
||||
@@ -632,7 +620,16 @@ class U2FDevice(Device):
|
||||
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):
|
||||
@@ -644,9 +641,14 @@ class WebAuthnDevice(Device):
|
||||
sign_count = models.IntegerField(default=0)
|
||||
|
||||
@property
|
||||
def webauthndevice(self):
|
||||
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id))
|
||||
|
||||
@property
|
||||
def webauthnpubkey(self):
|
||||
return websafe_decode(self.pub_key)
|
||||
def webauthnuser(self):
|
||||
return webauthn.WebAuthnUser(
|
||||
self.ukey,
|
||||
self.user.email,
|
||||
str(self.user),
|
||||
settings.SITE_URL,
|
||||
self.credential_id,
|
||||
self.pub_key,
|
||||
self.sign_count,
|
||||
urlparse(settings.SITE_URL).netloc
|
||||
)
|
||||
|
||||
@@ -115,7 +115,7 @@ class LoggingMixin:
|
||||
kwargs['api_token'] = api_token
|
||||
|
||||
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event,
|
||||
organizer_id=organizer_id, **kwargs)
|
||||
organizer_link_id=organizer_id, **kwargs)
|
||||
if isinstance(data, dict):
|
||||
sensitivekeys = ['password', 'secret', 'api_key']
|
||||
|
||||
|
||||
@@ -280,8 +280,7 @@ class CheckinList(LoggedModel):
|
||||
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
|
||||
}
|
||||
allowed_operators = top_level_operators | {
|
||||
'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before', 'entries_days_since',
|
||||
'entries_days_before',
|
||||
'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'
|
||||
}
|
||||
allowed_vars = {
|
||||
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
|
||||
@@ -310,7 +309,7 @@ class CheckinList(LoggedModel):
|
||||
raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.')
|
||||
return rules
|
||||
|
||||
if operator in ('entries_since', 'entries_before', 'entries_days_since', 'entries_days_before'):
|
||||
if operator in ('entries_since', 'entries_before'):
|
||||
if len(values) != 1 or "buildTime" not in values[0]:
|
||||
raise ValidationError(f'Operator "{operator}" takes exactly one "buildTime" argument.')
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ class Discount(LoggedModel):
|
||||
|
||||
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
|
||||
def key(idx):
|
||||
return positions[idx][1] or 0 # subevent_id
|
||||
return positions[idx][1] # subevent_id
|
||||
|
||||
# Build groups of candidates with the same subevent, then apply our regular algorithm
|
||||
# to each group
|
||||
|
||||
@@ -229,14 +229,6 @@ class EventMixin:
|
||||
else:
|
||||
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) > now()
|
||||
return True
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
"""
|
||||
@@ -273,9 +265,6 @@ class EventMixin:
|
||||
|
||||
@property
|
||||
def event_microdata(self):
|
||||
if self.settings.event_microdata:
|
||||
return self.settings.event_microdata
|
||||
|
||||
import json
|
||||
|
||||
eventdict = {
|
||||
@@ -786,7 +775,7 @@ class Event(EventMixin, LoggedModel):
|
||||
time(hour=23, minute=59, second=59)
|
||||
), tz)
|
||||
|
||||
def copy_data_from(self, other, skip_meta_data=False):
|
||||
def copy_data_from(self, other):
|
||||
from pretix.presale.style import regenerate_css
|
||||
|
||||
from ..signals import event_copy_data
|
||||
@@ -809,11 +798,10 @@ class Event(EventMixin, LoggedModel):
|
||||
self.save()
|
||||
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
|
||||
|
||||
if not skip_meta_data:
|
||||
for emv in EventMetaValue.objects.filter(event=other):
|
||||
emv.pk = None
|
||||
emv.event = self
|
||||
emv.save(force_insert=True)
|
||||
for emv in EventMetaValue.objects.filter(event=other):
|
||||
emv.pk = None
|
||||
emv.event = self
|
||||
emv.save(force_insert=True)
|
||||
|
||||
for fl in EventFooterLink.objects.filter(event=other):
|
||||
fl.pk = None
|
||||
@@ -1075,7 +1063,7 @@ class Event(EventMixin, LoggedModel):
|
||||
providers[pp.identifier] = pp
|
||||
|
||||
self._cached_payment_providers = OrderedDict(sorted(
|
||||
providers.items(), key=lambda v: (-v[1].priority, str(v[1].verbose_name).title())
|
||||
providers.items(), key=lambda v: (-v[1].priority, str(v[1].verbose_name))
|
||||
))
|
||||
return self._cached_payment_providers
|
||||
|
||||
|
||||
@@ -263,8 +263,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
|
||||
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||
# in models/event.py: EventMixin.annotated()
|
||||
Q(active=True)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()) | Q(available_from_mode='info'))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()) | Q(available_until_mode='info'))
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
|
||||
)
|
||||
if not allow_addons:
|
||||
@@ -374,13 +374,6 @@ class Item(LoggedModel):
|
||||
(VALIDITY_MODE_DYNAMIC, _('Dynamic validity')),
|
||||
)
|
||||
|
||||
UNAVAIL_MODE_HIDDEN = "hide"
|
||||
UNAVAIL_MODE_INFO = "info"
|
||||
UNAVAIL_MODES = (
|
||||
(UNAVAIL_MODE_HIDDEN, _("Hide product if unavailable")),
|
||||
(UNAVAIL_MODE_INFO, _("Show info text if unavailable")),
|
||||
)
|
||||
|
||||
MEDIA_POLICY_REUSE = 'reuse'
|
||||
MEDIA_POLICY_NEW = 'new'
|
||||
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
|
||||
@@ -430,7 +423,7 @@ class Item(LoggedModel):
|
||||
help_text=_("If this product has multiple variations, you can set different prices for each of the "
|
||||
"variations. If a variation does not have a special price or if you do not have variations, "
|
||||
"this price will be used."),
|
||||
max_digits=13, decimal_places=2,
|
||||
max_digits=13, decimal_places=2, null=True
|
||||
)
|
||||
free_price = models.BooleanField(
|
||||
default=False,
|
||||
@@ -494,21 +487,11 @@ class Item(LoggedModel):
|
||||
null=True, blank=True,
|
||||
help_text=_('This product will not be sold before the given date.')
|
||||
)
|
||||
available_from_mode = models.CharField(
|
||||
choices=UNAVAIL_MODES,
|
||||
default=UNAVAIL_MODE_HIDDEN,
|
||||
max_length=16,
|
||||
)
|
||||
available_until = models.DateTimeField(
|
||||
verbose_name=_("Available until"),
|
||||
null=True, blank=True,
|
||||
help_text=_('This product will not be sold after the given date.')
|
||||
)
|
||||
available_until_mode = models.CharField(
|
||||
choices=UNAVAIL_MODES,
|
||||
default=UNAVAIL_MODE_HIDDEN,
|
||||
max_length=16,
|
||||
)
|
||||
hidden_if_available = models.ForeignKey(
|
||||
'Quota',
|
||||
null=True, blank=True,
|
||||
@@ -648,7 +631,7 @@ class Item(LoggedModel):
|
||||
null=True, blank=True, max_length=16,
|
||||
verbose_name=_('Validity'),
|
||||
help_text=_(
|
||||
'When setting up a regular event, or an event series with time slots, you typically do NOT need to change '
|
||||
'When setting up a regular event, or an event series with time slots, you typically to NOT need to change '
|
||||
'this value. The default setting means that the validity time of tickets will not be decided by the '
|
||||
'product, but by the event and check-in configuration. Only use the other options if you need them to '
|
||||
'realize e.g. a booking of a year-long ticket with a dynamic start date. Note that the validity will be '
|
||||
@@ -720,8 +703,6 @@ class Item(LoggedModel):
|
||||
return str(self.internal_name or self.name)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.hide_without_voucher:
|
||||
self.require_voucher = True
|
||||
super().save(*args, **kwargs)
|
||||
if self.event:
|
||||
self.event.cache.clear()
|
||||
@@ -799,24 +780,6 @@ class Item(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
||||
now_dt = now_dt or now()
|
||||
subevent_item = subevent and subevent.item_overrides.get(self.pk)
|
||||
if not self.active:
|
||||
return 'active'
|
||||
elif self.available_from and self.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif self.available_until and self.available_until < now_dt:
|
||||
return 'available_until'
|
||||
elif (self.require_voucher or self.hide_without_voucher) and not has_voucher:
|
||||
return 'require_voucher'
|
||||
elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt:
|
||||
return 'available_until'
|
||||
else:
|
||||
return None
|
||||
|
||||
def _get_quotas(self, ignored_quotas=None, subevent=None):
|
||||
check_quotas = set(getattr(
|
||||
self, '_subevent_quotas', # Utilize cache in product list
|
||||
@@ -1115,21 +1078,11 @@ class ItemVariation(models.Model):
|
||||
null=True, blank=True,
|
||||
help_text=_('This variation will not be sold before the given date.')
|
||||
)
|
||||
available_from_mode = models.CharField(
|
||||
choices=Item.UNAVAIL_MODES,
|
||||
default=Item.UNAVAIL_MODE_HIDDEN,
|
||||
max_length=16,
|
||||
)
|
||||
available_until = models.DateTimeField(
|
||||
verbose_name=_("Available until"),
|
||||
null=True, blank=True,
|
||||
help_text=_('This variation will not be sold after the given date.')
|
||||
)
|
||||
available_until_mode = models.CharField(
|
||||
choices=Item.UNAVAIL_MODES,
|
||||
default=Item.UNAVAIL_MODE_HIDDEN,
|
||||
max_length=16,
|
||||
)
|
||||
sales_channels = fields.MultiStringField(
|
||||
verbose_name=_('Sales channels'),
|
||||
default=_all_sales_channels_identifiers,
|
||||
@@ -1307,22 +1260,6 @@ class ItemVariation(models.Model):
|
||||
return False
|
||||
return True
|
||||
|
||||
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
||||
now_dt = now_dt or now()
|
||||
subevent_var = subevent and subevent.var_overrides.get(self.pk)
|
||||
if not self.active:
|
||||
return 'active'
|
||||
elif self.available_from and self.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif self.available_until and self.available_until < now_dt:
|
||||
return 'available_until'
|
||||
elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt:
|
||||
return 'available_until'
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def meta_data(self):
|
||||
data = self.item.meta_data
|
||||
|
||||
@@ -78,7 +78,7 @@ class LogEntry(models.Model):
|
||||
device = models.ForeignKey('Device', null=True, blank=True, on_delete=models.PROTECT)
|
||||
oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
organizer = models.ForeignKey('Organizer', null=True, blank=True, on_delete=models.PROTECT, db_column='organizer_link_id')
|
||||
organizer_link = models.ForeignKey('Organizer', null=True, blank=True, on_delete=models.PROTECT)
|
||||
action_type = models.CharField(max_length=255)
|
||||
data = models.TextField(default='{}')
|
||||
visible = models.BooleanField(default=True)
|
||||
@@ -123,6 +123,22 @@ class LogEntry(models.Model):
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
return no_type
|
||||
|
||||
@cached_property
|
||||
def organizer(self):
|
||||
from .organizer import Organizer
|
||||
|
||||
if self.organizer_link:
|
||||
return self.organizer_link
|
||||
elif self.event:
|
||||
return self.event.organizer
|
||||
elif hasattr(self.content_object, 'event'):
|
||||
return self.content_object.event.organizer
|
||||
elif hasattr(self.content_object, 'organizer'):
|
||||
return self.content_object.organizer
|
||||
elif isinstance(self.content_object, Organizer):
|
||||
return self.content_object
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def display_object(self):
|
||||
from . import (
|
||||
|
||||
@@ -122,6 +122,7 @@ class ReusableMedium(LoggedModel):
|
||||
class Meta:
|
||||
unique_together = (("identifier", "type", "organizer"),)
|
||||
indexes = [
|
||||
models.Index(fields=("identifier", "type", "organizer")),
|
||||
models.Index(fields=("updated", "id")),
|
||||
]
|
||||
ordering = "identifier", "type", "organizer"
|
||||
|
||||
@@ -49,8 +49,7 @@ class MembershipType(LoggedModel):
|
||||
allow_parallel_usage = models.BooleanField(
|
||||
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 '
|
||||
'that this will only check for an identical start time of the events, not for any overlap between events. An overlap '
|
||||
'check will be performed if there is a product-level validity of the ticket.'),
|
||||
'that this will only check for an identical start time of the events, not for any overlap between events.'),
|
||||
default=False
|
||||
)
|
||||
max_usages = models.PositiveIntegerField(
|
||||
@@ -163,12 +162,8 @@ class Membership(models.Model):
|
||||
def attendee_name(self):
|
||||
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):
|
||||
if valid_from_not_chosen:
|
||||
return not self.canceled and self.date_end >= now()
|
||||
elif ticket_valid_from:
|
||||
dt = ticket_valid_from
|
||||
elif ev:
|
||||
def is_valid(self, ev=None):
|
||||
if ev:
|
||||
dt = ev.date_from
|
||||
else:
|
||||
dt = now()
|
||||
|
||||
@@ -44,7 +44,7 @@ from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from functools import reduce
|
||||
from time import sleep
|
||||
from typing import Any, Dict, Iterable, List, Union
|
||||
from typing import Any, Dict, List, Union
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import dateutil
|
||||
@@ -79,7 +79,7 @@ from pretix.base.i18n import language
|
||||
from pretix.base.models import Customer, User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
|
||||
from pretix.base.signals import order_gracefully_delete
|
||||
|
||||
from ...helpers import OF_SELF
|
||||
from ...helpers.countries import CachedCountries, FastCountryField
|
||||
@@ -188,14 +188,6 @@ class Order(LockModel, LoggedModel):
|
||||
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,
|
||||
verbose_name=_("Event"),
|
||||
@@ -294,9 +286,6 @@ class Order(LockModel, LoggedModel):
|
||||
models.Index(fields=["datetime", "id"]),
|
||||
models.Index(fields=["last_modified", "id"]),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.full_code
|
||||
@@ -462,9 +451,9 @@ class Order(LockModel, LoggedModel):
|
||||
if results:
|
||||
qs = qs.annotate(
|
||||
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)),
|
||||
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)),
|
||||
default=Value(0),
|
||||
output_field=models.IntegerField()
|
||||
@@ -479,7 +468,7 @@ class Order(LockModel, LoggedModel):
|
||||
is_underpaid=Case(
|
||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
|
||||
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)),
|
||||
default=Value(0),
|
||||
output_field=models.IntegerField()
|
||||
@@ -510,10 +499,6 @@ class Order(LockModel, LoggedModel):
|
||||
self.set_expires()
|
||||
if 'update_fields' in kwargs:
|
||||
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
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
@@ -1105,6 +1090,9 @@ class Order(LockModel, LoggedModel):
|
||||
if not self.email and not (position and position.attendee_email):
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale, self.event.settings.region):
|
||||
recipient = self.email
|
||||
if position and position.attendee_email:
|
||||
@@ -1149,19 +1137,12 @@ class Order(LockModel, LoggedModel):
|
||||
attach_tickets=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def positions_with_tickets_ignoring_plugins(self):
|
||||
return (op for op in self.positions.select_related('item') if op.generate_ticket)
|
||||
|
||||
@property
|
||||
def positions_with_tickets(self):
|
||||
signal_response = allow_ticket_download.send(self.event, order=self)
|
||||
if all([r is True for rr, r in signal_response]):
|
||||
return self.positions_with_tickets_ignoring_plugins
|
||||
elif any([r is False for rr, r in signal_response]):
|
||||
return []
|
||||
else:
|
||||
return set.intersection(set(self.positions_with_tickets_ignoring_plugins), *[set(r) for rr, r in signal_response if isinstance(r, Iterable)])
|
||||
for op in self.positions.select_related('item'):
|
||||
if not op.generate_ticket:
|
||||
continue
|
||||
yield op
|
||||
|
||||
def create_transactions(self, is_new=False, positions=None, fees=None, dt_now=None, migrated=False,
|
||||
_backfill_before_cancellation=False, save=True):
|
||||
@@ -2162,12 +2143,6 @@ class OrderRefund(models.Model):
|
||||
self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'local_id'}.union(kwargs['update_fields'])
|
||||
|
||||
if self.state == OrderRefund.REFUND_STATE_DONE and not self.execution_date:
|
||||
self.execution_date = now()
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'execution_date'}.union(kwargs['update_fields'])
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -2371,14 +2346,6 @@ class OrderPosition(AbstractPosition):
|
||||
"""
|
||||
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,
|
||||
verbose_name=_("Order"),
|
||||
@@ -2452,9 +2419,6 @@ class OrderPosition(AbstractPosition):
|
||||
verbose_name = _("Order position")
|
||||
verbose_name_plural = _("Order positions")
|
||||
ordering = ("positionid", "id")
|
||||
constraints = [
|
||||
models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq")
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def sort_key(self):
|
||||
@@ -2524,8 +2488,7 @@ class OrderPosition(AbstractPosition):
|
||||
op = OrderPosition(order=order)
|
||||
for f in AbstractPosition._meta.fields:
|
||||
if f.name == 'addon_to':
|
||||
if cartpos.addon_to_id:
|
||||
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
|
||||
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
|
||||
else:
|
||||
setattr(op, f.name, getattr(cartpos, f.name))
|
||||
op._calculate_tax()
|
||||
@@ -2545,9 +2508,6 @@ class OrderPosition(AbstractPosition):
|
||||
op.valid_from = valid_from
|
||||
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.save()
|
||||
ops.append(op)
|
||||
@@ -2582,10 +2542,10 @@ class OrderPosition(AbstractPosition):
|
||||
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
|
||||
try:
|
||||
ia = invoice_address or self.order.invoice_address
|
||||
ia = self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
if self.tax_rule:
|
||||
@@ -2615,10 +2575,6 @@ class OrderPosition(AbstractPosition):
|
||||
if 'update_fields' in kwargs:
|
||||
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:
|
||||
self.blocked = None
|
||||
if 'update_fields' in kwargs:
|
||||
@@ -2688,6 +2644,9 @@ class OrderPosition(AbstractPosition):
|
||||
if not self.attendee_email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.order.locale, self.order.event.settings.region):
|
||||
recipient = self.attendee_email
|
||||
try:
|
||||
@@ -2966,14 +2925,6 @@ class CartPosition(AbstractPosition):
|
||||
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
|
||||
def tax_value(self):
|
||||
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
|
||||
|
||||
@@ -263,12 +263,6 @@ class Team(LoggedModel):
|
||||
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)"))
|
||||
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(
|
||||
default=False,
|
||||
|
||||
@@ -251,8 +251,7 @@ class Voucher(LoggedModel):
|
||||
null=True, blank=True,
|
||||
on_delete=models.PROTECT, # We use a fake version of SET_NULL in Item.delete()
|
||||
help_text=_(
|
||||
"This product is added to the user's cart if the voucher is redeemed. Instead of a specific product, you "
|
||||
"can also select a quota. In this case, all products assigned to this quota can be selected."
|
||||
"This product is added to the user's cart if the voucher is redeemed."
|
||||
)
|
||||
)
|
||||
variation = models.ForeignKey(
|
||||
@@ -351,6 +350,9 @@ class Voucher(LoggedModel):
|
||||
'variations.'))
|
||||
if variation and not item.variations.filter(pk=variation.pk).exists():
|
||||
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:
|
||||
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
|
||||
elif block_quota:
|
||||
@@ -428,15 +430,7 @@ class Voucher(LoggedModel):
|
||||
elif old_instance.variation:
|
||||
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
|
||||
elif old_instance.item:
|
||||
if old_instance.item.has_variations:
|
||||
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))
|
||||
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
|
||||
return quotas
|
||||
|
||||
@staticmethod
|
||||
@@ -451,19 +445,13 @@ class Voucher(LoggedModel):
|
||||
|
||||
if 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:
|
||||
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
|
||||
elif item and not item.has_variations:
|
||||
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=old_instance.item,
|
||||
quota__subevent=data.get('subevent'),
|
||||
).values('quota_id')
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
|
||||
'tickets.'))
|
||||
@@ -517,6 +505,9 @@ class Voucher(LoggedModel):
|
||||
if item and seat.product != item:
|
||||
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
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -259,6 +259,9 @@ class WaitingListEntry(LoggedModel):
|
||||
if not self.email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale, self.event.settings.region):
|
||||
recipient = self.email
|
||||
|
||||
|
||||
@@ -19,13 +19,17 @@
|
||||
# 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 datetime
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal, DecimalException
|
||||
|
||||
import pycountry
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import EmailValidator
|
||||
from django.db.models import Q
|
||||
from django.utils import formats
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import (
|
||||
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
|
||||
@@ -38,13 +42,9 @@ from phonenumbers import SUPPORTED_REGIONS
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms.questions import guess_country
|
||||
from pretix.base.modelimport import (
|
||||
DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin,
|
||||
i18n_flat,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Customer, ItemVariation, OrderPosition, Question, QuestionAnswer,
|
||||
QuestionOption, Seat,
|
||||
QuestionOption, Seat, SubEvent,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.settings import (
|
||||
@@ -53,6 +53,99 @@ from pretix.base.settings import (
|
||||
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):
|
||||
identifier = 'email'
|
||||
verbose_name = gettext_lazy('E-mail address')
|
||||
@@ -89,20 +182,74 @@ class PhoneColumn(ImportColumn):
|
||||
order.phone = value
|
||||
|
||||
|
||||
class SubeventColumn(SubeventColumnMixin, ImportColumn):
|
||||
class SubeventColumn(ImportColumn):
|
||||
identifier = 'subevent'
|
||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
||||
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):
|
||||
if not value:
|
||||
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):
|
||||
position.subevent = value
|
||||
|
||||
|
||||
def i18n_flat(l):
|
||||
if isinstance(l.data, dict):
|
||||
return l.data.values()
|
||||
return [l.data]
|
||||
|
||||
|
||||
class ItemColumn(ImportColumn):
|
||||
identifier = 'item'
|
||||
verbose_name = gettext_lazy('Product')
|
||||
@@ -425,11 +572,20 @@ class AttendeeState(ImportColumn):
|
||||
position.state = value or ''
|
||||
|
||||
|
||||
class Price(DecimalColumnMixin, ImportColumn):
|
||||
class Price(ImportColumn):
|
||||
identifier = 'price'
|
||||
verbose_name = gettext_lazy('Price')
|
||||
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):
|
||||
if value is None:
|
||||
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
|
||||
@@ -493,18 +649,48 @@ class Locale(ImportColumn):
|
||||
order.locale = value
|
||||
|
||||
|
||||
class ValidFrom(DatetimeColumnMixin, ImportColumn):
|
||||
class ValidFrom(ImportColumn):
|
||||
identifier = 'valid_from'
|
||||
verbose_name = gettext_lazy('Valid from')
|
||||
|
||||
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_from = value
|
||||
|
||||
|
||||
class ValidUntil(DatetimeColumnMixin, ImportColumn):
|
||||
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
|
||||
|
||||
@@ -663,7 +849,7 @@ class CustomerColumn(ImportColumn):
|
||||
order.customer = value
|
||||
|
||||
|
||||
def get_order_import_columns(event):
|
||||
def get_all_columns(event):
|
||||
default = []
|
||||
if event.has_subevents:
|
||||
default.append(SubeventColumn(event))
|
||||
@@ -849,7 +849,7 @@ class BasePaymentProvider:
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
if str(ia.country) != '' and str(ia.country) not in restricted_countries:
|
||||
if str(ia.country) not in restricted_countries:
|
||||
return False
|
||||
|
||||
if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
|
||||
@@ -1311,7 +1311,9 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
|
||||
@property
|
||||
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
|
||||
def settings_form_fields(self):
|
||||
|
||||
@@ -78,7 +78,7 @@ from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import Paragraph
|
||||
|
||||
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.signals import layout_image_variables, layout_text_variables
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
@@ -738,10 +738,9 @@ class Renderer:
|
||||
else:
|
||||
self.bg_bytes = None
|
||||
self.bg_pdf = None
|
||||
self.event_fonts = list(get_fonts(event, pdf_support_required=True).keys()) + ['Open Sans']
|
||||
|
||||
@classmethod
|
||||
def _register_fonts(cls, event: Event = None):
|
||||
def _register_fonts(cls):
|
||||
if hasattr(cls, '_fonts_registered'):
|
||||
return
|
||||
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
|
||||
@@ -749,7 +748,7 @@ class Renderer:
|
||||
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')))
|
||||
|
||||
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'])))
|
||||
if 'italic' in styles:
|
||||
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
|
||||
@@ -935,13 +934,6 @@ class Renderer:
|
||||
|
||||
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||
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']:
|
||||
font += ' B'
|
||||
if o['italic']:
|
||||
|
||||
@@ -41,7 +41,6 @@ from typing import List, Optional
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
|
||||
@@ -113,15 +112,6 @@ error_messages = {
|
||||
'Some of the products you selected are no longer available in '
|
||||
'the quantity you selected. Please see below for details.'
|
||||
),
|
||||
'unavailable_listed': gettext_lazy(
|
||||
'Some of the products you selected are no longer available. '
|
||||
'The following products are affected and have not been added to your cart: %s'
|
||||
),
|
||||
'in_part_listed': gettext_lazy(
|
||||
'Some of the products you selected are no longer available in '
|
||||
'the quantity you selected. The following products are affected and have not '
|
||||
'been added to your cart: %s'
|
||||
),
|
||||
'max_items': ngettext_lazy(
|
||||
"You cannot select more than %s item per order.",
|
||||
"You cannot select more than %s items per order."
|
||||
@@ -203,7 +193,7 @@ error_messages = {
|
||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'min'
|
||||
),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-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.'),
|
||||
'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.'),
|
||||
@@ -388,9 +378,8 @@ class CartManager:
|
||||
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
|
||||
not op.position.addon_to_id])
|
||||
limit = min(int(self.event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE)
|
||||
if cartsize > limit:
|
||||
raise CartError(error_messages['max_items'] % limit)
|
||||
if cartsize > int(self.event.settings.max_items_per_order):
|
||||
raise CartError(error_messages['max_items'] % self.event.settings.max_items_per_order)
|
||||
|
||||
def _check_item_constraints(self, op, current_ops=[]):
|
||||
if isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
||||
@@ -1114,8 +1103,6 @@ class CartManager:
|
||||
if 'sleep-after-quota-check' in debugflags_var.get():
|
||||
sleep(2)
|
||||
|
||||
err_unavailable_products = []
|
||||
|
||||
for iop, op in enumerate(self._operations):
|
||||
if isinstance(op, self.RemoveOperation):
|
||||
if op.position.expires > self.now_dt:
|
||||
@@ -1143,15 +1130,9 @@ class CartManager:
|
||||
voucher_available_count = min(voucher_available_count, vouchers_ok[op.voucher])
|
||||
|
||||
if quota_available_count < 1:
|
||||
err = err or error_messages['unavailable_listed']
|
||||
err_unavailable_products.append(
|
||||
f'{op.item.name} – {op.variation}' if op.variation else op.item.name
|
||||
)
|
||||
err = err or error_messages['unavailable']
|
||||
elif quota_available_count < requested_count:
|
||||
err = err or error_messages['in_part_listed']
|
||||
err_unavailable_products.append(
|
||||
f'{op.item.name} – {op.variation}' if op.variation else op.item.name
|
||||
)
|
||||
err = err or error_messages['in_part']
|
||||
|
||||
if voucher_available_count < 1:
|
||||
if op.voucher in self._voucher_depend_on_cart:
|
||||
@@ -1168,25 +1149,16 @@ class CartManager:
|
||||
b_quotas = list(b.quotas)
|
||||
if not b_quotas:
|
||||
if not op.voucher or not op.voucher.allow_ignore_quota:
|
||||
err = err or error_messages['unavailable_listed']
|
||||
err_unavailable_products.append(
|
||||
f'{op.item.name} – {op.variation}' if op.variation else op.item.name
|
||||
)
|
||||
err = err or error_messages['unavailable']
|
||||
available_count = 0
|
||||
continue
|
||||
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b_quotas))
|
||||
if b_quota_available_count < b.count:
|
||||
err = err or error_messages['unavailable_listed']
|
||||
err_unavailable_products.append(
|
||||
f'{op.item.name} – {op.variation}' if op.variation else op.item.name
|
||||
)
|
||||
err = err or error_messages['unavailable']
|
||||
available_count = 0
|
||||
elif b_quota_available_count < available_count * b.count:
|
||||
err = err or error_messages['in_part_listed']
|
||||
err = err or error_messages['in_part']
|
||||
available_count = b_quota_available_count // b.count
|
||||
err_unavailable_products.append(
|
||||
f'{op.item.name} – {op.variation}' if op.variation else op.item.name
|
||||
)
|
||||
for q in b_quotas:
|
||||
quotas_ok[q] -= available_count * b.count
|
||||
# TODO: is this correct?
|
||||
@@ -1325,23 +1297,10 @@ class CartManager:
|
||||
|
||||
op.position.price_after_voucher = op.price_after_voucher
|
||||
op.position.voucher = op.voucher
|
||||
# op.position.price will be set in recompute_final_prices_and_taxes
|
||||
# op.posiiton.price will be set in recompute_final_prices_and_taxes
|
||||
op.position.save(update_fields=['price_after_voucher', 'voucher'])
|
||||
vouchers_ok[op.voucher] -= 1
|
||||
|
||||
if op.voucher.all_bundles_included or op.voucher.all_addons_included:
|
||||
for a in op.position.addons.all():
|
||||
if a.is_bundled and op.voucher.all_bundles_included and a.price:
|
||||
a.listed_price = Decimal("0.00")
|
||||
a.price_after_voucher = Decimal("0.00")
|
||||
# a.price will be set in recompute_final_prices_and_taxes
|
||||
a.save(update_fields=['listed_price', 'price_after_voucher'])
|
||||
elif not a.is_bundled and op.voucher.all_addons_included and a.price and not a.custom_price_input:
|
||||
a.listed_price = Decimal("0.00")
|
||||
a.price_after_voucher = Decimal("0.00")
|
||||
# op.positon.price will be set in recompute_final_prices_and_taxes
|
||||
a.save(update_fields=['listed_price', 'price_after_voucher'])
|
||||
|
||||
for p in new_cart_positions:
|
||||
if getattr(p, '_answers', None):
|
||||
if not p.pk: # We stored some to the database already before
|
||||
@@ -1351,14 +1310,10 @@ class CartManager:
|
||||
|
||||
if 'sleep-before-commit' in debugflags_var.get():
|
||||
sleep(2)
|
||||
|
||||
if err in (error_messages['unavailable_listed'], error_messages['in_part_listed']):
|
||||
err = err % ', '.join(str(p) for p in err_unavailable_products)
|
||||
|
||||
return err
|
||||
|
||||
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')
|
||||
for cp in positions:
|
||||
if cp.listed_price is None:
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||
# 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.
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import partial, reduce
|
||||
@@ -66,8 +65,6 @@ from pretix.helpers.jsonlogic_query import (
|
||||
MinutesSince, tolerance,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_time(t=None, value=None, ev=None, now_dt=None):
|
||||
now_dt = now_dt or now()
|
||||
@@ -202,7 +199,7 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
|
||||
'var': values[0]["var"],
|
||||
'rhs': values[1:],
|
||||
}
|
||||
elif any(t in values[0] for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before")):
|
||||
elif "entries_since" in values[0] or "entries_before" in values[0]:
|
||||
_var_explanations[new_var_name] = {
|
||||
'operator': operator,
|
||||
'var': values[0],
|
||||
@@ -280,13 +277,11 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
|
||||
var_weights[vname] = (500, 0)
|
||||
var_texts[vname] = _('Wrong entrance gate')
|
||||
elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday') \
|
||||
or (isinstance(var, dict) and any(t in var for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before"))):
|
||||
or (isinstance(var, dict) and ("entries_since" in var or "entries_before" in var)):
|
||||
w = {
|
||||
'minutes_since_first_entry': 80,
|
||||
'minutes_since_last_entry': 90,
|
||||
'entries_days': 100,
|
||||
'entries_days_since': 105,
|
||||
'entries_days_before': 105,
|
||||
'entries_since': 110,
|
||||
'entries_before': 110,
|
||||
'entries_number': 120,
|
||||
@@ -309,12 +304,10 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
|
||||
'entries_today': _('number of entries today'),
|
||||
'entries_since': _('number of entries since {datetime}'),
|
||||
'entries_before': _('number of entries before {datetime}'),
|
||||
'entries_days_since': _('number of days with an entry since {datetime}'),
|
||||
'entries_days_before': _('number of days with an entry before {datetime}'),
|
||||
'now_isoweekday': _('week day'),
|
||||
}
|
||||
|
||||
if isinstance(var, dict) and any(t in var for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before")):
|
||||
if isinstance(var, dict) and ("entries_since" in var or "entries_before" in var):
|
||||
varname = list(var.keys())[0]
|
||||
cutoff = _build_time(*var[varname][0]['buildTime'], ev=ev, now_dt=now_dt).astimezone(ev.timezone)
|
||||
if abs(now_dt - cutoff) < timedelta(hours=12):
|
||||
@@ -414,8 +407,6 @@ def _get_logic_environment(ev, rule_data, now_dt):
|
||||
logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol))
|
||||
logic.add_operation('entries_since', lambda t1: rule_data.entries_since(t1))
|
||||
logic.add_operation('entries_before', lambda t1: rule_data.entries_before(t1))
|
||||
logic.add_operation('entries_days_since', lambda t1: rule_data.entries_days_since(t1))
|
||||
logic.add_operation('entries_days_before', lambda t1: rule_data.entries_days_before(t1))
|
||||
return logic
|
||||
|
||||
|
||||
@@ -473,32 +464,6 @@ class LazyRuleVars:
|
||||
self.__cache['entries_before', cutoff] = self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__lt=cutoff).count()
|
||||
return self.__cache['entries_before', cutoff]
|
||||
|
||||
def entries_days_since(self, cutoff):
|
||||
tz = self._clist.event.timezone
|
||||
with override(tz):
|
||||
if ('entries_days_since', cutoff) not in self.__cache:
|
||||
self.__cache['entries_days_since', cutoff] = self._position.checkins.filter(
|
||||
type=Checkin.TYPE_ENTRY,
|
||||
list=self._clist,
|
||||
datetime__gte=cutoff
|
||||
).annotate(
|
||||
day=TruncDate('datetime', tzinfo=tz)
|
||||
).values('day').distinct().count()
|
||||
return self.__cache['entries_days_since', cutoff]
|
||||
|
||||
def entries_days_before(self, cutoff):
|
||||
tz = self._clist.event.timezone
|
||||
with override(tz):
|
||||
if ('entries_days_before', cutoff) not in self.__cache:
|
||||
self.__cache['entries_days_before', cutoff] = self._position.checkins.filter(
|
||||
type=Checkin.TYPE_ENTRY,
|
||||
list=self._clist,
|
||||
datetime__lt=cutoff
|
||||
).annotate(
|
||||
day=TruncDate('datetime', tzinfo=tz)
|
||||
).values('day').distinct().count()
|
||||
return self.__cache['entries_days_before', cutoff]
|
||||
|
||||
@cached_property
|
||||
def entries_days(self):
|
||||
tz = self._clist.event.timezone
|
||||
@@ -565,8 +530,7 @@ class SQLLogic:
|
||||
"isBefore": partial(self.comparison_to_q, operator=LowerThan, modifier=partial(tolerance, sign=1)),
|
||||
"isAfter": partial(self.comparison_to_q, operator=GreaterThan, modifier=partial(tolerance, sign=-1)),
|
||||
}
|
||||
self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before',
|
||||
'entries_days_since', 'entries_days_before'}
|
||||
self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'}
|
||||
|
||||
def operation_to_expression(self, rule):
|
||||
if not isinstance(rule, dict):
|
||||
@@ -644,42 +608,6 @@ class SQLLogic:
|
||||
Value(0),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
elif operator == 'entries_days_since':
|
||||
tz = self.list.event.timezone
|
||||
return Coalesce(
|
||||
Subquery(
|
||||
Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
type=Checkin.TYPE_ENTRY,
|
||||
list_id=self.list.pk,
|
||||
datetime__gte=self.operation_to_expression(values[0]),
|
||||
).annotate(
|
||||
day=TruncDate('datetime', tzinfo=tz)
|
||||
).values('position_id').order_by().annotate(
|
||||
c=Count('day', distinct=True)
|
||||
).values('c')
|
||||
),
|
||||
Value(0),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
elif operator == 'entries_days_before':
|
||||
tz = self.list.event.timezone
|
||||
return Coalesce(
|
||||
Subquery(
|
||||
Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
type=Checkin.TYPE_ENTRY,
|
||||
list_id=self.list.pk,
|
||||
datetime__lt=self.operation_to_expression(values[0]),
|
||||
).annotate(
|
||||
day=TruncDate('datetime', tzinfo=tz)
|
||||
).values('position_id').order_by().annotate(
|
||||
c=Count('day', distinct=True)
|
||||
).values('c')
|
||||
),
|
||||
Value(0),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
elif operator == 'var':
|
||||
if values[0] == 'now':
|
||||
return Value(now().astimezone(timezone.utc))
|
||||
@@ -1036,16 +964,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
if type == Checkin.TYPE_ENTRY and clist.rules:
|
||||
rule_data = LazyRuleVars(op, clist, dt, gate=gate)
|
||||
logic = _get_logic_environment(op.subevent or clist.event, rule_data, now_dt=dt)
|
||||
try:
|
||||
logic_result = logic.apply(clist.rules, rule_data)
|
||||
except Exception:
|
||||
logger.exception("Check-in rule evaluation failed")
|
||||
raise CheckInError(
|
||||
_('Evaluation of custom rules has failed.'),
|
||||
'rules',
|
||||
)
|
||||
|
||||
if not logic_result:
|
||||
if not logic.apply(clist.rules, rule_data):
|
||||
if force:
|
||||
force_used = True
|
||||
else:
|
||||
|
||||
@@ -31,7 +31,6 @@ from pretix.base.models import CachedCombinedTicket, CachedTicket
|
||||
from pretix.base.models.customers import CustomerSSOGrant
|
||||
|
||||
from ..models import CachedFile, CartPosition, InvoiceAddress
|
||||
from ..models.auth import UserKnownLoginSource
|
||||
from ..signals import periodic_task
|
||||
|
||||
|
||||
@@ -76,9 +75,3 @@ def clearsessions(sender, **kwargs):
|
||||
@scopes_disabled()
|
||||
def clear_oidc_data(sender, **kwargs):
|
||||
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")
|
||||
)
|
||||
|
||||
invoice.introductory_text = str(introductory).replace('\n', '<br />').replace('\r', '')
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />').replace('\r', '')
|
||||
invoice.introductory_text = str(introductory).replace('\n', '<br />')
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />')
|
||||
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
|
||||
|
||||
try:
|
||||
@@ -462,10 +462,10 @@ def build_preview_invoice_pdf(event):
|
||||
footer = event.settings.get('invoice_footer_text', as_type=LazyI18nString)
|
||||
payment = _("A payment provider specific text might appear here.")
|
||||
|
||||
invoice.introductory_text = str(introductory).replace('\n', '<br />').replace('\r', '')
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />').replace('\r', '')
|
||||
invoice.introductory_text = str(introductory).replace('\n', '<br />')
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />')
|
||||
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.invoice_to_name = _("John Doe")
|
||||
invoice.invoice_to_street = _("214th Example Street")
|
||||
@@ -488,7 +488,7 @@ def build_preview_invoice_pdf(event):
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=_("Sample product {}").format(i + 1),
|
||||
gross_value=tax.gross, tax_value=tax.tax,
|
||||
tax_rate=tax.rate, tax_name=tax.name
|
||||
tax_rate=tax.rate
|
||||
)
|
||||
else:
|
||||
for i in range(5):
|
||||
|
||||
@@ -183,9 +183,12 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
if auto_email:
|
||||
headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN'
|
||||
headers['Auto-Submitted'] = 'auto-generated'
|
||||
headers.setdefault('X-Mailer', 'pretix')
|
||||
|
||||
with language(locale):
|
||||
if isinstance(context, dict) and event:
|
||||
for k, v in event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
if isinstance(context, dict) and order:
|
||||
try:
|
||||
context.update({
|
||||
@@ -383,7 +386,6 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
if event:
|
||||
with scopes_disabled():
|
||||
event = Event.objects.get(id=event)
|
||||
organizer = event.organizer
|
||||
backend = event.get_mail_backend()
|
||||
cm = lambda: scope(organizer=event.organizer) # noqa
|
||||
elif organizer:
|
||||
@@ -571,11 +573,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
except smtplib.SMTPRecipientsRefused as e:
|
||||
smtp_codes = [a[0] for a in e.recipients.values()]
|
||||
|
||||
if not any(c >= 500 for c in smtp_codes) or any(b'Message is too large' in a[1] for a in e.recipients.values()):
|
||||
# This is not a permanent failure (mailbox full, service unavailable), retry later, but with large
|
||||
# intervals. One would think that "Message is too lage" is a permanent failure, but apparently it is not.
|
||||
# We have documented cases of emails to Microsoft returning the error occasionally and then later
|
||||
# allowing the very same email.
|
||||
if not any(c >= 500 for c in smtp_codes):
|
||||
# Not a permanent failure (mailbox full, service unavailable), retry later, but with large intervals
|
||||
try:
|
||||
self.retry(max_retries=5, countdown=[60, 300, 600, 1200, 1800, 1800][self.request.retries])
|
||||
except MaxRetriesExceededError:
|
||||
|
||||
@@ -29,8 +29,8 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import (
|
||||
AbstractPosition, CartPosition, Customer, Event, Item, Membership, Order,
|
||||
OrderPosition, SubEvent,
|
||||
AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition,
|
||||
SubEvent,
|
||||
)
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
@@ -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,
|
||||
valid_from_not_chosen=False):
|
||||
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False):
|
||||
"""
|
||||
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 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 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
|
||||
applicable_positions = [
|
||||
@@ -135,11 +132,7 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
||||
qs = qs.exclude(order_id=ignored_order.pk)
|
||||
m._used_at_dates = [
|
||||
(op.subevent or op.order.event).date_from
|
||||
for op in qs if not op.valid_from or not op.valid_until
|
||||
]
|
||||
m._used_for_ranges = [
|
||||
(op.valid_from, op.valid_until)
|
||||
for op in qs if op.valid_from or op.valid_until
|
||||
for op in qs
|
||||
]
|
||||
|
||||
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.')
|
||||
)
|
||||
|
||||
if m.testmode and not testmode:
|
||||
if m.testmode != testmode:
|
||||
raise ValidationError(
|
||||
_('You can not use a test mode membership for tickets that are not in test mode.')
|
||||
)
|
||||
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.')
|
||||
_('You can only use a test mode membership for test mode tickets.')
|
||||
)
|
||||
|
||||
ev = p.subevent or event
|
||||
|
||||
if isinstance(p, (OrderPosition, CartPosition)):
|
||||
# override_ variants are for usage of fake cart in OrderChangeManager
|
||||
valid_from = getattr(p, 'override_valid_from', p.valid_from)
|
||||
valid_until = getattr(p, 'override_valid_until', p.valid_until)
|
||||
else: # future safety, not technically defined on AbstractPosition
|
||||
valid_from = None
|
||||
valid_until = None
|
||||
|
||||
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 not m.is_valid(ev):
|
||||
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:
|
||||
types = p.variation.require_membership_types.all()
|
||||
@@ -217,34 +188,13 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
||||
m.usages += 1
|
||||
|
||||
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):
|
||||
for used_range in m._used_for_ranges:
|
||||
if valid_from and valid_from > used_range[1]:
|
||||
continue
|
||||
if valid_until and valid_until < used_range[0]:
|
||||
continue
|
||||
raise ValidationError(
|
||||
_('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'),
|
||||
)
|
||||
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_for_ranges.append((p.valid_from, p.valid_until))
|
||||
|
||||
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)
|
||||
)
|
||||
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')
|
||||
|
||||
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)
|
||||
|
||||
tpl_plain = get_template('pretixbase/email/notification.txt')
|
||||
|
||||
@@ -19,24 +19,22 @@
|
||||
# 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 io
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from pretix.base.i18n import 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.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition,
|
||||
User, Voucher,
|
||||
User,
|
||||
)
|
||||
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.locking import lock_objects
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
@@ -44,36 +42,47 @@ from pretix.base.signals import order_paid, order_placed
|
||||
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:
|
||||
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)
|
||||
)
|
||||
)
|
||||
data = []
|
||||
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)
|
||||
return data
|
||||
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
|
||||
|
||||
|
||||
def setif(record, obj, attr, setting):
|
||||
if setting.startswith('csv:'):
|
||||
setattr(obj, attr, record[setting[4:]] or '')
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
|
||||
@@ -81,16 +90,31 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
cf = CachedFile.objects.get(id=fileid)
|
||||
user = User.objects.get(pk=user)
|
||||
with language(locale, event.settings.region):
|
||||
cols = get_order_import_columns(event)
|
||||
data = _validate(cf, charset, cols, settings)
|
||||
|
||||
if settings['orders'] == 'one' and len(data) > django_settings.PRETIX_MAX_ORDER_SIZE:
|
||||
raise DataImportError(
|
||||
_('Orders cannot have more than %(max)s positions.') % {'max': django_settings.PRETIX_MAX_ORDER_SIZE}
|
||||
)
|
||||
|
||||
cols = get_all_columns(event)
|
||||
parsed = parse_csv(cf.file, charset=charset)
|
||||
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)
|
||||
|
||||
# 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…
|
||||
@@ -111,16 +135,16 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
position = OrderPosition(positionid=len(order._positions) + 1)
|
||||
position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
|
||||
position.meta_info = {}
|
||||
if position.seat is not None:
|
||||
lock_seats.append(position.seat)
|
||||
order._positions.append(position)
|
||||
position.assign_pseudonymization_id()
|
||||
|
||||
for c in cols:
|
||||
c.assign(record.get(c.identifier), order, position, order._address)
|
||||
|
||||
if position.seat is not None:
|
||||
lock_seats.append(position.seat)
|
||||
except (ValidationError, ImportError) as e:
|
||||
raise DataImportError(
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
|
||||
)
|
||||
|
||||
@@ -131,7 +155,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
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.'))
|
||||
raise ImportError(_('The seat you selected has already been taken. Please select a different seat.'))
|
||||
|
||||
save_transactions = []
|
||||
for o in orders:
|
||||
@@ -194,62 +218,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. '
|
||||
'Please try again.'))
|
||||
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)
|
||||
|
||||
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()
|
||||
@@ -98,9 +98,10 @@ from pretix.base.services.pricing import (
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
||||
from pretix.base.signals import (
|
||||
order_approved, order_canceled, order_changed, order_denied, order_expired,
|
||||
order_fee_calculation, order_paid, order_placed, order_split,
|
||||
order_valid_if_pending, periodic_task, validate_order,
|
||||
allow_ticket_download, order_approved, order_canceled, order_changed,
|
||||
order_denied, order_expired, order_fee_calculation, order_paid,
|
||||
order_placed, order_split, order_valid_if_pending, periodic_task,
|
||||
validate_order,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
@@ -197,7 +198,7 @@ error_messages = {
|
||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'min'
|
||||
),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-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.'),
|
||||
}
|
||||
|
||||
@@ -220,7 +221,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,
|
||||
check_memberships=True, lock=True, force=force)
|
||||
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
|
||||
else:
|
||||
order.status = Order.STATUS_PENDING
|
||||
@@ -412,11 +413,6 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_approved', user,
|
||||
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 []
|
||||
)
|
||||
except SendMailException:
|
||||
@@ -670,7 +666,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
deleted_positions.add(cp.pk)
|
||||
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:
|
||||
cp._cached_quotas = list(cp.quotas)
|
||||
@@ -881,13 +877,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.discount = 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)
|
||||
if old_total != new_total:
|
||||
err = err or error_messages['price_changed']
|
||||
@@ -896,7 +885,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
for cp in sorted_positions:
|
||||
cp.expires = now_dt + timedelta(
|
||||
minutes=event.settings.get('reservation_time', as_type=int))
|
||||
cp.save(update_fields=['expires'])
|
||||
cp.save()
|
||||
|
||||
if err:
|
||||
raise OrderError(err)
|
||||
@@ -1057,11 +1046,7 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp
|
||||
log_entry,
|
||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True,
|
||||
attach_ical=event.settings.mail_attach_ical and (
|
||||
not event.settings.mail_attach_ical_paid_only or
|
||||
is_free or
|
||||
order.valid_if_pending
|
||||
),
|
||||
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
|
||||
attach_other_files=[a for a in [
|
||||
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||
] if a],
|
||||
@@ -1080,11 +1065,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
||||
log_entry,
|
||||
invoices=[],
|
||||
attach_tickets=True,
|
||||
attach_ical=event.settings.mail_attach_ical and (
|
||||
not event.settings.mail_attach_ical_paid_only or
|
||||
is_free or
|
||||
order.valid_if_pending
|
||||
),
|
||||
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
|
||||
attach_other_files=[a for a in [
|
||||
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||
] if a],
|
||||
@@ -1164,7 +1145,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
|
||||
positions = list(
|
||||
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:
|
||||
raise OrderError(error_messages['empty'])
|
||||
if len(position_ids) != len(positions):
|
||||
@@ -1427,16 +1408,23 @@ def send_download_reminders(sender, **kwargs):
|
||||
if o.download_reminder_sent:
|
||||
# Race condition
|
||||
continue
|
||||
positions = list(o.positions_with_tickets)
|
||||
if not positions:
|
||||
if not all([r for rr, r in allow_ticket_download.send(event, order=o)]):
|
||||
continue
|
||||
|
||||
if not o.ticket_download_available:
|
||||
continue
|
||||
positions = o.positions.select_related('item')
|
||||
|
||||
if o.status != Order.STATUS_PAID:
|
||||
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
|
||||
continue
|
||||
send = False
|
||||
for p in positions:
|
||||
if p.generate_ticket:
|
||||
send = True
|
||||
break
|
||||
if not send:
|
||||
continue
|
||||
|
||||
with language(o.locale, o.event.settings.region):
|
||||
o.download_reminder_sent = True
|
||||
@@ -1454,7 +1442,10 @@ def send_download_reminders(sender, **kwargs):
|
||||
logger.exception('Reminder email could not be sent')
|
||||
|
||||
if event.settings.mail_send_download_reminder_attendee:
|
||||
for p in positions:
|
||||
for p in o.positions.all():
|
||||
if not p.generate_ticket:
|
||||
continue
|
||||
|
||||
if p.subevent_id:
|
||||
reminder_date = (p.subevent.date_from - timedelta(days=days)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
@@ -1521,7 +1512,6 @@ class OrderChangeManager:
|
||||
"You need to select at least %(min)s items of the product %(product)s.",
|
||||
"min"
|
||||
),
|
||||
'max_order_size': gettext_lazy('Orders cannot have more than %(max)s positions.'),
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
|
||||
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
|
||||
@@ -2520,7 +2510,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()])
|
||||
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
|
||||
else:
|
||||
split_order.status = Order.STATUS_PENDING
|
||||
@@ -2609,14 +2599,6 @@ class OrderChangeManager:
|
||||
self.order.total = total + payment_fee
|
||||
self.order.save()
|
||||
|
||||
def _check_order_size(self):
|
||||
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
|
||||
raise OrderError(
|
||||
self.error_messages['max_order_size'] % {
|
||||
'max': settings.PRETIX_MAX_ORDER_SIZE,
|
||||
}
|
||||
)
|
||||
|
||||
def _payment_fee_diff(self):
|
||||
total = self.order.total + self._totaldiff
|
||||
if self.open_payment:
|
||||
@@ -2691,7 +2673,6 @@ class OrderChangeManager:
|
||||
|
||||
for p in self.order.positions.all():
|
||||
cp = CartPosition(
|
||||
event=self.event,
|
||||
item=p.item,
|
||||
variation=p.variation,
|
||||
attendee_name_parts=p.attendee_name_parts,
|
||||
@@ -2712,23 +2693,16 @@ class OrderChangeManager:
|
||||
positions_to_fake_cart[op.position].seat = op.seat
|
||||
elif isinstance(op, self.MembershipOperation):
|
||||
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:
|
||||
fake_cart.remove(positions_to_fake_cart[op.position])
|
||||
elif isinstance(op, self.AddOperation):
|
||||
cp = CartPosition(
|
||||
event=self.event,
|
||||
item=op.item,
|
||||
variation=op.variation,
|
||||
used_membership=op.membership,
|
||||
subevent=op.subevent,
|
||||
seat=op.seat,
|
||||
)
|
||||
cp.override_valid_from = op.valid_from
|
||||
cp.override_valid_until = op.valid_until
|
||||
fake_cart.append(cp)
|
||||
try:
|
||||
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
|
||||
@@ -2765,7 +2739,6 @@ class OrderChangeManager:
|
||||
|
||||
# finally, incorporate difference in payment fees
|
||||
self._payment_fee_diff()
|
||||
self._check_order_size()
|
||||
|
||||
with transaction.atomic():
|
||||
locked_instance = Order.objects.select_for_update(of=OF_SELF).get(pk=self.order.pk)
|
||||
|
||||
@@ -1,584 +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 inspect
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.forms import PlaceholderValidator
|
||||
from pretix.base.forms.widgets import format_placeholders_help_text
|
||||
from pretix.base.i18n import (
|
||||
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
|
||||
)
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
|
||||
from pretix.base.signals import (
|
||||
register_mail_placeholders, register_text_placeholders,
|
||||
)
|
||||
from pretix.helpers.format import SafeFormatter
|
||||
|
||||
logger = logging.getLogger('pretix.base.services.placeholders')
|
||||
|
||||
|
||||
class BaseTextPlaceholder:
|
||||
"""
|
||||
This is the base class for all email text placeholders.
|
||||
"""
|
||||
|
||||
@property
|
||||
def required_context(self):
|
||||
"""
|
||||
This property should return a list of all attribute names that need to be
|
||||
contained in the base context so that this placeholder is available. By default,
|
||||
it returns a list containing the string "event".
|
||||
"""
|
||||
return ["event"]
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
This should return the identifier of this placeholder in the email.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render(self, context):
|
||||
"""
|
||||
This method is called to generate the actual text that is being
|
||||
used in the email. You will be passed a context dictionary with the
|
||||
base context attributes specified in ``required_context``. You are
|
||||
expected to return a plain-text string.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, event):
|
||||
"""
|
||||
This method is called to generate a text to be used in email previews.
|
||||
This may only depend on the event.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class SimpleFunctionalTextPlaceholder(BaseTextPlaceholder):
|
||||
def __init__(self, identifier, args, func, sample):
|
||||
self._identifier = identifier
|
||||
self._args = args
|
||||
self._func = func
|
||||
self._sample = sample
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def required_context(self):
|
||||
return self._args
|
||||
|
||||
def render(self, context):
|
||||
return self._func(**{k: context[k] for k in self._args})
|
||||
|
||||
def render_sample(self, event):
|
||||
if callable(self._sample):
|
||||
return self._sample(event)
|
||||
else:
|
||||
return self._sample
|
||||
|
||||
|
||||
class PlaceholderContext(SafeFormatter):
|
||||
"""
|
||||
Holds the contextual arguments and corresponding list of available placeholders for formatting
|
||||
an email or other templated text.
|
||||
|
||||
Example:
|
||||
context = PlaceholderContext(event=my_event, order=my_order)
|
||||
formatted_doc = context.format(input_doc)
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__({})
|
||||
self.context_args = kwargs
|
||||
self._extend_context_args()
|
||||
self.placeholders = {}
|
||||
self.cache = {}
|
||||
event = kwargs['event']
|
||||
for r, val in [
|
||||
*register_mail_placeholders.send(sender=event),
|
||||
*register_text_placeholders.send(sender=event)
|
||||
]:
|
||||
if not isinstance(val, (list, tuple)):
|
||||
val = [val]
|
||||
for v in val:
|
||||
if all(rp in kwargs for rp in v.required_context):
|
||||
self.placeholders[v.identifier] = v
|
||||
|
||||
def _extend_context_args(self):
|
||||
from pretix.base.models import InvoiceAddress
|
||||
|
||||
if 'position' in self.context_args:
|
||||
self.context_args.setdefault("position_or_address", self.context_args['position'])
|
||||
if 'order' in self.context_args:
|
||||
try:
|
||||
if not self.context_args.get('invoice_address'):
|
||||
self.context_args['invoice_address'] = self.context_args['order'].invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
self.context_args['invoice_address'] = InvoiceAddress(order=self.context_args['order'])
|
||||
finally:
|
||||
self.context_args.setdefault("position_or_address", self.context_args['invoice_address'])
|
||||
|
||||
def render_placeholder(self, placeholder):
|
||||
try:
|
||||
return self.cache[placeholder.identifier]
|
||||
except KeyError:
|
||||
try:
|
||||
value = self.cache[placeholder.identifier] = placeholder.render(self.context_args)
|
||||
return value
|
||||
except:
|
||||
logger.exception(f'Failed to process template placeholder {placeholder.identifier}.')
|
||||
return '(error)'
|
||||
|
||||
def render_all(self):
|
||||
return {identifier: self.render_placeholder(placeholder)
|
||||
for (identifier, placeholder) in self.placeholders.items()}
|
||||
|
||||
def get_value(self, key, args, kwargs):
|
||||
if key not in self.placeholders:
|
||||
return '{' + str(key) + '}'
|
||||
return self.render_placeholder(self.placeholders[key])
|
||||
|
||||
|
||||
def _placeholder_payments(order, payments):
|
||||
d = []
|
||||
for payment in payments:
|
||||
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
|
||||
d.append(str(payment.payment_provider.order_pending_mail_render(order, payment)))
|
||||
else:
|
||||
d.append(str(payment.payment_provider.order_pending_mail_render(order)))
|
||||
d = [line for line in d if line.strip()]
|
||||
if d:
|
||||
return '\n\n'.join(d)
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
def get_best_name(position_or_address, parts=False):
|
||||
"""
|
||||
Return the best name we got for either an invoice address or an order position, falling back to the respective other
|
||||
"""
|
||||
from pretix.base.models import InvoiceAddress, OrderPosition
|
||||
if isinstance(position_or_address, InvoiceAddress):
|
||||
if position_or_address.name:
|
||||
return position_or_address.name_parts if parts else position_or_address.name
|
||||
elif position_or_address.order:
|
||||
position_or_address = position_or_address.order.positions.exclude(attendee_name_cached="").exclude(attendee_name_cached__isnull=True).first()
|
||||
|
||||
if isinstance(position_or_address, OrderPosition):
|
||||
if position_or_address.attendee_name:
|
||||
return position_or_address.attendee_name_parts if parts else position_or_address.attendee_name
|
||||
elif position_or_address.order:
|
||||
try:
|
||||
return position_or_address.order.invoice_address.name_parts if parts else position_or_address.order.invoice_address.name
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
return {} if parts else ""
|
||||
|
||||
|
||||
@receiver(register_text_placeholders, dispatch_uid="pretixbase_register_text_placeholders")
|
||||
def base_placeholders(sender, **kwargs):
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
ph = [
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event', ['event'], lambda event: event.name, lambda event: event.name
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
|
||||
lambda event_or_subevent: event_or_subevent.name
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'code', ['order'], lambda order: order.code, 'F8VVL'
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'total', ['order'], lambda order: LazyNumber(order.total), lambda event: LazyNumber(Decimal('42.23'))
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'order_email', ['order'], lambda order: order.email, 'john@example.org'
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'invoice_number', ['invoice'],
|
||||
lambda invoice: invoice.full_invoice_no,
|
||||
f'{sender.settings.invoice_numbers_prefix or (sender.slug.upper() + "-")}00000'
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'refund_amount', ['event_or_subevent', 'refund_amount'],
|
||||
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
|
||||
lambda event_or_subevent: LazyCurrencyNumber(Decimal('42.23'), event_or_subevent.currency)
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'pending_sum', ['event', 'pending_sum'],
|
||||
lambda event, pending_sum: LazyCurrencyNumber(pending_sum, event.currency),
|
||||
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
|
||||
event.currency),
|
||||
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'expire_date', ['event', 'order'], lambda event, order: LazyExpiresDate(order.expires.astimezone(event.timezone)),
|
||||
lambda event: LazyDate(now() + timedelta(days=15))
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'url', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.open', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
'hash': '98kusd8ofsj8dnkd'
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.modify', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.modify', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.change', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.change', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.cancel', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.cancel', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.position',
|
||||
kwargs={
|
||||
'order': position.order.code,
|
||||
'secret': position.web_secret,
|
||||
'position': position.positionid
|
||||
}
|
||||
),
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.position', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
'position': '123'
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'order_modification_deadline_date_and_time', ['order', 'event'],
|
||||
lambda order, event:
|
||||
date_format(order.modify_deadline.astimezone(event.timezone), 'SHORT_DATETIME_FORMAT')
|
||||
if order.modify_deadline
|
||||
else '',
|
||||
lambda event: date_format(
|
||||
event.settings.get(
|
||||
'last_order_modification_date', as_type=RelativeDateWrapper
|
||||
).datetime(event).astimezone(event.timezone),
|
||||
'SHORT_DATETIME_FORMAT'
|
||||
) if event.settings.get('last_order_modification_date') else '',
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
|
||||
lambda event: str(event.location or ''),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event_admission_time', ['event_or_subevent'],
|
||||
lambda event_or_subevent:
|
||||
date_format(event_or_subevent.date_admission.astimezone(event_or_subevent.timezone), 'TIME_FORMAT')
|
||||
if event_or_subevent.date_admission
|
||||
else '',
|
||||
lambda event: date_format(event.date_admission.astimezone(event.timezone), 'TIME_FORMAT') if event.date_admission else '',
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'subevent', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
|
||||
lambda event: str(event if not event.has_subevents or not event.subevents.exists() else event.subevents.first())
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'subevent_date_from', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(),
|
||||
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'url_remove', ['waiting_list_voucher', 'event'],
|
||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
||||
event, 'presale:event.waitinglist.remove'
|
||||
) + '?voucher=' + waiting_list_voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.waitinglist.remove',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'url', ['waiting_list_voucher', 'event'],
|
||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + waiting_list_voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.redeem',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'invoice_name', ['invoice_address'], lambda invoice_address: invoice_address.name or '',
|
||||
_('John Doe')
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
|
||||
_('Sample Corporation')
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
order.full_code,
|
||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash(),
|
||||
}),
|
||||
)
|
||||
for order in orders
|
||||
), lambda event: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
'{}-{}'.format(event.slug.upper(), order['code']),
|
||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order['code'],
|
||||
'secret': order['secret'],
|
||||
'hash': order['hash'],
|
||||
}),
|
||||
)
|
||||
for order in [
|
||||
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': 'abcdefghi'},
|
||||
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd', 'hash': 'jklmnopqr'},
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
|
||||
]
|
||||
),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
|
||||
event.settings.waiting_list_hours,
|
||||
lambda event: event.settings.waiting_list_hours
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'product', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.item.name,
|
||||
_('Sample Admission Ticket')
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
|
||||
'68CYU2H6ZTP3WLK5'
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
||||
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_url_list', ['event', 'voucher_list'],
|
||||
lambda event, voucher_list: ' \n'.join([
|
||||
build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + c
|
||||
for c in voucher_list
|
||||
]),
|
||||
lambda event: ' \n'.join([
|
||||
build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + c
|
||||
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
||||
]),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
}), lambda event: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
})
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'name', ['name'], lambda name: name,
|
||||
_('John Doe')
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'comment', ['comment'], lambda comment: comment,
|
||||
_('An individual text with a reason can be inserted here.'),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'payment_info', ['order', 'payments'], _placeholder_payments,
|
||||
_('The amount has been charged to your card.'),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'payment_info', ['payment_info'], lambda payment_info: payment_info,
|
||||
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'attendee_name', ['position'], lambda position: position.attendee_name,
|
||||
_('John Doe'),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'positionid', ['position'], lambda position: str(position.positionid),
|
||||
'1'
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'name', ['position_or_address'],
|
||||
get_best_name,
|
||||
_('John Doe'),
|
||||
),
|
||||
]
|
||||
|
||||
name_scheme = PERSON_NAME_SCHEMES[sender.settings.name_scheme]
|
||||
if "concatenation_for_salutation" in name_scheme:
|
||||
concatenation_for_salutation = name_scheme["concatenation_for_salutation"]
|
||||
else:
|
||||
concatenation_for_salutation = name_scheme["concatenation"]
|
||||
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
"name_for_salutation", ["waiting_list_entry"],
|
||||
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
|
||||
lambda event: concatenation_for_salutation(name_scheme['sample']),
|
||||
))
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
"name", ["waiting_list_entry"],
|
||||
lambda waiting_list_entry: waiting_list_entry.name or "",
|
||||
_("Mr Doe"),
|
||||
))
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
"name_for_salutation", ["position_or_address"],
|
||||
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
|
||||
lambda event: concatenation_for_salutation(name_scheme['sample']),
|
||||
))
|
||||
|
||||
for f, l, w in name_scheme['fields']:
|
||||
if f == 'full_name':
|
||||
continue
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
'name_%s' % f, ['waiting_list_entry'], lambda waiting_list_entry, f=f: get_name_parts_localized(waiting_list_entry.name_parts, f),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
'attendee_name_%s' % f, ['position'], lambda position, f=f: get_name_parts_localized(position.attendee_name_parts, f),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
'name_%s' % f, ['position_or_address'],
|
||||
lambda position_or_address, f=f: get_name_parts_localized(get_best_name(position_or_address, parts=True), f),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
|
||||
for k, v in sender.meta_data.items():
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
||||
v
|
||||
))
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
|
||||
v
|
||||
))
|
||||
|
||||
return ph
|
||||
|
||||
|
||||
class FormPlaceholderMixin:
|
||||
def _set_field_placeholders(self, fn, base_parameters):
|
||||
placeholders = get_available_placeholders(self.event, base_parameters)
|
||||
ht = format_placeholders_help_text(placeholders, self.event)
|
||||
if self.fields[fn].help_text:
|
||||
self.fields[fn].help_text += ' ' + str(ht)
|
||||
else:
|
||||
self.fields[fn].help_text = ht
|
||||
self.fields[fn].validators.append(
|
||||
PlaceholderValidator(['{%s}' % p for p in placeholders.keys()])
|
||||
)
|
||||
|
||||
|
||||
def get_available_placeholders(event, base_parameters):
|
||||
if 'order' in base_parameters:
|
||||
base_parameters.append('invoice_address')
|
||||
base_parameters.append('position_or_address')
|
||||
params = {}
|
||||
for r, val in [*register_mail_placeholders.send(sender=event), *register_text_placeholders.send(sender=event)]:
|
||||
if not isinstance(val, (list, tuple)):
|
||||
val = [val]
|
||||
for v in val:
|
||||
if all(rp in base_parameters for rp in v.required_context):
|
||||
params[v.identifier] = v
|
||||
return params
|
||||
@@ -90,8 +90,8 @@ class QuotaAvailability:
|
||||
self._count_waitinglist = count_waitinglist
|
||||
self._ignore_closed = ignore_closed
|
||||
self._full_results = full_results
|
||||
self._item_to_quotas = defaultdict(set)
|
||||
self._var_to_quotas = defaultdict(set)
|
||||
self._item_to_quotas = defaultdict(list)
|
||||
self._var_to_quotas = defaultdict(list)
|
||||
self._early_out = early_out
|
||||
self._quota_objects = {}
|
||||
self.results = {}
|
||||
@@ -243,16 +243,13 @@ class QuotaAvailability:
|
||||
quota_id__in=[q.pk for q in quotas]
|
||||
).values('quota_id', 'item_id')
|
||||
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(
|
||||
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:
|
||||
self._var_to_quotas[m['itemvariation_id']].add(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._var_to_quotas[m['itemvariation_id']].append(self._quota_objects[m['quota_id']])
|
||||
|
||||
self._compute_orders(quotas, q_items, q_vars, size_left)
|
||||
|
||||
@@ -381,10 +378,7 @@ class QuotaAvailability:
|
||||
Q(
|
||||
Q(
|
||||
Q(variation_id__isnull=True) &
|
||||
Q(item_id__in=(
|
||||
{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(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||
) | Q(
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
||||
) | Q(
|
||||
@@ -446,11 +440,6 @@ class QuotaAvailability:
|
||||
self.results[q] = Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
|
||||
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}
|
||||
subevents = {q.subevent_id for q in quotas}
|
||||
quota_ids = {q.pk for q in quotas}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user