mirror of
https://github.com/pretix/pretix.git
synced 2025-12-10 01:12:28 +00:00
Compare commits
1 Commits
v4.6.0
...
consistenc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2dad8fdcc |
15
.github/dependabot.yml
vendored
15
.github/dependabot.yml
vendored
@@ -1,15 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/src"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/src/pretix/static/npm_dir"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
14
.github/workflows/tests.yml
vendored
14
.github/workflows/tests.yml
vendored
@@ -18,17 +18,17 @@ jobs:
|
||||
name: Tests
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.8", "3.9"]
|
||||
python-version: [3.6, 3.7, 3.8]
|
||||
database: [sqlite, postgres, mysql]
|
||||
exclude:
|
||||
- database: mysql
|
||||
python-version: "3.8"
|
||||
python-version: 3.7
|
||||
- database: sqlite
|
||||
python-version: 3.7
|
||||
- database: mysql
|
||||
python-version: "3.9"
|
||||
python-version: 3.6
|
||||
- database: sqlite
|
||||
python-version: "3.7"
|
||||
- database: sqlite
|
||||
python-version: "3.8"
|
||||
python-version: 3.6
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: getong/mariadb-action@v1.1
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system dependencies
|
||||
run: sudo apt update && sudo apt install gettext mariadb-client
|
||||
run: sudo apt update && sudo apt install gettext mysql-client
|
||||
- name: Install Python dependencies
|
||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||
working-directory: ./src
|
||||
|
||||
@@ -220,30 +220,12 @@ Example::
|
||||
``user``, ``password``
|
||||
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``
|
||||
The email address to set as ``From`` header in outgoing emails by the system.
|
||||
Default: ``pretix@localhost``
|
||||
|
||||
``from_notifications``
|
||||
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.
|
||||
``tls``, ``ssl``
|
||||
Use STARTTLS or SSL for the SMTP connection. Off by default.
|
||||
|
||||
``admins``
|
||||
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
||||
@@ -300,7 +282,7 @@ You can use an existing memcached server as pretix's caching backend::
|
||||
``location``
|
||||
The location of memcached, either a host:port combination or a socket file.
|
||||
|
||||
If no memcached is configured, pretix will use redis for caching. If neither is configured, pretix will not use any caching.
|
||||
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
|
||||
|
||||
.. 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
|
||||
@@ -463,10 +445,8 @@ You can configure the maximum file size for uploading various files::
|
||||
max_size_image = 12
|
||||
; Max upload size for favicons in MiB, defaults to 1 MiB
|
||||
max_size_favicon = 2
|
||||
; Max upload size for email attachments of manually sent emails in MiB, defaults to 10 MiB
|
||||
; Max upload size for email attachments in MiB, defaults to 10 MiB
|
||||
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
|
||||
; This includes all file upload type order questions
|
||||
max_size_other = 100
|
||||
|
||||
@@ -142,7 +142,7 @@ If you're running MySQL, also install the client library::
|
||||
|
||||
(venv)$ pip3 install mysqlclient
|
||||
|
||||
Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``.
|
||||
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
|
||||
|
||||
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/ {
|
||||
alias /var/pretix/venv/lib/python3.10/site-packages/pretix/static.dist/;
|
||||
alias /var/pretix/venv/lib/python3.7/site-packages/pretix/static.dist/;
|
||||
access_log off;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
}
|
||||
|
||||
.. note:: Remember to replace the ``python3.10`` in the ``/static/`` path in the config
|
||||
.. note:: Remember to replace the ``python3.7`` in the ``/static/`` path in the config
|
||||
above with your python version.
|
||||
|
||||
We recommend reading about setting `strong encryption settings`_ for your web server.
|
||||
|
||||
@@ -26,13 +26,6 @@ In addition to these standard update steps, the following list issues steps that
|
||||
to specific versions for pretix. If you're skipping versions, please read the instructions for every version in
|
||||
between as well.
|
||||
|
||||
Upgrade to 3.17.0 or newer
|
||||
""""""""""""""""""""""""""
|
||||
|
||||
pretix 3.17 introduces a dependency on ``nodejs``, so you should install it on your system::
|
||||
|
||||
# apt install nodejs npm
|
||||
|
||||
Upgrade to 4.4.0 or newer
|
||||
"""""""""""""""""""""""""
|
||||
|
||||
|
||||
@@ -97,8 +97,7 @@ 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/``.
|
||||
|
||||
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
||||
**regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error
|
||||
message, but you should not rely on that and instead make sure you use our API to check if the payment actually
|
||||
**regardless of whether it was successful or not**. Make sure you use our API to check if the payment actually
|
||||
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
|
||||
|
||||
@@ -31,6 +31,5 @@ Resources and endpoints
|
||||
webhooks
|
||||
seatingplans
|
||||
exporters
|
||||
sendmail_rules
|
||||
billing_invoices
|
||||
billing_var
|
||||
|
||||
@@ -58,12 +58,6 @@ lines list of objects The actual invo
|
||||
created before this field was introduced as well as for
|
||||
all lines not created by a product (e.g. a shipping or
|
||||
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``,
|
||||
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
|
||||
all invoice lines
|
||||
@@ -84,12 +78,6 @@ lines list of objects The actual invo
|
||||
an event series not created by a product (e.g. shipping or
|
||||
cancellation fees) as well as whenever the respective (sub)event
|
||||
has no end date set.
|
||||
├ event_location string Location of the (sub)event this line was created for as it
|
||||
was set during invoice creation. Can be ``null`` for all invoice
|
||||
lines created before this was introduced as well as for lines in
|
||||
an event series not created by a product (e.g. shipping or
|
||||
cancellation fees) as well as whenever the respective (sub)event
|
||||
has no location set.
|
||||
├ attendee_name string Attendee name at time of invoice creation. Can be ``null`` if no
|
||||
name was set or if names are configured to not be added to invoices.
|
||||
├ gross_value money (string) Price including taxes
|
||||
@@ -122,14 +110,6 @@ internal_reference string Customer's refe
|
||||
|
||||
The attributes ``fee_type`` and ``fee_internal_type`` have been added.
|
||||
|
||||
.. versionchanged:: 4.1
|
||||
|
||||
The attribute ``lines.event_location`` has been added.
|
||||
|
||||
.. versionchanged:: 4.6
|
||||
|
||||
The attribute ``lines.subevent`` has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -195,12 +175,10 @@ Endpoints
|
||||
"description": "Budget Ticket",
|
||||
"item": 1234,
|
||||
"variation": 245,
|
||||
"subevent": null,
|
||||
"fee_type": null,
|
||||
"fee_internal_type": null,
|
||||
"event_date_from": "2017-12-27T10:00:00Z",
|
||||
"event_date_to": null,
|
||||
"event_location": "Heidelberg",
|
||||
"attendee_name": null,
|
||||
"gross_value": "23.00",
|
||||
"tax_value": "0.00",
|
||||
@@ -285,12 +263,10 @@ Endpoints
|
||||
"description": "Budget Ticket",
|
||||
"item": 1234,
|
||||
"variation": 245,
|
||||
"subevent": null,
|
||||
"fee_type": null,
|
||||
"fee_internal_type": null,
|
||||
"event_date_from": "2017-12-27T10:00:00Z",
|
||||
"event_date_to": null,
|
||||
"event_location": "Heidelberg",
|
||||
"attendee_name": null,
|
||||
"gross_value": "23.00",
|
||||
"tax_value": "0.00",
|
||||
|
||||
@@ -24,9 +24,6 @@ active boolean If ``false``, t
|
||||
description multi-lingual string A public description of the variation. May contain
|
||||
Markdown syntax or can be ``null``.
|
||||
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_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
|
||||
be hidden from users without a valid membership.
|
||||
@@ -79,7 +76,6 @@ Endpoints
|
||||
"en": "S"
|
||||
},
|
||||
"active": true,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
@@ -101,7 +97,6 @@ Endpoints
|
||||
"en": "L"
|
||||
},
|
||||
"active": true,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
@@ -152,7 +147,6 @@ Endpoints
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
@@ -189,7 +183,6 @@ Endpoints
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"active": true,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
@@ -216,7 +209,6 @@ Endpoints
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
@@ -274,7 +266,6 @@ Endpoints
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": false,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
|
||||
@@ -132,10 +132,6 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``item`` and ``variation`` query parameters have been added.
|
||||
|
||||
.. versionchanged:: 4.6
|
||||
|
||||
The ``subevent`` query parameters has been added.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -437,7 +433,6 @@ List of all orders
|
||||
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.
|
||||
: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_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.
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
Automated email rules
|
||||
=====================
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
Automated email rules that specify emails that the system will send automatically at a specific point in time, e.g.
|
||||
the day of the event.
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the rule
|
||||
enabled boolean If ``false``, the rule is ignored
|
||||
subject multi-lingual string The subject of the email
|
||||
template multi-lingual string The body of the email
|
||||
all_products boolean If ``true``, the email is sent to buyers of all products
|
||||
limit_products list of integers List of product IDs, if ``all_products`` is not set
|
||||
include_pending boolean If ``true``, the email is sent to pending orders. If ``false``,
|
||||
only paid orders are considered.
|
||||
date_is_absolute boolean If ``true``, the email is set at a specific point in time.
|
||||
send_date datetime If ``date_is_absolute`` is set: Date and time to send the email.
|
||||
send_offset_days integer If ``date_is_absolute`` is not set, this is the number of days
|
||||
before/after the email is sent.
|
||||
send_offset_time time If ``date_is_absolute`` is not set, this is the time of day the
|
||||
email is sent on the day specified by ``send_offset_days``.
|
||||
offset_to_event_end boolean If ``true``, ``send_offset_days`` is relative to the event end
|
||||
date. Otherwise it is relative to the event start date.
|
||||
offset_is_after boolean If ``true``, ``send_offset_days`` is the number of days **after**
|
||||
the event start or end date. Otherwise it is the number of days
|
||||
**before**.
|
||||
send_to string Can be ``"orders"`` if the email should be sent to customers
|
||||
(one email per order),
|
||||
``"attendees"`` if the email should be sent to every attendee,
|
||||
or ``"both"``.
|
||||
date. Otherwise it is relative to the event start date.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/
|
||||
|
||||
Returns a list of all rules configured for an event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"enabled": true,
|
||||
"subject": {"en": "See you tomorrow!"},
|
||||
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"send_date": null,
|
||||
"send_offset_days": 1,
|
||||
"send_offset_time": "18:00",
|
||||
"date_is_absolute": false,
|
||||
"offset_to_event_end": false,
|
||||
"offset_is_after": false,
|
||||
"send_to": "orders"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/(id)/
|
||||
|
||||
Returns information on one rule, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"enabled": true,
|
||||
"subject": {"en": "See you tomorrow!"},
|
||||
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"send_date": null,
|
||||
"send_offset_days": 1,
|
||||
"send_offset_time": "18:00",
|
||||
"date_is_absolute": false,
|
||||
"offset_to_event_end": false,
|
||||
"offset_is_after": false,
|
||||
"send_to": "orders"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the rule to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/
|
||||
|
||||
Create a new rule.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 166
|
||||
|
||||
{
|
||||
"enabled": true,
|
||||
"subject": {"en": "See you tomorrow!"},
|
||||
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"send_date": null,
|
||||
"send_offset_days": 1,
|
||||
"send_offset_time": "18:00",
|
||||
"date_is_absolute": false,
|
||||
"offset_to_event_end": false,
|
||||
"offset_is_after": false,
|
||||
"send_to": "orders"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"enabled": true,
|
||||
"subject": {"en": "See you tomorrow!"},
|
||||
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"send_date": null,
|
||||
"send_offset_days": 1,
|
||||
"send_offset_time": "18:00",
|
||||
"date_is_absolute": false,
|
||||
"offset_to_event_end": false,
|
||||
"offset_is_after": false,
|
||||
"send_to": "orders"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a rule for
|
||||
:param event: The ``slug`` field of the event to create a rule for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The rule could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create rules.
|
||||
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/(id)/
|
||||
|
||||
Update a rule. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 34
|
||||
|
||||
{
|
||||
"enabled": false,
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"enabled": false,
|
||||
"subject": {"en": "See you tomorrow!"},
|
||||
"template": {"en": "Don't forget your tickets, download them at {url}"},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"send_date": null,
|
||||
"send_offset_days": 1,
|
||||
"send_offset_time": "18:00",
|
||||
"date_is_absolute": false,
|
||||
"offset_to_event_end": false,
|
||||
"offset_is_after": false,
|
||||
"send_to": "orders"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the rule to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The rule could not be modified due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it.
|
||||
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/sendmail_rules/(id)/
|
||||
|
||||
Delete a rule.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/sendmail_rules/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the rule to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this rule cannot be deleted since it is currently in use.
|
||||
@@ -16,22 +16,15 @@ Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the tax rule
|
||||
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
|
||||
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
|
||||
the specified product price
|
||||
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
|
||||
home_country string Merchant country (required for reverse charge), can be
|
||||
``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
|
||||
---------
|
||||
|
||||
@@ -63,11 +56,9 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"internal_name": "VAT",
|
||||
"rate": "19.00",
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"home_country": "DE"
|
||||
}
|
||||
]
|
||||
@@ -103,11 +94,9 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"internal_name": "VAT",
|
||||
"rate": "19.00",
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"home_country": "DE"
|
||||
}
|
||||
|
||||
@@ -151,11 +140,9 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"internal_name": "VAT",
|
||||
"rate": "19.00",
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"home_country": "DE"
|
||||
}
|
||||
|
||||
@@ -198,11 +185,9 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"internal_name": "VAT",
|
||||
"rate": "20.00",
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"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
|
||||
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very important
|
||||
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
.. 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,7 +17,6 @@ Contents:
|
||||
shredder
|
||||
import
|
||||
customview
|
||||
cookieconsent
|
||||
auth
|
||||
general
|
||||
quality
|
||||
|
||||
@@ -62,8 +62,6 @@ The provider class
|
||||
|
||||
.. autoattribute:: public_name
|
||||
|
||||
.. autoattribute:: confirm_button_name
|
||||
|
||||
.. autoattribute:: is_enabled
|
||||
|
||||
.. autoattribute:: priority
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
.. spelling:: Rebase rebasing
|
||||
|
||||
Coding style and quality
|
||||
========================
|
||||
|
||||
Code
|
||||
----
|
||||
|
||||
* Basically, we want all python code to follow the `PEP 8`_ standard. There are a few exceptions where
|
||||
we see things differently or just aren't that strict. The ``setup.cfg`` file in the project's source
|
||||
folder contains definitions that allow `flake8`_ to check for violations automatically. See :ref:`checksandtests`
|
||||
@@ -25,62 +20,8 @@ Code
|
||||
test suite are in the style of Python's unit test module. If you extend those files, you might continue in this style,
|
||||
but please use ``pytest`` style for any new test files.
|
||||
|
||||
Commits and Pull Requests
|
||||
-------------------------
|
||||
|
||||
|
||||
|
||||
Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since
|
||||
the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge"
|
||||
feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the
|
||||
individual commits, we use "Rebase and merge" instead. Merge commits should be avoided.
|
||||
|
||||
* The commit message should start with a single subject line and can optionally be followed by a commit message body.
|
||||
|
||||
* The subject line should be the shortest possible representation of what the commit changes. Someone who reviewed
|
||||
the commit should able to immediately remember the commit in a couple of weeks based on the subject line and tell
|
||||
it apart from other commits.
|
||||
|
||||
* If there's additional useful information that we should keep, such as reasoning behind the commit, you can
|
||||
add a longer body, separated from the first line by a blank line.
|
||||
|
||||
* The body should explain **what** you changed and more importantly **why** you changed it. There's no need to iterate
|
||||
**how** you changed something.
|
||||
|
||||
* The subject line should be capitalized ("Add new feature" instead of "add new feature") and should not end with a period
|
||||
("Add new feature" instead of "Add new feature.")
|
||||
|
||||
* The subject line should be written in imperative mood, as if you were giving a command what the computer should do if the
|
||||
commit is applied. This is how generated commit messages by git itself are already written ("Merge branch …", "Revert …")
|
||||
and makes for short and consistent messages.
|
||||
|
||||
* Good: "Fix typo in template"
|
||||
* Good: "Add Chinese translation"
|
||||
* Good: "Remove deprecated method"
|
||||
* Good: "Bump version to 4.4.0"
|
||||
* Bad: "Fixed bug with …"
|
||||
* Bad: "Fixes bug with …"
|
||||
* Bad: "Fixing bug …"
|
||||
|
||||
* If all changes in your commit are in context of a single feature or e.g. a bundled plugin, it makes sense to prefix the
|
||||
subject line with the name of that feature. Examples:
|
||||
|
||||
* "API: Add support for PATCH on customers"
|
||||
* "Docs: Add chapter on alpaca feeding"
|
||||
* "Stripe: Fix duplicate payments"
|
||||
* "Order change form: Fix incorrect validation"
|
||||
|
||||
* If your commit references a GitHub issue that is fully resolved by your commit, start your subject line with the issue
|
||||
ID in the form of "Fix #1234 -- Crash in order list". In this case, you can omit the verb "Fix" at the beginning of the
|
||||
second part of the message to avoid repetition of the word "fix". If your commit only partially resolves the issue, use
|
||||
"Refs #1234 -- Crash in order list" instead.
|
||||
|
||||
* Applies to pretix employees only: If your commit references a sentry issue, please put it in parentheses at the end
|
||||
of the subject line or inside the body ("Fix crash in order list (PRETIXEU-ABC)"). If your commit references a support
|
||||
ticket, please put it in parentheses at the end of the subject line with a "Z#" prefix ("Fix crash in order list (Z#12345)").
|
||||
|
||||
* If your PR was open for a while and might cause conflicts on merge, please prefer rebasing it (``git rebase -i master``)
|
||||
over merging ``master`` into your branch unless it is prohibitively complicated.
|
||||
* Please keep the first line of your commit messages short. When referencing an issue, please phrase it like
|
||||
``Fix #123 -- Problems with order creation`` or ``Refs #123 -- Fix this part of that bug``.
|
||||
|
||||
|
||||
.. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/
|
||||
|
||||
@@ -26,7 +26,7 @@ Your should install the following on your system:
|
||||
* ``libssl`` (Debian package: ``libssl-dev``)
|
||||
* ``libxml2`` (Debian package ``libxml2-dev``)
|
||||
* ``libxslt`` (Debian package ``libxslt1-dev``)
|
||||
* ``libenchant-2-2`` (Debian package ``libenchant-2-2``)
|
||||
* ``libenchant1c2a`` (Debian package ``libenchant1c2a``)
|
||||
* ``msgfmt`` (Debian package ``gettext``)
|
||||
* ``git``
|
||||
|
||||
@@ -51,12 +51,7 @@ the dependencies might fail::
|
||||
|
||||
Working with the code
|
||||
---------------------
|
||||
If you do not have a recent installation of ``nodejs``, install it now::
|
||||
|
||||
curl -sL https://deb.nodesource.com/setup_17.x | sudo -E bash -
|
||||
sudo apt install nodejs
|
||||
|
||||
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
|
||||
The first thing you need are all the main application's dependencies::
|
||||
|
||||
cd src/
|
||||
pip3 install -e ".[dev]"
|
||||
|
||||
@@ -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
|
||||
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax
|
||||
.. _SPF specification: http://www.openspf.org/SPF_Record_Syntax
|
||||
|
||||
@@ -309,10 +309,6 @@ 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
|
||||
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
|
||||
Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
|
||||
|
||||
@@ -34,7 +34,5 @@ git push
|
||||
# Unlock Weblate
|
||||
for c in $COMPONENTS; do
|
||||
wlc unlock $c;
|
||||
done
|
||||
for c in $COMPONENTS; do
|
||||
wlc pull $c;
|
||||
done
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "4.6.0"
|
||||
__version__ = "4.4.0.dev0"
|
||||
|
||||
@@ -637,7 +637,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = TaxRule
|
||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
|
||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
|
||||
|
||||
|
||||
class EventSettingsSerializer(SettingsSerializer):
|
||||
@@ -713,6 +713,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'ticket_download_require_validated_email',
|
||||
'ticket_secret_length',
|
||||
'mail_prefix',
|
||||
'mail_from',
|
||||
'mail_from_name',
|
||||
'mail_attach_ical',
|
||||
'mail_attach_tickets',
|
||||
@@ -733,7 +734,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_numbers_prefix_cancellations',
|
||||
'invoice_numbers_counter_length',
|
||||
'invoice_attendee_name',
|
||||
'invoice_event_location',
|
||||
'invoice_include_expire_date',
|
||||
'invoice_address_explanation_text',
|
||||
'invoice_email_attachment',
|
||||
@@ -763,7 +763,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'cancel_allow_user_paid_refund_as_giftcard',
|
||||
'cancel_allow_user_paid_require_approval',
|
||||
'change_allow_user_variation',
|
||||
'change_allow_user_addons',
|
||||
'change_allow_user_until',
|
||||
'change_allow_user_price',
|
||||
'primary_color',
|
||||
|
||||
@@ -58,9 +58,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price', 'require_approval',
|
||||
'require_membership', 'require_membership_types',
|
||||
'require_membership_hidden', 'available_from', 'available_until',
|
||||
'position', 'default_price', 'price', 'original_price',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -75,9 +74,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price', 'require_approval',
|
||||
'require_membership', 'require_membership_types',
|
||||
'require_membership_hidden', 'available_from', 'available_until',
|
||||
'position', 'default_price', 'price', 'original_price',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -1426,9 +1426,9 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = InvoiceLine
|
||||
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
|
||||
fields = ('position', 'description', 'item', 'variation', 'attendee_name', 'event_date_from',
|
||||
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
|
||||
'fee_internal_type', 'event_location')
|
||||
'fee_internal_type')
|
||||
|
||||
|
||||
class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -296,14 +296,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
'theme_round_borders',
|
||||
'primary_font',
|
||||
'organizer_logo_image_inherit',
|
||||
'organizer_logo_image',
|
||||
'privacy_url',
|
||||
'cookie_consent',
|
||||
'cookie_consent_dialog_title',
|
||||
'cookie_consent_dialog_text',
|
||||
'cookie_consent_dialog_text_secondary',
|
||||
'cookie_consent_dialog_button_yes',
|
||||
'cookie_consent_dialog_button_no',
|
||||
'organizer_logo_image'
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -69,7 +69,7 @@ class ExportersMixin:
|
||||
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
||||
if cf.file:
|
||||
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
|
||||
return resp
|
||||
elif not settings.HAS_CELERY:
|
||||
return Response(
|
||||
|
||||
@@ -94,7 +94,6 @@ with scopes_disabled():
|
||||
search = django_filters.CharFilter(method='search_qs')
|
||||
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_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:
|
||||
model = Order
|
||||
|
||||
@@ -46,7 +46,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import invoice # NOQA
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .services import auth, checkin, checks, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .models import _transactions # NOQA
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -146,8 +146,7 @@ class NativeAuthBackend(BaseAuthBackend):
|
||||
d = OrderedDict([
|
||||
('email', forms.EmailField(label=_("E-mail"), max_length=254,
|
||||
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
|
||||
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
|
||||
max_length=4096)),
|
||||
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)),
|
||||
])
|
||||
return d
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from smtplib import SMTPResponseException
|
||||
from typing import TypeVar
|
||||
|
||||
import css_inline
|
||||
from django.conf import settings
|
||||
@@ -50,23 +49,23 @@ from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
|
||||
logger = logging.getLogger('pretix.base.email')
|
||||
|
||||
T = TypeVar("T", bound=EmailBackend)
|
||||
|
||||
class CustomSMTPBackend(EmailBackend):
|
||||
|
||||
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
|
||||
try:
|
||||
backend.open()
|
||||
backend.connection.ehlo_or_helo_if_needed()
|
||||
(code, resp) = backend.connection.mail(from_addr, [])
|
||||
if code != 250:
|
||||
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||
raise SMTPResponseException(code, resp)
|
||||
(code, resp) = backend.connection.rcpt('testdummy@pretix.eu')
|
||||
if (code != 250) and (code != 251):
|
||||
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||
raise SMTPResponseException(code, resp)
|
||||
finally:
|
||||
backend.close()
|
||||
def test(self, from_addr):
|
||||
try:
|
||||
self.open()
|
||||
self.connection.ehlo_or_helo_if_needed()
|
||||
(code, resp) = self.connection.mail(from_addr, [])
|
||||
if code != 250:
|
||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||
raise SMTPResponseException(code, resp)
|
||||
(code, resp) = self.connection.rcpt('testdummy@pretix.eu')
|
||||
if (code != 250) and (code != 251):
|
||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||
raise SMTPResponseException(code, resp)
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
|
||||
class BaseHTMLMailRenderer:
|
||||
|
||||
@@ -324,6 +324,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
_('Tax rate'),
|
||||
_('Tax name'),
|
||||
_('Event start date'),
|
||||
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('E-mail address'),
|
||||
@@ -347,8 +348,6 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
_('Payment providers'),
|
||||
_('Event end date'),
|
||||
_('Location'),
|
||||
]
|
||||
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
@@ -407,9 +406,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((l.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]),
|
||||
date_format(l.event_date_to, "SHORT_DATE_FORMAT") if l.event_date_to else "",
|
||||
l.event_location or "",
|
||||
])
|
||||
]
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -118,27 +118,6 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
self.cleaned_data[k] = self.initial[k]
|
||||
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:
|
||||
from pretix.base.models import Event
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ class RegistrationForm(forms.Form):
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||
}),
|
||||
max_length=4096,
|
||||
required=True
|
||||
)
|
||||
password_repeat = forms.CharField(
|
||||
@@ -162,7 +161,6 @@ class RegistrationForm(forms.Form):
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||
}),
|
||||
max_length=4096,
|
||||
required=True
|
||||
)
|
||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||
@@ -206,13 +204,11 @@ class PasswordRecoverForm(forms.Form):
|
||||
password = forms.CharField(
|
||||
label=_('Password'),
|
||||
widget=forms.PasswordInput,
|
||||
max_length=4096,
|
||||
required=True
|
||||
)
|
||||
password_repeat = forms.CharField(
|
||||
label=_('Repeat password'),
|
||||
widget=forms.PasswordInput,
|
||||
max_length=4096,
|
||||
widget=forms.PasswordInput
|
||||
)
|
||||
|
||||
def __init__(self, user_id=None, *args, **kwargs):
|
||||
|
||||
@@ -333,41 +333,23 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
def guess_country(event):
|
||||
# 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 :)
|
||||
locale = get_language_without_region()
|
||||
country = event.settings.region or event.settings.invoice_address_from_country
|
||||
if not country:
|
||||
country = get_country_by_locale(get_language_without_region())
|
||||
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 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):
|
||||
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
||||
|
||||
@@ -798,26 +780,25 @@ class BaseQuestionsForm(forms.Form):
|
||||
if q.valid_datetime_max:
|
||||
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||
elif q.type == Question.TYPE_PHONENUMBER:
|
||||
if initial:
|
||||
with language(get_babel_locale()):
|
||||
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:
|
||||
initial = PhoneNumber().from_string(initial.answer)
|
||||
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
|
||||
except NumberParseException:
|
||||
initial = None
|
||||
|
||||
if not initial:
|
||||
phone_prefix = guess_phone_prefix(event)
|
||||
if phone_prefix:
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
|
||||
field = PhoneNumberField(
|
||||
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 = PhoneNumberField(
|
||||
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
|
||||
if answers:
|
||||
# Cache the answer object for later use
|
||||
@@ -888,12 +869,6 @@ class BaseQuestionsForm(forms.Form):
|
||||
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.')]})
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -1069,11 +1044,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.instance.vat_id_validated = True
|
||||
self.instance.vat_id = normalized_id
|
||||
except VATIDFinalError as e:
|
||||
if self.all_optional:
|
||||
self.instance.vat_id_validated = False
|
||||
messages.warning(self.request, e.message)
|
||||
else:
|
||||
raise ValidationError(e.message)
|
||||
raise ValidationError(e.message)
|
||||
except VATIDTemporaryError as e:
|
||||
self.instance.vat_id_validated = False
|
||||
if self.request and self.vat_warning:
|
||||
|
||||
@@ -55,7 +55,6 @@ class UserSettingsForm(forms.ModelForm):
|
||||
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||
'pw_mismatch': _("Please enter the same password twice"),
|
||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||
'pw_equal': _("Please choose a password different to your current one.")
|
||||
}
|
||||
|
||||
old_pw = forms.CharField(max_length=255,
|
||||
@@ -159,12 +158,6 @@ class UserSettingsForm(forms.ModelForm):
|
||||
code='pw_current'
|
||||
)
|
||||
|
||||
if password1 and password1 == old_pw:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_equal'],
|
||||
code='pw_equal'
|
||||
)
|
||||
|
||||
if password1:
|
||||
self.instance.set_password(password1)
|
||||
|
||||
|
||||
@@ -86,6 +86,14 @@ class TimePickerWidget(forms.TimeInput):
|
||||
|
||||
class UploadedFileWidget(forms.ClearableFileInput):
|
||||
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.event = kwargs.pop('event')
|
||||
self.answer = kwargs.pop('answer')
|
||||
@@ -117,15 +125,6 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
||||
'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):
|
||||
if self.is_initial(value):
|
||||
return self.FakeFile(value, self.position, self.event, self.answer)
|
||||
|
||||
@@ -395,13 +395,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
return txt
|
||||
|
||||
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
|
||||
tz = self.invoice.event.timezone
|
||||
show_end_date = (
|
||||
self.invoice.event.settings.show_date_to and
|
||||
self.invoice.event.date_to and
|
||||
self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date()
|
||||
)
|
||||
if show_end_date:
|
||||
if self.invoice.event.settings.show_date_to and self.invoice.event.date_to:
|
||||
p_str = (
|
||||
shorten(self.invoice.event.name) + '\n' +
|
||||
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
|
||||
@@ -556,10 +550,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
for line in self.invoice.lines.all():
|
||||
if has_taxes:
|
||||
tdata.append((
|
||||
Paragraph(
|
||||
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
|
||||
self.stylesheet['Normal']
|
||||
),
|
||||
Paragraph(line.description, self.stylesheet['Normal']),
|
||||
"1",
|
||||
localize(line.tax_rate) + " %",
|
||||
money_filter(line.net_value, self.invoice.event.currency),
|
||||
@@ -567,10 +558,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
))
|
||||
else:
|
||||
tdata.append((
|
||||
Paragraph(
|
||||
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
|
||||
self.stylesheet['Normal']
|
||||
),
|
||||
Paragraph(line.description, self.stylesheet['Normal']),
|
||||
"1",
|
||||
money_filter(line.gross_value, self.invoice.event.currency),
|
||||
))
|
||||
|
||||
@@ -23,9 +23,7 @@ from decimal import Decimal
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models
|
||||
from django.db.models import (
|
||||
Case, Count, F, OuterRef, Q, Subquery, Sum, Value, When,
|
||||
)
|
||||
from django.db.models import Case, F, OuterRef, Q, Subquery, Sum, Value, When
|
||||
from django.db.models.functions import Coalesce
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
@@ -45,19 +43,7 @@ class Command(BaseCommand):
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('price')).values('p'),
|
||||
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()
|
||||
), Value(Decimal(0)), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
fee_total=Coalesce(
|
||||
Subquery(
|
||||
@@ -65,7 +51,7 @@ class Command(BaseCommand):
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('value')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(Decimal(0)), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
tx_total=Coalesce(
|
||||
Subquery(
|
||||
@@ -73,35 +59,21 @@ class Command(BaseCommand):
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum(F('price') * 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)
|
||||
),
|
||||
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)
|
||||
), Value(Decimal(0)), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
).annotate(
|
||||
correct_total=Case(
|
||||
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
|
||||
then=Value(0)),
|
||||
then=Value(Decimal(0))),
|
||||
default=F('position_total') + F('fee_total'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
).exclude(
|
||||
total=F('position_total') + F('fee_total'),
|
||||
tx_total=F('correct_total'),
|
||||
tx_cnt=F('position_cnt')
|
||||
tx_total=F('correct_total')
|
||||
).select_related('event')
|
||||
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') \
|
||||
and o.tx_cnt == o.position_cnt:
|
||||
# Ignore SQLite which treats Decimals like floats…
|
||||
continue
|
||||
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}, pos_cnt={o.position_cnt}, tx_pos_cnt={o.tx_cnt}")
|
||||
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}")
|
||||
|
||||
self.stderr.write(self.style.SUCCESS(f'Check completed.'))
|
||||
|
||||
@@ -208,7 +208,7 @@ def _parse_csp(header):
|
||||
|
||||
|
||||
def _render_csp(h):
|
||||
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v)
|
||||
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items())
|
||||
|
||||
|
||||
def _merge_csp(a, b):
|
||||
|
||||
29
src/pretix/base/migrations/0201_check.py
Normal file
29
src/pretix/base/migrations/0201_check.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.2.4 on 2021-10-29 09:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0200_transaction'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Check',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('result', models.CharField(max_length=190)),
|
||||
('check_type', models.CharField(max_length=190)),
|
||||
('log', models.TextField()),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.4 on 2021-11-03 09:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0200_transaction'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceline',
|
||||
name='event_location',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.9 on 2021-11-04 13:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0201_invoiceline_event_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='needs_password_change',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 3.2.2 on 2021-11-08 07:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0202_user_needs_password_change'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='is_bundled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
# Generated by Django 3.2.2 on 2021-11-08 07:51
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
|
||||
def fill_is_bundled(apps, schema_editor):
|
||||
# We cannot really know if a position was bundled or an add-on, but we can at least guess
|
||||
ItemBundle = apps.get_model("pretixbase", "ItemBundle")
|
||||
OrderPosition = apps.get_model("pretixbase", "OrderPosition")
|
||||
|
||||
for ib in ItemBundle.objects.iterator():
|
||||
OrderPosition.all.alias(
|
||||
pos_earlier=Coalesce(Subquery(
|
||||
OrderPosition.all.filter(
|
||||
canceled=False,
|
||||
addon_to=OuterRef('addon_to'),
|
||||
item=ib.bundled_item,
|
||||
variation=ib.bundled_variation,
|
||||
positionid__lt=OuterRef('positionid'),
|
||||
).values('addon_to').order_by().annotate(c=Count('*')).values('c'),
|
||||
output_field=models.IntegerField()
|
||||
), 0)
|
||||
).filter(
|
||||
canceled=False,
|
||||
addon_to__item=ib.base_item,
|
||||
item=ib.bundled_item,
|
||||
variation=ib.bundled_variation,
|
||||
pos_earlier__lt=ib.count,
|
||||
).update(
|
||||
is_bundled=True
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0203_orderposition_is_bundled'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
fill_is_bundled,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -23,6 +23,7 @@ from ..settings import GlobalSettingsObject_SettingsStore
|
||||
from .auth import U2FDevice, User, WebAuthnDevice
|
||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||
from .checkin import Checkin, CheckinList
|
||||
from .checks import Check
|
||||
from .customers import Customer
|
||||
from .devices import Device, Gate
|
||||
from .event import (
|
||||
|
||||
@@ -36,7 +36,7 @@ from django.db import transaction
|
||||
dirty_transactions = threading.local()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
fail_loudly = os.getenv('PRETIX_DIRTY_TRANSACTIONS_QUIET', 'false' if settings.DEBUG else 'true') not in ('true', 'True', 'on', '1')
|
||||
fail_loudly = os.getenv('PRETIX_DIRTY_TRANSACTIONS_QUIET', 'false') not in ('true', 'True', 'on', '1')
|
||||
|
||||
|
||||
class DirtyTransactionsForOrderException(Exception):
|
||||
|
||||
@@ -113,8 +113,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
:type date_joined: datetime
|
||||
:param locale: The user's preferred locale code.
|
||||
:type locale: str
|
||||
:param needs_password_change: Whether this user's password needs to be changed.
|
||||
:type needs_password_change: bool
|
||||
:param timezone: The user's preferred timezone.
|
||||
:type timezone: str
|
||||
"""
|
||||
@@ -132,8 +130,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
verbose_name=_('Is site admin'))
|
||||
date_joined = models.DateTimeField(auto_now_add=True,
|
||||
verbose_name=_('Date joined'))
|
||||
needs_password_change = models.BooleanField(default=False,
|
||||
verbose_name=_('Force user to select a new password'))
|
||||
locale = models.CharField(max_length=50,
|
||||
choices=settings.LANGUAGES,
|
||||
default=settings.LANGUAGE_CODE,
|
||||
|
||||
@@ -26,39 +26,29 @@
|
||||
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
|
||||
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
|
||||
#
|
||||
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
|
||||
# This file contains Apache-licensed contributions copyrighted by: Jakob Schnell
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.presale.signals import register_cookie_providers
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
|
||||
class UsageClass(Enum):
|
||||
FUNCTIONAL = 1
|
||||
ANALYTICS = 2
|
||||
MARKETING = 3
|
||||
SOCIAL = 4
|
||||
class Check(LoggedModel):
|
||||
RESULT_OK = 'ok'
|
||||
RESULT_WARNING = 'warning'
|
||||
RESULT_ERROR = 'error'
|
||||
|
||||
RESULTS = (
|
||||
(RESULT_OK, _('OK')),
|
||||
(RESULT_WARNING, _('Warning')),
|
||||
(RESULT_ERROR, _('Error')),
|
||||
)
|
||||
|
||||
class CookieProvider:
|
||||
def __init__(self, identifier: str, usage_classes: List[UsageClass], provider_name: str, privacy_url: str = None, **kwargs):
|
||||
self.identifier = identifier
|
||||
self.usage_classes = usage_classes
|
||||
self.provider_name = provider_name
|
||||
self.privacy_url = privacy_url
|
||||
|
||||
|
||||
def get_cookie_providers(event, request):
|
||||
c = [
|
||||
]
|
||||
for receiver, response in register_cookie_providers.send(event, request=request):
|
||||
if isinstance(response, list):
|
||||
c += response
|
||||
else:
|
||||
c.append(response)
|
||||
c.sort(key=lambda k: str(k.provider_name))
|
||||
return c
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
result = models.CharField(max_length=190, choices=RESULTS)
|
||||
check_type = models.CharField(max_length=190)
|
||||
log = models.TextField()
|
||||
@@ -29,7 +29,6 @@ from django.db.models import F, Q
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.models.base import LoggedModel
|
||||
@@ -46,7 +45,6 @@ class Customer(LoggedModel):
|
||||
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
||||
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)
|
||||
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
|
||||
password = models.CharField(verbose_name=_('Password'), max_length=128)
|
||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
name_parts = models.JSONField(default=dict)
|
||||
@@ -89,7 +87,6 @@ class Customer(LoggedModel):
|
||||
self.name_parts = {}
|
||||
self.name_cached = ''
|
||||
self.email = None
|
||||
self.phone = None
|
||||
self.save()
|
||||
self.all_logentries().update(data={}, shredded=True)
|
||||
self.orders.all().update(customer=None)
|
||||
|
||||
@@ -565,8 +565,6 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.ticketoutput_pdf__enabled = True
|
||||
self.settings.ticketoutput_passbook__enabled = True
|
||||
self.settings.event_list_type = 'calendar'
|
||||
self.settings.invoice_email_attachment = True
|
||||
self.settings.name_scheme = 'given_family'
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
@@ -665,22 +663,21 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
return locking.LockManager(self)
|
||||
|
||||
def get_mail_backend(self, timeout=None):
|
||||
def get_mail_backend(self, timeout=None, force_custom=False):
|
||||
"""
|
||||
Returns an email server connection, either by using the system-wide connection
|
||||
or by returning a custom one based on the event's settings.
|
||||
"""
|
||||
from pretix.base.email import CustomSMTPBackend
|
||||
|
||||
if self.settings.smtp_use_custom:
|
||||
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||
host=self.settings.smtp_host,
|
||||
port=self.settings.smtp_port,
|
||||
username=self.settings.smtp_username,
|
||||
password=self.settings.smtp_password,
|
||||
use_tls=self.settings.smtp_use_tls,
|
||||
use_ssl=self.settings.smtp_use_ssl,
|
||||
fail_silently=False,
|
||||
timeout=timeout)
|
||||
if self.settings.smtp_use_custom or force_custom:
|
||||
return CustomSMTPBackend(host=self.settings.smtp_host,
|
||||
port=self.settings.smtp_port,
|
||||
username=self.settings.smtp_username,
|
||||
password=self.settings.smtp_password,
|
||||
use_tls=self.settings.smtp_use_tls,
|
||||
use_ssl=self.settings.smtp_use_ssl,
|
||||
fail_silently=False, timeout=timeout)
|
||||
else:
|
||||
return get_connection(fail_silently=False)
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ class Invoice(models.Model):
|
||||
def _get_invoice_number_from_order(self):
|
||||
return '{order}-{count}'.format(
|
||||
order=self.order.code,
|
||||
count=Invoice.objects.filter(event=self.event, prefix=self.prefix, invoice_no__startswith=f"{self.order.code}-", order=self.order).count() + 1,
|
||||
count=Invoice.objects.filter(event=self.event, order=self.order).count() + 1,
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -264,7 +264,6 @@ class Invoice(models.Model):
|
||||
self.invoice_no = self._get_invoice_number_from_order()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self.full_invoice_no = self.prefix + self.invoice_no
|
||||
return super().save(*args, **kwargs)
|
||||
except DatabaseError:
|
||||
# Suppress duplicate key errors and try again
|
||||
@@ -329,8 +328,6 @@ class InvoiceLine(models.Model):
|
||||
:type event_date_from: datetime
|
||||
:param event_date_to: Event end date of the (sub)event at the time the invoice was created
|
||||
:type event_date_to: datetime
|
||||
:param event_location: Event location of the (sub)event at the time the invoice was created
|
||||
:type event_location: str
|
||||
:param item: The item this line refers to
|
||||
:type item: Item
|
||||
:param variation: The variation this line refers to
|
||||
@@ -348,7 +345,6 @@ class InvoiceLine(models.Model):
|
||||
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event_date_from = models.DateTimeField(null=True)
|
||||
event_date_to = models.DateTimeField(null=True)
|
||||
event_location = models.TextField(null=True, blank=True)
|
||||
item = models.ForeignKey('Item', null=True, blank=True, on_delete=models.PROTECT)
|
||||
variation = models.ForeignKey('ItemVariation', null=True, blank=True, on_delete=models.PROTECT)
|
||||
attendee_name = models.TextField(null=True, blank=True)
|
||||
|
||||
@@ -764,9 +764,6 @@ class ItemVariation(models.Model):
|
||||
:type default_price: decimal.Decimal
|
||||
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
|
||||
: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,
|
||||
@@ -802,13 +799,6 @@ 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 '
|
||||
'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(
|
||||
verbose_name=_('Require a valid membership'),
|
||||
default=False,
|
||||
@@ -842,7 +832,7 @@ class ItemVariation(models.Model):
|
||||
blank=True,
|
||||
)
|
||||
hide_without_voucher = models.BooleanField(
|
||||
verbose_name=_('Show only if a matching voucher is redeemed.'),
|
||||
verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'),
|
||||
default=False,
|
||||
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
|
||||
'that unlocks this variation.')
|
||||
|
||||
@@ -75,7 +75,7 @@ from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Customer, User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.locking import LOCK_TIMEOUT, NoLockManager
|
||||
from pretix.base.services.locking import NoLockManager
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import order_gracefully_delete
|
||||
|
||||
@@ -581,7 +581,6 @@ class Order(LockModel, LoggedModel):
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
from .checkin import Checkin
|
||||
from .items import ItemAddOn
|
||||
|
||||
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID) or not self.count_positions:
|
||||
return False
|
||||
@@ -607,10 +606,7 @@ class Order(LockModel, LoggedModel):
|
||||
if self.user_change_deadline and now() > self.user_change_deadline:
|
||||
return False
|
||||
|
||||
return (
|
||||
(self.event.settings.change_allow_user_variation and any([op.has_variations for op in positions])) or
|
||||
(self.event.settings.change_allow_user_addons and ItemAddOn.objects.filter(base_item_id__in=[op.item_id for op in positions]).exists())
|
||||
)
|
||||
return self.event.settings.change_allow_user_variation and any([op.has_variations for op in positions])
|
||||
|
||||
@property
|
||||
@scopes_disabled()
|
||||
@@ -950,7 +946,7 @@ class Order(LockModel, LoggedModel):
|
||||
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,
|
||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
|
||||
attach_ical=False, attach_other_files: list=None):
|
||||
attach_ical=False):
|
||||
"""
|
||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||
|
||||
@@ -994,8 +990,7 @@ class Order(LockModel, LoggedModel):
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers=headers, sender=sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets,
|
||||
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
||||
attach_other_files=attach_other_files,
|
||||
position=position, auto_email=auto_email, attach_ical=attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
@@ -1311,7 +1306,6 @@ class AbstractPosition(models.Model):
|
||||
seat = models.ForeignKey(
|
||||
'Seat', null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
is_bundled = models.BooleanField(default=False)
|
||||
|
||||
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
|
||||
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
|
||||
@@ -1442,15 +1436,6 @@ class AbstractPosition(models.Model):
|
||||
lines = [r.strip() for r in lines if r]
|
||||
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):
|
||||
"""
|
||||
@@ -1557,7 +1542,7 @@ class OrderPayment(models.Model):
|
||||
return self.order.event.get_payment_providers(cached=True).get(self.provider)
|
||||
|
||||
@transaction.atomic()
|
||||
def _mark_paid_inner(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
|
||||
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
|
||||
from pretix.base.signals import order_paid
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
|
||||
if can_be_paid is not True:
|
||||
@@ -1633,6 +1618,10 @@ class OrderPayment(models.Model):
|
||||
:type mail_text: str
|
||||
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
|
||||
"""
|
||||
from pretix.base.services.invoices import (
|
||||
generate_invoice, invoice_qualified,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
|
||||
@@ -1676,15 +1665,7 @@ class OrderPayment(models.Model):
|
||||
))
|
||||
return
|
||||
|
||||
self._mark_order_paid(count_waitinglist, send_mail, force, user, auth, mail_text, ignore_date, lock, payment_sum - refund_sum)
|
||||
|
||||
def _mark_order_paid(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
|
||||
ignore_date=False, lock=True, payment_refund_sum=0):
|
||||
from pretix.base.services.invoices import (
|
||||
generate_invoice, invoice_qualified,
|
||||
)
|
||||
|
||||
if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(seconds=LOCK_TIMEOUT * 2)) or not lock:
|
||||
if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12)) or not lock:
|
||||
# Performance optimization. In this case, there's really no reason to lock everything and an atomic
|
||||
# database transaction is more than enough.
|
||||
lockfn = NoLockManager
|
||||
@@ -1692,8 +1673,8 @@ class OrderPayment(models.Model):
|
||||
lockfn = self.order.event.lock
|
||||
|
||||
with lockfn():
|
||||
self._mark_paid_inner(force, count_waitinglist, user, auth, overpaid=payment_refund_sum > self.order.total,
|
||||
ignore_date=ignore_date)
|
||||
self._mark_paid(force, count_waitinglist, user, auth, overpaid=payment_sum - refund_sum > self.order.total,
|
||||
ignore_date=ignore_date)
|
||||
|
||||
invoice = None
|
||||
if invoice_qualified(self.order):
|
||||
@@ -2326,7 +2307,7 @@ class OrderPosition(AbstractPosition):
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
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,
|
||||
auth=None, attach_tickets=False, attach_ical=False, attach_other_files: list=None):
|
||||
auth=None, attach_tickets=False, attach_ical=False):
|
||||
"""
|
||||
Sends an email to the attendee. Basically, this method does two things:
|
||||
|
||||
@@ -2367,7 +2348,6 @@ class OrderPosition(AbstractPosition):
|
||||
invoices=invoices,
|
||||
attach_tickets=attach_tickets,
|
||||
attach_ical=attach_ical,
|
||||
attach_other_files=attach_other_files,
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
@@ -2582,6 +2562,7 @@ class CartPosition(AbstractPosition):
|
||||
max_digits=10, decimal_places=2,
|
||||
null=True, blank=True
|
||||
)
|
||||
is_bundled = models.BooleanField(default=False)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ import string
|
||||
from datetime import date, datetime, time
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import MinLengthValidator, RegexValidator
|
||||
from django.db import models
|
||||
@@ -98,21 +97,10 @@ class Organizer(LoggedModel):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = not self.pk
|
||||
obj = super().save(*args, **kwargs)
|
||||
if is_new:
|
||||
self.set_defaults()
|
||||
else:
|
||||
self.get_cache().clear()
|
||||
self.get_cache().clear()
|
||||
return obj
|
||||
|
||||
def set_defaults(self):
|
||||
"""
|
||||
This will be called after organizer creation.
|
||||
This way, we can use this to introduce new default settings to pretix that do not affect existing organizers.
|
||||
"""
|
||||
self.settings.cookie_consent = True
|
||||
|
||||
def get_cache(self):
|
||||
"""
|
||||
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
||||
@@ -191,20 +179,21 @@ class Organizer(LoggedModel):
|
||||
e.delete()
|
||||
self.teams.all().delete()
|
||||
|
||||
def get_mail_backend(self, timeout=None):
|
||||
def get_mail_backend(self, timeout=None, force_custom=False):
|
||||
"""
|
||||
Returns an email server connection, either by using the system-wide connection
|
||||
or by returning a custom one based on the organizer's settings.
|
||||
"""
|
||||
if self.settings.smtp_use_custom:
|
||||
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||
host=self.settings.smtp_host,
|
||||
port=self.settings.smtp_port,
|
||||
username=self.settings.smtp_username,
|
||||
password=self.settings.smtp_password,
|
||||
use_tls=self.settings.smtp_use_tls,
|
||||
use_ssl=self.settings.smtp_use_ssl,
|
||||
fail_silently=False, timeout=timeout)
|
||||
from pretix.base.email import CustomSMTPBackend
|
||||
|
||||
if self.settings.smtp_use_custom or force_custom:
|
||||
return CustomSMTPBackend(host=self.settings.smtp_host,
|
||||
port=self.settings.smtp_port,
|
||||
username=self.settings.smtp_username,
|
||||
password=self.settings.smtp_password,
|
||||
use_tls=self.settings.smtp_use_tls,
|
||||
use_ssl=self.settings.smtp_use_ssl,
|
||||
fail_silently=False, timeout=timeout)
|
||||
else:
|
||||
return get_connection(fail_silently=False)
|
||||
|
||||
|
||||
@@ -81,15 +81,6 @@ class TaxedPrice:
|
||||
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(
|
||||
gross=Decimal('0.00'),
|
||||
@@ -136,13 +127,8 @@ def cc_to_vat_prefix(country_code):
|
||||
|
||||
class TaxRule(LoggedModel):
|
||||
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(
|
||||
verbose_name=_('Official name'),
|
||||
verbose_name=_('Name'),
|
||||
help_text=_('Should be short, e.g. "VAT"'),
|
||||
max_length=190,
|
||||
)
|
||||
@@ -155,10 +141,6 @@ class TaxRule(LoggedModel):
|
||||
verbose_name=_("The configured product prices include the tax amount"),
|
||||
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(
|
||||
verbose_name=_("Use EU reverse charge taxation rules"),
|
||||
default=False,
|
||||
@@ -216,8 +198,6 @@ class TaxRule(LoggedModel):
|
||||
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
|
||||
if self.eu_reverse_charge:
|
||||
s += ' ({})'.format(_('reverse charge enabled'))
|
||||
if self.internal_name:
|
||||
return f'{self.internal_name} ({s})'
|
||||
return str(s)
|
||||
|
||||
@property
|
||||
@@ -231,7 +211,7 @@ class TaxRule(LoggedModel):
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
if rule.get('action', 'vat') == 'block':
|
||||
raise self.SaleNotAllowed()
|
||||
if rule.get('action', 'vat') in ('vat', 'require_approval') and rule.get('rate') is not None:
|
||||
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
|
||||
return Decimal(rule.get('rate'))
|
||||
return Decimal(self.rate)
|
||||
|
||||
@@ -248,19 +228,13 @@ class TaxRule(LoggedModel):
|
||||
rate = override_tax_rate
|
||||
elif invoice_address:
|
||||
adjust_rate = self.tax_rate_for(invoice_address)
|
||||
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':
|
||||
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price) and base_price_is == 'gross':
|
||||
rate = adjust_rate
|
||||
elif adjust_rate != rate:
|
||||
if self.keep_gross_if_rate_changes:
|
||||
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||
base_price = normal_price.gross
|
||||
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')
|
||||
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
|
||||
|
||||
if rate == Decimal('0.00'):
|
||||
@@ -363,19 +337,12 @@ class TaxRule(LoggedModel):
|
||||
|
||||
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):
|
||||
if self._custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
if rule.get('action', 'vat') == 'block':
|
||||
raise self.SaleNotAllowed()
|
||||
return rule.get('action', 'vat') in ('vat', 'require_approval')
|
||||
return rule.get('action', 'vat') == 'vat'
|
||||
|
||||
if not self.eu_reverse_charge:
|
||||
# No reverse charge rules? Always apply VAT!
|
||||
|
||||
@@ -191,15 +191,6 @@ class BasePaymentProvider:
|
||||
"""
|
||||
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
|
||||
def identifier(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -273,11 +273,6 @@ class RelativeDateTimeField(forms.MultiValueField):
|
||||
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):
|
||||
if value[0] == 'absolute' and not value[1]:
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
|
||||
@@ -426,10 +426,10 @@ class CartManager:
|
||||
if not cp.includes_tax:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
cp_is_net=True, bundled_sum=bundled_sum)
|
||||
price = TaxedPrice(net=price.net, gross=price.net, rate=Decimal('0'), tax=Decimal('0'), name='')
|
||||
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
||||
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
|
||||
cp_is_net=True, bundled_sum=bundled_sum)
|
||||
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=Decimal('0'), tax=Decimal('0'), name='')
|
||||
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=0, tax=0, name='')
|
||||
else:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
bundled_sum=bundled_sum)
|
||||
@@ -1106,11 +1106,10 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
|
||||
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
|
||||
|
||||
if pos.tax_rate != rate:
|
||||
if not pos.item.tax_rule.keep_gross_if_rate_changes:
|
||||
current_net = pos.price - pos.tax_value
|
||||
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
||||
totaldiff += new_gross - pos.price
|
||||
pos.price = new_gross
|
||||
current_net = pos.price - pos.tax_value
|
||||
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
||||
totaldiff += new_gross - pos.price
|
||||
pos.price = new_gross
|
||||
pos.includes_tax = rate != Decimal('0.00')
|
||||
pos.override_tax_rate = rate
|
||||
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
|
||||
|
||||
@@ -736,11 +736,7 @@ def process_exit_all(sender, **kwargs):
|
||||
exit_all_at__isnull=False
|
||||
).select_related('event', 'event__organizer')
|
||||
for cl in qs:
|
||||
positions = cl.positions_inside.filter(
|
||||
Q(last_exit__isnull=True) | Q(last_exit__lte=cl.exit_all_at),
|
||||
last_entry__lte=cl.exit_all_at,
|
||||
)
|
||||
for p in positions:
|
||||
for p in cl.positions_inside:
|
||||
with scope(organizer=cl.event.organizer):
|
||||
ci = Checkin.objects.create(
|
||||
position=p, list=cl, auto_checked_in=True, type=Checkin.TYPE_EXIT, datetime=cl.exit_all_at
|
||||
@@ -752,9 +748,6 @@ def process_exit_all(sender, **kwargs):
|
||||
cl.event.settings.delete(f'autocheckin_dst_hack_{cl.pk}')
|
||||
try:
|
||||
cl.exit_all_at = make_aware(datetime.combine(d.date() + timedelta(days=1), d.time()), cl.event.timezone)
|
||||
except pytz.exceptions.AmbiguousTimeError:
|
||||
cl.exit_all_at = make_aware(datetime.combine(d.date() + timedelta(days=1), d.time()), cl.event.timezone,
|
||||
is_dst=False)
|
||||
except pytz.exceptions.NonExistentTimeError:
|
||||
cl.event.settings.set(f'autocheckin_dst_hack_{cl.pk}', True)
|
||||
d += timedelta(hours=1)
|
||||
|
||||
191
src/pretix/base/services/checks.py
Normal file
191
src/pretix/base/services/checks.py
Normal file
@@ -0,0 +1,191 @@
|
||||
#
|
||||
# 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
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Case, F, OuterRef, Q, Subquery, Sum, Value, When
|
||||
from django.db.models.functions import Cast, Coalesce, StrIndex, Substr
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.models import Check, Invoice, Order, OrderFee, OrderPosition
|
||||
from pretix.base.models.orders import Transaction
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.periodic import minimum_interval
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_order_transactions():
|
||||
qs = Order.objects.annotate(
|
||||
position_total=Coalesce(
|
||||
Subquery(
|
||||
OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('price')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
fee_total=Coalesce(
|
||||
Subquery(
|
||||
OrderFee.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('value')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
tx_total=Coalesce(
|
||||
Subquery(
|
||||
Transaction.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum(F('price') * 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(
|
||||
correct_total=Case(
|
||||
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
|
||||
then=0),
|
||||
default=F('position_total') + F('fee_total'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
).exclude(
|
||||
tx_total=F('correct_total')
|
||||
).select_related('event')
|
||||
for o in qs:
|
||||
yield [
|
||||
Check.RESULT_ERROR,
|
||||
f'Order {o.full_code} has a wrong total: Status is {o.status} and sum of positions and fees is '
|
||||
f'{o.position_total + o.fee_total}, so sum of transactions should be {o.correct_total} but is {o.tx_total}'
|
||||
]
|
||||
yield [
|
||||
Check.RESULT_OK,
|
||||
'Check completed.'
|
||||
]
|
||||
|
||||
|
||||
def check_order_total():
|
||||
qs = Order.objects.annotate(
|
||||
position_total=Coalesce(
|
||||
Subquery(
|
||||
OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('price')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
fee_total=Coalesce(
|
||||
Subquery(
|
||||
OrderFee.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Sum('value')).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
).exclude(
|
||||
total=F('position_total') + F('fee_total'),
|
||||
).select_related('event')
|
||||
for o in qs:
|
||||
if o.total != o.position_total + o.fee_total:
|
||||
yield [
|
||||
Check.RESULT_ERROR,
|
||||
f'Order {o.full_code} has a wrong total: Sum of positions and fees is '
|
||||
f'{o.position_total + o.fee_total}, but total is {o.total}'
|
||||
]
|
||||
yield [
|
||||
Check.RESULT_OK,
|
||||
'Check completed.'
|
||||
]
|
||||
|
||||
|
||||
def check_invoice_gaps():
|
||||
group_qs = Invoice.objects.annotate(
|
||||
sub_prefix=Substr('invoice_no', 1, StrIndex('invoice_no', Value('-'))),
|
||||
).order_by().values(
|
||||
'organizer', 'prefix', 'sub_prefix', 'organizer__slug'
|
||||
)
|
||||
for g in group_qs:
|
||||
numbers = Invoice.objects.filter(
|
||||
prefix=g['prefix'], organizer=g['organizer']
|
||||
)
|
||||
if g['sub_prefix']:
|
||||
numbers = numbers.filter(invoice_no__startswith=g['sub_prefix']).alias(
|
||||
real_number=Cast(Substr('invoice_no', StrIndex('invoice_no', Value('-')) + 1), models.IntegerField())
|
||||
).order_by('real_number')
|
||||
else:
|
||||
numbers = numbers.exclude(invoice_no__contains='-').order_by('invoice_no')
|
||||
|
||||
numbers = list(numbers.values_list('invoice_no', flat=True))
|
||||
previous_n = "(initial state)"
|
||||
previous_numeric_part = 0
|
||||
for n in numbers:
|
||||
numeric_part = int(n.split("-")[-1])
|
||||
if numeric_part != previous_numeric_part + 1:
|
||||
print(g)
|
||||
yield [
|
||||
Check.RESULT_WARNING,
|
||||
f'Organizer {g["organizer__slug"]}, prefix {g["prefix"]}, invoice {n} follows on {previous_n} with gap'
|
||||
]
|
||||
previous_n = n
|
||||
previous_numeric_part = numeric_part
|
||||
|
||||
yield [
|
||||
Check.RESULT_OK,
|
||||
'Check completed.'
|
||||
]
|
||||
|
||||
|
||||
@app.task()
|
||||
@scopes_disabled()
|
||||
def run_default_consistency_checks():
|
||||
check_functions = [
|
||||
('pretix.orders.transactions', check_order_transactions),
|
||||
('pretix.orders.total', check_order_total),
|
||||
('pretix.invoices.gaps', check_invoice_gaps),
|
||||
]
|
||||
for check_type, check_function in check_functions:
|
||||
r = Check.RESULT_OK
|
||||
log = []
|
||||
try:
|
||||
for result, logline in check_function():
|
||||
if result == Check.RESULT_WARNING and r == Check.RESULT_OK:
|
||||
r = Check.RESULT_WARNING
|
||||
elif result == Check.RESULT_ERROR:
|
||||
r = Check.RESULT_ERROR
|
||||
log.append(f'[{result}] {logline}')
|
||||
except Exception as e:
|
||||
logger.exception('Could not run consistency check')
|
||||
r = Check.RESULT_ERROR
|
||||
log.append(f'[error] Check aborted: {e}')
|
||||
|
||||
Check.objects.create(result=r, check_type=check_type, log='\n'.join(log))
|
||||
|
||||
Check.objects.filter(created__lt=now() - timedelta(days=90)).delete()
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@minimum_interval(minutes_after_success=24 * 60)
|
||||
def periodic_consistency_checks(sender, **kwargs):
|
||||
run_default_consistency_checks.apply_async()
|
||||
@@ -69,10 +69,6 @@ from pretix.helpers.models import modelcopy
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _location_oneliner(loc):
|
||||
return ', '.join([l.strip() for l in loc.splitlines() if l and l.strip()])
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.locale = invoice.event.settings.get('invoice_language', invoice.event.settings.locale)
|
||||
@@ -102,7 +98,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
payment = ""
|
||||
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
|
||||
if payment:
|
||||
payment += "<br /><br />"
|
||||
payment += "<br />"
|
||||
payment += pgettext("invoice", "Please complete your payment before {expire_date}.").format(
|
||||
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
|
||||
)
|
||||
@@ -180,38 +176,19 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
reverse_charge = False
|
||||
|
||||
positions.sort(key=lambda p: p.sort_key)
|
||||
fees = list(invoice.order.fees.all())
|
||||
|
||||
locations = {
|
||||
str((p.subevent or invoice.event).location) if (p.subevent or invoice.event).location else None
|
||||
for p in positions
|
||||
}
|
||||
if fees and invoice.event.has_subevents:
|
||||
locations.add(None)
|
||||
|
||||
tax_texts = []
|
||||
|
||||
if invoice.event.settings.invoice_event_location and len(locations) == 1 and list(locations)[0] is not None:
|
||||
tax_texts.append(pgettext("invoice", "Event location: {location}").format(
|
||||
location=_location_oneliner(str(list(locations)[0]))
|
||||
))
|
||||
|
||||
for i, p in enumerate(positions):
|
||||
if not invoice.event.settings.invoice_include_free and p.price == Decimal('0.00') and not p.addon_c:
|
||||
continue
|
||||
|
||||
location = str((p.subevent or invoice.event).location) if (p.subevent or invoice.event).location else None
|
||||
|
||||
desc = str(p.item.name)
|
||||
if p.variation:
|
||||
desc += " - " + str(p.variation.value)
|
||||
if p.addon_to_id:
|
||||
desc = " + " + desc
|
||||
if invoice.event.settings.invoice_attendee_name and p.attendee_name:
|
||||
desc += "<br />" + pgettext("invoice", "Attendee: {name}").format(
|
||||
name=p.attendee_name
|
||||
)
|
||||
|
||||
desc += "<br />" + pgettext("invoice", "Attendee: {name}").format(name=p.attendee_name)
|
||||
for recv, resp in invoice_line_text.send(sender=invoice.event, position=p):
|
||||
if resp:
|
||||
desc += "<br/>" + resp
|
||||
@@ -227,12 +204,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
|
||||
if invoice.event.has_subevents:
|
||||
desc += "<br />" + pgettext("subevent", "Date: {}").format(p.subevent)
|
||||
|
||||
if invoice.event.settings.invoice_event_location and location and len(locations) > 1:
|
||||
desc += "<br />" + pgettext("invoice", "Event location: {location}").format(
|
||||
location=_location_oneliner(location)
|
||||
)
|
||||
|
||||
InvoiceLine.objects.create(
|
||||
position=i,
|
||||
invoice=invoice,
|
||||
@@ -245,7 +216,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
attendee_name=p.attendee_name if invoice.event.settings.invoice_attendee_name else None,
|
||||
event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from,
|
||||
event_date_to=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to,
|
||||
event_location=location if invoice.event.settings.invoice_event_location else None,
|
||||
tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
|
||||
)
|
||||
|
||||
@@ -258,7 +228,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
tax_texts.append(tax_text)
|
||||
|
||||
offset = len(positions)
|
||||
for i, fee in enumerate(fees):
|
||||
for i, fee in enumerate(invoice.order.fees.all()):
|
||||
if fee.fee_type == OrderFee.FEE_TYPE_OTHER and fee.description:
|
||||
fee_title = fee.description
|
||||
else:
|
||||
@@ -272,12 +242,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
gross_value=fee.value,
|
||||
event_date_from=None if invoice.event.has_subevents else invoice.event.date_from,
|
||||
event_date_to=None if invoice.event.has_subevents else invoice.event.date_to,
|
||||
event_location=(
|
||||
None if invoice.event.has_subevents
|
||||
else (str(invoice.event.location)
|
||||
if invoice.event.settings.invoice_event_location and invoice.event.location
|
||||
else None)
|
||||
),
|
||||
tax_value=fee.tax_value,
|
||||
tax_rate=fee.tax_rate,
|
||||
tax_name=fee.tax_rule.name if fee.tax_rule else '',
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
import hashlib
|
||||
import inspect
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
@@ -52,7 +51,6 @@ from bs4 import BeautifulSoup
|
||||
from celery import chain
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.mail import (
|
||||
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
|
||||
)
|
||||
@@ -75,7 +73,6 @@ from pretix.base.services.tasks import TransactionAwareTask
|
||||
from pretix.base.services.tickets import get_tickets_for_order
|
||||
from pretix.base.signals import email_filter, global_email_filter
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.hierarkey import clean_filename
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.presale.ical import get_ical
|
||||
|
||||
@@ -97,7 +94,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,
|
||||
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,
|
||||
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None):
|
||||
attach_ical=False, attach_cached_files: Sequence = None):
|
||||
"""
|
||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||
|
||||
@@ -145,8 +142,6 @@ 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_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
|
||||
that the email has been sent, just that it has been queued by the email backend.
|
||||
"""
|
||||
@@ -217,8 +212,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
|
||||
bcc.append(bcc_mail.strip())
|
||||
|
||||
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'):
|
||||
if settings_holder.settings.mail_from == settings.DEFAULT_FROM_EMAIL and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
|
||||
headers['Reply-To'] = settings_holder.settings.contact_mail
|
||||
|
||||
prefix = settings_holder.settings.get('mail_prefix')
|
||||
@@ -307,7 +301,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
organizer=organizer.pk if organizer 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_other_files=attach_other_files,
|
||||
)
|
||||
|
||||
if invoices:
|
||||
@@ -345,8 +338,7 @@ class CustomEmail(EmailMultiAlternatives):
|
||||
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,
|
||||
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,
|
||||
attach_other_files: List[str] = None) -> bool:
|
||||
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None) -> bool:
|
||||
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
if html is not None:
|
||||
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
||||
@@ -463,20 +455,6 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
logger.exception('Could not attach invoice to email')
|
||||
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:
|
||||
for cf in CachedFile.objects.filter(id__in=attach_cached_files):
|
||||
if cf.file:
|
||||
|
||||
@@ -148,7 +148,7 @@ def send_notification_mail(notification: Notification, user: User):
|
||||
),
|
||||
'body': body_plain,
|
||||
'html': body_html,
|
||||
'sender': settings.MAIL_FROM_NOTIFICATIONS,
|
||||
'sender': settings.MAIL_FROM,
|
||||
'headers': {},
|
||||
'user': user.pk
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import Counter, defaultdict, namedtuple
|
||||
from collections import Counter, namedtuple
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
@@ -46,7 +46,7 @@ from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value,
|
||||
Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
from django.db.transaction import get_connection
|
||||
@@ -73,7 +73,7 @@ from pretix.base.models.orders import (
|
||||
InvoiceAddress, OrderFee, OrderRefund, generate_secret,
|
||||
)
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||
from pretix.base.models.tax import TaxRule
|
||||
from pretix.base.payment import BasePaymentProvider, PaymentException
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.secrets import assign_ticket_secret
|
||||
@@ -122,7 +122,8 @@ error_messages = {
|
||||
'from your cart.'),
|
||||
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
|
||||
'removed this item from your cart.'),
|
||||
'voucher_required': _('You need a valid voucher code to order one of the products.'),
|
||||
'voucher_required': _('You need a valid voucher code to order one of the products in your cart. We removed this '
|
||||
'item from your cart.'),
|
||||
'some_subevent_not_started': _('The presale period for one of the events in your cart has not yet started. The '
|
||||
'affected positions have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
||||
@@ -130,13 +131,6 @@ error_messages = {
|
||||
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
|
||||
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
||||
'country_blocked': _('One of the selected products is not available in the selected country.'),
|
||||
'not_for_sale': _('You selected a product which is not available for sale.'),
|
||||
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
||||
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
||||
'product %(base)s.'),
|
||||
'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -700,7 +694,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
changed_prices[cp.pk] = bprice
|
||||
else:
|
||||
bundled_sum = Decimal('0.00')
|
||||
bundled_sum = 0
|
||||
if not cp.addon_to_id:
|
||||
for bundledp in cp.addons.all():
|
||||
if bundledp.is_bundled:
|
||||
@@ -856,7 +850,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
total=total,
|
||||
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
||||
meta_info=json.dumps(meta_info or {}),
|
||||
require_approval=any(p.requires_approval(invoice_address=address) for p in positions),
|
||||
require_approval=any(p.item.require_approval for p in positions),
|
||||
sales_channel=sales_channel.identifier,
|
||||
customer=customer,
|
||||
)
|
||||
@@ -941,10 +935,7 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
|
||||
log_entry,
|
||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True,
|
||||
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],
|
||||
attach_ical=event.settings.mail_attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent')
|
||||
@@ -961,10 +952,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
||||
invoices=[],
|
||||
attach_tickets=True,
|
||||
position=position,
|
||||
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],
|
||||
attach_ical=event.settings.mail_attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent to attendee')
|
||||
@@ -1273,15 +1261,15 @@ class OrderChangeManager:
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
|
||||
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
|
||||
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
|
||||
PriceOperation = namedtuple('PriceOperation', ('position', 'price', 'price_diff'))
|
||||
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
|
||||
TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule'))
|
||||
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
|
||||
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
|
||||
CancelOperation = namedtuple('CancelOperation', ('position',))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership'))
|
||||
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
|
||||
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
|
||||
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee', 'price_diff'))
|
||||
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value'))
|
||||
AddFeeOperation = namedtuple('AddFeeOperation', ('fee',))
|
||||
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee',))
|
||||
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
|
||||
|
||||
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
|
||||
@@ -1398,7 +1386,7 @@ class OrderChangeManager:
|
||||
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
self._operations.append(self.PriceOperation(position, price, price.gross - position.price))
|
||||
self._operations.append(self.PriceOperation(position, price))
|
||||
|
||||
def change_tax_rule(self, position_or_fee, tax_rule: TaxRule):
|
||||
self._operations.append(self.TaxRuleOperation(position_or_fee, tax_rule))
|
||||
@@ -1438,28 +1426,28 @@ class OrderChangeManager:
|
||||
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
|
||||
override_tax_rate=new_rate)
|
||||
self._totaldiff += new_tax.gross - pos.price
|
||||
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
|
||||
self._operations.append(self.PriceOperation(pos, new_tax))
|
||||
|
||||
def cancel_fee(self, fee: OrderFee):
|
||||
self._totaldiff -= fee.value
|
||||
self._operations.append(self.CancelFeeOperation(fee, -fee.value))
|
||||
self._operations.append(self.CancelFeeOperation(fee))
|
||||
self._invoice_dirty = True
|
||||
|
||||
def add_fee(self, fee: OrderFee):
|
||||
self._totaldiff += fee.value
|
||||
self._invoice_dirty = True
|
||||
self._operations.append(self.AddFeeOperation(fee, fee.value))
|
||||
self._operations.append(self.AddFeeOperation(fee))
|
||||
|
||||
def change_fee(self, fee: OrderFee, value: Decimal):
|
||||
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross')
|
||||
self._totaldiff += value.gross - fee.value
|
||||
self._invoice_dirty = True
|
||||
self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value))
|
||||
self._operations.append(self.FeeValueOperation(fee, value))
|
||||
|
||||
def cancel(self, position: OrderPosition):
|
||||
self._totaldiff -= position.price
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.CancelOperation(position, -position.price))
|
||||
self._operations.append(self.CancelOperation(position))
|
||||
if position.seat:
|
||||
self._seatdiff.subtract([position.seat])
|
||||
|
||||
@@ -1484,7 +1472,7 @@ class OrderChangeManager:
|
||||
try:
|
||||
if price is None:
|
||||
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
|
||||
elif not isinstance(price, TaxedPrice):
|
||||
else:
|
||||
price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||
@@ -1527,190 +1515,6 @@ class OrderChangeManager:
|
||||
|
||||
self._operations.append(self.SplitOperation(position))
|
||||
|
||||
def set_addons(self, addons):
|
||||
if self._operations:
|
||||
raise ValueError("Setting addons should be the first/only operation")
|
||||
|
||||
# Prepare various containers to hold data later
|
||||
current_addons = defaultdict(lambda: defaultdict(list)) # OrderPos -> currently attached add-ons
|
||||
input_addons = defaultdict(Counter) # OrderPos -> final desired set of add-ons
|
||||
selected_addons = defaultdict(Counter) # OrderPos, ItemAddOn -> final desired set of add-ons
|
||||
opcache = {} # OrderPos.pk -> OrderPos
|
||||
quota_diff = Counter() # Quota -> Number of usages
|
||||
available_categories = defaultdict(set) # OrderPos -> Category IDs to choose from
|
||||
price_included = defaultdict(dict) # OrderPos -> CategoryID -> bool(price is included)
|
||||
toplevel_op = self.order.positions.filter(
|
||||
addon_to__isnull=True
|
||||
).prefetch_related(
|
||||
'addons', 'item__addons', 'item__addons__addon_category'
|
||||
).select_related('item', 'variation')
|
||||
|
||||
_items_cache = {
|
||||
i.pk: i
|
||||
for i in self.event.items.select_related('category').prefetch_related(
|
||||
'addons', 'bundles', 'addons__addon_category', 'quotas'
|
||||
).annotate(
|
||||
has_variations=Count('variations'),
|
||||
).filter(
|
||||
id__in=[a['item'] for a in addons]
|
||||
).order_by()
|
||||
}
|
||||
_variations_cache = {
|
||||
v.pk: v
|
||||
for v in ItemVariation.objects.filter(item__event=self.event).prefetch_related(
|
||||
'quotas'
|
||||
).select_related('item', 'item__event').filter(
|
||||
id__in=[a['variation'] for a in addons if a.get('variation')]
|
||||
).order_by()
|
||||
}
|
||||
|
||||
# Prefill some of the cache containers
|
||||
for op in toplevel_op:
|
||||
if op.canceled:
|
||||
continue
|
||||
available_categories[op.pk] = {iao.addon_category_id for iao in op.item.addons.all()}
|
||||
price_included[op.pk] = {iao.addon_category_id: iao.price_included for iao in op.item.addons.all()}
|
||||
opcache[op.pk] = op
|
||||
for a in op.addons.all():
|
||||
if a.canceled:
|
||||
continue
|
||||
|
||||
if not a.is_bundled:
|
||||
current_addons[op][a.item_id, a.variation_id].append(a)
|
||||
|
||||
# Create operations, perform various checks
|
||||
for a in addons:
|
||||
# Check whether the specified items are part of what we just fetched from the database
|
||||
# If they are not, the user supplied item IDs which either do not exist or belong to
|
||||
# a different event
|
||||
if a['item'] not in _items_cache or (a['variation'] and a['variation'] not in _variations_cache):
|
||||
raise OrderError(error_messages['not_for_sale'])
|
||||
|
||||
# Only attach addons to things that are actually in this user's cart
|
||||
if a['addon_to'] not in opcache:
|
||||
raise OrderError(error_messages['addon_invalid_base'])
|
||||
|
||||
op = opcache[a['addon_to']]
|
||||
item = _items_cache[a['item']]
|
||||
variation = _variations_cache[a['variation']] if a['variation'] is not None else None
|
||||
|
||||
if item.category_id not in available_categories[op.pk]:
|
||||
raise OrderError(error_messages['addon_invalid_base'])
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
quotas = list(item.quotas.filter(subevent=op.subevent)
|
||||
if variation is None else variation.quotas.filter(subevent=op.subevent))
|
||||
if not quotas:
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
if (a['item'], a['variation']) in input_addons[op.id]:
|
||||
raise OrderError(error_messages['addon_duplicate_item'])
|
||||
|
||||
if item.require_voucher or op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher):
|
||||
raise OrderError(error_messages['voucher_required'])
|
||||
|
||||
if not item.is_available() or (variation and not variation.is_available()):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
if self.order.sales_channel not in item.sales_channels or (
|
||||
variation and self.order.sales_channel not in variation.sales_channels):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
if op.subevent and item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
|
||||
raise OrderError(error_messages['not_for_sale'])
|
||||
|
||||
if op.subevent and variation and variation.pk in op.subevent.var_overrides and \
|
||||
not op.subevent.var_overrides[variation.pk].is_available():
|
||||
raise OrderError(error_messages['not_for_sale'])
|
||||
|
||||
if item.has_variations and not variation:
|
||||
raise OrderError(error_messages['not_for_sale'])
|
||||
|
||||
if variation and variation.item_id != item.pk:
|
||||
raise OrderError(error_messages['not_for_sale'])
|
||||
|
||||
if op.subevent and op.subevent.presale_start and now() < op.subevent.presale_start:
|
||||
raise OrderError(error_messages['not_started'])
|
||||
|
||||
if (op.subevent and op.subevent.presale_has_ended) or self.event.presale_has_ended:
|
||||
raise OrderError(error_messages['ended'])
|
||||
|
||||
if item.require_bundling:
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
input_addons[op.id][a['item'], a['variation']] = a.get('count', 1)
|
||||
selected_addons[op.id, item.category_id][a['item'], a['variation']] = a.get('count', 1)
|
||||
|
||||
if price_included[op.pk].get(item.category_id):
|
||||
price = TAXED_ZERO
|
||||
else:
|
||||
price = get_price(
|
||||
item, variation, voucher=None, custom_price=a.get('price'), subevent=op.subevent,
|
||||
custom_price_is_net=self.event.settings.display_net_prices,
|
||||
invoice_address=self._invoice_address,
|
||||
)
|
||||
|
||||
if a.get('count', 1) > len(current_addons[op][a['item'], a['variation']]):
|
||||
# This add-on is new, add it to the cart
|
||||
for quota in quotas:
|
||||
quota_diff[quota] += a.get('count', 1) - len(current_addons[op][a['item'], a['variation']])
|
||||
|
||||
for i in range(a.get('count', 1) - len(current_addons[op][a['item'], a['variation']])):
|
||||
self.add_position(
|
||||
item=item, variation=variation, price=price,
|
||||
addon_to=op, subevent=op.subevent, seat=None,
|
||||
)
|
||||
|
||||
# Check constraints on the add-on combinations
|
||||
for op in toplevel_op:
|
||||
item = op.item
|
||||
for iao in item.addons.all():
|
||||
selected = selected_addons[op.id, iao.addon_category_id]
|
||||
n_per_i = Counter()
|
||||
for (i, v), c in selected.items():
|
||||
n_per_i[i] += c
|
||||
if sum(selected.values()) > iao.max_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise OrderError(
|
||||
error_messages['addon_max_count'],
|
||||
{
|
||||
'base': str(item.name),
|
||||
'max': iao.max_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
elif sum(selected.values()) < iao.min_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise OrderError(
|
||||
error_messages['addon_min_count'],
|
||||
{
|
||||
'base': str(item.name),
|
||||
'min': iao.min_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed:
|
||||
raise OrderError(
|
||||
error_messages['addon_no_multi'],
|
||||
{
|
||||
'base': str(item.name),
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
|
||||
# Detect removed add-ons and create RemoveOperations
|
||||
for cp, al in list(current_addons.items()):
|
||||
for k, v in al.items():
|
||||
input_num = input_addons[cp.id].get(k, 0)
|
||||
current_num = len(current_addons[cp].get(k, []))
|
||||
if input_num < current_num:
|
||||
for a in current_addons[cp][k][:current_num - input_num]:
|
||||
if a.canceled:
|
||||
continue
|
||||
self.cancel(a)
|
||||
|
||||
def _check_seats(self):
|
||||
for seat, diff in self._seatdiff.items():
|
||||
if diff <= 0:
|
||||
@@ -2071,7 +1875,7 @@ class OrderChangeManager:
|
||||
split_order.code = None
|
||||
split_order.datetime = now()
|
||||
split_order.secret = generate_secret()
|
||||
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.require_approval = self.order.require_approval and any(p.item.require_approval for p in split_positions)
|
||||
split_order.save()
|
||||
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
|
||||
'original_order': self.order.code
|
||||
@@ -2323,10 +2127,10 @@ class OrderChangeManager:
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||
self._recalculate_total_and_payment_fee()
|
||||
self._check_paid_price_change()
|
||||
self._check_paid_to_free()
|
||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
self._reissue_invoice()
|
||||
self._check_paid_price_change()
|
||||
self._check_paid_to_free()
|
||||
self._clear_tickets_cache()
|
||||
self.order.touch()
|
||||
self.order.create_transactions()
|
||||
|
||||
@@ -113,8 +113,10 @@ class QuotaAvailability:
|
||||
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()
|
||||
quota_ids_set = {q.id for q in self._queue}
|
||||
if not quota_ids_set:
|
||||
quotas = list(set(self._queue))
|
||||
quotas_original = list(self._queue)
|
||||
self._queue.clear()
|
||||
if not quotas:
|
||||
return
|
||||
|
||||
if allow_cache:
|
||||
@@ -127,7 +129,7 @@ class QuotaAvailability:
|
||||
elif settings.HAS_REDIS:
|
||||
rc = get_redis_connection("redis")
|
||||
quotas_by_event = defaultdict(list)
|
||||
for q in [_q for _q in self._queue if _q.id in quota_ids_set]:
|
||||
for q in quotas_original:
|
||||
quotas_by_event[q.event_id].append(q)
|
||||
|
||||
for eventid, evquotas in quotas_by_event.items():
|
||||
@@ -137,19 +139,16 @@ class QuotaAvailability:
|
||||
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
|
||||
if time.time() - int(data[2]) < 120 or allow_cache_stale:
|
||||
quota_ids_set.remove(q.id)
|
||||
quotas_original.remove(q)
|
||||
quotas.remove(q)
|
||||
if data[1] == "None":
|
||||
self.results[q] = int(data[0]), None
|
||||
else:
|
||||
self.results[q] = int(data[0]), int(data[1])
|
||||
|
||||
if not quota_ids_set:
|
||||
if not quotas:
|
||||
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)
|
||||
|
||||
for q in quotas_original:
|
||||
@@ -285,16 +284,15 @@ class QuotaAvailability:
|
||||
seq = Q(subevent_id__in=subevents)
|
||||
if None in subevents:
|
||||
seq |= Q(subevent__isnull=True)
|
||||
quota_ids = {q.pk for q in quotas}
|
||||
op_lookup = OrderPosition.objects.filter(
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
|
||||
order__event_id__in=events,
|
||||
).filter(seq).filter(
|
||||
Q(
|
||||
Q(variation_id__isnull=True) &
|
||||
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
||||
) | Q(
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas})
|
||||
).order_by()
|
||||
if any(q.release_after_exit for q in quotas):
|
||||
op_lookup = op_lookup.annotate(
|
||||
@@ -361,7 +359,6 @@ class QuotaAvailability:
|
||||
func = 'GREATEST'
|
||||
|
||||
subevents = {q.subevent_id for q in quotas}
|
||||
quota_ids = {q.pk for q in quotas}
|
||||
seq = Q(subevent_id__in=subevents)
|
||||
if None in subevents:
|
||||
seq |= Q(subevent__isnull=True)
|
||||
@@ -373,9 +370,10 @@ class QuotaAvailability:
|
||||
Q(
|
||||
Q(
|
||||
Q(variation_id__isnull=True) &
|
||||
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
||||
) | Q(
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if
|
||||
self._quota_objects[i['quota_id']] in quotas}
|
||||
) | Q(
|
||||
quota_id__in=[q.pk for q in quotas]
|
||||
)
|
||||
@@ -400,7 +398,6 @@ class QuotaAvailability:
|
||||
def _compute_carts(self, quotas, q_items, q_vars, size_left, now_dt):
|
||||
events = {q.event_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)
|
||||
if None in subevents:
|
||||
seq |= Q(subevent__isnull=True)
|
||||
@@ -416,9 +413,9 @@ class QuotaAvailability:
|
||||
Q(
|
||||
Q(
|
||||
Q(variation_id__isnull=True) &
|
||||
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
||||
) | Q(
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas}
|
||||
)
|
||||
)
|
||||
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
||||
@@ -437,7 +434,6 @@ class QuotaAvailability:
|
||||
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
|
||||
events = {q.event_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)
|
||||
if None in subevents:
|
||||
seq |= Q(subevent__isnull=True)
|
||||
@@ -448,8 +444,9 @@ class QuotaAvailability:
|
||||
Q(
|
||||
Q(
|
||||
Q(variation_id__isnull=True) &
|
||||
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
|
||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
||||
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if
|
||||
self._quota_objects[i['quota_id']] in quotas})
|
||||
)
|
||||
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
||||
for line in w_lookup:
|
||||
|
||||
@@ -20,13 +20,11 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import vat_moss.errors
|
||||
import vat_moss.id
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from zeep import Client, Transport
|
||||
from zeep.cache import SqliteCache
|
||||
@@ -85,12 +83,14 @@ def _validate_vat_id_CH(vat_id, country_code):
|
||||
|
||||
vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', ''))
|
||||
try:
|
||||
transport = Transport(cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")))
|
||||
transport = Transport(cache=SqliteCache())
|
||||
client = Client(
|
||||
'https://www.uid-wse.admin.ch/V5.0/PublicServices.svc?wsdl',
|
||||
'https://www.uid-wse-a.admin.ch/V5.0/PublicServices.svc?wsdl',
|
||||
transport=transport
|
||||
)
|
||||
result = client.service.ValidateUID(uid=vat_id)
|
||||
if not client.service.ValidateUID(uid=vat_id):
|
||||
raise VATIDFinalError(_('This VAT ID is not valid. Please re-check your input.'))
|
||||
return vat_id
|
||||
except Fault as e:
|
||||
if e.message == 'Data_validation_failed':
|
||||
raise VATIDFinalError(_('This VAT ID is not valid. Please re-check your input.'))
|
||||
@@ -118,10 +118,6 @@ def _validate_vat_id_CH(vat_id, country_code):
|
||||
'need to charge VAT on your invoice. You can get the tax amount '
|
||||
'back via the VAT reimbursement process.'
|
||||
))
|
||||
else:
|
||||
if not result:
|
||||
raise VATIDFinalError(_('This VAT ID is not valid. Please re-check your input.'))
|
||||
return vat_id
|
||||
|
||||
|
||||
def validate_vat_id(vat_id, country_code):
|
||||
|
||||
@@ -136,15 +136,11 @@ DEFAULTS = {
|
||||
'type': int,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'serializer_kwargs': dict(
|
||||
min_value=1,
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
min_value=1,
|
||||
required=True,
|
||||
label=_("Maximum number of items per order"),
|
||||
help_text=_("Add-on products will not be counted.")
|
||||
),
|
||||
)
|
||||
},
|
||||
'display_net_prices': {
|
||||
'default': 'False',
|
||||
@@ -314,17 +310,6 @@ DEFAULTS = {
|
||||
label=_("Show attendee names on invoices"),
|
||||
)
|
||||
},
|
||||
'invoice_event_location': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Show event location on invoices"),
|
||||
help_text=_("The event location will be shown below the list of products if it is the same for all "
|
||||
"lines. It will be shown on every line if there are different locations.")
|
||||
)
|
||||
},
|
||||
'invoice_eu_currencies': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
@@ -372,12 +357,11 @@ DEFAULTS = {
|
||||
'form_class': I18nFormField,
|
||||
'serializer_class': I18nField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Custom recipient field"),
|
||||
label=_("Custom address field"),
|
||||
widget=I18nTextInput,
|
||||
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 "
|
||||
"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. "
|
||||
"asking the user to input their details as well as for displaying the value on the invoice. "
|
||||
"The field will not be required.")
|
||||
)
|
||||
},
|
||||
@@ -431,7 +415,7 @@ DEFAULTS = {
|
||||
)
|
||||
},
|
||||
'invoice_include_expire_date': {
|
||||
'default': 'False', # default for new events is True
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
@@ -445,11 +429,9 @@ DEFAULTS = {
|
||||
'type': int,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'serializer_kwargs': dict(),
|
||||
'form_kwargs': dict(
|
||||
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."),
|
||||
required=True,
|
||||
)
|
||||
},
|
||||
'invoice_numbers_consecutive': {
|
||||
@@ -489,7 +471,7 @@ DEFAULTS = {
|
||||
)
|
||||
},
|
||||
'invoice_renderer': {
|
||||
'default': 'classic', # default for new events is 'modern1'
|
||||
'default': 'classic',
|
||||
'type': str,
|
||||
},
|
||||
'ticket_secret_generator': {
|
||||
@@ -513,7 +495,6 @@ DEFAULTS = {
|
||||
MinValueValidator(12),
|
||||
MaxValueValidator(64),
|
||||
],
|
||||
required=True,
|
||||
widget=forms.NumberInput(
|
||||
attrs={
|
||||
'min': '12',
|
||||
@@ -528,13 +509,9 @@ DEFAULTS = {
|
||||
'type': int,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'serializer_kwargs': dict(
|
||||
min_value=0,
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
min_value=0,
|
||||
label=_("Reservation period"),
|
||||
required=True,
|
||||
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
|
||||
)
|
||||
},
|
||||
@@ -589,7 +566,6 @@ DEFAULTS = {
|
||||
'form_kwargs': dict(
|
||||
label=_("Set payment term"),
|
||||
widget=forms.RadioSelect,
|
||||
required=True,
|
||||
choices=(
|
||||
('days', _("in days")),
|
||||
('minutes', _("in minutes"))
|
||||
@@ -921,7 +897,7 @@ DEFAULTS = {
|
||||
'type': str
|
||||
},
|
||||
'invoice_email_attachment': {
|
||||
'default': 'False', # default for new events is True
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
@@ -1104,13 +1080,9 @@ DEFAULTS = {
|
||||
'type': int,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_kwargs': dict(
|
||||
min_value=1,
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
label=_("Waiting list response time"),
|
||||
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 "
|
||||
"number of hours until it expires and can be re-assigned to the next person on the list."),
|
||||
widget=forms.NumberInput(),
|
||||
@@ -1258,7 +1230,7 @@ DEFAULTS = {
|
||||
)
|
||||
},
|
||||
'event_list_type': {
|
||||
'default': 'list', # default for new events is 'calendar'
|
||||
'default': 'list',
|
||||
'type': str,
|
||||
'form_class': forms.ChoiceField,
|
||||
'serializer_class': serializers.ChoiceField,
|
||||
@@ -1318,15 +1290,6 @@ DEFAULTS = {
|
||||
label=_("Customers can change the variation of the products they purchased"),
|
||||
)
|
||||
},
|
||||
'change_allow_user_addons': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Customers can change their selected add-on products"),
|
||||
)
|
||||
},
|
||||
'change_allow_user_price': {
|
||||
'default': 'gte',
|
||||
'type': str,
|
||||
@@ -1529,17 +1492,6 @@ DEFAULTS = {
|
||||
),
|
||||
'serializer_class': serializers.URLField,
|
||||
},
|
||||
'privacy_url': {
|
||||
'default': None,
|
||||
'type': str,
|
||||
'form_class': forms.URLField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Privacy Policy URL"),
|
||||
help_text=_("This should point e.g. to a part of your website that explains how you use data gathered in "
|
||||
"your ticket shop."),
|
||||
),
|
||||
'serializer_class': serializers.URLField,
|
||||
},
|
||||
'confirm_texts': {
|
||||
'default': LazyI18nStringList(),
|
||||
'type': LazyI18nStringList,
|
||||
@@ -1589,7 +1541,7 @@ DEFAULTS = {
|
||||
'type': str
|
||||
},
|
||||
'mail_from': {
|
||||
'default': settings.MAIL_FROM_ORGANIZERS,
|
||||
'default': settings.MAIL_FROM,
|
||||
'type': str,
|
||||
'form_class': forms.EmailField,
|
||||
'serializer_class': serializers.EmailField,
|
||||
@@ -1704,30 +1656,6 @@ You can change your order details and view the status of your order at
|
||||
Best regards,
|
||||
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': {
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
@@ -2046,7 +1974,7 @@ Your {organizer} team"""))
|
||||
),
|
||||
},
|
||||
'theme_color_success': {
|
||||
'default': '#50a167',
|
||||
'default': '#50A167',
|
||||
'type': str,
|
||||
'form_class': forms.CharField,
|
||||
'serializer_class': serializers.CharField,
|
||||
@@ -2068,7 +1996,7 @@ Your {organizer} team"""))
|
||||
),
|
||||
},
|
||||
'theme_color_danger': {
|
||||
'default': '#c44f4f',
|
||||
'default': '#C44F4F',
|
||||
'type': str,
|
||||
'form_class': forms.CharField,
|
||||
'serializer_class': serializers.CharField,
|
||||
@@ -2090,7 +2018,7 @@ Your {organizer} team"""))
|
||||
),
|
||||
},
|
||||
'theme_color_background': {
|
||||
'default': '#f5f5f5',
|
||||
'default': '#FFFFFF',
|
||||
'type': str,
|
||||
'form_class': forms.CharField,
|
||||
'serializer_class': serializers.CharField,
|
||||
@@ -2516,7 +2444,7 @@ Your {organizer} team"""))
|
||||
)
|
||||
},
|
||||
'name_scheme': {
|
||||
'default': 'full', # default for new events is 'given_family'
|
||||
'default': 'full',
|
||||
'type': str
|
||||
},
|
||||
'giftcard_length': {
|
||||
@@ -2541,77 +2469,6 @@ Your {organizer} team"""))
|
||||
'many years. If you keep it empty, gift cards do not have an explicit expiry date.'),
|
||||
)
|
||||
},
|
||||
'cookie_consent': {
|
||||
'default': 'False',
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Enable cookie consent management features"),
|
||||
),
|
||||
'type': bool,
|
||||
},
|
||||
'cookie_consent_dialog_text': {
|
||||
'default': LazyI18nString.from_gettext(gettext_noop(
|
||||
'By clicking "Accept all cookies", you agree to the storing of cookies and use of similar technologies on '
|
||||
'your device.'
|
||||
)),
|
||||
'type': LazyI18nString,
|
||||
'serializer_class': I18nField,
|
||||
'form_class': I18nFormField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Dialog text"),
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||
)
|
||||
},
|
||||
'cookie_consent_dialog_text_secondary': {
|
||||
'default': LazyI18nString.from_gettext(gettext_noop(
|
||||
'We use cookies and similar technologies to gather data that allows us to improve this website and our '
|
||||
'offerings. If you do not agree, we will only use cookies if they are essential to providing the services '
|
||||
'this website offers.'
|
||||
)),
|
||||
'type': LazyI18nString,
|
||||
'serializer_class': I18nField,
|
||||
'form_class': I18nFormField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Secondary dialog text"),
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||
)
|
||||
},
|
||||
'cookie_consent_dialog_title': {
|
||||
'default': LazyI18nString.from_gettext(gettext_noop('Privacy settings')),
|
||||
'type': LazyI18nString,
|
||||
'serializer_class': I18nField,
|
||||
'form_class': I18nFormField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Dialog title'),
|
||||
widget=I18nTextInput,
|
||||
widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||
)
|
||||
},
|
||||
'cookie_consent_dialog_button_yes': {
|
||||
'default': LazyI18nString.from_gettext(gettext_noop('Accept all cookies')),
|
||||
'type': LazyI18nString,
|
||||
'serializer_class': I18nField,
|
||||
'form_class': I18nFormField,
|
||||
'form_kwargs': dict(
|
||||
label=_('"Accept" button description'),
|
||||
widget=I18nTextInput,
|
||||
widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||
)
|
||||
},
|
||||
'cookie_consent_dialog_button_no': {
|
||||
'default': LazyI18nString.from_gettext(gettext_noop('Required cookies only')),
|
||||
'type': LazyI18nString,
|
||||
'serializer_class': I18nField,
|
||||
'form_class': I18nFormField,
|
||||
'form_kwargs': dict(
|
||||
label=_('"Reject" button description'),
|
||||
widget=I18nTextInput,
|
||||
widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
|
||||
)
|
||||
},
|
||||
'seating_choice': {
|
||||
'default': 'True',
|
||||
'form_class': forms.BooleanField,
|
||||
|
||||
@@ -85,6 +85,9 @@
|
||||
-webkit-hyphens: auto;
|
||||
hyphens: auto;
|
||||
}
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 10px;
|
||||
@@ -113,9 +116,6 @@
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.content {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.content table {
|
||||
width: 100%;
|
||||
@@ -142,8 +142,7 @@
|
||||
}
|
||||
|
||||
.order-button {
|
||||
padding-top: 5px;
|
||||
text-align: center;
|
||||
padding-top: 5px
|
||||
}
|
||||
.order-button a.button {
|
||||
font-size: 12px;
|
||||
@@ -174,7 +173,7 @@
|
||||
body {
|
||||
direction: rtl;
|
||||
}
|
||||
.content {
|
||||
.content table td {
|
||||
text-align: right;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{% load eventurl %}
|
||||
{% load i18n %}
|
||||
{% load oneline %}
|
||||
|
||||
{% if position %}
|
||||
<div class="order-info">
|
||||
@@ -108,10 +107,6 @@
|
||||
{% if event.settings.show_times %}
|
||||
{{ groupkey.2.date_from|date:"TIME_FORMAT" }}
|
||||
{% endif %}
|
||||
{% if groupkey.2.location %}
|
||||
<br>
|
||||
{{ groupkey.2.location|oneline }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if groupkey.3 %} {# attendee name #}
|
||||
<br>
|
||||
|
||||
@@ -14,17 +14,16 @@
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml><![endif]-->
|
||||
<style type="text/css">
|
||||
body, .container {
|
||||
body {
|
||||
background-color: #eee;
|
||||
background-position: top;
|
||||
background-repeat: repeat-x;
|
||||
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
padding: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
table.layout > tr > td,
|
||||
@@ -37,8 +36,7 @@
|
||||
table.layout > tr > td.logo,
|
||||
table.layout > tbody > tr > td.logo,
|
||||
table.layout > thead > tr > td.logo {
|
||||
padding: {% if event.settings.logo_image_large %}0 0 0 0{% else %}20px 0 0 0{% endif %};
|
||||
mso-line-height-rule: at-least;
|
||||
padding: 20px 0 0 0;
|
||||
}
|
||||
|
||||
table.layout > tr > td.header,
|
||||
@@ -104,6 +102,9 @@
|
||||
-webkit-hyphens: auto;
|
||||
hyphens: auto;
|
||||
}
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 10px;
|
||||
@@ -111,6 +112,10 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: {{ color }};
|
||||
color: #FFF;
|
||||
@@ -133,10 +138,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.content table {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -148,7 +149,7 @@
|
||||
table.layout > tr > td.containertd,
|
||||
table.layout > tbody > tr > td.containertd,
|
||||
table.layout > thead > tr > td.containertd {
|
||||
padding: 20px;
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
a.button {
|
||||
@@ -166,8 +167,7 @@
|
||||
}
|
||||
|
||||
.order-button {
|
||||
padding-top: 5px;
|
||||
text-align: center;
|
||||
padding-top: 5px
|
||||
}
|
||||
.order-button a.button {
|
||||
font-size: 12px;
|
||||
@@ -198,7 +198,7 @@
|
||||
body {
|
||||
direction: rtl;
|
||||
}
|
||||
.content {
|
||||
.content table td {
|
||||
text-align: right;
|
||||
}
|
||||
{% endif %}
|
||||
@@ -214,14 +214,14 @@
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body align="center">
|
||||
<table width="100%"><tr><td align="center" class="container">
|
||||
<!--[if gte mso 9]>
|
||||
<table width="600"><tr><td align="center">
|
||||
<table width="100%"><tr><td align="center">
|
||||
<table width="600"><tr><td align="center"
|
||||
<![endif]-->
|
||||
<table class="layout" style="max-width:600px" border="0" cellspacing="0">
|
||||
{% if event.settings.logo_image %}
|
||||
<tr>
|
||||
<td align="center" class="logo">
|
||||
<td style="line-height: 0; {% if event.settings.logo_image_large %}padding: 0;{% endif %}" align="center" class="logo">
|
||||
{% if event.settings.logo_image_large %}
|
||||
<img src="{% if event.settings.logo_image|thumb:'600_x5000'|first == '/' %}{{ site_url }}{% endif %}{{ event.settings.logo_image|thumb:'600_x5000' }}" alt="{{ event.name }}" style="width:100%" />
|
||||
{% else %}
|
||||
@@ -232,6 +232,9 @@
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td class="header" align="center">
|
||||
<!--[if gte mso 9]>
|
||||
<table cellpadding="20"><tr><td align="center">
|
||||
<![endif]-->
|
||||
{% if event %}
|
||||
<h2><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a>
|
||||
</h2>
|
||||
@@ -244,30 +247,51 @@
|
||||
{% block header %}
|
||||
<h1>{{ subject }}</h1>
|
||||
{% endblock %}
|
||||
<!--[if gte mso 9]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="containertd">
|
||||
<!--[if gte mso 9]>
|
||||
<table cellpadding="20"><tr><td>
|
||||
<![endif]-->
|
||||
<div class="content">
|
||||
{{ body|safe }}
|
||||
</div>
|
||||
<!--[if gte mso 9]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
{% if order %}
|
||||
<tr>
|
||||
<td class="order containertd">
|
||||
<!--[if gte mso 9]>
|
||||
<table cellpadding="20"><tr><td>
|
||||
<![endif]-->
|
||||
<div class="content">
|
||||
{% include "pretixbase/email/order_details.html" %}
|
||||
</div>
|
||||
<!--[if gte mso 9]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if signature %}
|
||||
<tr>
|
||||
<td class="order containertd">
|
||||
<!--[if gte mso 9]>
|
||||
<table cellpadding="20"><tr><td>
|
||||
<![endif]-->
|
||||
<div class="content">
|
||||
{{ signature | safe }}
|
||||
</div>
|
||||
<!--[if gte mso 9]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
@@ -279,7 +303,7 @@
|
||||
<br/>
|
||||
<!--[if gte mso 9]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,29 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def classname(obj):
|
||||
return obj.__class__.__name__
|
||||
@@ -1,31 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django 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
|
||||
url = attrs.get((None, 'href'), '/')
|
||||
href_url = urllib.parse.urlparse(url)
|
||||
if (None, 'href') in attrs and URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
||||
if URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
||||
# link text looks like a url
|
||||
if text.startswith('//'):
|
||||
text = 'https:' + text
|
||||
@@ -157,8 +157,6 @@ 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
|
||||
window.opener attribute.
|
||||
"""
|
||||
if (None, 'href') not in attrs:
|
||||
return attrs
|
||||
url = attrs.get((None, 'href'), '/')
|
||||
if not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)
|
||||
|
||||
@@ -47,7 +47,7 @@ class DownloadView(TemplateView):
|
||||
return HttpResponse('1' if self.object.file else '0')
|
||||
elif self.object.file:
|
||||
resp = ChunkBasedFileResponse(self.object.file.file, content_type=self.object.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(self.object.filename).encode('ascii', 'ignore')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(self.object.filename)
|
||||
return resp
|
||||
else:
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
@@ -30,85 +30,67 @@ from django.utils.translation import gettext as _
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
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=""):
|
||||
try:
|
||||
locale = get_language_from_request(request)
|
||||
except:
|
||||
locale = "en"
|
||||
with language(locale): # Middleware might not have run, need to do this manually
|
||||
t = get_template('csrffail.html')
|
||||
c = {
|
||||
'reason': reason,
|
||||
'no_referer': reason == REASON_NO_REFERER,
|
||||
'no_referer1': _(
|
||||
"You are seeing this message because this HTTPS site requires a "
|
||||
"'Referer header' to be sent by your Web browser, but none was "
|
||||
"sent. This header is required for security reasons, to ensure "
|
||||
"that your browser is not being hijacked by third parties."),
|
||||
'no_referer2': _(
|
||||
"If you have configured your browser to disable 'Referer' headers, "
|
||||
"please re-enable them, at least for this site, or for HTTPS "
|
||||
"connections, or for 'same-origin' requests."),
|
||||
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
|
||||
'no_cookie1': _(
|
||||
"You are seeing this message because this site requires a CSRF "
|
||||
"cookie when submitting forms. This cookie is required for "
|
||||
"security reasons, to ensure that your browser is not being "
|
||||
"hijacked by third parties."),
|
||||
'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')
|
||||
t = get_template('csrffail.html')
|
||||
c = {
|
||||
'reason': reason,
|
||||
'no_referer': reason == REASON_NO_REFERER,
|
||||
'no_referer1': _(
|
||||
"You are seeing this message because this HTTPS site requires a "
|
||||
"'Referer header' to be sent by your Web browser, but none was "
|
||||
"sent. This header is required for security reasons, to ensure "
|
||||
"that your browser is not being hijacked by third parties."),
|
||||
'no_referer2': _(
|
||||
"If you have configured your browser to disable 'Referer' headers, "
|
||||
"please re-enable them, at least for this site, or for HTTPS "
|
||||
"connections, or for 'same-origin' requests."),
|
||||
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
|
||||
'no_cookie1': _(
|
||||
"You are seeing this message because this site requires a CSRF "
|
||||
"cookie when submitting forms. This cookie is required for "
|
||||
"security reasons, to ensure that your browser is not being "
|
||||
"hijacked by third parties."),
|
||||
'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
|
||||
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:
|
||||
locale = get_language_from_request(request)
|
||||
except:
|
||||
locale = "en"
|
||||
with language(locale): # Middleware might not have run, need to do this manually
|
||||
exception_repr = exception.__class__.__name__
|
||||
# Try to get an "interesting" exception message, if any (and not the ugly
|
||||
# Resolver404 dictionary)
|
||||
try:
|
||||
message = exception.args[0]
|
||||
except (AttributeError, IndexError):
|
||||
pass
|
||||
else:
|
||||
if isinstance(message, (str, Promise)):
|
||||
exception_repr = str(message)
|
||||
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
|
||||
message = exception.args[0]
|
||||
except (AttributeError, IndexError):
|
||||
pass
|
||||
else:
|
||||
if isinstance(message, (str, Promise)):
|
||||
exception_repr = str(message)
|
||||
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
|
||||
def server_error(request):
|
||||
try:
|
||||
locale = get_language_from_request(request)
|
||||
except:
|
||||
locale = "en"
|
||||
with language(locale): # Middleware might not have run, need to do this manually
|
||||
try:
|
||||
template = loader.get_template('500.html')
|
||||
except TemplateDoesNotExist:
|
||||
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
||||
r = HttpResponseServerError(template.render({
|
||||
'request': request,
|
||||
'sentry_event_id': last_event_id(),
|
||||
}))
|
||||
r.xframe_options_exempt = True
|
||||
return r
|
||||
template = loader.get_template('500.html')
|
||||
except TemplateDoesNotExist:
|
||||
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
||||
r = HttpResponseServerError(template.render({
|
||||
'request': request,
|
||||
'sentry_event_id': last_event_id(),
|
||||
}))
|
||||
r.xframe_options_exempt = True
|
||||
return r
|
||||
|
||||
@@ -355,29 +355,20 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
override[k].pop('initial', None)
|
||||
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
|
||||
def invoice_form(self):
|
||||
if not self.address_asked and self.request.event.settings.invoice_name_required:
|
||||
f = self.invoice_name_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
event=self.request.event,
|
||||
instance=self.invoice_address,
|
||||
validate_vat_id=False,
|
||||
request=self.request,
|
||||
instance=self.invoice_address, validate_vat_id=False,
|
||||
all_optional=self.all_optional
|
||||
)
|
||||
elif self.address_asked:
|
||||
f = self.invoice_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
event=self.request.event,
|
||||
instance=self.invoice_address,
|
||||
validate_vat_id=self.vat_id_validation_enabled,
|
||||
request=self.request,
|
||||
instance=self.invoice_address, validate_vat_id=False,
|
||||
all_optional=self.all_optional,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -24,21 +24,6 @@ import urllib.parse
|
||||
from django.core import signing
|
||||
from django.http import HttpResponseBadRequest, HttpResponseRedirect
|
||||
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):
|
||||
@@ -47,14 +32,6 @@ def redir_view(request):
|
||||
url = signer.unsign(request.GET.get('url', ''))
|
||||
except signing.BadSignature:
|
||||
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['X-Robots-Tag'] = 'noindex'
|
||||
return r
|
||||
|
||||
@@ -48,7 +48,7 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
|
||||
from ...base.forms import I18nModelForm
|
||||
from ...base.forms import I18nModelForm, SecretKeySettingsField
|
||||
|
||||
# Import for backwards compatibility with okd import paths
|
||||
from ...base.forms.widgets import ( # noqa
|
||||
@@ -373,6 +373,49 @@ class FontSelect(forms.RadioSelect):
|
||||
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):
|
||||
def label_from_instance(self, obj):
|
||||
return str(obj) if obj.active else mark_safe(f'<strike class="text-muted">{escape(obj)}</strike>')
|
||||
|
||||
@@ -43,7 +43,6 @@ from django.core.validators import validate_email
|
||||
from django.db.models import Prefetch, Q, prefetch_related_objects
|
||||
from django.forms import CheckboxSelectMultiple, formset_factory
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import get_current_timezone_name
|
||||
@@ -64,7 +63,7 @@ from pretix.base.settings import (
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||
)
|
||||
from pretix.control.forms import (
|
||||
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||
MultipleLanguagesWidget, SlugWidget, SMTPSettingsMixin, SplitDateTimeField,
|
||||
SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2
|
||||
@@ -535,39 +534,34 @@ class EventSettingsForm(SettingsForm):
|
||||
'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):
|
||||
data = super().clean()
|
||||
settings_dict = self.event.settings.freeze()
|
||||
settings_dict.update(data)
|
||||
|
||||
data = self._resolve_virtual_keys_input(data)
|
||||
# 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 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)
|
||||
return data
|
||||
@@ -627,35 +621,6 @@ class EventSettingsForm(SettingsForm):
|
||||
else:
|
||||
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):
|
||||
auto_fields = [
|
||||
@@ -674,7 +639,6 @@ class CancelSettingsForm(SettingsForm):
|
||||
'change_allow_user_variation',
|
||||
'change_allow_user_price',
|
||||
'change_allow_user_until',
|
||||
'change_allow_user_addons',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -787,7 +751,6 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
'invoice_reissue_after_modify',
|
||||
'invoice_generate',
|
||||
'invoice_attendee_name',
|
||||
'invoice_event_location',
|
||||
'invoice_include_expire_date',
|
||||
'invoice_numbers_consecutive',
|
||||
'invoice_numbers_prefix',
|
||||
@@ -865,13 +828,13 @@ def contains_web_channel_validate(val):
|
||||
raise ValidationError(_("The online shop must be selected to receive these emails."))
|
||||
|
||||
|
||||
class MailSettingsForm(SettingsForm):
|
||||
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
|
||||
auto_fields = [
|
||||
'mail_prefix',
|
||||
'mail_from',
|
||||
'mail_from_name',
|
||||
'mail_attach_ical',
|
||||
'mail_attach_tickets',
|
||||
'mail_attachment_new_order',
|
||||
]
|
||||
|
||||
mail_sales_channel_placed_paid = forms.MultipleChoiceField(
|
||||
@@ -1214,7 +1177,6 @@ class TaxRuleLineForm(I18nForm):
|
||||
('reverse', _('Reverse charge')),
|
||||
('no', _('No VAT')),
|
||||
('block', _('Sale not allowed')),
|
||||
('require_approval', _('Order requires approval')),
|
||||
],
|
||||
)
|
||||
rate = forms.DecimalField(
|
||||
@@ -1248,7 +1210,7 @@ TaxRuleLineFormSet = formset_factory(
|
||||
class TaxRuleForm(I18nModelForm):
|
||||
class Meta:
|
||||
model = TaxRule
|
||||
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes']
|
||||
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country']
|
||||
|
||||
|
||||
class WidgetCodeForm(forms.Form):
|
||||
|
||||
@@ -811,213 +811,6 @@ 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):
|
||||
orders = {
|
||||
'date_from': 'date_from',
|
||||
@@ -2225,15 +2018,6 @@ class DeviceFilterForm(FilterForm):
|
||||
],
|
||||
required=False,
|
||||
)
|
||||
state = forms.ChoiceField(
|
||||
label=_('Device status'),
|
||||
choices=[
|
||||
('', _('All devices')),
|
||||
('active', _('Active devices')),
|
||||
('revoked', _('Revoked devices'))
|
||||
],
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
request = kwargs.pop('request')
|
||||
@@ -2263,11 +2047,6 @@ class DeviceFilterForm(FilterForm):
|
||||
if fdata.get('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'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
else:
|
||||
|
||||
@@ -713,7 +713,6 @@ class ItemVariationForm(I18nModelForm):
|
||||
'default_price',
|
||||
'original_price',
|
||||
'description',
|
||||
'require_approval',
|
||||
'require_membership',
|
||||
'require_membership_hidden',
|
||||
'require_membership_types',
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import 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
|
||||
def taxrule_label_from_instance(obj):
|
||||
return f"{obj.internal_name or obj.name} ({obj.rate} %)"
|
||||
return f"{obj.name} ({obj.rate} %)"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.pop('instance')
|
||||
|
||||
@@ -44,23 +44,21 @@ from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from pytz import common_timezones
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
||||
from pretix.base.forms.questions import (
|
||||
NamePartsFormField, WrappedPhoneNumberPrefixWidget, get_country_by_locale,
|
||||
get_phone_prefix,
|
||||
)
|
||||
from pretix.base.forms.questions import NamePartsFormField
|
||||
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||
from pretix.base.models import (
|
||||
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
|
||||
MembershipType, Organizer, Team,
|
||||
)
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
|
||||
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, SMTPSettingsMixin, SplitDateTimeField,
|
||||
)
|
||||
from pretix.control.forms.event import (
|
||||
SafeEventMultipleChoiceField, multimail_validate,
|
||||
)
|
||||
@@ -309,14 +307,8 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
'theme_color_danger',
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font',
|
||||
'privacy_url',
|
||||
'cookie_consent',
|
||||
'cookie_consent_dialog_title',
|
||||
'cookie_consent_dialog_text',
|
||||
'cookie_consent_dialog_text_secondary',
|
||||
'cookie_consent_dialog_button_yes',
|
||||
'cookie_consent_dialog_button_no',
|
||||
'primary_font'
|
||||
|
||||
]
|
||||
|
||||
organizer_logo_image = ExtFileField(
|
||||
@@ -356,8 +348,9 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
]
|
||||
|
||||
|
||||
class MailSettingsForm(SettingsForm):
|
||||
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
|
||||
auto_fields = [
|
||||
'mail_from',
|
||||
'mail_from_name',
|
||||
]
|
||||
|
||||
@@ -536,21 +529,11 @@ class CustomerUpdateForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'phone', 'locale']
|
||||
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'locale']
|
||||
|
||||
def __init__(self, *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(
|
||||
max_length=255,
|
||||
required=False,
|
||||
@@ -576,13 +559,6 @@ class CustomerUpdateForm(forms.ModelForm):
|
||||
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 Meta:
|
||||
|
||||
@@ -70,7 +70,6 @@ class UserEditForm(forms.ModelForm):
|
||||
'require_2fa',
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'needs_password_change',
|
||||
'last_login'
|
||||
]
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from django.urls import get_script_prefix, resolve, reverse
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scope
|
||||
from hijack.templatetags.hijack_tags import is_hijacked
|
||||
|
||||
from pretix.base.models import Event, Organizer
|
||||
from pretix.base.models.auth import SuperuserPermissionSet, User
|
||||
@@ -68,11 +69,6 @@ class PermissionMiddleware:
|
||||
"user.settings.notifications.off",
|
||||
)
|
||||
|
||||
EXCEPTIONS_FORCED_PW_CHANGE = (
|
||||
"user.settings",
|
||||
"auth.logout"
|
||||
)
|
||||
|
||||
EXCEPTIONS_2FA = (
|
||||
"user.settings.2fa",
|
||||
"user.settings.2fa.add",
|
||||
@@ -134,9 +130,6 @@ class PermissionMiddleware:
|
||||
if url_name not in ('user.reauth', 'auth.logout'):
|
||||
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
|
||||
|
||||
if request.user.needs_password_change and url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
|
||||
return redirect(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
|
||||
|
||||
if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \
|
||||
and url_name not in self.EXCEPTIONS_2FA:
|
||||
return redirect(reverse('control:user.settings.2fa'))
|
||||
@@ -182,7 +175,7 @@ class AuditLogMiddleware:
|
||||
|
||||
def __call__(self, request):
|
||||
if request.path.startswith(get_script_prefix() + 'control') and request.user.is_authenticated:
|
||||
if getattr(request.user, "is_hijacked", False):
|
||||
if is_hijacked(request):
|
||||
hijack_history = request.session.get('hijack_history', False)
|
||||
hijacker = get_object_or_404(User, pk=hijack_history[0])
|
||||
ss = hijacker.get_active_staff_session(request.session.get('hijacker_session'))
|
||||
|
||||
@@ -343,24 +343,10 @@ def get_global_navigation(request):
|
||||
'icon': 'group',
|
||||
},
|
||||
{
|
||||
'label': _('Search'),
|
||||
'label': _('Order search'),
|
||||
'url': reverse('control:search.orders'),
|
||||
'active': False,
|
||||
'active': 'search.orders' in url.url_name,
|
||||
'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'),
|
||||
@@ -436,6 +422,11 @@ def get_global_navigation(request):
|
||||
'url': reverse('control:global.license'),
|
||||
'active': (url.url_name == 'global.license'),
|
||||
},
|
||||
{
|
||||
'label': _('Consistency check'),
|
||||
'url': reverse('control:global.consistency'),
|
||||
'active': (url.url_name == 'global.consistency'),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load compress %}
|
||||
{% load i18n %}
|
||||
{% load hijack_tags %}
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html{% if rtl %} dir="rtl" class="rtl"{% endif %}>
|
||||
@@ -38,7 +39,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if request.user.is_hijacked %}
|
||||
{% if request|is_hijacked %}
|
||||
<div class="impersonate-warning">
|
||||
<span class="fa fa-user-secret"></span>
|
||||
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% load compress %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load hijack_tags %}
|
||||
{% load statici18n %}
|
||||
{% load eventsignal %}
|
||||
{% load eventurl %}
|
||||
@@ -350,7 +351,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if request.user.is_hijacked %}
|
||||
{% if request|is_hijacked %}
|
||||
<div class="impersonate-warning">
|
||||
<span class="fa fa-user-secret"></span>
|
||||
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}
|
||||
@@ -428,15 +429,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.needs_password_change %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
For security reasons, please change your password before you continue. Afterwards you
|
||||
will be redirected to your original destination.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
<footer>
|
||||
|
||||
@@ -74,17 +74,17 @@
|
||||
{{ c.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% if c.type == "exit" %}
|
||||
{% if c.auto_checked_in %}
|
||||
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip"
|
||||
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip_html"
|
||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
|
||||
{% endif %}
|
||||
{% elif c.forced and c.successful %}
|
||||
<span class="fa fa-fw fa-warning" data-toggle="tooltip"
|
||||
<span class="fa fa-fw fa-warning" data-toggle="tooltip_html"
|
||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}"></span>
|
||||
{% elif c.forced and not c.successful %}
|
||||
<br>
|
||||
<small class="text-muted">{% trans "Failed in offline mode" %}</small>
|
||||
{% elif c.auto_checked_in %}
|
||||
<span class="fa fa-fw fa-magic" data-toggle="tooltip"
|
||||
<span class="fa fa-fw fa-magic" data-toggle="tooltip_html"
|
||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,127 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,79 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,42 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -42,37 +42,13 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "Order changes" %}</legend>
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Allowing customers to change their own orders is a complex process due to the many different options pretix provides. Therefore, this feature currently has the following
|
||||
limitations:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<ul>
|
||||
<li>{% trans "It is possible to switch to a different variation of the same product, but not to an entirely different product (except for add-on products)." %}</li>
|
||||
<li>{% trans "Changing the seat or the event date in an event series will become available in the future, but is not possible now." %}</li>
|
||||
<li>{% trans "If a change leads to a price change, there will not be a change to fees such as payment, service, or shipping fees, even though an additional payment might be required." %}</li>
|
||||
<li>{% trans "If an add-on product is newly added, the system currently does not validate if there are required questions or fields that need to be filled out." %}</li>
|
||||
<li>{% trans "Customers currently cannot switch to a product variation or add an add-on product that requires them to use a voucher or membership." %}</li>
|
||||
<li>{% trans "Additional constraints and validation steps added by plugins are not enforced." %}</li>
|
||||
</ul>
|
||||
{% blocktrans trimmed %}
|
||||
Allowing users to change their order is a feature under development. Therefore, currently only specific changes (such as changing the variation of a product) are possible. More options might be added later.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% bootstrap_field form.change_allow_user_variation layout="control" %}
|
||||
{% bootstrap_field form.change_allow_user_addons layout="control" %}
|
||||
{% bootstrap_field form.change_allow_user_until layout="control" %}
|
||||
{% bootstrap_field form.change_allow_user_price layout="control" %}
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If the change leads to a price reduction and automatic refunds are enabled for self-service cancellations,
|
||||
the system will try to refund the money automatically.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
Refunds can be issued as a gift card if the respective option is set, but there is no customer choice between
|
||||
gift card and direct refund.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
{% bootstrap_field form.change_allow_user_until layout="control" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user