forked from CGM_Public/pretix_original
Compare commits
279 Commits
fix-check-
...
release/4.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce4a6be7f7 | ||
|
|
cbfac15e23 | ||
|
|
3077292d15 | ||
|
|
2c831d5d6e | ||
|
|
be8d84be13 | ||
|
|
23c497e438 | ||
|
|
09643e47b9 | ||
|
|
1ef922cf56 | ||
|
|
b12ab02e89 | ||
|
|
cce98e0418 | ||
|
|
b8dd30b6dd | ||
|
|
ea9a96e124 | ||
|
|
b72dc0ce8e | ||
|
|
0a30fa70da | ||
|
|
add240a7b9 | ||
|
|
0b97198cff | ||
|
|
8f94d14479 | ||
|
|
0919d5dbca | ||
|
|
ff153164f8 | ||
|
|
b8e3d6c71d | ||
|
|
f782324d5f | ||
|
|
5259c8f33e | ||
|
|
079b72391c | ||
|
|
e9ba9a25df | ||
|
|
5858ed8d5c | ||
|
|
0b0ecf22bf | ||
|
|
3b1cd8e659 | ||
|
|
5e66809c7b | ||
|
|
c39328dd2a | ||
|
|
70ccd2fbe4 | ||
|
|
8c8e8031fc | ||
|
|
355b16e8e5 | ||
|
|
09c316ccba | ||
|
|
a1075840c6 | ||
|
|
b1a3ececad | ||
|
|
9624b1c505 | ||
|
|
d3589696d7 | ||
|
|
9523291651 | ||
|
|
b539f5e2f2 | ||
|
|
a18eb3be70 | ||
|
|
ac59bbff5d | ||
|
|
69f3e938f2 | ||
|
|
a0c1903ce5 | ||
|
|
3c8b188352 | ||
|
|
76e3b39f8f | ||
|
|
662e2cd116 | ||
|
|
eeaa3bc2a9 | ||
|
|
bbe8247606 | ||
|
|
5c46c1d14f | ||
|
|
651b676cfc | ||
|
|
5ee62c551e | ||
|
|
50e79b51de | ||
|
|
6e24c20a7a | ||
|
|
481a242054 | ||
|
|
f923c2fed0 | ||
|
|
228448b00f | ||
|
|
603345762a | ||
|
|
1812a23860 | ||
|
|
45374d0c94 | ||
|
|
c5f823596e | ||
|
|
eebb0a3527 | ||
|
|
bac1e8faf6 | ||
|
|
5cf7654099 | ||
|
|
988ef53972 | ||
|
|
36d20a45dd | ||
|
|
0691af7aa4 | ||
|
|
6b5436b71a | ||
|
|
a06a693c5c | ||
|
|
7b58ddbfde | ||
|
|
f18fb02d0b | ||
|
|
3a185b1cbc | ||
|
|
ba2a9fbd93 | ||
|
|
a337cf8efa | ||
|
|
616cc42b9c | ||
|
|
08012c42f2 | ||
|
|
08368684b0 | ||
|
|
17200df0cd | ||
|
|
28d1bedfc4 | ||
|
|
af90db9d1e | ||
|
|
19c4089da9 | ||
|
|
71723935e1 | ||
|
|
e2ad8f2f74 | ||
|
|
f8580a2789 | ||
|
|
cfeaa502a3 | ||
|
|
0ee8d6e9c3 | ||
|
|
a0e5717f7d | ||
|
|
49097037da | ||
|
|
62a6a11836 | ||
|
|
3d82058269 | ||
|
|
4f21bf8001 | ||
|
|
e32e7e2a50 | ||
|
|
5b8228bea0 | ||
|
|
a628f605a6 | ||
|
|
e658744f67 | ||
|
|
776c5e9fa2 | ||
|
|
46b5055aec | ||
|
|
ef227deb2e | ||
|
|
30cfe1ef3c | ||
|
|
4d5c828e2a | ||
|
|
f509306b35 | ||
|
|
706e479cff | ||
|
|
a5be7dcff5 | ||
|
|
845b3a866b | ||
|
|
91e1e079e1 | ||
|
|
9075c75a93 | ||
|
|
7b97204f2f | ||
|
|
dfedf09656 | ||
|
|
655cfe0afd | ||
|
|
faf17f824e | ||
|
|
fbf52a5219 | ||
|
|
9466c57c35 | ||
|
|
806ef8477b | ||
|
|
7cb654706a | ||
|
|
dea448e0f8 | ||
|
|
98b413249a | ||
|
|
4630c1fe8b | ||
|
|
bb718375e9 | ||
|
|
7d2dd722bd | ||
|
|
2adbd3cd4a | ||
|
|
fb483ad00e | ||
|
|
9cef65f359 | ||
|
|
ceeb69856b | ||
|
|
c184187e59 | ||
|
|
8ca38bdbaf | ||
|
|
3ae42b0c57 | ||
|
|
6368954ecb | ||
|
|
26ebdb7113 | ||
|
|
a1cb0b386b | ||
|
|
d46e1aba52 | ||
|
|
1f41184f9e | ||
|
|
2c746dffb2 | ||
|
|
84bd4e0e94 | ||
|
|
93f8b38745 | ||
|
|
4110d6ec15 | ||
|
|
9bea383ff0 | ||
|
|
2287c8b34c | ||
|
|
f7a129854e | ||
|
|
a96fccef63 | ||
|
|
dc5a85b39e | ||
|
|
23f9fb4a9a | ||
|
|
6130c45b3e | ||
|
|
83840c4024 | ||
|
|
02d1d1e0c3 | ||
|
|
f641f0fdd1 | ||
|
|
0c827c94a8 | ||
|
|
4fb76f1b55 | ||
|
|
cb3b1f3ac5 | ||
|
|
0b95f89882 | ||
|
|
bccd7cd1a4 | ||
|
|
9c33078a40 | ||
|
|
6403e5370a | ||
|
|
3fe2a0455f | ||
|
|
6956b198ae | ||
|
|
36f7a3d3a3 | ||
|
|
587e1a1c96 | ||
|
|
8707ab5277 | ||
|
|
4f6fa84fa7 | ||
|
|
e76d13bf8e | ||
|
|
39449ecbbe | ||
|
|
0204b42587 | ||
|
|
c1d1e437cc | ||
|
|
2fe0ceb4c7 | ||
|
|
4cba292b57 | ||
|
|
9e91197c5d | ||
|
|
10a8cf3758 | ||
|
|
d1deb35711 | ||
|
|
c4d2b0bff7 | ||
|
|
2d8ceb3255 | ||
|
|
176e5f115b | ||
|
|
9939793e91 | ||
|
|
7d3cd16785 | ||
|
|
7c5fac306a | ||
|
|
37683781d0 | ||
|
|
89dda69205 | ||
|
|
f2c72e5ff8 | ||
|
|
780ebfe120 | ||
|
|
c7d5b687f3 | ||
|
|
5fcb51f372 | ||
|
|
9b08f1b286 | ||
|
|
4f35be7a25 | ||
|
|
884dbff4b8 | ||
|
|
51768eaef9 | ||
|
|
45f579caf2 | ||
|
|
a29dbd88ac | ||
|
|
957337b091 | ||
|
|
4983073172 | ||
|
|
b99d21df69 | ||
|
|
2cfffe6526 | ||
|
|
87a413ea42 | ||
|
|
4146437380 | ||
|
|
b4a7369642 | ||
|
|
f9b51a8abb | ||
|
|
d69d70cfb1 | ||
|
|
ba2d908a89 | ||
|
|
c05abcbccd | ||
|
|
e16fd61bec | ||
|
|
a29d69d8f7 | ||
|
|
e063ad7dda | ||
|
|
7c2bacf3b5 | ||
|
|
c921ca4e65 | ||
|
|
29a36057ed | ||
|
|
5eeecf9214 | ||
|
|
5992abcb7d | ||
|
|
0db7ec3169 | ||
|
|
8046bf98b7 | ||
|
|
9ed39ab0fa | ||
|
|
7e79fc8b5e | ||
|
|
9da68645da | ||
|
|
f7a4b66da1 | ||
|
|
c9212a483b | ||
|
|
cc4e946d95 | ||
|
|
9d1cfd1eb6 | ||
|
|
38969747f4 | ||
|
|
6e7af4c64b | ||
|
|
fb45f9f08c | ||
|
|
6848ce24eb | ||
|
|
dac4fd8d3c | ||
|
|
6905d3e801 | ||
|
|
909b16be64 | ||
|
|
a18162cc47 | ||
|
|
6f0fc9ed49 | ||
|
|
2409c513d6 | ||
|
|
0a95f90012 | ||
|
|
edbd24e942 | ||
|
|
3940af868b | ||
|
|
8b4197d868 | ||
|
|
632e441c24 | ||
|
|
c73ede81ae | ||
|
|
c4b7aeaaa2 | ||
|
|
b5bd98336a | ||
|
|
5af52f6087 | ||
|
|
c5e4d06921 | ||
|
|
917cc00091 | ||
|
|
63cb88bfb8 | ||
|
|
ac1fe15b6c | ||
|
|
ddaa0570bc | ||
|
|
07352743f2 | ||
|
|
f99ef5fff2 | ||
|
|
9d686072e2 | ||
|
|
4e44a2809b | ||
|
|
370e4eafc2 | ||
|
|
b7ec372ebc | ||
|
|
60cdfe4029 | ||
|
|
74e14285ee | ||
|
|
8f56ab54a4 | ||
|
|
4ac58654a0 | ||
|
|
167eb06aeb | ||
|
|
9a0cc7e8c1 | ||
|
|
d4ff1808d5 | ||
|
|
0ff22786cb | ||
|
|
abfb53872c | ||
|
|
67f60a9e09 | ||
|
|
1d04d40507 | ||
|
|
14fdd7cfca | ||
|
|
402ed61756 | ||
|
|
66c75cbb1b | ||
|
|
c32791c7dd | ||
|
|
d6846d8415 | ||
|
|
b1c8efa33f | ||
|
|
f14d031de4 | ||
|
|
25c86db6f5 | ||
|
|
7205d0689e | ||
|
|
cde46012cb | ||
|
|
e4a0122938 | ||
|
|
77c08cb710 | ||
|
|
af49a02047 | ||
|
|
11495c80e3 | ||
|
|
00ab996640 | ||
|
|
a4f77b3e4a | ||
|
|
1839dcdb74 | ||
|
|
6bba37288e | ||
|
|
0c3a12b4d3 | ||
|
|
7e0b590e10 | ||
|
|
009f100375 | ||
|
|
4fdbe3912a | ||
|
|
d4af9130e0 | ||
|
|
d56e2de409 | ||
|
|
6a22cb3021 | ||
|
|
814e8fc73b |
7
.github/workflows/docs.yml
vendored
7
.github/workflows/docs.yml
vendored
@@ -14,10 +14,13 @@ on:
|
||||
- 'src/pretix/static/**'
|
||||
- 'src/tests/**'
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
jobs:
|
||||
spelling:
|
||||
name: Spellcheck
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.8
|
||||
@@ -31,7 +34,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system packages
|
||||
run: sudo apt update && sudo apt install enchant hunspell aspell-en
|
||||
run: sudo apt update && sudo apt install enchant-2 hunspell aspell-en
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -Ur requirements.txt
|
||||
working-directory: ./doc
|
||||
|
||||
9
.github/workflows/strings.yml
vendored
9
.github/workflows/strings.yml
vendored
@@ -12,9 +12,12 @@ on:
|
||||
- 'doc/**'
|
||||
- 'src/pretix/locale/**'
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
name: Check gettext syntax
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -40,7 +43,7 @@ jobs:
|
||||
run: python manage.py compilejsi18n
|
||||
working-directory: ./src
|
||||
spelling:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
name: Spellcheck
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -55,7 +58,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system packages
|
||||
run: sudo apt update && sudo apt install enchant hunspell hunspell-de-de aspell-en aspell-de
|
||||
run: sudo apt update && sudo apt install enchant-2 hunspell hunspell-de-de aspell-en aspell-de
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -e ".[dev]"
|
||||
working-directory: ./src
|
||||
|
||||
9
.github/workflows/style.yml
vendored
9
.github/workflows/style.yml
vendored
@@ -12,10 +12,13 @@ on:
|
||||
- 'src/pretix/locale/**'
|
||||
- 'src/pretix/static/**'
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
jobs:
|
||||
isort:
|
||||
name: isort
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.8
|
||||
@@ -36,7 +39,7 @@ jobs:
|
||||
working-directory: ./src
|
||||
flake:
|
||||
name: flake8
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.8
|
||||
@@ -57,7 +60,7 @@ jobs:
|
||||
working-directory: ./src
|
||||
licenseheader:
|
||||
name: licenseheaders
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.8
|
||||
|
||||
15
.github/workflows/tests.yml
vendored
15
.github/workflows/tests.yml
vendored
@@ -12,23 +12,26 @@ on:
|
||||
- 'doc/**'
|
||||
- 'src/pretix/locale/**'
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
name: Tests
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.8", "3.9"]
|
||||
python-version: ["3.7", "3.9", "3.10"]
|
||||
database: [sqlite, postgres, mysql]
|
||||
exclude:
|
||||
- database: mysql
|
||||
python-version: "3.8"
|
||||
python-version: "3.10"
|
||||
- database: mysql
|
||||
python-version: "3.9"
|
||||
- database: sqlite
|
||||
python-version: "3.7"
|
||||
- database: sqlite
|
||||
python-version: "3.8"
|
||||
python-version: "3.10"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: getong/mariadb-action@v1.1
|
||||
@@ -55,7 +58,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system dependencies
|
||||
run: sudo apt update && sudo apt install gettext mariadb-client-10.3
|
||||
run: sudo apt update && sudo apt install gettext mariadb-client
|
||||
- name: Install Python dependencies
|
||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||
working-directory: ./src
|
||||
@@ -76,4 +79,4 @@ jobs:
|
||||
with:
|
||||
file: src/coverage.xml
|
||||
fail_ci_if_error: true
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.8'
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.10'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
-r doc/requirements.txt
|
||||
15
.readthedocs.yaml
Normal file
15
.readthedocs.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
version: 2
|
||||
sphinx:
|
||||
configuration: doc/conf.py
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.8"
|
||||
nodejs: "16"
|
||||
apt_packages:
|
||||
- gettext
|
||||
python:
|
||||
install:
|
||||
- method: pip
|
||||
path: ./src/
|
||||
- requirements: doc/requirements.rtd.txt
|
||||
@@ -117,6 +117,9 @@ Example::
|
||||
``loglevel``
|
||||
Set console and file log level (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``.
|
||||
|
||||
``request_id_header``
|
||||
Specifies the name of a header that should be used for logging request IDs. Off by default.
|
||||
|
||||
Locale settings
|
||||
---------------
|
||||
|
||||
@@ -396,9 +399,9 @@ The two ``transport_options`` entries can be omitted in most cases.
|
||||
If they are present they need to be a valid JSON dictionary.
|
||||
For possible entries in that dictionary see the `Celery documentation`_.
|
||||
|
||||
To use redis with sentinels set the broker or backend to ``sentinel://sentinel_host_1:26379;sentinal_host_2:26379/0``
|
||||
To use redis with sentinels set the broker or backend to ``sentinel://sentinel_host_1:26379;sentinel_host_2:26379/0``
|
||||
and the respective transport_options to ``{"master_name":"mymaster"}``.
|
||||
If your redis instances behind the sentinel have a password use ``sentinel://:my_password@sentinel_host_1:26379;sentinal_host_2:26379/0``.
|
||||
If your redis instances behind the sentinel have a password use ``sentinel://:my_password@sentinel_host_1:26379;sentinel_host_2:26379/0``.
|
||||
If your redis sentinels themselves have a password set the transport_options to ``{"master_name":"mymaster","sentinel_kwargs":{"password":"my_password"}}``.
|
||||
|
||||
Sentry
|
||||
|
||||
@@ -17,8 +17,8 @@ The cart position resource contains the following public fields:
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the cart position
|
||||
cart_id string Identifier of the cart this belongs to. Needs to end
|
||||
in "@api" for API-created positions.
|
||||
cart_id string Identifier of the cart this belongs to, needs to end
|
||||
in "@api" for API-created positions
|
||||
datetime datetime Time of creation
|
||||
expires datetime The cart position will expire at this time and no longer block quota
|
||||
item integer ID of the item
|
||||
@@ -29,22 +29,23 @@ attendee_name_parts object of strings Composition of
|
||||
attendee_email string Specified attendee email address for this position (or ``null``)
|
||||
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
||||
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
|
||||
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
|
||||
is_bundled boolean If ``addon_to`` is set, this shows whether this is a bundled product or an addon product
|
||||
subevent integer ID of the date inside an event series this position belongs to (or ``null``)
|
||||
answers list of objects Answers to user-defined questions
|
||||
├ question integer Internal ID of the answered question
|
||||
├ answer string Text representation of the answer
|
||||
├ question_identifier string The question's ``identifier`` field
|
||||
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
|
||||
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||
seat objects The assigned seat. Can be ``null``.
|
||||
seat objects The assigned seat (or ``null``)
|
||||
├ id integer Internal ID of the seat instance
|
||||
├ name string Human-readable seat name
|
||||
└ seat_guid string Identifier of the seat within the seating plan
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
.. versionchanged:: 4.14
|
||||
|
||||
This ``seat`` attribute has been added.
|
||||
This ``is_bundled`` attribute has been added and the cart creation endpoints have been updated.
|
||||
|
||||
|
||||
Cart position endpoints
|
||||
@@ -87,6 +88,7 @@ Cart position endpoints
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"addon_to": null,
|
||||
"is_bundled": false,
|
||||
"subevent": null,
|
||||
"datetime": "2018-06-11T10:00:00Z",
|
||||
"expires": "2018-06-11T10:00:00Z",
|
||||
@@ -133,6 +135,7 @@ Cart position endpoints
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"addon_to": null,
|
||||
"is_bundled": false,
|
||||
"subevent": null,
|
||||
"datetime": "2018-06-11T10:00:00Z",
|
||||
"expires": "2018-06-11T10:00:00Z",
|
||||
@@ -168,7 +171,7 @@ Cart position endpoints
|
||||
|
||||
* does not validate if the event's ticket sales are already over or haven't started
|
||||
|
||||
* does not support add-on products at the moment
|
||||
* does not validate constraints on add-on products at the moment
|
||||
|
||||
* does not check or calculate prices but believes any prices you send
|
||||
|
||||
@@ -176,6 +179,8 @@ Cart position endpoints
|
||||
|
||||
* does not support file upload questions
|
||||
|
||||
Note that more validation might be added in the future, so please do not rely on missing validation.
|
||||
|
||||
You can supply the following fields of the resource:
|
||||
|
||||
* ``cart_id`` (optional, needs to end in ``@api``)
|
||||
@@ -190,6 +195,8 @@ Cart position endpoints
|
||||
* ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
|
||||
* ``sales_channel`` (optional)
|
||||
* ``voucher`` (optional, expect a voucher code)
|
||||
* ``addons`` (optional, expect a list of nested objects of cart positions)
|
||||
* ``bundled`` (optional, expect a list of nested objects of cart positions)
|
||||
* ``answers``
|
||||
|
||||
* ``question``
|
||||
@@ -221,6 +228,12 @@ Cart position endpoints
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"addons": [
|
||||
{
|
||||
"item": 2,
|
||||
"variation": null,
|
||||
}
|
||||
],
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
@@ -232,7 +245,7 @@ Cart position endpoints
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
(Full cart position resource, see above.)
|
||||
(Full cart position resource, see above, with additional nested objects "addons" and "bundled".)
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create a position for
|
||||
:param event: The ``slug`` field of the event to create a position for
|
||||
@@ -244,8 +257,8 @@ Cart position endpoints
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/bulk_create/
|
||||
|
||||
Creates multiple new cart position. This operation is deliberately not atomic, so each cart position can succeed
|
||||
or fail individually, so the response code of the response is not the only thing to look at!
|
||||
Creates multiple new cart position. **This operation is deliberately not atomic, so each cart position can succeed
|
||||
or fail individually, so the response code of the response is not the only thing to look at!**
|
||||
|
||||
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
|
||||
|
||||
|
||||
@@ -39,23 +39,6 @@ exit_all_at datetime Automatically c
|
||||
addon_match boolean If ``true``, tickets on this list can be redeemed by scanning their parent ticket if this still leads to an unambiguous match.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.9
|
||||
|
||||
The ``subevent`` attribute may now be ``null`` inside event series. The ``allow_multiple_entries``,
|
||||
``allow_entry_after_exit``, and ``rules`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 3.11
|
||||
|
||||
The ``subevent_match`` and ``exclude`` query parameters have been added.
|
||||
|
||||
.. versionchanged:: 3.12
|
||||
|
||||
The ``exit_all_at`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 3.17
|
||||
|
||||
The ``ends_after`` and ``expand`` query parameters have been added.
|
||||
|
||||
.. versionchanged:: 4.12
|
||||
|
||||
The ``addon_match`` attribute has been added.
|
||||
|
||||
@@ -52,34 +52,9 @@ sales_channels list A list of sales
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The attributes ``geo_lat`` and ``geo_lon`` have been added.
|
||||
|
||||
.. versionchanged:: 3.4
|
||||
|
||||
The attribute ``timezone`` has been added.
|
||||
|
||||
.. versionchanged:: 3.7
|
||||
|
||||
The attribute ``item_meta_properties`` has been added.
|
||||
|
||||
.. versionchanged:: 3.12
|
||||
|
||||
The attribute ``valid_keys`` has been added.
|
||||
|
||||
.. versionchanged:: 3.14
|
||||
|
||||
The attribute ``sales_channels`` has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The events resource can now be filtered by meta data attributes.
|
||||
|
||||
.. versionchanged:: 4.0
|
||||
|
||||
The ``clone_from`` parameter has been added to the event creation endpoint.
|
||||
@@ -567,10 +542,6 @@ information about the properties.
|
||||
.. warning:: This API is intended for advanced users. Even though we take care to validate your input, you will be
|
||||
able to break your event using this API by creating situations of conflicting settings. Please take care.
|
||||
|
||||
.. versionchanged:: 3.6
|
||||
|
||||
Initial support for settings has been added to the API.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/settings/
|
||||
|
||||
Get current values of event settings.
|
||||
|
||||
@@ -6,10 +6,6 @@ Data exporters
|
||||
pretix and it's plugins include a number of data exporters that allow you to bulk download various data from pretix in
|
||||
different formats. This page shows you how to use these exporters through the API.
|
||||
|
||||
.. versionchanged:: 3.13
|
||||
|
||||
This feature has been added to the API.
|
||||
|
||||
.. warning::
|
||||
|
||||
While we consider the methods listed on this page to be a stable API, the availability and specific input field
|
||||
|
||||
@@ -40,10 +40,6 @@ text string Custom text of
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionadded:: 3.14
|
||||
|
||||
The transaction list endpoint was added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/giftcards/
|
||||
|
||||
Returns a list of all gift cards issued by a given organizer.
|
||||
@@ -257,10 +253,6 @@ Endpoints
|
||||
"value": "15.37"
|
||||
}
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
This endpoint now returns status code ``409`` if the transaction would lead to a negative gift card value.
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the gift card to modify
|
||||
:query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer.
|
||||
|
||||
@@ -108,16 +108,6 @@ internal_reference string Customer's refe
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 3.4
|
||||
|
||||
The attribute ``lines.number`` has been added.
|
||||
|
||||
.. versionchanged:: 3.17
|
||||
|
||||
The attribute ``invoice_to_*``, ``invoice_from_*``, ``custom_field``, ``lines.item``, ``lines.variation``, ``lines.event_date_from``,
|
||||
``lines.event_date_to``, and ``lines.attendee_name`` have been added.
|
||||
``refers`` now returns an invoice number including the prefix.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The attributes ``fee_type`` and ``fee_internal_type`` have been added.
|
||||
|
||||
@@ -146,14 +146,6 @@ bundles list of objects Definition of b
|
||||
meta_data object Values set for event-specific meta data parameters.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.7
|
||||
|
||||
The attribute ``meta_data`` has been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The attribute ``multi_allowed`` has been added to ``addons``.
|
||||
|
||||
.. versionchanged:: 4.0
|
||||
|
||||
The attributes ``require_membership``, ``require_membership_types``, ``grant_membership_type``, ``grant_membership_duration_like_event``,
|
||||
|
||||
@@ -98,30 +98,6 @@ last_modified datetime Last modificati
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The ``order.fees.canceled`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 3.8
|
||||
|
||||
The ``reactivate`` operation has been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The ``search`` query parameter has been added.
|
||||
|
||||
.. versionchanged:: 3.11
|
||||
|
||||
The ``exclude`` and ``subevent_after`` query parameter has been added.
|
||||
|
||||
.. versionchanged:: 3.13
|
||||
|
||||
The ``subevent_before`` query parameter has been added.
|
||||
|
||||
.. versionchanged:: 3.14
|
||||
|
||||
The ``phone`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 4.0
|
||||
|
||||
The ``customer`` attribute has been added.
|
||||
@@ -142,6 +118,10 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``order.fees.id`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 4.15
|
||||
|
||||
The ``include`` query parameter has been added.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -178,6 +158,7 @@ tax_rule integer The ID of the u
|
||||
secret string Secret code printed on the tickets for validation
|
||||
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
|
||||
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
|
||||
discount integer ID of a discount that has been used during the creation of this position in some way (or ``null``).
|
||||
pseudonymization_id string A random ID, e.g. for use in lead scanning apps
|
||||
checkins list of objects List of **successful** check-ins with this ticket
|
||||
├ id integer Internal ID of the check-in event
|
||||
@@ -205,27 +186,6 @@ pdf_data object Data object req
|
||||
``pdf_data=true`` query parameter to your request.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See
|
||||
:ref:`order-position-ticket-download` for details.
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The attribute ``canceled`` has been added.
|
||||
|
||||
.. versionchanged:: 3.8
|
||||
|
||||
The attributes ``company``, ``street``, ``zipcode``, ``city``, ``country``, and ``state`` have been added.
|
||||
|
||||
.. versionchanged:: 3.9
|
||||
|
||||
The ``checkin.type`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 3.16
|
||||
|
||||
Answers to file questions are now returned as an URL.
|
||||
|
||||
.. _order-payment-resource:
|
||||
|
||||
Order payment resource
|
||||
@@ -272,15 +232,20 @@ created datetime Date and time o
|
||||
comment string Reason for refund (shown to the customer in some cases, can be ``null``).
|
||||
execution_date datetime Date and time of completion of this refund (or ``null``)
|
||||
provider string Identification string of the payment provider
|
||||
details object Refund-specific information. This is a dictionary
|
||||
with various fields that can be different between
|
||||
payment providers, versions, payment states, etc. If
|
||||
you read this field, you always need to be able to
|
||||
deal with situations where values that you expect are
|
||||
missing. Mostly, the field contains various IDs that
|
||||
can be used for matching with other systems. If a
|
||||
payment provider does not implement this feature,
|
||||
the object is empty.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
List of all orders
|
||||
------------------
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/
|
||||
|
||||
Returns a list of all orders within a given event.
|
||||
@@ -371,6 +336,7 @@ List of all orders
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"discount": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
@@ -447,6 +413,7 @@ List of all orders
|
||||
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
|
||||
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
|
||||
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
|
||||
:query string include: Include only the given field in the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times. ``include`` is applied before ``exclude``, so ``exclude`` takes precedence.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
|
||||
@@ -458,10 +425,6 @@ List of all orders
|
||||
Fetching individual orders
|
||||
--------------------------
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
|
||||
|
||||
Returns information on one order, identified by its order code.
|
||||
@@ -546,6 +509,7 @@ Fetching individual orders
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"discount": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
@@ -1035,10 +999,6 @@ Creating orders
|
||||
Order state operations
|
||||
----------------------
|
||||
|
||||
.. versionchanged:: 3.12
|
||||
|
||||
The ``mark_paid`` operation now takes a ``send_email`` parameter.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/
|
||||
|
||||
Marks a pending or expired order as successfully paid.
|
||||
@@ -1440,10 +1400,6 @@ Sending e-mails
|
||||
List of all order positions
|
||||
---------------------------
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
||||
|
||||
Returns a list of all order positions within a given event.
|
||||
@@ -1487,6 +1443,7 @@ List of all order positions
|
||||
"tax_rule": null,
|
||||
"tax_value": "0.00",
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"discount": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"addon_to": null,
|
||||
@@ -1597,6 +1554,7 @@ Fetching individual positions
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"discount": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
@@ -1696,10 +1654,6 @@ Order position ticket download
|
||||
Manipulating individual positions
|
||||
---------------------------------
|
||||
|
||||
.. versionchanged:: 3.15
|
||||
|
||||
The ``PATCH`` method has been added for individual positions.
|
||||
|
||||
.. versionchanged:: 4.8
|
||||
|
||||
The ``PATCH`` method now supports changing items, variations, subevents, seats, prices, and tax rules.
|
||||
@@ -1925,7 +1879,7 @@ otherwise, such as splitting an order or changing fees.
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/change/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
@@ -2006,14 +1960,6 @@ otherwise, such as splitting an order or changing fees.
|
||||
Order payment endpoints
|
||||
-----------------------
|
||||
|
||||
.. versionchanged:: 3.6
|
||||
|
||||
Payments can now be created through the API.
|
||||
|
||||
.. versionchanged:: 3.12
|
||||
|
||||
The ``confirm`` operation now takes a ``send_email`` parameter.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
|
||||
|
||||
Returns a list of all payments for an order.
|
||||
@@ -2319,6 +2265,7 @@ Order refund endpoints
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"execution_date": "2017-12-04T12:13:12Z",
|
||||
"comment": "Cancellation",
|
||||
"details": {},
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
]
|
||||
@@ -2362,6 +2309,7 @@ Order refund endpoints
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"execution_date": "2017-12-04T12:13:12Z",
|
||||
"comment": "Cancellation",
|
||||
"details": {},
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
|
||||
@@ -2419,6 +2367,7 @@ Order refund endpoints
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"execution_date": null,
|
||||
"comment": "Cancellation",
|
||||
"details": {},
|
||||
"provider": "manual"
|
||||
}
|
||||
|
||||
@@ -2548,10 +2497,6 @@ Revoked ticket secrets
|
||||
|
||||
With some non-default ticket secret generation methods, a list of revoked ticket secrets is required for proper validation.
|
||||
|
||||
.. versionchanged:: 3.12
|
||||
|
||||
Added revocation lists.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/revokedsecrets/
|
||||
|
||||
Returns a list of all revoked secrets within a given event.
|
||||
|
||||
@@ -109,10 +109,6 @@ information about the properties.
|
||||
.. warning:: This API is intended for advanced users. Even though we take care to validate your input, you will be
|
||||
able to break your shops using this API by creating situations of conflicting settings. Please take care.
|
||||
|
||||
.. versionchanged:: 3.14
|
||||
|
||||
Initial support for settings has been added to the API.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/settings/
|
||||
|
||||
Get current values of organizer settings.
|
||||
|
||||
@@ -76,26 +76,9 @@ dependency_value string An old version
|
||||
for one value. **Deprecated.**
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The attribute ``help_text`` has been added.
|
||||
|
||||
.. versionchanged:: 3.14
|
||||
|
||||
The attributes ``valid_*`` have been added.
|
||||
|
||||
.. versionchanged:: 3.18
|
||||
|
||||
The attribute ``valid_file_portrait`` have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The questions endpoint has been extended by the filter queries ``ask_during_checkin``, ``requred``, and
|
||||
``identifier``.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/
|
||||
|
||||
Returns a list of all questions within a given event.
|
||||
|
||||
@@ -36,10 +36,6 @@ available_number integer Number of avail
|
||||
slightly out of date. ``null`` means unlimited.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The attribute ``release_after_exit`` has been added.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``with_availability`` query parameter has been added.
|
||||
|
||||
@@ -59,29 +59,13 @@ seat_category_mapping object An object mappi
|
||||
last_modified datetime Last modification of this object
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
.. versionchanged:: 4.15
|
||||
|
||||
The attributes ``geo_lat`` and ``geo_lon`` have been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The ``disabled`` attribute has been added to ``item_price_overrides`` and ``variation_price_overrides``.
|
||||
|
||||
.. versionchanged:: 3.12
|
||||
|
||||
The ``last_modified`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 3.18
|
||||
|
||||
The ``available_from``/``available_until`` attributes have been added to ``item_price_overrides`` and ``variation_price_overrides``.
|
||||
The ``search`` query parameter has been added to filter sub-events by their name or location in any language.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The sub-events resource can now be filtered by meta data attributes.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The ``with_availability_for`` parameter has been added.
|
||||
@@ -147,6 +131,7 @@ Endpoints
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
|
||||
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
|
||||
:query search: Only return events matching a given search query.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the main event
|
||||
:query datetime modified_since: Only return objects that have changed since the given date. Be careful: This does not
|
||||
|
||||
@@ -19,6 +19,8 @@ max_usages integer The maximum num
|
||||
redeemed (default: 1).
|
||||
redeemed integer The number of times this voucher already has been
|
||||
redeemed.
|
||||
min_usages integer The minimum number of times this voucher must be
|
||||
redeemed on first usage (default: 1).
|
||||
valid_until datetime The voucher expiration date (or ``null``).
|
||||
block_quota boolean If ``true``, quota is blocked for this voucher.
|
||||
allow_ignore_quota boolean If ``true``, this voucher can be redeemed even if a
|
||||
@@ -48,10 +50,6 @@ show_hidden_items boolean Only if set to
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 3.4
|
||||
|
||||
The attribute ``seat`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
|
||||
@@ -30,12 +30,6 @@ subevent integer ID of the date
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added as well as a method to send out
|
||||
vouchers.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
|
||||
@@ -60,7 +60,13 @@ The exporter class
|
||||
.. py:attribute:: BaseExporter.event
|
||||
|
||||
The default constructor sets this property to the event we are currently
|
||||
working for.
|
||||
working for. This will be ``None`` if the exporter is run for multiple
|
||||
events.
|
||||
|
||||
.. py:attribute:: BaseExporter.events
|
||||
|
||||
The default constructor sets this property to the list of events to work
|
||||
on, regardless of whether the exporter is called for one or multiple events.
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ The provider class
|
||||
|
||||
.. automethod:: api_payment_details
|
||||
|
||||
.. automethod:: api_refund_details
|
||||
|
||||
.. automethod:: matching_id
|
||||
|
||||
.. automethod:: shred_payment_info
|
||||
@@ -136,6 +138,10 @@ The provider class
|
||||
|
||||
.. autoattribute:: is_meta
|
||||
|
||||
.. autoattribute:: execute_payment_needs_user
|
||||
|
||||
.. autoattribute:: multi_use_supported
|
||||
|
||||
.. autoattribute:: test_mode_message
|
||||
|
||||
.. autoattribute:: requires_invoice_immediately
|
||||
|
||||
@@ -184,11 +184,6 @@ Most of these methods work identically on :class:`pretix.base.models.TeamAPIToke
|
||||
Staff sessions
|
||||
--------------
|
||||
|
||||
.. versionchanged:: 1.14
|
||||
|
||||
In 1.14, the ``User.is_superuser`` attribute has been deprecated and statically set to return ``False``. Staff
|
||||
sessions have been newly introduced.
|
||||
|
||||
System administrators of a pretix instance are identified by the ``is_staff`` attribute on the user model. By default,
|
||||
the regular permission rules apply for users with ``is_staff = True``. The only difference is that such users can
|
||||
temporarily turn on "staff mode" via a button in the user interface that grants them **all permissions** as long as
|
||||
|
||||
@@ -93,6 +93,7 @@ id integer Internal conten
|
||||
title multi-lingual string The content title (required)
|
||||
content_type string The type of content, valid values are ``webinar``, ``video``, ``livestream``, ``link``, ``file``
|
||||
url string The location of the digital content
|
||||
file file A downloadable file. Either ``url`` or ``file`` must be ``null``.
|
||||
description multi-lingual string A public description of the item. May contain Markdown
|
||||
syntax and is not required.
|
||||
available_from datetime The first date time at which this content will be shown
|
||||
@@ -144,6 +145,7 @@ API Endpoints
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"file": null,
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
@@ -191,6 +193,7 @@ API Endpoints
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"file": null,
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
@@ -229,6 +232,7 @@ API Endpoints
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"file": null,
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
@@ -255,6 +259,7 @@ API Endpoints
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"file": null,
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
@@ -309,6 +314,7 @@ API Endpoints
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://mywebsite.com",
|
||||
"file": null,
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
|
||||
10
doc/requirements.rtd.txt
Normal file
10
doc/requirements.rtd.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
sphinx==2.3.*
|
||||
jinja2==3.0.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
sphinxcontrib-images
|
||||
sphinxcontrib-spelling==4.*
|
||||
sphinxemoji
|
||||
pygments-markdown-lexer
|
||||
# See https://github.com/rfk/pyenchant/pull/130
|
||||
git+https://github.com/raphaelm/pyenchant.git@patch-1#egg=pyenchant
|
||||
@@ -447,8 +447,4 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
</script>
|
||||
|
||||
|
||||
.. versionchanged:: 3.6
|
||||
|
||||
Dynamically opening the widget has been added in pretix 3.6.
|
||||
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
|
||||
build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.6
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "4.13.0.dev0"
|
||||
__version__ = "4.15.1"
|
||||
|
||||
@@ -46,6 +46,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('GET', 'api-v1:device.info'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -80,6 +81,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('GET', 'api-v1:device.info'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -112,6 +114,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('GET', 'api-v1:device.info'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -145,6 +148,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('GET', 'api-v1:device.info'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -192,6 +196,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'plugins:pretix_posbackend:posdebuglogentry-bulk-create'),
|
||||
('GET', 'plugins:pretix_posbackend:poscashier-list'),
|
||||
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
|
||||
('POST', 'plugins:pretix_posbackend:stripeterminal.paymentintent'),
|
||||
('PUT', 'plugins:pretix_posbackend:file.upload'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
|
||||
@@ -19,11 +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 logging
|
||||
|
||||
import ujson
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler, status
|
||||
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def custom_exception_handler(exc, context):
|
||||
response = exception_handler(exc, context)
|
||||
@@ -37,4 +43,7 @@ def custom_exception_handler(exc, context):
|
||||
}
|
||||
)
|
||||
|
||||
if isinstance(exc, exceptions.APIException):
|
||||
logger.info(f'API Exception [{exc.status_code}]: {ujson.dumps(exc.detail)}')
|
||||
|
||||
return response
|
||||
|
||||
@@ -23,8 +23,7 @@ import os
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.files import File
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.db.models import prefetch_related_objects
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy
|
||||
from rest_framework import serializers
|
||||
@@ -34,7 +33,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
|
||||
)
|
||||
from pretix.base.models import Quota, Seat, Voucher
|
||||
from pretix.base.models import Seat, Voucher
|
||||
from pretix.base.models.orders import CartPosition
|
||||
|
||||
|
||||
@@ -52,148 +51,18 @@ class CartPositionSerializer(I18nAwareModelSerializer):
|
||||
model = CartPosition
|
||||
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
|
||||
'answers', 'seat')
|
||||
'answers', 'seat', 'is_bundled')
|
||||
|
||||
|
||||
class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
expires = serializers.DateTimeField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
seat = serializers.CharField(required=False, allow_null=True)
|
||||
sales_channel = serializers.CharField(required=False, default='sales_channel')
|
||||
includes_tax = serializers.BooleanField(required=False, allow_null=True)
|
||||
voucher = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel', 'voucher')
|
||||
|
||||
def create(self, validated_data):
|
||||
answers_data = validated_data.pop('answers')
|
||||
if not validated_data.get('cart_id'):
|
||||
cid = "{}@api".format(get_random_string(48))
|
||||
while CartPosition.objects.filter(cart_id=cid).exists():
|
||||
cid = "{}@api".format(get_random_string(48))
|
||||
validated_data['cart_id'] = cid
|
||||
|
||||
if not validated_data.get('expires'):
|
||||
validated_data['expires'] = now() + timedelta(
|
||||
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
|
||||
)
|
||||
|
||||
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
|
||||
if validated_data.get('variation')
|
||||
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
|
||||
if len(new_quotas) == 0:
|
||||
raise ValidationError(
|
||||
gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(validated_data.get('item'))
|
||||
)
|
||||
)
|
||||
for quota in new_quotas:
|
||||
avail = quota.availability(_cache=self.context['quota_cache'])
|
||||
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
|
||||
raise ValidationError(
|
||||
gettext_lazy('There is not enough quota available on quota "{}" to perform '
|
||||
'the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
)
|
||||
|
||||
for quota in new_quotas:
|
||||
oldsize = self.context['quota_cache'][quota.pk][1]
|
||||
newsize = oldsize - 1 if oldsize is not None else None
|
||||
self.context['quota_cache'][quota.pk] = (
|
||||
Quota.AVAILABILITY_OK if newsize is None or newsize > 0 else Quota.AVAILABILITY_GONE,
|
||||
newsize
|
||||
)
|
||||
|
||||
attendee_name = validated_data.pop('attendee_name', '')
|
||||
if attendee_name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
|
||||
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
|
||||
if validated_data.get('seat'):
|
||||
if not seated:
|
||||
raise ValidationError('The specified product does not allow to choose a seat.')
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError('The specified seat does not exist.')
|
||||
except Seat.MultipleObjectsReturned:
|
||||
raise ValidationError('The specified seat ID is not unique.')
|
||||
else:
|
||||
validated_data['seat'] = seat
|
||||
elif seated:
|
||||
raise ValidationError('The specified product requires to choose a seat.')
|
||||
|
||||
if validated_data.get('voucher'):
|
||||
try:
|
||||
voucher = self.context['event'].vouchers.get(code__iexact=validated_data.get('voucher'))
|
||||
except Voucher.DoesNotExist:
|
||||
raise ValidationError('The specified voucher does not exist.')
|
||||
|
||||
if voucher and not voucher.applies_to(validated_data.get('item'), validated_data.get('variation')):
|
||||
raise ValidationError('The specified voucher is not valid for the given item and variation.')
|
||||
|
||||
if voucher and voucher.seat and voucher.seat != validated_data.get('seat'):
|
||||
raise ValidationError('The specified voucher is not valid for this seat.')
|
||||
|
||||
if voucher and voucher.subevent_id and (not validated_data.get('subevent') or voucher.subevent_id != validated_data['subevent'].pk):
|
||||
raise ValidationError('The specified voucher is not valid for this subevent.')
|
||||
|
||||
if voucher.valid_until is not None and voucher.valid_until < now():
|
||||
raise ValidationError('The specified voucher is expired.')
|
||||
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=self.context['event']) & Q(expires__gte=now())
|
||||
)
|
||||
cart_count = redeemed_in_carts.count()
|
||||
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
||||
if v_avail < 1:
|
||||
raise ValidationError('The specified voucher has already been used the maximum number of times.')
|
||||
|
||||
validated_data['voucher'] = voucher
|
||||
|
||||
if validated_data.get('seat'):
|
||||
if not validated_data['seat'].is_available(
|
||||
sales_channel=validated_data.get('sales_channel', 'web'),
|
||||
distance_ignore_cart_id=validated_data['cart_id'],
|
||||
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
|
||||
):
|
||||
raise ValidationError(
|
||||
gettext_lazy('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name))
|
||||
|
||||
validated_data.pop('sales_channel')
|
||||
# todo: does this make sense?
|
||||
validated_data['custom_price_input'] = validated_data['price']
|
||||
# todo: listed price, etc?
|
||||
# currently does not matter because there is no way to transform an API cart position into an order that keeps
|
||||
# prices, cart positions are just quota/voucher placeholders
|
||||
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options')
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = cp.answers.create(**answ_data, answer='')
|
||||
answ.file.save(os.path.basename(an.name), an, save=False)
|
||||
answ.answer = 'file://' + answ.file.name
|
||||
answ.save()
|
||||
an.close()
|
||||
else:
|
||||
answ = cp.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
return cp
|
||||
|
||||
def validate_cart_id(self, cid):
|
||||
if cid and not cid.endswith('@api'):
|
||||
raise ValidationError('Cart ID should end in @api or be empty.')
|
||||
return cid
|
||||
fields = ('item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'includes_tax', 'answers')
|
||||
|
||||
def validate_item(self, item):
|
||||
if item.event != self.context['event']:
|
||||
@@ -240,4 +109,180 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
|
||||
if not data.get('expires'):
|
||||
data['expires'] = now() + timedelta(
|
||||
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
|
||||
)
|
||||
|
||||
quotas_for_item_cache = self.context.get('quotas_for_item_cache', {})
|
||||
quotas_for_variation_cache = self.context.get('quotas_for_variation_cache', {})
|
||||
|
||||
seated = data.get('item').seat_category_mappings.filter(subevent=data.get('subevent')).exists()
|
||||
if data.get('seat'):
|
||||
if not seated:
|
||||
raise ValidationError({'seat': ['The specified product does not allow to choose a seat.']})
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=data['seat'], subevent=data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError({'seat': ['The specified seat does not exist.']})
|
||||
except Seat.MultipleObjectsReturned:
|
||||
raise ValidationError({'seat': ['The specified seat ID is not unique.']})
|
||||
else:
|
||||
data['seat'] = seat
|
||||
elif seated:
|
||||
raise ValidationError({'seat': ['The specified product requires to choose a seat.']})
|
||||
|
||||
if data.get('voucher'):
|
||||
try:
|
||||
voucher = self.context['event'].vouchers.get(code__iexact=data['voucher'])
|
||||
except Voucher.DoesNotExist:
|
||||
raise ValidationError({'voucher': ['The specified voucher does not exist.']})
|
||||
|
||||
if voucher and not voucher.applies_to(data['item'], data.get('variation')):
|
||||
raise ValidationError({'voucher': ['The specified voucher is not valid for the given item and variation.']})
|
||||
|
||||
if voucher and voucher.seat and voucher.seat != data.get('seat'):
|
||||
raise ValidationError({'voucher': ['The specified voucher is not valid for this seat.']})
|
||||
|
||||
if voucher and voucher.subevent_id and (not data.get('subevent') or voucher.subevent_id != data['subevent'].pk):
|
||||
raise ValidationError({'voucher': ['The specified voucher is not valid for this subevent.']})
|
||||
|
||||
if voucher.valid_until is not None and voucher.valid_until < now():
|
||||
raise ValidationError({'voucher': ['The specified voucher is expired.']})
|
||||
|
||||
data['voucher'] = voucher
|
||||
|
||||
if not data.get('voucher') or (not data['voucher'].allow_ignore_quota and not data['voucher'].block_quota):
|
||||
if data.get('variation'):
|
||||
if data['variation'].pk not in quotas_for_variation_cache:
|
||||
quotas_for_variation_cache[data['variation'].pk] = data['variation'].quotas.filter(subevent=data.get('subevent'))
|
||||
data['_quotas'] = quotas_for_variation_cache[data['variation'].pk]
|
||||
else:
|
||||
if data['item'].pk not in quotas_for_item_cache:
|
||||
quotas_for_item_cache[data['item'].pk] = data['item'].quotas.filter(subevent=data.get('subevent'))
|
||||
data['_quotas'] = quotas_for_item_cache[data['item'].pk]
|
||||
|
||||
if len(data['_quotas']) == 0:
|
||||
raise ValidationError(
|
||||
gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(data.get('item'))
|
||||
)
|
||||
)
|
||||
else:
|
||||
data['_quotas'] = []
|
||||
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop('_quotas')
|
||||
answers_data = validated_data.pop('answers')
|
||||
|
||||
attendee_name = validated_data.pop('attendee_name', '')
|
||||
if attendee_name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
|
||||
# todo: does this make sense?
|
||||
validated_data['custom_price_input'] = validated_data['price']
|
||||
# todo: listed price, etc?
|
||||
# currently does not matter because there is no way to transform an API cart position into an order that keeps
|
||||
# prices, cart positions are just quota/voucher placeholders
|
||||
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options')
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = cp.answers.create(**answ_data, answer='')
|
||||
answ.file.save(os.path.basename(an.name), an, save=False)
|
||||
answ.answer = 'file://' + answ.file.name
|
||||
answ.save()
|
||||
an.close()
|
||||
else:
|
||||
answ = cp.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
return cp
|
||||
|
||||
|
||||
class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
|
||||
expires = serializers.DateTimeField(required=False)
|
||||
addons = BaseCartPositionCreateSerializer(many=True, required=False)
|
||||
bundled = BaseCartPositionCreateSerializer(many=True, required=False)
|
||||
seat = serializers.CharField(required=False, allow_null=True)
|
||||
sales_channel = serializers.CharField(required=False, default='sales_channel')
|
||||
voucher = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = BaseCartPositionCreateSerializer.Meta.fields + (
|
||||
'cart_id', 'expires', 'addons', 'bundled', 'seat', 'sales_channel', 'voucher'
|
||||
)
|
||||
|
||||
def validate_cart_id(self, cid):
|
||||
if cid and not cid.endswith('@api'):
|
||||
raise ValidationError('Cart ID should end in @api or be empty.')
|
||||
return cid
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop('sales_channel')
|
||||
addons_data = validated_data.pop('addons', None)
|
||||
bundled_data = validated_data.pop('bundled', None)
|
||||
|
||||
cp = super().create(validated_data)
|
||||
|
||||
if addons_data:
|
||||
for addon_data in addons_data:
|
||||
addon_data['addon_to'] = cp
|
||||
addon_data['is_bundled'] = False
|
||||
addon_data['cart_id'] = cp.cart_id
|
||||
super().create(addon_data)
|
||||
|
||||
if bundled_data:
|
||||
for bundle_data in bundled_data:
|
||||
bundle_data['addon_to'] = cp
|
||||
bundle_data['is_bundled'] = True
|
||||
bundle_data['cart_id'] = cp.cart_id
|
||||
super().create(bundle_data)
|
||||
|
||||
return cp
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
# This is currently only a very basic validation of add-ons and bundled products, we don't validate their number
|
||||
# or price. We can always go stricter, as the endpoint is documented as experimental.
|
||||
# However, this serializer should always be *at least* as strict as the order creation serializer.
|
||||
|
||||
if data.get('item') and data.get('addons'):
|
||||
prefetch_related_objects([data['item']], 'addons')
|
||||
for sub_data in data['addons']:
|
||||
if not any(a.addon_category_id == sub_data['item'].category_id for a in data['item'].addons.all()):
|
||||
raise ValidationError({
|
||||
'addons': [
|
||||
'The product "{prod}" can not be used as an add-on product for "{main}".'.format(
|
||||
prod=str(sub_data['item']),
|
||||
main=str(data['item']),
|
||||
)
|
||||
]
|
||||
})
|
||||
|
||||
if data.get('item') and data.get('bundled'):
|
||||
prefetch_related_objects([data['item']], 'bundles')
|
||||
for sub_data in data['bundled']:
|
||||
if not any(
|
||||
a.bundled_item_id == sub_data['item'].pk and
|
||||
a.bundled_variation_id == (sub_data['variation'].pk if sub_data.get('variation') else None)
|
||||
for a in data['item'].bundles.all()
|
||||
):
|
||||
raise ValidationError({
|
||||
'bundled': [
|
||||
'The product "{prod}" can not be used as an bundled product for "{main}".'.format(
|
||||
prod=str(sub_data['item']),
|
||||
main=str(data['item']),
|
||||
)
|
||||
]
|
||||
})
|
||||
return data
|
||||
|
||||
@@ -411,7 +411,8 @@ class CloneEventSerializer(EventSerializer):
|
||||
has_subevents = validated_data.pop('has_subevents', None)
|
||||
tz = validated_data.pop('timezone', None)
|
||||
sales_channels = validated_data.pop('sales_channels', None)
|
||||
new_event = super().create(validated_data)
|
||||
date_admission = validated_data.pop('date_admission', None)
|
||||
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)
|
||||
@@ -426,6 +427,10 @@ class CloneEventSerializer(EventSerializer):
|
||||
new_event.sales_channels = sales_channels
|
||||
if has_subevents is not None:
|
||||
new_event.has_subevents = has_subevents
|
||||
if has_subevents is not None:
|
||||
new_event.has_subevents = has_subevents
|
||||
if date_admission is not None:
|
||||
new_event.date_admission = date_admission
|
||||
new_event.save()
|
||||
if tz:
|
||||
new_event.settings.timezone = tz
|
||||
@@ -755,6 +760,9 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_logo_image',
|
||||
'cancel_allow_user',
|
||||
'cancel_allow_user_until',
|
||||
'cancel_allow_user_unpaid_keep',
|
||||
'cancel_allow_user_unpaid_keep_fees',
|
||||
'cancel_allow_user_unpaid_keep_percentage',
|
||||
'cancel_allow_user_paid',
|
||||
'cancel_allow_user_paid_until',
|
||||
'cancel_allow_user_paid_keep',
|
||||
|
||||
@@ -23,6 +23,8 @@ from django import forms
|
||||
from django.http import QueryDict
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
|
||||
|
||||
class FormFieldWrapperField(serializers.Field):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -49,7 +51,6 @@ simple_mappings = (
|
||||
(forms.EmailField, serializers.EmailField, ()),
|
||||
(forms.UUIDField, serializers.UUIDField, ()),
|
||||
(forms.URLField, serializers.URLField, ()),
|
||||
(forms.NullBooleanField, serializers.NullBooleanField, ()),
|
||||
(forms.BooleanField, serializers.BooleanField, ()),
|
||||
)
|
||||
|
||||
@@ -87,7 +88,7 @@ class JobRunSerializer(serializers.Serializer):
|
||||
ex = kwargs.pop('exporter')
|
||||
events = kwargs.pop('events', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
if events is not None:
|
||||
if events is not None and not isinstance(ex, OrganizerLevelExportMixin):
|
||||
self.fields["events"] = serializers.SlugRelatedField(
|
||||
queryset=events,
|
||||
required=True,
|
||||
@@ -106,6 +107,12 @@ class JobRunSerializer(serializers.Serializer):
|
||||
)
|
||||
break
|
||||
|
||||
if isinstance(v, forms.NullBooleanField):
|
||||
self.fields[k] = serializers.BooleanField(
|
||||
required=v.required,
|
||||
allow_null=True,
|
||||
validators=v.validators,
|
||||
)
|
||||
if isinstance(v, forms.ModelMultipleChoiceField):
|
||||
self.fields[k] = PrimaryKeyRelatedField(
|
||||
queryset=v.queryset,
|
||||
|
||||
@@ -184,6 +184,8 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['default_price'].allow_null = False
|
||||
self.fields['default_price'].required = True
|
||||
if not self.read_only:
|
||||
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
|
||||
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
|
||||
|
||||
@@ -29,6 +29,7 @@ import pycountry
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.db.models import F, Q
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy
|
||||
from django_countries.fields import Country
|
||||
@@ -61,14 +62,25 @@ from pretix.base.services.pricing import (
|
||||
)
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompatibleCountryField(serializers.Field):
|
||||
countries = CachedCountries()
|
||||
default_error_messages = {
|
||||
'invalid_choice': gettext_lazy('"{input}" is not a valid choice.')
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return {self.field_name: Country(data)}
|
||||
country = self.countries.alpha2(data)
|
||||
if data and not country:
|
||||
country = self.countries.by_name(force_str(data))
|
||||
if not country:
|
||||
self.fail("invalid_choice", input=data)
|
||||
return {self.field_name: Country(country)}
|
||||
|
||||
def to_representation(self, instance: InvoiceAddress):
|
||||
if instance.country:
|
||||
@@ -410,13 +422,13 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
||||
'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')
|
||||
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'
|
||||
'seat', 'canceled', 'discount',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -553,12 +565,22 @@ class OrderPaymentSerializer(I18nAwareModelSerializer):
|
||||
'details')
|
||||
|
||||
|
||||
class RefundDetailsField(serializers.Field):
|
||||
def to_representation(self, value: OrderRefund):
|
||||
pp = value.payment_provider
|
||||
if not pp:
|
||||
return {}
|
||||
return pp.api_refund_details(value)
|
||||
|
||||
|
||||
class OrderRefundSerializer(I18nAwareModelSerializer):
|
||||
payment = SlugRelatedField(slug_field='local_id', read_only=True)
|
||||
details = RefundDetailsField(source='*', allow_null=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderRefund
|
||||
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'comment', 'provider')
|
||||
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'comment', 'provider',
|
||||
'details')
|
||||
|
||||
|
||||
class OrderURLField(serializers.URLField):
|
||||
@@ -600,6 +622,23 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
if not self.context['pdf_data']:
|
||||
self.fields['positions'].child.fields.pop('pdf_data', None)
|
||||
|
||||
includes = set(self.context['include'])
|
||||
if includes:
|
||||
for fname, field in list(self.fields.items()):
|
||||
if fname in includes:
|
||||
continue
|
||||
elif hasattr(field, 'child'):
|
||||
found_any = False
|
||||
for childfname, childfield in list(field.child.fields.items()):
|
||||
if f'{fname}.{childfname}' not in includes:
|
||||
field.child.fields.pop(childfname)
|
||||
else:
|
||||
found_any = True
|
||||
if not found_any:
|
||||
self.fields.pop(fname)
|
||||
else:
|
||||
self.fields.pop(fname)
|
||||
|
||||
for exclude_field in self.context['exclude']:
|
||||
p = exclude_field.split('.')
|
||||
if p[0] in self.fields:
|
||||
@@ -721,7 +760,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
|
||||
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -1086,6 +1125,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
|
||||
if pos_data.get('seat'):
|
||||
if pos_data.get('addon_to'):
|
||||
errs[i]['seat'] = ['Seats are currently not supported for add-on products.']
|
||||
continue
|
||||
|
||||
if not seated:
|
||||
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
|
||||
try:
|
||||
@@ -1281,6 +1324,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
if not simulate:
|
||||
for cp in delete_cps:
|
||||
if cp.addon_to_id:
|
||||
continue
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
|
||||
order.total = sum([p.price for p in pos_map.values()])
|
||||
|
||||
@@ -61,7 +61,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Voucher
|
||||
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
|
||||
fields = ('id', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
|
||||
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
|
||||
'tag', 'comment', 'subevent', 'show_hidden_items', 'seat')
|
||||
read_only_fields = ('id', 'redeemed')
|
||||
|
||||
@@ -19,19 +19,28 @@
|
||||
# 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 collections import Counter
|
||||
from typing import List
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.settings import api_settings
|
||||
from rest_framework.serializers import as_serializer_error
|
||||
|
||||
from pretix.api.serializers.cart import (
|
||||
CartPositionCreateSerializer, CartPositionSerializer,
|
||||
)
|
||||
from pretix.base.models import CartPosition
|
||||
from pretix.base.services.cart import (
|
||||
_get_quota_availability, _get_voucher_availability, error_messages,
|
||||
)
|
||||
from pretix.base.services.locking import NoLockManager
|
||||
|
||||
|
||||
@@ -54,18 +63,17 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['quota_cache'] = {}
|
||||
ctx['quotas_for_item_cache'] = {}
|
||||
ctx['quotas_for_variation_cache'] = {}
|
||||
return ctx
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
ctx = self.get_serializer_context()
|
||||
serializer = CartPositionCreateSerializer(data=request.data, context=ctx)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic(), self.request.event.lock():
|
||||
self.perform_create(serializer)
|
||||
cp = serializer.instance
|
||||
serializer = CartPositionSerializer(cp, context=serializer.context)
|
||||
results = self._create(serializers=[serializer], raise_exception=True, ctx=ctx)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
return Response(results[0]['data'], status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@action(detail=False, methods=['POST'])
|
||||
def bulk_create(self, request, *args, **kwargs):
|
||||
@@ -73,42 +81,163 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
||||
return Response({"error": "Please supply a list"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ctx = self.get_serializer_context()
|
||||
with transaction.atomic():
|
||||
serializers = [
|
||||
CartPositionCreateSerializer(data=d, context=ctx)
|
||||
for d in request.data
|
||||
]
|
||||
|
||||
lockfn = self.request.event.lock
|
||||
if not any(s.is_valid(raise_exception=False) for s in serializers):
|
||||
lockfn = NoLockManager
|
||||
|
||||
results = []
|
||||
with lockfn():
|
||||
for s in serializers:
|
||||
if s.is_valid(raise_exception=False):
|
||||
try:
|
||||
cp = s.save()
|
||||
except ValidationError as e:
|
||||
results.append({
|
||||
'success': False,
|
||||
'data': None,
|
||||
'errors': {api_settings.NON_FIELD_ERRORS_KEY: e.detail},
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
'success': True,
|
||||
'data': CartPositionSerializer(cp, context=ctx).data,
|
||||
'errors': None,
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
'success': False,
|
||||
'data': None,
|
||||
'errors': s.errors,
|
||||
})
|
||||
serializers = [
|
||||
CartPositionCreateSerializer(data=d, context=ctx)
|
||||
for d in request.data
|
||||
]
|
||||
|
||||
results = self._create(serializers=serializers, raise_exception=False, ctx=ctx)
|
||||
return Response({'results': results}, status=status.HTTP_200_OK)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
raise NotImplementedError()
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
instance.addons.all().delete()
|
||||
instance.delete()
|
||||
|
||||
def _require_locking(self, quota_diff, voucher_use_diff, seat_diff):
|
||||
if voucher_use_diff or seat_diff:
|
||||
# If any vouchers or seats are used, we lock to make sure we don't redeem them to often
|
||||
return True
|
||||
|
||||
if quota_diff and any(q.size is not None for q in quota_diff):
|
||||
# If any quotas are affected that are not unlimited, we lock
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def _create_default_cart_id(self):
|
||||
cid = "{}@api".format(get_random_string(48))
|
||||
while CartPosition.objects.filter(cart_id=cid).exists():
|
||||
cid = "{}@api".format(get_random_string(48))
|
||||
return cid
|
||||
|
||||
def _create(self, serializers: List[CartPositionCreateSerializer], ctx, raise_exception=False):
|
||||
voucher_use_diff = Counter()
|
||||
quota_diff = Counter()
|
||||
seat_diff = Counter()
|
||||
results = [{} for pserializer in serializers]
|
||||
|
||||
for i, pserializer in enumerate(serializers):
|
||||
if not pserializer.is_valid(raise_exception=raise_exception):
|
||||
results[i] = {
|
||||
'success': False,
|
||||
'data': None,
|
||||
'errors': pserializer.errors,
|
||||
}
|
||||
|
||||
for pserializer in serializers:
|
||||
if pserializer.errors:
|
||||
continue
|
||||
|
||||
validated_data = pserializer.validated_data
|
||||
if not validated_data.get('cart_id'):
|
||||
validated_data['cart_id'] = self._create_default_cart_id
|
||||
|
||||
if validated_data.get('voucher'):
|
||||
voucher_use_diff[validated_data['voucher']] += 1
|
||||
|
||||
if validated_data.get('seat'):
|
||||
seat_diff[validated_data['seat']] += 1
|
||||
|
||||
for q in validated_data['_quotas']:
|
||||
quota_diff[q] += 1
|
||||
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
|
||||
for q in sub_data['_quotas']:
|
||||
quota_diff[q] += 1
|
||||
|
||||
seats_seen = set()
|
||||
|
||||
lockfn = NoLockManager
|
||||
if self._require_locking(quota_diff, voucher_use_diff, seat_diff):
|
||||
lockfn = self.request.event.lock
|
||||
|
||||
with lockfn() as now_dt, transaction.atomic():
|
||||
vouchers_ok, vouchers_depend_on_cart = _get_voucher_availability(
|
||||
self.request.event,
|
||||
voucher_use_diff,
|
||||
now_dt,
|
||||
exclude_position_ids=[],
|
||||
)
|
||||
quotas_ok = _get_quota_availability(quota_diff, now_dt)
|
||||
|
||||
for i, pserializer in enumerate(serializers):
|
||||
if results[i]:
|
||||
continue
|
||||
|
||||
try:
|
||||
validated_data = pserializer.validated_data
|
||||
|
||||
if validated_data.get('seat'):
|
||||
# Assumption: Add-ons currently can't have seats
|
||||
if validated_data['seat'] in seats_seen:
|
||||
raise ValidationError(error_messages['seat_multiple'])
|
||||
seats_seen.add(validated_data['seat'])
|
||||
|
||||
quotas_needed = Counter()
|
||||
for q in validated_data['_quotas']:
|
||||
quotas_needed[q] += 1
|
||||
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
|
||||
for q in sub_data['_quotas']:
|
||||
quotas_needed[q] += 1
|
||||
|
||||
for q, needed in quotas_needed.items():
|
||||
if quotas_ok[q] < needed:
|
||||
raise ValidationError(
|
||||
_('There is not enough quota available on quota "{}" to perform the operation.').format(
|
||||
q.name
|
||||
)
|
||||
)
|
||||
|
||||
if validated_data.get('voucher'):
|
||||
# Assumption: Add-ons currently can't have vouchers, thus we only need to check the main voucher
|
||||
if vouchers_ok[validated_data['voucher']] < 1:
|
||||
raise ValidationError(
|
||||
{'voucher': [_('The specified voucher has already been used the maximum number of times.')]}
|
||||
)
|
||||
|
||||
if validated_data.get('seat'):
|
||||
# Assumption: Add-ons currently can't have seats, thus we only need to check the main product
|
||||
if not validated_data['seat'].is_available(
|
||||
sales_channel=validated_data.get('sales_channel', 'web'),
|
||||
distance_ignore_cart_id=validated_data['cart_id'],
|
||||
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
|
||||
):
|
||||
raise ValidationError(
|
||||
{'seat': [_('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name)]}
|
||||
)
|
||||
|
||||
for q, needed in quotas_needed.items():
|
||||
quotas_ok[q] -= needed
|
||||
if validated_data.get('voucher'):
|
||||
vouchers_ok[validated_data['voucher']] -= 1
|
||||
|
||||
if any(qa < 0 for qa in quotas_ok.values()):
|
||||
# Safeguard, should never happen because of conditions above
|
||||
raise ValidationError(error_messages['unavailable'])
|
||||
|
||||
cp = pserializer.create(validated_data)
|
||||
|
||||
d = CartPositionSerializer(cp, context=ctx).data
|
||||
addons = sorted(cp.addons.all(), key=lambda a: a.pk) # order of creation, safe since they are created in the same transaction
|
||||
d['addons'] = CartPositionSerializer([a for a in addons if not a.is_bundled], many=True, context=ctx).data
|
||||
d['bundled'] = CartPositionSerializer([a for a in addons if a.is_bundled], many=True, context=ctx).data
|
||||
|
||||
results[i] = {
|
||||
'success': True,
|
||||
'data': d,
|
||||
'errors': None,
|
||||
}
|
||||
except ValidationError as e:
|
||||
if raise_exception:
|
||||
raise
|
||||
results[i] = {
|
||||
'success': False,
|
||||
'data': None,
|
||||
'errors': as_serializer_error(e),
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Prefetch, ProtectedError, Q
|
||||
from django.utils.timezone import now
|
||||
@@ -241,13 +242,17 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
except Event.DoesNotExist:
|
||||
raise ValidationError('Event to copy from was not found')
|
||||
|
||||
# Ensure that .installed() is only called when we NOT clone
|
||||
plugins = serializer.validated_data.pop('plugins', None)
|
||||
serializer.validated_data['plugins'] = None
|
||||
|
||||
new_event = serializer.save(organizer=self.request.organizer)
|
||||
|
||||
if copy_from:
|
||||
new_event.copy_data_from(copy_from)
|
||||
|
||||
if 'plugins' in serializer.validated_data:
|
||||
new_event.set_active_plugins(serializer.validated_data['plugins'])
|
||||
if plugins is not None:
|
||||
new_event.set_active_plugins(plugins)
|
||||
if 'is_public' in serializer.validated_data:
|
||||
new_event.is_public = serializer.validated_data['is_public']
|
||||
if 'testmode' in serializer.validated_data:
|
||||
@@ -256,12 +261,17 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
new_event.sales_channels = serializer.validated_data['sales_channels']
|
||||
if 'has_subevents' in serializer.validated_data:
|
||||
new_event.has_subevents = serializer.validated_data['has_subevents']
|
||||
if 'date_admission' in serializer.validated_data:
|
||||
new_event.date_admission = serializer.validated_data['date_admission']
|
||||
new_event.save()
|
||||
if 'timezone' in serializer.validated_data:
|
||||
new_event.settings.timezone = serializer.validated_data['timezone']
|
||||
else:
|
||||
serializer.instance.set_defaults()
|
||||
|
||||
new_event.set_active_plugins(plugins if plugins is not None else settings.PRETIX_PLUGINS_DEFAULT.split(','))
|
||||
new_event.save(update_fields=['plugins'])
|
||||
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.added',
|
||||
user=self.request.user,
|
||||
@@ -322,6 +332,7 @@ with scopes_disabled():
|
||||
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
||||
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
|
||||
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
|
||||
search = django_filters.rest_framework.CharFilter(method='search_qs')
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
@@ -357,6 +368,12 @@ with scopes_disabled():
|
||||
def sales_channel_qs(self, queryset, name, value):
|
||||
return queryset.filter(event__sales_channels__contains=value)
|
||||
|
||||
def search_qs(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(name__icontains=i18ncomp(value))
|
||||
| Q(location__icontains=i18ncomp(value))
|
||||
)
|
||||
|
||||
|
||||
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = SubEventSerializer
|
||||
|
||||
@@ -35,7 +35,8 @@ from rest_framework.reverse import reverse
|
||||
from pretix.api.serializers.exporters import (
|
||||
ExporterSerializer, JobRunSerializer,
|
||||
)
|
||||
from pretix.base.models import CachedFile, Device, TeamAPIToken
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.models import CachedFile, Device, Event, TeamAPIToken
|
||||
from pretix.base.services.export import export, multiexport
|
||||
from pretix.base.signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
@@ -155,7 +156,19 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
||||
for ex in sorted([response(events, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
|
||||
raw_exporters = [
|
||||
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else events, self.request.organizer)
|
||||
for r, response in responses
|
||||
if response
|
||||
]
|
||||
raw_exporters = [
|
||||
ex for ex in raw_exporters
|
||||
if (
|
||||
not isinstance(ex, OrganizerLevelExportMixin) or
|
||||
perm_holder.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request)
|
||||
)
|
||||
]
|
||||
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex, events=events)
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
|
||||
@@ -34,6 +34,7 @@ from oauth2_provider.views import (
|
||||
|
||||
from pretix.api.models import OAuthApplication
|
||||
from pretix.base.models import Organizer
|
||||
from pretix.control.views.user import RecentAuthenticationRequiredMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,7 +55,7 @@ class OAuthAllowForm(AllowForm):
|
||||
del self.fields['organizers']
|
||||
|
||||
|
||||
class AuthorizationView(BaseAuthorizationView):
|
||||
class AuthorizationView(RecentAuthenticationRequiredMixin, BaseAuthorizationView):
|
||||
template_name = "pretixcontrol/auth/oauth_authorization.html"
|
||||
form_class = OAuthAllowForm
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ from pretix.api.serializers.orderchange import (
|
||||
OrderPositionCreateForExistingOrderSerializer,
|
||||
OrderPositionInfoPatchSerializer,
|
||||
)
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue,
|
||||
@@ -190,6 +191,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
|
||||
ctx['exclude'] = self.request.query_params.getlist('exclude')
|
||||
ctx['include'] = self.request.query_params.getlist('include')
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -679,28 +681,33 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
if order.require_approval:
|
||||
email_template = request.event.settings.mail_text_order_placed_require_approval
|
||||
subject_template = request.event.settings.mail_subject_order_placed_require_approval
|
||||
log_entry = 'pretix.event.order.email.order_placed_require_approval'
|
||||
email_attendees = False
|
||||
elif free_flow:
|
||||
email_template = request.event.settings.mail_text_order_free
|
||||
subject_template = request.event.settings.mail_subject_order_free
|
||||
log_entry = 'pretix.event.order.email.order_free'
|
||||
email_attendees = request.event.settings.mail_send_order_free_attendee
|
||||
email_attendees_template = request.event.settings.mail_text_order_free_attendee
|
||||
subject_attendees_template = request.event.settings.mail_subject_order_free_attendee
|
||||
else:
|
||||
email_template = request.event.settings.mail_text_order_placed
|
||||
subject_template = request.event.settings.mail_subject_order_placed
|
||||
log_entry = 'pretix.event.order.email.order_placed'
|
||||
email_attendees = request.event.settings.mail_send_order_placed_attendee
|
||||
email_attendees_template = request.event.settings.mail_text_order_placed_attendee
|
||||
subject_attendees_template = request.event.settings.mail_subject_order_placed_attendee
|
||||
|
||||
_order_placed_email(
|
||||
request.event, order, payment.payment_provider if payment else None, email_template,
|
||||
log_entry, invoice, payment, is_free=free_flow
|
||||
request.event, order, email_template, subject_template,
|
||||
log_entry, invoice, [payment] if payment else [], is_free=free_flow
|
||||
)
|
||||
if email_attendees:
|
||||
for p in order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry,
|
||||
is_free=free_flow)
|
||||
_order_placed_email_attendee(request.event, order, p, email_attendees_template, subject_attendees_template,
|
||||
log_entry, is_free=free_flow)
|
||||
|
||||
if not free_flow and order.status == Order.STATUS_PAID and payment:
|
||||
payment._send_paid_mail(invoice, None, '')
|
||||
@@ -930,7 +937,7 @@ with scopes_disabled():
|
||||
class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
queryset = OrderPosition.all.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
ordering = ('order__datetime', 'positionid')
|
||||
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
|
||||
filterset_class = OrderPositionFilter
|
||||
@@ -1604,6 +1611,17 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth
|
||||
)
|
||||
|
||||
if r.state in (OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_CANCELED, OrderRefund.REFUND_STATE_FAILED):
|
||||
r.order.log_action(
|
||||
f'pretix.event.order.refund.{r.state}', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
},
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth
|
||||
)
|
||||
|
||||
if mark_refunded:
|
||||
try:
|
||||
mark_order_refunded(
|
||||
|
||||
@@ -320,13 +320,18 @@ def get_email_context(**kwargs):
|
||||
return ctx
|
||||
|
||||
|
||||
def _placeholder_payment(order, payment):
|
||||
if not payment:
|
||||
return None
|
||||
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
|
||||
return str(payment.payment_provider.order_pending_mail_render(order, payment))
|
||||
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 str(payment.payment_provider.order_pending_mail_render(order))
|
||||
return ''
|
||||
|
||||
|
||||
def get_best_name(position_or_address, parts=False):
|
||||
@@ -617,7 +622,7 @@ def base_placeholders(sender, **kwargs):
|
||||
_('An individual text with a reason can be inserted here.'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'payment_info', ['order', 'payment'], _placeholder_payment,
|
||||
'payment_info', ['order', 'payments'], _placeholder_payments,
|
||||
_('The amount has been charged to your card.'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
|
||||
@@ -51,7 +51,7 @@ from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for
|
||||
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
|
||||
)
|
||||
|
||||
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
|
||||
__ = excel_safe # just so the compatibility import above is "used" and doesn't get removed by linter
|
||||
|
||||
|
||||
class BaseExporter:
|
||||
@@ -80,7 +80,7 @@ class BaseExporter:
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
A human-readable name for this exporter. This should be short but
|
||||
self-explaining. Good examples include 'JSON' or 'Microsoft Excel'.
|
||||
self-explaining. Good examples include 'Orders as JSON' or 'Orders as Microsoft Excel'.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@@ -137,6 +137,16 @@ class BaseExporter:
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
|
||||
class OrganizerLevelExportMixin:
|
||||
@property
|
||||
def organizer_required_permission(self) -> str:
|
||||
"""
|
||||
The permission level required to use this exporter. Only useful for organizer-level exports,
|
||||
not for event-level exports.
|
||||
"""
|
||||
return 'can_view_orders'
|
||||
|
||||
|
||||
class ListExporter(BaseExporter):
|
||||
ProgressSetTotal = namedtuple('ProgressSetTotal', 'total')
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from .answers import * # noqa
|
||||
from .customers import * # noqa
|
||||
from .dekodi import * # noqa
|
||||
from .events import * # noqa
|
||||
from .invoices import * # noqa
|
||||
|
||||
113
src/pretix/base/exporters/customers.py
Normal file
113
src/pretix/base/exporters/customers.py
Normal file
@@ -0,0 +1,113 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
||||
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
#
|
||||
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
|
||||
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
|
||||
#
|
||||
# This file contains Apache-licensed contributions copyrighted by: Benjamin Hättasch, Tobias Kunze
|
||||
#
|
||||
# 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.
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import get_current_timezone
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ..exporter import ListExporter, OrganizerLevelExportMixin
|
||||
from ..signals import register_multievent_data_exporters
|
||||
|
||||
|
||||
class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'customerlist'
|
||||
verbose_name = gettext_lazy('Customer accounts')
|
||||
organizer_required_permission = 'can_manage_customers'
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[]
|
||||
)
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
qs = self.organizer.customers.prefetch_related('provider')
|
||||
|
||||
headers = [
|
||||
_('Customer ID'),
|
||||
_('SSO provider'),
|
||||
_('External identifier'),
|
||||
_('E-mail'),
|
||||
_('Phone number'),
|
||||
_('Full name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Name') + ': ' + str(label))
|
||||
|
||||
headers += [
|
||||
_('Account active'),
|
||||
_('Verified email address'),
|
||||
_('Last login'),
|
||||
_('Registration date'),
|
||||
_('Language'),
|
||||
_('Notes'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
tz = get_current_timezone()
|
||||
for obj in qs:
|
||||
row = [
|
||||
obj.identifier,
|
||||
obj.provider.name if obj.provider else None,
|
||||
obj.external_identifier,
|
||||
obj.email or '',
|
||||
obj.phone or '',
|
||||
obj.name,
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(obj.name_parts.get(k, ''))
|
||||
row += [
|
||||
_('Yes') if obj.is_active else _('No'),
|
||||
_('Yes') if obj.is_verified else _('No'),
|
||||
obj.last_login.astimezone(tz).date().strftime('%Y-%m-%d') if obj.last_login else '',
|
||||
obj.date_joined.astimezone(tz).date().strftime('%Y-%m-%d') if obj.date_joined else '',
|
||||
obj.get_locale_display(),
|
||||
obj.notes or '',
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_customers'.format(self.organizer.slug)
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_customerlist")
|
||||
def register_multievent_i_customerlist_exporter(sender, **kwargs):
|
||||
return CustomerListExporter
|
||||
@@ -60,7 +60,9 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from ...control.forms.filter import get_all_payment_providers
|
||||
from ...helpers import GroupConcat
|
||||
from ...helpers.iter import chunked_iterable
|
||||
from ..exporter import ListExporter, MultiSheetListExporter
|
||||
from ..exporter import (
|
||||
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
|
||||
)
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
@@ -301,6 +303,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for id, vn in payment_methods:
|
||||
headers.append(_('Paid by {method}').format(method=vn))
|
||||
|
||||
# get meta_data labels from first cached event
|
||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
yield headers
|
||||
|
||||
full_fee_sum_cache = {
|
||||
@@ -414,6 +418,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
|
||||
refund_sum_cache.get((order.id, id), Decimal('0.00'))
|
||||
)
|
||||
row += self.event_object_cache[order.event_id].meta_data.values()
|
||||
yield row
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
@@ -463,6 +468,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
headers.append(_('External customer ID'))
|
||||
headers.append(_('Payment providers'))
|
||||
|
||||
# get meta_data labels from first cached event
|
||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
yield headers
|
||||
|
||||
yield self.ProgressSetTotal(total=qs.count())
|
||||
@@ -510,6 +518,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]))
|
||||
row += self.event_object_cache[order.event_id].meta_data.values()
|
||||
yield row
|
||||
|
||||
def iterate_positions(self, form_data: dict):
|
||||
@@ -531,6 +540,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
'order', 'order__invoice_address', 'order__customer', 'item', 'variation',
|
||||
'voucher', 'tax_rule'
|
||||
).prefetch_related(
|
||||
'subevent', 'subevent__meta_values',
|
||||
'answers', 'answers__question', 'answers__options'
|
||||
)
|
||||
if form_data['paid_only']:
|
||||
@@ -622,6 +632,10 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Payment providers'),
|
||||
]
|
||||
|
||||
# get meta_data labels from first cached event
|
||||
meta_data_labels = next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
if has_subevents:
|
||||
headers += meta_data_labels
|
||||
yield headers
|
||||
|
||||
all_ids = list(base_qs.order_by('order__datetime', 'positionid').values_list('pk', flat=True))
|
||||
@@ -745,6 +759,12 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]))
|
||||
|
||||
if has_subevents:
|
||||
if op.subevent:
|
||||
row += op.subevent.meta_data.values()
|
||||
else:
|
||||
row += [''] * len(meta_data_labels)
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
@@ -884,76 +904,75 @@ class QuotaListExporter(ListExporter):
|
||||
return '{}_quotas'.format(self.event.slug)
|
||||
|
||||
|
||||
def generate_GiftCardTransactionListExporter(organizer): # hackhack
|
||||
class GiftcardTransactionListExporter(ListExporter):
|
||||
identifier = 'giftcardtransactionlist'
|
||||
verbose_name = gettext_lazy('Gift card transactions')
|
||||
class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'giftcardtransactionlist'
|
||||
verbose_name = gettext_lazy('Gift card transactions')
|
||||
organizer_required_permission = 'can_manage_gift_cards'
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
d = [
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
d = [
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
]
|
||||
d = OrderedDict(d)
|
||||
return d
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
qs = GiftCardTransaction.objects.filter(
|
||||
card__issuer=self.organizer,
|
||||
).order_by('datetime').select_related('card', 'order', 'order__event')
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(
|
||||
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
)
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
|
||||
qs = qs.filter(
|
||||
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
)
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
_('Test mode'),
|
||||
_('Date'),
|
||||
_('Amount'),
|
||||
_('Currency'),
|
||||
_('Order'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
for obj in qs:
|
||||
row = [
|
||||
obj.card.secret,
|
||||
_('TEST MODE') if obj.card.testmode else '',
|
||||
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
obj.value,
|
||||
obj.card.currency,
|
||||
obj.order.full_code if obj.order else None,
|
||||
]
|
||||
d = OrderedDict(d)
|
||||
return d
|
||||
yield row
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
qs = GiftCardTransaction.objects.filter(
|
||||
card__issuer=organizer,
|
||||
).order_by('datetime').select_related('card', 'order', 'order__event')
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(
|
||||
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
)
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
|
||||
qs = qs.filter(
|
||||
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
)
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
_('Test mode'),
|
||||
_('Date'),
|
||||
_('Amount'),
|
||||
_('Currency'),
|
||||
_('Order'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
for obj in qs:
|
||||
row = [
|
||||
obj.card.secret,
|
||||
_('TEST MODE') if obj.card.testmode else '',
|
||||
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
obj.value,
|
||||
obj.card.currency,
|
||||
obj.order.full_code if obj.order else None,
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_giftcardtransactions'.format(organizer.slug)
|
||||
return GiftcardTransactionListExporter
|
||||
def get_filename(self):
|
||||
return '{}_giftcardtransactions'.format(self.organizer.slug)
|
||||
|
||||
|
||||
class GiftcardRedemptionListExporter(ListExporter):
|
||||
@@ -1000,114 +1019,112 @@ class GiftcardRedemptionListExporter(ListExporter):
|
||||
return '{}_giftcardredemptions'.format(self.event.slug)
|
||||
|
||||
|
||||
def generate_GiftCardListExporter(organizer): # hackhack
|
||||
class GiftcardListExporter(ListExporter):
|
||||
identifier = 'giftcardlist'
|
||||
verbose_name = gettext_lazy('Gift cards')
|
||||
class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'giftcardlist'
|
||||
verbose_name = gettext_lazy('Gift cards')
|
||||
organizer_required_permission = 'can_manage_gift_cards'
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date', forms.DateTimeField(
|
||||
label=_('Show value at'),
|
||||
initial=now(),
|
||||
)),
|
||||
('testmode', forms.ChoiceField(
|
||||
label=_('Test mode'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('yes', _('Test mode')),
|
||||
('no', _('Live')),
|
||||
),
|
||||
initial='no',
|
||||
required=False
|
||||
)),
|
||||
('state', forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('empty', _('Empty')),
|
||||
('valid_value', _('Valid and with value')),
|
||||
('expired_value', _('Expired and with value')),
|
||||
('expired', _('Expired')),
|
||||
),
|
||||
initial='valid_value',
|
||||
required=False
|
||||
))
|
||||
]
|
||||
)
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
s = GiftCardTransaction.objects.filter(
|
||||
card=OuterRef('pk'),
|
||||
datetime__lte=form_data['date']
|
||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||
qs = organizer.issued_gift_cards.filter(
|
||||
issuance__lte=form_data['date']
|
||||
).annotate(
|
||||
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
|
||||
).order_by('issuance').prefetch_related(
|
||||
'transactions', 'transactions__order', 'transactions__order__event', 'transactions__order__invoices'
|
||||
)
|
||||
|
||||
if form_data.get('testmode') == 'yes':
|
||||
qs = qs.filter(testmode=True)
|
||||
elif form_data.get('testmode') == 'no':
|
||||
qs = qs.filter(testmode=False)
|
||||
|
||||
if form_data.get('state') == 'empty':
|
||||
qs = qs.filter(cached_value=0)
|
||||
elif form_data.get('state') == 'valid_value':
|
||||
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
|
||||
elif form_data.get('state') == 'expired_value':
|
||||
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
|
||||
elif form_data.get('state') == 'expired':
|
||||
qs = qs.filter(expires__lt=form_data['date'])
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
_('Test mode card'),
|
||||
_('Creation date'),
|
||||
_('Expiry date'),
|
||||
_('Special terms and conditions'),
|
||||
_('Currency'),
|
||||
_('Current value'),
|
||||
_('Created in order'),
|
||||
_('Last invoice number of order'),
|
||||
_('Last invoice date of order'),
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date', forms.DateTimeField(
|
||||
label=_('Show value at'),
|
||||
initial=now(),
|
||||
)),
|
||||
('testmode', forms.ChoiceField(
|
||||
label=_('Test mode'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('yes', _('Test mode')),
|
||||
('no', _('Live')),
|
||||
),
|
||||
initial='no',
|
||||
required=False
|
||||
)),
|
||||
('state', forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('empty', _('Empty')),
|
||||
('valid_value', _('Valid and with value')),
|
||||
('expired_value', _('Expired and with value')),
|
||||
('expired', _('Expired')),
|
||||
),
|
||||
initial='valid_value',
|
||||
required=False
|
||||
))
|
||||
]
|
||||
yield headers
|
||||
)
|
||||
|
||||
tz = get_current_timezone()
|
||||
for obj in qs:
|
||||
o = None
|
||||
i = None
|
||||
trans = list(obj.transactions.all())
|
||||
if trans:
|
||||
o = trans[0].order
|
||||
if o:
|
||||
invs = list(o.invoices.all())
|
||||
if invs:
|
||||
i = invs[-1]
|
||||
row = [
|
||||
obj.secret,
|
||||
_('Yes') if obj.testmode else _('No'),
|
||||
obj.issuance.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
obj.expires.astimezone(tz).date().strftime('%Y-%m-%d') if obj.expires else '',
|
||||
obj.conditions or '',
|
||||
obj.currency,
|
||||
obj.cached_value,
|
||||
o.full_code if o else '',
|
||||
i.number if i else '',
|
||||
i.date.strftime('%Y-%m-%d') if i else '',
|
||||
]
|
||||
yield row
|
||||
def iterate_list(self, form_data):
|
||||
s = GiftCardTransaction.objects.filter(
|
||||
card=OuterRef('pk'),
|
||||
datetime__lte=form_data['date']
|
||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||
qs = self.organizer.issued_gift_cards.filter(
|
||||
issuance__lte=form_data['date']
|
||||
).annotate(
|
||||
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
|
||||
).order_by('issuance').prefetch_related(
|
||||
'transactions', 'transactions__order', 'transactions__order__event', 'transactions__order__invoices'
|
||||
)
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_giftcards'.format(organizer.slug)
|
||||
if form_data.get('testmode') == 'yes':
|
||||
qs = qs.filter(testmode=True)
|
||||
elif form_data.get('testmode') == 'no':
|
||||
qs = qs.filter(testmode=False)
|
||||
|
||||
return GiftcardListExporter
|
||||
if form_data.get('state') == 'empty':
|
||||
qs = qs.filter(cached_value=0)
|
||||
elif form_data.get('state') == 'valid_value':
|
||||
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
|
||||
elif form_data.get('state') == 'expired_value':
|
||||
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
|
||||
elif form_data.get('state') == 'expired':
|
||||
qs = qs.filter(expires__lt=form_data['date'])
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
_('Test mode card'),
|
||||
_('Creation date'),
|
||||
_('Expiry date'),
|
||||
_('Special terms and conditions'),
|
||||
_('Currency'),
|
||||
_('Current value'),
|
||||
_('Created in order'),
|
||||
_('Last invoice number of order'),
|
||||
_('Last invoice date of order'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
tz = get_current_timezone()
|
||||
for obj in qs:
|
||||
o = None
|
||||
i = None
|
||||
trans = list(obj.transactions.all())
|
||||
if trans:
|
||||
o = trans[0].order
|
||||
if o:
|
||||
invs = list(o.invoices.all())
|
||||
if invs:
|
||||
i = invs[-1]
|
||||
row = [
|
||||
obj.secret,
|
||||
_('Yes') if obj.testmode else _('No'),
|
||||
obj.issuance.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
obj.expires.astimezone(tz).date().strftime('%Y-%m-%d') if obj.expires else '',
|
||||
obj.conditions or '',
|
||||
obj.currency,
|
||||
obj.cached_value,
|
||||
o.full_code if o else '',
|
||||
i.number if i else '',
|
||||
i.date.strftime('%Y-%m-%d') if i else '',
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_giftcards'.format(self.organizer.slug)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
||||
@@ -1147,9 +1164,9 @@ def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardlist")
|
||||
def register_multievent_i_giftcardlist_exporter(sender, **kwargs):
|
||||
return generate_GiftCardListExporter(sender)
|
||||
return GiftcardListExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardtransactionlist")
|
||||
def register_multievent_i_giftcardtransactionlist_exporter(sender, **kwargs):
|
||||
return generate_GiftCardTransactionListExporter(sender)
|
||||
return GiftcardTransactionListExporter
|
||||
|
||||
@@ -23,6 +23,7 @@ import logging
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from itertools import groupby
|
||||
from typing import Tuple
|
||||
|
||||
import bleach
|
||||
@@ -554,31 +555,47 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
pgettext('invoice', 'Amount'),
|
||||
)]
|
||||
|
||||
def _group_key(line):
|
||||
return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent_id,
|
||||
line.event_date_from, line.event_date_to)
|
||||
|
||||
total = Decimal('0.00')
|
||||
for line in self.invoice.lines.all():
|
||||
for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in groupby(self.invoice.lines.all(), key=_group_key):
|
||||
lines = list(lines)
|
||||
if has_taxes:
|
||||
if len(lines) > 1:
|
||||
single_price_line = pgettext('invoice', 'Single price: {net_price} net / {gross_price} gross').format(
|
||||
net_price=money_filter(net_value, self.invoice.event.currency),
|
||||
gross_price=money_filter(gross_value, self.invoice.event.currency),
|
||||
)
|
||||
description = description + "\n" + single_price_line
|
||||
tdata.append((
|
||||
Paragraph(
|
||||
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
|
||||
bleach.clean(description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
|
||||
self.stylesheet['Normal']
|
||||
),
|
||||
"1",
|
||||
localize(line.tax_rate) + " %",
|
||||
money_filter(line.net_value, self.invoice.event.currency),
|
||||
money_filter(line.gross_value, self.invoice.event.currency),
|
||||
str(len(lines)),
|
||||
localize(tax_rate) + " %",
|
||||
money_filter(net_value * len(lines), self.invoice.event.currency),
|
||||
money_filter(gross_value * len(lines), self.invoice.event.currency),
|
||||
))
|
||||
else:
|
||||
if len(lines) > 1:
|
||||
single_price_line = pgettext('invoice', 'Single price: {price}').format(
|
||||
price=money_filter(gross_value, self.invoice.event.currency),
|
||||
)
|
||||
description = description + "\n" + single_price_line
|
||||
tdata.append((
|
||||
Paragraph(
|
||||
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
|
||||
bleach.clean(description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
|
||||
self.stylesheet['Normal']
|
||||
),
|
||||
"1",
|
||||
money_filter(line.gross_value, self.invoice.event.currency),
|
||||
str(len(lines)),
|
||||
money_filter(gross_value * len(lines), self.invoice.event.currency),
|
||||
))
|
||||
taxvalue_map[line.tax_rate, line.tax_name] += line.tax_value
|
||||
grossvalue_map[line.tax_rate, line.tax_name] += line.gross_value
|
||||
total += line.gross_value
|
||||
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
|
||||
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
|
||||
total += gross_value * len(lines)
|
||||
|
||||
if has_taxes:
|
||||
tdata.append([
|
||||
|
||||
@@ -103,6 +103,8 @@ class Command(BaseCommand):
|
||||
|
||||
with language(locale), override(timezone):
|
||||
for receiver, response in signal_result:
|
||||
if not response:
|
||||
return None
|
||||
ex = response(e, o, report_status)
|
||||
if ex.identifier == options['export_provider']:
|
||||
params = json.loads(options.get('parameters') or '{}')
|
||||
|
||||
@@ -79,9 +79,9 @@ class Command(BaseCommand):
|
||||
if settings.SENTRY_ENABLED:
|
||||
from sentry_sdk import capture_exception
|
||||
capture_exception(err)
|
||||
self.stdout.write(self.style.ERROR(f'ERROR runperiodic {str(err)}\n'))
|
||||
self.stdout.write(self.style.ERROR(f'ERROR {name}: {str(err)}\n'))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(f'ERROR runperiodic {str(err)}\n'))
|
||||
self.stdout.write(self.style.ERROR(f'ERROR {name}: {str(err)}\n'))
|
||||
traceback.print_exc()
|
||||
else:
|
||||
if options.get('verbosity') > 1:
|
||||
|
||||
18
src/pretix/base/migrations/0223_voucher_min_usages.py
Normal file
18
src/pretix/base/migrations/0223_voucher_min_usages.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.12 on 2022-10-12 09:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0222_alter_question_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='min_usages',
|
||||
field=models.PositiveIntegerField(default=1),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.16 on 2022-11-14 11:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0223_voucher_min_usages'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='eventmetaproperty',
|
||||
name='filter_allowed',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.16 on 2022-11-17 15:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0224_eventmetaproperty_filter_allowed'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='orderpayment',
|
||||
name='process_initiated',
|
||||
field=models.BooleanField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -78,6 +78,7 @@ class Customer(LoggedModel):
|
||||
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
||||
provider = models.ForeignKey(CustomerSSOProvider, related_name='customers', on_delete=models.PROTECT, null=True, blank=True)
|
||||
identifier = models.CharField(
|
||||
verbose_name=_('Customer ID'),
|
||||
max_length=190,
|
||||
db_index=True,
|
||||
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
|
||||
@@ -261,7 +262,7 @@ class Customer(LoggedModel):
|
||||
) + '?id=' + self.identifier + '&token=' + token
|
||||
mail(
|
||||
self.email,
|
||||
_('Activate your account at {organizer}').format(organizer=self.organizer.name),
|
||||
self.organizer.settings.mail_subject_customer_registration,
|
||||
self.organizer.settings.mail_text_customer_registration,
|
||||
ctx,
|
||||
locale=self.locale,
|
||||
|
||||
@@ -590,6 +590,7 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.event_list_type = 'calendar'
|
||||
self.settings.invoice_email_attachment = True
|
||||
self.settings.name_scheme = 'given_family'
|
||||
self.settings.payment_banktransfer_invoice_immediately = True
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
@@ -1579,6 +1580,11 @@ class EventMetaProperty(LoggedModel):
|
||||
verbose_name=_("Valid values"),
|
||||
help_text=_("If you keep this empty, any value is allowed. Otherwise, enter one possible value per line.")
|
||||
)
|
||||
filter_allowed = models.BooleanField(
|
||||
default=True, verbose_name=_("Can be used for filtering"),
|
||||
help_text=_("This field will be shown to filter events or reports in the backend, and it can also be used "
|
||||
"for hidden filter parameters in the frontend (e.g. using the widget).")
|
||||
)
|
||||
|
||||
def full_clean(self, exclude=None, validate_unique=True):
|
||||
super().full_clean(exclude, validate_unique)
|
||||
|
||||
@@ -581,18 +581,15 @@ class Item(LoggedModel):
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
|
||||
price = price if price is not None else self.default_price
|
||||
|
||||
if not self.tax_rule:
|
||||
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
|
||||
rate=Decimal('0.00'), name='')
|
||||
else:
|
||||
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
|
||||
override_tax_rate=override_tax_rate, currency=currency or self.event.currency)
|
||||
|
||||
bundled_sum = Decimal('0.00')
|
||||
bundled_sum_net = Decimal('0.00')
|
||||
bundled_sum_tax = Decimal('0.00')
|
||||
if include_bundled:
|
||||
for b in self.bundles.all():
|
||||
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
|
||||
if b.bundled_variation:
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross',
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count,
|
||||
base_price_is='gross',
|
||||
invoice_address=invoice_address,
|
||||
currency=currency)
|
||||
else:
|
||||
@@ -600,17 +597,23 @@ class Item(LoggedModel):
|
||||
invoice_address=invoice_address,
|
||||
base_price_is='gross',
|
||||
currency=currency)
|
||||
if not self.tax_rule:
|
||||
compare_price = TaxedPrice(gross=b.designated_price * b.count, net=b.designated_price * b.count,
|
||||
tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
|
||||
else:
|
||||
compare_price = self.tax_rule.tax(b.designated_price * b.count,
|
||||
override_tax_rate=override_tax_rate,
|
||||
invoice_address=invoice_address,
|
||||
currency=currency)
|
||||
t.net += bprice.net - compare_price.net
|
||||
t.tax += bprice.tax - compare_price.tax
|
||||
t.name = "MIXED!"
|
||||
bundled_sum += bprice.gross
|
||||
bundled_sum_net += bprice.net
|
||||
bundled_sum_tax += bprice.tax
|
||||
|
||||
if not self.tax_rule:
|
||||
t = TaxedPrice(gross=price - bundled_sum, net=price - bundled_sum, tax=Decimal('0.00'),
|
||||
rate=Decimal('0.00'), name='')
|
||||
else:
|
||||
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
|
||||
override_tax_rate=override_tax_rate, currency=currency or self.event.currency,
|
||||
subtract_from_gross=bundled_sum)
|
||||
|
||||
if bundled_sum:
|
||||
t.name = "MIXED!"
|
||||
t.gross += bundled_sum
|
||||
t.net += bundled_sum_net
|
||||
t.tax += bundled_sum_tax
|
||||
|
||||
return t
|
||||
|
||||
@@ -1381,8 +1384,10 @@ class Question(LoggedModel):
|
||||
if self.type == Question.TYPE_CHOICE:
|
||||
if isinstance(answer, QuestionOption):
|
||||
return answer
|
||||
if not isinstance(answer, (int, str)):
|
||||
raise ValidationError(_('Invalid input type.'))
|
||||
q = Q(identifier=answer)
|
||||
if isinstance(answer, int) or answer.isdigit():
|
||||
if isinstance(answer, int) or (isinstance(answer, str) and answer.isdigit()):
|
||||
q |= Q(pk=answer)
|
||||
o = self.options.filter(q).first()
|
||||
if not o:
|
||||
|
||||
@@ -564,17 +564,30 @@ class Order(LockModel, LoggedModel):
|
||||
@cached_property
|
||||
def user_cancel_fee(self):
|
||||
fee = Decimal('0.00')
|
||||
if self.event.settings.cancel_allow_user_paid_keep_fees:
|
||||
fee += self.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
|
||||
OrderFee.FEE_TYPE_CANCELLATION)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
if self.status == Order.STATUS_PAID:
|
||||
if self.event.settings.cancel_allow_user_paid_keep_fees:
|
||||
fee += self.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
|
||||
OrderFee.FEE_TYPE_CANCELLATION)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
else:
|
||||
if self.event.settings.cancel_allow_user_unpaid_keep_fees:
|
||||
fee += self.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
|
||||
OrderFee.FEE_TYPE_CANCELLATION)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
if self.event.settings.cancel_allow_user_unpaid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_unpaid_keep_percentage / Decimal('100.0') * (self.total - fee)
|
||||
if self.event.settings.cancel_allow_user_unpaid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_unpaid_keep
|
||||
return round_decimal(min(fee, self.total), self.event.currency)
|
||||
|
||||
@property
|
||||
@@ -642,10 +655,12 @@ class Order(LockModel, LoggedModel):
|
||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||
return False
|
||||
|
||||
if self.status == Order.STATUS_PAID or self.payment_refund_sum > Decimal('0.00'):
|
||||
if self.status == Order.STATUS_PAID:
|
||||
if self.total == Decimal('0.00'):
|
||||
return self.event.settings.cancel_allow_user
|
||||
return self.event.settings.cancel_allow_user_paid
|
||||
elif self.payment_refund_sum > Decimal('0.00'):
|
||||
return False
|
||||
elif self.status == Order.STATUS_PENDING:
|
||||
return self.event.settings.cancel_allow_user
|
||||
return False
|
||||
@@ -1027,7 +1042,7 @@ class Order(LockModel, LoggedModel):
|
||||
with language(self.locale, self.event.settings.region):
|
||||
email_template = self.event.settings.mail_text_resend_link
|
||||
email_context = get_email_context(event=self.event, order=self)
|
||||
email_subject = _('Your order: %(code)s') % {'code': self.code}
|
||||
email_subject = self.event.settings.mail_subject_resend_link
|
||||
self.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.resend', user=user, auth=auth,
|
||||
@@ -1494,6 +1509,9 @@ class OrderPayment(models.Model):
|
||||
:type info: str
|
||||
:param fee: The ``OrderFee`` object used to track the fee for this order.
|
||||
:type fee: pretix.base.models.OrderFee
|
||||
:param process_initiated: Only for internal use inside pretix.presale to check which payments have started
|
||||
the execution process.
|
||||
:type process_initiated: bool
|
||||
"""
|
||||
PAYMENT_STATE_CREATED = 'created'
|
||||
PAYMENT_STATE_PENDING = 'pending'
|
||||
@@ -1544,6 +1562,9 @@ class OrderPayment(models.Model):
|
||||
null=True, blank=True, related_name='payments', on_delete=models.SET_NULL
|
||||
)
|
||||
migrated = models.BooleanField(default=False)
|
||||
process_initiated = models.BooleanField(
|
||||
null=True # null = created before this field was introduced
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='order__event__organizer')
|
||||
|
||||
@@ -1738,8 +1759,8 @@ class OrderPayment(models.Model):
|
||||
|
||||
with language(self.order.locale, self.order.event.settings.region):
|
||||
email_template = self.order.event.settings.mail_text_order_paid_attendee
|
||||
email_subject = self.order.event.settings.mail_subject_order_paid_attendee
|
||||
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
||||
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
position.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
@@ -1756,8 +1777,8 @@ class OrderPayment(models.Model):
|
||||
|
||||
with language(self.order.locale, self.order.event.settings.region):
|
||||
email_template = self.order.event.settings.mail_text_order_paid
|
||||
email_subject = self.order.event.settings.mail_subject_order_paid
|
||||
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
|
||||
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
@@ -2237,7 +2258,7 @@ class OrderPosition(AbstractPosition):
|
||||
|
||||
@cached_property
|
||||
def sort_key(self):
|
||||
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
|
||||
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0, self.positionid
|
||||
|
||||
@property
|
||||
def checkins(self):
|
||||
@@ -2263,7 +2284,7 @@ class OrderPosition(AbstractPosition):
|
||||
ops = []
|
||||
cp_mapping = {}
|
||||
# The sorting key ensures that all addons come directly after the position they refer to
|
||||
for i, cartpos in enumerate(sorted(cp, key=lambda c: (c.addon_to_id or c.pk, c.addon_to_id or 0))):
|
||||
for i, cartpos in enumerate(sorted(cp, key=lambda c: c.sort_key)):
|
||||
op = OrderPosition(order=order)
|
||||
for f in AbstractPosition._meta.fields:
|
||||
if f.name == 'addon_to':
|
||||
@@ -2437,7 +2458,7 @@ class OrderPosition(AbstractPosition):
|
||||
with language(self.order.locale, self.order.event.settings.region):
|
||||
email_template = self.event.settings.mail_text_resend_link
|
||||
email_context = get_email_context(event=self.order.event, order=self.order, position=self)
|
||||
email_subject = _('Your event registration: %(code)s') % {'code': self.order.code}
|
||||
email_subject = self.event.settings.mail_subject_resend_link
|
||||
self.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.resend', user=user, auth=auth,
|
||||
@@ -2659,6 +2680,20 @@ class CartPosition(AbstractPosition):
|
||||
self.event.currency)
|
||||
return self.price - net
|
||||
|
||||
@cached_property
|
||||
def sort_key(self):
|
||||
subevent_key = (self.subevent.date_from, str(self.subevent.name), self.subevent_id) if self.subevent_id else (0, "", 0)
|
||||
category_key = (self.item.category.position, self.item.category.id) if self.item.category_id is not None else (0, 0)
|
||||
item_key = self.item.position, self.item_id
|
||||
variation_key = (self.variation.position, self.variation.id) if self.variation_id is not None else (0, 0)
|
||||
line_key = (self.price, (self.voucher_id or 0), (self.seat.sorting_rank if self.seat_id else None), self.pk)
|
||||
sort_key = subevent_key + category_key + item_key + variation_key + line_key
|
||||
|
||||
if self.addon_to_id:
|
||||
return self.addon_to.sort_key + (1 if self.is_bundled else 2,) + sort_key
|
||||
else:
|
||||
return sort_key
|
||||
|
||||
def update_listed_price_and_voucher(self, voucher_only=False, max_discount=None):
|
||||
from pretix.base.services.pricing import (
|
||||
get_listed_price, is_included_for_free,
|
||||
@@ -2708,12 +2743,18 @@ class CartPosition(AbstractPosition):
|
||||
tax_rule=self.item.tax_rule,
|
||||
invoice_address=invoice_address,
|
||||
bundled_sum=sum([b.price_after_voucher for b in bundled_positions]),
|
||||
is_bundled=self.is_bundled,
|
||||
)
|
||||
if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate:
|
||||
self.line_price_gross = line_price.gross
|
||||
self.tax_rate = line_price.rate
|
||||
self.save(update_fields=['line_price_gross', 'tax_rate'])
|
||||
|
||||
@property
|
||||
def addons_without_bundled(self):
|
||||
addons = [op for op in self.addons.all() if not op.is_bundled]
|
||||
return sorted(addons, key=lambda cp: cp.sort_key)
|
||||
|
||||
|
||||
class InvoiceAddress(models.Model):
|
||||
last_modified = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -137,6 +137,8 @@ class Voucher(LoggedModel):
|
||||
:type max_usages: int
|
||||
:param redeemed: The number of times this voucher already has been redeemed
|
||||
:type redeemed: int
|
||||
:param min_usages: The minimum number of times this voucher must be redeemed
|
||||
:type min_usages: int
|
||||
:param valid_until: The expiration date of this voucher (optional)
|
||||
:type valid_until: datetime
|
||||
:param block_quota: If set to true, this voucher will reserve quota for its holder
|
||||
@@ -199,6 +201,14 @@ class Voucher(LoggedModel):
|
||||
verbose_name=_("Redeemed"),
|
||||
default=0
|
||||
)
|
||||
min_usages = models.PositiveIntegerField(
|
||||
verbose_name=_("Minimum usages"),
|
||||
help_text=_("If set to more than one, the voucher must be redeemed for this many products when it is used for "
|
||||
"the first time. On later usages, it can also be used for lower numbers of products. Note that "
|
||||
"this means that the total number of usages in some cases can be lower than this limit, e.g. in "
|
||||
"case of cancellations."),
|
||||
default=1
|
||||
)
|
||||
budget = models.DecimalField(
|
||||
verbose_name=_("Maximum discount budget"),
|
||||
help_text=_("This is the maximum monetary amount that will be discounted using this voucher across all usages. "
|
||||
@@ -350,6 +360,10 @@ class Voucher(LoggedModel):
|
||||
'redeemed': redeemed
|
||||
}
|
||||
)
|
||||
if data.get('max_usages', 1) < data.get('min_usages', 1):
|
||||
raise ValidationError(
|
||||
_('The maximum number of usages may not be lower than the minimum number of usages.'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def clean_subevent(data, event):
|
||||
@@ -464,7 +478,7 @@ class Voucher(LoggedModel):
|
||||
if quota:
|
||||
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
|
||||
|
||||
if data.get('max_usages', 1) > 1:
|
||||
if data.get('max_usages', 1) > 1 or data.get('min_usages', 1) > 1:
|
||||
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
|
||||
|
||||
if item and seat.product != item:
|
||||
@@ -567,6 +581,10 @@ class Voucher(LoggedModel):
|
||||
else:
|
||||
return bool(subevent.seating_plan) if subevent else self.event.seating_plan
|
||||
|
||||
@property
|
||||
def min_usages_remaining(self):
|
||||
return max(1, self.min_usages - self.redeemed)
|
||||
|
||||
@classmethod
|
||||
def annotate_budget_used_orders(cls, qs):
|
||||
opq = OrderPosition.objects.filter(
|
||||
|
||||
@@ -216,7 +216,7 @@ class WaitingListEntry(LoggedModel):
|
||||
with language(self.locale, self.event.settings.region):
|
||||
mail(
|
||||
self.email,
|
||||
_('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
|
||||
self.event.settings.mail_subject_waiting_list,
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
get_email_context(event=self.event, waiting_list_entry=self),
|
||||
self.event,
|
||||
|
||||
@@ -63,14 +63,13 @@ from pretix.base.models import (
|
||||
OrderRefund, Quota,
|
||||
)
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
||||
from pretix.base.services.cart import get_fees
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.presale.views import get_cart, get_cart_total
|
||||
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
||||
|
||||
@@ -138,6 +137,48 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return self.settings.get('_enabled', as_type=bool)
|
||||
|
||||
@property
|
||||
def multi_use_supported(self) -> bool:
|
||||
"""
|
||||
Returns whether or whether not this payment provider supports being used multiple times in the same
|
||||
checkout, or in addition to a different payment provider. This is usually only useful for payment providers
|
||||
that represent gift cards, i.e. payment methods with an upper limit per payment instrument that can usually
|
||||
be combined with other instruments.
|
||||
|
||||
If you set this property to ``True``, the behavior of how pretix interacts with your payment provider changes
|
||||
and you will need to respect the following rules:
|
||||
|
||||
- ``payment_form_render`` must not depend on session state, it must always allow a user to add a new payment.
|
||||
Editing a payment is not possible, but pretix will give users an option to delete it.
|
||||
|
||||
- Returning ``True`` from ``checkout_prepare`` is no longer enough. Instead, you must *also* call
|
||||
``pretix.base.services.cart.add_payment_to_cart(request, provider, min_value, max_value, info_data)``
|
||||
to add the payment to the session. You are still allowed to do a redirect from ``checkout_prepare`` and then
|
||||
call this function upon return.
|
||||
|
||||
- Unlike in the general case, when ``checkout_prepare`` is called, the ``cart['total']`` parameter will _not yet_
|
||||
include payment fees charged by your provider as we don't yet know the amount of the charge, so you need to
|
||||
take care of that yourself when setting your maximum amount.
|
||||
|
||||
- ``payment_is_valid_session`` will not be called during checkout, don't rely on it. If you called
|
||||
``add_payment_to_cart``, we'll trust the payment is okay and your next chance to change that will be
|
||||
``execute_payment``.
|
||||
|
||||
The changed behavior currently only affects the behavior during initial checkout (i.e. ``checkout_prepare``),
|
||||
for ``payment_prepare`` the regular behavior applies and you are expected to just modify the amount of the
|
||||
``OrderPayment`` object if you need to.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def execute_payment_needs_user(self) -> bool:
|
||||
"""
|
||||
Set this to ``True`` if your ``execute_payment`` function needs to be triggered by a user request, i.e. either
|
||||
needs the ``request`` object or might require a browser redirect. If this is ``False``, you will not receive
|
||||
a ``request`` and may not redirect since execute_payment might be called server-side.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def test_mode_message(self) -> str:
|
||||
"""
|
||||
@@ -574,7 +615,7 @@ class BasePaymentProvider:
|
||||
ctx = {'request': request, 'form': form}
|
||||
return template.render(ctx)
|
||||
|
||||
def checkout_confirm_render(self, request, order: Order=None) -> str:
|
||||
def checkout_confirm_render(self, request, order: Order=None, info_data: dict=None) -> str:
|
||||
"""
|
||||
If the user has successfully filled in their payment data, they will be redirected
|
||||
to a confirmation page which lists all details of their order for a final review.
|
||||
@@ -584,7 +625,9 @@ class BasePaymentProvider:
|
||||
In most cases, this should include a short summary of the user's input and
|
||||
a short explanation on how the payment process will continue.
|
||||
|
||||
:param request: The current HTTP request.
|
||||
:param order: Only set when this is a change to a new payment method for an existing order.
|
||||
:param info_data: The ``info_data`` dictionary you set during ``add_payment_to_cart`` (only filled if ``multi_use_supported`` is set)
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@@ -618,6 +661,10 @@ class BasePaymentProvider:
|
||||
.. IMPORTANT:: If this is called, the user has not yet confirmed their order.
|
||||
You may NOT do anything which actually moves money.
|
||||
|
||||
Note: The behavior of this method changes significantly when you set
|
||||
``multi_use_supported``. Please refer to the ``multi_use_supported`` documentation
|
||||
for more information.
|
||||
|
||||
:param cart: This dictionary contains at least the following keys:
|
||||
|
||||
positions:
|
||||
@@ -657,9 +704,9 @@ class BasePaymentProvider:
|
||||
You will be passed an :py:class:`pretix.base.models.OrderPayment` object that contains
|
||||
the amount of money that should be paid.
|
||||
|
||||
If you need any special behavior, you can return a string
|
||||
containing the URL the user will be redirected to. If you are done with your process
|
||||
you should return the user to the order's detail page.
|
||||
If you need any special behavior, you can return a string containing the URL the user will be redirected to.
|
||||
If you are done with your process you should return the user to the order's detail page. Redirection is not
|
||||
allowed if you set ``execute_payment_needs_user`` to ``True``.
|
||||
|
||||
If the payment is completed, you should call ``payment.confirm()``. Please note that this might
|
||||
raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this order is over and
|
||||
@@ -671,7 +718,7 @@ class BasePaymentProvider:
|
||||
|
||||
On errors, you should raise a ``PaymentException``.
|
||||
|
||||
:param order: The order object
|
||||
:param request: A HTTP request, except if ``execute_payment_needs_user`` is ``False``
|
||||
:param payment: An ``OrderPayment`` instance
|
||||
"""
|
||||
return None
|
||||
@@ -877,6 +924,15 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return {}
|
||||
|
||||
def api_refund_details(self, refund: OrderRefund):
|
||||
"""
|
||||
Will be called to populate the ``details`` parameter of the refund in the REST API.
|
||||
|
||||
:param refund: The refund in question.
|
||||
:return: A serializable dictionary
|
||||
"""
|
||||
return {}
|
||||
|
||||
def matching_id(self, payment: OrderPayment):
|
||||
"""
|
||||
Will be called to get an ID for matching this payment when comparing pretix records with records of an external
|
||||
@@ -896,6 +952,7 @@ class FreeOrderProvider(BasePaymentProvider):
|
||||
is_implicit = True
|
||||
is_enabled = True
|
||||
identifier = "free"
|
||||
execute_payment_needs_user = False
|
||||
|
||||
def checkout_confirm_render(self, request: HttpRequest) -> str:
|
||||
return _("No payment is required as this order only includes products which are free of charge.")
|
||||
@@ -959,6 +1016,9 @@ class BoxOfficeProvider(BasePaymentProvider):
|
||||
"payment_data": payment.info_data.get('payment_data', {}),
|
||||
}
|
||||
|
||||
def api_refund_details(self, refund: OrderRefund):
|
||||
return self.api_payment_details(refund)
|
||||
|
||||
def payment_control_render(self, request, payment) -> str:
|
||||
if not payment.info:
|
||||
return
|
||||
@@ -979,6 +1039,7 @@ class BoxOfficeProvider(BasePaymentProvider):
|
||||
class ManualPayment(BasePaymentProvider):
|
||||
identifier = 'manual'
|
||||
verbose_name = _('Manual payment')
|
||||
execute_payment_needs_user = False
|
||||
|
||||
@property
|
||||
def test_mode_message(self):
|
||||
@@ -1119,18 +1180,42 @@ class OffsettingProvider(BasePaymentProvider):
|
||||
|
||||
class GiftCardPayment(BasePaymentProvider):
|
||||
identifier = "giftcard"
|
||||
verbose_name = _("Gift card")
|
||||
priority = 10
|
||||
multi_use_supported = True
|
||||
execute_payment_needs_user = False
|
||||
verbose_name = _("Gift card")
|
||||
|
||||
@property
|
||||
def public_name(self) -> str:
|
||||
return str(self.settings.get("public_name", as_type=LazyI18nString)) or _(
|
||||
"Gift card"
|
||||
)
|
||||
|
||||
@property
|
||||
def settings_form_fields(self):
|
||||
f = super().settings_form_fields
|
||||
fields = [
|
||||
(
|
||||
"public_name",
|
||||
I18nFormField(
|
||||
label=_("Payment method name"), widget=I18nTextInput, required=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"public_description",
|
||||
I18nFormField(
|
||||
label=_("Payment method description"), widget=I18nTextarea, required=False
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
f = OrderedDict(fields + list(super().settings_form_fields.items()))
|
||||
del f['_fee_abs']
|
||||
del f['_fee_percent']
|
||||
del f['_fee_reverse_calc']
|
||||
del f['_total_min']
|
||||
del f['_total_max']
|
||||
del f['_invoice_text']
|
||||
f.move_to_end("_enabled", last=False)
|
||||
return f
|
||||
|
||||
@property
|
||||
@@ -1144,10 +1229,14 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
return super().order_change_allowed(order) and self.event.organizer.has_gift_cards
|
||||
|
||||
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
|
||||
return get_template('pretixcontrol/giftcards/checkout.html').render({})
|
||||
return get_template('pretixcontrol/giftcards/checkout.html').render({
|
||||
'request': request,
|
||||
})
|
||||
|
||||
def checkout_confirm_render(self, request) -> str:
|
||||
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({})
|
||||
def checkout_confirm_render(self, request, order=None, info_data=None) -> str:
|
||||
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({
|
||||
'info_data': info_data,
|
||||
})
|
||||
|
||||
def refund_control_render(self, request, refund) -> str:
|
||||
from .models import GiftCard
|
||||
@@ -1191,6 +1280,9 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
}
|
||||
}
|
||||
|
||||
def api_refund_details(self, refund: OrderRefund):
|
||||
return self.api_payment_details(refund)
|
||||
|
||||
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
return True
|
||||
|
||||
@@ -1198,6 +1290,8 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
return True
|
||||
|
||||
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
|
||||
from pretix.base.services.cart import add_payment_to_cart
|
||||
|
||||
for p in get_cart(request):
|
||||
if p.item.issue_giftcard:
|
||||
messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
|
||||
@@ -1206,7 +1300,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
cs = cart_session(request)
|
||||
try:
|
||||
gc = self.event.organizer.accepted_gift_cards.get(
|
||||
secret=request.POST.get("giftcard")
|
||||
secret=request.POST.get("giftcard").strip()
|
||||
)
|
||||
if gc.currency != self.event.currency:
|
||||
messages.error(request, _("This gift card does not support this currency."))
|
||||
@@ -1223,34 +1317,22 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
if gc.value <= Decimal("0.00"):
|
||||
messages.error(request, _("All credit on this gift card has been used."))
|
||||
return
|
||||
if 'gift_cards' not in cs:
|
||||
cs['gift_cards'] = []
|
||||
elif gc.pk in cs['gift_cards']:
|
||||
messages.error(request, _("This gift card is already used for your payment."))
|
||||
return
|
||||
cs['gift_cards'] = cs['gift_cards'] + [gc.pk]
|
||||
|
||||
total = sum(p.total for p in cart['positions'])
|
||||
# Recompute fees. Some plugins, e.g. pretix-servicefees, change their fee schedule if a gift card is
|
||||
# applied.
|
||||
fees = get_fees(
|
||||
self.event, request, total, cart['invoice_address'], cs.get('payment'),
|
||||
cart['raw']
|
||||
for p in cs.get('payments', []):
|
||||
if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk:
|
||||
messages.error(request, _("This gift card is already used for your payment."))
|
||||
return
|
||||
|
||||
add_payment_to_cart(
|
||||
request,
|
||||
self,
|
||||
max_value=gc.value,
|
||||
info_data={
|
||||
'gift_card': gc.pk,
|
||||
'gift_card_secret': gc.secret,
|
||||
}
|
||||
)
|
||||
total += sum([f.value for f in fees])
|
||||
remainder = total
|
||||
if remainder > Decimal('0.00'):
|
||||
del cs['payment']
|
||||
messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format(
|
||||
money_filter(remainder, self.event.currency)
|
||||
))
|
||||
else:
|
||||
messages.success(request, _("Your gift card has been applied."))
|
||||
|
||||
kwargs = {'step': 'payment'}
|
||||
if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs:
|
||||
kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace']
|
||||
return eventreverse(self.event, 'presale:event.checkout', kwargs=kwargs)
|
||||
return True
|
||||
except GiftCard.DoesNotExist:
|
||||
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
|
||||
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
|
||||
@@ -1268,7 +1350,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
|
||||
try:
|
||||
gc = self.event.organizer.accepted_gift_cards.get(
|
||||
secret=request.POST.get("giftcard")
|
||||
secret=request.POST.get("giftcard").strip()
|
||||
)
|
||||
if gc.currency != self.event.currency:
|
||||
messages.error(request, _("This gift card does not support this currency."))
|
||||
@@ -1287,6 +1369,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
return
|
||||
payment.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'gift_card_secret': gc.secret,
|
||||
'retry': True
|
||||
}
|
||||
payment.amount = min(payment.amount, gc.value)
|
||||
@@ -1294,7 +1377,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
|
||||
return True
|
||||
except GiftCard.DoesNotExist:
|
||||
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
|
||||
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard").strip()).exists():
|
||||
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
|
||||
"the product selection."))
|
||||
else:
|
||||
@@ -1303,36 +1386,45 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
|
||||
|
||||
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
|
||||
# This method will only be called when retrying payments, e.g. after a payment_prepare call. It is not called
|
||||
# during the order creation phase because this payment provider is a special case.
|
||||
for p in payment.order.positions.all(): # noqa - just a safeguard
|
||||
for p in payment.order.positions.all():
|
||||
if p.item.issue_giftcard:
|
||||
raise PaymentException(_("You cannot pay with gift cards when buying a gift card."))
|
||||
|
||||
gcpk = payment.info_data.get('gift_card')
|
||||
if not gcpk or not payment.info_data.get('retry'):
|
||||
if not gcpk:
|
||||
raise PaymentException("Invalid state, should never occur.")
|
||||
with transaction.atomic():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gcpk)
|
||||
if gc.currency != self.event.currency: # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card does not support this currency."))
|
||||
if not gc.accepted_by(self.event.organizer): # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card is not accepted by this event organizer."))
|
||||
if payment.amount > gc.value: # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card was used in the meantime. Please try again."))
|
||||
if gc.expires and gc.expires < now(): # noqa - just a safeguard
|
||||
messages.error(request, _("This gift card is no longer valid."))
|
||||
return
|
||||
trans = gc.transactions.create(
|
||||
value=-1 * payment.amount,
|
||||
order=payment.order,
|
||||
payment=payment
|
||||
)
|
||||
payment.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'transaction_id': trans.pk,
|
||||
}
|
||||
payment.confirm()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
try:
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gcpk)
|
||||
except GiftCard.DoesNotExist:
|
||||
raise PaymentException(_("This gift card does not support this currency."))
|
||||
if gc.currency != self.event.currency: # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card does not support this currency."))
|
||||
if not gc.accepted_by(self.event.organizer):
|
||||
raise PaymentException(_("This gift card is not accepted by this event organizer."))
|
||||
if payment.amount > gc.value:
|
||||
raise PaymentException(_("This gift card was used in the meantime. Please try again."))
|
||||
if gc.testmode and not payment.order.testmode:
|
||||
raise PaymentException(_("This gift card can only be used in test mode."))
|
||||
if not gc.testmode and payment.order.testmode:
|
||||
raise PaymentException(_("Only test gift cards can be used in test mode."))
|
||||
if gc.expires and gc.expires < now():
|
||||
raise PaymentException(_("This gift card is no longer valid."))
|
||||
|
||||
trans = gc.transactions.create(
|
||||
value=-1 * payment.amount,
|
||||
order=payment.order,
|
||||
payment=payment
|
||||
)
|
||||
payment.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'transaction_id': trans.pk,
|
||||
}
|
||||
payment.confirm()
|
||||
except PaymentException as e:
|
||||
payment.fail(info={'error': str(e)})
|
||||
raise e
|
||||
|
||||
def payment_is_valid_session(self, request: HttpRequest) -> bool:
|
||||
return True
|
||||
|
||||
@@ -40,6 +40,7 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unicodedata
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from functools import partial
|
||||
@@ -827,6 +828,13 @@ class Renderer:
|
||||
if o['italic']:
|
||||
font += ' I'
|
||||
|
||||
try:
|
||||
ad = getAscentDescent(font, float(o['fontsize']))
|
||||
except KeyError: # font not known, fall back
|
||||
logger.warning(f'Use of unknown font "{font}"')
|
||||
font = 'Open Sans'
|
||||
ad = getAscentDescent(font, float(o['fontsize']))
|
||||
|
||||
align_map = {
|
||||
'left': TA_LEFT,
|
||||
'center': TA_CENTER,
|
||||
@@ -853,10 +861,12 @@ class Renderer:
|
||||
except:
|
||||
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
|
||||
|
||||
# reportlab does not support unicode combination characters
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
|
||||
p = Paragraph(text, style=style)
|
||||
w, h = p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
|
||||
# p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
|
||||
ad = getAscentDescent(font, float(o['fontsize']))
|
||||
canvas.saveState()
|
||||
# The ascent/descent offsets here are not really proven to be correct, they're just empirical values to get
|
||||
# reportlab render similarly to browser canvas.
|
||||
@@ -891,7 +901,11 @@ class Renderer:
|
||||
elif o['type'] == "poweredby":
|
||||
self._draw_poweredby(canvas, op, o)
|
||||
if self.bg_pdf:
|
||||
canvas.setPageSize((self.bg_pdf.pages[0].mediabox[2], self.bg_pdf.pages[0].mediabox[3]))
|
||||
page_size = (self.bg_pdf.pages[0].mediabox[2], self.bg_pdf.pages[0].mediabox[3])
|
||||
if self.bg_pdf.pages[0].get('/Rotate') in (90, 270):
|
||||
# swap dimensions due to pdf being rotated
|
||||
page_size = page_size[::-1]
|
||||
canvas.setPageSize(page_size)
|
||||
if show_page:
|
||||
canvas.showPage()
|
||||
|
||||
@@ -915,13 +929,37 @@ class Renderer:
|
||||
with open(os.path.join(d, 'out.pdf'), 'rb') as f:
|
||||
return BytesIO(f.read())
|
||||
else:
|
||||
from PyPDF2 import PdfReader, PdfWriter
|
||||
from PyPDF2 import PdfReader, PdfWriter, Transformation
|
||||
from PyPDF2.generic import RectangleObject
|
||||
buffer.seek(0)
|
||||
new_pdf = PdfReader(buffer)
|
||||
output = PdfWriter()
|
||||
|
||||
for i, page in enumerate(new_pdf.pages):
|
||||
bg_page = copy.copy(self.bg_pdf.pages[i])
|
||||
bg_rotation = bg_page.get('/Rotate')
|
||||
if bg_rotation:
|
||||
# /Rotate is clockwise, transformation.rotate is counter-clockwise
|
||||
t = Transformation().rotate(bg_rotation)
|
||||
w = float(page.mediabox.getWidth())
|
||||
h = float(page.mediabox.getHeight())
|
||||
if bg_rotation in (90, 270):
|
||||
# offset due to rotation base
|
||||
if bg_rotation == 90:
|
||||
t = t.translate(h, 0)
|
||||
else:
|
||||
t = t.translate(0, w)
|
||||
# rotate mediabox as well
|
||||
page.mediabox = RectangleObject((
|
||||
page.mediabox.left.as_numeric(),
|
||||
page.mediabox.bottom.as_numeric(),
|
||||
page.mediabox.top.as_numeric(),
|
||||
page.mediabox.right.as_numeric(),
|
||||
))
|
||||
page.trimbox = page.mediabox
|
||||
elif bg_rotation == 180:
|
||||
t = t.translate(w, h)
|
||||
page.add_transformation(t)
|
||||
bg_page.merge_page(page)
|
||||
output.add_page(bg_page)
|
||||
|
||||
|
||||
@@ -23,11 +23,12 @@
|
||||
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: pretix_sig1.proto
|
||||
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import (
|
||||
descriptor as _descriptor, message as _message, reflection as _reflection,
|
||||
descriptor as _descriptor, descriptor_pool as _descriptor_pool,
|
||||
symbol_database as _symbol_database,
|
||||
)
|
||||
from google.protobuf.internal import builder as _builder
|
||||
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
@@ -36,80 +37,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor.FileDescriptor(
|
||||
name='pretix_sig1.proto',
|
||||
package='',
|
||||
syntax='proto3',
|
||||
serialized_options=b'\n\026eu.pretix.secrets.sig1B\014TicketProtos',
|
||||
create_key=_descriptor._internal_create_key,
|
||||
serialized_pb=b'\n\x11pretix_sig1.proto\"I\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x42&\n\x16\x65u.pretix.secrets.sig1B\x0cTicketProtosb\x06proto3'
|
||||
)
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11pretix_sig1.proto\"I\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x42\x33\n#eu.pretix.libpretixsync.crypto.sig1B\x0cTicketProtosb\x06proto3')
|
||||
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'pretix_sig1_pb2', globals())
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
|
||||
|
||||
_TICKET = _descriptor.Descriptor(
|
||||
name='Ticket',
|
||||
full_name='Ticket',
|
||||
filename=None,
|
||||
file=DESCRIPTOR,
|
||||
containing_type=None,
|
||||
create_key=_descriptor._internal_create_key,
|
||||
fields=[
|
||||
_descriptor.FieldDescriptor(
|
||||
name='seed', full_name='Ticket.seed', index=0,
|
||||
number=1, type=9, cpp_type=9, label=1,
|
||||
has_default_value=False, default_value=b"".decode('utf-8'),
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='item', full_name='Ticket.item', index=1,
|
||||
number=2, type=3, cpp_type=2, label=1,
|
||||
has_default_value=False, default_value=0,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='variation', full_name='Ticket.variation', index=2,
|
||||
number=3, type=3, cpp_type=2, label=1,
|
||||
has_default_value=False, default_value=0,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='subevent', full_name='Ticket.subevent', index=3,
|
||||
number=4, type=3, cpp_type=2, label=1,
|
||||
has_default_value=False, default_value=0,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
],
|
||||
extensions=[
|
||||
],
|
||||
nested_types=[],
|
||||
enum_types=[
|
||||
],
|
||||
serialized_options=None,
|
||||
is_extendable=False,
|
||||
syntax='proto3',
|
||||
extension_ranges=[],
|
||||
oneofs=[
|
||||
],
|
||||
serialized_start=21,
|
||||
serialized_end=94,
|
||||
)
|
||||
|
||||
DESCRIPTOR.message_types_by_name['Ticket'] = _TICKET
|
||||
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
|
||||
|
||||
Ticket = _reflection.GeneratedProtocolMessageType('Ticket', (_message.Message,), {
|
||||
'DESCRIPTOR' : _TICKET,
|
||||
'__module__' : 'pretix_sig1_pb2'
|
||||
# @@protoc_insertion_point(class_scope:Ticket)
|
||||
})
|
||||
_sym_db.RegisterMessage(Ticket)
|
||||
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n#eu.pretix.libpretixsync.crypto.sig1B\014TicketProtos'
|
||||
_TICKET._serialized_start=21
|
||||
_TICKET._serialized_end=94
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
# 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 uuid
|
||||
from collections import Counter, defaultdict, namedtuple
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
@@ -101,15 +101,20 @@ error_messages = {
|
||||
'min_items_per_product': _("You need to select at least %(min)s items of the product %(product)s."),
|
||||
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
|
||||
"%(min)s items of it."),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
'ended': _('The presale period for this event has ended.'),
|
||||
'not_started': _('The booking period for this event has not yet started.'),
|
||||
'ended': _('The booking period for this event has ended.'),
|
||||
'payment_ended': _('All payments for this event need to be confirmed already, so no new orders can be created.'),
|
||||
'some_subevent_not_started': _('The presale period for this event has not yet started. The affected positions '
|
||||
'some_subevent_not_started': _('The booking period for this event has not yet started. The affected positions '
|
||||
'have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
||||
'some_subevent_ended': _('The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'price_too_high': _('The entered price is to high.'),
|
||||
'voucher_invalid': _('This voucher code is not known in our database.'),
|
||||
'voucher_min_usages': _('The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
|
||||
'matching products.'),
|
||||
'voucher_min_usages_removed': _('The voucher code "%(voucher)s" can only be used if you select at least '
|
||||
'%(number)s matching products. We have therefore removed some positions from '
|
||||
'your cart that can no longer be purchased like this.'),
|
||||
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
|
||||
'voucher_redeemed_cart': _('This voucher code is currently locked since it is already contained in a cart. This '
|
||||
'might mean that someone else is redeeming this voucher right now, or that you tried '
|
||||
@@ -147,11 +152,50 @@ error_messages = {
|
||||
}
|
||||
|
||||
|
||||
def _get_quota_availability(quota_diff, now_dt):
|
||||
quotas_ok = defaultdict(int)
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(*[k for k, v in quota_diff.items() if v > 0])
|
||||
qa.compute(now_dt=now_dt)
|
||||
for quota, count in quota_diff.items():
|
||||
if count <= 0:
|
||||
quotas_ok[quota] = 0
|
||||
break
|
||||
avail = qa.results[quota]
|
||||
if avail[1] is not None and avail[1] < count:
|
||||
quotas_ok[quota] = min(count, avail[1])
|
||||
else:
|
||||
quotas_ok[quota] = count
|
||||
return quotas_ok
|
||||
|
||||
|
||||
def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_ids):
|
||||
vouchers_ok = {}
|
||||
_voucher_depend_on_cart = set()
|
||||
for voucher, count in voucher_use_diff.items():
|
||||
voucher.refresh_from_db()
|
||||
|
||||
if voucher.valid_until is not None and voucher.valid_until < now_dt:
|
||||
raise CartError(error_messages['voucher_expired'])
|
||||
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=event) &
|
||||
Q(expires__gte=now_dt)
|
||||
).exclude(pk__in=exclude_position_ids)
|
||||
cart_count = redeemed_in_carts.count()
|
||||
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
||||
if cart_count > 0:
|
||||
_voucher_depend_on_cart.add(voucher)
|
||||
vouchers_ok[voucher] = v_avail
|
||||
|
||||
return vouchers_ok, _voucher_depend_on_cart
|
||||
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
|
||||
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
|
||||
'price_after_voucher', 'custom_price_input',
|
||||
'custom_price_input_is_net'))
|
||||
'custom_price_input_is_net', 'voucher_ignored'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price_after_voucher'))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'voucher',
|
||||
@@ -286,12 +330,16 @@ class CartManager:
|
||||
(isinstance(op, self.ExtendOperation) and op.position.is_bundled)
|
||||
):
|
||||
if op.item.require_voucher and op.voucher is None:
|
||||
if getattr(op, 'voucher_ignored', False):
|
||||
raise CartError(error_messages['voucher_redeemed'])
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if (
|
||||
(op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher)) and
|
||||
(op.voucher is None or not op.voucher.show_hidden_items)
|
||||
):
|
||||
if getattr(op, 'voucher_ignored', False):
|
||||
raise CartError(error_messages['voucher_redeemed'])
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if not op.item.is_available() or (op.variation and not op.variation.is_available()):
|
||||
@@ -405,12 +453,15 @@ class CartManager:
|
||||
if cp.is_bundled:
|
||||
bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first()
|
||||
if bundle:
|
||||
listed_price = bundle.designated_price or 0
|
||||
listed_price = bundle.designated_price or Decimal('0.00')
|
||||
else:
|
||||
listed_price = cp.price
|
||||
price_after_voucher = listed_price
|
||||
else:
|
||||
listed_price = get_listed_price(cp.item, cp.variation, cp.subevent)
|
||||
if cp.addon_to_id and is_included_for_free(cp.item, cp.addon_to):
|
||||
listed_price = Decimal('0.00')
|
||||
else:
|
||||
listed_price = get_listed_price(cp.item, cp.variation, cp.subevent)
|
||||
if cp.voucher:
|
||||
price_after_voucher = cp.voucher.calculate_price(listed_price)
|
||||
else:
|
||||
@@ -436,7 +487,7 @@ class CartManager:
|
||||
self._check_item_constraints(op)
|
||||
|
||||
if cp.voucher:
|
||||
self._voucher_use_diff[cp.voucher] += 1
|
||||
self._voucher_use_diff[cp.voucher] += 2
|
||||
|
||||
self._operations.append(op)
|
||||
return err
|
||||
@@ -485,6 +536,15 @@ class CartManager:
|
||||
voucher_use_diff[voucher] += 1
|
||||
ops.append((listed_price - price_after_voucher, self.VoucherOperation(p, voucher, price_after_voucher)))
|
||||
|
||||
for voucher, cnt in list(voucher_use_diff.items()):
|
||||
if 0 < cnt < voucher.min_usages_remaining:
|
||||
raise CartError(
|
||||
_(error_messages['voucher_min_usages']) % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
}
|
||||
)
|
||||
|
||||
# If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits
|
||||
# the user the most.
|
||||
ops.sort(key=lambda k: k[0], reverse=True)
|
||||
@@ -533,6 +593,7 @@ class CartManager:
|
||||
item = self._items_cache[i['item']]
|
||||
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
|
||||
voucher = None
|
||||
voucher_ignored = False
|
||||
|
||||
if i.get('voucher'):
|
||||
try:
|
||||
@@ -542,6 +603,24 @@ class CartManager:
|
||||
else:
|
||||
voucher_use_diff[voucher] += i['count']
|
||||
|
||||
if i.get('voucher_ignore_if_redeemed', False):
|
||||
# This is a special case handling for when a user clicks "+" on an existing line in their cart
|
||||
# that has a voucher attached. If the voucher still has redemptions left, we'll add another line
|
||||
# with the same voucher, but if it does not we silently continue as if there was no voucher,
|
||||
# leading to either a higher-priced ticket or an error. Still, this leads to less error cases
|
||||
# than either of the possible default assumptions.
|
||||
predicted_redeemed_after = (
|
||||
voucher.redeemed +
|
||||
CartPosition.objects.filter(voucher=voucher, expires__gte=self.now_dt).count() +
|
||||
self._voucher_use_diff[voucher] +
|
||||
voucher_use_diff[voucher]
|
||||
)
|
||||
if predicted_redeemed_after > voucher.max_usages:
|
||||
i.pop('voucher')
|
||||
voucher_ignored = True
|
||||
voucher = None
|
||||
voucher_use_diff[voucher] -= i['count']
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
quotas = list(item.quotas.filter(subevent=subevent)
|
||||
if variation is None else variation.quotas.filter(subevent=subevent))
|
||||
@@ -588,6 +667,7 @@ class CartManager:
|
||||
price_after_voucher=bundle.designated_price,
|
||||
custom_price_input=None,
|
||||
custom_price_input_is_net=False,
|
||||
voucher_ignored=False,
|
||||
)
|
||||
self._check_item_constraints(bop, operations)
|
||||
bundled.append(bop)
|
||||
@@ -617,6 +697,7 @@ class CartManager:
|
||||
price_after_voucher=price_after_voucher,
|
||||
custom_price_input=custom_price,
|
||||
custom_price_input_is_net=self.event.settings.display_net_prices,
|
||||
voucher_ignored=voucher_ignored,
|
||||
)
|
||||
self._check_item_constraints(op, operations)
|
||||
operations.append(op)
|
||||
@@ -748,6 +829,7 @@ class CartManager:
|
||||
price_after_voucher=listed_price,
|
||||
custom_price_input=custom_price,
|
||||
custom_price_input_is_net=self.event.settings.display_net_prices,
|
||||
voucher_ignored=False,
|
||||
)
|
||||
self._check_item_constraints(op, operations)
|
||||
operations.append(op)
|
||||
@@ -819,43 +901,13 @@ class CartManager:
|
||||
self._quota_diff.update(quota_diff)
|
||||
self._operations += operations
|
||||
|
||||
def _get_quota_availability(self):
|
||||
quotas_ok = defaultdict(int)
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(*[k for k, v in self._quota_diff.items() if v > 0])
|
||||
qa.compute(now_dt=self.now_dt)
|
||||
for quota, count in self._quota_diff.items():
|
||||
if count <= 0:
|
||||
quotas_ok[quota] = 0
|
||||
break
|
||||
avail = qa.results[quota]
|
||||
if avail[1] is not None and avail[1] < count:
|
||||
quotas_ok[quota] = min(count, avail[1])
|
||||
else:
|
||||
quotas_ok[quota] = count
|
||||
return quotas_ok
|
||||
|
||||
def _get_voucher_availability(self):
|
||||
vouchers_ok = {}
|
||||
self._voucher_depend_on_cart = set()
|
||||
for voucher, count in self._voucher_use_diff.items():
|
||||
voucher.refresh_from_db()
|
||||
|
||||
if voucher.valid_until is not None and voucher.valid_until < self.now_dt:
|
||||
raise CartError(error_messages['voucher_expired'])
|
||||
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=self.event) &
|
||||
Q(expires__gte=self.now_dt)
|
||||
).exclude(pk__in=[
|
||||
vouchers_ok, self._voucher_depend_on_cart = _get_voucher_availability(
|
||||
self.event, self._voucher_use_diff, self.now_dt,
|
||||
exclude_position_ids=[
|
||||
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||
])
|
||||
cart_count = redeemed_in_carts.count()
|
||||
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
||||
if cart_count > 0:
|
||||
self._voucher_depend_on_cart.add(voucher)
|
||||
vouchers_ok[voucher] = v_avail
|
||||
|
||||
]
|
||||
)
|
||||
return vouchers_ok
|
||||
|
||||
def _check_min_max_per_product(self):
|
||||
@@ -906,9 +958,44 @@ class CartManager:
|
||||
)
|
||||
return err
|
||||
|
||||
def _check_min_per_voucher(self):
|
||||
vouchers = Counter()
|
||||
for p in self.positions:
|
||||
vouchers[p.voucher] += 1
|
||||
for op in self._operations:
|
||||
if isinstance(op, self.AddOperation):
|
||||
vouchers[op.voucher] += op.count
|
||||
elif isinstance(op, self.RemoveOperation):
|
||||
vouchers[op.position.voucher] -= 1
|
||||
|
||||
err = None
|
||||
for voucher, count in vouchers.items():
|
||||
if not voucher or count == 0:
|
||||
continue
|
||||
if count < voucher.min_usages_remaining:
|
||||
self._operations = [o for o in self._operations if not (
|
||||
isinstance(o, self.AddOperation) and o.voucher and o.voucher.pk == voucher.pk
|
||||
)]
|
||||
removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)]
|
||||
for p in self.positions:
|
||||
if p.voucher_id == voucher.pk and p.pk not in removals:
|
||||
self._operations.append(self.RemoveOperation(position=p))
|
||||
err = _(error_messages['voucher_min_usages_removed']) % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
}
|
||||
if not err:
|
||||
raise CartError(
|
||||
_(error_messages['voucher_min_usages']) % {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
}
|
||||
)
|
||||
return err
|
||||
|
||||
def _perform_operations(self):
|
||||
vouchers_ok = self._get_voucher_availability()
|
||||
quotas_ok = self._get_quota_availability()
|
||||
quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt)
|
||||
err = None
|
||||
new_cart_positions = []
|
||||
|
||||
@@ -1162,6 +1249,7 @@ class CartManager:
|
||||
|
||||
err = self._delete_out_of_timeframe()
|
||||
err = self.extend_expired_positions() or err
|
||||
err = err or self._check_min_per_voucher()
|
||||
|
||||
lockfn = NoLockManager
|
||||
if self._require_locking():
|
||||
@@ -1177,44 +1265,71 @@ class CartManager:
|
||||
raise CartError(err)
|
||||
|
||||
|
||||
def get_fees(event, request, total, invoice_address, provider, positions):
|
||||
def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
|
||||
"""
|
||||
:param request: The current HTTP request context.
|
||||
:param provider: The instance of your payment provider.
|
||||
:param min_value: The minimum value this payment instrument supports, or ``None`` for unlimited.
|
||||
:param max_value: The maximum value this payment instrument supports, or ``None`` for unlimited. Highly discouraged
|
||||
to use for payment providers which charge a payment fee, as this can be very user-unfriendly if
|
||||
users need a second payment method just for the payment fee of the first method.
|
||||
:param info_data: A dictionary of information that will be passed through to the ``OrderPayment.info_data`` attribute.
|
||||
:return:
|
||||
"""
|
||||
from pretix.presale.views.cart import cart_session
|
||||
|
||||
cs = cart_session(request)
|
||||
cs.setdefault('payments', [])
|
||||
|
||||
cs['payments'].append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'provider': provider.identifier,
|
||||
'multi_use_supported': provider.multi_use_supported,
|
||||
'min_value': str(min_value) if min_value is not None else None,
|
||||
'max_value': str(max_value) if max_value is not None else None,
|
||||
'info_data': info_data or {},
|
||||
})
|
||||
|
||||
|
||||
def get_fees(event, request, total, invoice_address, payments, positions):
|
||||
if payments and not isinstance(payments, list):
|
||||
raise TypeError("payments must now be a list")
|
||||
|
||||
fees = []
|
||||
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
|
||||
total=total, positions=positions):
|
||||
total=total, positions=positions, payment_requests=payments):
|
||||
if resp:
|
||||
fees += resp
|
||||
|
||||
total = total + sum(f.value for f in fees)
|
||||
|
||||
cs = cart_session(request)
|
||||
if cs.get('gift_cards'):
|
||||
gcs = cs['gift_cards']
|
||||
gc_qs = event.organizer.accepted_gift_cards.filter(pk__in=cs.get('gift_cards'), currency=event.currency)
|
||||
for gc in gc_qs:
|
||||
if gc.testmode != event.testmode:
|
||||
gcs.remove(gc.pk)
|
||||
if total != 0 and payments:
|
||||
total_remaining = total
|
||||
for p in payments:
|
||||
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
||||
# places in the code base:
|
||||
# - pretix.base.services.cart.get_fees
|
||||
# - pretix.base.services.orders._get_fees
|
||||
# - pretix.presale.views.CartMixin.current_selected_payments
|
||||
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
|
||||
continue
|
||||
fval = Decimal(gc.value) # TODO: don't require an extra query
|
||||
fval = min(fval, total)
|
||||
if fval > 0:
|
||||
total -= fval
|
||||
fees.append(OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_GIFTCARD,
|
||||
internal_type='giftcard',
|
||||
description=gc.secret,
|
||||
value=-1 * fval,
|
||||
tax_rate=Decimal('0.00'),
|
||||
tax_value=Decimal('0.00'),
|
||||
tax_rule=TaxRule.zero()
|
||||
))
|
||||
cs['gift_cards'] = gcs
|
||||
|
||||
if provider and total != 0:
|
||||
provider = event.get_payment_providers().get(provider)
|
||||
if provider:
|
||||
payment_fee = provider.calculate_fee(total)
|
||||
to_pay = total_remaining
|
||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
pprov = event.get_payment_providers(cached=True).get(p['provider'])
|
||||
if not pprov:
|
||||
continue
|
||||
|
||||
payment_fee = pprov.calculate_fee(to_pay)
|
||||
total_remaining += payment_fee
|
||||
to_pay += payment_fee
|
||||
|
||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
total_remaining -= to_pay
|
||||
|
||||
if payment_fee:
|
||||
payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero()
|
||||
|
||||
@@ -26,6 +26,7 @@ from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import override
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Device, Event, Organizer, TeamAPIToken, User, cachedfile_name,
|
||||
@@ -66,8 +67,8 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
|
||||
gettext('Your export did not contain any data.')
|
||||
)
|
||||
file.filename, file.type, data = d
|
||||
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
|
||||
file.save()
|
||||
f = ContentFile(data)
|
||||
file.file.save(cachedfile_name(file, file.filename), f)
|
||||
return file.pk
|
||||
|
||||
|
||||
@@ -101,9 +102,9 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
|
||||
timezone = e.settings.timezone
|
||||
region = e.settings.region
|
||||
else:
|
||||
locale = settings.LANGUAGE_CODE
|
||||
timezone = settings.TIME_ZONE
|
||||
region = None
|
||||
locale = organizer.settings.locale or settings.LANGUAGE_CODE
|
||||
timezone = organizer.settings.timezone or settings.TIME_ZONE
|
||||
region = organizer.settings.region
|
||||
with language(locale, region), override(timezone):
|
||||
if form_data.get('events') is not None:
|
||||
if isinstance(form_data['events'][0], str):
|
||||
@@ -119,12 +120,21 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
|
||||
continue
|
||||
ex = response(events, organizer, set_progress)
|
||||
if ex.identifier == provider:
|
||||
if (
|
||||
isinstance(ex, OrganizerLevelExportMixin) and
|
||||
not staff_session and
|
||||
not (device or token or user).has_organizer_permission(organizer, ex.organizer_required_permission)
|
||||
):
|
||||
raise ExportError(
|
||||
gettext('You do not have sufficient permission to perform this export.')
|
||||
)
|
||||
|
||||
d = ex.render(form_data)
|
||||
if d is None:
|
||||
raise ExportError(
|
||||
gettext('Your export did not contain any data.')
|
||||
)
|
||||
file.filename, file.type, data = d
|
||||
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
|
||||
file.save()
|
||||
f = ContentFile(data)
|
||||
file.file.save(cachedfile_name(file, file.filename), f)
|
||||
return file.pk
|
||||
|
||||
@@ -452,17 +452,19 @@ def build_preview_invoice_pdf(event):
|
||||
|
||||
if event.tax_rules.exists():
|
||||
for i, tr in enumerate(event.tax_rules.all()):
|
||||
tax = tr.tax(Decimal('100.00'), base_price_is='gross')
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=_("Sample product {}").format(i + 1),
|
||||
gross_value=tax.gross, tax_value=tax.tax,
|
||||
tax_rate=tax.rate
|
||||
)
|
||||
for j in range(150):
|
||||
tax = tr.tax(Decimal('100.00'), base_price_is='gross')
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=_("Sample product {}").format(i + 1),
|
||||
gross_value=tax.gross, tax_value=tax.tax,
|
||||
tax_rate=tax.rate
|
||||
)
|
||||
else:
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=_("Sample product A"),
|
||||
gross_value=100, tax_value=0, tax_rate=0
|
||||
)
|
||||
for i in range(150):
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=_("Sample product A"),
|
||||
gross_value=100, tax_value=0, tax_rate=0
|
||||
)
|
||||
|
||||
return event.invoice_renderer.generate(invoice)
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class LockTimeoutException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LockReleaseException(Exception):
|
||||
class LockReleaseException(LockTimeoutException):
|
||||
pass
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ def lock_event_redis(event):
|
||||
retries = 5
|
||||
for i in range(retries):
|
||||
try:
|
||||
if lock.acquire(False):
|
||||
if lock.acquire(blocking=False):
|
||||
return True
|
||||
except RedisError:
|
||||
logger.exception('Error locking an event')
|
||||
@@ -180,5 +180,5 @@ def release_event_redis(event):
|
||||
lock.release()
|
||||
except RedisError:
|
||||
logger.exception('Error releasing an event lock')
|
||||
raise LockTimeoutException()
|
||||
raise LockReleaseException()
|
||||
event._lock = None
|
||||
|
||||
@@ -63,6 +63,7 @@ from django.utils.timezone import now, override
|
||||
from django.utils.translation import gettext as _, pgettext
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from text_unidecode import unidecode
|
||||
|
||||
from pretix.base.email import ClassicMailRenderer
|
||||
from pretix.base.i18n import language
|
||||
@@ -196,7 +197,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
else:
|
||||
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
|
||||
|
||||
subject = raw_subject = str(subject)
|
||||
subject = raw_subject = str(subject).replace('\n', ' ').replace('\r', '')[:900]
|
||||
signature = ""
|
||||
|
||||
bcc = []
|
||||
@@ -431,8 +432,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
}
|
||||
)
|
||||
if attach_ical:
|
||||
fname = re.sub('[^a-zA-Z0-9 ]', '-', unidecode(pgettext('attachment_filename', 'Calendar invite')))
|
||||
for i, cal in enumerate(get_private_icals(event, [position] if position else order.positions.all())):
|
||||
email.attach('event-{}.ics'.format(i), cal.serialize(), 'text/calendar')
|
||||
email.attach('{}{}.ics'.format(fname, f'-{i + 1}' if i > 0 else ''), cal.serialize(), 'text/calendar')
|
||||
|
||||
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
|
||||
|
||||
@@ -506,7 +508,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
else:
|
||||
# Most likely some other kind of temporary failure, retry again (but pretty soon)
|
||||
max_retries = 5
|
||||
retry_after = [10, 30, 60, 300, 900][self.request.retries]
|
||||
retry_after = [10, 30, 60, 300, 900, 900][self.request.retries]
|
||||
|
||||
try:
|
||||
self.retry(max_retries=max_retries, countdown=retry_after)
|
||||
@@ -542,7 +544,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
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][self.request.retries])
|
||||
self.retry(max_retries=5, countdown=[60, 300, 600, 1200, 1800, 1800][self.request.retries])
|
||||
except MaxRetriesExceededError:
|
||||
# ignore and go on with logging the error
|
||||
pass
|
||||
@@ -567,7 +569,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
except Exception as e:
|
||||
if isinstance(e, (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, ssl.SSLError, OSError)):
|
||||
try:
|
||||
self.retry(max_retries=5, countdown=[10, 30, 60, 300, 900][self.request.retries])
|
||||
self.retry(max_retries=5, countdown=[10, 30, 60, 300, 900, 900][self.request.retries])
|
||||
except MaxRetriesExceededError:
|
||||
if log_target:
|
||||
log_target.log_action(
|
||||
|
||||
@@ -54,7 +54,7 @@ from django.db.transaction import get_connection
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.api.models import OAuthApplication
|
||||
@@ -74,7 +74,7 @@ from pretix.base.models.orders import (
|
||||
)
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||
from pretix.base.payment import BasePaymentProvider, PaymentException
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.secrets import assign_ticket_secret
|
||||
from pretix.base.services import tickets
|
||||
@@ -113,8 +113,10 @@ error_messages = {
|
||||
"surplus items from your cart."),
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
'ended': _('The presale period has ended.'),
|
||||
'not_started': _('The booking period for this event has not yet started.'),
|
||||
'ended': _('The booking period has ended.'),
|
||||
'voucher_min_usages': _('The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
|
||||
'matching products.'),
|
||||
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
|
||||
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
|
||||
'number of times allowed. We removed this item from your cart.'),
|
||||
@@ -125,9 +127,9 @@ error_messages = {
|
||||
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
|
||||
'removed this item from your cart.'),
|
||||
'voucher_required': _('You need a valid voucher code to order one of the products.'),
|
||||
'some_subevent_not_started': _('The presale period for one of the events in your cart has not yet started. The '
|
||||
'some_subevent_not_started': _('The booking period for one of the events in your cart has not yet started. The '
|
||||
'affected positions have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
||||
'some_subevent_ended': _('The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
|
||||
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
||||
@@ -322,10 +324,10 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
with language(order.locale, order.event.settings.region):
|
||||
if order.total == Decimal('0.00'):
|
||||
email_template = order.event.settings.mail_text_order_approved_free
|
||||
email_subject = _('Order approved and confirmed: %(code)s') % {'code': order.code}
|
||||
email_subject = order.event.settings.mail_subject_order_approved_free
|
||||
else:
|
||||
email_template = order.event.settings.mail_text_order_approved
|
||||
email_subject = _('Order approved and awaiting payment: %(code)s') % {'code': order.code}
|
||||
email_subject = order.event.settings.mail_subject_order_approved
|
||||
|
||||
email_context = get_email_context(event=order.event, order=order)
|
||||
try:
|
||||
@@ -371,9 +373,9 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
|
||||
if send_mail:
|
||||
email_template = order.event.settings.mail_text_order_denied
|
||||
email_subject = order.event.settings.mail_subject_order_denied
|
||||
email_context = get_email_context(event=order.event, order=order, comment=comment)
|
||||
with language(order.locale, order.event.settings.region):
|
||||
email_subject = _('Order denied: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
@@ -460,9 +462,13 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
|
||||
if order.payment_refund_sum < cancellation_fee:
|
||||
raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
|
||||
order.status = Order.STATUS_PAID
|
||||
if cancellation_fee > order.total:
|
||||
raise OrderError(_('The cancellation fee cannot be higher than the total amount of this order.'))
|
||||
elif order.payment_refund_sum < cancellation_fee:
|
||||
order.status = Order.STATUS_PENDING
|
||||
order.set_expires()
|
||||
else:
|
||||
order.status = Order.STATUS_PAID
|
||||
order.total = cancellation_fee
|
||||
order.cancellation_date = now()
|
||||
order.save(update_fields=['status', 'cancellation_date', 'total'])
|
||||
@@ -489,10 +495,10 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
order.create_transactions()
|
||||
|
||||
if send_mail:
|
||||
email_template = order.event.settings.mail_text_order_canceled
|
||||
with language(order.locale, order.event.settings.region):
|
||||
email_template = order.event.settings.mail_text_order_canceled
|
||||
email_subject = order.event.settings.mail_subject_order_canceled
|
||||
email_context = get_email_context(event=order.event, order=order, comment=comment or "")
|
||||
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
@@ -569,6 +575,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
products_seen = Counter()
|
||||
q_avail = Counter()
|
||||
v_avail = Counter()
|
||||
v_usages = Counter()
|
||||
v_budget = {}
|
||||
deleted_positions = set()
|
||||
seats_seen = set()
|
||||
@@ -606,6 +613,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
break
|
||||
|
||||
if cp.voucher:
|
||||
v_usages[cp.voucher] += 1
|
||||
if cp.voucher not in v_avail:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
||||
@@ -717,6 +725,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
# Sorry, can't let you keep that!
|
||||
delete(cp)
|
||||
|
||||
for voucher, cnt in v_usages.items():
|
||||
if 0 < cnt < voucher.min_usages_remaining:
|
||||
raise OrderError(error_messages['voucher_min_usages'], {
|
||||
'voucher': voucher.code,
|
||||
'number': voucher.min_usages_remaining,
|
||||
})
|
||||
|
||||
# Check prices
|
||||
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
|
||||
old_total = sum(cp.price for cp in sorted_positions)
|
||||
@@ -778,68 +793,75 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
raise OrderError(err, errargs)
|
||||
|
||||
|
||||
def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvider, address: InvoiceAddress,
|
||||
meta_info: dict, event: Event, gift_cards: List[GiftCard]):
|
||||
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
|
||||
meta_info: dict, event: Event, require_approval=False):
|
||||
fees = []
|
||||
total = sum([c.price for c in positions])
|
||||
|
||||
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
|
||||
gift_cards = [] # for backwards compatibility
|
||||
for p in payment_requests:
|
||||
if p['provider'] == 'giftcard':
|
||||
gift_cards.append(GiftCard.objects.get(pk=p['info_data']['gift_card']))
|
||||
|
||||
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total, payment_requests=payment_requests,
|
||||
meta_info=meta_info, positions=positions, gift_cards=gift_cards):
|
||||
if resp:
|
||||
fees += resp
|
||||
total += sum(f.value for f in fees)
|
||||
|
||||
gift_card_values = {}
|
||||
for gc in gift_cards:
|
||||
fval = Decimal(gc.value) # TODO: don't require an extra query
|
||||
fval = min(fval, total)
|
||||
if fval > 0:
|
||||
total -= fval
|
||||
gift_card_values[gc] = fval
|
||||
total_remaining = total
|
||||
for p in payment_requests:
|
||||
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
||||
# places in the code base:
|
||||
# - pretix.base.services.cart.get_fees
|
||||
# - pretix.base.services.orders._get_fees
|
||||
# - pretix.presale.views.CartMixin.current_selected_payments
|
||||
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
|
||||
p['payment_amount'] = Decimal('0.00')
|
||||
continue
|
||||
|
||||
if payment_provider:
|
||||
payment_fee = payment_provider.calculate_fee(total)
|
||||
else:
|
||||
payment_fee = 0
|
||||
pf = None
|
||||
if payment_fee:
|
||||
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
|
||||
internal_type=payment_provider.identifier)
|
||||
fees.append(pf)
|
||||
to_pay = total_remaining
|
||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
return fees, pf, gift_card_values
|
||||
payment_fee = p['pprov'].calculate_fee(to_pay)
|
||||
total_remaining += payment_fee
|
||||
to_pay += payment_fee
|
||||
|
||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
total_remaining -= to_pay
|
||||
|
||||
p['payment_amount'] = to_pay
|
||||
if payment_fee:
|
||||
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
|
||||
internal_type=p['pprov'].identifier)
|
||||
fees.append(pf)
|
||||
p['fee'] = pf
|
||||
|
||||
if total_remaining != Decimal('0.00') and not require_approval:
|
||||
raise OrderError(_("The selected payment methods do not cover the total balance."))
|
||||
|
||||
return fees
|
||||
|
||||
|
||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
|
||||
meta_info: dict=None, sales_channel: str='web', gift_cards: list=None, shown_total=None,
|
||||
payment_requests: List[dict], locale: str=None, address: InvoiceAddress=None,
|
||||
meta_info: dict=None, sales_channel: str='web', shown_total=None,
|
||||
customer=None):
|
||||
p = None
|
||||
payments = []
|
||||
sales_channel = get_all_sales_channels()[sales_channel]
|
||||
|
||||
with transaction.atomic():
|
||||
checked_gift_cards = []
|
||||
if gift_cards:
|
||||
gc_qs = GiftCard.objects.select_for_update().filter(pk__in=gift_cards)
|
||||
for gc in gc_qs:
|
||||
if gc.currency != event.currency:
|
||||
raise OrderError(_("This gift card does not support this currency."))
|
||||
if gc.testmode and not event.testmode:
|
||||
raise OrderError(_("This gift card can only be used in test mode."))
|
||||
if not gc.testmode and event.testmode:
|
||||
raise OrderError(_("Only test gift cards can be used in test mode."))
|
||||
if not gc.accepted_by(event.organizer):
|
||||
raise OrderError(_("This gift card is not accepted by this event organizer."))
|
||||
checked_gift_cards.append(gc)
|
||||
if checked_gift_cards and any(c.item.issue_giftcard for c in positions):
|
||||
raise OrderError(_("You cannot pay with gift cards when buying a gift card."))
|
||||
|
||||
try:
|
||||
validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode)
|
||||
except ValidationError as e:
|
||||
raise OrderError(e.message)
|
||||
|
||||
fees, pf, gift_card_values = _get_fees(positions, payment_provider, address, meta_info, event, checked_gift_cards)
|
||||
require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
|
||||
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
|
||||
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
||||
|
||||
order = Order(
|
||||
@@ -852,7 +874,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
total=total,
|
||||
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
||||
meta_info=json.dumps(meta_info or {}),
|
||||
require_approval=any(p.requires_approval(invoice_address=address) for p in positions),
|
||||
require_approval=require_approval,
|
||||
sales_channel=sales_channel.identifier,
|
||||
customer=customer,
|
||||
)
|
||||
@@ -876,28 +898,11 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
fee.tax_rule = None # TODO: deprecate
|
||||
fee.save()
|
||||
|
||||
for gc, val in gift_card_values.items():
|
||||
p = order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='giftcard',
|
||||
amount=val,
|
||||
fee=pf
|
||||
)
|
||||
trans = gc.transactions.create(
|
||||
value=-1 * val,
|
||||
order=order,
|
||||
payment=p
|
||||
)
|
||||
p.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'transaction_id': trans.pk,
|
||||
}
|
||||
p.save()
|
||||
pending_sum -= val
|
||||
|
||||
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
|
||||
# pressed "Confirm purchase"? If not, we should better warn the user and show the confirmation page again.
|
||||
# The only *known* case where this happens is if a gift card is used in two concurrent sessions.
|
||||
# We used to have a *known* case where this happened is if a gift card is used in two concurrent sessions,
|
||||
# but this is now a payment error instead. So currently this code branch is usually only triggered by bugs
|
||||
# in other places (e.g. tax calculation).
|
||||
if shown_total is not None:
|
||||
if Decimal(shown_total) != pending_sum:
|
||||
raise OrderError(
|
||||
@@ -906,13 +911,17 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
'check the prices below and try again.')
|
||||
)
|
||||
|
||||
if payment_provider and not order.require_approval:
|
||||
p = order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider=payment_provider.identifier,
|
||||
amount=pending_sum,
|
||||
fee=pf
|
||||
)
|
||||
if payment_requests and not order.require_approval:
|
||||
for p in payment_requests:
|
||||
if not p.get('multi_use_supported') or p['payment_amount'] > Decimal('0.00'):
|
||||
payments.append(order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider=p['provider'],
|
||||
amount=p['payment_amount'],
|
||||
fee=p.get('fee'),
|
||||
info=json.dumps(p['info_data']),
|
||||
process_initiated=False,
|
||||
))
|
||||
|
||||
orderpositions = OrderPosition.transform_cart_positions(positions, order)
|
||||
order.create_transactions(positions=orderpositions, fees=fees, is_new=True)
|
||||
@@ -924,16 +933,15 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
order.log_action('pretix.event.order.consent', data={'msg': msg})
|
||||
|
||||
order_placed.send(event, order=order)
|
||||
return order, p
|
||||
return order, payments
|
||||
|
||||
|
||||
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
|
||||
invoice, payment: OrderPayment, is_free=False):
|
||||
email_context = get_email_context(event=event, order=order, payment=payment if pprov else None)
|
||||
email_subject = gettext_lazy('Your order: {code}')
|
||||
def _order_placed_email(event: Event, order: Order, email_template, subject_template,
|
||||
log_entry: str, invoice, payments: List[OrderPayment], is_free=False):
|
||||
email_context = get_email_context(event=event, order=order, payments=payments)
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
subject_template, email_template, email_context,
|
||||
log_entry,
|
||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True,
|
||||
@@ -946,13 +954,13 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
|
||||
logger.exception('Order received email could not be sent')
|
||||
|
||||
|
||||
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str, is_free=False):
|
||||
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template,
|
||||
log_entry: str, is_free=False):
|
||||
email_context = get_email_context(event=event, order=order, position=position)
|
||||
email_subject = gettext_lazy('Your event registration: {code}')
|
||||
|
||||
try:
|
||||
position.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
subject_template, email_template, email_context,
|
||||
log_entry,
|
||||
invoices=[],
|
||||
attach_tickets=True,
|
||||
@@ -965,15 +973,13 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
||||
logger.exception('Order received email could not be sent to attendee')
|
||||
|
||||
|
||||
def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
|
||||
gift_cards: list=None, shown_total=None, customer=None):
|
||||
if payment_provider:
|
||||
pprov = event.get_payment_providers().get(payment_provider)
|
||||
if not pprov:
|
||||
shown_total=None, customer=None):
|
||||
for p in payment_requests:
|
||||
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
|
||||
if not p['pprov']:
|
||||
raise OrderError(error_messages['internal'])
|
||||
else:
|
||||
pprov = None
|
||||
|
||||
if customer:
|
||||
customer = event.organizer.customers.get(pk=customer)
|
||||
@@ -1003,8 +1009,17 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
id__in=position_ids, event=event
|
||||
)
|
||||
|
||||
validate_order.send(event, payment_provider=pprov, email=email, positions=positions, locale=locale,
|
||||
invoice_address=addr, meta_info=meta_info, customer=customer)
|
||||
validate_order.send(
|
||||
event,
|
||||
payment_provider=payment_requests[0]['provider'] if payment_requests else None, # only for backwards compatibility
|
||||
payments=payment_requests,
|
||||
email=email,
|
||||
positions=positions,
|
||||
locale=locale,
|
||||
invoice_address=addr,
|
||||
meta_info=meta_info,
|
||||
customer=customer,
|
||||
)
|
||||
|
||||
lockfn = NoLockManager
|
||||
locked = False
|
||||
@@ -1024,21 +1039,28 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
if len(position_ids) != len(positions):
|
||||
raise OrderError(error_messages['internal'])
|
||||
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel, customer=customer)
|
||||
order, payment = _create_order(event, email, positions, now_dt, pprov,
|
||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
|
||||
gift_cards=gift_cards, shown_total=shown_total, customer=customer)
|
||||
order, payment_objs = _create_order(event, email, positions, now_dt, payment_requests,
|
||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
|
||||
shown_total=shown_total, customer=customer)
|
||||
|
||||
free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval
|
||||
free_order_flow = (
|
||||
payment_objs and
|
||||
any(p['provider'] == 'free' for p in payment_requests) and
|
||||
order.pending_sum == Decimal('0.00') and
|
||||
not order.require_approval
|
||||
)
|
||||
if free_order_flow:
|
||||
try:
|
||||
payment.confirm(send_mail=False, lock=not locked)
|
||||
for p in payment_objs:
|
||||
if p.provider == 'free':
|
||||
p.confirm(send_mail=False, lock=not locked)
|
||||
except Quota.QuotaExceededException:
|
||||
pass
|
||||
|
||||
invoice = order.invoices.last() # Might be generated by plugin already
|
||||
if not invoice and invoice_qualified(order):
|
||||
if event.settings.get('invoice_generate') == 'True' or (
|
||||
event.settings.get('invoice_generate') == 'paid' and payment.payment_provider.requires_invoice_immediately):
|
||||
event.settings.get('invoice_generate') == 'paid' and any(p['pprov'].requires_invoice_immediately for p in payment_requests)):
|
||||
invoice = generate_invoice(
|
||||
order,
|
||||
trigger_pdf=not event.settings.invoice_email_attachment or not order.email
|
||||
@@ -1048,32 +1070,63 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
if order.email:
|
||||
if order.require_approval:
|
||||
email_template = event.settings.mail_text_order_placed_require_approval
|
||||
subject_template = event.settings.mail_subject_order_placed_require_approval
|
||||
log_entry = 'pretix.event.order.email.order_placed_require_approval'
|
||||
|
||||
email_attendees = False
|
||||
elif free_order_flow:
|
||||
email_template = event.settings.mail_text_order_free
|
||||
subject_template = event.settings.mail_subject_order_free
|
||||
log_entry = 'pretix.event.order.email.order_free'
|
||||
|
||||
email_attendees = event.settings.mail_send_order_free_attendee
|
||||
email_attendees_template = event.settings.mail_text_order_free_attendee
|
||||
subject_attendees_template = event.settings.mail_subject_order_free_attendee
|
||||
else:
|
||||
email_template = event.settings.mail_text_order_placed
|
||||
subject_template = event.settings.mail_subject_order_placed
|
||||
log_entry = 'pretix.event.order.email.order_placed'
|
||||
|
||||
email_attendees = event.settings.mail_send_order_placed_attendee
|
||||
email_attendees_template = event.settings.mail_text_order_placed_attendee
|
||||
subject_attendees_template = event.settings.mail_subject_order_placed_attendee
|
||||
|
||||
if sales_channel in event.settings.mail_sales_channel_placed_paid:
|
||||
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment,
|
||||
_order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs,
|
||||
is_free=free_order_flow)
|
||||
if email_attendees:
|
||||
for p in order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry,
|
||||
_order_placed_email_attendee(event, order, p, email_attendees_template, subject_attendees_template, log_entry,
|
||||
is_free=free_order_flow)
|
||||
|
||||
return order.id
|
||||
warnings = []
|
||||
any_failed = False
|
||||
for p in payment_objs:
|
||||
if not p.payment_provider.execute_payment_needs_user:
|
||||
try:
|
||||
p.process_initiated = True
|
||||
p.save(update_fields=['process_initiated'])
|
||||
resp = p.payment_provider.execute_payment(None, p)
|
||||
if isinstance(resp, str):
|
||||
logger.warning('Payment provider returned URL from execute_payment even though execute_payment_needs_user is not set')
|
||||
except PaymentException as e:
|
||||
warnings.append(str(e))
|
||||
any_failed = True
|
||||
except Exception:
|
||||
logger.exception('Error during payment attempt')
|
||||
|
||||
if any_failed:
|
||||
# Cancel all other payments because their amount might be wrong now.
|
||||
for p in payment_objs:
|
||||
if p.state == OrderPayment.PAYMENT_STATE_CREATED:
|
||||
p.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||
p.save(update_fields=['state'])
|
||||
|
||||
return {
|
||||
'order_id': order.id,
|
||||
'warnings': warnings,
|
||||
}
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@@ -1082,8 +1135,16 @@ def expire_orders(sender, **kwargs):
|
||||
event_id = None
|
||||
expire = None
|
||||
|
||||
for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING,
|
||||
require_approval=False).select_related('event').order_by('event_id'):
|
||||
qs = Order.objects.filter(
|
||||
expires__lt=now(),
|
||||
status=Order.STATUS_PENDING,
|
||||
require_approval=False
|
||||
).exclude(
|
||||
Exists(
|
||||
OrderFee.objects.filter(order_id=OuterRef('pk'), fee_type=OrderFee.FEE_TYPE_CANCELLATION)
|
||||
)
|
||||
).select_related('event').order_by('event_id')
|
||||
for o in qs:
|
||||
if o.event_id != event_id:
|
||||
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
|
||||
event_id = o.event_id
|
||||
@@ -1124,9 +1185,9 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
email_template = settings.mail_text_order_expire_warning
|
||||
email_context = get_email_context(event=o.event, order=o)
|
||||
if settings.payment_term_expire_automatically:
|
||||
email_subject = _('Your order is about to expire: %(code)s') % {'code': o.code}
|
||||
email_subject = settings.mail_subject_order_expire_warning
|
||||
else:
|
||||
email_subject = _('Your order is pending payment: %(code)s') % {'code': o.code}
|
||||
email_subject = settings.mail_subject_order_pending_warning
|
||||
|
||||
try:
|
||||
o.send_mail(
|
||||
@@ -1199,8 +1260,8 @@ def send_download_reminders(sender, **kwargs):
|
||||
o.download_reminder_sent = True
|
||||
o.save(update_fields=['download_reminder_sent'])
|
||||
email_template = event.settings.mail_text_download_reminder
|
||||
email_subject = event.settings.mail_subject_download_reminder
|
||||
email_context = get_email_context(event=event, order=o)
|
||||
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
@@ -1223,6 +1284,7 @@ def send_download_reminders(sender, **kwargs):
|
||||
continue
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
|
||||
email_template = event.settings.mail_text_download_reminder_attendee
|
||||
email_subject = event.settings.mail_subject_download_reminder_attendee
|
||||
email_context = get_email_context(event=event, order=o, position=p)
|
||||
try:
|
||||
o.send_mail(
|
||||
@@ -1238,7 +1300,7 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
|
||||
with language(order.locale, order.event.settings.region):
|
||||
email_template = order.event.settings.mail_text_order_changed
|
||||
email_context = get_email_context(event=order.event, order=order)
|
||||
email_subject = _('Your order has been changed: %(code)s') % {'code': order.code}
|
||||
email_subject = order.event.settings.mail_subject_order_changed
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
@@ -1944,14 +2006,14 @@ class OrderChangeManager:
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'addon_to': op.position.addon_to_id,
|
||||
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rate else None,
|
||||
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rule else None,
|
||||
'new_taxrule': op.tax_rule.pk
|
||||
})
|
||||
elif isinstance(op.position, OrderFee):
|
||||
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={
|
||||
'fee': op.position.pk,
|
||||
'fee_type': op.position.fee_type,
|
||||
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rate else None,
|
||||
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rule else None,
|
||||
'new_taxrule': op.tax_rule.pk
|
||||
})
|
||||
op.position._calculate_tax(op.tax_rule)
|
||||
@@ -2366,14 +2428,14 @@ class OrderChangeManager:
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def perform_order(self, event: Event, payment_provider: str, positions: List[str],
|
||||
def perform_order(self, event: Event, payments: List[dict], positions: List[str],
|
||||
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
|
||||
sales_channel: str='web', gift_cards: list=None, shown_total=None, customer=None):
|
||||
sales_channel: str='web', shown_total=None, customer=None):
|
||||
with language(locale):
|
||||
try:
|
||||
try:
|
||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
|
||||
sales_channel, gift_cards, shown_total, customer)
|
||||
return _perform_order(event, payments, positions, email, locale, address, meta_info,
|
||||
sales_channel, shown_total, customer)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
|
||||
@@ -117,7 +117,7 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE
|
||||
|
||||
|
||||
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
|
||||
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal) -> TaxedPrice:
|
||||
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice:
|
||||
if not tax_rule:
|
||||
tax_rule = TaxRule(
|
||||
name='',
|
||||
@@ -135,7 +135,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
|
||||
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
|
||||
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum,
|
||||
base_price_is='gross' if is_bundled else 'auto')
|
||||
|
||||
return price
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ def dictsum(*dicts) -> dict:
|
||||
|
||||
def order_overview(
|
||||
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
|
||||
admission_only=False
|
||||
admission_only=False, base_qs=None
|
||||
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
|
||||
items = event.items.all().select_related(
|
||||
'category', # for re-grouping
|
||||
@@ -120,7 +120,7 @@ def order_overview(
|
||||
'variations'
|
||||
).order_by('category__position', 'category_id', 'position', 'name')
|
||||
|
||||
qs = OrderPosition.all
|
||||
qs = OrderPosition.all if base_qs is None else base_qs
|
||||
if isinstance(subevent, (list, QuerySet)):
|
||||
qs = qs.filter(subevent__in=subevent)
|
||||
elif subevent:
|
||||
|
||||
@@ -743,6 +743,18 @@ DEFAULTS = {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'payment_giftcard_public_name': {
|
||||
'default': LazyI18nString.from_gettext(gettext_noop('Gift card')),
|
||||
'type': LazyI18nString
|
||||
},
|
||||
'payment_giftcard_public_description': {
|
||||
'default': LazyI18nString.from_gettext(gettext_noop(
|
||||
'If you have a gift card, please enter the gift card code here. If the gift card does not have '
|
||||
'enough credit to pay for the full order, you will be shown this page again and you can either '
|
||||
'redeem another gift card or select a different payment method for the difference.'
|
||||
)),
|
||||
'type': LazyI18nString
|
||||
},
|
||||
'payment_resellers__restrict_to_sales_channels': {
|
||||
'default': ['resellers'],
|
||||
'type': list
|
||||
@@ -1421,6 +1433,45 @@ DEFAULTS = {
|
||||
label=_("Customers can cancel their unpaid orders"),
|
||||
)
|
||||
},
|
||||
'cancel_allow_user_unpaid_keep': {
|
||||
'default': '0.00',
|
||||
'type': Decimal,
|
||||
'form_class': forms.DecimalField,
|
||||
'serializer_class': serializers.DecimalField,
|
||||
'serializer_kwargs': dict(
|
||||
max_digits=10, decimal_places=2
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
label=_("Charge a fixed cancellation fee"),
|
||||
help_text=_("Only affects orders pending payments, a cancellation fee for free orders is never charged. "
|
||||
"Note that it will be your responsibility to claim the cancellation fee from the user."),
|
||||
)
|
||||
},
|
||||
'cancel_allow_user_unpaid_keep_fees': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Charge payment, shipping and service fees"),
|
||||
help_text=_("Only affects orders pending payments, a cancellation fee for free orders is never charged. "
|
||||
"Note that it will be your responsibility to claim the cancellation fee from the user."),
|
||||
)
|
||||
},
|
||||
'cancel_allow_user_unpaid_keep_percentage': {
|
||||
'default': '0.00',
|
||||
'type': Decimal,
|
||||
'form_class': forms.DecimalField,
|
||||
'serializer_class': serializers.DecimalField,
|
||||
'serializer_kwargs': dict(
|
||||
max_digits=10, decimal_places=2
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
label=_("Charge a percentual cancellation fee"),
|
||||
help_text=_("Only affects orders pending payments, a cancellation fee for free orders is never charged. "
|
||||
"Note that it will be your responsibility to claim the cancellation fee from the user."),
|
||||
)
|
||||
},
|
||||
'cancel_allow_user_until': {
|
||||
'default': None,
|
||||
'type': RelativeDateWrapper,
|
||||
@@ -1708,6 +1759,14 @@ DEFAULTS = {
|
||||
'type': LazyI18nString,
|
||||
'default': ""
|
||||
},
|
||||
'mail_subject_resend_link': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your order: {code}")),
|
||||
},
|
||||
'mail_subject_resend_link_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your event registration: {code}")),
|
||||
},
|
||||
'mail_text_resend_link': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1721,6 +1780,10 @@ You can change your order details and view the status of your order at
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_resend_all_links': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your orders for {event}")),
|
||||
},
|
||||
'mail_text_resend_all_links': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1733,6 +1796,10 @@ The list is as follows:
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_order_free_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your event registration: {code}")),
|
||||
},
|
||||
'mail_text_order_free_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name},
|
||||
@@ -1745,6 +1812,14 @@ You can view the details and status of your ticket here:
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_send_order_free_attendee': {
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
},
|
||||
'mail_subject_order_free': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your order: {code}")),
|
||||
},
|
||||
'mail_text_order_free': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1758,9 +1833,9 @@ You can change your order details and view the status of your order at
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_send_order_free_attendee': {
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
'mail_subject_order_placed_require_approval': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your order: {code}")),
|
||||
},
|
||||
'mail_text_order_placed_require_approval': {
|
||||
'type': LazyI18nString,
|
||||
@@ -1776,6 +1851,10 @@ You can change your order details and view the status of your order at
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_order_placed': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your order: {code}")),
|
||||
},
|
||||
'mail_text_order_placed': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1819,6 +1898,10 @@ Your {event} team"""))
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
},
|
||||
'mail_subject_order_placed_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your event registration: {code}")),
|
||||
},
|
||||
'mail_text_order_placed_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name},
|
||||
@@ -1831,6 +1914,10 @@ You can view the details and status of your ticket here:
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_order_changed': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your order has been changed: {code}")),
|
||||
},
|
||||
'mail_text_order_changed': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1843,6 +1930,10 @@ You can view the status of your order at
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_order_paid': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Payment received for your order: {code}")),
|
||||
},
|
||||
'mail_text_order_paid': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1861,6 +1952,10 @@ Your {event} team"""))
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
},
|
||||
'mail_subject_order_paid_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Event registration confirmed: {code}")),
|
||||
},
|
||||
'mail_text_order_paid_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name},
|
||||
@@ -1888,6 +1983,14 @@ Your {event} team"""))
|
||||
'type': int,
|
||||
'default': '3'
|
||||
},
|
||||
'mail_subject_order_expire_warning': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your order is about to expire: {code}")),
|
||||
},
|
||||
'mail_subject_order_pending_warning': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your order is pending payment: {code}")),
|
||||
},
|
||||
'mail_text_order_expire_warning': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1902,6 +2005,10 @@ You can view the payment information and the status of your order at
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_waiting_list': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("You have been selected from the waitinglist for {event}")),
|
||||
},
|
||||
'mail_text_waiting_list': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1931,6 +2038,10 @@ as possible to the next person on the waiting list:
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_order_canceled': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Order canceled: {code}")),
|
||||
},
|
||||
'mail_text_order_canceled': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1945,6 +2056,10 @@ You can view the details of your order at
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_order_approved': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Order approved and awaiting payment: {code}")),
|
||||
},
|
||||
'mail_text_order_approved': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1961,6 +2076,10 @@ You can select a payment method and perform the payment here:
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_order_approved_free': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Order approved and confirmed: {code}")),
|
||||
},
|
||||
'mail_text_order_approved_free': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -1974,6 +2093,10 @@ You can change your order details and view the status of your order at
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_order_denied': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Order denied: {code}")),
|
||||
},
|
||||
'mail_text_order_denied': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -2007,6 +2130,10 @@ Your {event} team"""))
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
},
|
||||
'mail_subject_download_reminder_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your ticket is ready for download: {code}")),
|
||||
},
|
||||
'mail_text_download_reminder_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name},
|
||||
@@ -2019,6 +2146,10 @@ Your {event} team"""))
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_download_reminder': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your ticket is ready for download: {code}")),
|
||||
},
|
||||
'mail_text_download_reminder': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
@@ -2031,6 +2162,10 @@ If you did not do so already, you can download your ticket here:
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_subject_customer_registration': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Activate your account at {organizer}")),
|
||||
},
|
||||
'mail_text_customer_registration': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
|
||||
@@ -2049,6 +2184,10 @@ Best regards,
|
||||
|
||||
Your {organizer} team"""))
|
||||
},
|
||||
'mail_subject_customer_email_change': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Confirm email address for your account at {organizer}")),
|
||||
},
|
||||
'mail_text_customer_email_change': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
|
||||
@@ -2067,6 +2206,10 @@ Best regards,
|
||||
|
||||
Your {organizer} team"""))
|
||||
},
|
||||
'mail_subject_customer_reset': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Set a new password for your account at {organizer}")),
|
||||
},
|
||||
'mail_text_customer_reset': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
|
||||
@@ -2756,6 +2899,11 @@ PERSON_NAME_TITLE_GROUPS = OrderedDict([
|
||||
'Dr.',
|
||||
'Prof.',
|
||||
'Prof. Dr.',
|
||||
))),
|
||||
('dr_prof_he', ('Dr., Prof., H.E.', (
|
||||
'Dr.',
|
||||
'Prof.',
|
||||
'H.E.',
|
||||
)))
|
||||
])
|
||||
|
||||
@@ -3042,7 +3190,7 @@ settings_hierarkey.add_type(LazyI18nStringList,
|
||||
settings_hierarkey.add_type(RelativeDateWrapper,
|
||||
serialize=lambda rdw: rdw.to_string(),
|
||||
unserialize=lambda s: RelativeDateWrapper.from_string(s))
|
||||
settings_hierarkey.add_type(PhoneNumber, lambda pn: pn.as_international, lambda s: parse(s))
|
||||
settings_hierarkey.add_type(PhoneNumber, lambda pn: pn.as_international, lambda s: parse(s) if s else None)
|
||||
|
||||
|
||||
@settings_hierarkey.set_global(cache_namespace='global')
|
||||
|
||||
@@ -64,6 +64,10 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
# Send to all events!
|
||||
return True
|
||||
|
||||
# If sentry packed this in a wrapper, unpack that
|
||||
if "sentry" in receiver.__module__:
|
||||
receiver = receiver.__wrapped__
|
||||
|
||||
# Find the Django application this belongs to
|
||||
searchpath = receiver.__module__
|
||||
core_module = any([searchpath.startswith(cm) for cm in settings.CORE_MODULES])
|
||||
@@ -303,7 +307,7 @@ The ``sender`` keyword argument will contain an organizer.
|
||||
validate_order = EventPluginSignal(
|
||||
)
|
||||
"""
|
||||
Arguments: ``payment_provider``, ``positions``, ``email``, ``locale``, ``invoice_address``,
|
||||
Arguments: ``payments``, ``positions``, ``email``, ``locale``, ``invoice_address``,
|
||||
``meta_info``, ``customer``
|
||||
|
||||
This signal is sent out when the user tries to confirm the order, before we actually create
|
||||
@@ -312,6 +316,9 @@ but you can raise an OrderError with an appropriate exception message if you lik
|
||||
the order. We strongly discourage making changes to the order here.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
|
||||
**DEPRECTATION:** Stop listening to the ``payment_provider`` attribute, it will be removed
|
||||
in the future, as the ``payments`` attribute gives more information.
|
||||
"""
|
||||
|
||||
validate_cart = EventPluginSignal()
|
||||
@@ -560,7 +567,7 @@ an OrderedDict of (setting name, form field).
|
||||
|
||||
order_fee_calculation = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``positions``, ``invoice_address``, ``meta_info``, ``total``, ``gift_cards``
|
||||
Arguments: ``positions``, ``invoice_address``, ``meta_info``, ``total``, ``gift_cards``, ``payment_requests``
|
||||
|
||||
This signals allows you to add fees to an order while it is being created. You are expected to
|
||||
return a list of ``OrderFee`` objects that are not yet saved to the database
|
||||
@@ -570,8 +577,10 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
|
||||
argument will contain the cart positions and ``invoice_address`` the invoice address (useful for
|
||||
tax calculation). The argument ``meta_info`` contains the order's meta dictionary. The ``total``
|
||||
keyword argument will contain the total cart sum without any fees. You should not rely on this
|
||||
``total`` value for fee calculations as other fees might interfere. The ``gift_cards`` argument lists
|
||||
the gift cards in use.
|
||||
``total`` value for fee calculations as other fees might interfere. The ``gift_cards`` argument
|
||||
lists the gift cards in use.
|
||||
|
||||
**DEPRECTATION:** Stop listening to the ``gift_cards`` attribute, it will be removed in the future.
|
||||
"""
|
||||
|
||||
order_fee_type_name = EventPluginSignal()
|
||||
|
||||
@@ -44,16 +44,6 @@ class BaseQuestionsViewMixin:
|
||||
form_class = BaseQuestionsForm
|
||||
all_optional = False
|
||||
|
||||
@staticmethod
|
||||
def _keyfunc(pos):
|
||||
# Sort addons after the item they are an addon to
|
||||
if isinstance(pos, OrderPosition):
|
||||
i = pos.addon_to.positionid if pos.addon_to else pos.positionid
|
||||
else:
|
||||
i = pos.addon_to.pk if pos.addon_to else pos.pk
|
||||
addon_penalty = 1 if pos.addon_to else 0
|
||||
return i, addon_penalty, pos.pk
|
||||
|
||||
@cached_property
|
||||
def _positions_for_questions(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -215,6 +215,13 @@ class AsyncFormView(AsyncMixin, FormView):
|
||||
expected_exceptions = (ValidationError,)
|
||||
task_base = ProfiledEventTask
|
||||
|
||||
def async_set_progress(self, percentage):
|
||||
if not self._task_self.request.called_directly:
|
||||
self._task_self.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'value': percentage}
|
||||
)
|
||||
|
||||
def __init_subclass__(cls):
|
||||
def async_execute(self, *, request_path, query_string, form_kwargs, locale, tz, url_kwargs=None, url_args=None,
|
||||
organizer=None, event=None, user=None, session_key=None):
|
||||
@@ -240,6 +247,9 @@ class AsyncFormView(AsyncMixin, FormView):
|
||||
self.SessionStore = engine.SessionStore
|
||||
view_instance.request.session = self.SessionStore(session_key)
|
||||
|
||||
task_self = self
|
||||
view_instance._task_self = task_self
|
||||
|
||||
with translation.override(locale), timezone.override(pytz.timezone(tz)):
|
||||
form_class = view_instance.get_form_class()
|
||||
if form_kwargs.get('instance'):
|
||||
@@ -331,6 +341,13 @@ class AsyncPostView(AsyncMixin, View):
|
||||
expected_exceptions = (ValidationError,)
|
||||
task_base = ProfiledEventTask
|
||||
|
||||
def async_set_progress(self, percentage):
|
||||
if not self._task_self.request.called_directly:
|
||||
self._task_self.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'value': percentage}
|
||||
)
|
||||
|
||||
def __init_subclass__(cls):
|
||||
def async_execute(self, *, request_path, url_args, url_kwargs, query_string, post_data, locale, tz,
|
||||
organizer=None, event=None, user=None, session_key=None):
|
||||
@@ -355,6 +372,9 @@ class AsyncPostView(AsyncMixin, View):
|
||||
self.SessionStore = engine.SessionStore
|
||||
view_instance.request.session = self.SessionStore(session_key)
|
||||
|
||||
task_self = self
|
||||
view_instance._task_self = task_self
|
||||
|
||||
with translation.override(locale), timezone.override(pytz.timezone(tz)):
|
||||
return view_instance.async_post(view_instance.request, *url_args, **url_kwargs)
|
||||
|
||||
|
||||
@@ -589,7 +589,7 @@ class EventSettingsForm(SettingsForm):
|
||||
(k, '{scheme}: {samples}'.format(
|
||||
scheme=v[0],
|
||||
samples=', '.join(v[1])
|
||||
))
|
||||
) if v[0] != ', '.join(v[1]) else v[0])
|
||||
for k, v in PERSON_NAME_TITLE_GROUPS.items()
|
||||
]
|
||||
if not self.event.has_subevents:
|
||||
@@ -666,6 +666,9 @@ class CancelSettingsForm(SettingsForm):
|
||||
'cancel_allow_user_until',
|
||||
'cancel_allow_user_paid',
|
||||
'cancel_allow_user_paid_until',
|
||||
'cancel_allow_user_unpaid_keep',
|
||||
'cancel_allow_user_unpaid_keep_fees',
|
||||
'cancel_allow_user_unpaid_keep_percentage',
|
||||
'cancel_allow_user_paid_keep',
|
||||
'cancel_allow_user_paid_keep_fees',
|
||||
'cancel_allow_user_paid_keep_percentage',
|
||||
@@ -927,6 +930,11 @@ class MailSettingsForm(SettingsForm):
|
||||
required=True,
|
||||
choices=[]
|
||||
)
|
||||
mail_subject_order_placed = I18nFormField(
|
||||
label=_("Subject sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_placed = I18nFormField(
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
@@ -938,12 +946,22 @@ class MailSettingsForm(SettingsForm):
|
||||
'tickets, the following email will be sent out to the attendees.'),
|
||||
required=False,
|
||||
)
|
||||
mail_subject_order_placed_attendee = I18nFormField(
|
||||
label=_("Subject sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_placed_attendee = I18nFormField(
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
|
||||
mail_subject_order_paid = I18nFormField(
|
||||
label=_("Subject sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_paid = I18nFormField(
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
@@ -955,12 +973,22 @@ class MailSettingsForm(SettingsForm):
|
||||
'tickets, the following email will be sent out to the attendees.'),
|
||||
required=False,
|
||||
)
|
||||
mail_subject_order_paid_attendee = I18nFormField(
|
||||
label=_("Subject sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_paid_attendee = I18nFormField(
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
|
||||
mail_subject_order_free = I18nFormField(
|
||||
label=_("Subject sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_free = I18nFormField(
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
@@ -972,22 +1000,47 @@ class MailSettingsForm(SettingsForm):
|
||||
'tickets, the following email will be sent out to the attendees.'),
|
||||
required=False,
|
||||
)
|
||||
mail_subject_order_free_attendee = I18nFormField(
|
||||
label=_("Subject sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_free_attendee = I18nFormField(
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
|
||||
mail_subject_order_changed = I18nFormField(
|
||||
label=_("Subject"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_changed = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
mail_subject_resend_link = I18nFormField(
|
||||
label=_("Subject (sent by admin)"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_subject_resend_link_attendee = I18nFormField(
|
||||
label=_("Subject (sent by admin to attendee)"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_resend_link = I18nFormField(
|
||||
label=_("Text (sent by admin)"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
mail_subject_resend_all_links = I18nFormField(
|
||||
label=_("Subject (requested by user)"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_resend_all_links = I18nFormField(
|
||||
label=_("Text (requested by user)"),
|
||||
required=False,
|
||||
@@ -1005,11 +1058,31 @@ class MailSettingsForm(SettingsForm):
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
mail_subject_order_expire_warning = I18nFormField(
|
||||
label=_("Subject (if order will expire automatically)"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_subject_order_pending_warning = I18nFormField(
|
||||
label=_("Subject (if order will not expire automatically)"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_subject_waiting_list = I18nFormField(
|
||||
label=_("Subject"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_waiting_list = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
mail_subject_order_canceled = I18nFormField(
|
||||
label=_("Subject"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_canceled = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
@@ -1020,6 +1093,11 @@ class MailSettingsForm(SettingsForm):
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
mail_subject_download_reminder = I18nFormField(
|
||||
label=_("Subject sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_download_reminder = I18nFormField(
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
@@ -1031,6 +1109,11 @@ class MailSettingsForm(SettingsForm):
|
||||
'tickets, the following email will be sent out to the attendees.'),
|
||||
required=False,
|
||||
)
|
||||
mail_subject_download_reminder_attendee = I18nFormField(
|
||||
label=_("Subject sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_download_reminder_attendee = I18nFormField(
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
@@ -1043,50 +1126,90 @@ class MailSettingsForm(SettingsForm):
|
||||
help_text=_("This email will be sent out this many days before the order event starts. If the "
|
||||
"field is empty, the mail will never be sent.")
|
||||
)
|
||||
mail_subject_order_placed_require_approval = I18nFormField(
|
||||
label=_("Subject for received order"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_placed_require_approval = I18nFormField(
|
||||
label=_("Received order"),
|
||||
label=_("Text for received order"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
mail_subject_order_approved = I18nFormField(
|
||||
label=_("Subject for approved order"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_approved = I18nFormField(
|
||||
label=_("Approved order"),
|
||||
label=_("Text for approved order"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("This will only be sent out for non-free orders. Free orders will receive the free order "
|
||||
"template from below instead."),
|
||||
)
|
||||
mail_subject_order_approved_free = I18nFormField(
|
||||
label=_("Subject for approved free order"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_approved_free = I18nFormField(
|
||||
label=_("Approved free order"),
|
||||
label=_("Text for approved free order"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("This will only be sent out for free orders. Non-free orders will receive the non-free order "
|
||||
"template from above instead."),
|
||||
)
|
||||
mail_subject_order_denied = I18nFormField(
|
||||
label=_("Subject for denied order"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_denied = I18nFormField(
|
||||
label=_("Denied order"),
|
||||
label=_("Text for denied order"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
base_context = {
|
||||
'mail_text_order_placed': ['event', 'order', 'payment'],
|
||||
'mail_text_order_placed': ['event', 'order', 'payments'],
|
||||
'mail_subject_order_placed': ['event', 'order', 'payments'],
|
||||
'mail_text_order_placed_attendee': ['event', 'order', 'position'],
|
||||
'mail_subject_order_placed_attendee': ['event', 'order', 'position'],
|
||||
'mail_text_order_placed_require_approval': ['event', 'order'],
|
||||
'mail_subject_order_placed_require_approval': ['event', 'order'],
|
||||
'mail_text_order_approved': ['event', 'order'],
|
||||
'mail_subject_order_approved': ['event', 'order'],
|
||||
'mail_text_order_approved_free': ['event', 'order'],
|
||||
'mail_subject_order_approved_free': ['event', 'order'],
|
||||
'mail_text_order_denied': ['event', 'order', 'comment'],
|
||||
'mail_subject_order_denied': ['event', 'order', 'comment'],
|
||||
'mail_text_order_paid': ['event', 'order', 'payment_info'],
|
||||
'mail_subject_order_paid': ['event', 'order', 'payment_info'],
|
||||
'mail_text_order_paid_attendee': ['event', 'order', 'position'],
|
||||
'mail_subject_order_paid_attendee': ['event', 'order', 'position'],
|
||||
'mail_text_order_free': ['event', 'order'],
|
||||
'mail_subject_order_free': ['event', 'order'],
|
||||
'mail_text_order_free_attendee': ['event', 'order', 'position'],
|
||||
'mail_subject_order_free_attendee': ['event', 'order', 'position'],
|
||||
'mail_text_order_changed': ['event', 'order'],
|
||||
'mail_subject_order_changed': ['event', 'order'],
|
||||
'mail_text_order_canceled': ['event', 'order', 'comment'],
|
||||
'mail_subject_order_canceled': ['event', 'order', 'comment'],
|
||||
'mail_text_order_expire_warning': ['event', 'order'],
|
||||
'mail_subject_order_expire_warning': ['event', 'order'],
|
||||
'mail_subject_order_pending_warning': ['event', 'order'],
|
||||
'mail_text_order_custom_mail': ['event', 'order'],
|
||||
'mail_text_download_reminder': ['event', 'order'],
|
||||
'mail_subject_download_reminder': ['event', 'order'],
|
||||
'mail_text_download_reminder_attendee': ['event', 'order', 'position'],
|
||||
'mail_subject_download_reminder_attendee': ['event', 'order', 'position'],
|
||||
'mail_text_resend_link': ['event', 'order'],
|
||||
'mail_subject_resend_link': ['event', 'order'],
|
||||
'mail_subject_resend_link_attendee': ['event', 'order'],
|
||||
'mail_text_waiting_list': ['event', 'waiting_list_entry'],
|
||||
'mail_subject_waiting_list': ['event', 'waiting_list_entry'],
|
||||
'mail_text_resend_all_links': ['event', 'orders'],
|
||||
'mail_subject_resend_all_links': ['event', 'orders'],
|
||||
'mail_attach_ical_description': ['event', 'event_or_subevent'],
|
||||
}
|
||||
|
||||
|
||||
@@ -806,7 +806,8 @@ class OrderSearchFilterForm(OrderFilterForm):
|
||||
# We ignore superuser permissions here. This is intentional – we do not want to show super
|
||||
# users a form with all meta properties ever assigned.
|
||||
return EventMetaProperty.objects.filter(
|
||||
organizer_id__in=self.request.user.teams.values_list('organizer', flat=True)
|
||||
organizer_id__in=self.request.user.teams.values_list('organizer', flat=True),
|
||||
filter_allowed=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -1545,12 +1546,13 @@ class EventFilterForm(FilterForm):
|
||||
@cached_property
|
||||
def meta_properties(self):
|
||||
if self.organizer:
|
||||
return self.organizer.meta_properties.all()
|
||||
return self.organizer.meta_properties.filter(filter_allowed=True)
|
||||
else:
|
||||
# We ignore superuser permissions here. This is intentional – we do not want to show super
|
||||
# users a form with all meta properties ever assigned.
|
||||
return EventMetaProperty.objects.filter(
|
||||
organizer_id__in=self.request.user.teams.values_list('organizer', flat=True)
|
||||
organizer_id__in=self.request.user.teams.values_list('organizer', flat=True),
|
||||
filter_allowed=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ from decimal import Decimal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db.models import Max
|
||||
from django.forms.formsets import DELETION_FIELD_NAME
|
||||
from django.urls import reverse
|
||||
@@ -61,7 +63,8 @@ from pretix.base.models import (
|
||||
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
|
||||
from pretix.base.signals import item_copy_data
|
||||
from pretix.control.forms import (
|
||||
ItemMultipleChoiceField, SplitDateTimeField, SplitDateTimePickerWidget,
|
||||
ItemMultipleChoiceField, SizeValidationMixin, SplitDateTimeField,
|
||||
SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2
|
||||
from pretix.helpers.models import modelcopy
|
||||
@@ -584,6 +587,14 @@ class ItemUpdateForm(I18nModelForm):
|
||||
)
|
||||
return d
|
||||
|
||||
def clean_picture(self):
|
||||
value = self.cleaned_data.get('picture')
|
||||
if isinstance(value, UploadedFile) and value.size > settings.FILE_UPLOAD_MAX_SIZE_IMAGE:
|
||||
raise forms.ValidationError(_("Please do not upload files larger than {size}!").format(
|
||||
size=SizeValidationMixin._sizeof_fmt(settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
|
||||
))
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
localized_fields = '__all__'
|
||||
@@ -768,10 +779,6 @@ class ItemAddOnsFormSet(I18nFormSet):
|
||||
if self._should_delete_form(form):
|
||||
# This form is going to be deleted so any of its errors
|
||||
# should not cause the entire formset to be invalid.
|
||||
try:
|
||||
categories.remove(form.cleaned_data['addon_category'].pk)
|
||||
except KeyError:
|
||||
pass
|
||||
continue
|
||||
|
||||
if 'addon_category' in form.cleaned_data:
|
||||
|
||||
@@ -158,7 +158,7 @@ class CancelForm(ForceQuotaConfirmationForm):
|
||||
localize=True,
|
||||
label=_('Keep a cancellation fee of'),
|
||||
help_text=_('If you keep a fee, all positions within this order will be canceled and the order will be reduced '
|
||||
'to a paid cancellation fee. Payment and shipping fees will be canceled as well, so include them '
|
||||
'to a cancellation fee. Payment and shipping fees will be canceled as well, so include them '
|
||||
'in your cancellation fee if you want to keep them. Please always enter a gross value, '
|
||||
'tax will be calculated automatically.'),
|
||||
)
|
||||
@@ -176,23 +176,19 @@ class CancelForm(ForceQuotaConfirmationForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
prs = self.instance.payment_refund_sum
|
||||
if prs > 0:
|
||||
change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency)
|
||||
self.fields['cancellation_fee'].widget.attrs['placeholder'] = floatformat(
|
||||
Decimal('0.00'),
|
||||
settings.CURRENCY_PLACES.get(self.instance.event.currency, 2)
|
||||
)
|
||||
self.fields['cancellation_fee'].max_value = prs
|
||||
else:
|
||||
del self.fields['cancellation_fee']
|
||||
change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency)
|
||||
self.fields['cancellation_fee'].widget.attrs['placeholder'] = floatformat(
|
||||
Decimal('0.00'),
|
||||
settings.CURRENCY_PLACES.get(self.instance.event.currency, 2)
|
||||
)
|
||||
self.fields['cancellation_fee'].max_value = self.instance.total
|
||||
if not self.instance.invoices.exists():
|
||||
del self.fields['cancel_invoice']
|
||||
|
||||
def clean_cancellation_fee(self):
|
||||
val = self.cleaned_data['cancellation_fee'] or Decimal('0.00')
|
||||
if val > self.instance.payment_refund_sum:
|
||||
raise ValidationError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
|
||||
if val > self.instance.total:
|
||||
raise ValidationError(_('The cancellation fee cannot be higher than the total amount of this order.'))
|
||||
return val
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
from i18nfield.forms import I18nFormField, I18nFormSetMixin, I18nTextarea
|
||||
from i18nfield.forms import (
|
||||
I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
|
||||
)
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from pytz import common_timezones
|
||||
|
||||
@@ -180,7 +182,7 @@ class OrganizerUpdateForm(OrganizerForm):
|
||||
class EventMetaPropertyForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = EventMetaProperty
|
||||
fields = ['name', 'default', 'required', 'protected', 'allowed_values']
|
||||
fields = ['name', 'default', 'required', 'protected', 'allowed_values', 'filter_allowed']
|
||||
widgets = {
|
||||
'default': forms.TextInput()
|
||||
}
|
||||
@@ -457,16 +459,31 @@ class MailSettingsForm(SettingsForm):
|
||||
}}
|
||||
)
|
||||
|
||||
mail_subject_customer_registration = I18nFormField(
|
||||
label=_("Subject"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_customer_registration = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
mail_subject_customer_email_change = I18nFormField(
|
||||
label=_("Subject"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_customer_email_change = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
)
|
||||
mail_subject_customer_reset = I18nFormField(
|
||||
label=_("Subject"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_customer_reset = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
@@ -475,8 +492,11 @@ class MailSettingsForm(SettingsForm):
|
||||
|
||||
base_context = {
|
||||
'mail_text_customer_registration': ['customer', 'url'],
|
||||
'mail_subject_customer_registration': ['customer', 'url'],
|
||||
'mail_text_customer_email_change': ['customer', 'url'],
|
||||
'mail_subject_customer_email_change': ['customer', 'url'],
|
||||
'mail_text_customer_reset': ['customer', 'url'],
|
||||
'mail_subject_customer_reset': ['customer', 'url'],
|
||||
}
|
||||
|
||||
def _get_sample_context(self, base_parameters):
|
||||
|
||||
@@ -72,7 +72,7 @@ class VoucherForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
|
||||
'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
|
||||
'comment', 'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
|
||||
]
|
||||
field_classes = {
|
||||
'valid_until': SplitDateTimeField,
|
||||
@@ -308,7 +308,7 @@ class VoucherBulkForm(VoucherForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
|
||||
'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
|
||||
'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
|
||||
]
|
||||
field_classes = {
|
||||
'valid_until': SplitDateTimeField,
|
||||
@@ -345,8 +345,11 @@ class VoucherBulkForm(VoucherForm):
|
||||
if ',' in raw or ';' in raw:
|
||||
if '@' in r[0]:
|
||||
raise ValidationError(_('CSV input needs to contain a header row in the first line.'))
|
||||
dialect = csv.Sniffer().sniff(raw[:1024])
|
||||
reader = csv.DictReader(StringIO(raw), dialect=dialect)
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(raw[:1024])
|
||||
reader = csv.DictReader(StringIO(raw), dialect=dialect)
|
||||
except csv.Error as e:
|
||||
raise ValidationError(_('CSV parsing failed: {error}.').format(error=str(e)))
|
||||
if 'email' not in reader.fieldnames:
|
||||
raise ValidationError(_('CSV input needs to contain a field with the header "{header}".').format(header="email"))
|
||||
unknown_fields = [f for f in reader.fieldnames if f not in ('email', 'name', 'tag', 'number')]
|
||||
|
||||
56
src/pretix/control/forms/waitinglist.py
Normal file
56
src/pretix/control/forms/waitinglist.py
Normal file
@@ -0,0 +1,56 @@
|
||||
#
|
||||
# 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.urls import reverse
|
||||
from django_scopes.forms import SafeModelChoiceField
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.models import WaitingListEntry
|
||||
from pretix.control.forms.widgets import Select2
|
||||
|
||||
|
||||
class WaitingListEntryTransferForm(I18nModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].required = True
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
|
||||
class Meta:
|
||||
model = WaitingListEntry
|
||||
fields = [
|
||||
'subevent',
|
||||
]
|
||||
field_classes = {
|
||||
'subevent': SafeModelChoiceField,
|
||||
}
|
||||
@@ -487,6 +487,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
|
||||
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.'),
|
||||
'pretix.event.orders.waitinglist.deleted': _('An entry has been removed from the waiting list.'),
|
||||
'pretix.event.orders.waitinglist.transferred': _('An entry has been transferred to another waiting list.'),
|
||||
'pretix.event.orders.waitinglist.changed': _('An entry has been changed on the waiting list.'),
|
||||
'pretix.event.orders.waitinglist.added': _('An entry has been added to the waiting list.'),
|
||||
'pretix.team.created': _('The team has been created.'),
|
||||
|
||||
@@ -112,7 +112,7 @@ class PermissionMiddleware:
|
||||
url = resolve(request.path_info)
|
||||
url_name = url.url_name
|
||||
|
||||
if not request.path.startswith(get_script_prefix() + 'control'):
|
||||
if not request.path.startswith(get_script_prefix() + 'control') and not (url.namespace.startswith("api-") and url_name == "authorize"):
|
||||
# This middleware should only touch the /control subpath
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{% trans "You configured your account to require authentication with a second medium, e.g. your phone. Please enter your verification code here:" %}
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<input class="form-control" name="token" placeholder="{% trans "Token" %}"
|
||||
<input class="form-control" name="token" placeholder="{% trans "Token" %}" autocomplete="one-time-code"
|
||||
type="text" required="required" autofocus="autofocus" id="webauthn-response">
|
||||
</div>
|
||||
<div class="sr-only alert alert-danger" id="webauthn-error">
|
||||
|
||||
@@ -72,10 +72,10 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" action="{% url "control:event.orders.checkinlists.bulk_action" event=request.event.slug organizer=request.event.organizer.slug list=checkinlist.pk %}" data-asynctask>
|
||||
<div class="hidden">
|
||||
{{ filter_form.as_p }}
|
||||
<input name="returnquery" type="hidden" value="{{ request.META.QUERY_STRING }}">
|
||||
</div>
|
||||
{% for field in filter_form %}
|
||||
{{ field.as_hidden }}
|
||||
{% endfor %}
|
||||
<input name="returnquery" type="hidden" value="{{ request.META.QUERY_STRING }}">
|
||||
{% csrf_token %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover">
|
||||
|
||||
@@ -116,8 +116,12 @@
|
||||
</form>
|
||||
{{ items|json_script:"items" }}
|
||||
|
||||
{% compress js %}
|
||||
{% if DEBUG %}
|
||||
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
|
||||
{% else %}
|
||||
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
|
||||
{% endif %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "d3/d3.v6.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "d3/d3-color.v2.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "d3/d3-dispatch.v2.js" %}"></script>
|
||||
@@ -128,6 +132,8 @@
|
||||
<script type="text/javascript" src="{% static "d3/d3-transition.v2.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/datetimefield.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/timefield.vue' %}"></script>
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<legend>{% trans "Unpaid or free orders" %}</legend>
|
||||
{% bootstrap_field form.cancel_allow_user layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_until layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_unpaid_keep layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_unpaid_keep_percentage layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_unpaid_keep_fees layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Paid orders" %}</legend>
|
||||
|
||||
@@ -88,37 +88,37 @@
|
||||
<h4>{% trans "Text" %}</h4>
|
||||
<div class="panel-group" id="questions_group">
|
||||
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed,mail_send_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_subject_order_placed,mail_text_order_placed,mail_send_order_placed_attendee,mail_subject_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_paid_order %}Paid order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_text_order_paid,mail_send_order_paid_attendee,mail_text_order_paid_attendee" exclude="mail_send_order_paid_attendee" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_subject_order_paid,mail_text_order_paid,mail_send_order_paid_attendee,mail_subject_order_paid_attendee,mail_text_order_paid_attendee" exclude="mail_send_order_paid_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_free_order %}Free order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_text_order_free,mail_send_order_free_attendee,mail_text_order_free_attendee" exclude="mail_send_order_free_attendee" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_subject_order_free,mail_text_order_free,mail_send_order_free_attendee,mail_subject_order_free_attendee,mail_text_order_free_attendee" exclude="mail_send_order_free_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_resend_link %}Resend link{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="resend_link" title=title_resend_link items="mail_text_resend_link,mail_text_resend_all_links" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="resend_link" title=title_resend_link items="mail_subject_resend_link,mail_subject_resend_link_attendee,mail_text_resend_link,mail_subject_resend_all_links,mail_text_resend_all_links" %}
|
||||
|
||||
{% blocktrans asvar title_order_changed %}Order changed{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_text_order_changed" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_subject_order_changed,mail_text_order_changed" %}
|
||||
|
||||
{% blocktrans asvar title_payment_reminder %}Payment reminder{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_subject_order_expire_warning,mail_subject_order_pending_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
|
||||
|
||||
{% blocktrans asvar title_waiting_list_notification %}Waiting list notification{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_text_waiting_list" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_subject_waiting_list,mail_text_waiting_list" %}
|
||||
|
||||
{% blocktrans asvar title_order_canceled %}Order canceled{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_canceled" title=title_order_canceled items="mail_text_order_canceled" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_canceled" title=title_order_canceled items="mail_subject_order_canceled,mail_text_order_canceled" %}
|
||||
|
||||
{% blocktrans asvar title_order_custom_mail %}Order custom mail{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="custom_mail" title=title_order_custom_mail items="mail_text_order_custom_mail" %}
|
||||
|
||||
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_text_download_reminder_attendee,mail_sales_channel_download_reminder" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee,mail_sales_channel_download_reminder" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_subject_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_subject_download_reminder_attendee,mail_text_download_reminder_attendee,mail_sales_channel_download_reminder" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee,mail_sales_channel_download_reminder" %}
|
||||
|
||||
{% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_approved_free,mail_text_order_denied" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_subject_order_placed_require_approval,mail_text_order_placed_require_approval,mail_subject_order_approved,mail_text_order_approved,mail_subject_order_approved_free,mail_text_order_approved_free,mail_subject_order_denied,mail_text_order_denied" %}
|
||||
</div>
|
||||
<h4>{% trans "Attachments" %}</h4>
|
||||
{% bootstrap_field form.mail_attachment_new_order layout="control" %}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% load rich_text %}
|
||||
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
If you have a gift card, please enter the gift card code here. If the gift card does not have
|
||||
enough credit to pay for the full order, you will be shown this page again and you can either
|
||||
redeem another gift card or select a different payment method for the difference.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{{ request.event.settings.payment_giftcard_public_description|rich_text }}
|
||||
<input name="giftcard" class="form-control" placeholder="{% trans "Gift card code" %}">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{% load i18n %}
|
||||
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
Your gift card will be used to pay for this order. If the credit on the gift card is lower than the order total, you will be able to pay the
|
||||
difference with a different payment method. If the credit is higher than the order total, you will be able to re-use the gift card in the future.
|
||||
{% blocktrans trimmed with card=info_data.gift_card_secret %}
|
||||
Your gift card {{ card }} will be used to pay for this order.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
@@ -190,7 +190,13 @@
|
||||
</dd>
|
||||
{% if order.status == "n" %}
|
||||
<dt>{% trans "Expiry date" %}</dt>
|
||||
<dd>{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||
<dd>
|
||||
{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% if has_cancellation_fee and request.event.settings.payment_term_expire_automatically %}
|
||||
<span class="fa fa-warning text-danger" data-toggle="tooltip"
|
||||
title="{% trans "This order will not expire automatically as it has an open cancellation fee." %}"></span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if request.organizer.settings.customer_accounts %}
|
||||
<dt>{% trans "Customer account" %}</dt>
|
||||
|
||||
@@ -57,9 +57,9 @@
|
||||
</p>
|
||||
<form action="{% url "control:organizer.device.bulk_edit" organizer=request.organizer.slug %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="hidden">
|
||||
{{ filter_form.as_p }}
|
||||
</div>
|
||||
{% for field in filter_form %}
|
||||
{{ field.as_hidden }}
|
||||
{% endfor %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover table-quotas">
|
||||
<thead>
|
||||
|
||||
@@ -58,13 +58,13 @@
|
||||
<legend>{% trans "E-mail content" %}</legend>
|
||||
<div class="panel-group" id="questions_group">
|
||||
{% blocktrans asvar title_customer_registration %}Customer account registration{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="customer_registration" title=title_customer_registration items="mail_text_customer_registration" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="customer_registration" title=title_customer_registration items="mail_subject_customer_registration,mail_text_customer_registration" %}
|
||||
|
||||
{% blocktrans asvar title_email_change %}Customer account email change{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="email_change" title=title_email_change items="mail_text_customer_email_change" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="email_change" title=title_email_change items="mail_subject_customer_email_change,mail_text_customer_email_change" %}
|
||||
|
||||
{% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_text_customer_reset" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_subject_customer_reset,mail_text_customer_reset" %}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -72,9 +72,9 @@
|
||||
{% endif %}
|
||||
<form action="{% url "control:event.subevents.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="hidden">
|
||||
{{ filter_form.as_p }}
|
||||
</div>
|
||||
{% for field in filter_form %}
|
||||
{{ field.as_hidden }}
|
||||
{% endfor %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
<legend>{% trans "Advanced settings" %}</legend>
|
||||
{% bootstrap_field form.block_quota layout="control" %}
|
||||
{% bootstrap_field form.allow_ignore_quota layout="control" %}
|
||||
{% bootstrap_field form.min_usages layout="control" %}
|
||||
{% bootstrap_field form.budget addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field form.tag layout="control" %}
|
||||
{% bootstrap_field form.comment layout="control" %}
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
<legend>{% trans "Advanced settings" %}</legend>
|
||||
{% bootstrap_field form.block_quota layout="control" %}
|
||||
{% bootstrap_field form.allow_ignore_quota layout="control" %}
|
||||
{% bootstrap_field form.min_usages layout="control" %}
|
||||
{% bootstrap_field form.budget addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field form.tag layout="control" %}
|
||||
{% bootstrap_field form.comment layout="control" %}
|
||||
|
||||
@@ -242,6 +242,13 @@
|
||||
data-toggle="tooltip" title="{% trans "Move to the end of the list" %}">
|
||||
<span class="fa fa-thumbs-down"></span>
|
||||
</button>
|
||||
{% if request.event.has_subevents %}
|
||||
<a href="{% url "control:event.orders.waitinglist.transfer" organizer=request.event.organizer.slug event=request.event.slug entry=e.id %}"
|
||||
class="btn btn-default btn-sm" title="{% trans "Transfer to other date" context "subevent" %}"
|
||||
data-toggle="tooltip">
|
||||
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.orders.waitinglist.delete" organizer=request.event.organizer.slug event=request.event.slug entry=e.id %}?next={{ request.get_full_path|urlencode }}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
{% else %}
|
||||
<button class="btn btn-default btn-sm disabled">
|
||||
@@ -261,9 +268,11 @@
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<div class="batch-select-actions">
|
||||
<button type="submit" class="btn btn-danger btn-save" name="action" value="delete">
|
||||
<i class="fa fa-trash"></i>{% trans "Delete selected" %}
|
||||
<i class="fa fa-trash"></i>
|
||||
{% trans "Delete selected" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</form>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Transfer entry" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Transfer entry" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans trimmed context "subevent" %}
|
||||
Please select the date to which the following waiting list entry should be
|
||||
transferred: <strong>{{ entry }}</strong>?
|
||||
{% endblocktrans %}</p>
|
||||
{% bootstrap_field form.subevent layout="control" %}
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:event.orders.waitinglist" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Transfer" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -401,6 +401,8 @@ urlpatterns = [
|
||||
re_path(r'^waitinglist/auto_assign$', waitinglist.AutoAssign.as_view(), name='event.orders.waitinglist.auto'),
|
||||
re_path(r'^waitinglist/(?P<entry>\d+)/delete$', waitinglist.EntryDelete.as_view(),
|
||||
name='event.orders.waitinglist.delete'),
|
||||
re_path(r'^waitinglist/(?P<entry>\d+)/transfer$', waitinglist.EntryTransfer.as_view(),
|
||||
name='event.orders.waitinglist.transfer'),
|
||||
re_path(r'^checkins/$', checkin.CheckinListView.as_view(), name='event.orders.checkins'),
|
||||
re_path(r'^checkinlists/$', checkin.CheckinListList.as_view(), name='event.orders.checkinlists'),
|
||||
re_path(r'^checkinlists/add$', checkin.CheckinListCreate.as_view(), name='event.orders.checkinlists.add'),
|
||||
|
||||
@@ -61,6 +61,7 @@ from pretix.base.forms.auth import (
|
||||
)
|
||||
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.helpers.http import redirect_to_url
|
||||
from pretix.helpers.webauthn import generate_challenge
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -81,7 +82,7 @@ def process_login(request, user, keep_logged_in):
|
||||
twofa_url = reverse('control:auth.login.2fa')
|
||||
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts=None):
|
||||
twofa_url += '?next=' + quote(next_url)
|
||||
return redirect(twofa_url)
|
||||
return redirect_to_url(twofa_url)
|
||||
else:
|
||||
auth_login(request, user)
|
||||
request.session['pretix_auth_login_time'] = int(time.time())
|
||||
@@ -110,7 +111,7 @@ def login(request):
|
||||
if request.user.is_authenticated:
|
||||
next_url = backend.get_next_url(request) or 'control:index'
|
||||
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts=None):
|
||||
return redirect(next_url)
|
||||
return redirect_to_url(next_url)
|
||||
return redirect(reverse('control:index'))
|
||||
if request.method == 'POST':
|
||||
form = LoginForm(backend=backend, data=request.POST, request=request)
|
||||
@@ -136,8 +137,8 @@ def logout(request):
|
||||
if 'next' in request.GET and url_has_allowed_host_and_scheme(request.GET.get('next'), allowed_hosts=None):
|
||||
next += '?next=' + quote(request.GET.get('next'))
|
||||
if 'back' in request.GET and url_has_allowed_host_and_scheme(request.GET.get('back'), allowed_hosts=None):
|
||||
return redirect(request.GET.get('back'))
|
||||
return redirect(next)
|
||||
return redirect_to_url(request.GET.get('back'))
|
||||
return redirect_to_url(next)
|
||||
|
||||
|
||||
def register(request):
|
||||
@@ -443,7 +444,7 @@ class Login2FAView(TemplateView):
|
||||
del request.session['pretix_auth_2fa_user']
|
||||
del request.session['pretix_auth_2fa_time']
|
||||
if "next" in request.GET and url_has_allowed_host_and_scheme(request.GET.get("next"), allowed_hosts=None):
|
||||
return redirect(request.GET.get("next"))
|
||||
return redirect_to_url(request.GET.get("next"))
|
||||
return redirect(reverse('control:index'))
|
||||
else:
|
||||
messages.error(request, _('Invalid code, please try again.'))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user