forked from CGM_Public/pretix_original
Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9aaa343e6 | ||
|
|
87a196c4df | ||
|
|
a220f1678b | ||
|
|
c8fa0852b2 | ||
|
|
fe3433106c | ||
|
|
f8086daf34 | ||
|
|
66f75a5614 | ||
|
|
6f30c347c0 | ||
|
|
3596fa9c5a | ||
|
|
e3c7cd7c6d | ||
|
|
194042dca5 | ||
|
|
3be6e83f33 | ||
|
|
4262bce2b5 | ||
|
|
73ab962e16 | ||
|
|
13a86fc6f3 | ||
|
|
9d6f11718a | ||
|
|
c9d3428996 | ||
|
|
d4ef16b31a | ||
|
|
6a35e7d3cd | ||
|
|
463443d606 | ||
|
|
6f0da5c2ca | ||
|
|
c1344422a5 | ||
|
|
c2bd3dde44 | ||
|
|
9e51736232 | ||
|
|
5b27ce1265 | ||
|
|
0757542f4f | ||
|
|
12be98c888 | ||
|
|
51e6b02aa9 | ||
|
|
acc4a167b1 | ||
|
|
dd9429bbfa | ||
|
|
768bb8c106 | ||
|
|
cbdafac999 | ||
|
|
96f694cf61 | ||
|
|
5576829ebf | ||
|
|
b0d67e92ac | ||
|
|
63e28723d2 | ||
|
|
cc0656f169 | ||
|
|
849c8e719a | ||
|
|
a3ec2a4061 | ||
|
|
00a7187a7a | ||
|
|
701c4f768e | ||
|
|
cf751d38d2 | ||
|
|
888402a4bf | ||
|
|
1134f610fd | ||
|
|
8ae4304c7d | ||
|
|
357092ec44 | ||
|
|
70a5c76d79 | ||
|
|
7a4db8ea23 | ||
|
|
223b160c0c | ||
|
|
30c1771d29 | ||
|
|
b3b7b9bbab | ||
|
|
be040cd6ea | ||
|
|
c6665ec2e6 | ||
|
|
fd16ef1e4d | ||
|
|
39557fc452 | ||
|
|
408397a639 | ||
|
|
d4a2500204 | ||
|
|
e74d9e56cf | ||
|
|
f3767ab4ac | ||
|
|
5d13f5f885 | ||
|
|
451d3fce05 | ||
|
|
ccb61e0f56 | ||
|
|
b6273adc57 | ||
|
|
0bf7bba6ba | ||
|
|
7090e0bae2 | ||
|
|
c75cb0b8e3 | ||
|
|
3dbf22f670 | ||
|
|
f26cbdc257 | ||
|
|
6b4adccee5 | ||
|
|
c2a8286022 | ||
|
|
4145887a9b | ||
|
|
9f4b834abc | ||
|
|
8fcc314f09 | ||
|
|
94a7d02ab1 | ||
|
|
ad2943263c | ||
|
|
5210ac3a78 | ||
|
|
0e9600a7bf | ||
|
|
eccba09452 | ||
|
|
c8a830ecde | ||
|
|
aed64d16f6 | ||
|
|
d16f6167f6 | ||
|
|
77d59248e5 | ||
|
|
a0e05f8af6 | ||
|
|
9b8a47c8b8 | ||
|
|
b3d692276c | ||
|
|
55543e12f6 | ||
|
|
1e16185c02 | ||
|
|
cd900e24bd | ||
|
|
0dbedc07ce | ||
|
|
f71877b7fc | ||
|
|
f69e270e4d | ||
|
|
533939cae4 | ||
|
|
91ec5fd78c | ||
|
|
0056fb447b | ||
|
|
20c4d12e98 | ||
|
|
e13c567e84 | ||
|
|
9fef97a7c6 | ||
|
|
e68a995376 | ||
|
|
6abdb40ef5 | ||
|
|
43cc06b0a1 | ||
|
|
d17476cd75 | ||
|
|
5c3bfd2a71 | ||
|
|
033b8d70e7 | ||
|
|
bd22c2afc9 | ||
|
|
b355733f53 | ||
|
|
e1f924c4ce | ||
|
|
8038f4e173 | ||
|
|
5c55219d45 | ||
|
|
bfd37af467 | ||
|
|
b2509e120c | ||
|
|
e2339acd09 | ||
|
|
c15b4fa03c | ||
|
|
c4aa2e0484 | ||
|
|
361eeb7159 | ||
|
|
0109e1806f | ||
|
|
30aadac099 | ||
|
|
0458f1b2dc | ||
|
|
e006ca3feb | ||
|
|
1f31ee2ea1 | ||
|
|
2d37b0df77 | ||
|
|
4133e5ac4d | ||
|
|
0fd3d0fe71 | ||
|
|
d0685e99ad | ||
|
|
c6fd5bc864 | ||
|
|
9fa935099f | ||
|
|
83b5a325e3 | ||
|
|
97e12c5003 | ||
|
|
e6db8340f2 | ||
|
|
3cf9caa5d3 | ||
|
|
2ffd68ace7 | ||
|
|
0231be63b4 | ||
|
|
fae8bc254e | ||
|
|
1d5c700fa2 | ||
|
|
e61775d5c1 | ||
|
|
e767c6a68d | ||
|
|
832235411f | ||
|
|
1f0f7b752f | ||
|
|
3117eceb72 | ||
|
|
c1b39782fd | ||
|
|
860cfc3227 | ||
|
|
45859a07dd | ||
|
|
04fb8efc0d | ||
|
|
fdb8a3720b | ||
|
|
5638d68894 | ||
|
|
f64042280a | ||
|
|
50060cdc8d | ||
|
|
4499f58e3d | ||
|
|
918e4a5a89 | ||
|
|
15a86fd796 | ||
|
|
4126d20f1c | ||
|
|
ea3edf83f8 | ||
|
|
9a42819b56 | ||
|
|
3e4ba28700 | ||
|
|
9014ffcc28 | ||
|
|
48f4bcf88c | ||
|
|
b7dfb3697e | ||
|
|
475a5be351 | ||
|
|
8254d8f5cc |
14
.github/workflows/tests.yml
vendored
14
.github/workflows/tests.yml
vendored
@@ -18,17 +18,17 @@ jobs:
|
|||||||
name: Tests
|
name: Tests
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.6, 3.7, 3.8]
|
python-version: ["3.7", "3.8", "3.9"]
|
||||||
database: [sqlite, postgres, mysql]
|
database: [sqlite, postgres, mysql]
|
||||||
exclude:
|
exclude:
|
||||||
- database: mysql
|
- database: mysql
|
||||||
python-version: 3.7
|
python-version: "3.8"
|
||||||
- database: sqlite
|
|
||||||
python-version: 3.7
|
|
||||||
- database: mysql
|
- database: mysql
|
||||||
python-version: 3.6
|
python-version: "3.9"
|
||||||
- database: sqlite
|
- database: sqlite
|
||||||
python-version: 3.6
|
python-version: "3.7"
|
||||||
|
- database: sqlite
|
||||||
|
python-version: "3.8"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: getong/mariadb-action@v1.1
|
- uses: getong/mariadb-action@v1.1
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pip-
|
${{ runner.os }}-pip-
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: sudo apt update && sudo apt install gettext mysql-client
|
run: sudo apt update && sudo apt install gettext mariadb-client
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
|
|||||||
@@ -220,12 +220,30 @@ Example::
|
|||||||
``user``, ``password``
|
``user``, ``password``
|
||||||
The SMTP user data to use for the connection. Empty by default.
|
The SMTP user data to use for the connection. Empty by default.
|
||||||
|
|
||||||
|
``tls``, ``ssl``
|
||||||
|
Use STARTTLS or SSL for the SMTP connection. Off by default.
|
||||||
|
|
||||||
``from``
|
``from``
|
||||||
The email address to set as ``From`` header in outgoing emails by the system.
|
The email address to set as ``From`` header in outgoing emails by the system.
|
||||||
Default: ``pretix@localhost``
|
Default: ``pretix@localhost``
|
||||||
|
|
||||||
``tls``, ``ssl``
|
``from_notifications``
|
||||||
Use STARTTLS or SSL for the SMTP connection. Off by default.
|
The email address to set as ``From`` header in admin notification emails by the system.
|
||||||
|
Defaults to the value of ``from``.
|
||||||
|
|
||||||
|
``from_organizers``
|
||||||
|
The email address to set as ``From`` header in outgoing emails by the system sent on behalf of organizers.
|
||||||
|
Defaults to the value of ``from``.
|
||||||
|
|
||||||
|
``custom_sender_verification_required``
|
||||||
|
If this is on (the default), organizers need to verify email addresses they want to use as senders in their event.
|
||||||
|
|
||||||
|
``custom_sender_spf_string``
|
||||||
|
If this is set to a valid SPF string, pretix will show a warning if organizers use a sender address from a domain
|
||||||
|
that does not include this value.
|
||||||
|
|
||||||
|
``custom_smtp_allow_private_networks``
|
||||||
|
If this is off (the default), custom SMTP servers cannot be private network addresses.
|
||||||
|
|
||||||
``admins``
|
``admins``
|
||||||
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
||||||
@@ -282,7 +300,7 @@ You can use an existing memcached server as pretix's caching backend::
|
|||||||
``location``
|
``location``
|
||||||
The location of memcached, either a host:port combination or a socket file.
|
The location of memcached, either a host:port combination or a socket file.
|
||||||
|
|
||||||
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
|
If no memcached is configured, pretix will use redis for caching. If neither is configured, pretix will not use any caching.
|
||||||
|
|
||||||
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
|
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
|
||||||
shared memcached instance, not multiple ones, because cache invalidations would not be
|
shared memcached instance, not multiple ones, because cache invalidations would not be
|
||||||
@@ -445,8 +463,10 @@ You can configure the maximum file size for uploading various files::
|
|||||||
max_size_image = 12
|
max_size_image = 12
|
||||||
; Max upload size for favicons in MiB, defaults to 1 MiB
|
; Max upload size for favicons in MiB, defaults to 1 MiB
|
||||||
max_size_favicon = 2
|
max_size_favicon = 2
|
||||||
; Max upload size for email attachments in MiB, defaults to 10 MiB
|
; Max upload size for email attachments of manually sent emails in MiB, defaults to 10 MiB
|
||||||
max_size_email_attachment = 15
|
max_size_email_attachment = 15
|
||||||
|
; Max upload size for email attachments of automatically sent emails in MiB, defaults to 1 MiB
|
||||||
|
max_size_email_auto_attachment = 2
|
||||||
; Max upload size for other files in MiB, defaults to 10 MiB
|
; Max upload size for other files in MiB, defaults to 10 MiB
|
||||||
; This includes all file upload type order questions
|
; This includes all file upload type order questions
|
||||||
max_size_other = 100
|
max_size_other = 100
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ If you're running MySQL, also install the client library::
|
|||||||
|
|
||||||
(venv)$ pip3 install mysqlclient
|
(venv)$ pip3 install mysqlclient
|
||||||
|
|
||||||
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
|
Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``.
|
||||||
|
|
||||||
We also need to create a data directory::
|
We also need to create a data directory::
|
||||||
|
|
||||||
@@ -259,14 +259,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /static/ {
|
location /static/ {
|
||||||
alias /var/pretix/venv/lib/python3.7/site-packages/pretix/static.dist/;
|
alias /var/pretix/venv/lib/python3.10/site-packages/pretix/static.dist/;
|
||||||
access_log off;
|
access_log off;
|
||||||
expires 365d;
|
expires 365d;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.. note:: Remember to replace the ``python3.7`` in the ``/static/`` path in the config
|
.. note:: Remember to replace the ``python3.10`` in the ``/static/`` path in the config
|
||||||
above with your python version.
|
above with your python version.
|
||||||
|
|
||||||
We recommend reading about setting `strong encryption settings`_ for your web server.
|
We recommend reading about setting `strong encryption settings`_ for your web server.
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ For example, if you want users to be redirected to ``https://example.org/order/r
|
|||||||
either enter ``https://example.org`` or ``https://example.org/order/``.
|
either enter ``https://example.org`` or ``https://example.org/order/``.
|
||||||
|
|
||||||
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
||||||
**regardless of whether it was successful or not**. Make sure you use our API to check if the payment actually
|
**regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error
|
||||||
|
message, but you should not rely on that and instead make sure you use our API to check if the payment actually
|
||||||
worked! Your final URL could look like this::
|
worked! Your final URL could look like this::
|
||||||
|
|
||||||
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234
|
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ lines list of objects The actual invo
|
|||||||
created before this field was introduced as well as for
|
created before this field was introduced as well as for
|
||||||
all lines not created by a product (e.g. a shipping or
|
all lines not created by a product (e.g. a shipping or
|
||||||
cancellation fee).
|
cancellation fee).
|
||||||
|
├ subevent integer Event series date ID used to create this line. Note that everything
|
||||||
|
about the subevent might have changed since the creation
|
||||||
|
of the invoice. Can be ``null`` for all invoice lines
|
||||||
|
created before this field was introduced as well as for
|
||||||
|
all lines not created by a product (e.g. a shipping or
|
||||||
|
cancellation fee) as well as for all events that are not a series.
|
||||||
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
|
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
|
||||||
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
|
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
|
||||||
all invoice lines
|
all invoice lines
|
||||||
@@ -120,6 +126,10 @@ internal_reference string Customer's refe
|
|||||||
|
|
||||||
The attribute ``lines.event_location`` has been added.
|
The attribute ``lines.event_location`` has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
|
||||||
|
The attribute ``lines.subevent`` has been added.
|
||||||
|
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
@@ -185,6 +195,7 @@ Endpoints
|
|||||||
"description": "Budget Ticket",
|
"description": "Budget Ticket",
|
||||||
"item": 1234,
|
"item": 1234,
|
||||||
"variation": 245,
|
"variation": 245,
|
||||||
|
"subevent": null,
|
||||||
"fee_type": null,
|
"fee_type": null,
|
||||||
"fee_internal_type": null,
|
"fee_internal_type": null,
|
||||||
"event_date_from": "2017-12-27T10:00:00Z",
|
"event_date_from": "2017-12-27T10:00:00Z",
|
||||||
@@ -274,6 +285,7 @@ Endpoints
|
|||||||
"description": "Budget Ticket",
|
"description": "Budget Ticket",
|
||||||
"item": 1234,
|
"item": 1234,
|
||||||
"variation": 245,
|
"variation": 245,
|
||||||
|
"subevent": null,
|
||||||
"fee_type": null,
|
"fee_type": null,
|
||||||
"fee_internal_type": null,
|
"fee_internal_type": null,
|
||||||
"event_date_from": "2017-12-27T10:00:00Z",
|
"event_date_from": "2017-12-27T10:00:00Z",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ active boolean If ``false``, t
|
|||||||
description multi-lingual string A public description of the variation. May contain
|
description multi-lingual string A public description of the variation. May contain
|
||||||
Markdown syntax or can be ``null``.
|
Markdown syntax or can be ``null``.
|
||||||
position integer An integer, used for sorting
|
position integer An integer, used for sorting
|
||||||
|
require_approval boolean If ``true``, orders with this variation will need to be
|
||||||
|
approved by the event organizer before they can be
|
||||||
|
paid.
|
||||||
require_membership boolean If ``true``, booking this variation requires an active membership.
|
require_membership boolean If ``true``, booking this variation requires an active membership.
|
||||||
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
|
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
|
||||||
be hidden from users without a valid membership.
|
be hidden from users without a valid membership.
|
||||||
@@ -76,6 +79,7 @@ Endpoints
|
|||||||
"en": "S"
|
"en": "S"
|
||||||
},
|
},
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
@@ -97,6 +101,7 @@ Endpoints
|
|||||||
"en": "L"
|
"en": "L"
|
||||||
},
|
},
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
@@ -147,6 +152,7 @@ Endpoints
|
|||||||
"price": "10.00",
|
"price": "10.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
@@ -183,6 +189,7 @@ Endpoints
|
|||||||
"value": {"en": "Student"},
|
"value": {"en": "Student"},
|
||||||
"default_price": "10.00",
|
"default_price": "10.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
@@ -209,6 +216,7 @@ Endpoints
|
|||||||
"price": "10.00",
|
"price": "10.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
@@ -266,6 +274,7 @@ Endpoints
|
|||||||
"price": "10.00",
|
"price": "10.00",
|
||||||
"original_price": null,
|
"original_price": null,
|
||||||
"active": false,
|
"active": false,
|
||||||
|
"require_approval": false,
|
||||||
"require_membership": false,
|
"require_membership": false,
|
||||||
"require_membership_hidden": false,
|
"require_membership_hidden": false,
|
||||||
"require_membership_types": [],
|
"require_membership_types": [],
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ last_modified datetime Last modificati
|
|||||||
|
|
||||||
The ``item`` and ``variation`` query parameters have been added.
|
The ``item`` and ``variation`` query parameters have been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
|
||||||
|
The ``subevent`` query parameters has been added.
|
||||||
|
|
||||||
|
|
||||||
.. _order-position-resource:
|
.. _order-position-resource:
|
||||||
|
|
||||||
@@ -433,6 +437,7 @@ List of all orders
|
|||||||
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
||||||
you will not notice it using this method.
|
you will not notice it using this method.
|
||||||
:query datetime created_since: Only return orders that have been created since the given date.
|
:query datetime created_since: Only return orders that have been created since the given date.
|
||||||
|
:query integer subevent: Only return orders with a position that contains this subevent ID. *Warning:* Result will also include orders if they contain mixed subevents, and it will even return orders where the subevent is only contained in a canceled position.
|
||||||
: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_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 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 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.
|
||||||
|
|||||||
@@ -16,15 +16,22 @@ Field Type Description
|
|||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
id integer Internal ID of the tax rule
|
id integer Internal ID of the tax rule
|
||||||
name multi-lingual string The tax rules' name
|
name multi-lingual string The tax rules' name
|
||||||
|
internal_name string An optional name that is only used in the backend
|
||||||
rate decimal (string) Tax rate in percent
|
rate decimal (string) Tax rate in percent
|
||||||
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
|
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
|
||||||
the specified product price
|
the specified product price
|
||||||
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
|
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
|
||||||
home_country string Merchant country (required for reverse charge), can be
|
home_country string Merchant country (required for reverse charge), can be
|
||||||
``null`` or empty string
|
``null`` or empty string
|
||||||
|
keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom
|
||||||
|
rules keep the gross price constant (default is ``false``)
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
|
||||||
|
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
|
|
||||||
@@ -56,9 +63,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "19.00",
|
"rate": "19.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -94,9 +103,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "19.00",
|
"rate": "19.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +151,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "19.00",
|
"rate": "19.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +198,11 @@ Endpoints
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": {"en": "VAT"},
|
"name": {"en": "VAT"},
|
||||||
|
"internal_name": "VAT",
|
||||||
"rate": "20.00",
|
"rate": "20.00",
|
||||||
"price_includes_tax": true,
|
"price_includes_tax": true,
|
||||||
"eu_reverse_charge": false,
|
"eu_reverse_charge": false,
|
||||||
|
"keep_gross_if_rate_changes": false,
|
||||||
"home_country": "DE"
|
"home_country": "DE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ Algorithms
|
|||||||
==========
|
==========
|
||||||
|
|
||||||
The business logic inside pretix is full of complex algorithms making decisions based on all the hundreds of settings
|
The business logic inside pretix is full of complex algorithms making decisions based on all the hundreds of settings
|
||||||
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very
|
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very important
|
||||||
when working on features close to them, or because they also need to be re-implemented by client-side components like our
|
when working on features close to them, or because they also need to be re-implemented by client-side components like our
|
||||||
ticket scanning apps and we want to ensure the implementations are as similar as possible to avoid confusion.
|
ticket scanning apps and we want to ensure the implementations are as similar as possible to avoid confusion.
|
||||||
|
|
||||||
|
|||||||
119
doc/development/api/cookieconsent.rst
Normal file
119
doc/development/api/cookieconsent.rst
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
.. highlight:: python
|
||||||
|
:linenothreshold: 5
|
||||||
|
|
||||||
|
.. _`cookieconsent`:
|
||||||
|
|
||||||
|
Handling cookie consent
|
||||||
|
=======================
|
||||||
|
|
||||||
|
pretix includes an optional feature to handle cookie consent explicitly to comply with EU regulations.
|
||||||
|
If your plugin sets non-essential cookies or includes a third-party service that does so, you should
|
||||||
|
integrate with this feature.
|
||||||
|
|
||||||
|
Server-side integration
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
First, you need to declare that you are using non-essential cookies by responding to the following
|
||||||
|
signal:
|
||||||
|
|
||||||
|
.. automodule:: pretix.presale.signals
|
||||||
|
:members: register_cookie_providers
|
||||||
|
|
||||||
|
You are expected to return a list of ``CookieProvider`` objects instantiated from the following class:
|
||||||
|
|
||||||
|
.. class:: pretix.presale.cookies.CookieProvider
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.identifier
|
||||||
|
|
||||||
|
A short and unique identifier used to distinguish this cookie provider form others (required).
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.provider_name
|
||||||
|
|
||||||
|
A human-readable name of the entity of feature responsible for setting the cookie (required).
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.usage_classes
|
||||||
|
|
||||||
|
A list of enum values from the ``pretix.presale.cookies.UsageClass`` enumeration class, such as
|
||||||
|
``UsageClass.ANALYTICS``, ``UsageClass.MARKETING``, or ``UsageClass.SOCIAL`` (required).
|
||||||
|
|
||||||
|
.. py:attribute:: CookieProvider.privacy_url
|
||||||
|
|
||||||
|
A link to a privacy policy (optional).
|
||||||
|
|
||||||
|
Here is an example of such a receiver:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
@receiver(register_cookie_providers)
|
||||||
|
def recv_cookie_providers(sender, request, **kwargs):
|
||||||
|
return [
|
||||||
|
CookieProvider(
|
||||||
|
identifier='google_analytics',
|
||||||
|
provider_name='Google Analytics',
|
||||||
|
usage_classes=[UsageClass.ANALYTICS],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
JavaScript-side integration
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
The server-side integration only causes the cookie provider to show up in the cookie dialog. You still
|
||||||
|
need to care about actually enforcing the consent state.
|
||||||
|
|
||||||
|
You can access the consent state through the ``window.pretix.cookie_consent`` variable. Whenever the
|
||||||
|
value changes, a ``pretix:cookie-consent:change`` event is fired on the ``document`` object.
|
||||||
|
|
||||||
|
The variable will generally have one of the following states:
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
================================================================ =====================================================
|
||||||
|
State Interpretation
|
||||||
|
================================================================ =====================================================
|
||||||
|
``pretix === undefined || pretix.cookie_consent === undefined`` Your JavaScript has loaded before the cookie consent
|
||||||
|
script. Wait for the event to be fired, then try again,
|
||||||
|
do not yet set a cookie.
|
||||||
|
``pretix.cookie_consent === null`` The cookie consent mechanism has not been enabled. This
|
||||||
|
usually means that you can set cookies however you like.
|
||||||
|
``pretix.cookie_consent[identifier] === undefined`` The cookie consent mechanism is loaded, but has no data
|
||||||
|
on your cookie yet, wait for the event to be fired, do not
|
||||||
|
yet set a cookie.
|
||||||
|
``pretix.cookie_consent[identifier] === true`` The user has consented to your cookie.
|
||||||
|
``pretix.cookie_consent[identifier] === false`` The user has actively rejected your cookie.
|
||||||
|
================================================================ =====================================================
|
||||||
|
|
||||||
|
If you are integrating e.g. a tracking provider with native cookie consent support such
|
||||||
|
as Facebook's Pixel, you can integrate it like this:
|
||||||
|
|
||||||
|
.. code-block:: javascript
|
||||||
|
|
||||||
|
var consent = (window.pretix || {}).cookie_consent;
|
||||||
|
if (consent !== null && !(consent || {}).facebook) {
|
||||||
|
fbq('consent', 'revoke');
|
||||||
|
}
|
||||||
|
fbq('init', ...);
|
||||||
|
document.addEventListener('pretix:cookie-consent:change', function (e) {
|
||||||
|
fbq('consent', (e.detail || {}).facebook ? 'grant' : 'revoke');
|
||||||
|
})
|
||||||
|
|
||||||
|
If you have a JavaScript function that you only want to load if consent for a specific ``identifier``
|
||||||
|
is given, you can wrap it like this:
|
||||||
|
|
||||||
|
.. code-block:: javascript
|
||||||
|
|
||||||
|
var consent_identifier = "youridentifier";
|
||||||
|
var consent = (window.pretix || {}).cookie_consent;
|
||||||
|
if (consent === null || (consent || {})[consent_identifier] === true) {
|
||||||
|
// Cookie consent tool is either disabled or consent is given
|
||||||
|
addScriptElement(src);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either cookie consent tool has not loaded yet or consent is not given
|
||||||
|
document.addEventListener('pretix:cookie-consent:change', function onChange(e) {
|
||||||
|
var consent = e.detail || {};
|
||||||
|
if (consent === null || consent[consent_identifier] === true) {
|
||||||
|
addScriptElement(src);
|
||||||
|
document.removeEventListener('pretix:cookie-consent:change', onChange);
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -17,6 +17,7 @@ Contents:
|
|||||||
shredder
|
shredder
|
||||||
import
|
import
|
||||||
customview
|
customview
|
||||||
|
cookieconsent
|
||||||
auth
|
auth
|
||||||
general
|
general
|
||||||
quality
|
quality
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ The provider class
|
|||||||
|
|
||||||
.. autoattribute:: public_name
|
.. autoattribute:: public_name
|
||||||
|
|
||||||
|
.. autoattribute:: confirm_button_name
|
||||||
|
|
||||||
.. autoattribute:: is_enabled
|
.. autoattribute:: is_enabled
|
||||||
|
|
||||||
.. autoattribute:: priority
|
.. autoattribute:: priority
|
||||||
|
|||||||
@@ -203,4 +203,4 @@ Then, please contact support@pretix.eu and we will enable DKIM for your domain o
|
|||||||
|
|
||||||
|
|
||||||
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
||||||
.. _SPF specification: http://www.openspf.org/SPF_Record_Syntax
|
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax
|
||||||
|
|||||||
@@ -309,6 +309,10 @@ Currently, the following attributes are understood by pretix itself:
|
|||||||
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
|
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
|
||||||
on this for authentication.
|
on this for authentication.
|
||||||
|
|
||||||
|
* If ``data-consent="…"`` is given, the cookie consent mechanism will be initialized with consent for the given cookie
|
||||||
|
providers. All other providers will be disabled, no consent dialog will be shown. This is useful if you already
|
||||||
|
asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"``
|
||||||
|
|
||||||
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
|
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
|
||||||
Hosted or pretix Enterprise are active, you can pass the following fields:
|
Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,4 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
__version__ = "4.5.0.dev0"
|
__version__ = "4.6.0"
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
|||||||
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TaxRule
|
model = TaxRule
|
||||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
|
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
|
||||||
|
|
||||||
|
|
||||||
class EventSettingsSerializer(SettingsSerializer):
|
class EventSettingsSerializer(SettingsSerializer):
|
||||||
@@ -713,7 +713,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'ticket_download_require_validated_email',
|
'ticket_download_require_validated_email',
|
||||||
'ticket_secret_length',
|
'ticket_secret_length',
|
||||||
'mail_prefix',
|
'mail_prefix',
|
||||||
'mail_from',
|
|
||||||
'mail_from_name',
|
'mail_from_name',
|
||||||
'mail_attach_ical',
|
'mail_attach_ical',
|
||||||
'mail_attach_tickets',
|
'mail_attach_tickets',
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ItemVariation
|
model = ItemVariation
|
||||||
fields = ('id', 'value', 'active', 'description',
|
fields = ('id', 'value', 'active', 'description',
|
||||||
'position', 'default_price', 'price', 'original_price',
|
'position', 'default_price', 'price', 'original_price', 'require_approval',
|
||||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
|
'require_membership', 'require_membership_types',
|
||||||
|
'require_membership_hidden', 'available_from', 'available_until',
|
||||||
'sales_channels', 'hide_without_voucher',)
|
'sales_channels', 'hide_without_voucher',)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -74,8 +75,9 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = ItemVariation
|
model = ItemVariation
|
||||||
fields = ('id', 'value', 'active', 'description',
|
fields = ('id', 'value', 'active', 'description',
|
||||||
'position', 'default_price', 'price', 'original_price',
|
'position', 'default_price', 'price', 'original_price', 'require_approval',
|
||||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
|
'require_membership', 'require_membership_types',
|
||||||
|
'require_membership_hidden', 'available_from', 'available_until',
|
||||||
'sales_channels', 'hide_without_voucher',)
|
'sales_channels', 'hide_without_voucher',)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -1426,7 +1426,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InvoiceLine
|
model = InvoiceLine
|
||||||
fields = ('position', 'description', 'item', 'variation', 'attendee_name', 'event_date_from',
|
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
|
||||||
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
|
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
|
||||||
'fee_internal_type', 'event_location')
|
'fee_internal_type', 'event_location')
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ with scopes_disabled():
|
|||||||
search = django_filters.CharFilter(method='search_qs')
|
search = django_filters.CharFilter(method='search_qs')
|
||||||
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_id')
|
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_id')
|
||||||
variation = django_filters.CharFilter(field_name='all_positions', lookup_expr='variation_id')
|
variation = django_filters.CharFilter(field_name='all_positions', lookup_expr='variation_id')
|
||||||
|
subevent = django_filters.CharFilter(field_name='all_positions', lookup_expr='subevent_id')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ class NativeAuthBackend(BaseAuthBackend):
|
|||||||
d = OrderedDict([
|
d = OrderedDict([
|
||||||
('email', forms.EmailField(label=_("E-mail"), max_length=254,
|
('email', forms.EmailField(label=_("E-mail"), max_length=254,
|
||||||
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
|
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
|
||||||
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)),
|
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
|
||||||
|
max_length=4096)),
|
||||||
])
|
])
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from datetime import timedelta
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
from smtplib import SMTPResponseException
|
from smtplib import SMTPResponseException
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
import css_inline
|
import css_inline
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -49,23 +50,23 @@ from pretix.base.templatetags.rich_text import markdown_compile_email
|
|||||||
|
|
||||||
logger = logging.getLogger('pretix.base.email')
|
logger = logging.getLogger('pretix.base.email')
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=EmailBackend)
|
||||||
|
|
||||||
class CustomSMTPBackend(EmailBackend):
|
|
||||||
|
|
||||||
def test(self, from_addr):
|
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
|
||||||
try:
|
try:
|
||||||
self.open()
|
backend.open()
|
||||||
self.connection.ehlo_or_helo_if_needed()
|
backend.connection.ehlo_or_helo_if_needed()
|
||||||
(code, resp) = self.connection.mail(from_addr, [])
|
(code, resp) = backend.connection.mail(from_addr, [])
|
||||||
if code != 250:
|
if code != 250:
|
||||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||||
raise SMTPResponseException(code, resp)
|
raise SMTPResponseException(code, resp)
|
||||||
(code, resp) = self.connection.rcpt('testdummy@pretix.eu')
|
(code, resp) = backend.connection.rcpt('testdummy@pretix.eu')
|
||||||
if (code != 250) and (code != 251):
|
if (code != 250) and (code != 251):
|
||||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||||
raise SMTPResponseException(code, resp)
|
raise SMTPResponseException(code, resp)
|
||||||
finally:
|
finally:
|
||||||
self.close()
|
backend.close()
|
||||||
|
|
||||||
|
|
||||||
class BaseHTMLMailRenderer:
|
class BaseHTMLMailRenderer:
|
||||||
|
|||||||
@@ -118,6 +118,27 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
|||||||
self.cleaned_data[k] = self.initial[k]
|
self.cleaned_data[k] = self.initial[k]
|
||||||
return super().save()
|
return super().save()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
d = super().clean()
|
||||||
|
|
||||||
|
# There is logic in HierarkeyForm.save() to only persist fields that changed. HierarkeyForm determines if
|
||||||
|
# something changed by comparing `self._s.get(name)` to `value`. This leaves an edge case open for multi-lingual
|
||||||
|
# text fields. On the very first load, the initial value in `self._s.get(name)` will be a LazyGettextProxy-based
|
||||||
|
# string. However, only some of the languages are usually visible, so even if the user does not change anything
|
||||||
|
# at all, it will be considered a changed value and stored. We do not want that, as it makes it very hard to add
|
||||||
|
# languages to an organizer/event later on. So we trick it and make sure nothing gets changed in that situation.
|
||||||
|
for name, field in self.fields.items():
|
||||||
|
if isinstance(field, i18nfield.forms.I18nFormField):
|
||||||
|
value = d.get(name)
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current = self._s.get(name, as_type=type(value))
|
||||||
|
if name not in self.changed_data:
|
||||||
|
d[name] = current
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
def get_new_filename(self, name: str) -> str:
|
def get_new_filename(self, name: str) -> str:
|
||||||
from pretix.base.models import Event
|
from pretix.base.models import Event
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class RegistrationForm(forms.Form):
|
|||||||
widget=forms.PasswordInput(attrs={
|
widget=forms.PasswordInput(attrs={
|
||||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||||
}),
|
}),
|
||||||
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
password_repeat = forms.CharField(
|
password_repeat = forms.CharField(
|
||||||
@@ -161,6 +162,7 @@ class RegistrationForm(forms.Form):
|
|||||||
widget=forms.PasswordInput(attrs={
|
widget=forms.PasswordInput(attrs={
|
||||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||||
}),
|
}),
|
||||||
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||||
@@ -204,11 +206,13 @@ class PasswordRecoverForm(forms.Form):
|
|||||||
password = forms.CharField(
|
password = forms.CharField(
|
||||||
label=_('Password'),
|
label=_('Password'),
|
||||||
widget=forms.PasswordInput,
|
widget=forms.PasswordInput,
|
||||||
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
password_repeat = forms.CharField(
|
password_repeat = forms.CharField(
|
||||||
label=_('Repeat password'),
|
label=_('Repeat password'),
|
||||||
widget=forms.PasswordInput
|
widget=forms.PasswordInput,
|
||||||
|
max_length=4096,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, user_id=None, *args, **kwargs):
|
def __init__(self, user_id=None, *args, **kwargs):
|
||||||
|
|||||||
@@ -333,23 +333,41 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
|||||||
def guess_country(event):
|
def guess_country(event):
|
||||||
# Try to guess the initial country from either the country of the merchant
|
# Try to guess the initial country from either the country of the merchant
|
||||||
# or the locale. This will hopefully save at least some users some scrolling :)
|
# or the locale. This will hopefully save at least some users some scrolling :)
|
||||||
locale = get_language_without_region()
|
|
||||||
country = event.settings.region or event.settings.invoice_address_from_country
|
country = event.settings.region or event.settings.invoice_address_from_country
|
||||||
if not country:
|
if not country:
|
||||||
valid_countries = countries.countries
|
country = get_country_by_locale(get_language_without_region())
|
||||||
if '-' in locale:
|
|
||||||
parts = locale.split('-')
|
|
||||||
# TODO: does this actually work?
|
|
||||||
if parts[1].upper() in valid_countries:
|
|
||||||
country = Country(parts[1].upper())
|
|
||||||
elif parts[0].upper() in valid_countries:
|
|
||||||
country = Country(parts[0].upper())
|
|
||||||
else:
|
|
||||||
if locale.upper() in valid_countries:
|
|
||||||
country = Country(locale.upper())
|
|
||||||
return country
|
return country
|
||||||
|
|
||||||
|
|
||||||
|
def get_country_by_locale(locale):
|
||||||
|
country = None
|
||||||
|
valid_countries = countries.countries
|
||||||
|
if '-' in locale:
|
||||||
|
parts = locale.split('-')
|
||||||
|
# TODO: does this actually work?
|
||||||
|
if parts[1].upper() in valid_countries:
|
||||||
|
country = Country(parts[1].upper())
|
||||||
|
elif parts[0].upper() in valid_countries:
|
||||||
|
country = Country(parts[0].upper())
|
||||||
|
else:
|
||||||
|
if locale.upper() in valid_countries:
|
||||||
|
country = Country(locale.upper())
|
||||||
|
return country
|
||||||
|
|
||||||
|
|
||||||
|
def guess_phone_prefix(event):
|
||||||
|
with language(get_babel_locale()):
|
||||||
|
country = str(guess_country(event))
|
||||||
|
return get_phone_prefix(country)
|
||||||
|
|
||||||
|
|
||||||
|
def get_phone_prefix(country):
|
||||||
|
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||||
|
if country in values:
|
||||||
|
return prefix
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
|
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
|
||||||
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
||||||
|
|
||||||
@@ -780,25 +798,26 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if q.valid_datetime_max:
|
if q.valid_datetime_max:
|
||||||
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||||
elif q.type == Question.TYPE_PHONENUMBER:
|
elif q.type == Question.TYPE_PHONENUMBER:
|
||||||
with language(get_babel_locale()):
|
if initial:
|
||||||
default_country = guess_country(event)
|
|
||||||
default_prefix = None
|
|
||||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
|
||||||
if str(default_country) in values:
|
|
||||||
default_prefix = prefix
|
|
||||||
try:
|
try:
|
||||||
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
|
initial = PhoneNumber().from_string(initial.answer)
|
||||||
except NumberParseException:
|
except NumberParseException:
|
||||||
initial = None
|
initial = None
|
||||||
field = PhoneNumberField(
|
|
||||||
label=label, required=required,
|
if not initial:
|
||||||
help_text=help_text,
|
phone_prefix = guess_phone_prefix(event)
|
||||||
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
if phone_prefix:
|
||||||
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
initial = "+{}.".format(phone_prefix)
|
||||||
# the future.
|
|
||||||
initial=initial,
|
field = PhoneNumberField(
|
||||||
widget=WrappedPhoneNumberPrefixWidget()
|
label=label, required=required,
|
||||||
)
|
help_text=help_text,
|
||||||
|
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
||||||
|
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
||||||
|
# the future.
|
||||||
|
initial=initial,
|
||||||
|
widget=WrappedPhoneNumberPrefixWidget()
|
||||||
|
)
|
||||||
field.question = q
|
field.question = q
|
||||||
if answers:
|
if answers:
|
||||||
# Cache the answer object for later use
|
# Cache the answer object for later use
|
||||||
@@ -869,6 +888,12 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if question_is_required(q) and not answer and answer != 0 and not field.errors:
|
if question_is_required(q) and not answer and answer != 0 and not field.errors:
|
||||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
|
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
|
||||||
|
|
||||||
|
# Strip invisible question from cleaned_data so they don't end up in the database
|
||||||
|
for q in question_cache.values():
|
||||||
|
answer = d.get('question_%d' % q.pk)
|
||||||
|
if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
|
||||||
|
d['question_%d' % q.pk] = None
|
||||||
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -1044,7 +1069,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
self.instance.vat_id_validated = True
|
self.instance.vat_id_validated = True
|
||||||
self.instance.vat_id = normalized_id
|
self.instance.vat_id = normalized_id
|
||||||
except VATIDFinalError as e:
|
except VATIDFinalError as e:
|
||||||
raise ValidationError(e.message)
|
if self.all_optional:
|
||||||
|
self.instance.vat_id_validated = False
|
||||||
|
messages.warning(self.request, e.message)
|
||||||
|
else:
|
||||||
|
raise ValidationError(e.message)
|
||||||
except VATIDTemporaryError as e:
|
except VATIDTemporaryError as e:
|
||||||
self.instance.vat_id_validated = False
|
self.instance.vat_id_validated = False
|
||||||
if self.request and self.vat_warning:
|
if self.request and self.vat_warning:
|
||||||
|
|||||||
@@ -86,14 +86,6 @@ class TimePickerWidget(forms.TimeInput):
|
|||||||
|
|
||||||
class UploadedFileWidget(forms.ClearableFileInput):
|
class UploadedFileWidget(forms.ClearableFileInput):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Browsers can't recognize that the server already has a file uploaded
|
|
||||||
# Don't mark this input as being required if we already have an answer
|
|
||||||
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
|
|
||||||
attrs = kwargs.get('attrs', {})
|
|
||||||
if kwargs.get('required') and kwargs.get('initial'):
|
|
||||||
attrs.update({'required': None})
|
|
||||||
kwargs.update({'attrs': attrs})
|
|
||||||
|
|
||||||
self.position = kwargs.pop('position')
|
self.position = kwargs.pop('position')
|
||||||
self.event = kwargs.pop('event')
|
self.event = kwargs.pop('event')
|
||||||
self.answer = kwargs.pop('answer')
|
self.answer = kwargs.pop('answer')
|
||||||
@@ -125,6 +117,15 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
|||||||
'answer': self.answer.pk,
|
'answer': self.answer.pk,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def get_context(self, name, value, attrs):
|
||||||
|
# Browsers can't recognize that the server already has a file uploaded
|
||||||
|
# Don't mark this input as being required if we already have an answer
|
||||||
|
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
|
||||||
|
ctx = super().get_context(name, value, attrs)
|
||||||
|
if ctx['widget']['is_initial']:
|
||||||
|
ctx['widget']['attrs']['required'] = False
|
||||||
|
return ctx
|
||||||
|
|
||||||
def format_value(self, value):
|
def format_value(self, value):
|
||||||
if self.is_initial(value):
|
if self.is_initial(value):
|
||||||
return self.FakeFile(value, self.position, self.event, self.answer)
|
return self.FakeFile(value, self.position, self.event, self.answer)
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Case, F, OuterRef, Q, Subquery, Sum, Value, When
|
from django.db.models import (
|
||||||
|
Case, Count, F, OuterRef, Q, Subquery, Sum, Value, When,
|
||||||
|
)
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
@@ -45,6 +47,18 @@ class Command(BaseCommand):
|
|||||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
),
|
),
|
||||||
|
position_cnt=Case(
|
||||||
|
When(Q(status__in=('e', 'c')) | Q(require_approval=True), then=Value(0)),
|
||||||
|
default=Coalesce(
|
||||||
|
Subquery(
|
||||||
|
OrderPosition.objects.filter(
|
||||||
|
order=OuterRef('pk')
|
||||||
|
).order_by().values('order').annotate(p=Count('*')).values('p'),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
), Value(0), output_field=models.IntegerField()
|
||||||
|
),
|
||||||
|
output_field=models.IntegerField()
|
||||||
|
),
|
||||||
fee_total=Coalesce(
|
fee_total=Coalesce(
|
||||||
Subquery(
|
Subquery(
|
||||||
OrderFee.objects.filter(
|
OrderFee.objects.filter(
|
||||||
@@ -61,6 +75,15 @@ class Command(BaseCommand):
|
|||||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
),
|
),
|
||||||
|
tx_cnt=Coalesce(
|
||||||
|
Subquery(
|
||||||
|
Transaction.objects.filter(
|
||||||
|
order=OuterRef('pk'),
|
||||||
|
item__isnull=False,
|
||||||
|
).order_by().values('order').annotate(p=Sum(F('count'))).values('p'),
|
||||||
|
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||||
|
),
|
||||||
).annotate(
|
).annotate(
|
||||||
correct_total=Case(
|
correct_total=Case(
|
||||||
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
|
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
|
||||||
@@ -70,13 +93,15 @@ class Command(BaseCommand):
|
|||||||
),
|
),
|
||||||
).exclude(
|
).exclude(
|
||||||
total=F('position_total') + F('fee_total'),
|
total=F('position_total') + F('fee_total'),
|
||||||
tx_total=F('correct_total')
|
tx_total=F('correct_total'),
|
||||||
|
tx_cnt=F('position_cnt')
|
||||||
).select_related('event')
|
).select_related('event')
|
||||||
for o in qs:
|
for o in qs:
|
||||||
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001'):
|
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001') \
|
||||||
|
and o.tx_cnt == o.position_cnt:
|
||||||
# Ignore SQLite which treats Decimals like floats…
|
# Ignore SQLite which treats Decimals like floats…
|
||||||
continue
|
continue
|
||||||
print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, "
|
print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, "
|
||||||
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}")
|
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}, pos_cnt={o.position_cnt}, tx_pos_cnt={o.tx_cnt}")
|
||||||
|
|
||||||
self.stderr.write(self.style.SUCCESS(f'Check completed.'))
|
self.stderr.write(self.style.SUCCESS(f'Check completed.'))
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.2.9 on 2021-12-13 14:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0204_orderposition_backfill_is_bundled'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='itemvariation',
|
||||||
|
name='require_approval',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
src/pretix/base/migrations/0206_customer_phone.py
Normal file
19
src/pretix/base/migrations/0206_customer_phone.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 3.2.9 on 2022-01-12 10:59
|
||||||
|
|
||||||
|
import phonenumber_field.modelfields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0205_itemvariation_require_approval'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='customer',
|
||||||
|
name='phone',
|
||||||
|
field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None),
|
||||||
|
),
|
||||||
|
]
|
||||||
23
src/pretix/base/migrations/0207_auto_20220119_1427.py
Normal file
23
src/pretix/base/migrations/0207_auto_20220119_1427.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.4 on 2022-01-19 14:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0206_customer_phone'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='taxrule',
|
||||||
|
name='internal_name',
|
||||||
|
field=models.CharField(max_length=190, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='taxrule',
|
||||||
|
name='keep_gross_if_rate_changes',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -29,6 +29,7 @@ from django.db.models import F, Q
|
|||||||
from django.utils.crypto import get_random_string, salted_hmac
|
from django.utils.crypto import get_random_string, salted_hmac
|
||||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
from django_scopes import ScopedManager, scopes_disabled
|
from django_scopes import ScopedManager, scopes_disabled
|
||||||
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
|
|
||||||
from pretix.base.banlist import banned
|
from pretix.base.banlist import banned
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
@@ -45,6 +46,7 @@ class Customer(LoggedModel):
|
|||||||
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
||||||
identifier = models.CharField(max_length=190, db_index=True, unique=True)
|
identifier = models.CharField(max_length=190, db_index=True, unique=True)
|
||||||
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
|
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
|
||||||
|
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
|
||||||
password = models.CharField(verbose_name=_('Password'), max_length=128)
|
password = models.CharField(verbose_name=_('Password'), max_length=128)
|
||||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||||
name_parts = models.JSONField(default=dict)
|
name_parts = models.JSONField(default=dict)
|
||||||
@@ -87,6 +89,7 @@ class Customer(LoggedModel):
|
|||||||
self.name_parts = {}
|
self.name_parts = {}
|
||||||
self.name_cached = ''
|
self.name_cached = ''
|
||||||
self.email = None
|
self.email = None
|
||||||
|
self.phone = None
|
||||||
self.save()
|
self.save()
|
||||||
self.all_logentries().update(data={}, shredded=True)
|
self.all_logentries().update(data={}, shredded=True)
|
||||||
self.orders.all().update(customer=None)
|
self.orders.all().update(customer=None)
|
||||||
|
|||||||
@@ -665,21 +665,22 @@ class Event(EventMixin, LoggedModel):
|
|||||||
|
|
||||||
return locking.LockManager(self)
|
return locking.LockManager(self)
|
||||||
|
|
||||||
def get_mail_backend(self, timeout=None, force_custom=False):
|
def get_mail_backend(self, timeout=None):
|
||||||
"""
|
"""
|
||||||
Returns an email server connection, either by using the system-wide connection
|
Returns an email server connection, either by using the system-wide connection
|
||||||
or by returning a custom one based on the event's settings.
|
or by returning a custom one based on the event's settings.
|
||||||
"""
|
"""
|
||||||
from pretix.base.email import CustomSMTPBackend
|
|
||||||
|
|
||||||
if self.settings.smtp_use_custom or force_custom:
|
if self.settings.smtp_use_custom:
|
||||||
return CustomSMTPBackend(host=self.settings.smtp_host,
|
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||||
port=self.settings.smtp_port,
|
host=self.settings.smtp_host,
|
||||||
username=self.settings.smtp_username,
|
port=self.settings.smtp_port,
|
||||||
password=self.settings.smtp_password,
|
username=self.settings.smtp_username,
|
||||||
use_tls=self.settings.smtp_use_tls,
|
password=self.settings.smtp_password,
|
||||||
use_ssl=self.settings.smtp_use_ssl,
|
use_tls=self.settings.smtp_use_tls,
|
||||||
fail_silently=False, timeout=timeout)
|
use_ssl=self.settings.smtp_use_ssl,
|
||||||
|
fail_silently=False,
|
||||||
|
timeout=timeout)
|
||||||
else:
|
else:
|
||||||
return get_connection(fail_silently=False)
|
return get_connection(fail_silently=False)
|
||||||
|
|
||||||
|
|||||||
@@ -764,6 +764,9 @@ class ItemVariation(models.Model):
|
|||||||
:type default_price: decimal.Decimal
|
:type default_price: decimal.Decimal
|
||||||
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
|
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
|
||||||
:type original_price: decimal.Decimal
|
:type original_price: decimal.Decimal
|
||||||
|
:param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after
|
||||||
|
approval by an administrator
|
||||||
|
:type require_approval: bool
|
||||||
"""
|
"""
|
||||||
item = models.ForeignKey(
|
item = models.ForeignKey(
|
||||||
Item,
|
Item,
|
||||||
@@ -799,6 +802,13 @@ class ItemVariation(models.Model):
|
|||||||
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
|
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
|
||||||
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
||||||
)
|
)
|
||||||
|
require_approval = models.BooleanField(
|
||||||
|
verbose_name=_('Require approval'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('If this variation is part of an order, the order will be put into an "approval" state and '
|
||||||
|
'will need to be confirmed by you before it can be paid and completed. You can use this e.g. for '
|
||||||
|
'discounted tickets that are only available to specific groups.'),
|
||||||
|
)
|
||||||
require_membership = models.BooleanField(
|
require_membership = models.BooleanField(
|
||||||
verbose_name=_('Require a valid membership'),
|
verbose_name=_('Require a valid membership'),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -832,7 +842,7 @@ class ItemVariation(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
hide_without_voucher = models.BooleanField(
|
hide_without_voucher = models.BooleanField(
|
||||||
verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'),
|
verbose_name=_('Show only if a matching voucher is redeemed.'),
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
|
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
|
||||||
'that unlocks this variation.')
|
'that unlocks this variation.')
|
||||||
|
|||||||
@@ -950,7 +950,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
|
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
|
||||||
attach_ical=False):
|
attach_ical=False, attach_other_files: list=None):
|
||||||
"""
|
"""
|
||||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||||
|
|
||||||
@@ -994,7 +994,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
recipient, subject, template, context,
|
recipient, subject, template, context,
|
||||||
self.event, self.locale, self, headers=headers, sender=sender,
|
self.event, self.locale, self, headers=headers, sender=sender,
|
||||||
invoices=invoices, attach_tickets=attach_tickets,
|
invoices=invoices, attach_tickets=attach_tickets,
|
||||||
position=position, auto_email=auto_email, attach_ical=attach_ical
|
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
||||||
|
attach_other_files=attach_other_files,
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
raise
|
raise
|
||||||
@@ -1441,6 +1442,15 @@ class AbstractPosition(models.Model):
|
|||||||
lines = [r.strip() for r in lines if r]
|
lines = [r.strip() for r in lines if r]
|
||||||
return '\n'.join(lines).strip()
|
return '\n'.join(lines).strip()
|
||||||
|
|
||||||
|
def requires_approval(self, invoice_address=None):
|
||||||
|
if self.item.require_approval:
|
||||||
|
return True
|
||||||
|
if self.variation and self.variation.require_approval:
|
||||||
|
return True
|
||||||
|
if self.item.tax_rule and self.item.tax_rule._require_approval(invoice_address):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class OrderPayment(models.Model):
|
class OrderPayment(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -2316,7 +2326,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||||
auth=None, attach_tickets=False, attach_ical=False):
|
auth=None, attach_tickets=False, attach_ical=False, attach_other_files: list=None):
|
||||||
"""
|
"""
|
||||||
Sends an email to the attendee. Basically, this method does two things:
|
Sends an email to the attendee. Basically, this method does two things:
|
||||||
|
|
||||||
@@ -2357,6 +2367,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
invoices=invoices,
|
invoices=invoices,
|
||||||
attach_tickets=attach_tickets,
|
attach_tickets=attach_tickets,
|
||||||
attach_ical=attach_ical,
|
attach_ical=attach_ical,
|
||||||
|
attach_other_files=attach_other_files,
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import string
|
|||||||
from datetime import date, datetime, time
|
from datetime import date, datetime, time
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
from django.conf import settings
|
||||||
from django.core.mail import get_connection
|
from django.core.mail import get_connection
|
||||||
from django.core.validators import MinLengthValidator, RegexValidator
|
from django.core.validators import MinLengthValidator, RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@@ -190,21 +191,20 @@ class Organizer(LoggedModel):
|
|||||||
e.delete()
|
e.delete()
|
||||||
self.teams.all().delete()
|
self.teams.all().delete()
|
||||||
|
|
||||||
def get_mail_backend(self, timeout=None, force_custom=False):
|
def get_mail_backend(self, timeout=None):
|
||||||
"""
|
"""
|
||||||
Returns an email server connection, either by using the system-wide connection
|
Returns an email server connection, either by using the system-wide connection
|
||||||
or by returning a custom one based on the organizer's settings.
|
or by returning a custom one based on the organizer's settings.
|
||||||
"""
|
"""
|
||||||
from pretix.base.email import CustomSMTPBackend
|
if self.settings.smtp_use_custom:
|
||||||
|
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||||
if self.settings.smtp_use_custom or force_custom:
|
host=self.settings.smtp_host,
|
||||||
return CustomSMTPBackend(host=self.settings.smtp_host,
|
port=self.settings.smtp_port,
|
||||||
port=self.settings.smtp_port,
|
username=self.settings.smtp_username,
|
||||||
username=self.settings.smtp_username,
|
password=self.settings.smtp_password,
|
||||||
password=self.settings.smtp_password,
|
use_tls=self.settings.smtp_use_tls,
|
||||||
use_tls=self.settings.smtp_use_tls,
|
use_ssl=self.settings.smtp_use_ssl,
|
||||||
use_ssl=self.settings.smtp_use_ssl,
|
fail_silently=False, timeout=timeout)
|
||||||
fail_silently=False, timeout=timeout)
|
|
||||||
else:
|
else:
|
||||||
return get_connection(fail_silently=False)
|
return get_connection(fail_silently=False)
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ class TaxedPrice:
|
|||||||
name=self.name,
|
name=self.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (
|
||||||
|
self.gross == other.gross and
|
||||||
|
self.net == other.net and
|
||||||
|
self.tax == other.tax and
|
||||||
|
self.rate == other.rate and
|
||||||
|
self.name == other.name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TAXED_ZERO = TaxedPrice(
|
TAXED_ZERO = TaxedPrice(
|
||||||
gross=Decimal('0.00'),
|
gross=Decimal('0.00'),
|
||||||
@@ -127,8 +136,13 @@ def cc_to_vat_prefix(country_code):
|
|||||||
|
|
||||||
class TaxRule(LoggedModel):
|
class TaxRule(LoggedModel):
|
||||||
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
|
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
|
||||||
|
internal_name = models.CharField(
|
||||||
|
verbose_name=_('Internal name'),
|
||||||
|
max_length=190,
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
name = I18nCharField(
|
name = I18nCharField(
|
||||||
verbose_name=_('Name'),
|
verbose_name=_('Official name'),
|
||||||
help_text=_('Should be short, e.g. "VAT"'),
|
help_text=_('Should be short, e.g. "VAT"'),
|
||||||
max_length=190,
|
max_length=190,
|
||||||
)
|
)
|
||||||
@@ -141,6 +155,10 @@ class TaxRule(LoggedModel):
|
|||||||
verbose_name=_("The configured product prices include the tax amount"),
|
verbose_name=_("The configured product prices include the tax amount"),
|
||||||
default=True,
|
default=True,
|
||||||
)
|
)
|
||||||
|
keep_gross_if_rate_changes = models.BooleanField(
|
||||||
|
verbose_name=_("Keep gross amount constant if the tax rate changes based on the invoice address"),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
eu_reverse_charge = models.BooleanField(
|
eu_reverse_charge = models.BooleanField(
|
||||||
verbose_name=_("Use EU reverse charge taxation rules"),
|
verbose_name=_("Use EU reverse charge taxation rules"),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -198,6 +216,8 @@ class TaxRule(LoggedModel):
|
|||||||
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
|
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
|
||||||
if self.eu_reverse_charge:
|
if self.eu_reverse_charge:
|
||||||
s += ' ({})'.format(_('reverse charge enabled'))
|
s += ' ({})'.format(_('reverse charge enabled'))
|
||||||
|
if self.internal_name:
|
||||||
|
return f'{self.internal_name} ({s})'
|
||||||
return str(s)
|
return str(s)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -211,7 +231,7 @@ class TaxRule(LoggedModel):
|
|||||||
rule = self.get_matching_rule(invoice_address)
|
rule = self.get_matching_rule(invoice_address)
|
||||||
if rule.get('action', 'vat') == 'block':
|
if rule.get('action', 'vat') == 'block':
|
||||||
raise self.SaleNotAllowed()
|
raise self.SaleNotAllowed()
|
||||||
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
|
if rule.get('action', 'vat') in ('vat', 'require_approval') and rule.get('rate') is not None:
|
||||||
return Decimal(rule.get('rate'))
|
return Decimal(rule.get('rate'))
|
||||||
return Decimal(self.rate)
|
return Decimal(self.rate)
|
||||||
|
|
||||||
@@ -228,13 +248,19 @@ class TaxRule(LoggedModel):
|
|||||||
rate = override_tax_rate
|
rate = override_tax_rate
|
||||||
elif invoice_address:
|
elif invoice_address:
|
||||||
adjust_rate = self.tax_rate_for(invoice_address)
|
adjust_rate = self.tax_rate_for(invoice_address)
|
||||||
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price) and base_price_is == 'gross':
|
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price or self.keep_gross_if_rate_changes) and base_price_is == 'gross':
|
||||||
rate = adjust_rate
|
rate = adjust_rate
|
||||||
elif adjust_rate != rate:
|
elif adjust_rate != rate:
|
||||||
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
if self.keep_gross_if_rate_changes:
|
||||||
base_price = normal_price.net
|
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||||
base_price_is = 'net'
|
base_price = normal_price.gross
|
||||||
subtract_from_gross = Decimal('0.00')
|
base_price_is = 'gross'
|
||||||
|
subtract_from_gross = Decimal('0.00')
|
||||||
|
else:
|
||||||
|
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||||
|
base_price = normal_price.net
|
||||||
|
base_price_is = 'net'
|
||||||
|
subtract_from_gross = Decimal('0.00')
|
||||||
rate = adjust_rate
|
rate = adjust_rate
|
||||||
|
|
||||||
if rate == Decimal('0.00'):
|
if rate == Decimal('0.00'):
|
||||||
@@ -337,12 +363,19 @@ class TaxRule(LoggedModel):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _require_approval(self, invoice_address):
|
||||||
|
if self._custom_rules:
|
||||||
|
rule = self.get_matching_rule(invoice_address)
|
||||||
|
if rule.get('action', 'vat') == 'require_approval':
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _tax_applicable(self, invoice_address):
|
def _tax_applicable(self, invoice_address):
|
||||||
if self._custom_rules:
|
if self._custom_rules:
|
||||||
rule = self.get_matching_rule(invoice_address)
|
rule = self.get_matching_rule(invoice_address)
|
||||||
if rule.get('action', 'vat') == 'block':
|
if rule.get('action', 'vat') == 'block':
|
||||||
raise self.SaleNotAllowed()
|
raise self.SaleNotAllowed()
|
||||||
return rule.get('action', 'vat') == 'vat'
|
return rule.get('action', 'vat') in ('vat', 'require_approval')
|
||||||
|
|
||||||
if not self.eu_reverse_charge:
|
if not self.eu_reverse_charge:
|
||||||
# No reverse charge rules? Always apply VAT!
|
# No reverse charge rules? Always apply VAT!
|
||||||
|
|||||||
@@ -191,6 +191,15 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
return self.verbose_name
|
return self.verbose_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def confirm_button_name(self) -> str:
|
||||||
|
"""
|
||||||
|
A label for the "confirm" button on the last page before a payment is started. This
|
||||||
|
is **not** used in the regular checkout flow, but only if the payment method is selected
|
||||||
|
for an existing order later on.
|
||||||
|
"""
|
||||||
|
return _("Pay now")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def identifier(self) -> str:
|
def identifier(self) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -273,6 +273,11 @@ class RelativeDateTimeField(forms.MultiValueField):
|
|||||||
minutes_before=None
|
minutes_before=None
|
||||||
))
|
))
|
||||||
|
|
||||||
|
def has_changed(self, initial, data):
|
||||||
|
if initial is None:
|
||||||
|
initial = self.widget.decompress(initial)
|
||||||
|
return super().has_changed(initial, data)
|
||||||
|
|
||||||
def clean(self, value):
|
def clean(self, value):
|
||||||
if value[0] == 'absolute' and not value[1]:
|
if value[0] == 'absolute' and not value[1]:
|
||||||
raise ValidationError(self.error_messages['incomplete'])
|
raise ValidationError(self.error_messages['incomplete'])
|
||||||
|
|||||||
@@ -426,10 +426,10 @@ class CartManager:
|
|||||||
if not cp.includes_tax:
|
if not cp.includes_tax:
|
||||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||||
cp_is_net=True, bundled_sum=bundled_sum)
|
cp_is_net=True, bundled_sum=bundled_sum)
|
||||||
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
price = TaxedPrice(net=price.net, gross=price.net, rate=Decimal('0'), tax=Decimal('0'), name='')
|
||||||
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
|
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
|
||||||
cp_is_net=True, bundled_sum=bundled_sum)
|
cp_is_net=True, bundled_sum=bundled_sum)
|
||||||
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=0, tax=0, name='')
|
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=Decimal('0'), tax=Decimal('0'), name='')
|
||||||
else:
|
else:
|
||||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||||
bundled_sum=bundled_sum)
|
bundled_sum=bundled_sum)
|
||||||
@@ -1106,10 +1106,11 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
|
|||||||
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
|
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
|
||||||
|
|
||||||
if pos.tax_rate != rate:
|
if pos.tax_rate != rate:
|
||||||
current_net = pos.price - pos.tax_value
|
if not pos.item.tax_rule.keep_gross_if_rate_changes:
|
||||||
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
current_net = pos.price - pos.tax_value
|
||||||
totaldiff += new_gross - pos.price
|
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
||||||
pos.price = new_gross
|
totaldiff += new_gross - pos.price
|
||||||
|
pos.price = new_gross
|
||||||
pos.includes_tax = rate != Decimal('0.00')
|
pos.includes_tax = rate != Decimal('0.00')
|
||||||
pos.override_tax_rate = rate
|
pos.override_tax_rate = rate
|
||||||
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
|
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
payment = ""
|
payment = ""
|
||||||
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
|
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
|
||||||
if payment:
|
if payment:
|
||||||
payment += "<br />"
|
payment += "<br /><br />"
|
||||||
payment += pgettext("invoice", "Please complete your payment before {expire_date}.").format(
|
payment += pgettext("invoice", "Please complete your payment before {expire_date}.").format(
|
||||||
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
|
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
@@ -51,6 +52,7 @@ from bs4 import BeautifulSoup
|
|||||||
from celery import chain
|
from celery import chain
|
||||||
from celery.exceptions import MaxRetriesExceededError
|
from celery.exceptions import MaxRetriesExceededError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
from django.core.mail import (
|
from django.core.mail import (
|
||||||
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
|
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
|
||||||
)
|
)
|
||||||
@@ -73,6 +75,7 @@ from pretix.base.services.tasks import TransactionAwareTask
|
|||||||
from pretix.base.services.tickets import get_tickets_for_order
|
from pretix.base.services.tickets import get_tickets_for_order
|
||||||
from pretix.base.signals import email_filter, global_email_filter
|
from pretix.base.signals import email_filter, global_email_filter
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
|
from pretix.helpers.hierarkey import clean_filename
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
from pretix.presale.ical import get_ical
|
from pretix.presale.ical import get_ical
|
||||||
|
|
||||||
@@ -94,7 +97,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
|
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
|
||||||
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
|
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
|
||||||
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
|
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
|
||||||
attach_ical=False, attach_cached_files: Sequence = None):
|
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None):
|
||||||
"""
|
"""
|
||||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||||
|
|
||||||
@@ -142,6 +145,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
|
|
||||||
:param attach_cached_files: A list of cached file to attach to this email.
|
:param attach_cached_files: A list of cached file to attach to this email.
|
||||||
|
|
||||||
|
:param attach_other_files: A list of file paths on our storage to attach.
|
||||||
|
|
||||||
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
|
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
|
||||||
that the email has been sent, just that it has been queued by the email backend.
|
that the email has been sent, just that it has been queued by the email backend.
|
||||||
"""
|
"""
|
||||||
@@ -212,7 +217,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
|
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
|
||||||
bcc.append(bcc_mail.strip())
|
bcc.append(bcc_mail.strip())
|
||||||
|
|
||||||
if settings_holder.settings.mail_from == settings.DEFAULT_FROM_EMAIL and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
|
if settings_holder.settings.mail_from not in (settings.DEFAULT_FROM_EMAIL, settings.MAIL_FROM_ORGANIZERS) \
|
||||||
|
and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
|
||||||
headers['Reply-To'] = settings_holder.settings.contact_mail
|
headers['Reply-To'] = settings_holder.settings.contact_mail
|
||||||
|
|
||||||
prefix = settings_holder.settings.get('mail_prefix')
|
prefix = settings_holder.settings.get('mail_prefix')
|
||||||
@@ -301,6 +307,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
|||||||
organizer=organizer.pk if organizer else None,
|
organizer=organizer.pk if organizer else None,
|
||||||
customer=customer.pk if customer else None,
|
customer=customer.pk if customer else None,
|
||||||
attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [],
|
attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [],
|
||||||
|
attach_other_files=attach_other_files,
|
||||||
)
|
)
|
||||||
|
|
||||||
if invoices:
|
if invoices:
|
||||||
@@ -338,7 +345,8 @@ class CustomEmail(EmailMultiAlternatives):
|
|||||||
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||||
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
|
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
|
||||||
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
|
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
|
||||||
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None) -> bool:
|
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None,
|
||||||
|
attach_other_files: List[str] = None) -> bool:
|
||||||
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||||
if html is not None:
|
if html is not None:
|
||||||
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
||||||
@@ -455,6 +463,20 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
|||||||
logger.exception('Could not attach invoice to email')
|
logger.exception('Could not attach invoice to email')
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if attach_other_files:
|
||||||
|
for fname in attach_other_files:
|
||||||
|
ftype, _ = mimetypes.guess_type(fname)
|
||||||
|
data = default_storage.open(fname).read()
|
||||||
|
try:
|
||||||
|
email.attach(
|
||||||
|
clean_filename(os.path.basename(fname)),
|
||||||
|
data,
|
||||||
|
ftype
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
logger.exception('Could not attach file to email')
|
||||||
|
pass
|
||||||
|
|
||||||
if attach_cached_files:
|
if attach_cached_files:
|
||||||
for cf in CachedFile.objects.filter(id__in=attach_cached_files):
|
for cf in CachedFile.objects.filter(id__in=attach_cached_files):
|
||||||
if cf.file:
|
if cf.file:
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ def send_notification_mail(notification: Notification, user: User):
|
|||||||
),
|
),
|
||||||
'body': body_plain,
|
'body': body_plain,
|
||||||
'html': body_html,
|
'html': body_html,
|
||||||
'sender': settings.MAIL_FROM,
|
'sender': settings.MAIL_FROM_NOTIFICATIONS,
|
||||||
'headers': {},
|
'headers': {},
|
||||||
'user': user.pk
|
'user': user.pk
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -700,7 +700,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
|||||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||||
changed_prices[cp.pk] = bprice
|
changed_prices[cp.pk] = bprice
|
||||||
else:
|
else:
|
||||||
bundled_sum = 0
|
bundled_sum = Decimal('0.00')
|
||||||
if not cp.addon_to_id:
|
if not cp.addon_to_id:
|
||||||
for bundledp in cp.addons.all():
|
for bundledp in cp.addons.all():
|
||||||
if bundledp.is_bundled:
|
if bundledp.is_bundled:
|
||||||
@@ -856,7 +856,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
|||||||
total=total,
|
total=total,
|
||||||
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
||||||
meta_info=json.dumps(meta_info or {}),
|
meta_info=json.dumps(meta_info or {}),
|
||||||
require_approval=any(p.item.require_approval for p in positions),
|
require_approval=any(p.requires_approval(invoice_address=address) for p in positions),
|
||||||
sales_channel=sales_channel.identifier,
|
sales_channel=sales_channel.identifier,
|
||||||
customer=customer,
|
customer=customer,
|
||||||
)
|
)
|
||||||
@@ -941,7 +941,10 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
|
|||||||
log_entry,
|
log_entry,
|
||||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||||
attach_tickets=True,
|
attach_tickets=True,
|
||||||
attach_ical=event.settings.mail_attach_ical
|
attach_ical=event.settings.mail_attach_ical,
|
||||||
|
attach_other_files=[a for a in [
|
||||||
|
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||||
|
] if a],
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
logger.exception('Order received email could not be sent')
|
logger.exception('Order received email could not be sent')
|
||||||
@@ -958,7 +961,10 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
|||||||
invoices=[],
|
invoices=[],
|
||||||
attach_tickets=True,
|
attach_tickets=True,
|
||||||
position=position,
|
position=position,
|
||||||
attach_ical=event.settings.mail_attach_ical
|
attach_ical=event.settings.mail_attach_ical,
|
||||||
|
attach_other_files=[a for a in [
|
||||||
|
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||||
|
] if a],
|
||||||
)
|
)
|
||||||
except SendMailException:
|
except SendMailException:
|
||||||
logger.exception('Order received email could not be sent to attendee')
|
logger.exception('Order received email could not be sent to attendee')
|
||||||
@@ -2065,7 +2071,7 @@ class OrderChangeManager:
|
|||||||
split_order.code = None
|
split_order.code = None
|
||||||
split_order.datetime = now()
|
split_order.datetime = now()
|
||||||
split_order.secret = generate_secret()
|
split_order.secret = generate_secret()
|
||||||
split_order.require_approval = self.order.require_approval and any(p.item.require_approval for p in split_positions)
|
split_order.require_approval = self.order.require_approval and any(p.requires_approval(invoice_address=self._invoice_address) for p in split_positions)
|
||||||
split_order.save()
|
split_order.save()
|
||||||
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
|
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
|
||||||
'original_order': self.order.code
|
'original_order': self.order.code
|
||||||
@@ -2317,10 +2323,10 @@ class OrderChangeManager:
|
|||||||
except TaxRule.SaleNotAllowed:
|
except TaxRule.SaleNotAllowed:
|
||||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||||
self._recalculate_total_and_payment_fee()
|
self._recalculate_total_and_payment_fee()
|
||||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
|
||||||
self._reissue_invoice()
|
|
||||||
self._check_paid_price_change()
|
self._check_paid_price_change()
|
||||||
self._check_paid_to_free()
|
self._check_paid_to_free()
|
||||||
|
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||||
|
self._reissue_invoice()
|
||||||
self._clear_tickets_cache()
|
self._clear_tickets_cache()
|
||||||
self.order.touch()
|
self.order.touch()
|
||||||
self.order.create_transactions()
|
self.order.create_transactions()
|
||||||
|
|||||||
@@ -113,10 +113,8 @@ class QuotaAvailability:
|
|||||||
be a few minutes outdated. In this case, you may not rely on the results in the ``count_*`` properties.
|
be a few minutes outdated. In this case, you may not rely on the results in the ``count_*`` properties.
|
||||||
"""
|
"""
|
||||||
now_dt = now_dt or now()
|
now_dt = now_dt or now()
|
||||||
quotas = list(set(self._queue))
|
quota_ids_set = {q.id for q in self._queue}
|
||||||
quotas_original = list(self._queue)
|
if not quota_ids_set:
|
||||||
self._queue.clear()
|
|
||||||
if not quotas:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if allow_cache:
|
if allow_cache:
|
||||||
@@ -129,7 +127,7 @@ class QuotaAvailability:
|
|||||||
elif settings.HAS_REDIS:
|
elif settings.HAS_REDIS:
|
||||||
rc = get_redis_connection("redis")
|
rc = get_redis_connection("redis")
|
||||||
quotas_by_event = defaultdict(list)
|
quotas_by_event = defaultdict(list)
|
||||||
for q in quotas_original:
|
for q in [_q for _q in self._queue if _q.id in quota_ids_set]:
|
||||||
quotas_by_event[q.event_id].append(q)
|
quotas_by_event[q.event_id].append(q)
|
||||||
|
|
||||||
for eventid, evquotas in quotas_by_event.items():
|
for eventid, evquotas in quotas_by_event.items():
|
||||||
@@ -139,16 +137,19 @@ class QuotaAvailability:
|
|||||||
data = [rv for rv in redisval.decode().split(',')]
|
data = [rv for rv in redisval.decode().split(',')]
|
||||||
# Except for some rare situations, we don't want to use cache entries older than 2 minutes
|
# Except for some rare situations, we don't want to use cache entries older than 2 minutes
|
||||||
if time.time() - int(data[2]) < 120 or allow_cache_stale:
|
if time.time() - int(data[2]) < 120 or allow_cache_stale:
|
||||||
quotas_original.remove(q)
|
quota_ids_set.remove(q.id)
|
||||||
quotas.remove(q)
|
|
||||||
if data[1] == "None":
|
if data[1] == "None":
|
||||||
self.results[q] = int(data[0]), None
|
self.results[q] = int(data[0]), None
|
||||||
else:
|
else:
|
||||||
self.results[q] = int(data[0]), int(data[1])
|
self.results[q] = int(data[0]), int(data[1])
|
||||||
|
|
||||||
if not quotas:
|
if not quota_ids_set:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
quotas = [_q for _q in self._queue if _q.id in quota_ids_set]
|
||||||
|
quotas_original = list(quotas)
|
||||||
|
self._queue.clear()
|
||||||
|
|
||||||
self._compute(quotas, now_dt)
|
self._compute(quotas, now_dt)
|
||||||
|
|
||||||
for q in quotas_original:
|
for q in quotas_original:
|
||||||
@@ -284,15 +285,16 @@ class QuotaAvailability:
|
|||||||
seq = Q(subevent_id__in=subevents)
|
seq = Q(subevent_id__in=subevents)
|
||||||
if None in subevents:
|
if None in subevents:
|
||||||
seq |= Q(subevent__isnull=True)
|
seq |= Q(subevent__isnull=True)
|
||||||
|
quota_ids = {q.pk for q in quotas}
|
||||||
op_lookup = OrderPosition.objects.filter(
|
op_lookup = OrderPosition.objects.filter(
|
||||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
|
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
|
||||||
order__event_id__in=events,
|
order__event_id__in=events,
|
||||||
).filter(seq).filter(
|
).filter(seq).filter(
|
||||||
Q(
|
Q(
|
||||||
Q(variation_id__isnull=True) &
|
Q(variation_id__isnull=True) &
|
||||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||||
) | Q(
|
) | Q(
|
||||||
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas})
|
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
|
||||||
).order_by()
|
).order_by()
|
||||||
if any(q.release_after_exit for q in quotas):
|
if any(q.release_after_exit for q in quotas):
|
||||||
op_lookup = op_lookup.annotate(
|
op_lookup = op_lookup.annotate(
|
||||||
@@ -359,6 +361,7 @@ class QuotaAvailability:
|
|||||||
func = 'GREATEST'
|
func = 'GREATEST'
|
||||||
|
|
||||||
subevents = {q.subevent_id for q in quotas}
|
subevents = {q.subevent_id for q in quotas}
|
||||||
|
quota_ids = {q.pk for q in quotas}
|
||||||
seq = Q(subevent_id__in=subevents)
|
seq = Q(subevent_id__in=subevents)
|
||||||
if None in subevents:
|
if None in subevents:
|
||||||
seq |= Q(subevent__isnull=True)
|
seq |= Q(subevent__isnull=True)
|
||||||
@@ -370,10 +373,9 @@ class QuotaAvailability:
|
|||||||
Q(
|
Q(
|
||||||
Q(
|
Q(
|
||||||
Q(variation_id__isnull=True) &
|
Q(variation_id__isnull=True) &
|
||||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||||
) | Q(
|
) | Q(
|
||||||
variation_id__in={i['itemvariation_id'] for i in q_vars if
|
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
||||||
self._quota_objects[i['quota_id']] in quotas}
|
|
||||||
) | Q(
|
) | Q(
|
||||||
quota_id__in=[q.pk for q in quotas]
|
quota_id__in=[q.pk for q in quotas]
|
||||||
)
|
)
|
||||||
@@ -398,6 +400,7 @@ class QuotaAvailability:
|
|||||||
def _compute_carts(self, quotas, q_items, q_vars, size_left, now_dt):
|
def _compute_carts(self, quotas, q_items, q_vars, size_left, now_dt):
|
||||||
events = {q.event_id for q in quotas}
|
events = {q.event_id for q in quotas}
|
||||||
subevents = {q.subevent_id for q in quotas}
|
subevents = {q.subevent_id for q in quotas}
|
||||||
|
quota_ids = {q.pk for q in quotas}
|
||||||
seq = Q(subevent_id__in=subevents)
|
seq = Q(subevent_id__in=subevents)
|
||||||
if None in subevents:
|
if None in subevents:
|
||||||
seq |= Q(subevent__isnull=True)
|
seq |= Q(subevent__isnull=True)
|
||||||
@@ -413,9 +416,9 @@ class QuotaAvailability:
|
|||||||
Q(
|
Q(
|
||||||
Q(
|
Q(
|
||||||
Q(variation_id__isnull=True) &
|
Q(variation_id__isnull=True) &
|
||||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||||
) | Q(
|
) | Q(
|
||||||
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas}
|
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
||||||
@@ -434,6 +437,7 @@ class QuotaAvailability:
|
|||||||
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
|
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
|
||||||
events = {q.event_id for q in quotas}
|
events = {q.event_id for q in quotas}
|
||||||
subevents = {q.subevent_id for q in quotas}
|
subevents = {q.subevent_id for q in quotas}
|
||||||
|
quota_ids = {q.pk for q in quotas}
|
||||||
seq = Q(subevent_id__in=subevents)
|
seq = Q(subevent_id__in=subevents)
|
||||||
if None in subevents:
|
if None in subevents:
|
||||||
seq |= Q(subevent__isnull=True)
|
seq |= Q(subevent__isnull=True)
|
||||||
@@ -444,9 +448,8 @@ class QuotaAvailability:
|
|||||||
Q(
|
Q(
|
||||||
Q(
|
Q(
|
||||||
Q(variation_id__isnull=True) &
|
Q(variation_id__isnull=True) &
|
||||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||||
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if
|
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
|
||||||
self._quota_objects[i['quota_id']] in quotas})
|
|
||||||
)
|
)
|
||||||
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
||||||
for line in w_lookup:
|
for line in w_lookup:
|
||||||
|
|||||||
@@ -136,11 +136,15 @@ DEFAULTS = {
|
|||||||
'type': int,
|
'type': int,
|
||||||
'form_class': forms.IntegerField,
|
'form_class': forms.IntegerField,
|
||||||
'serializer_class': serializers.IntegerField,
|
'serializer_class': serializers.IntegerField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
min_value=1,
|
||||||
|
),
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
min_value=1,
|
min_value=1,
|
||||||
|
required=True,
|
||||||
label=_("Maximum number of items per order"),
|
label=_("Maximum number of items per order"),
|
||||||
help_text=_("Add-on products will not be counted.")
|
help_text=_("Add-on products will not be counted.")
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
'display_net_prices': {
|
'display_net_prices': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
@@ -368,11 +372,12 @@ DEFAULTS = {
|
|||||||
'form_class': I18nFormField,
|
'form_class': I18nFormField,
|
||||||
'serializer_class': I18nField,
|
'serializer_class': I18nField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Custom address field"),
|
label=_("Custom recipient field"),
|
||||||
widget=I18nTextInput,
|
widget=I18nTextInput,
|
||||||
help_text=_("If you want to add a custom text field, e.g. for a country-specific registration number, to "
|
help_text=_("If you want to add a custom text field, e.g. for a country-specific registration number, to "
|
||||||
"your invoice address form, please fill in the label here. This label will both be used for "
|
"your invoice address form, please fill in the label here. This label will both be used for "
|
||||||
"asking the user to input their details as well as for displaying the value on the invoice. "
|
"asking the user to input their details as well as for displaying the value on the invoice. It will "
|
||||||
|
"be shown on the invoice below the headline. "
|
||||||
"The field will not be required.")
|
"The field will not be required.")
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -440,9 +445,11 @@ DEFAULTS = {
|
|||||||
'type': int,
|
'type': int,
|
||||||
'form_class': forms.IntegerField,
|
'form_class': forms.IntegerField,
|
||||||
'serializer_class': serializers.IntegerField,
|
'serializer_class': serializers.IntegerField,
|
||||||
|
'serializer_kwargs': dict(),
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Minimum length of invoice number after prefix"),
|
label=_("Minimum length of invoice number after prefix"),
|
||||||
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
|
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
|
||||||
|
required=True,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'invoice_numbers_consecutive': {
|
'invoice_numbers_consecutive': {
|
||||||
@@ -506,6 +513,7 @@ DEFAULTS = {
|
|||||||
MinValueValidator(12),
|
MinValueValidator(12),
|
||||||
MaxValueValidator(64),
|
MaxValueValidator(64),
|
||||||
],
|
],
|
||||||
|
required=True,
|
||||||
widget=forms.NumberInput(
|
widget=forms.NumberInput(
|
||||||
attrs={
|
attrs={
|
||||||
'min': '12',
|
'min': '12',
|
||||||
@@ -520,9 +528,13 @@ DEFAULTS = {
|
|||||||
'type': int,
|
'type': int,
|
||||||
'form_class': forms.IntegerField,
|
'form_class': forms.IntegerField,
|
||||||
'serializer_class': serializers.IntegerField,
|
'serializer_class': serializers.IntegerField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
min_value=0,
|
||||||
|
),
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
min_value=0,
|
min_value=0,
|
||||||
label=_("Reservation period"),
|
label=_("Reservation period"),
|
||||||
|
required=True,
|
||||||
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
|
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -577,6 +589,7 @@ DEFAULTS = {
|
|||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Set payment term"),
|
label=_("Set payment term"),
|
||||||
widget=forms.RadioSelect,
|
widget=forms.RadioSelect,
|
||||||
|
required=True,
|
||||||
choices=(
|
choices=(
|
||||||
('days', _("in days")),
|
('days', _("in days")),
|
||||||
('minutes', _("in minutes"))
|
('minutes', _("in minutes"))
|
||||||
@@ -1091,9 +1104,13 @@ DEFAULTS = {
|
|||||||
'type': int,
|
'type': int,
|
||||||
'serializer_class': serializers.IntegerField,
|
'serializer_class': serializers.IntegerField,
|
||||||
'form_class': forms.IntegerField,
|
'form_class': forms.IntegerField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
min_value=1,
|
||||||
|
),
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Waiting list response time"),
|
label=_("Waiting list response time"),
|
||||||
min_value=1,
|
min_value=1,
|
||||||
|
required=True,
|
||||||
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
|
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
|
||||||
"number of hours until it expires and can be re-assigned to the next person on the list."),
|
"number of hours until it expires and can be re-assigned to the next person on the list."),
|
||||||
widget=forms.NumberInput(),
|
widget=forms.NumberInput(),
|
||||||
@@ -1572,7 +1589,7 @@ DEFAULTS = {
|
|||||||
'type': str
|
'type': str
|
||||||
},
|
},
|
||||||
'mail_from': {
|
'mail_from': {
|
||||||
'default': settings.MAIL_FROM,
|
'default': settings.MAIL_FROM_ORGANIZERS,
|
||||||
'type': str,
|
'type': str,
|
||||||
'form_class': forms.EmailField,
|
'form_class': forms.EmailField,
|
||||||
'serializer_class': serializers.EmailField,
|
'serializer_class': serializers.EmailField,
|
||||||
@@ -1687,6 +1704,30 @@ You can change your order details and view the status of your order at
|
|||||||
Best regards,
|
Best regards,
|
||||||
Your {event} team"""))
|
Your {event} team"""))
|
||||||
},
|
},
|
||||||
|
'mail_attachment_new_order': {
|
||||||
|
'default': None,
|
||||||
|
'type': File,
|
||||||
|
'form_class': ExtFileField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_('Attachment for new orders'),
|
||||||
|
ext_whitelist=(".pdf",),
|
||||||
|
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
|
||||||
|
help_text=_('This file will be attached to the first email that we send for every new order. Therefore it will be '
|
||||||
|
'combined with the "Placed order", "Free order", or "Received order" texts from above. It will be sent '
|
||||||
|
'to both order contacts and attendees. You can use this e.g. to send your terms of service. Do not use '
|
||||||
|
'it to send non-public information as this file might be sent before payment is confirmed or the order '
|
||||||
|
'is approved. To avoid this vital email going to spam, you can only upload PDF files of up to {size} MB.').format(
|
||||||
|
size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT // (1024 * 1024),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'serializer_class': UploadedFileField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
allowed_types=[
|
||||||
|
'application/pdf'
|
||||||
|
],
|
||||||
|
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
|
||||||
|
)
|
||||||
|
},
|
||||||
'mail_send_order_placed_attendee': {
|
'mail_send_order_placed_attendee': {
|
||||||
'type': bool,
|
'type': bool,
|
||||||
'default': 'False'
|
'default': 'False'
|
||||||
|
|||||||
@@ -85,9 +85,6 @@
|
|||||||
-webkit-hyphens: auto;
|
-webkit-hyphens: auto;
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -116,6 +113,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
.content {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.content table {
|
.content table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -142,7 +142,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-button {
|
.order-button {
|
||||||
padding-top: 5px
|
padding-top: 5px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.order-button a.button {
|
.order-button a.button {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -173,7 +174,7 @@
|
|||||||
body {
|
body {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
}
|
}
|
||||||
.content table td {
|
.content {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% load eventurl %}
|
{% load eventurl %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load oneline %}
|
||||||
|
|
||||||
{% if position %}
|
{% if position %}
|
||||||
<div class="order-info">
|
<div class="order-info">
|
||||||
@@ -107,6 +108,10 @@
|
|||||||
{% if event.settings.show_times %}
|
{% if event.settings.show_times %}
|
||||||
{{ groupkey.2.date_from|date:"TIME_FORMAT" }}
|
{{ groupkey.2.date_from|date:"TIME_FORMAT" }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if groupkey.2.location %}
|
||||||
|
<br>
|
||||||
|
{{ groupkey.2.location|oneline }}
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if groupkey.3 %} {# attendee name #}
|
{% if groupkey.3 %} {# attendee name #}
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
@@ -104,9 +104,6 @@
|
|||||||
-webkit-hyphens: auto;
|
-webkit-hyphens: auto;
|
||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -136,6 +133,10 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.content table {
|
.content table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -197,7 +198,7 @@
|
|||||||
body {
|
body {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
}
|
}
|
||||||
.content table td {
|
.content {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
26
src/pretix/base/templates/pretixbase/redirect.html
Normal file
26
src/pretix/base/templates/pretixbase/redirect.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "error.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load rich_text %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}{% trans "Redirect" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<i class="fa fa-link fa-fw big-icon"></i>
|
||||||
|
<div class="error-details">
|
||||||
|
<h1>{% trans "Redirect" %}</h1>
|
||||||
|
<h3>
|
||||||
|
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %}
|
||||||
|
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Please only proceed if you trust this website to be safe.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
<a href="{{ url }}" class="btn btn-primary btn-lg">
|
||||||
|
{% blocktrans trimmed with host=hostname %}
|
||||||
|
Proceed to {{ host }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
31
src/pretix/base/templatetags/oneline.py
Normal file
31
src/pretix/base/templatetags/oneline.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def oneline(value):
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
return ', '.join([l.strip() for l in str(value).splitlines() if l and l.strip()])
|
||||||
@@ -138,7 +138,7 @@ def truelink_callback(attrs, new=False):
|
|||||||
text = re.sub(r'[^a-zA-Z0-9.\-/_ ]', '', attrs.get('_text')) # clean up link text
|
text = re.sub(r'[^a-zA-Z0-9.\-/_ ]', '', attrs.get('_text')) # clean up link text
|
||||||
url = attrs.get((None, 'href'), '/')
|
url = attrs.get((None, 'href'), '/')
|
||||||
href_url = urllib.parse.urlparse(url)
|
href_url = urllib.parse.urlparse(url)
|
||||||
if URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
if (None, 'href') in attrs and URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
||||||
# link text looks like a url
|
# link text looks like a url
|
||||||
if text.startswith('//'):
|
if text.startswith('//'):
|
||||||
text = 'https:' + text
|
text = 'https:' + text
|
||||||
@@ -157,6 +157,8 @@ def abslink_callback(attrs, new=False):
|
|||||||
Makes sure that all links will be absolute links and will be opened in a new page with no
|
Makes sure that all links will be absolute links and will be opened in a new page with no
|
||||||
window.opener attribute.
|
window.opener attribute.
|
||||||
"""
|
"""
|
||||||
|
if (None, 'href') not in attrs:
|
||||||
|
return attrs
|
||||||
url = attrs.get((None, 'href'), '/')
|
url = attrs.get((None, 'href'), '/')
|
||||||
if not url.startswith('mailto:') and not url.startswith('tel:'):
|
if not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||||
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)
|
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)
|
||||||
|
|||||||
@@ -30,67 +30,85 @@ from django.utils.translation import gettext as _
|
|||||||
from django.views.decorators.csrf import requires_csrf_token
|
from django.views.decorators.csrf import requires_csrf_token
|
||||||
from sentry_sdk import last_event_id
|
from sentry_sdk import last_event_id
|
||||||
|
|
||||||
|
from pretix.base.i18n import language
|
||||||
|
from pretix.base.middleware import get_language_from_request
|
||||||
|
|
||||||
|
|
||||||
def csrf_failure(request, reason=""):
|
def csrf_failure(request, reason=""):
|
||||||
t = get_template('csrffail.html')
|
try:
|
||||||
c = {
|
locale = get_language_from_request(request)
|
||||||
'reason': reason,
|
except:
|
||||||
'no_referer': reason == REASON_NO_REFERER,
|
locale = "en"
|
||||||
'no_referer1': _(
|
with language(locale): # Middleware might not have run, need to do this manually
|
||||||
"You are seeing this message because this HTTPS site requires a "
|
t = get_template('csrffail.html')
|
||||||
"'Referer header' to be sent by your Web browser, but none was "
|
c = {
|
||||||
"sent. This header is required for security reasons, to ensure "
|
'reason': reason,
|
||||||
"that your browser is not being hijacked by third parties."),
|
'no_referer': reason == REASON_NO_REFERER,
|
||||||
'no_referer2': _(
|
'no_referer1': _(
|
||||||
"If you have configured your browser to disable 'Referer' headers, "
|
"You are seeing this message because this HTTPS site requires a "
|
||||||
"please re-enable them, at least for this site, or for HTTPS "
|
"'Referer header' to be sent by your Web browser, but none was "
|
||||||
"connections, or for 'same-origin' requests."),
|
"sent. This header is required for security reasons, to ensure "
|
||||||
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
|
"that your browser is not being hijacked by third parties."),
|
||||||
'no_cookie1': _(
|
'no_referer2': _(
|
||||||
"You are seeing this message because this site requires a CSRF "
|
"If you have configured your browser to disable 'Referer' headers, "
|
||||||
"cookie when submitting forms. This cookie is required for "
|
"please re-enable them, at least for this site, or for HTTPS "
|
||||||
"security reasons, to ensure that your browser is not being "
|
"connections, or for 'same-origin' requests."),
|
||||||
"hijacked by third parties."),
|
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
|
||||||
'no_cookie2': _(
|
'no_cookie1': _(
|
||||||
"If you have configured your browser to disable cookies, please "
|
"You are seeing this message because this site requires a CSRF "
|
||||||
"re-enable them, at least for this site, or for 'same-origin' "
|
"cookie when submitting forms. This cookie is required for "
|
||||||
"requests."),
|
"security reasons, to ensure that your browser is not being "
|
||||||
}
|
"hijacked by third parties."),
|
||||||
return HttpResponseForbidden(t.render(c), content_type='text/html')
|
'no_cookie2': _(
|
||||||
|
"If you have configured your browser to disable cookies, please "
|
||||||
|
"re-enable them, at least for this site, or for 'same-origin' "
|
||||||
|
"requests."),
|
||||||
|
}
|
||||||
|
return HttpResponseForbidden(t.render(c), content_type='text/html')
|
||||||
|
|
||||||
|
|
||||||
@requires_csrf_token
|
@requires_csrf_token
|
||||||
def page_not_found(request, exception):
|
def page_not_found(request, exception):
|
||||||
exception_repr = exception.__class__.__name__
|
|
||||||
# Try to get an "interesting" exception message, if any (and not the ugly
|
|
||||||
# Resolver404 dictionary)
|
|
||||||
try:
|
try:
|
||||||
message = exception.args[0]
|
locale = get_language_from_request(request)
|
||||||
except (AttributeError, IndexError):
|
except:
|
||||||
pass
|
locale = "en"
|
||||||
else:
|
with language(locale): # Middleware might not have run, need to do this manually
|
||||||
if isinstance(message, (str, Promise)):
|
exception_repr = exception.__class__.__name__
|
||||||
exception_repr = str(message)
|
# Try to get an "interesting" exception message, if any (and not the ugly
|
||||||
context = {
|
# Resolver404 dictionary)
|
||||||
'request_path': request.path,
|
try:
|
||||||
'exception': exception_repr,
|
message = exception.args[0]
|
||||||
}
|
except (AttributeError, IndexError):
|
||||||
template = get_template('404.html')
|
pass
|
||||||
body = template.render(context, request)
|
else:
|
||||||
r = HttpResponseNotFound(body)
|
if isinstance(message, (str, Promise)):
|
||||||
r.xframe_options_exempt = True
|
exception_repr = str(message)
|
||||||
return r
|
context = {
|
||||||
|
'request_path': request.path,
|
||||||
|
'exception': exception_repr,
|
||||||
|
}
|
||||||
|
template = get_template('404.html')
|
||||||
|
body = template.render(context, request)
|
||||||
|
r = HttpResponseNotFound(body)
|
||||||
|
r.xframe_options_exempt = True
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
@requires_csrf_token
|
@requires_csrf_token
|
||||||
def server_error(request):
|
def server_error(request):
|
||||||
try:
|
try:
|
||||||
template = loader.get_template('500.html')
|
locale = get_language_from_request(request)
|
||||||
except TemplateDoesNotExist:
|
except:
|
||||||
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
locale = "en"
|
||||||
r = HttpResponseServerError(template.render({
|
with language(locale): # Middleware might not have run, need to do this manually
|
||||||
'request': request,
|
try:
|
||||||
'sentry_event_id': last_event_id(),
|
template = loader.get_template('500.html')
|
||||||
}))
|
except TemplateDoesNotExist:
|
||||||
r.xframe_options_exempt = True
|
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
||||||
return r
|
r = HttpResponseServerError(template.render({
|
||||||
|
'request': request,
|
||||||
|
'sentry_event_id': last_event_id(),
|
||||||
|
}))
|
||||||
|
r.xframe_options_exempt = True
|
||||||
|
return r
|
||||||
|
|||||||
@@ -355,20 +355,29 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
|||||||
override[k].pop('initial', None)
|
override[k].pop('initial', None)
|
||||||
return override_sets
|
return override_sets
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def vat_id_validation_enabled(self):
|
||||||
|
return any([p.item.tax_rule and (p.item.tax_rule.eu_reverse_charge or p.item.tax_rule.custom_rules)
|
||||||
|
for p in self.positions])
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def invoice_form(self):
|
def invoice_form(self):
|
||||||
if not self.address_asked and self.request.event.settings.invoice_name_required:
|
if not self.address_asked and self.request.event.settings.invoice_name_required:
|
||||||
f = self.invoice_name_form_class(
|
f = self.invoice_name_form_class(
|
||||||
data=self.request.POST if self.request.method == "POST" else None,
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
event=self.request.event,
|
event=self.request.event,
|
||||||
instance=self.invoice_address, validate_vat_id=False,
|
instance=self.invoice_address,
|
||||||
|
validate_vat_id=False,
|
||||||
|
request=self.request,
|
||||||
all_optional=self.all_optional
|
all_optional=self.all_optional
|
||||||
)
|
)
|
||||||
elif self.address_asked:
|
elif self.address_asked:
|
||||||
f = self.invoice_form_class(
|
f = self.invoice_form_class(
|
||||||
data=self.request.POST if self.request.method == "POST" else None,
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
event=self.request.event,
|
event=self.request.event,
|
||||||
instance=self.invoice_address, validate_vat_id=False,
|
instance=self.invoice_address,
|
||||||
|
validate_vat_id=self.vat_id_validation_enabled,
|
||||||
|
request=self.request,
|
||||||
all_optional=self.all_optional,
|
all_optional=self.all_optional,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ import urllib.parse
|
|||||||
from django.core import signing
|
from django.core import signing
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseRedirect
|
from django.http import HttpResponseBadRequest, HttpResponseRedirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
|
||||||
|
def _is_samesite_referer(request):
|
||||||
|
referer = request.META.get('HTTP_REFERER')
|
||||||
|
if referer is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
referer = urllib.parse.urlparse(referer)
|
||||||
|
|
||||||
|
# Make sure we have a valid URL for Referer.
|
||||||
|
if '' in (referer.scheme, referer.netloc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (referer.scheme, referer.netloc) == (request.scheme, request.get_host())
|
||||||
|
|
||||||
|
|
||||||
def redir_view(request):
|
def redir_view(request):
|
||||||
@@ -32,6 +47,14 @@ def redir_view(request):
|
|||||||
url = signer.unsign(request.GET.get('url', ''))
|
url = signer.unsign(request.GET.get('url', ''))
|
||||||
except signing.BadSignature:
|
except signing.BadSignature:
|
||||||
return HttpResponseBadRequest('Invalid parameter')
|
return HttpResponseBadRequest('Invalid parameter')
|
||||||
|
|
||||||
|
if not _is_samesite_referer(request):
|
||||||
|
u = urllib.parse.urlparse(url)
|
||||||
|
return render(request, 'pretixbase/redirect.html', {
|
||||||
|
'hostname': u.hostname,
|
||||||
|
'url': url,
|
||||||
|
})
|
||||||
|
|
||||||
r = HttpResponseRedirect(url)
|
r = HttpResponseRedirect(url)
|
||||||
r['X-Robots-Tag'] = 'noindex'
|
r['X-Robots-Tag'] = 'noindex'
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ from django.utils.timezone import now
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||||
|
|
||||||
from ...base.forms import I18nModelForm, SecretKeySettingsField
|
from ...base.forms import I18nModelForm
|
||||||
|
|
||||||
# Import for backwards compatibility with okd import paths
|
# Import for backwards compatibility with okd import paths
|
||||||
from ...base.forms.widgets import ( # noqa
|
from ...base.forms.widgets import ( # noqa
|
||||||
@@ -373,49 +373,6 @@ class FontSelect(forms.RadioSelect):
|
|||||||
option_template_name = 'pretixcontrol/font_option.html'
|
option_template_name = 'pretixcontrol/font_option.html'
|
||||||
|
|
||||||
|
|
||||||
class SMTPSettingsMixin(forms.Form):
|
|
||||||
smtp_use_custom = forms.BooleanField(
|
|
||||||
label=_("Use custom SMTP server"),
|
|
||||||
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
smtp_host = forms.CharField(
|
|
||||||
label=_("Hostname"),
|
|
||||||
required=False,
|
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
|
|
||||||
)
|
|
||||||
smtp_port = forms.IntegerField(
|
|
||||||
label=_("Port"),
|
|
||||||
required=False,
|
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
|
|
||||||
)
|
|
||||||
smtp_username = forms.CharField(
|
|
||||||
label=_("Username"),
|
|
||||||
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
smtp_password = SecretKeySettingsField(
|
|
||||||
label=_("Password"),
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
smtp_use_tls = forms.BooleanField(
|
|
||||||
label=_("Use STARTTLS"),
|
|
||||||
help_text=_("Commonly enabled on port 587."),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
smtp_use_ssl = forms.BooleanField(
|
|
||||||
label=_("Use SSL"),
|
|
||||||
help_text=_("Commonly enabled on port 465."),
|
|
||||||
required=False
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
data = super().clean()
|
|
||||||
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
|
|
||||||
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class ItemMultipleChoiceField(SafeModelMultipleChoiceField):
|
class ItemMultipleChoiceField(SafeModelMultipleChoiceField):
|
||||||
def label_from_instance(self, obj):
|
def label_from_instance(self, obj):
|
||||||
return str(obj) if obj.active else mark_safe(f'<strike class="text-muted">{escape(obj)}</strike>')
|
return str(obj) if obj.active else mark_safe(f'<strike class="text-muted">{escape(obj)}</strike>')
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from django.core.validators import validate_email
|
|||||||
from django.db.models import Prefetch, Q, prefetch_related_objects
|
from django.db.models import Prefetch, Q, prefetch_related_objects
|
||||||
from django.forms import CheckboxSelectMultiple, formset_factory
|
from django.forms import CheckboxSelectMultiple, formset_factory
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.timezone import get_current_timezone_name
|
from django.utils.timezone import get_current_timezone_name
|
||||||
@@ -63,7 +64,7 @@ from pretix.base.settings import (
|
|||||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||||
)
|
)
|
||||||
from pretix.control.forms import (
|
from pretix.control.forms import (
|
||||||
MultipleLanguagesWidget, SlugWidget, SMTPSettingsMixin, SplitDateTimeField,
|
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||||
SplitDateTimePickerWidget,
|
SplitDateTimePickerWidget,
|
||||||
)
|
)
|
||||||
from pretix.control.forms.widgets import Select2
|
from pretix.control.forms.widgets import Select2
|
||||||
@@ -534,34 +535,39 @@ class EventSettingsForm(SettingsForm):
|
|||||||
'og_image',
|
'og_image',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _resolve_virtual_keys_input(self, data, prefix=''):
|
||||||
|
# set all dependants of virtual_keys and
|
||||||
|
# delete all virtual_fields to prevent them from being saved
|
||||||
|
for virtual_key in self.virtual_keys:
|
||||||
|
if prefix + virtual_key not in data:
|
||||||
|
continue
|
||||||
|
base_key = prefix + virtual_key.rsplit('_', 2)[0]
|
||||||
|
asked_key = base_key + '_asked'
|
||||||
|
required_key = base_key + '_required'
|
||||||
|
|
||||||
|
if data[prefix + virtual_key] == 'optional':
|
||||||
|
data[asked_key] = True
|
||||||
|
data[required_key] = False
|
||||||
|
elif data[prefix + virtual_key] == 'required':
|
||||||
|
data[asked_key] = True
|
||||||
|
data[required_key] = True
|
||||||
|
# Explicitly check for 'do_not_ask'.
|
||||||
|
# Do not overwrite as default-behaviour when no value for virtual field is transmitted!
|
||||||
|
elif data[prefix + virtual_key] == 'do_not_ask':
|
||||||
|
data[asked_key] = False
|
||||||
|
data[required_key] = False
|
||||||
|
|
||||||
|
# hierarkey.forms cannot handle non-existent keys in cleaned_data => do not delete, but set to None
|
||||||
|
if not prefix:
|
||||||
|
data[virtual_key] = None
|
||||||
|
return data
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
data = super().clean()
|
data = super().clean()
|
||||||
settings_dict = self.event.settings.freeze()
|
settings_dict = self.event.settings.freeze()
|
||||||
settings_dict.update(data)
|
settings_dict.update(data)
|
||||||
|
|
||||||
# set all dependants of virtual_keys and
|
data = self._resolve_virtual_keys_input(data)
|
||||||
# delete all virtual_fields to prevent them from being saved
|
|
||||||
for virtual_key in self.virtual_keys:
|
|
||||||
if virtual_key not in data:
|
|
||||||
continue
|
|
||||||
base_key = virtual_key.rsplit('_', 2)[0]
|
|
||||||
asked_key = base_key + '_asked'
|
|
||||||
required_key = base_key + '_required'
|
|
||||||
|
|
||||||
if data[virtual_key] == 'optional':
|
|
||||||
data[asked_key] = True
|
|
||||||
data[required_key] = False
|
|
||||||
elif data[virtual_key] == 'required':
|
|
||||||
data[asked_key] = True
|
|
||||||
data[required_key] = True
|
|
||||||
# Explicitly check for 'do_not_ask'.
|
|
||||||
# Do not overwrite as default-behaviour when no value for virtual field is transmitted!
|
|
||||||
elif data[virtual_key] == 'do_not_ask':
|
|
||||||
data[asked_key] = False
|
|
||||||
data[required_key] = False
|
|
||||||
|
|
||||||
# hierarkey.forms cannot handle non-existent keys in cleaned_data => do not delete, but set to None
|
|
||||||
data[virtual_key] = None
|
|
||||||
|
|
||||||
validate_event_settings(self.event, data)
|
validate_event_settings(self.event, data)
|
||||||
return data
|
return data
|
||||||
@@ -621,6 +627,35 @@ class EventSettingsForm(SettingsForm):
|
|||||||
else:
|
else:
|
||||||
self.initial[virtual_key] = 'do_not_ask'
|
self.initial[virtual_key] = 'do_not_ask'
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def changed_data(self):
|
||||||
|
data = []
|
||||||
|
|
||||||
|
# We need to resolve the mapping between our "virtual" fields and the "real"fields here, otherwise
|
||||||
|
# they are detected as "changed" on every save even though they aren't.
|
||||||
|
in_data = self._resolve_virtual_keys_input(self.data.copy(), prefix=f'{self.prefix}-' if self.prefix else '')
|
||||||
|
|
||||||
|
for name, field in self.fields.items():
|
||||||
|
prefixed_name = self.add_prefix(name)
|
||||||
|
data_value = field.widget.value_from_datadict(in_data, self.files, prefixed_name)
|
||||||
|
if not field.show_hidden_initial:
|
||||||
|
# Use the BoundField's initial as this is the value passed to
|
||||||
|
# the widget.
|
||||||
|
initial_value = self[name].initial
|
||||||
|
else:
|
||||||
|
initial_prefixed_name = self.add_initial_prefix(name)
|
||||||
|
hidden_widget = field.hidden_widget()
|
||||||
|
try:
|
||||||
|
initial_value = field.to_python(hidden_widget.value_from_datadict(
|
||||||
|
self.data, self.files, initial_prefixed_name))
|
||||||
|
except ValidationError:
|
||||||
|
# Always assume data has changed if validation fails.
|
||||||
|
data.append(name)
|
||||||
|
continue
|
||||||
|
if field.has_changed(initial_value, data_value):
|
||||||
|
data.append(name)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class CancelSettingsForm(SettingsForm):
|
class CancelSettingsForm(SettingsForm):
|
||||||
auto_fields = [
|
auto_fields = [
|
||||||
@@ -830,13 +865,13 @@ def contains_web_channel_validate(val):
|
|||||||
raise ValidationError(_("The online shop must be selected to receive these emails."))
|
raise ValidationError(_("The online shop must be selected to receive these emails."))
|
||||||
|
|
||||||
|
|
||||||
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
|
class MailSettingsForm(SettingsForm):
|
||||||
auto_fields = [
|
auto_fields = [
|
||||||
'mail_prefix',
|
'mail_prefix',
|
||||||
'mail_from',
|
|
||||||
'mail_from_name',
|
'mail_from_name',
|
||||||
'mail_attach_ical',
|
'mail_attach_ical',
|
||||||
'mail_attach_tickets',
|
'mail_attach_tickets',
|
||||||
|
'mail_attachment_new_order',
|
||||||
]
|
]
|
||||||
|
|
||||||
mail_sales_channel_placed_paid = forms.MultipleChoiceField(
|
mail_sales_channel_placed_paid = forms.MultipleChoiceField(
|
||||||
@@ -1179,6 +1214,7 @@ class TaxRuleLineForm(I18nForm):
|
|||||||
('reverse', _('Reverse charge')),
|
('reverse', _('Reverse charge')),
|
||||||
('no', _('No VAT')),
|
('no', _('No VAT')),
|
||||||
('block', _('Sale not allowed')),
|
('block', _('Sale not allowed')),
|
||||||
|
('require_approval', _('Order requires approval')),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
rate = forms.DecimalField(
|
rate = forms.DecimalField(
|
||||||
@@ -1212,7 +1248,7 @@ TaxRuleLineFormSet = formset_factory(
|
|||||||
class TaxRuleForm(I18nModelForm):
|
class TaxRuleForm(I18nModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TaxRule
|
model = TaxRule
|
||||||
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country']
|
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes']
|
||||||
|
|
||||||
|
|
||||||
class WidgetCodeForm(forms.Form):
|
class WidgetCodeForm(forms.Form):
|
||||||
|
|||||||
@@ -811,6 +811,213 @@ class OrderSearchFilterForm(OrderFilterForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPaymentSearchFilterForm(forms.Form):
|
||||||
|
orders = {'id': 'id', 'local_id': 'local_id', 'state': 'state', 'amount': 'amount', 'order': 'order',
|
||||||
|
'created': 'created', 'payment_date': 'payment_date', 'provider': 'provider', 'info': 'info',
|
||||||
|
'fee': 'fee'}
|
||||||
|
|
||||||
|
query = forms.CharField(
|
||||||
|
label=_('Search for…'),
|
||||||
|
widget=forms.TextInput(attrs={
|
||||||
|
'placeholder': _('Search for…'),
|
||||||
|
'autofocus': 'autofocus'
|
||||||
|
}),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
event = forms.ModelChoiceField(
|
||||||
|
label=_('Event'),
|
||||||
|
queryset=Event.objects.none(),
|
||||||
|
required=False,
|
||||||
|
widget=Select2(
|
||||||
|
attrs={
|
||||||
|
'data-model-select2': 'event',
|
||||||
|
'data-select2-url': reverse_lazy('control:events.typeahead'),
|
||||||
|
'data-placeholder': _('All events')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
organizer = forms.ModelChoiceField(
|
||||||
|
label=_('Organizer'),
|
||||||
|
queryset=Organizer.objects.none(),
|
||||||
|
required=False,
|
||||||
|
empty_label=_('All organizers'),
|
||||||
|
widget=Select2(
|
||||||
|
attrs={
|
||||||
|
'data-model-select2': 'generic',
|
||||||
|
'data-select2-url': reverse_lazy('control:organizers.select2'),
|
||||||
|
'data-placeholder': _('All organizers')
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
state = forms.ChoiceField(
|
||||||
|
label=_('Status'),
|
||||||
|
required=False,
|
||||||
|
choices=[('', _('All payments'))] + list(OrderPayment.PAYMENT_STATES),
|
||||||
|
)
|
||||||
|
provider = forms.ChoiceField(
|
||||||
|
label=_('Payment provider'),
|
||||||
|
choices=[
|
||||||
|
('', _('All payment providers')),
|
||||||
|
],
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
created_from = forms.DateField(
|
||||||
|
label=_('Payment created from'),
|
||||||
|
required=False,
|
||||||
|
widget=DatePickerWidget,
|
||||||
|
)
|
||||||
|
created_until = forms.DateField(
|
||||||
|
label=_('Payment created until'),
|
||||||
|
required=False,
|
||||||
|
widget=DatePickerWidget,
|
||||||
|
)
|
||||||
|
completed_from = forms.DateField(
|
||||||
|
label=_('Paid from'),
|
||||||
|
required=False,
|
||||||
|
widget=DatePickerWidget,
|
||||||
|
)
|
||||||
|
completed_until = forms.DateField(
|
||||||
|
label=_('Paid until'),
|
||||||
|
required=False,
|
||||||
|
widget=DatePickerWidget,
|
||||||
|
)
|
||||||
|
amount = forms.CharField(
|
||||||
|
label=_('Amount'),
|
||||||
|
required=False,
|
||||||
|
widget=forms.NumberInput(attrs={
|
||||||
|
'placeholder': _('Amount'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.request = kwargs.pop('request')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields['ordering'] = forms.ChoiceField(
|
||||||
|
choices=sum([
|
||||||
|
[(a, a), ('-' + a, '-' + a)]
|
||||||
|
for a in self.orders.keys()
|
||||||
|
], []),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.request.user.has_active_staff_session(self.request.session.session_key):
|
||||||
|
self.fields['organizer'].queryset = Organizer.objects.all()
|
||||||
|
self.fields['event'].queryset = Event.objects.all()
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.fields['organizer'].queryset = Organizer.objects.filter(
|
||||||
|
pk__in=self.request.user.teams.values_list('organizer', flat=True)
|
||||||
|
)
|
||||||
|
self.fields['event'].queryset = self.request.user.get_events_with_permission('can_view_orders')
|
||||||
|
|
||||||
|
self.fields['provider'].choices += get_all_payment_providers()
|
||||||
|
|
||||||
|
def filter_qs(self, qs):
|
||||||
|
fdata = self.cleaned_data
|
||||||
|
|
||||||
|
if fdata.get('created_from'):
|
||||||
|
date_start = make_aware(datetime.combine(
|
||||||
|
fdata.get('created_from'),
|
||||||
|
time(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
), get_current_timezone())
|
||||||
|
qs = qs.filter(created__gte=date_start)
|
||||||
|
|
||||||
|
if fdata.get('created_until'):
|
||||||
|
date_end = make_aware(datetime.combine(
|
||||||
|
fdata.get('created_until') + timedelta(days=1),
|
||||||
|
time(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
), get_current_timezone())
|
||||||
|
qs = qs.filter(created__lt=date_end)
|
||||||
|
|
||||||
|
if fdata.get('completed_from'):
|
||||||
|
date_start = make_aware(datetime.combine(
|
||||||
|
fdata.get('completed_from'),
|
||||||
|
time(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
), get_current_timezone())
|
||||||
|
qs = qs.filter(payment_date__gte=date_start)
|
||||||
|
|
||||||
|
if fdata.get('completed_until'):
|
||||||
|
date_end = make_aware(datetime.combine(
|
||||||
|
fdata.get('completed_until') + timedelta(days=1),
|
||||||
|
time(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
), get_current_timezone())
|
||||||
|
qs = qs.filter(payment_date__lt=date_end)
|
||||||
|
|
||||||
|
if fdata.get('event'):
|
||||||
|
qs = qs.filter(order__event=fdata.get('event'))
|
||||||
|
|
||||||
|
if fdata.get('organizer'):
|
||||||
|
qs = qs.filter(order__event__organizer=fdata.get('organizer'))
|
||||||
|
|
||||||
|
if fdata.get('state'):
|
||||||
|
qs = qs.filter(state=fdata.get('state'))
|
||||||
|
|
||||||
|
if fdata.get('provider'):
|
||||||
|
qs = qs.filter(provider=fdata.get('provider'))
|
||||||
|
|
||||||
|
if fdata.get('query'):
|
||||||
|
u = fdata.get('query')
|
||||||
|
|
||||||
|
matching_invoices = Invoice.objects.filter(
|
||||||
|
Q(invoice_no__iexact=u)
|
||||||
|
| Q(invoice_no__iexact=u.zfill(5))
|
||||||
|
| Q(full_invoice_no__iexact=u)
|
||||||
|
).values_list('order_id', flat=True)
|
||||||
|
|
||||||
|
matching_invoice_addresses = InvoiceAddress.objects.filter(
|
||||||
|
Q(
|
||||||
|
Q(name_cached__icontains=u) | Q(company__icontains=u)
|
||||||
|
)
|
||||||
|
).values_list('order_id', flat=True)
|
||||||
|
|
||||||
|
if "-" in u:
|
||||||
|
code = (Q(event__slug__icontains=u.rsplit("-", 1)[0])
|
||||||
|
& Q(code__icontains=Order.normalize_code(u.rsplit("-", 1)[1])))
|
||||||
|
else:
|
||||||
|
code = Q(code__icontains=Order.normalize_code(u))
|
||||||
|
|
||||||
|
matching_orders = Order.objects.filter(
|
||||||
|
Q(
|
||||||
|
code
|
||||||
|
| Q(email__icontains=u)
|
||||||
|
| Q(comment__icontains=u)
|
||||||
|
)
|
||||||
|
).values_list('id', flat=True)
|
||||||
|
|
||||||
|
mainq = (
|
||||||
|
Q(order__id__in=matching_invoices)
|
||||||
|
| Q(order__id__in=matching_invoice_addresses)
|
||||||
|
| Q(order__id__in=matching_orders)
|
||||||
|
)
|
||||||
|
|
||||||
|
qs = qs.filter(mainq)
|
||||||
|
|
||||||
|
if fdata.get('amount'):
|
||||||
|
amount = fdata.get('amount')
|
||||||
|
|
||||||
|
def is_decimal(value):
|
||||||
|
result = True
|
||||||
|
parts = value.split('.', maxsplit=1)
|
||||||
|
for part in parts:
|
||||||
|
result = result & part.isdecimal()
|
||||||
|
return result
|
||||||
|
|
||||||
|
if is_decimal(amount):
|
||||||
|
qs = qs.filter(amount=Decimal(amount))
|
||||||
|
|
||||||
|
if fdata.get('ordering'):
|
||||||
|
p = self.cleaned_data.get('ordering')
|
||||||
|
if p.startswith('-') and p not in self.orders:
|
||||||
|
qs = qs.order_by('-' + self.orders[p[1:]])
|
||||||
|
else:
|
||||||
|
qs = qs.order_by(self.orders[p])
|
||||||
|
else:
|
||||||
|
qs = qs.order_by('-created')
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class SubEventFilterForm(FilterForm):
|
class SubEventFilterForm(FilterForm):
|
||||||
orders = {
|
orders = {
|
||||||
'date_from': 'date_from',
|
'date_from': 'date_from',
|
||||||
@@ -2018,6 +2225,15 @@ class DeviceFilterForm(FilterForm):
|
|||||||
],
|
],
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
state = forms.ChoiceField(
|
||||||
|
label=_('Device status'),
|
||||||
|
choices=[
|
||||||
|
('', _('All devices')),
|
||||||
|
('active', _('Active devices')),
|
||||||
|
('revoked', _('Revoked devices'))
|
||||||
|
],
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
request = kwargs.pop('request')
|
request = kwargs.pop('request')
|
||||||
@@ -2047,6 +2263,11 @@ class DeviceFilterForm(FilterForm):
|
|||||||
if fdata.get('gate'):
|
if fdata.get('gate'):
|
||||||
qs = qs.filter(gate=fdata['gate'])
|
qs = qs.filter(gate=fdata['gate'])
|
||||||
|
|
||||||
|
if fdata.get('state') == 'active':
|
||||||
|
qs = qs.filter(revoked=False)
|
||||||
|
elif fdata.get('state') == 'revoked':
|
||||||
|
qs = qs.filter(revoked=True)
|
||||||
|
|
||||||
if fdata.get('ordering'):
|
if fdata.get('ordering'):
|
||||||
qs = qs.order_by(self.get_order_by())
|
qs = qs.order_by(self.get_order_by())
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -713,6 +713,7 @@ class ItemVariationForm(I18nModelForm):
|
|||||||
'default_price',
|
'default_price',
|
||||||
'original_price',
|
'original_price',
|
||||||
'description',
|
'description',
|
||||||
|
'require_approval',
|
||||||
'require_membership',
|
'require_membership',
|
||||||
'require_membership_hidden',
|
'require_membership_hidden',
|
||||||
'require_membership_types',
|
'require_membership_types',
|
||||||
|
|||||||
129
src/pretix/control/forms/mailsetup.py
Normal file
129
src/pretix/control/forms/mailsetup.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from pretix.base.forms import SecretKeySettingsField, SettingsForm
|
||||||
|
|
||||||
|
|
||||||
|
class SMTPMailForm(SettingsForm):
|
||||||
|
mail_from = forms.EmailField(
|
||||||
|
label=_("Sender address"),
|
||||||
|
help_text=_("Sender address for outgoing emails"),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
smtp_host = forms.CharField(
|
||||||
|
label=_("Hostname"),
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
|
||||||
|
)
|
||||||
|
smtp_port = forms.IntegerField(
|
||||||
|
label=_("Port"),
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
|
||||||
|
)
|
||||||
|
smtp_username = forms.CharField(
|
||||||
|
label=_("Username"),
|
||||||
|
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
smtp_password = SecretKeySettingsField(
|
||||||
|
label=_("Password"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
smtp_use_tls = forms.BooleanField(
|
||||||
|
label=_("Use STARTTLS"),
|
||||||
|
help_text=_("Commonly enabled on port 587."),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
smtp_use_ssl = forms.BooleanField(
|
||||||
|
label=_("Use SSL"),
|
||||||
|
help_text=_("Commonly enabled on port 465."),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
data = super().clean()
|
||||||
|
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
|
||||||
|
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
|
||||||
|
for k, v in self.fields.items():
|
||||||
|
val = data.get(k)
|
||||||
|
if v._required and not val:
|
||||||
|
self.add_error(k, _('This field is required.'))
|
||||||
|
return data
|
||||||
|
|
||||||
|
def clean_smtp_host(self):
|
||||||
|
v = self.cleaned_data['smtp_host']
|
||||||
|
if not settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS:
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(v).is_private:
|
||||||
|
raise ValidationError(_('You are not allowed to use this mail server, please choose one with a '
|
||||||
|
'public IP address instead.'))
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(socket.gethostbyname(v)).is_private:
|
||||||
|
raise ValidationError(_('You are not allowed to use this mail server, please choose one with a '
|
||||||
|
'public IP address instead.'))
|
||||||
|
except OSError:
|
||||||
|
raise ValidationError(_('We were unable to resolve this hostname.'))
|
||||||
|
return v
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self.obj.settings.mail_from in (settings.MAIL_FROM, settings.MAIL_FROM_ORGANIZERS):
|
||||||
|
self.initial.pop('mail_from')
|
||||||
|
|
||||||
|
for k, v in self.fields.items():
|
||||||
|
v._required = v.required
|
||||||
|
v.required = False
|
||||||
|
v.widget.is_required = False
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleMailForm(SettingsForm):
|
||||||
|
mail_from = forms.EmailField(
|
||||||
|
label=_("Sender address"),
|
||||||
|
help_text=_("Sender address for outgoing emails"),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
for k, v in self.fields.items():
|
||||||
|
val = cleaned_data.get(k)
|
||||||
|
if v._required and not val:
|
||||||
|
self.add_error(k, _('This field is required.'))
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self.obj.settings.mail_from in (settings.MAIL_FROM, settings.MAIL_FROM_ORGANIZERS):
|
||||||
|
self.initial.pop('mail_from')
|
||||||
|
|
||||||
|
for k, v in self.fields.items():
|
||||||
|
v._required = v.required
|
||||||
|
v.required = False
|
||||||
|
v.widget.is_required = False
|
||||||
@@ -452,7 +452,7 @@ class OrderPositionChangeForm(forms.Form):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def taxrule_label_from_instance(obj):
|
def taxrule_label_from_instance(obj):
|
||||||
return f"{obj.name} ({obj.rate} %)"
|
return f"{obj.internal_name or obj.name} ({obj.rate} %)"
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
instance = kwargs.pop('instance')
|
instance = kwargs.pop('instance')
|
||||||
|
|||||||
@@ -44,21 +44,23 @@ from django.utils.safestring import mark_safe
|
|||||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||||
|
from phonenumber_field.formfields import PhoneNumberField
|
||||||
from pytz import common_timezones
|
from pytz import common_timezones
|
||||||
|
|
||||||
from pretix.api.models import WebHook
|
from pretix.api.models import WebHook
|
||||||
from pretix.api.webhooks import get_all_webhook_events
|
from pretix.api.webhooks import get_all_webhook_events
|
||||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
||||||
from pretix.base.forms.questions import NamePartsFormField
|
from pretix.base.forms.questions import (
|
||||||
|
NamePartsFormField, WrappedPhoneNumberPrefixWidget, get_country_by_locale,
|
||||||
|
get_phone_prefix,
|
||||||
|
)
|
||||||
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
|
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
|
||||||
MembershipType, Organizer, Team,
|
MembershipType, Organizer, Team,
|
||||||
)
|
)
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
|
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
|
||||||
from pretix.control.forms import (
|
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||||
ExtFileField, SMTPSettingsMixin, SplitDateTimeField,
|
|
||||||
)
|
|
||||||
from pretix.control.forms.event import (
|
from pretix.control.forms.event import (
|
||||||
SafeEventMultipleChoiceField, multimail_validate,
|
SafeEventMultipleChoiceField, multimail_validate,
|
||||||
)
|
)
|
||||||
@@ -354,9 +356,8 @@ class OrganizerSettingsForm(SettingsForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
|
class MailSettingsForm(SettingsForm):
|
||||||
auto_fields = [
|
auto_fields = [
|
||||||
'mail_from',
|
|
||||||
'mail_from_name',
|
'mail_from_name',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -535,11 +536,21 @@ class CustomerUpdateForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Customer
|
model = Customer
|
||||||
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'locale']
|
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'phone', 'locale']
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
if not self.instance.phone and (self.instance.organizer.settings.region or self.instance.locale):
|
||||||
|
country_code = self.instance.organizer.settings.region or get_country_by_locale(self.instance.locale)
|
||||||
|
phone_prefix = get_phone_prefix(country_code)
|
||||||
|
if phone_prefix:
|
||||||
|
self.initial['phone'] = "+{}.".format(phone_prefix)
|
||||||
|
|
||||||
|
self.fields['phone'] = PhoneNumberField(
|
||||||
|
label=_('Phone'),
|
||||||
|
required=False,
|
||||||
|
widget=WrappedPhoneNumberPrefixWidget()
|
||||||
|
)
|
||||||
self.fields['name_parts'] = NamePartsFormField(
|
self.fields['name_parts'] = NamePartsFormField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
required=False,
|
required=False,
|
||||||
@@ -565,6 +576,13 @@ class CustomerUpdateForm(forms.ModelForm):
|
|||||||
return self.cleaned_data
|
return self.cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerCreateForm(CustomerUpdateForm):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Customer
|
||||||
|
fields = ['identifier', 'is_active', 'name_parts', 'email', 'is_verified', 'locale']
|
||||||
|
|
||||||
|
|
||||||
class MembershipUpdateForm(forms.ModelForm):
|
class MembershipUpdateForm(forms.ModelForm):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ from django.urls import get_script_prefix, resolve, reverse
|
|||||||
from django.utils.encoding import force_str
|
from django.utils.encoding import force_str
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django_scopes import scope
|
from django_scopes import scope
|
||||||
from hijack.templatetags.hijack_tags import is_hijacked
|
|
||||||
|
|
||||||
from pretix.base.models import Event, Organizer
|
from pretix.base.models import Event, Organizer
|
||||||
from pretix.base.models.auth import SuperuserPermissionSet, User
|
from pretix.base.models.auth import SuperuserPermissionSet, User
|
||||||
@@ -183,7 +182,7 @@ class AuditLogMiddleware:
|
|||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
if request.path.startswith(get_script_prefix() + 'control') and request.user.is_authenticated:
|
if request.path.startswith(get_script_prefix() + 'control') and request.user.is_authenticated:
|
||||||
if is_hijacked(request):
|
if getattr(request.user, "is_hijacked", False):
|
||||||
hijack_history = request.session.get('hijack_history', False)
|
hijack_history = request.session.get('hijack_history', False)
|
||||||
hijacker = get_object_or_404(User, pk=hijack_history[0])
|
hijacker = get_object_or_404(User, pk=hijack_history[0])
|
||||||
ss = hijacker.get_active_staff_session(request.session.get('hijacker_session'))
|
ss = hijacker.get_active_staff_session(request.session.get('hijacker_session'))
|
||||||
|
|||||||
@@ -343,10 +343,24 @@ def get_global_navigation(request):
|
|||||||
'icon': 'group',
|
'icon': 'group',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': _('Order search'),
|
'label': _('Search'),
|
||||||
'url': reverse('control:search.orders'),
|
'url': reverse('control:search.orders'),
|
||||||
'active': 'search.orders' in url.url_name,
|
'active': False,
|
||||||
'icon': 'search',
|
'icon': 'search',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'label': _('Orders'),
|
||||||
|
'url': reverse('control:search.orders'),
|
||||||
|
'active': 'search.orders' in url.url_name,
|
||||||
|
'icon': 'search',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': _('Payments'),
|
||||||
|
'url': reverse('control:search.payments'),
|
||||||
|
'active': 'search.payments' in url.url_name,
|
||||||
|
'icon': 'search',
|
||||||
|
},
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'label': _('User settings'),
|
'label': _('User settings'),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{% load compress %}
|
{% load compress %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load hijack_tags %}
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html{% if rtl %} dir="rtl" class="rtl"{% endif %}>
|
<html{% if rtl %} dir="rtl" class="rtl"{% endif %}>
|
||||||
@@ -39,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request|is_hijacked %}
|
{% if request.user.is_hijacked %}
|
||||||
<div class="impersonate-warning">
|
<div class="impersonate-warning">
|
||||||
<span class="fa fa-user-secret"></span>
|
<span class="fa fa-user-secret"></span>
|
||||||
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}
|
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{% load compress %}
|
{% load compress %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load hijack_tags %}
|
|
||||||
{% load statici18n %}
|
{% load statici18n %}
|
||||||
{% load eventsignal %}
|
{% load eventsignal %}
|
||||||
{% load eventurl %}
|
{% load eventurl %}
|
||||||
@@ -351,7 +350,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request|is_hijacked %}
|
{% if request.user.is_hijacked %}
|
||||||
<div class="impersonate-warning">
|
<div class="impersonate-warning">
|
||||||
<span class="fa fa-user-secret"></span>
|
<span class="fa fa-user-secret"></span>
|
||||||
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}
|
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}
|
||||||
|
|||||||
@@ -74,17 +74,17 @@
|
|||||||
{{ c.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
{{ c.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
{% if c.type == "exit" %}
|
{% if c.type == "exit" %}
|
||||||
{% if c.auto_checked_in %}
|
{% if c.auto_checked_in %}
|
||||||
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip_html"
|
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip"
|
||||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
|
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif c.forced and c.successful %}
|
{% elif c.forced and c.successful %}
|
||||||
<span class="fa fa-fw fa-warning" data-toggle="tooltip_html"
|
<span class="fa fa-fw fa-warning" data-toggle="tooltip"
|
||||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}"></span>
|
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}"></span>
|
||||||
{% elif c.forced and not c.successful %}
|
{% elif c.forced and not c.successful %}
|
||||||
<br>
|
<br>
|
||||||
<small class="text-muted">{% trans "Failed in offline mode" %}</small>
|
<small class="text-muted">{% trans "Failed in offline mode" %}</small>
|
||||||
{% elif c.auto_checked_in %}
|
{% elif c.auto_checked_in %}
|
||||||
<span class="fa fa-fw fa-magic" data-toggle="tooltip_html"
|
<span class="fa fa-fw fa-magic" data-toggle="tooltip"
|
||||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}"></span>
|
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% load i18n %}{% blocktrans with code=code instance=instance %}Hello,
|
||||||
|
|
||||||
|
someone requested to use {{ address }} as a sender address on {{ instance }}.
|
||||||
|
This will allow them to send emails that are shown to originate from this email address.
|
||||||
|
If that was you, please enter the following confirmation code:
|
||||||
|
|
||||||
|
{{ code }}
|
||||||
|
|
||||||
|
If this was not requested by you, you can safely ignore this email.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
|
||||||
|
Your {{ instance }} team
|
||||||
|
{% endblocktrans %}
|
||||||
127
src/pretix/control/templates/pretixcontrol/email_setup.html
Normal file
127
src/pretix/control/templates/pretixcontrol/email_setup.html
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends basetpl %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load hierarkey_form %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% trans "E-mail sending" %}</h1>
|
||||||
|
<form action="" method="post" class="form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="panel-group" id="email">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="accordion-radio">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<p class="panel-title">
|
||||||
|
<input type="radio" name="mode" value="system"
|
||||||
|
data-parent="#email"
|
||||||
|
{% if mode == "system" %}checked="checked"{% endif %}
|
||||||
|
id="input_mode_system"
|
||||||
|
data-toggle="radiocollapse" data-target="#mode_system"/>
|
||||||
|
<label for="input_mode_system"><strong>{% trans "Use system default" %}</strong></label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mode_system"
|
||||||
|
class="panel-collapse collapsed {% if mode == "system" %}in{% endif %}">
|
||||||
|
<div class="panel-body form-horizontal">
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
E-mails will be sent through the system's default server. They will show the following
|
||||||
|
sender information:
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
<dt>{% trans "From" context "mail_header" %}</dt>
|
||||||
|
<dd>{{ object.settings.mail_from_name|default_if_none:object.name }}
|
||||||
|
<{{ default_sender_address }}>
|
||||||
|
</dd>
|
||||||
|
{% if object.settings.contact_mail %}
|
||||||
|
<dt>{% trans "Reply-To" context "mail_header" %}</dt>
|
||||||
|
<dd>{{ object.settings.contact_mail }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="accordion-radio">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<p class="panel-title">
|
||||||
|
<input type="radio" name="mode" value="simple"
|
||||||
|
data-parent="#email"
|
||||||
|
{% if mode == "simple" %}checked="checked"{% endif %}
|
||||||
|
id="input_mode_simple"
|
||||||
|
data-toggle="radiocollapse" data-target="#mode_simple"/>
|
||||||
|
<label for="input_mode_simple"><strong>{% trans "Use system email server with a custom sender address" %}</strong></label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mode_simple"
|
||||||
|
class="panel-collapse collapsed {% if mode == "simple" %}in{% endif %}">
|
||||||
|
<div class="panel-body form-horizontal">
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
E-mails will be sent through the system's default server but with your own sender
|
||||||
|
address.
|
||||||
|
This will make your emails look more personalized and coming directly from you, but it
|
||||||
|
also might require some extra steps to ensure good deliverability.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
{% bootstrap_form simple_form layout="control" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="accordion-radio">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<p class="panel-title">
|
||||||
|
<input type="radio" name="mode" value="smtp"
|
||||||
|
data-parent="#email"
|
||||||
|
{% if mode == "smtp" %}checked="checked"{% endif %}
|
||||||
|
id="input_mode_smtp"
|
||||||
|
data-toggle="radiocollapse" data-target="#mode_smtp"/>
|
||||||
|
<label for="input_mode_smtp"><strong>{% trans "Use a custom SMTP server" %}</strong></label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mode_smtp"
|
||||||
|
class="panel-collapse collapsed {% if mode == "smtp" %}in{% endif %}">
|
||||||
|
<div class="panel-body form-horizontal">
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
For full customization, you can configure your own SMTP server that will be used for
|
||||||
|
email sending.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
{% bootstrap_form smtp_form layout="control" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if request.event %}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="accordion-radio">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<p class="panel-title">
|
||||||
|
<input type="radio" name="mode" value="reset"
|
||||||
|
data-parent="#reset"
|
||||||
|
id="input_mode_reset"
|
||||||
|
data-toggle="radiocollapse" data-target="#mode_reset"/>
|
||||||
|
<label for="input_mode_reset"><strong>{% trans "Reset to organizer settings" %}</strong></label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mode_reset"
|
||||||
|
class="panel-collapse collapsed {% if mode == "reset" %}in{% endif %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
|
{% trans "Continue" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{% extends basetpl %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load hierarkey_form %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% trans "E-mail sending" %}</h1>
|
||||||
|
<form action="" method="post" class="form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for k, v in request.POST.items %}
|
||||||
|
<input type="hidden" name="{{ k }}" value="{{ v }}">
|
||||||
|
{% endfor %}
|
||||||
|
<input type="hidden" name="state" value="save">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<p class="panel-title">
|
||||||
|
<strong>{% trans "Use system email server with a custom sender address" %}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body form-horizontal">
|
||||||
|
{% if spf_warning %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<p>
|
||||||
|
{{ spf_warning }}
|
||||||
|
</p>
|
||||||
|
{% if spf_record %}
|
||||||
|
<p>
|
||||||
|
{% trans "This is the SPF record we found on your domain:" %}
|
||||||
|
</p>
|
||||||
|
<pre><code>{{ spf_record }}</code></pre>
|
||||||
|
<p>
|
||||||
|
{% trans "To fix this, include the following part before the last word:" %}
|
||||||
|
</p>
|
||||||
|
<pre><code>{{ spf_key }}</code></pre>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
{% trans "Your new SPF record could look like this:" %}
|
||||||
|
</p>
|
||||||
|
<pre><code>v=spf1 a mx {{ spf_key }} ~all</code></pre>
|
||||||
|
{% endif %}
|
||||||
|
<p>
|
||||||
|
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% elif spf_key %}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
We found an SPF record on your domain that includes this system. Great!
|
||||||
|
{% endblocktrans %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if verification %}
|
||||||
|
<h3>{% trans "Verification" %}</h3>
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed with recp=recp %}
|
||||||
|
We've sent an email to {{ recp }} with a confirmation code to verify that this email address
|
||||||
|
is owned by you. Please enter the verification code below:
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label" for="id_verification">
|
||||||
|
{% trans "Verification code" %}
|
||||||
|
</label>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<input type="text" name="verification" class="form-control">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends basetpl %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load hierarkey_form %}
|
||||||
|
{% load static %}
|
||||||
|
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% trans "E-mail sending" %}</h1>
|
||||||
|
<form action="" method="post" class="form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for k, v in request.POST.items %}
|
||||||
|
<input type="hidden" name="{{ k }}" value="{{ v }}">
|
||||||
|
{% endfor %}
|
||||||
|
<input type="hidden" name="state" value="save">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<p class="panel-title">
|
||||||
|
<strong>{% trans "Use a custom SMTP server" %}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body form-horizontal">
|
||||||
|
<div class="alert alert-success">
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
A test connection to your SMTP server was successful. You can now save your new settings
|
||||||
|
to put them in use.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</div>
|
||||||
|
{% if known_host_problem %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{{ known_host_problem }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -12,16 +12,49 @@
|
|||||||
<div class="tabbed-form">
|
<div class="tabbed-form">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "General" %}</legend>
|
<legend>{% trans "General" %}</legend>
|
||||||
|
{% bootstrap_field form.mail_prefix layout="control" %}
|
||||||
|
{% bootstrap_field form.mail_attach_tickets layout="control" %}
|
||||||
|
{% bootstrap_field form.mail_attach_ical layout="control" %}
|
||||||
{% url "control:organizer.settings.mail" organizer=request.organizer.slug as org_url %}
|
{% url "control:organizer.settings.mail" organizer=request.organizer.slug as org_url %}
|
||||||
{% propagated request.event org_url "mail_from" "mail_from_name" "mail_text_signature" "mail_bcc" %}
|
{% propagated request.event org_url "mail_from" "smtp_use_custom" "smtp_host" "smtp_port" "smtp_username" "smtp_password" "smtp_use_tls" "smtp_use_ssl" %}
|
||||||
{% bootstrap_field form.mail_from layout="control" %}
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label">
|
||||||
|
{% trans "Sending method" %}
|
||||||
|
</label>
|
||||||
|
<div class="col-md-9 static-form-row-with-btn">
|
||||||
|
{% if request.event.settings.smtp_use_custom %}
|
||||||
|
{% trans "Custom SMTP server" %}: {{ request.event.settings.smtp_host }}
|
||||||
|
{% else %}
|
||||||
|
{% trans "System-provided email server" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{% url "control:event.settings.mail.setup" organizer=request.organizer.slug event=request.event.slug %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
<span class="fa fa-edit"></span>
|
||||||
|
{% trans "Edit" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label">
|
||||||
|
{% trans "Sender address" %}
|
||||||
|
</label>
|
||||||
|
<div class="col-md-9 static-form-row-with-btn">
|
||||||
|
{{ request.event.settings.mail_from }}
|
||||||
|
|
||||||
|
<a href="{% url "control:event.settings.mail.setup" organizer=request.organizer.slug event=request.event.slug %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
<span class="fa fa-edit"></span>
|
||||||
|
{% trans "Edit" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endpropagated %}
|
||||||
|
{% propagated request.event org_url "mail_from_name" "mail_text_signature" "mail_bcc" %}
|
||||||
{% bootstrap_field form.mail_from_name layout="control" %}
|
{% bootstrap_field form.mail_from_name layout="control" %}
|
||||||
{% bootstrap_field form.mail_text_signature layout="control" %}
|
{% bootstrap_field form.mail_text_signature layout="control" %}
|
||||||
{% bootstrap_field form.mail_bcc layout="control" %}
|
{% bootstrap_field form.mail_bcc layout="control" %}
|
||||||
{% endpropagated %}
|
{% endpropagated %}
|
||||||
{% bootstrap_field form.mail_prefix layout="control" %}
|
|
||||||
{% bootstrap_field form.mail_attach_tickets layout="control" %}
|
|
||||||
{% bootstrap_field form.mail_attach_ical layout="control" %}
|
|
||||||
{% bootstrap_field form.mail_sales_channel_placed_paid layout="control" %}
|
{% bootstrap_field form.mail_sales_channel_placed_paid layout="control" %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -47,6 +80,7 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "E-mail content" %}</legend>
|
<legend>{% trans "E-mail content" %}</legend>
|
||||||
|
<h4>{% trans "Text" %}</h4>
|
||||||
<div class="panel-group" id="questions_group">
|
<div class="panel-group" id="questions_group">
|
||||||
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
|
{% 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_text_order_placed,mail_send_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
|
||||||
@@ -81,27 +115,14 @@
|
|||||||
{% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %}
|
{% 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_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_approved_free,mail_text_order_denied" %}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
<h4>{% trans "Attachments" %}</h4>
|
||||||
<fieldset>
|
{% bootstrap_field form.mail_attachment_new_order layout="control" %}
|
||||||
<legend>{% trans "SMTP settings" %}</legend>
|
|
||||||
{% propagated request.event org_url "smtp_use_custom" "smtp_host" "smtp_port" "smtp_username" "smtp_password" "smtp_use_tls" "smtp_use_ssl" %}
|
|
||||||
{% bootstrap_field form.smtp_use_custom layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_host layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_port layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_username layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_password layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_use_tls layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_use_ssl layout="control" %}
|
|
||||||
{% endpropagated %}
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group submit-group">
|
<div class="form-group submit-group">
|
||||||
<button type="submit" class="btn btn-primary btn-save">
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
{% trans "Save" %}
|
{% trans "Save" %}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-default btn-save pull-left" name="test" value="1">
|
|
||||||
{% trans "Save and test custom SMTP connection" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "General" %}</legend>
|
<legend>{% trans "General" %}</legend>
|
||||||
{% bootstrap_field form.name layout="control" %}
|
{% bootstrap_field form.name layout="control" %}
|
||||||
|
{% bootstrap_field form.internal_name layout="control" %}
|
||||||
{% bootstrap_field form.rate addon_after="%" layout="control" %}
|
{% bootstrap_field form.rate addon_after="%" layout="control" %}
|
||||||
{% bootstrap_field form.price_includes_tax layout="control" %}
|
{% bootstrap_field form.price_includes_tax layout="control" %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% bootstrap_field form.eu_reverse_charge layout="control" %}
|
{% bootstrap_field form.eu_reverse_charge layout="control" %}
|
||||||
{% bootstrap_field form.home_country layout="control" %}
|
{% bootstrap_field form.home_country layout="control" %}
|
||||||
|
{% bootstrap_field form.keep_gross_if_rate_changes layout="control" %}
|
||||||
<h3>{% trans "Custom taxation rules" %}</h3>
|
<h3>{% trans "Custom taxation rules" %}</h3>
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
{% blocktrans trimmed %}
|
{% blocktrans trimmed %}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<strong><a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
|
<strong><a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
|
||||||
{{ tr.name }}
|
{{ tr.internal_name|default:tr.name }}
|
||||||
</a></strong>
|
</a></strong>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<div class="quotabox availability" data-toggle="tooltip_html" data-placement="top"
|
<div class="quotabox availability" data-toggle="tooltip_html" data-placement="top"
|
||||||
title="{% trans "Quota:" %} {{ q.name }}<br>{% blocktrans with date=q.cached_availability_time|date:"SHORT_DATETIME_FORMAT" %}Numbers as of {{ date }}{% endblocktrans %}">
|
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}<br>{% blocktrans with date=q.cached_availability_time|date:"SHORT_DATETIME_FORMAT" %}Numbers as of {{ date }}{% endblocktrans %}">
|
||||||
{% if q.size|default_if_none:"NONE" == "NONE" %}
|
{% if q.size|default_if_none:"NONE" == "NONE" %}
|
||||||
<div class="progress">
|
<div class="progress">
|
||||||
<div class="progress-bar progress-bar-success progress-bar-100">
|
<div class="progress-bar progress-bar-success progress-bar-100">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<a class="quotabox" data-toggle="tooltip_html" data-placement="top"
|
<a class="quotabox" data-toggle="tooltip_html" data-placement="top"
|
||||||
title="{% trans "Quota:" %} {{ q.name }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
|
title="{% trans "Quota:" %} {{ q.name|force_escape|force_escape }}{% if q.cached_avail.1 is not None %}<br>{% blocktrans with num=q.cached_avail.1 %}Currently available: {{ num }}{% endblocktrans %}{% endif %}"
|
||||||
href="{% url "control:event.items.quotas.show" event=q.event.slug organizer=q.event.organizer.slug quota=q.pk %}">
|
href="{% url "control:event.items.quotas.show" event=q.event.slug organizer=q.event.organizer.slug quota=q.pk %}">
|
||||||
{% if q.size|default_if_none:"NONE" == "NONE" %}
|
{% if q.size|default_if_none:"NONE" == "NONE" %}
|
||||||
<div class="progress">
|
<div class="progress">
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
{% bootstrap_field form.available_until layout="control" %}
|
{% bootstrap_field form.available_until layout="control" %}
|
||||||
{% bootstrap_field form.sales_channels layout="control" %}
|
{% bootstrap_field form.sales_channels layout="control" %}
|
||||||
{% bootstrap_field form.hide_without_voucher layout="control" %}
|
{% bootstrap_field form.hide_without_voucher layout="control" %}
|
||||||
|
{% bootstrap_field form.require_approval layout="control" %}
|
||||||
{% if form.require_membership %}
|
{% if form.require_membership %}
|
||||||
{% bootstrap_field form.require_membership layout="control" %}
|
{% bootstrap_field form.require_membership layout="control" %}
|
||||||
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
|
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
|
||||||
@@ -144,6 +145,7 @@
|
|||||||
{% bootstrap_field formset.empty_form.available_until layout="control" %}
|
{% bootstrap_field formset.empty_form.available_until layout="control" %}
|
||||||
{% bootstrap_field formset.empty_form.sales_channels layout="control" %}
|
{% bootstrap_field formset.empty_form.sales_channels layout="control" %}
|
||||||
{% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %}
|
{% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %}
|
||||||
|
{% bootstrap_field formset.empty_form.require_approval layout="control" %}
|
||||||
{% if formset.empty_form.require_membership %}
|
{% if formset.empty_form.require_membership %}
|
||||||
{% bootstrap_field formset.empty_form.require_membership layout="control" %}
|
{% bootstrap_field formset.empty_form.require_membership layout="control" %}
|
||||||
<div data-display-dependency="#{{ formset.empty_form.require_membership.id_for_label }}">
|
<div data-display-dependency="#{{ formset.empty_form.require_membership.id_for_label }}">
|
||||||
|
|||||||
@@ -145,7 +145,12 @@
|
|||||||
<strong>{% trans "Tax rule" %}</strong>
|
<strong>{% trans "Tax rule" %}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-5">
|
<div class="col-sm-5">
|
||||||
{{ position.tax_rule.name }} ({{ position.tax_rule.rate }} %)
|
{% if position.tax_rule.internal_name %}
|
||||||
|
{{ position.tax_rule.internal_name }}
|
||||||
|
{% else %}
|
||||||
|
{{ position.tax_rule.name }}
|
||||||
|
{% endif %}
|
||||||
|
({{ position.tax_rule.rate }} %)
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-4 field-container">
|
<div class="col-sm-4 field-container">
|
||||||
{% bootstrap_field position.form.tax_rule layout='inline' %}
|
{% bootstrap_field position.form.tax_rule layout='inline' %}
|
||||||
|
|||||||
@@ -360,19 +360,19 @@
|
|||||||
{% if line.checkins.all %}
|
{% if line.checkins.all %}
|
||||||
{% for c in line.all_checkins.all %}
|
{% for c in line.all_checkins.all %}
|
||||||
{% if not c.successful %}
|
{% if not c.successful %}
|
||||||
<span class="fa fa-fw fa-exclamation-circle text-danger" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Denied scan: {{ date }}{% endblocktrans %}<br>{{ c.get_error_reason_display }}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
<span class="fa fa-fw fa-exclamation-circle text-danger" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Denied scan: {{ date }}{% endblocktrans %}<br>{{ c.get_error_reason_display }}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||||
{% elif c.type == "exit" %}
|
{% elif c.type == "exit" %}
|
||||||
{% if c.auto_checked_in %}
|
{% if c.auto_checked_in %}
|
||||||
<span class="fa fa-fw text-success fa-hourglass-end" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
|
<span class="fa fa-fw text-success fa-hourglass-end" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="fa fa-fw text-success fa-sign-out" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Exit scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
<span class="fa fa-fw text-success fa-sign-out" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Exit scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif c.forced %}
|
{% elif c.forced %}
|
||||||
<span class="fa fa-fw fa-warning text-warning" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
<span class="fa fa-fw fa-warning text-warning" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||||
{% elif c.auto_checked_in %}
|
{% elif c.auto_checked_in %}
|
||||||
<span class="fa fa-fw fa-magic text-success" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
<span class="fa fa-fw fa-magic text-success" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="fa fa-fw fa-check text-success" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
<span class="fa fa-fw fa-check text-success" data-toggle="tooltip_html" title="{{ c.list.name|force_escape|force_escape }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Entry scan: {{ date }}{% endblocktrans %}{% if c.gate %}<br>{{ c.gate }}{% endif %}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
</dd>
|
</dd>
|
||||||
<dt>{% trans "Name" %}</dt>
|
<dt>{% trans "Name" %}</dt>
|
||||||
<dd>{{ customer.name }}</dd>
|
<dd>{{ customer.name }}</dd>
|
||||||
|
{% if customer.phone %}
|
||||||
|
<dt>{% trans "Phone" %}</dt>
|
||||||
|
<dd>{{ customer.phone }}</dd>
|
||||||
|
{% endif %}
|
||||||
<dt>{% trans "Locale" %}</dt>
|
<dt>{% trans "Locale" %}</dt>
|
||||||
<dd>{{ display_locale }}</dd>
|
<dd>{{ display_locale }}</dd>
|
||||||
<dt>{% trans "Registration date" %}</dt>
|
<dt>{% trans "Registration date" %}</dt>
|
||||||
|
|||||||
@@ -2,15 +2,23 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% blocktrans trimmed with id=customer.identifier %}
|
{% if not customer.id %}
|
||||||
Customer #{{ id }}
|
{% trans "New customer" %}
|
||||||
{% endblocktrans %}
|
{% else %}
|
||||||
{% endblock %}
|
|
||||||
{% block inner %}
|
|
||||||
<h1>
|
|
||||||
{% blocktrans trimmed with id=customer.identifier %}
|
{% blocktrans trimmed with id=customer.identifier %}
|
||||||
Customer #{{ id }}
|
Customer #{{ id }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block inner %}
|
||||||
|
<h1>
|
||||||
|
{% if not customer.id %}
|
||||||
|
{% trans "New customer" %}
|
||||||
|
{% else %}
|
||||||
|
{% blocktrans trimmed with id=customer.identifier %}
|
||||||
|
Customer #{{ id }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
<form class="form-horizontal" action="" method="post">
|
<form class="form-horizontal" action="" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
No customer accounts have been created yet.
|
No customer accounts have been created yet.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
|
<a href="{% url "control:organizer.customer.create" organizer=request.organizer.slug %}"
|
||||||
|
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new customer" %}</a>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
@@ -41,6 +43,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<p>
|
||||||
|
<a href="{% url "control:organizer.customer.create" organizer=request.organizer.slug %}"
|
||||||
|
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new customer" %}</a>
|
||||||
|
</p>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-condensed table-hover">
|
<table class="table table-condensed table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<form class="panel-body filter-form" action="" method="get">
|
<form class="panel-body filter-form" action="" method="get">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
<div class="col-md-4 col-sm-6 col-xs-12">
|
||||||
{% bootstrap_field filter_form.query %}
|
{% bootstrap_field filter_form.query %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||||
@@ -39,6 +39,9 @@
|
|||||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||||
{% bootstrap_field filter_form.software_brand %}
|
{% bootstrap_field filter_form.software_brand %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-2 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.state %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<button class="btn btn-primary btn-lg" type="submit">
|
<button class="btn btn-primary btn-lg" type="submit">
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<form class="" method="post" action="">
|
<form class="" method="post" action="">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="hidden">Add</button> <!-- Required because pressing enter in the text fields will submit the first button -->
|
||||||
<table class="panel-body table">
|
<table class="panel-body table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -101,7 +102,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-right form-inline">
|
<td class="text-right form-inline">
|
||||||
<input type="text" class="form-control input-sm" placeholder="{% trans "Value" %}" name="value">
|
<input type="text" class="form-control input-sm" placeholder="{% trans "Value" %}" name="value">
|
||||||
<button class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<span class="fa fa-plus"></span>
|
<span class="fa fa-plus"></span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -11,13 +11,45 @@
|
|||||||
<h1>{% trans "E-mail settings" %}</h1>
|
<h1>{% trans "E-mail settings" %}</h1>
|
||||||
|
|
||||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
|
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
|
||||||
mail-preview-url="{% url "control:organizer.settings.mail.preview" organizer=request.organizer.slug %}">
|
mail-preview-url="{% url "control:organizer.settings.mail.preview" organizer=request.organizer.slug %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% bootstrap_form_errors form %}
|
{% bootstrap_form_errors form %}
|
||||||
<div class="tabbed-form">
|
<div class="tabbed-form">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "General" %}</legend>
|
<legend>{% trans "General" %}</legend>
|
||||||
{% bootstrap_field form.mail_from layout="control" %}
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label">
|
||||||
|
{% trans "Sending method" %}
|
||||||
|
</label>
|
||||||
|
<div class="col-md-9 static-form-row-with-btn">
|
||||||
|
{% if request.organizer.settings.smtp_use_custom %}
|
||||||
|
{% trans "Custom SMTP server" %}: {{ request.organizer.settings.smtp_host }}
|
||||||
|
{% else %}
|
||||||
|
{% trans "System-provided email server" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{% url "control:organizer.settings.mail.setup" organizer=request.organizer.slug %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
<span class="fa fa-edit"></span>
|
||||||
|
{% trans "Edit" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label">
|
||||||
|
{% trans "Sender address" %}
|
||||||
|
</label>
|
||||||
|
<div class="col-md-9 static-form-row-with-btn">
|
||||||
|
{{ request.organizer.settings.mail_from }}
|
||||||
|
|
||||||
|
<a href="{% url "control:organizer.settings.mail.setup" organizer=request.organizer.slug %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
<span class="fa fa-edit"></span>
|
||||||
|
{% trans "Edit" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% bootstrap_field form.mail_from_name layout="control" %}
|
{% bootstrap_field form.mail_from_name layout="control" %}
|
||||||
{% bootstrap_field form.mail_text_signature layout="control" %}
|
{% bootstrap_field form.mail_text_signature layout="control" %}
|
||||||
{% bootstrap_field form.mail_bcc layout="control" %}
|
{% bootstrap_field form.mail_bcc layout="control" %}
|
||||||
@@ -35,24 +67,11 @@
|
|||||||
{% 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_text_customer_reset" %}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
|
||||||
<legend>{% trans "SMTP settings" %}</legend>
|
|
||||||
{% bootstrap_field form.smtp_use_custom layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_host layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_port layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_username layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_password layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_use_tls layout="control" %}
|
|
||||||
{% bootstrap_field form.smtp_use_ssl layout="control" %}
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group submit-group">
|
<div class="form-group submit-group">
|
||||||
<button type="submit" class="btn btn-primary btn-save">
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
{% trans "Save" %}
|
{% trans "Save" %}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-default btn-save pull-left" name="test" value="1">
|
|
||||||
{% trans "Save and test custom SMTP connection" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
164
src/pretix/control/templates/pretixcontrol/search/payments.html
Normal file
164
src/pretix/control/templates/pretixcontrol/search/payments.html
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{% extends "pretixcontrol/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load money %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% block title %}{% trans "Payment search" %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% trans "Payment search" %}</h1>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Filter" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<form class="panel-body filter-form" action="" method="get">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12 col-sm-12 col-xs-12">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-sm-12 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.query %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.completed_from %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.completed_until %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 col-sm-3 col-xs-4">
|
||||||
|
{% bootstrap_field filter_form.amount %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 col-sm-5 col-xs-8">
|
||||||
|
{% bootstrap_field filter_form.provider %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-sm-4 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.state %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 col-sm-12 col-xs-12">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.organizer %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.event %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.created_from %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.created_until %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right flip">
|
||||||
|
<button class="btn btn-primary btn-lg" type="submit">
|
||||||
|
<span class="fa fa-filter"></span>
|
||||||
|
{% trans "Filter" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-condensed table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
{% trans "Payment ID" %}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Order" %}
|
||||||
|
<a href="?{% url_replace request 'ordering' '-order' %}"><i class="fa fa-caret-down"></i></a>
|
||||||
|
<a href="?{% url_replace request 'ordering' 'order' %}"><i class="fa fa-caret-up"></i></a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Start date" %}
|
||||||
|
<a href="?{% url_replace request 'ordering' '-created' %}"><i class="fa fa-caret-down"></i></a>
|
||||||
|
<a href="?{% url_replace request 'ordering' 'created' %}"><i class="fa fa-caret-up"></i></a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Confirmation date" %}
|
||||||
|
<a href="?{% url_replace request 'ordering' '-payment_date' %}"><i class="fa fa-caret-down"></i></a>
|
||||||
|
<a href="?{% url_replace request 'ordering' 'payment_date' %}"><i class="fa fa-caret-up"></i></a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{% trans "Payment provider" %}
|
||||||
|
<a href="?{% url_replace request 'ordering' '-provider' %}"><i class="fa fa-caret-down"></i></a>
|
||||||
|
<a href="?{% url_replace request 'ordering' 'provider' %}"><i class="fa fa-caret-up"></i></a>
|
||||||
|
</th>
|
||||||
|
<th class="text-right flip">
|
||||||
|
{% trans "Amount" %}
|
||||||
|
<a href="?{% url_replace request 'ordering' '-amount' %}"><i class="fa fa-caret-down"></i></a>
|
||||||
|
<a href="?{% url_replace request 'ordering' 'amount' %}"><i class="fa fa-caret-up"></i></a>
|
||||||
|
</th>
|
||||||
|
<th class="text-right flip">
|
||||||
|
{% trans "Status" %}
|
||||||
|
<a href="?{% url_replace request 'ordering' '-state' %}"><i class="fa fa-caret-down"></i></a>
|
||||||
|
<a href="?{% url_replace request 'ordering' 'state' %}"><i class="fa fa-caret-up"></i></a>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in payments %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.full_id }}</td>
|
||||||
|
<td>
|
||||||
|
<strong>
|
||||||
|
<a href="{% url "control:event.order" event=p.order.event.slug organizer=p.order.event.organizer.slug code=p.order.code %}">
|
||||||
|
{{ p.order.full_code }}</a>
|
||||||
|
</strong>
|
||||||
|
{% if p.order.testmode %}
|
||||||
|
<span class="label label-warning">{% trans "TEST MODE" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if p.migrated %}
|
||||||
|
<span class="label label-default" data-toggle="tooltip"
|
||||||
|
title="{% trans "This payment was created with an older version of pretix, therefore accurate data might not be available." %}">
|
||||||
|
{% trans "MIGRATED" %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
{{ p.created|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ p.payment_date|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||||
|
<td>{{ p.payment_provider.verbose_name }}</td>
|
||||||
|
<td class="text-right flip">{{ p.amount|money:p.order.event.currency }}</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
<span class="label label-{% if p.state == "created" or p.state == "pending" %}warning{% elif p.state == "confirmed" %}success{% else %}danger{% endif %}">
|
||||||
|
{{ p.get_state_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if staff_session %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="1"></td>
|
||||||
|
<td colspan="6">
|
||||||
|
<a href="" class="btn btn-default btn-xs" data-expandpayment data-id="{{ p.pk }}">
|
||||||
|
<span class="fa-eye fa fa-fw"></span>
|
||||||
|
{% trans "Inspect" %}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center"><em>
|
||||||
|
{% trans "We couldn't find any payments that you have access to and that match your search query." %}
|
||||||
|
</em></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "pretixcontrol/pagination_huge.html" %}
|
||||||
|
{% endblock %}
|
||||||
@@ -112,6 +112,8 @@ urlpatterns = [
|
|||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
|
re_path(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/email$',
|
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/email$',
|
||||||
organizer.OrganizerMailSettings.as_view(), name='organizer.settings.mail'),
|
organizer.OrganizerMailSettings.as_view(), name='organizer.settings.mail'),
|
||||||
|
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/email/setup$',
|
||||||
|
organizer.MailSettingsSetup.as_view(), name='organizer.settings.mail.setup'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/email/preview$',
|
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/email/preview$',
|
||||||
organizer.MailSettingsPreview.as_view(), name='organizer.settings.mail.preview'),
|
organizer.MailSettingsPreview.as_view(), name='organizer.settings.mail.preview'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'),
|
re_path(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'),
|
||||||
@@ -133,6 +135,8 @@ urlpatterns = [
|
|||||||
name='organizer.membershiptype.delete'),
|
name='organizer.membershiptype.delete'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/customers$', organizer.CustomerListView.as_view(), name='organizer.customers'),
|
re_path(r'^organizer/(?P<organizer>[^/]+)/customers$', organizer.CustomerListView.as_view(), name='organizer.customers'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/customers/select2$', typeahead.customer_select2, name='organizer.customers.select2'),
|
re_path(r'^organizer/(?P<organizer>[^/]+)/customers/select2$', typeahead.customer_select2, name='organizer.customers.select2'),
|
||||||
|
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/add$',
|
||||||
|
organizer.CustomerCreateView.as_view(), name='organizer.customer.create'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/$',
|
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/$',
|
||||||
organizer.CustomerDetailView.as_view(), name='organizer.customer'),
|
organizer.CustomerDetailView.as_view(), name='organizer.customer'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/edit$',
|
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/edit$',
|
||||||
@@ -192,6 +196,7 @@ urlpatterns = [
|
|||||||
re_path(r'^events/typeahead/$', typeahead.event_list, name='events.typeahead'),
|
re_path(r'^events/typeahead/$', typeahead.event_list, name='events.typeahead'),
|
||||||
re_path(r'^events/typeahead/meta/$', typeahead.meta_values, name='events.meta.typeahead'),
|
re_path(r'^events/typeahead/meta/$', typeahead.meta_values, name='events.meta.typeahead'),
|
||||||
re_path(r'^search/orders/$', search.OrderSearch.as_view(), name='search.orders'),
|
re_path(r'^search/orders/$', search.OrderSearch.as_view(), name='search.orders'),
|
||||||
|
re_path(r'^search/payments/$', search.PaymentSearch.as_view(), name='search.payments'),
|
||||||
re_path(r'^event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include([
|
re_path(r'^event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include([
|
||||||
re_path(r'^$', dashboards.event_index, name='event.index'),
|
re_path(r'^$', dashboards.event_index, name='event.index'),
|
||||||
re_path(r'^widgets.json$', dashboards.event_index_widgets_lazy, name='event.index.widgets'),
|
re_path(r'^widgets.json$', dashboards.event_index_widgets_lazy, name='event.index.widgets'),
|
||||||
@@ -211,6 +216,7 @@ urlpatterns = [
|
|||||||
re_path(r'^settings/tickets/preview/(?P<output>[^/]+)$', event.TicketSettingsPreview.as_view(),
|
re_path(r'^settings/tickets/preview/(?P<output>[^/]+)$', event.TicketSettingsPreview.as_view(),
|
||||||
name='event.settings.tickets.preview'),
|
name='event.settings.tickets.preview'),
|
||||||
re_path(r'^settings/email$', event.MailSettings.as_view(), name='event.settings.mail'),
|
re_path(r'^settings/email$', event.MailSettings.as_view(), name='event.settings.mail'),
|
||||||
|
re_path(r'^settings/email/setup$', event.MailSettingsSetup.as_view(), name='event.settings.mail.setup'),
|
||||||
re_path(r'^settings/email/preview$', event.MailSettingsPreview.as_view(), name='event.settings.mail.preview'),
|
re_path(r'^settings/email/preview$', event.MailSettingsPreview.as_view(), name='event.settings.mail.preview'),
|
||||||
re_path(r'^settings/email/layoutpreview$', event.MailSettingsRendererPreview.as_view(),
|
re_path(r'^settings/email/layoutpreview$', event.MailSettingsRendererPreview.as_view(),
|
||||||
name='event.settings.mail.preview.layout'),
|
name='event.settings.mail.preview.layout'),
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ from pretix.control.forms.event import (
|
|||||||
TicketSettingsForm, WidgetCodeForm,
|
TicketSettingsForm, WidgetCodeForm,
|
||||||
)
|
)
|
||||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||||
|
from pretix.control.views.mailsetup import MailSettingsSetupView
|
||||||
from pretix.control.views.user import RecentAuthenticationRequiredMixin
|
from pretix.control.views.user import RecentAuthenticationRequiredMixin
|
||||||
from pretix.helpers.database import rolledback_transaction
|
from pretix.helpers.database import rolledback_transaction
|
||||||
from pretix.multidomain.urlreverse import get_event_domain
|
from pretix.multidomain.urlreverse import get_event_domain
|
||||||
@@ -637,29 +638,29 @@ class MailSettings(EventSettingsViewMixin, EventSettingsFormView):
|
|||||||
k: form.cleaned_data.get(k) for k in form.changed_data
|
k: form.cleaned_data.get(k) for k in form.changed_data
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
if request.POST.get('test', '0').strip() == '1':
|
|
||||||
backend = self.request.event.get_mail_backend(force_custom=True, timeout=10)
|
|
||||||
try:
|
|
||||||
backend.test(self.request.event.settings.mail_from)
|
|
||||||
except Exception as e:
|
|
||||||
messages.warning(self.request, _('An error occurred while contacting the SMTP server: %s') % str(e))
|
|
||||||
else:
|
|
||||||
if form.cleaned_data.get('smtp_use_custom'):
|
|
||||||
messages.success(self.request, _('Your changes have been saved and the connection attempt to '
|
|
||||||
'your SMTP server was successful.'))
|
|
||||||
else:
|
|
||||||
messages.success(self.request, _('We\'ve been able to contact the SMTP server you configured. '
|
|
||||||
'Remember to check the "use custom SMTP server" checkbox, '
|
|
||||||
'otherwise your SMTP server will not be used.'))
|
|
||||||
else:
|
|
||||||
messages.success(self.request, _('Your changes have been saved.'))
|
|
||||||
return redirect(self.get_success_url())
|
return redirect(self.get_success_url())
|
||||||
else:
|
else:
|
||||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||||
return self.get(request)
|
return self.get(request)
|
||||||
|
|
||||||
|
|
||||||
|
class MailSettingsSetup(EventPermissionRequiredMixin, MailSettingsSetupView):
|
||||||
|
permission = 'can_change_event_settings'
|
||||||
|
basetpl = 'pretixcontrol/event/base.html'
|
||||||
|
|
||||||
|
def get_success_url(self) -> str:
|
||||||
|
return reverse('control:event.settings.mail', kwargs={
|
||||||
|
'organizer': self.request.event.organizer.slug,
|
||||||
|
'event': self.request.event.slug
|
||||||
|
})
|
||||||
|
|
||||||
|
def log_action(self, data):
|
||||||
|
self.request.event.log_action(
|
||||||
|
'pretix.event.settings', user=self.request.user, data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MailSettingsPreview(EventPermissionRequiredMixin, View):
|
class MailSettingsPreview(EventPermissionRequiredMixin, View):
|
||||||
permission = 'can_change_event_settings'
|
permission = 'can_change_event_settings'
|
||||||
|
|
||||||
|
|||||||
279
src/pretix/control/views/mailsetup.py
Normal file
279
src/pretix/control/views/mailsetup.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import dns.resolver
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.mail import get_connection
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
from pretix.base import email
|
||||||
|
from pretix.base.models import Event
|
||||||
|
from pretix.base.services.mail import mail
|
||||||
|
from pretix.control.forms.filter import OrganizerFilterForm
|
||||||
|
from pretix.control.forms.mailsetup import SimpleMailForm, SMTPMailForm
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_spf_record(hostname):
|
||||||
|
try:
|
||||||
|
r = dns.resolver.Resolver()
|
||||||
|
for resp in r.query(hostname, 'TXT'):
|
||||||
|
data = b''.join(resp.strings).decode()
|
||||||
|
if data.lower().strip().startswith('v=spf1 '): # RFC7208, section 4.5
|
||||||
|
return data
|
||||||
|
except:
|
||||||
|
logger.exception("Could not fetch SPF record")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_spf_record(not_found_lookup_parts, spf_record, depth):
|
||||||
|
if depth > 10: # prevent infinite loops
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = spf_record.lower().split(" ") # RFC 7208, section 4.6.1
|
||||||
|
|
||||||
|
for p in parts:
|
||||||
|
try:
|
||||||
|
not_found_lookup_parts.remove(p)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not not_found_lookup_parts: # save some DNS requests if we already found everything
|
||||||
|
return
|
||||||
|
|
||||||
|
for p in parts:
|
||||||
|
if p.startswith('include:') or p.startswith('+include:'):
|
||||||
|
_, hostname = p.split(':')
|
||||||
|
rec_record = get_spf_record(hostname)
|
||||||
|
if rec_record:
|
||||||
|
_check_spf_record(not_found_lookup_parts, rec_record, depth + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def check_spf_record(lookup, spf_record):
|
||||||
|
"""
|
||||||
|
Check that all parts of lookup appear somewhere in the given SPF record, resolving
|
||||||
|
include: directives recursively
|
||||||
|
"""
|
||||||
|
not_found_lookup_parts = set(lookup.split(" "))
|
||||||
|
_check_spf_record(not_found_lookup_parts, spf_record, 0)
|
||||||
|
return not not_found_lookup_parts
|
||||||
|
|
||||||
|
|
||||||
|
class MailSettingsSetupView(TemplateView):
|
||||||
|
template_name = 'pretixcontrol/email_setup.html'
|
||||||
|
basetpl = None
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def object(self):
|
||||||
|
return getattr(self.request, 'event', self.request.organizer)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def smtp_form(self):
|
||||||
|
return SMTPMailForm(
|
||||||
|
obj=self.object,
|
||||||
|
prefix='smtp',
|
||||||
|
data=self.request.POST if (self.request.method == "POST" and self.request.POST.get("mode") == "smtp") else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def simple_form(self):
|
||||||
|
return SimpleMailForm(
|
||||||
|
obj=self.object,
|
||||||
|
prefix='simple',
|
||||||
|
data=self.request.POST if (self.request.method == "POST" and self.request.POST.get("mode") == "simple") else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['basetpl'] = self.basetpl
|
||||||
|
ctx['object'] = self.object
|
||||||
|
ctx['smtp_form'] = self.smtp_form
|
||||||
|
ctx['simple_form'] = self.simple_form
|
||||||
|
ctx['default_sender_address'] = settings.MAIL_FROM_ORGANIZERS
|
||||||
|
if 'mode' in self.request.POST:
|
||||||
|
ctx['mode'] = self.request.POST.get('mode')
|
||||||
|
elif self.object.settings.smtp_use_custom:
|
||||||
|
ctx['mode'] = 'smtp'
|
||||||
|
elif self.object.settings.mail_from not in (settings.MAIL_FROM_ORGANIZERS, settings.MAIL_FROM):
|
||||||
|
ctx['mode'] = 'simple'
|
||||||
|
else:
|
||||||
|
ctx['mode'] = 'system'
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def filter_form(self):
|
||||||
|
return OrganizerFilterForm(data=self.request.GET, request=self.request)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
if request.POST.get('mode') == 'system':
|
||||||
|
if isinstance(self.object, Event) and 'mail_from' in self.object.organizer.settings._cache():
|
||||||
|
self.object.settings.mail_from = settings.MAIL_FROM_ORGANIZERS
|
||||||
|
else:
|
||||||
|
del self.object.settings.mail_from
|
||||||
|
self.object.settings.smtp_use_custom = False
|
||||||
|
del self.object.settings.smtp_host
|
||||||
|
del self.object.settings.smtp_port
|
||||||
|
del self.object.settings.smtp_username
|
||||||
|
del self.object.settings.smtp_password
|
||||||
|
del self.object.settings.smtp_use_tls
|
||||||
|
del self.object.settings.smtp_use_ssl
|
||||||
|
messages.success(request, _('Your changes have been saved.'))
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
elif request.POST.get('mode') == 'reset':
|
||||||
|
del self.object.settings.mail_from
|
||||||
|
del self.object.settings.smtp_use_custom
|
||||||
|
del self.object.settings.smtp_host
|
||||||
|
del self.object.settings.smtp_port
|
||||||
|
del self.object.settings.smtp_username
|
||||||
|
del self.object.settings.smtp_password
|
||||||
|
del self.object.settings.smtp_use_tls
|
||||||
|
del self.object.settings.smtp_use_ssl
|
||||||
|
messages.success(request, _('Your changes have been saved.'))
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
elif request.POST.get('mode') == 'simple':
|
||||||
|
if not self.simple_form.is_valid():
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
session_key = f'sender_mail_verification_code_{self.request.path}_{self.simple_form.cleaned_data.get("mail_from")}'
|
||||||
|
allow_save = (
|
||||||
|
(not settings.MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED or
|
||||||
|
('verification' in self.request.POST and self.request.POST.get('verification', '') == self.request.session.get(session_key, None))) and
|
||||||
|
(not settings.MAIL_CUSTOM_SENDER_SPF_STRING or self.request.POST.get('state') == 'save')
|
||||||
|
)
|
||||||
|
|
||||||
|
if allow_save:
|
||||||
|
for k, v in self.simple_form.cleaned_data.items():
|
||||||
|
self.object.settings.set(k, v)
|
||||||
|
self.log_action(self.simple_form.cleaned_data)
|
||||||
|
if session_key in request.session:
|
||||||
|
del request.session[session_key]
|
||||||
|
messages.success(request, _('Your changes have been saved.'))
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
spf_warning = None
|
||||||
|
spf_record = None
|
||||||
|
if settings.MAIL_CUSTOM_SENDER_SPF_STRING:
|
||||||
|
hostname = self.simple_form.cleaned_data['mail_from'].split('@')[-1]
|
||||||
|
spf_record = get_spf_record(hostname)
|
||||||
|
if not spf_record:
|
||||||
|
spf_warning = _(
|
||||||
|
'We could not find an SPF record set for the domain you are trying to use. You can still '
|
||||||
|
'proceed, but it will increase the chance of emails going to spam or being rejected. We '
|
||||||
|
'strongly recommend setting an SPF record on the domain. You can do so through the DNS '
|
||||||
|
'settings at the provider you registered your domain with.'
|
||||||
|
)
|
||||||
|
elif not check_spf_record(settings.MAIL_CUSTOM_SENDER_SPF_STRING, spf_record):
|
||||||
|
spf_warning = _(
|
||||||
|
'We found an SPF record set for the domain you are trying to use, but it does not include this '
|
||||||
|
'system\'s email server. This means that there is a very high chance most of the emails will be '
|
||||||
|
'rejected or marked as spam. You should update the DNS settings of your domain to include '
|
||||||
|
'this system in the SPF record.'
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED:
|
||||||
|
if 'verification' in self.request.POST:
|
||||||
|
messages.error(request, _('The verification code was incorrect, please try again.'))
|
||||||
|
else:
|
||||||
|
self.request.session[session_key] = get_random_string(length=6, allowed_chars='1234567890')
|
||||||
|
mail(
|
||||||
|
self.simple_form.cleaned_data.get('mail_from'),
|
||||||
|
_('Sender address verification'),
|
||||||
|
'pretixcontrol/email/email_setup.txt',
|
||||||
|
{
|
||||||
|
'code': self.request.session[session_key],
|
||||||
|
'address': self.simple_form.cleaned_data.get('mail_from'),
|
||||||
|
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
locale=self.request.LANGUAGE_CODE,
|
||||||
|
user=self.request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.response_class(
|
||||||
|
request=self.request,
|
||||||
|
template='pretixcontrol/email_setup_simple.html',
|
||||||
|
context={
|
||||||
|
'basetpl': self.basetpl,
|
||||||
|
'object': self.object,
|
||||||
|
'verification': settings.MAIL_CUSTOM_SENDER_VERIFICATION_REQUIRED,
|
||||||
|
'spf_warning': spf_warning,
|
||||||
|
'spf_record': spf_record,
|
||||||
|
'spf_key': settings.MAIL_CUSTOM_SENDER_SPF_STRING,
|
||||||
|
'recp': self.simple_form.cleaned_data.get('mail_from')
|
||||||
|
},
|
||||||
|
using=self.template_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif request.POST.get('mode') == 'smtp':
|
||||||
|
if not self.smtp_form.is_valid():
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
if request.POST.get('state') == 'save':
|
||||||
|
for k, v in self.smtp_form.cleaned_data.items():
|
||||||
|
self.object.settings.set(k, v)
|
||||||
|
self.object.settings.smtp_use_custom = True
|
||||||
|
self.log_action({**self.smtp_form.cleaned_data, 'smtp_use_custom': True})
|
||||||
|
messages.success(request, _('Your changes have been saved.'))
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
else:
|
||||||
|
backend = get_connection(
|
||||||
|
backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||||
|
host=self.smtp_form.cleaned_data['smtp_host'],
|
||||||
|
port=self.smtp_form.cleaned_data['smtp_port'],
|
||||||
|
username=self.smtp_form.cleaned_data.get('smtp_username', ''),
|
||||||
|
password=self.smtp_form.cleaned_data.get('smtp_password', ''),
|
||||||
|
use_tls=self.smtp_form.cleaned_data.get('smtp_use_tls', False),
|
||||||
|
use_ssl=self.smtp_form.cleaned_data.get('smtp_use_ssl', False),
|
||||||
|
fail_silently=False,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
email.test_custom_smtp_backend(backend, self.smtp_form.cleaned_data.get('mail_from'))
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(self.request, _('An error occurred while contacting the SMTP server: %s') % str(e))
|
||||||
|
return self.get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return self.response_class(
|
||||||
|
request=self.request,
|
||||||
|
template='pretixcontrol/email_setup_smtp.html',
|
||||||
|
context={
|
||||||
|
'basetpl': self.basetpl,
|
||||||
|
'object': self.object,
|
||||||
|
'known_host_problem': {
|
||||||
|
'smtp.gmail.com': _(
|
||||||
|
'We recommend not using Google Mail for transactional emails. If you try sending many '
|
||||||
|
'emails in a short amount of time, e.g. when sending information to all your ticket '
|
||||||
|
'buyers, there is a high chance Google will not deliver all of your emails since they '
|
||||||
|
'impose a maximum number of emails per time period.'
|
||||||
|
),
|
||||||
|
}.get(self.smtp_form.cleaned_data['smtp_host']),
|
||||||
|
},
|
||||||
|
using=self.template_engine,
|
||||||
|
)
|
||||||
@@ -896,6 +896,11 @@ class OrderRefundView(OrderView):
|
|||||||
if self.request.POST.get('manual_state') == 'done'
|
if self.request.POST.get('manual_state') == 'done'
|
||||||
else OrderRefund.REFUND_STATE_CREATED
|
else OrderRefund.REFUND_STATE_CREATED
|
||||||
),
|
),
|
||||||
|
execution_date=(
|
||||||
|
now()
|
||||||
|
if self.request.POST.get('manual_state') == 'done'
|
||||||
|
else None
|
||||||
|
),
|
||||||
amount=manual_value,
|
amount=manual_value,
|
||||||
comment=comment,
|
comment=comment,
|
||||||
provider='manual'
|
provider='manual'
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ from pretix.control.forms.filter import (
|
|||||||
)
|
)
|
||||||
from pretix.control.forms.orders import ExporterForm
|
from pretix.control.forms.orders import ExporterForm
|
||||||
from pretix.control.forms.organizer import (
|
from pretix.control.forms.organizer import (
|
||||||
CustomerUpdateForm, DeviceForm, EventMetaPropertyForm, GateForm,
|
CustomerCreateForm, CustomerUpdateForm, DeviceForm, EventMetaPropertyForm,
|
||||||
GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm,
|
GateForm, GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm,
|
||||||
MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm,
|
MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm,
|
||||||
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
|
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
|
||||||
WebHookForm,
|
WebHookForm,
|
||||||
@@ -101,6 +101,7 @@ from pretix.control.permissions import (
|
|||||||
)
|
)
|
||||||
from pretix.control.signals import nav_organizer
|
from pretix.control.signals import nav_organizer
|
||||||
from pretix.control.views import PaginationMixin
|
from pretix.control.views import PaginationMixin
|
||||||
|
from pretix.control.views.mailsetup import MailSettingsSetupView
|
||||||
from pretix.helpers.dicts import merge_dicts
|
from pretix.helpers.dicts import merge_dicts
|
||||||
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
@@ -261,22 +262,6 @@ class OrganizerMailSettings(OrganizerSettingsFormView):
|
|||||||
k: form.cleaned_data.get(k) for k in form.changed_data
|
k: form.cleaned_data.get(k) for k in form.changed_data
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.POST.get('test', '0').strip() == '1':
|
|
||||||
backend = self.request.organizer.get_mail_backend(force_custom=True, timeout=10)
|
|
||||||
try:
|
|
||||||
backend.test(self.request.organizer.settings.mail_from)
|
|
||||||
except Exception as e:
|
|
||||||
messages.warning(self.request, _('An error occurred while contacting the SMTP server: %s') % str(e))
|
|
||||||
else:
|
|
||||||
if form.cleaned_data.get('smtp_use_custom'):
|
|
||||||
messages.success(self.request, _('Your changes have been saved and the connection attempt to '
|
|
||||||
'your SMTP server was successful.'))
|
|
||||||
else:
|
|
||||||
messages.success(self.request, _('We\'ve been able to contact the SMTP server you configured. '
|
|
||||||
'Remember to check the "use custom SMTP server" checkbox, '
|
|
||||||
'otherwise your SMTP server will not be used.'))
|
|
||||||
else:
|
|
||||||
messages.success(self.request, _('Your changes have been saved.'))
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
return redirect(self.get_success_url())
|
return redirect(self.get_success_url())
|
||||||
else:
|
else:
|
||||||
@@ -284,6 +269,21 @@ class OrganizerMailSettings(OrganizerSettingsFormView):
|
|||||||
return self.get(request)
|
return self.get(request)
|
||||||
|
|
||||||
|
|
||||||
|
class MailSettingsSetup(OrganizerPermissionRequiredMixin, MailSettingsSetupView):
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
basetpl = 'pretixcontrol/base.html'
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:organizer.settings.mail', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
})
|
||||||
|
|
||||||
|
def log_action(self, data):
|
||||||
|
self.request.organizer.log_action(
|
||||||
|
'pretix.organizer.settings', user=self.request.user, data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
|
class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
|
||||||
permission = 'can_change_organizer_settings'
|
permission = 'can_change_organizer_settings'
|
||||||
|
|
||||||
@@ -489,6 +489,7 @@ class OrganizerCreate(CreateView):
|
|||||||
organizer=form.instance, name=_('Administrators'),
|
organizer=form.instance, name=_('Administrators'),
|
||||||
all_events=True, can_create_events=True, can_change_teams=True, can_manage_gift_cards=True,
|
all_events=True, can_create_events=True, can_change_teams=True, can_manage_gift_cards=True,
|
||||||
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
|
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
|
||||||
|
can_manage_customers=True,
|
||||||
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
|
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
|
||||||
)
|
)
|
||||||
t.members.add(self.request.user)
|
t.members.add(self.request.user)
|
||||||
@@ -1194,6 +1195,10 @@ class GiftCardDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
|
|||||||
'retry': True,
|
'retry': True,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
t.order.log_action('pretix.event.order.payment.started', {
|
||||||
|
'local_id': r.local_id,
|
||||||
|
'provider': r.provider
|
||||||
|
}, user=request.user)
|
||||||
try:
|
try:
|
||||||
r.payment_provider.execute_payment(request, r)
|
r.payment_provider.execute_payment(request, r)
|
||||||
except PaymentException as e:
|
except PaymentException as e:
|
||||||
@@ -1863,6 +1868,35 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
|
||||||
|
template_name = 'pretixcontrol/organizers/customer_edit.html'
|
||||||
|
permission = 'can_manage_customers'
|
||||||
|
context_object_name = 'customer'
|
||||||
|
form_class = CustomerCreateForm
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
ctx = super().get_form_kwargs()
|
||||||
|
c = Customer(organizer=self.request.organizer)
|
||||||
|
c.assign_identifier()
|
||||||
|
ctx['instance'] = c
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
r = super().form_valid(form)
|
||||||
|
form.instance.log_action('pretix.customer.created', user=self.request.user, data={
|
||||||
|
k: getattr(form.instance, k)
|
||||||
|
for k in form.changed_data
|
||||||
|
})
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
|
return r
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:organizer.customer', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
'customer': self.object.identifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class CustomerUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
|
class CustomerUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
|
||||||
template_name = 'pretixcontrol/organizers/customer_edit.html'
|
template_name = 'pretixcontrol/organizers/customer_edit.html'
|
||||||
permission = 'can_manage_customers'
|
permission = 'can_manage_customers'
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ from django.utils.functional import cached_property
|
|||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
|
|
||||||
from pretix.base.models import Order, OrderPosition
|
from pretix.base.models import Order, OrderPosition
|
||||||
from pretix.base.models.orders import CancellationRequest
|
from pretix.base.models.orders import CancellationRequest, OrderPayment
|
||||||
from pretix.control.forms.filter import OrderSearchFilterForm
|
from pretix.control.forms.filter import (
|
||||||
|
OrderPaymentSearchFilterForm, OrderSearchFilterForm,
|
||||||
|
)
|
||||||
from pretix.control.views import LargeResultSetPaginator, PaginationMixin
|
from pretix.control.views import LargeResultSetPaginator, PaginationMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -136,3 +138,73 @@ class OrderSearch(PaginationMixin, ListView):
|
|||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'event', 'event__organizer'
|
'event', 'event__organizer'
|
||||||
).select_related('invoice_address')
|
).select_related('invoice_address')
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentSearch(PaginationMixin, ListView):
|
||||||
|
model = OrderPayment
|
||||||
|
paginator_class = LargeResultSetPaginator
|
||||||
|
context_object_name = 'payments'
|
||||||
|
template_name = 'pretixcontrol/search/payments.html'
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def filter_form(self):
|
||||||
|
return OrderPaymentSearchFilterForm(data=self.request.GET, request=self.request)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data()
|
||||||
|
ctx['filter_form'] = self.filter_form
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = OrderPayment.objects.using(settings.DATABASE_REPLICA)
|
||||||
|
|
||||||
|
if not self.request.user.has_active_staff_session(self.request.session.session_key):
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(order__event_id__in=self.request.user.get_events_with_permission('can_view_orders').values_list('id', flat=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.filter_form.is_valid():
|
||||||
|
qs = self.filter_form.filter_qs(qs)
|
||||||
|
|
||||||
|
if self.filter_form.cleaned_data.get('query'):
|
||||||
|
"""
|
||||||
|
We need to work around a bug in PostgreSQL's (and likely MySQL's) query plan optimizer here.
|
||||||
|
The database lacks statistical data to predict how common our search filter is and therefore
|
||||||
|
assumes that it is cheaper to first ORDER *all* orders in the system (since we got an index on
|
||||||
|
datetime), then filter out with a full scan until OFFSET/LIMIT condition is fulfilled. If we
|
||||||
|
look for something rare (such as an email address used once within hundreds of thousands of
|
||||||
|
orders, this ends up to be pathologically slow.
|
||||||
|
|
||||||
|
For some search queries on pretix.eu, we see search times of >30s, just due to the ORDER BY and
|
||||||
|
LIMIT clause. Without them. the query runs in roughly 0.6s. This heuristical approach tries to
|
||||||
|
detect these cases and rewrite the query as a nested subquery that strongly suggests sorting
|
||||||
|
before filtering. However, since even that fails in some cases because PostgreSQL thinks it knows
|
||||||
|
better, we literally force it by evaluating the subquery explicitly. We only do this for n<=200,
|
||||||
|
to avoid memory leaks – and problems with maximum parameter count on SQLite. In cases where the
|
||||||
|
search query yields lots of results, this will actually be slower since it requires two queries,
|
||||||
|
sorry.
|
||||||
|
|
||||||
|
Phew.
|
||||||
|
"""
|
||||||
|
|
||||||
|
page = self.kwargs.get(self.page_kwarg) or self.request.GET.get(self.page_kwarg) or 1
|
||||||
|
limit = self.get_paginate_by(None)
|
||||||
|
try:
|
||||||
|
offset = (int(page) - 1) * limit
|
||||||
|
except ValueError:
|
||||||
|
offset = 0
|
||||||
|
resultids = list(qs.order_by().values_list('id', flat=True)[:201])
|
||||||
|
if len(resultids) <= 200 and len(resultids) <= offset + limit:
|
||||||
|
qs = OrderPayment.objects.using(settings.DATABASE_REPLICA).filter(
|
||||||
|
id__in=resultids
|
||||||
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
|
We use prefetch_related here instead of select_related for a reason, even though select_related
|
||||||
|
would be the common choice for a foreign key and doesn't require an additional database query.
|
||||||
|
The problem is, that if our results contain the same event 25 times, select_related will create
|
||||||
|
25 Django objects which will all try to pull their ownsettings cache to show the event properly,
|
||||||
|
leading to lots of unnecessary queries. Due to the way prefetch_related works differently, it
|
||||||
|
will only create one shared Django object.
|
||||||
|
"""
|
||||||
|
return qs.prefetch_related('order', 'order__event', 'order__event__organizer')
|
||||||
|
|||||||
@@ -20,9 +20,13 @@
|
|||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import json
|
import json
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import (
|
||||||
|
BACKEND_SESSION_KEY, get_user_model, load_backend, login,
|
||||||
|
)
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -30,7 +34,7 @@ from django.utils.functional import cached_property
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic import ListView, TemplateView
|
from django.views.generic import ListView, TemplateView
|
||||||
from hijack.helpers import login_user, release_hijack
|
from hijack import signals
|
||||||
|
|
||||||
from pretix.base.auth import get_auth_backends
|
from pretix.base.auth import get_auth_backends
|
||||||
from pretix.base.models import User
|
from pretix.base.models import User
|
||||||
@@ -42,6 +46,25 @@ from pretix.control.views import CreateView, UpdateView
|
|||||||
from pretix.control.views.user import RecentAuthenticationRequiredMixin
|
from pretix.control.views.user import RecentAuthenticationRequiredMixin
|
||||||
|
|
||||||
|
|
||||||
|
def get_used_backend(request):
|
||||||
|
# vendored from hijack/views.py
|
||||||
|
backend_str = request.session[BACKEND_SESSION_KEY]
|
||||||
|
backend = load_backend(backend_str)
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def keep_session_age(session):
|
||||||
|
# vendored from hijack/views.py
|
||||||
|
try:
|
||||||
|
session_expiry = session["_session_expiry"]
|
||||||
|
except KeyError:
|
||||||
|
yield
|
||||||
|
else:
|
||||||
|
yield
|
||||||
|
session["_session_expiry"] = session_expiry
|
||||||
|
|
||||||
|
|
||||||
class UserListView(AdministratorPermissionRequiredMixin, ListView):
|
class UserListView(AdministratorPermissionRequiredMixin, ListView):
|
||||||
template_name = 'pretixcontrol/users/index.html'
|
template_name = 'pretixcontrol/users/index.html'
|
||||||
context_object_name = 'users'
|
context_object_name = 'users'
|
||||||
@@ -171,7 +194,28 @@ class UserImpersonateView(AdministratorPermissionRequiredMixin, RecentAuthentica
|
|||||||
'other_email': self.object.email
|
'other_email': self.object.email
|
||||||
})
|
})
|
||||||
oldkey = request.session.session_key
|
oldkey = request.session.session_key
|
||||||
login_user(request, self.object)
|
|
||||||
|
hijacker = request.user
|
||||||
|
hijacked = self.object
|
||||||
|
|
||||||
|
hijack_history = request.session.get("hijack_history", [])
|
||||||
|
hijack_history.append(request.user._meta.pk.value_to_string(hijacker))
|
||||||
|
|
||||||
|
backend = get_used_backend(request)
|
||||||
|
backend = f"{backend.__module__}.{backend.__class__.__name__}"
|
||||||
|
|
||||||
|
with signals.no_update_last_login(), keep_session_age(request.session):
|
||||||
|
login(request, hijacked, backend=backend)
|
||||||
|
|
||||||
|
request.session["hijack_history"] = hijack_history
|
||||||
|
|
||||||
|
signals.hijack_started.send(
|
||||||
|
sender=None,
|
||||||
|
request=request,
|
||||||
|
hijacker=hijacker,
|
||||||
|
hijacked=hijacked,
|
||||||
|
)
|
||||||
|
|
||||||
request.session['hijacker_session'] = oldkey
|
request.session['hijacker_session'] = oldkey
|
||||||
return redirect(reverse('control:index'))
|
return redirect(reverse('control:index'))
|
||||||
|
|
||||||
@@ -180,8 +224,26 @@ class UserImpersonateStopView(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
impersonated = request.user
|
impersonated = request.user
|
||||||
|
|
||||||
hijs = request.session['hijacker_session']
|
hijs = request.session['hijacker_session']
|
||||||
release_hijack(request)
|
hijack_history = request.session.get("hijack_history", [])
|
||||||
|
hijacked = request.user
|
||||||
|
user_pk = hijack_history.pop()
|
||||||
|
hijacker = get_object_or_404(get_user_model(), pk=user_pk)
|
||||||
|
backend = get_used_backend(request)
|
||||||
|
backend = f"{backend.__module__}.{backend.__class__.__name__}"
|
||||||
|
with signals.no_update_last_login(), keep_session_age(request.session):
|
||||||
|
login(request, hijacker, backend=backend)
|
||||||
|
|
||||||
|
request.session["hijack_history"] = hijack_history
|
||||||
|
|
||||||
|
signals.hijack_ended.send(
|
||||||
|
sender=None,
|
||||||
|
request=request,
|
||||||
|
hijacker=hijacker,
|
||||||
|
hijacked=hijacked,
|
||||||
|
)
|
||||||
|
|
||||||
ss = request.user.get_active_staff_session(hijs)
|
ss = request.user.get_active_staff_session(hijs)
|
||||||
if ss:
|
if ss:
|
||||||
request.session.save()
|
request.session.save()
|
||||||
|
|||||||
@@ -25,3 +25,7 @@ from django.apps import AppConfig
|
|||||||
class PretixHelpersConfig(AppConfig):
|
class PretixHelpersConfig(AppConfig):
|
||||||
name = 'pretix.helpers'
|
name = 'pretix.helpers'
|
||||||
label = 'pretixhelpers'
|
label = 'pretixhelpers'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from .monkeypatching import monkeypatch_all_at_ready
|
||||||
|
monkeypatch_all_at_ready()
|
||||||
|
|||||||
@@ -32,10 +32,11 @@
|
|||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
from django.template.defaultfilters import date as _date
|
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.translation import get_language, gettext_lazy as _
|
from django.utils.translation import get_language, gettext_lazy as _
|
||||||
|
|
||||||
|
from pretix.helpers.templatetags.date_fast import date_fast as _date
|
||||||
|
|
||||||
|
|
||||||
def daterange(df, dt, as_html=False):
|
def daterange(df, dt, as_html=False):
|
||||||
lng = get_language()
|
lng = get_language()
|
||||||
|
|||||||
@@ -39,3 +39,4 @@ SHORT_DATETIME_FORMAT = 'Y-m-d H:i'
|
|||||||
TIME_FORMAT = 'H:i'
|
TIME_FORMAT = 'H:i'
|
||||||
WEEK_FORMAT = '\\W W, o'
|
WEEK_FORMAT = '\\W W, o'
|
||||||
WEEK_DAY_FORMAT = 'D, M jS'
|
WEEK_DAY_FORMAT = 'D, M jS'
|
||||||
|
SHORT_MONTH_DAY_FORMAT = 'd.m.'
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ SHORT_DATETIME_FORMAT = 'm/d/Y P'
|
|||||||
TIME_FORMAT = 'P'
|
TIME_FORMAT = 'P'
|
||||||
WEEK_FORMAT = '\\W W, o'
|
WEEK_FORMAT = '\\W W, o'
|
||||||
WEEK_DAY_FORMAT = 'D, M jS'
|
WEEK_DAY_FORMAT = 'D, M jS'
|
||||||
|
SHORT_MONTH_DAY_FORMAT = 'm/d'
|
||||||
|
|
||||||
DATE_INPUT_FORMATS = [
|
DATE_INPUT_FORMATS = [
|
||||||
'%m/%d/%Y',
|
'%m/%d/%Y',
|
||||||
|
|||||||
21
src/pretix/helpers/formats/fr/__init__.py
Normal file
21
src/pretix/helpers/formats/fr/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
25
src/pretix/helpers/formats/fr/formats.py
Normal file
25
src/pretix/helpers/formats/fr/formats.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
# Date according to https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date
|
||||||
|
WEEK_FORMAT = '\\S W/o'
|
||||||
|
WEEK_DAY_FORMAT = 'D, j.n.'
|
||||||
40
src/pretix/helpers/hierarkey.py
Normal file
40
src/pretix/helpers/hierarkey.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
def clean_filename(fname):
|
||||||
|
"""
|
||||||
|
hierarkey.forms.SettingsForm appends a random value to every filename. However, it keeps the
|
||||||
|
extension around "twice". This leads to:
|
||||||
|
|
||||||
|
"Terms.pdf" → "Terms.pdf.OybgvyAH.pdf"
|
||||||
|
|
||||||
|
In pretix Hosted, our storage layer also adds a hash of the file to the filename, so we have
|
||||||
|
|
||||||
|
"Terms.pdf" → "Terms.pdf.OybgvyAH.22c0583727d5bc.pdf"
|
||||||
|
|
||||||
|
This function reverses this operation:
|
||||||
|
|
||||||
|
"Terms.pdf.OybgvyAH.22c0583727d5bc.pdf" → "Terms.pdf"
|
||||||
|
"""
|
||||||
|
ext = '.' + fname.split('.')[-1]
|
||||||
|
return fname.rsplit(ext + ".", 1)[0] + ext
|
||||||
56
src/pretix/helpers/monkeypatching.py
Normal file
56
src/pretix/helpers/monkeypatching.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 datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def monkeypatch_vobject_performance():
|
||||||
|
"""
|
||||||
|
This works around a performance issue in the unmaintained vobject library which calls
|
||||||
|
a very expensive function for every event in a calendar. Since the slow function is
|
||||||
|
mostly used to compare timezones to UTC, not to arbitrary other timezones, we can
|
||||||
|
add a few early-out optimizations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from vobject import icalendar
|
||||||
|
|
||||||
|
old_tzinfo_eq = icalendar.tzinfo_eq
|
||||||
|
test_date = datetime(2000, 1, 1)
|
||||||
|
|
||||||
|
def new_tzinfo_eq(tzinfo1, tzinfo2, *args, **kwargs):
|
||||||
|
if tzinfo1 is None:
|
||||||
|
return tzinfo2 is None
|
||||||
|
if tzinfo2 is None:
|
||||||
|
return tzinfo1 is None
|
||||||
|
|
||||||
|
n1 = tzinfo1.tzname(test_date)
|
||||||
|
n2 = tzinfo2.tzname(test_date)
|
||||||
|
if n1 == "UTC" and n2 == "UTC":
|
||||||
|
return True
|
||||||
|
if n1 == "UTC" or n2 == "UTC":
|
||||||
|
return False
|
||||||
|
return old_tzinfo_eq(tzinfo1, tzinfo2, *args, **kwargs)
|
||||||
|
|
||||||
|
icalendar.tzinfo_eq = new_tzinfo_eq
|
||||||
|
|
||||||
|
|
||||||
|
def monkeypatch_all_at_ready():
|
||||||
|
monkeypatch_vobject_performance()
|
||||||
61
src/pretix/helpers/templatetags/date_fast.py
Normal file
61
src/pretix/helpers/templatetags/date_fast.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.utils import dateformat
|
||||||
|
from django.utils.formats import get_format
|
||||||
|
from django.utils.translation import get_language
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=32)
|
||||||
|
def _get_format(format_type, lang):
|
||||||
|
return get_format(format_type, lang)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(expects_localtime=True, is_safe=False)
|
||||||
|
def date_fast(value, arg=None):
|
||||||
|
"""
|
||||||
|
Slightly quicker version of |date if the filter is called a lot. The speedup is achieved through
|
||||||
|
LRU caching for formats.
|
||||||
|
|
||||||
|
Django's built-in |date filter has a caching mechanism if you call it with a named format,
|
||||||
|
i.e. ``|date_fast:"SHORT_DATE_FORMAT"`` will only be ~6% faster than ``|date:"SHORT_DATE_FORMAT"``.
|
||||||
|
|
||||||
|
However, Django's built-in caching has a flaw with unnamed formats, therefore ``|date_fast:"Y-m-d"``
|
||||||
|
will be ~12% faster than ``|date:"Y-m-d"``.
|
||||||
|
"""
|
||||||
|
if value in (None, ''):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
lang = get_language()
|
||||||
|
format = _get_format(arg, lang)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return dateformat.format(value, format)
|
||||||
|
except AttributeError:
|
||||||
|
try:
|
||||||
|
return format(value, arg)
|
||||||
|
except AttributeError:
|
||||||
|
return ''
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user