Compare commits

..

1 Commits

Author SHA1 Message Date
Raphael Michel
d169958687 Add Transaction.source flag 2022-10-19 12:09:55 +02:00
315 changed files with 167345 additions and 233404 deletions

View File

@@ -9,7 +9,6 @@ updates:
directory: "/src"
schedule:
interval: "daily"
versioning-strategy: increase
- package-ecosystem: "npm"
directory: "/src/pretix/static/npm_dir"
schedule:

View File

@@ -14,22 +14,16 @@ on:
- 'src/pretix/static/**'
- 'src/tests/**'
permissions:
contents: read # to fetch code (actions/checkout)
env:
FORCE_COLOR: 1
jobs:
spelling:
name: Spellcheck
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.9
python-version: 3.8
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -37,7 +31,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system packages
run: sudo apt update && sudo apt install enchant-2 hunspell aspell-en
run: sudo apt update && sudo apt install enchant hunspell aspell-en
- name: Install Dependencies
run: pip3 install -Ur requirements.txt
working-directory: ./doc

View File

@@ -12,22 +12,16 @@ on:
- 'doc/**'
- 'src/pretix/locale/**'
permissions:
contents: read # to fetch code (actions/checkout)
env:
FORCE_COLOR: 1
jobs:
compile:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
name: Check gettext syntax
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.9
python-version: 3.8
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -46,14 +40,14 @@ jobs:
run: python manage.py compilejsi18n
working-directory: ./src
spelling:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
name: Spellcheck
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.9
python-version: 3.8
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -61,7 +55,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system packages
run: sudo apt update && sudo apt install enchant-2 hunspell hunspell-de-de aspell-en aspell-de
run: sudo apt update && sudo apt install enchant hunspell hunspell-de-de aspell-en aspell-de
- name: Install Dependencies
run: pip3 install -e ".[dev]"
working-directory: ./src

View File

@@ -12,22 +12,16 @@ on:
- 'src/pretix/locale/**'
- 'src/pretix/static/**'
permissions:
contents: read # to fetch code (actions/checkout)
env:
FORCE_COLOR: 1
jobs:
isort:
name: isort
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.9
python-version: 3.8
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -42,13 +36,13 @@ jobs:
working-directory: ./src
flake:
name: flake8
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.9
python-version: 3.8
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -63,13 +57,13 @@ jobs:
working-directory: ./src
licenseheader:
name: licenseheaders
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.9
python-version: 3.8
- name: Install Dependencies
run: pip3 install licenseheaders
- name: Run licenseheaders

View File

@@ -12,29 +12,23 @@ on:
- 'doc/**'
- 'src/pretix/locale/**'
permissions:
contents: read # to fetch code (actions/checkout)
env:
FORCE_COLOR: 1
jobs:
test:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
name: Tests
strategy:
matrix:
python-version: ["3.7", "3.9", "3.10"]
python-version: ["3.7", "3.8", "3.9"]
database: [sqlite, postgres, mysql]
exclude:
- database: mysql
python-version: "3.10"
python-version: "3.8"
- database: mysql
python-version: "3.9"
- database: sqlite
python-version: "3.7"
- database: sqlite
python-version: "3.10"
python-version: "3.8"
steps:
- uses: actions/checkout@v2
- uses: getong/mariadb-action@v1.1
@@ -61,9 +55,9 @@ 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 mariadb-client-10.3
- name: Install Python dependencies
run: pip3 install --ignore-requires-python -e ".[dev]" mysqlclient psycopg2-binary # We ignore that flake8 needs newer python as we don't run flake8 during tests
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
working-directory: ./src
- name: Run checks
run: python manage.py check
@@ -82,4 +76,4 @@ jobs:
with:
file: src/coverage.xml
fail_ci_if_error: true
if: matrix.database == 'postgres' && matrix.python-version == '3.10'
if: matrix.database == 'postgres' && matrix.python-version == '3.8'

View File

@@ -117,9 +117,6 @@ Example::
``loglevel``
Set console and file log level (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``.
``request_id_header``
Specifies the name of a header that should be used for logging request IDs. Off by default.
Locale settings
---------------
@@ -399,9 +396,9 @@ The two ``transport_options`` entries can be omitted in most cases.
If they are present they need to be a valid JSON dictionary.
For possible entries in that dictionary see the `Celery documentation`_.
To use redis with sentinels set the broker or backend to ``sentinel://sentinel_host_1:26379;sentinel_host_2:26379/0``
To use redis with sentinels set the broker or backend to ``sentinel://sentinel_host_1:26379;sentinal_host_2:26379/0``
and the respective transport_options to ``{"master_name":"mymaster"}``.
If your redis instances behind the sentinel have a password use ``sentinel://:my_password@sentinel_host_1:26379;sentinel_host_2:26379/0``.
If your redis instances behind the sentinel have a password use ``sentinel://:my_password@sentinel_host_1:26379;sentinal_host_2:26379/0``.
If your redis sentinels themselves have a password set the transport_options to ``{"master_name":"mymaster","sentinel_kwargs":{"password":"my_password"}}``.
Sentry

View File

@@ -318,27 +318,6 @@ example::
(venv)$ python -m pretix rebuild
# systemctl restart pretix-web pretix-worker
System updates
--------------
After system updates, such as updates to a new Ubuntu or Debian release, you might be using a new Python version.
That's great, but requires some adjustments. First, adjust any old version paths in your nginx configuration file.
Then, re-create your Python environment::
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 freeze > /tmp/pip-backup.txt
$ rm -rf /var/pretix/venv
$ python3 -m venv /var/pretix/venv
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U pip wheel setuptools
(venv)$ pip3 install -r /tmp/pip-backup.txt
Then, proceed like after any plugin installation::
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
(venv)$ python -m pretix updatestyles
# systemctl restart pretix-web pretix-worker
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/

View File

@@ -48,11 +48,10 @@ Possible permissions are:
Compatibility
-------------
We try to avoid any breaking changes to our API to avoid hassle on your end. If possible, we'll
build new features in a way that keeps all pre-existing API usage unchanged. In some cases,
this might not be possible or only possible with restrictions. In these case, any
backwards-incompatible changes will be prominently noted in the "Changes to the REST API"
section of our release notes. If possible, we will announce them multiple releases in advance.
We currently see pretix' API as a beta-stage feature. We therefore do not give any guarantees
for compatibility between feature releases of pretix (such as 1.5 and 1.6). However, as always,
we try not to break things when we don't need to. Any backwards-incompatible changes will be
prominently noted in the release notes.
We treat the following types of changes as *backwards-compatible* so we ask you to make sure
that your clients can deal with them properly:
@@ -61,7 +60,6 @@ that your clients can deal with them properly:
* Support of new HTTP methods for a given API endpoint
* Support of new query parameters for a given API endpoint
* New fields contained in API responses
* New possible values of enumeration-like fields
* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes)
We treat the following types of changes as *backwards-incompatible*:

View File

@@ -43,6 +43,10 @@ seat objects The assigned se
└ seat_guid string Identifier of the seat within the seating plan
===================================== ========================== =======================================================
.. versionchanged:: 3.0
This ``seat`` attribute has been added.
.. versionchanged:: 4.14
This ``is_bundled`` attribute has been added and the cart creation endpoints have been updated.

View File

@@ -39,6 +39,23 @@ exit_all_at datetime Automatically c
addon_match boolean If ``true``, tickets on this list can be redeemed by scanning their parent ticket if this still leads to an unambiguous match.
===================================== ========================== =======================================================
.. versionchanged:: 3.9
The ``subevent`` attribute may now be ``null`` inside event series. The ``allow_multiple_entries``,
``allow_entry_after_exit``, and ``rules`` attributes have been added.
.. versionchanged:: 3.11
The ``subevent_match`` and ``exclude`` query parameters have been added.
.. versionchanged:: 3.12
The ``exit_all_at`` attribute has been added.
.. versionchanged:: 3.17
The ``ends_after`` and ``expand`` query parameters have been added.
.. versionchanged:: 4.12
The ``addon_match`` attribute has been added.

View File

@@ -52,9 +52,34 @@ sales_channels list A list of sales
===================================== ========================== =======================================================
.. versionchanged:: 3.3
The attributes ``geo_lat`` and ``geo_lon`` have been added.
.. versionchanged:: 3.4
The attribute ``timezone`` has been added.
.. versionchanged:: 3.7
The attribute ``item_meta_properties`` has been added.
.. versionchanged:: 3.12
The attribute ``valid_keys`` has been added.
.. versionchanged:: 3.14
The attribute ``sales_channels`` has been added.
Endpoints
---------
.. versionchanged:: 3.3
The events resource can now be filtered by meta data attributes.
.. versionchanged:: 4.0
The ``clone_from`` parameter has been added to the event creation endpoint.
@@ -542,6 +567,10 @@ information about the properties.
.. warning:: This API is intended for advanced users. Even though we take care to validate your input, you will be
able to break your event using this API by creating situations of conflicting settings. Please take care.
.. versionchanged:: 3.6
Initial support for settings has been added to the API.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/settings/
Get current values of event settings.

View File

@@ -6,6 +6,10 @@ Data exporters
pretix and it's plugins include a number of data exporters that allow you to bulk download various data from pretix in
different formats. This page shows you how to use these exporters through the API.
.. versionchanged:: 3.13
This feature has been added to the API.
.. warning::
While we consider the methods listed on this page to be a stable API, the availability and specific input field

View File

@@ -40,6 +40,10 @@ text string Custom text of
Endpoints
---------
.. versionadded:: 3.14
The transaction list endpoint was added.
.. http:get:: /api/v1/organizers/(organizer)/giftcards/
Returns a list of all gift cards issued by a given organizer.
@@ -253,6 +257,10 @@ Endpoints
"value": "15.37"
}
.. versionchanged:: 3.5
This endpoint now returns status code ``409`` if the transaction would lead to a negative gift card value.
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the gift card to modify
:query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer.

View File

@@ -108,6 +108,16 @@ internal_reference string Customer's refe
===================================== ========================== =======================================================
.. versionchanged:: 3.4
The attribute ``lines.number`` has been added.
.. versionchanged:: 3.17
The attribute ``invoice_to_*``, ``invoice_from_*``, ``custom_field``, ``lines.item``, ``lines.variation``, ``lines.event_date_from``,
``lines.event_date_to``, and ``lines.attendee_name`` have been added.
``refers`` now returns an invoice number including the prefix.
.. versionchanged:: 4.1
The attributes ``fee_type`` and ``fee_internal_type`` have been added.

View File

@@ -43,13 +43,8 @@ available_until datetime The last date t
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop
frontend.
meta_data object Values set for event-specific meta data parameters.
===================================== ========================== =======================================================
.. versionchanged:: 4.16
The ``meta_data`` attribute has been added.
Endpoints
---------
@@ -99,7 +94,6 @@ Endpoints
"default_price": "223.00",
"price": 223.0,
"original_price": null,
"meta_data": {}
},
{
"id": 3,
@@ -114,8 +108,7 @@ Endpoints
"description": {},
"position": 1,
"default_price": null,
"price": 15.0,
"meta_data": {}
"price": 15.0
}
]
}
@@ -168,8 +161,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0,
"meta_data": {}
"position": 0
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -206,8 +198,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0,
"meta_data": {}
"position": 0
}
**Example response**:
@@ -234,8 +225,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0,
"meta_data": {}
"position": 0
}
:param organizer: The ``slug`` field of the organizer of the event/item to create a variation for
@@ -293,8 +283,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 1,
"meta_data": {}
"position": 1
}
:param organizer: The ``slug`` field of the organizer to modify

View File

@@ -123,7 +123,6 @@ variations list of objects A list with one
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop
frontend.
├ meta_data object Values set for event-specific meta data parameters.
└ position integer An integer, used for sorting
addons list of objects Definition of add-ons that can be chosen for this item.
Only writable during creation,
@@ -147,6 +146,14 @@ bundles list of objects Definition of b
meta_data object Values set for event-specific meta data parameters.
===================================== ========================== =======================================================
.. versionchanged:: 3.7
The attribute ``meta_data`` has been added.
.. versionchanged:: 3.10
The attribute ``multi_allowed`` has been added to ``addons``.
.. versionchanged:: 4.0
The attributes ``require_membership``, ``require_membership_types``, ``grant_membership_type``, ``grant_membership_duration_like_event``,
@@ -156,10 +163,6 @@ meta_data object Values set for
The attributes ``require_membership_hidden`` attribute has been added.
.. versionchanged:: 4.16
The ``variations[x].meta_data`` attribute has been added.
Notes
-----
@@ -252,7 +255,6 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -268,7 +270,6 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -368,7 +369,6 @@ Endpoints
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"meta_data": {},
"position": 0
},
{
@@ -384,7 +384,6 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -464,7 +463,6 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -480,7 +478,6 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -549,7 +546,6 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -565,7 +561,6 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -665,7 +660,6 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -681,7 +675,6 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],

View File

@@ -98,6 +98,30 @@ last_modified datetime Last modificati
===================================== ========================== =======================================================
.. versionchanged:: 3.5
The ``order.fees.canceled`` attribute has been added.
.. versionchanged:: 3.8
The ``reactivate`` operation has been added.
.. versionchanged:: 3.10
The ``search`` query parameter has been added.
.. versionchanged:: 3.11
The ``exclude`` and ``subevent_after`` query parameter has been added.
.. versionchanged:: 3.13
The ``subevent_before`` query parameter has been added.
.. versionchanged:: 3.14
The ``phone`` attribute has been added.
.. versionchanged:: 4.0
The ``customer`` attribute has been added.
@@ -118,10 +142,6 @@ last_modified datetime Last modificati
The ``order.fees.id`` attribute has been added.
.. versionchanged:: 4.15
The ``include`` query parameter has been added.
.. _order-position-resource:
@@ -158,7 +178,6 @@ tax_rule integer The ID of the u
secret string Secret code printed on the tickets for validation
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
discount integer ID of a discount that has been used during the creation of this position in some way (or ``null``).
pseudonymization_id string A random ID, e.g. for use in lead scanning apps
checkins list of objects List of **successful** check-ins with this ticket
├ id integer Internal ID of the check-in event
@@ -186,6 +205,27 @@ pdf_data object Data object req
``pdf_data=true`` query parameter to your request.
===================================== ========================== =======================================================
.. versionchanged:: 3.3
The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See
:ref:`order-position-ticket-download` for details.
.. versionchanged:: 3.5
The attribute ``canceled`` has been added.
.. versionchanged:: 3.8
The attributes ``company``, ``street``, ``zipcode``, ``city``, ``country``, and ``state`` have been added.
.. versionchanged:: 3.9
The ``checkin.type`` attribute has been added.
.. versionchanged:: 3.16
Answers to file questions are now returned as an URL.
.. _order-payment-resource:
Order payment resource
@@ -232,20 +272,15 @@ created datetime Date and time o
comment string Reason for refund (shown to the customer in some cases, can be ``null``).
execution_date datetime Date and time of completion of this refund (or ``null``)
provider string Identification string of the payment provider
details object Refund-specific information. This is a dictionary
with various fields that can be different between
payment providers, versions, payment states, etc. If
you read this field, you always need to be able to
deal with situations where values that you expect are
missing. Mostly, the field contains various IDs that
can be used for matching with other systems. If a
payment provider does not implement this feature,
the object is empty.
===================================== ========================== =======================================================
List of all orders
------------------
.. versionchanged:: 3.5
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/
Returns a list of all orders within a given event.
@@ -336,7 +371,6 @@ List of all orders
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
@@ -413,7 +447,6 @@ List of all orders
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
:query string include: Include only the given field in the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times. ``include`` is applied before ``exclude``, so ``exclude`` takes precedence.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
@@ -425,6 +458,10 @@ List of all orders
Fetching individual orders
--------------------------
.. versionchanged:: 3.5
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
Returns information on one order, identified by its order code.
@@ -509,7 +546,6 @@ Fetching individual orders
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
@@ -999,6 +1035,10 @@ Creating orders
Order state operations
----------------------
.. versionchanged:: 3.12
The ``mark_paid`` operation now takes a ``send_email`` parameter.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/
Marks a pending or expired order as successfully paid.
@@ -1400,6 +1440,10 @@ Sending e-mails
List of all order positions
---------------------------
.. versionchanged:: 3.5
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
Returns a list of all order positions within a given event.
@@ -1443,7 +1487,6 @@ List of all order positions
"tax_rule": null,
"tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"addon_to": null,
@@ -1554,7 +1597,6 @@ Fetching individual positions
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
@@ -1654,6 +1696,10 @@ Order position ticket download
Manipulating individual positions
---------------------------------
.. versionchanged:: 3.15
The ``PATCH`` method has been added for individual positions.
.. versionchanged:: 4.8
The ``PATCH`` method now supports changing items, variations, subevents, seats, prices, and tax rules.
@@ -1960,6 +2006,14 @@ otherwise, such as splitting an order or changing fees.
Order payment endpoints
-----------------------
.. versionchanged:: 3.6
Payments can now be created through the API.
.. versionchanged:: 3.12
The ``confirm`` operation now takes a ``send_email`` parameter.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
Returns a list of all payments for an order.
@@ -2265,7 +2319,6 @@ Order refund endpoints
"created": "2017-12-01T10:00:00Z",
"execution_date": "2017-12-04T12:13:12Z",
"comment": "Cancellation",
"details": {},
"provider": "banktransfer"
}
]
@@ -2309,7 +2362,6 @@ Order refund endpoints
"created": "2017-12-01T10:00:00Z",
"execution_date": "2017-12-04T12:13:12Z",
"comment": "Cancellation",
"details": {},
"provider": "banktransfer"
}
@@ -2367,7 +2419,6 @@ Order refund endpoints
"created": "2017-12-01T10:00:00Z",
"execution_date": null,
"comment": "Cancellation",
"details": {},
"provider": "manual"
}
@@ -2497,6 +2548,10 @@ Revoked ticket secrets
With some non-default ticket secret generation methods, a list of revoked ticket secrets is required for proper validation.
.. versionchanged:: 3.12
Added revocation lists.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/revokedsecrets/
Returns a list of all revoked secrets within a given event.

View File

@@ -109,6 +109,10 @@ information about the properties.
.. warning:: This API is intended for advanced users. Even though we take care to validate your input, you will be
able to break your shops using this API by creating situations of conflicting settings. Please take care.
.. versionchanged:: 3.14
Initial support for settings has been added to the API.
.. http:get:: /api/v1/organizers/(organizer)/settings/
Get current values of organizer settings.

View File

@@ -76,9 +76,26 @@ dependency_value string An old version
for one value. **Deprecated.**
===================================== ========================== =======================================================
.. versionchanged:: 3.5
The attribute ``help_text`` has been added.
.. versionchanged:: 3.14
The attributes ``valid_*`` have been added.
.. versionchanged:: 3.18
The attribute ``valid_file_portrait`` have been added.
Endpoints
---------
.. versionchanged:: 1.15
The questions endpoint has been extended by the filter queries ``ask_during_checkin``, ``requred``, and
``identifier``.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/
Returns a list of all questions within a given event.

View File

@@ -36,6 +36,10 @@ available_number integer Number of avail
slightly out of date. ``null`` means unlimited.
===================================== ========================== =======================================================
.. versionchanged:: 3.10
The attribute ``release_after_exit`` has been added.
.. versionchanged:: 4.1
The ``with_availability`` query parameter has been added.

View File

@@ -59,13 +59,29 @@ seat_category_mapping object An object mappi
last_modified datetime Last modification of this object
===================================== ========================== =======================================================
.. versionchanged:: 4.15
.. versionchanged:: 3.3
The ``search`` query parameter has been added to filter sub-events by their name or location in any language.
The attributes ``geo_lat`` and ``geo_lon`` have been added.
.. versionchanged:: 3.10
The ``disabled`` attribute has been added to ``item_price_overrides`` and ``variation_price_overrides``.
.. versionchanged:: 3.12
The ``last_modified`` attribute has been added.
.. versionchanged:: 3.18
The ``available_from``/``available_until`` attributes have been added to ``item_price_overrides`` and ``variation_price_overrides``.
Endpoints
---------
.. versionchanged:: 3.3
The sub-events resource can now be filtered by meta data attributes.
.. versionchanged:: 4.1
The ``with_availability_for`` parameter has been added.
@@ -131,7 +147,6 @@ Endpoints
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
:query search: Only return events matching a given search query.
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the main event
:query datetime modified_since: Only return objects that have changed since the given date. Be careful: This does not

View File

@@ -19,8 +19,6 @@ max_usages integer The maximum num
redeemed (default: 1).
redeemed integer The number of times this voucher already has been
redeemed.
min_usages integer The minimum number of times this voucher must be
redeemed on first usage (default: 1).
valid_until datetime The voucher expiration date (or ``null``).
block_quota boolean If ``true``, quota is blocked for this voucher.
allow_ignore_quota boolean If ``true``, this voucher can be redeemed even if a
@@ -50,6 +48,10 @@ show_hidden_items boolean Only if set to
===================================== ========================== =======================================================
.. versionchanged:: 3.4
The attribute ``seat`` has been added.
Endpoints
---------

View File

@@ -30,6 +30,12 @@ subevent integer ID of the date
===================================== ========================== =======================================================
.. versionchanged:: 1.15
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added as well as a method to send out
vouchers.
Endpoints
---------

View File

@@ -38,7 +38,7 @@ Frontend
.. automodule:: pretix.presale.signals
:members: order_info, order_info_top, order_meta_from_request
:members: order_info, order_info_top, order_meta_from_request, order_source_from_request
Request flow
""""""""""""

View File

@@ -126,8 +126,6 @@ The provider class
.. automethod:: api_payment_details
.. automethod:: api_refund_details
.. automethod:: matching_id
.. automethod:: shred_payment_info
@@ -138,10 +136,6 @@ The provider class
.. autoattribute:: is_meta
.. autoattribute:: execute_payment_needs_user
.. autoattribute:: multi_use_supported
.. autoattribute:: test_mode_message
.. autoattribute:: requires_invoice_immediately

View File

@@ -184,6 +184,11 @@ Most of these methods work identically on :class:`pretix.base.models.TeamAPIToke
Staff sessions
--------------
.. versionchanged:: 1.14
In 1.14, the ``User.is_superuser`` attribute has been deprecated and statically set to return ``False``. Staff
sessions have been newly introduced.
System administrators of a pretix instance are identified by the ``is_staff`` attribute on the user model. By default,
the regular permission rules apply for users with ``is_staff = True``. The only difference is that such users can
temporarily turn on "staff mode" via a button in the user interface that grants them **all permissions** as long as

View File

@@ -91,7 +91,6 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal content ID
title multi-lingual string The content title (required)
internal_name string An optional name that is only used in the backend
content_type string The type of content, valid values are ``webinar``, ``video``, ``livestream``, ``link``, ``file``
url string The location of the digital content
file file A downloadable file. Either ``url`` or ``file`` must be ``null``.

View File

@@ -447,4 +447,8 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
</script>
.. versionchanged:: 3.6
Dynamically opening the widget has been added in pretix 3.6.
.. _Let's Encrypt: https://letsencrypt.org/

View File

@@ -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.16.0.dev0"
__version__ = "4.14.1.dev0"

View File

@@ -196,7 +196,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'plugins:pretix_posbackend:posdebuglogentry-bulk-create'),
('GET', 'plugins:pretix_posbackend:poscashier-list'),
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
('POST', 'plugins:pretix_posbackend:stripeterminal.paymentintent'),
('PUT', 'plugins:pretix_posbackend:file.upload'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:event.settings'),

View File

@@ -19,17 +19,11 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
import ujson
from rest_framework import exceptions
from rest_framework.response import Response
from rest_framework.views import exception_handler, status
from pretix.base.services.locking import LockTimeoutException
logger = logging.getLogger(__name__)
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
@@ -43,7 +37,4 @@ def custom_exception_handler(exc, context):
}
)
if isinstance(exc, exceptions.APIException):
logger.info(f'API Exception [{exc.status_code}]: {ujson.dumps(exc.detail)}')
return response

View File

@@ -1,77 +0,0 @@
# Generated by Django 3.2.16 on 2022-12-17 18:47
import uuid
import django.db.models.deletion
import oauth2_provider.generators
import oauth2_provider.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0226_itemvariationmetavalue'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('pretixapi', '0008_webhookcallretry'),
]
run_before = [
('oauth2_provider', '0002_auto_20190406_1805'),
]
operations = [
migrations.AddField(
model_name='oauthapplication',
name='algorithm',
field=models.CharField(default='', max_length=5),
),
migrations.AddField(
model_name='oauthgrant',
name='claims',
field=models.TextField(default=''),
preserve_default=False,
),
migrations.AddField(
model_name='oauthgrant',
name='code_challenge',
field=models.CharField(default='', max_length=128),
),
migrations.AddField(
model_name='oauthgrant',
name='code_challenge_method',
field=models.CharField(default='', max_length=10),
),
migrations.AddField(
model_name='oauthgrant',
name='nonce',
field=models.CharField(default='', max_length=255),
),
migrations.AlterField(
model_name='oauthapplication',
name='client_secret',
field=oauth2_provider.models.ClientSecretField(db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255),
),
migrations.CreateModel(
name='OAuthIDToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('jti', models.UUIDField(default=uuid.uuid4, unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField()),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('organizers', models.ManyToManyField(to='pretixbase.Organizer')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pretixapi_oauthidtoken', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='oauthaccesstoken',
name='id_token',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to='pretixapi.oauthidtoken'),
),
]

View File

@@ -29,8 +29,8 @@ from oauth2_provider.generators import (
generate_client_id, generate_client_secret,
)
from oauth2_provider.models import (
AbstractAccessToken, AbstractApplication, AbstractGrant, AbstractIDToken,
AbstractRefreshToken, ClientSecretField,
AbstractAccessToken, AbstractApplication, AbstractGrant,
AbstractRefreshToken,
)
from oauth2_provider.validators import URIValidator
@@ -46,7 +46,7 @@ class OAuthApplication(AbstractApplication):
verbose_name=_("Client ID"),
max_length=100, unique=True, default=generate_client_id, db_index=True
)
client_secret = ClientSecretField(
client_secret = models.CharField(
verbose_name=_("Client secret"),
max_length=255, blank=False, default=generate_client_secret, db_index=True
)
@@ -67,26 +67,12 @@ class OAuthGrant(AbstractGrant):
redirect_uri = models.CharField(max_length=2500) # Only 255 in AbstractGrant, which caused problems
class OAuthIDToken(AbstractIDToken):
application = models.ForeignKey(
OAuthApplication, on_delete=models.CASCADE,
)
organizers = models.ManyToManyField('pretixbase.Organizer')
class OAuthAccessToken(AbstractAccessToken):
source_refresh_token = models.OneToOneField(
# unique=True implied by the OneToOneField
'OAuthRefreshToken', on_delete=models.SET_NULL, blank=True, null=True,
related_name="refreshed_access_token"
)
id_token = models.OneToOneField(
OAuthIDToken,
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="access_token",
)
application = models.ForeignKey(
OAuthApplication, on_delete=models.CASCADE, blank=True, null=True,
)

View File

@@ -237,14 +237,12 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
for addon_data in addons_data:
addon_data['addon_to'] = cp
addon_data['is_bundled'] = False
addon_data['cart_id'] = cp.cart_id
super().create(addon_data)
if bundled_data:
for bundle_data in bundled_data:
bundle_data['addon_to'] = cp
bundle_data['is_bundled'] = True
bundle_data['cart_id'] = cp.cart_id
super().create(bundle_data)
return cp

View File

@@ -411,8 +411,7 @@ class CloneEventSerializer(EventSerializer):
has_subevents = validated_data.pop('has_subevents', None)
tz = validated_data.pop('timezone', None)
sales_channels = validated_data.pop('sales_channels', None)
date_admission = validated_data.pop('date_admission', None)
new_event = super().create({**validated_data, 'plugins': None})
new_event = super().create(validated_data)
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
new_event.copy_data_from(event)
@@ -427,10 +426,6 @@ class CloneEventSerializer(EventSerializer):
new_event.sales_channels = sales_channels
if has_subevents is not None:
new_event.has_subevents = has_subevents
if has_subevents is not None:
new_event.has_subevents = has_subevents
if date_admission is not None:
new_event.date_admission = date_admission
new_event.save()
if tz:
new_event.settings.timezone = tz
@@ -760,9 +755,6 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_logo_image',
'cancel_allow_user',
'cancel_allow_user_until',
'cancel_allow_user_unpaid_keep',
'cancel_allow_user_unpaid_keep_fees',
'cancel_allow_user_unpaid_keep_percentage',
'cancel_allow_user_paid',
'cancel_allow_user_paid_until',
'cancel_allow_user_paid_keep',

View File

@@ -47,14 +47,13 @@ from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
ItemVariationMetaValue, Question, QuestionOption, Quota,
Question, QuestionOption, Quota,
)
class InlineItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
class Meta:
model = ItemVariation
@@ -62,23 +61,16 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher', 'meta_data')
'sales_channels', 'hide_without_voucher',)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet)
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.parent.parent.item_meta_properties:
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
return value
class ItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
class Meta:
model = ItemVariation
@@ -86,63 +78,12 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher', 'meta_data')
'sales_channels', 'hide_without_voucher',)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
@transaction.atomic
def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None)
variation = ItemVariation.objects.create(**validated_data)
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
ItemVariationMetaValue.objects.create(
property=self.item_meta_properties.get(key),
value=value,
variation=variation
)
return variation
@cached_property
def item_meta_properties(self):
return {
p.name: p for p in self.context['request'].event.item_meta_properties.all()
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.item_meta_properties:
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
return value
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
variation = super().update(instance, validated_data)
# Meta data
if meta_data is not None:
current = {mv.property: mv for mv in variation.meta_values.select_related('property')}
for key, value in meta_data.items():
prop = self.item_meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
variation.meta_values.create(
property=self.item_meta_properties.get(key),
value=value
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
return variation
class InlineItemBundleSerializer(serializers.ModelSerializer):
class Meta:
@@ -243,8 +184,6 @@ class ItemSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['default_price'].allow_null = False
self.fields['default_price'].required = True
if not self.read_only:
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
@@ -322,19 +261,9 @@ class ItemSerializer(I18nAwareModelSerializer):
for variation_data in variations_data:
require_membership_types = variation_data.pop('require_membership_types', [])
var_meta_data = variation_data.pop('meta_data', {})
v = ItemVariation.objects.create(item=item, **variation_data)
if require_membership_types:
v.require_membership_types.add(*require_membership_types)
if var_meta_data is not None:
for key, value in var_meta_data.items():
ItemVariationMetaValue.objects.create(
property=self.item_meta_properties.get(key),
value=value,
variation=v
)
for addon_data in addons_data:
ItemAddOn.objects.create(base_item=item, **addon_data)
for bundle_data in bundles_data:

View File

@@ -29,7 +29,6 @@ import pycountry
from django.conf import settings
from django.core.files import File
from django.db.models import F, Q
from django.utils.encoding import force_str
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
from django_countries.fields import Country
@@ -62,25 +61,14 @@ from pretix.base.services.pricing import (
)
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries
from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger(__name__)
class CompatibleCountryField(serializers.Field):
countries = CachedCountries()
default_error_messages = {
'invalid_choice': gettext_lazy('"{input}" is not a valid choice.')
}
def to_internal_value(self, data):
country = self.countries.alpha2(data)
if data and not country:
country = self.countries.by_name(force_str(data))
if not country:
self.fail("invalid_choice", input=data)
return {self.field_name: Country(country)}
return {self.field_name: Country(data)}
def to_representation(self, instance: InvoiceAddress):
if instance.country:
@@ -371,19 +359,10 @@ class PdfDataSerializer(serializers.Field):
for k, v in ev._cached_meta_data.items():
res['meta:' + k] = v
if instance.variation_id:
print(instance, instance.variation, instance.variation_id, instance.item)
if not hasattr(instance.variation, '_cached_meta_data'):
instance.variation.item = instance.item # saves some database lookups
instance.variation._cached_meta_data = instance.variation.meta_data
print(instance.variation._cached_meta_data.items())
for k, v in instance.variation._cached_meta_data.items():
res['itemmeta:' + k] = v
else:
if not hasattr(instance.item, '_cached_meta_data'):
instance.item._cached_meta_data = instance.item.meta_data
for k, v in instance.item._cached_meta_data.items():
res['itemmeta:' + k] = v
if not hasattr(instance.item, '_cached_meta_data'):
instance.item._cached_meta_data = instance.item.meta_data
for k, v in instance.item._cached_meta_data.items():
res['itemmeta:' + k] = v
res['images'] = {}
@@ -431,13 +410,13 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
read_only_fields = (
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
'seat', 'canceled', 'discount',
'seat', 'canceled'
)
def __init__(self, *args, **kwargs):
@@ -574,22 +553,12 @@ class OrderPaymentSerializer(I18nAwareModelSerializer):
'details')
class RefundDetailsField(serializers.Field):
def to_representation(self, value: OrderRefund):
pp = value.payment_provider
if not pp:
return {}
return pp.api_refund_details(value)
class OrderRefundSerializer(I18nAwareModelSerializer):
payment = SlugRelatedField(slug_field='local_id', read_only=True)
details = RefundDetailsField(source='*', allow_null=True, read_only=True)
class Meta:
model = OrderRefund
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'comment', 'provider',
'details')
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'comment', 'provider')
class OrderURLField(serializers.URLField):
@@ -631,32 +600,6 @@ class OrderSerializer(I18nAwareModelSerializer):
if not self.context['pdf_data']:
self.fields['positions'].child.fields.pop('pdf_data', None)
includes = set(self.context['include'])
if includes:
for fname, field in list(self.fields.items()):
if fname in includes:
continue
elif hasattr(field, 'child'): # Nested list serializers
found_any = False
for childfname, childfield in list(field.child.fields.items()):
if f'{fname}.{childfname}' not in includes:
field.child.fields.pop(childfname)
else:
found_any = True
if not found_any:
self.fields.pop(fname)
elif isinstance(field, serializers.Serializer): # Nested serializers
found_any = False
for childfname, childfield in list(field.fields.items()):
if f'{fname}.{childfname}' not in includes:
field.fields.pop(childfname)
else:
found_any = True
if not found_any:
self.fields.pop(fname)
else:
self.fields.pop(fname)
for exclude_field in self.context['exclude']:
p = exclude_field.split('.')
if p[0] in self.fields:
@@ -778,7 +721,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
def __init__(self, *args, **kwargs):
@@ -1435,7 +1378,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
state=OrderPayment.PAYMENT_STATE_CREATED
)
order.create_transactions(is_new=True, fees=fees, positions=pos_map.values())
order.create_transactions(is_new=True, fees=fees, positions=pos_map.values(), source=self.context['source'])
return order

View File

@@ -61,7 +61,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
class Meta:
model = Voucher
fields = ('id', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent', 'show_hidden_items', 'seat')
read_only_fields = ('id', 'redeemed')

View File

@@ -35,8 +35,7 @@
import importlib
from django.apps import apps
from django.conf.urls import re_path
from django.urls import include
from django.conf.urls import include, re_path
from rest_framework import routers
from pretix.api.views import cart

14
src/pretix/api/utils.py Normal file
View File

@@ -0,0 +1,14 @@
from pretix.api.models import OAuthAccessToken
from pretix.base.models import Device, TeamAPIToken
def get_api_source(request):
if isinstance(request.auth, Device):
return "pretix.api", f"device:{request.auth.pk}"
elif isinstance(request.auth, TeamAPIToken):
return "pretix.api", f"token:{request.auth.pk}"
elif isinstance(request.auth, OAuthAccessToken):
return "pretix.api", f"oauth.app:{request.auth.application.pk}"
elif request.user.is_authenticated:
return "pretix.api", f"user:{request.user.pk}"
return "pretix.api", None

View File

@@ -92,11 +92,6 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
def perform_create(self, serializer):
raise NotImplementedError()
@transaction.atomic()
def perform_destroy(self, instance):
instance.addons.all().delete()
instance.delete()
def _require_locking(self, quota_diff, voucher_use_diff, seat_diff):
if voucher_use_diff or seat_diff:
# If any vouchers or seats are used, we lock to make sure we don't redeem them to often

View File

@@ -33,7 +33,6 @@
# License for the specific language governing permissions and limitations under the License.
import django_filters
from django.conf import settings
from django.db import transaction
from django.db.models import Prefetch, ProtectedError, Q
from django.utils.timezone import now
@@ -242,17 +241,13 @@ class EventViewSet(viewsets.ModelViewSet):
except Event.DoesNotExist:
raise ValidationError('Event to copy from was not found')
# Ensure that .installed() is only called when we NOT clone
plugins = serializer.validated_data.pop('plugins', None)
serializer.validated_data['plugins'] = None
new_event = serializer.save(organizer=self.request.organizer)
if copy_from:
new_event.copy_data_from(copy_from)
if plugins is not None:
new_event.set_active_plugins(plugins)
if 'plugins' in serializer.validated_data:
new_event.set_active_plugins(serializer.validated_data['plugins'])
if 'is_public' in serializer.validated_data:
new_event.is_public = serializer.validated_data['is_public']
if 'testmode' in serializer.validated_data:
@@ -261,17 +256,12 @@ class EventViewSet(viewsets.ModelViewSet):
new_event.sales_channels = serializer.validated_data['sales_channels']
if 'has_subevents' in serializer.validated_data:
new_event.has_subevents = serializer.validated_data['has_subevents']
if 'date_admission' in serializer.validated_data:
new_event.date_admission = serializer.validated_data['date_admission']
new_event.save()
if 'timezone' in serializer.validated_data:
new_event.settings.timezone = serializer.validated_data['timezone']
else:
serializer.instance.set_defaults()
new_event.set_active_plugins(plugins if plugins is not None else settings.PRETIX_PLUGINS_DEFAULT.split(','))
new_event.save(update_fields=['plugins'])
serializer.instance.log_action(
'pretix.event.added',
user=self.request.user,
@@ -332,7 +322,6 @@ with scopes_disabled():
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
search = django_filters.rest_framework.CharFilter(method='search_qs')
class Meta:
model = SubEvent
@@ -368,12 +357,6 @@ with scopes_disabled():
def sales_channel_qs(self, queryset, name, value):
return queryset.filter(event__sales_channels__contains=value)
def search_qs(self, queryset, name, value):
return queryset.filter(
Q(name__icontains=i18ncomp(value))
| Q(location__icontains=i18ncomp(value))
)
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = SubEventSerializer

View File

@@ -84,9 +84,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self):
return self.request.event.items.select_related('tax_rule').prefetch_related(
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
'variations__meta_values', 'variations__meta_values__property',
'require_membership_types', 'variations__require_membership_types',
'variations', 'addons', 'bundles', 'meta_values'
).all()
def perform_create(self, serializer):
@@ -149,11 +147,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
def get_queryset(self):
return self.item.variations.all().prefetch_related(
'meta_values',
'meta_values__property',
'require_membership_types'
)
return self.item.variations.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()

View File

@@ -61,14 +61,13 @@ from pretix.api.serializers.orderchange import (
OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer,
)
from pretix.api.views import RichOrderingFilter
from pretix.api.utils import get_api_source
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue,
Invoice, InvoiceAddress, ItemMetaValue, ItemVariation,
ItemVariationMetaValue, Order, OrderFee, OrderPayment, OrderPosition,
OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule, TeamAPIToken,
generate_secret,
Invoice, InvoiceAddress, ItemMetaValue, Order, OrderFee, OrderPayment,
OrderPosition, OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule,
TeamAPIToken, generate_secret,
)
from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
from pretix.base.payment import PaymentException
@@ -192,7 +191,7 @@ class OrderViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['exclude'] = self.request.query_params.getlist('exclude')
ctx['include'] = self.request.query_params.getlist('include')
ctx['source'] = get_api_source(self.request)
return ctx
def get_queryset(self):
@@ -233,9 +232,7 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch('item', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached')
)),
Prefetch('variation', queryset=ItemVariation.objects.prefetch_related(
Prefetch('meta_values', ItemVariationMetaValue.objects.select_related('property'), to_attr='meta_values_cached')
)),
'variation',
'answers', 'answers__options', 'answers__question',
'item__category',
'addon_to__answers', 'addon_to__answers__options', 'addon_to__answers__question',
@@ -395,7 +392,8 @@ class OrderViewSet(viewsets.ModelViewSet):
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
send_mail=send_mail,
email_comment=comment,
cancellation_fee=cancellation_fee
cancellation_fee=cancellation_fee,
source=get_api_source(request),
)
except OrderError as e:
return Response(
@@ -419,6 +417,7 @@ class OrderViewSet(viewsets.ModelViewSet):
order,
user=request.user if request.user.is_authenticated else None,
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
source=get_api_source(request),
)
except OrderError as e:
return Response(
@@ -438,6 +437,7 @@ class OrderViewSet(viewsets.ModelViewSet):
user=request.user if request.user.is_authenticated else None,
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
send_mail=send_mail,
source=get_api_source(request),
)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
@@ -458,6 +458,7 @@ class OrderViewSet(viewsets.ModelViewSet):
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
send_mail=send_mail,
comment=comment,
source=get_api_source(request),
)
except OrderError as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
@@ -496,6 +497,7 @@ class OrderViewSet(viewsets.ModelViewSet):
order,
user=request.user if request.user.is_authenticated else None,
auth=request.auth,
source=get_api_source(request),
)
return self.retrieve(request, [], **kwargs)
@@ -513,6 +515,7 @@ class OrderViewSet(viewsets.ModelViewSet):
order,
user=request.user if request.user.is_authenticated else None,
auth=(request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken, Device)) else None),
source=get_api_source(request),
)
return self.retrieve(request, [], **kwargs)
@@ -684,33 +687,28 @@ class OrderViewSet(viewsets.ModelViewSet):
)
if order.require_approval:
email_template = request.event.settings.mail_text_order_placed_require_approval
subject_template = request.event.settings.mail_subject_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval'
email_attendees = False
elif free_flow:
email_template = request.event.settings.mail_text_order_free
subject_template = request.event.settings.mail_subject_order_free
log_entry = 'pretix.event.order.email.order_free'
email_attendees = request.event.settings.mail_send_order_free_attendee
email_attendees_template = request.event.settings.mail_text_order_free_attendee
subject_attendees_template = request.event.settings.mail_subject_order_free_attendee
else:
email_template = request.event.settings.mail_text_order_placed
subject_template = request.event.settings.mail_subject_order_placed
log_entry = 'pretix.event.order.email.order_placed'
email_attendees = request.event.settings.mail_send_order_placed_attendee
email_attendees_template = request.event.settings.mail_text_order_placed_attendee
subject_attendees_template = request.event.settings.mail_subject_order_placed_attendee
_order_placed_email(
request.event, order, email_template, subject_template,
log_entry, invoice, [payment] if payment else [], is_free=free_flow
request.event, order, payment.payment_provider if payment else None, email_template,
log_entry, invoice, payment, is_free=free_flow
)
if email_attendees:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
_order_placed_email_attendee(request.event, order, p, email_attendees_template, subject_attendees_template,
log_entry, is_free=free_flow)
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry,
is_free=free_flow)
if not free_flow and order.status == Order.STATUS_PAID and payment:
payment._send_paid_mail(invoice, None, '')
@@ -940,7 +938,7 @@ with scopes_disabled():
class OrderPositionViewSet(viewsets.ModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
filterset_class = OrderPositionFilter
@@ -1002,11 +1000,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
)),
Prefetch('variation', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemVariationMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
)),
'answers', 'answers__options', 'answers__question',
'variation', 'answers', 'answers__options', 'answers__question',
'item__category',
Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related(
Prefetch('meta_values', to_attr='meta_values_cached',
@@ -1498,7 +1492,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
if mark_refunded:
mark_order_refunded(payment.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth)
auth=self.request.auth,
source=get_api_source(self.request))
else:
payment.order.status = Order.STATUS_PENDING
payment.order.set_expires(
@@ -1571,7 +1566,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
mark_refunded = request.data.get('mark_canceled', False)
if mark_refunded:
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth)
auth=self.request.auth, source=get_api_source(self.request))
elif not (refund.order.status == Order.STATUS_PAID and refund.order.pending_sum <= 0):
refund.order.status = Order.STATUS_PENDING
refund.order.set_expires(
@@ -1618,23 +1613,13 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
user=request.user if request.user.is_authenticated else None,
auth=request.auth
)
if r.state in (OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_CANCELED, OrderRefund.REFUND_STATE_FAILED):
r.order.log_action(
f'pretix.event.order.refund.{r.state}', {
'local_id': r.local_id,
'provider': r.provider,
},
user=request.user if request.user.is_authenticated else None,
auth=request.auth
)
if mark_refunded:
try:
mark_order_refunded(
r.order,
user=request.user if request.user.is_authenticated else None,
auth=(request.auth if request.auth else None),
source=get_api_source(self.request),
)
except OrderError as e:
raise ValidationError(str(e))

View File

@@ -1,227 +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 collections import defaultdict
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from localflavor.ar.forms import ARPostalCodeField
from localflavor.at.forms import ATZipCodeField
from localflavor.au.forms import AUPostCodeField
from localflavor.be.forms import BEPostalCodeField
from localflavor.br.forms import BRZipCodeField
from localflavor.ca.forms import CAPostalCodeField
from localflavor.ch.forms import CHZipCodeField
from localflavor.cn.forms import CNPostCodeField
from localflavor.cu.forms import CUPostalCodeField
from localflavor.cz.forms import CZPostalCodeField
from localflavor.de.forms import DEZipCodeField
from localflavor.dk.forms import DKPostalCodeField
from localflavor.ee.forms import EEZipCodeField
from localflavor.es.forms import ESPostalCodeField
from localflavor.fi.forms import FIZipCodeField
from localflavor.fr.forms import FRZipCodeField
from localflavor.gb.forms import GBPostcodeField
from localflavor.gr.forms import GRPostalCodeField
from localflavor.hr.forms import HRPostalCodeField
from localflavor.id_.forms import IDPostCodeField
from localflavor.ie.forms import EircodeField
from localflavor.il.forms import ILPostalCodeField
from localflavor.in_.forms import INZipCodeField
from localflavor.ir.forms import IRPostalCodeField
from localflavor.is_.is_postalcodes import IS_POSTALCODES
from localflavor.it.forms import ITZipCodeField
from localflavor.jp.forms import JPPostalCodeField
from localflavor.lt.forms import LTPostalCodeField
from localflavor.lv.forms import LVPostalCodeField
from localflavor.ma.forms import MAPostalCodeField
from localflavor.mt.forms import MTPostalCodeField
from localflavor.mx.forms import MXZipCodeField
from localflavor.nl.forms import NLZipCodeField
from localflavor.no.forms import NOZipCodeField
from localflavor.nz.forms import NZPostCodeField
from localflavor.pk.forms import PKPostCodeField
from localflavor.pl.forms import PLPostalCodeField
from localflavor.pt.forms import PTZipCodeField
from localflavor.ro.forms import ROPostalCodeField
from localflavor.ru.forms import RUPostalCodeField
from localflavor.se.forms import SEPostalCodeField
from localflavor.sg.forms import SGPostCodeField
from localflavor.si.si_postalcodes import SI_POSTALCODES
from localflavor.sk.forms import SKPostalCodeField
from localflavor.tr.forms import TRPostalCodeField
from localflavor.ua.forms import UAPostalCodeField
from localflavor.us.forms import USZipCodeField
from localflavor.za.forms import ZAPostCodeField
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
_validator_classes = defaultdict(list)
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED = {
# We don't presume this for countries we don't have knowledge about, there are countries in the
# world e.g. without zipcodes
'AR', 'AT', 'AU', 'BE', 'BR', 'CA', 'CH', 'CN', 'CU', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR',
'GB', 'GR', 'HR', 'ID', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX',
'NL', 'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
}
def validate_address(address: dict, all_optional=False):
"""
:param address: A dictionary with at least the entries ``street``, ``zipcode``, ``city``, ``country``,
``state``
:return: The dictionary, possibly with changes
"""
if not address.get('street') and not address.get('zipcode') and not address.get('city'):
# Consider the actual address part to be empty, no further validation necessary, if the
# address should be required, it's the callers job to validate that at least one of these
# fields is filled
return address
if not address.get('country'):
raise ValidationError({'country': [_('This field is required.')]})
if str(address['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS and not address.get('state') and not all_optional:
raise ValidationError({'state': [_('This field is required.')]})
if str(address['country']) in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED and not all_optional:
for f in ('street', 'zipcode', 'city'):
if not address.get(f):
raise ValidationError({f: [_('This field is required.')]})
for klass in _validator_classes[str(address['country'])]:
validator = klass()
try:
if address.get('zipcode'):
address['zipcode'] = validator.validate_zipcode(address['zipcode'])
except ValidationError as e:
raise ValidationError({'zipcode': list(e)})
return address
def register_validator_for(country):
def inner(klass):
_validator_classes[country].append(klass)
return klass
return inner
class BaseValidator:
required_fields = []
def validate_zipcode(self, value):
return value
"""
Currently, mostly have validators that are auto-generated from django-localflavor
but custom ones can be added like this:
@register_validator_for('DE')
class DEValidator(BaseValidator):
def validate_zipcode(value):
return value
In the future, we can also add additional methods to validate that e.g. a city
is plausible for a given zip code.
"""
_zip_code_fields = {
'AR': ARPostalCodeField,
'AT': ATZipCodeField,
'AU': AUPostCodeField,
'BE': BEPostalCodeField,
'BR': BRZipCodeField,
'CA': CAPostalCodeField,
'CH': CHZipCodeField,
'CN': CNPostCodeField,
'CU': CUPostalCodeField,
'CZ': CZPostalCodeField,
'DE': DEZipCodeField,
'DK': DKPostalCodeField,
'EE': EEZipCodeField,
'ES': ESPostalCodeField,
'FI': FIZipCodeField,
'FR': FRZipCodeField,
'GB': GBPostcodeField,
'GR': GRPostalCodeField,
'HR': HRPostalCodeField,
'ID': IDPostCodeField,
'IE': EircodeField,
'IL': ILPostalCodeField,
'IN': INZipCodeField,
'IR': IRPostalCodeField,
'IT': ITZipCodeField,
'JP': JPPostalCodeField,
'LT': LTPostalCodeField,
'LV': LVPostalCodeField,
'MA': MAPostalCodeField,
'MT': MTPostalCodeField,
'MX': MXZipCodeField,
'NL': NLZipCodeField,
'NO': NOZipCodeField,
'NZ': NZPostCodeField,
'PK': PKPostCodeField,
'PL': PLPostalCodeField,
'PT': PTZipCodeField,
'RO': ROPostalCodeField,
'RU': RUPostalCodeField,
'SE': SEPostalCodeField,
'SG': SGPostCodeField,
'SK': SKPostalCodeField,
'TR': TRPostalCodeField,
'UA': UAPostalCodeField,
'US': USZipCodeField,
'ZA': ZAPostCodeField,
}
def _generate_class_from_zipcode_field(field_class):
class _GeneratedValidator(BaseValidator):
def validate_zipcode(self, value):
return field_class().clean(value)
return _GeneratedValidator
for cc, field_class in _zip_code_fields.items():
register_validator_for(cc)(_generate_class_from_zipcode_field(field_class))
@register_validator_for('IS')
class ISValidator(BaseValidator):
def validate_zipcode(self, value):
if value not in [entry[0] for entry in IS_POSTALCODES]:
raise ValidationError(_('Enter a postal code in the format XXX.'), code='invalid')
return value
@register_validator_for('SI')
class SIValidator(BaseValidator):
def validate_zipcode(self, value):
try:
if int(value) not in [entry[0] for entry in SI_POSTALCODES]:
raise ValidationError(_('Enter a postal code in the format XXXX.'), code='invalid')
except ValueError:
raise ValidationError(_('Enter a postal code in the format XXXX.'), code='invalid')
return value

View File

@@ -320,18 +320,13 @@ def get_email_context(**kwargs):
return ctx
def _placeholder_payments(order, payments):
d = []
for payment in payments:
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
d.append(str(payment.payment_provider.order_pending_mail_render(order, payment)))
else:
d.append(str(payment.payment_provider.order_pending_mail_render(order)))
d = [line for line in d if line.strip()]
if d:
return '\n\n'.join(d)
def _placeholder_payment(order, payment):
if not payment:
return None
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
return str(payment.payment_provider.order_pending_mail_render(order, payment))
else:
return ''
return str(payment.payment_provider.order_pending_mail_render(order))
def get_best_name(position_or_address, parts=False):
@@ -381,14 +376,6 @@ def base_placeholders(sender, **kwargs):
SimpleFunctionalMailTextPlaceholder(
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
),
SimpleFunctionalMailTextPlaceholder(
'order_email', ['order'], lambda order: order.email, 'john@example.org'
),
SimpleFunctionalMailTextPlaceholder(
'invoice_number', ['invoice'],
lambda invoice: invoice.full_invoice_no,
f'{sender.settings.invoice_numbers_prefix or (sender.slug.upper() + "-")}00000'
),
SimpleFunctionalMailTextPlaceholder(
'refund_amount', ['event_or_subevent', 'refund_amount'],
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
@@ -630,7 +617,7 @@ def base_placeholders(sender, **kwargs):
_('An individual text with a reason can be inserted here.'),
),
SimpleFunctionalMailTextPlaceholder(
'payment_info', ['order', 'payments'], _placeholder_payments,
'payment_info', ['order', 'payment'], _placeholder_payment,
_('The amount has been charged to your card.'),
),
SimpleFunctionalMailTextPlaceholder(

View File

@@ -29,7 +29,7 @@ from openpyxl.utils import get_column_letter
from ...helpers.safe_openpyxl import SafeCell
from ..channels import get_all_sales_channels
from ..exporter import ListExporter
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
from ..models import ItemMetaValue
from ..signals import register_data_exporters
@@ -106,27 +106,18 @@ class ItemDataExporter(ListExporter):
yield row
for i in self.event.items.prefetch_related(
'variations',
Prefetch(
'meta_values',
ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
),
Prefetch(
'variations',
queryset=ItemVariation.objects.prefetch_related(
Prefetch(
'meta_values',
ItemVariationMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
),
),
),
)
).select_related('category', 'tax_rule'):
m = i.meta_data
vars = list(i.variations.all())
if vars:
for v in vars:
m = v.meta_data
row = [
i.pk,
v.pk,
@@ -169,7 +160,6 @@ class ItemDataExporter(ListExporter):
yield row
else:
m = i.meta_data
row = [
i.pk,
"",

View File

@@ -36,11 +36,9 @@ import json
from decimal import Decimal
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Prefetch
from django.dispatch import receiver
from ..exporter import BaseExporter
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
from ..signals import register_data_exporters
@@ -108,26 +106,9 @@ class JSONExporter(BaseExporter):
'available_from': variation.available_from,
'available_until': variation.available_until,
'hide_without_voucher': variation.hide_without_voucher,
'meta_data': variation.meta_data,
} for variation in item.variations.all()
]
} for item in self.event.items.select_related('tax_rule').prefetch_related(
Prefetch(
'meta_values',
ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
),
Prefetch(
'variations',
queryset=ItemVariation.objects.prefetch_related(
Prefetch(
'meta_values',
ItemVariationMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
),
),
),
)
} for item in self.event.items.select_related('tax_rule').prefetch_related('variations')
],
'questions': [
{

View File

@@ -303,8 +303,6 @@ class OrderListExporter(MultiSheetListExporter):
for id, vn in payment_methods:
headers.append(_('Paid by {method}').format(method=vn))
# get meta_data labels from first cached event
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
yield headers
full_fee_sum_cache = {
@@ -418,7 +416,6 @@ class OrderListExporter(MultiSheetListExporter):
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
refund_sum_cache.get((order.id, id), Decimal('0.00'))
)
row += self.event_object_cache[order.event_id].meta_data.values()
yield row
def iterate_fees(self, form_data: dict):
@@ -468,9 +465,6 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('External customer ID'))
headers.append(_('Payment providers'))
# get meta_data labels from first cached event
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
yield headers
yield self.ProgressSetTotal(total=qs.count())
@@ -518,7 +512,6 @@ class OrderListExporter(MultiSheetListExporter):
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
if p and p != 'free'
]))
row += self.event_object_cache[order.event_id].meta_data.values()
yield row
def iterate_positions(self, form_data: dict):
@@ -540,7 +533,6 @@ class OrderListExporter(MultiSheetListExporter):
'order', 'order__invoice_address', 'order__customer', 'item', 'variation',
'voucher', 'tax_rule'
).prefetch_related(
'subevent', 'subevent__meta_values',
'answers', 'answers__question', 'answers__options'
)
if form_data['paid_only']:
@@ -632,10 +624,6 @@ class OrderListExporter(MultiSheetListExporter):
_('Payment providers'),
]
# get meta_data labels from first cached event
meta_data_labels = next(iter(self.event_object_cache.values())).meta_data.keys()
if has_subevents:
headers += meta_data_labels
yield headers
all_ids = list(base_qs.order_by('order__datetime', 'positionid').values_list('pk', flat=True))
@@ -759,12 +747,6 @@ class OrderListExporter(MultiSheetListExporter):
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
if p and p != 'free'
]))
if has_subevents:
if op.subevent:
row += op.subevent.meta_data.values()
else:
row += [''] * len(meta_data_labels)
yield row
def get_filename(self):

View File

@@ -135,10 +135,6 @@ class NamePartsWidget(forms.MultiWidget):
data.append(value.get(fname, ""))
if '_legacy' in value and not data[-1]:
data[-1] = value.get('_legacy', '')
elif not any(d for d in data) and '_scheme' in value:
scheme = PERSON_NAME_SCHEMES[value['_scheme']]
data[-1] = scheme['concatenation'](value).strip()
return data
def render(self, name: str, value, attrs=None, renderer=None) -> str:
@@ -919,7 +915,6 @@ class BaseQuestionsForm(forms.Form):
class BaseInvoiceAddressForm(forms.ModelForm):
vat_warning = False
address_validation = False
class Meta:
model = InvoiceAddress
@@ -1055,9 +1050,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
data = self.cleaned_data
if not data.get('is_business'):
data['company'] = ''
@@ -1073,8 +1065,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False
if self.address_validation:
self.cleaned_data = data = validate_address(data, self.all_optional)
if data.get('city') and data.get('country') and str(data['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not data.get('state'):
self.add_error('state', _('This field is required.'))
self.instance.name_parts = data.get('name_parts')

View File

@@ -23,7 +23,6 @@ import logging
from collections import defaultdict
from decimal import Decimal
from io import BytesIO
from itertools import groupby
from typing import Tuple
import bleach
@@ -242,12 +241,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
buffer.seek(0)
return 'invoice.pdf', 'application/pdf', buffer.read()
def _clean_text(self, text, tags=None):
return bleach.clean(
text,
tags=tags or []
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
identifier = 'classic'
@@ -272,7 +265,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas):
p = Paragraph(self._clean_text(self.invoice.address_invoice_to),
p = Paragraph(bleach.clean(self.invoice.address_invoice_to, tags=[]).strip().replace('\n', '<br />\n'),
style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
@@ -285,7 +278,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_invoice_from(self, canvas):
p = Paragraph(
self._clean_text(self.invoice.full_invoice_from),
bleach.clean(self.invoice.full_invoice_from, tags=[]).strip().replace('\n', '<br />\n'),
style=self.stylesheet['InvoiceFrom']
)
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
@@ -480,8 +473,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.custom_field:
story.append(Paragraph(
'{}: {}'.format(
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
self._clean_text(self.invoice.custom_field),
bleach.clean(str(self.invoice.event.settings.invoice_address_custom_field), tags=[]).strip().replace('\n', '<br />\n'),
bleach.clean(self.invoice.custom_field, tags=[]).strip().replace('\n', '<br />\n'),
),
self.stylesheet['Normal']
))
@@ -489,7 +482,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.internal_reference:
story.append(Paragraph(
pgettext('invoice', 'Customer reference: {reference}').format(
reference=self._clean_text(self.invoice.internal_reference),
reference=bleach.clean(self.invoice.internal_reference, tags=[]).strip().replace('\n', '<br />\n'),
),
self.stylesheet['Normal']
))
@@ -497,20 +490,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.invoice_to_vat_id:
story.append(Paragraph(
pgettext('invoice', 'Customer VAT ID') + ': ' +
self._clean_text(self.invoice.invoice_to_vat_id),
bleach.clean(self.invoice.invoice_to_vat_id, tags=[]).replace("\n", "<br />\n"),
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary:
story.append(Paragraph(
pgettext('invoice', 'Beneficiary') + ':<br />' +
self._clean_text(self.invoice.invoice_to_beneficiary),
bleach.clean(self.invoice.invoice_to_beneficiary, tags=[]).replace("\n", "<br />\n"),
self.stylesheet['Normal']
))
if self.invoice.introductory_text:
story.append(Paragraph(
self._clean_text(self.invoice.introductory_text, tags=['br']),
self.invoice.introductory_text,
self.stylesheet['Normal']
))
story.append(Spacer(1, 10 * mm))
@@ -561,47 +554,31 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
pgettext('invoice', 'Amount'),
)]
def _group_key(line):
return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent_id,
line.event_date_from, line.event_date_to)
total = Decimal('0.00')
for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in groupby(self.invoice.lines.all(), key=_group_key):
lines = list(lines)
for line in self.invoice.lines.all():
if has_taxes:
if len(lines) > 1:
single_price_line = pgettext('invoice', 'Single price: {net_price} net / {gross_price} gross').format(
net_price=money_filter(net_value, self.invoice.event.currency),
gross_price=money_filter(gross_value, self.invoice.event.currency),
)
description = description + "\n" + single_price_line
tdata.append((
Paragraph(
self._clean_text(description, tags=['br']),
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
self.stylesheet['Normal']
),
str(len(lines)),
localize(tax_rate) + " %",
money_filter(net_value * len(lines), self.invoice.event.currency),
money_filter(gross_value * len(lines), self.invoice.event.currency),
"1",
localize(line.tax_rate) + " %",
money_filter(line.net_value, self.invoice.event.currency),
money_filter(line.gross_value, self.invoice.event.currency),
))
else:
if len(lines) > 1:
single_price_line = pgettext('invoice', 'Single price: {price}').format(
price=money_filter(gross_value, self.invoice.event.currency),
)
description = description + "\n" + single_price_line
tdata.append((
Paragraph(
self._clean_text(description, tags=['br']),
bleach.clean(line.description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
self.stylesheet['Normal']
),
str(len(lines)),
money_filter(gross_value * len(lines), self.invoice.event.currency),
"1",
money_filter(line.gross_value, self.invoice.event.currency),
))
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
total += gross_value * len(lines)
taxvalue_map[line.tax_rate, line.tax_name] += line.tax_value
grossvalue_map[line.tax_rate, line.tax_name] += line.gross_value
total += line.gross_value
if has_taxes:
tdata.append([
@@ -663,7 +640,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.additional_text:
story.append(Paragraph(
self._clean_text(self.invoice.additional_text, tags=['br']),
self.invoice.additional_text,
self.stylesheet['Normal']
))
story.append(Spacer(1, 5 * mm))
@@ -800,7 +777,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
if not self.invoice.invoice_from:
return
c = [
self._clean_text(l)
bleach.clean(l, tags=[]).strip().replace('\n', '<br />\n')
for l in self.invoice.address_invoice_from.strip().split('\n')
]
p = Paragraph(' · '.join(c), style=self.stylesheet['Sender'])

View File

@@ -103,8 +103,6 @@ class Command(BaseCommand):
with language(locale), override(timezone):
for receiver, response in signal_result:
if not response:
return None
ex = response(e, o, report_status)
if ex.identifier == options['export_provider']:
params = json.loads(options.get('parameters') or '{}')

View File

@@ -79,9 +79,9 @@ class Command(BaseCommand):
if settings.SENTRY_ENABLED:
from sentry_sdk import capture_exception
capture_exception(err)
self.stdout.write(self.style.ERROR(f'ERROR {name}: {str(err)}\n'))
self.stdout.write(self.style.ERROR(f'ERROR runperiodic {str(err)}\n'))
else:
self.stdout.write(self.style.ERROR(f'ERROR {name}: {str(err)}\n'))
self.stdout.write(self.style.ERROR(f'ERROR runperiodic {str(err)}\n'))
traceback.print_exc()
else:
if options.get('verbosity') > 1:

View File

@@ -224,11 +224,6 @@ def _merge_csp(a, b):
if k not in a:
a[k] = b[k]
for k, v in a.items():
if "'unsafe-inline'" in v:
# If we need unsafe-inline, drop any hashes or nonce as they will be ignored otherwise
a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha-")]
class SecurityMiddleware(MiddlewareMixin):
CSP_EXEMPT = (
@@ -306,7 +301,7 @@ class SecurityMiddleware(MiddlewareMixin):
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
media=mediadomain)
for k, v in h.items():
h[k] = sorted(set(' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')))
h[k] = ' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')
resp['Content-Security-Policy'] = _render_csp(h)
elif 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.2.2 on 2022-10-19 09:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0222_alter_question_unique_together'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='source_identifier',
field=models.CharField(db_index=True, max_length=190, null=True),
),
migrations.AddField(
model_name='transaction',
name='source_type',
field=models.CharField(db_index=True, max_length=190, null=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.12 on 2022-10-12 09:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0222_alter_question_unique_together'),
]
operations = [
migrations.AddField(
model_name='voucher',
name='min_usages',
field=models.PositiveIntegerField(default=1),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.16 on 2022-11-14 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0223_voucher_min_usages'),
]
operations = [
migrations.AddField(
model_name='eventmetaproperty',
name='filter_allowed',
field=models.BooleanField(default=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.16 on 2022-11-17 15:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0224_eventmetaproperty_filter_allowed'),
]
operations = [
migrations.AddField(
model_name='orderpayment',
name='process_initiated',
field=models.BooleanField(null=True),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 3.2.16 on 2022-12-09 10:06
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0225_orderpayment_process_initiated'),
]
operations = [
migrations.CreateModel(
name='ItemVariationMetaValue',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('value', models.TextField()),
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variation_values', to='pretixbase.itemmetaproperty')),
('variation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_values', to='pretixbase.itemvariation')),
],
options={
'unique_together': {('variation', 'property')},
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
]

View File

@@ -34,8 +34,8 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SubEventItem, SubEventItemVariation, itempicture_upload_to,
ItemVariation, Question, QuestionOption, Quota, SubEventItem,
SubEventItemVariation, itempicture_upload_to,
)
from .log import LogEntry
from .memberships import Membership, MembershipType

View File

@@ -262,7 +262,7 @@ class Customer(LoggedModel):
) + '?id=' + self.identifier + '&token=' + token
mail(
self.email,
self.organizer.settings.mail_subject_customer_registration,
_('Activate your account at {organizer}').format(organizer=self.organizer.name),
self.organizer.settings.mail_text_customer_registration,
ctx,
locale=self.locale,

View File

@@ -28,7 +28,6 @@ from typing import Dict, Optional, Tuple
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
@@ -199,14 +198,6 @@ class Discount(LoggedModel):
'subevent_mode': self.subevent_mode,
})
def is_available_by_time(self, now_dt=None) -> bool:
now_dt = now_dt or now()
if self.available_from and self.available_from > now_dt:
return False
if self.available_until and self.available_until < now_dt:
return False
return True
def _apply_min_value(self, positions, idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in idx_group) < self.condition_min_value:
return

View File

@@ -590,7 +590,6 @@ class Event(EventMixin, LoggedModel):
self.settings.event_list_type = 'calendar'
self.settings.invoice_email_attachment = True
self.settings.name_scheme = 'given_family'
self.settings.payment_banktransfer_invoice_immediately = True
@property
def social_image(self):
@@ -728,7 +727,7 @@ class Event(EventMixin, LoggedModel):
from ..signals import event_copy_data
from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
ItemVariationMetaValue, Question, Quota,
Question, Quota,
)
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
@@ -804,18 +803,12 @@ class Event(EventMixin, LoggedModel):
v.item = i
v.save(force_insert=True)
for imv in ItemMetaValue.objects.filter(item__event=other):
for imv in ItemMetaValue.objects.filter(item__event=other).prefetch_related('item', 'property'):
imv.pk = None
imv.property = item_meta_properties_map[imv.property_id]
imv.property = item_meta_properties_map[imv.property.pk]
imv.item = item_map[imv.item.pk]
imv.save(force_insert=True)
for imv in ItemVariationMetaValue.objects.filter(variation__item__event=other):
imv.pk = None
imv.property = item_meta_properties_map[imv.property_id]
imv.variation = variation_map[imv.variation_id]
imv.save(force_insert=True)
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
ia.pk = None
ia.base_item = item_map[ia.base_item.pk]
@@ -1586,11 +1579,6 @@ class EventMetaProperty(LoggedModel):
verbose_name=_("Valid values"),
help_text=_("If you keep this empty, any value is allowed. Otherwise, enter one possible value per line.")
)
filter_allowed = models.BooleanField(
default=True, verbose_name=_("Can be used for filtering"),
help_text=_("This field will be shown to filter events or reports in the backend, and it can also be used "
"for hidden filter parameters in the frontend (e.g. using the widget).")
)
def full_clean(self, exclude=None, validate_unique=True):
super().full_clean(exclude, validate_unique)

View File

@@ -581,15 +581,18 @@ class Item(LoggedModel):
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
price = price if price is not None else self.default_price
bundled_sum = Decimal('0.00')
bundled_sum_net = Decimal('0.00')
bundled_sum_tax = Decimal('0.00')
if not self.tax_rule:
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
else:
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
override_tax_rate=override_tax_rate, currency=currency or self.event.currency)
if include_bundled:
for b in self.bundles.all():
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
if b.bundled_variation:
bprice = b.bundled_variation.tax(b.designated_price * b.count,
base_price_is='gross',
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross',
invoice_address=invoice_address,
currency=currency)
else:
@@ -597,23 +600,17 @@ class Item(LoggedModel):
invoice_address=invoice_address,
base_price_is='gross',
currency=currency)
bundled_sum += bprice.gross
bundled_sum_net += bprice.net
bundled_sum_tax += bprice.tax
if not self.tax_rule:
t = TaxedPrice(gross=price - bundled_sum, net=price - bundled_sum, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
else:
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
override_tax_rate=override_tax_rate, currency=currency or self.event.currency,
subtract_from_gross=bundled_sum)
if bundled_sum:
t.name = "MIXED!"
t.gross += bundled_sum
t.net += bundled_sum_net
t.tax += bundled_sum_tax
if not self.tax_rule:
compare_price = TaxedPrice(gross=b.designated_price * b.count, net=b.designated_price * b.count,
tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
else:
compare_price = self.tax_rule.tax(b.designated_price * b.count,
override_tax_rate=override_tax_rate,
invoice_address=invoice_address,
currency=currency)
t.net += bprice.net - compare_price.net
t.tax += bprice.tax - compare_price.tax
t.name = "MIXED!"
return t
@@ -1008,16 +1005,6 @@ class ItemVariation(models.Model):
return False
return True
@property
def meta_data(self):
data = self.item.meta_data
if hasattr(self, 'meta_values_cached'):
data.update({v.property.name: v.value for v in self.meta_values_cached})
else:
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
class ItemAddOn(models.Model):
"""
@@ -1394,10 +1381,8 @@ class Question(LoggedModel):
if self.type == Question.TYPE_CHOICE:
if isinstance(answer, QuestionOption):
return answer
if not isinstance(answer, (int, str)):
raise ValidationError(_('Invalid input type.'))
q = Q(identifier=answer)
if isinstance(answer, int) or (isinstance(answer, str) and answer.isdigit()):
if isinstance(answer, int) or answer.isdigit():
q |= Q(pk=answer)
o = self.options.filter(q).first()
if not o:
@@ -1797,21 +1782,8 @@ class ItemMetaValue(LoggedModel):
class Meta:
unique_together = ('item', 'property')
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
class ItemVariationMetaValue(LoggedModel):
"""
A meta-data value assigned to an item variation, overriding the value on the item.
:param variation: The variation this metadata is valid for
:type variation: ItemVariation
:param property: The property this value belongs to
:type property: ItemMetaProperty
:param value: The actual value
:type value: str
"""
variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE, related_name='meta_values')
property = models.ForeignKey('ItemMetaProperty', on_delete=models.CASCADE, related_name='variation_values')
value = models.TextField()
class Meta:
unique_together = ('variation', 'property')
def save(self, *args, **kwargs):
super().save(*args, **kwargs)

View File

@@ -80,7 +80,6 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import order_gracefully_delete
from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import format_map
from ._transactions import (
_fail, _transactions_mark_order_clean, _transactions_mark_order_dirty,
)
@@ -565,30 +564,17 @@ class Order(LockModel, LoggedModel):
@cached_property
def user_cancel_fee(self):
fee = Decimal('0.00')
if self.status == Order.STATUS_PAID:
if self.event.settings.cancel_allow_user_paid_keep_fees:
fee += self.fees.filter(
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
OrderFee.FEE_TYPE_CANCELLATION)
).aggregate(
s=Sum('value')
)['s'] or 0
if self.event.settings.cancel_allow_user_paid_keep_percentage:
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
if self.event.settings.cancel_allow_user_paid_keep:
fee += self.event.settings.cancel_allow_user_paid_keep
else:
if self.event.settings.cancel_allow_user_unpaid_keep_fees:
fee += self.fees.filter(
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
OrderFee.FEE_TYPE_CANCELLATION)
).aggregate(
s=Sum('value')
)['s'] or 0
if self.event.settings.cancel_allow_user_unpaid_keep_percentage:
fee += self.event.settings.cancel_allow_user_unpaid_keep_percentage / Decimal('100.0') * (self.total - fee)
if self.event.settings.cancel_allow_user_unpaid_keep:
fee += self.event.settings.cancel_allow_user_unpaid_keep
if self.event.settings.cancel_allow_user_paid_keep_fees:
fee += self.fees.filter(
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
OrderFee.FEE_TYPE_CANCELLATION)
).aggregate(
s=Sum('value')
)['s'] or 0
if self.event.settings.cancel_allow_user_paid_keep_percentage:
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
if self.event.settings.cancel_allow_user_paid_keep:
fee += self.event.settings.cancel_allow_user_paid_keep
return round_decimal(min(fee, self.total), self.event.currency)
@property
@@ -656,12 +642,10 @@ class Order(LockModel, LoggedModel):
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
return False
if self.status == Order.STATUS_PAID:
if self.status == Order.STATUS_PAID or self.payment_refund_sum > Decimal('0.00'):
if self.total == Decimal('0.00'):
return self.event.settings.cancel_allow_user
return self.event.settings.cancel_allow_user_paid
elif self.payment_refund_sum > Decimal('0.00'):
return False
elif self.status == Order.STATUS_PENDING:
return self.event.settings.cancel_allow_user
return False
@@ -997,7 +981,7 @@ class Order(LockModel, LoggedModel):
position and the attendee email will be used if available.
"""
from pretix.base.services.mail import (
SendMailException, mail, render_mail,
SendMailException, TolerantDict, mail, render_mail,
)
if not self.email and not (position and position.attendee_email):
@@ -1013,7 +997,7 @@ class Order(LockModel, LoggedModel):
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
subject = str(subject).format_map(TolerantDict(context))
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
@@ -1043,7 +1027,7 @@ class Order(LockModel, LoggedModel):
with language(self.locale, self.event.settings.region):
email_template = self.event.settings.mail_text_resend_link
email_context = get_email_context(event=self.event, order=self)
email_subject = self.event.settings.mail_subject_resend_link
email_subject = _('Your order: %(code)s') % {'code': self.code}
self.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.resend', user=user, auth=auth,
@@ -1057,10 +1041,13 @@ class Order(LockModel, LoggedModel):
continue
yield op
def create_transactions(self, is_new=False, positions=None, fees=None, dt_now=None, migrated=False,
_backfill_before_cancellation=False, save=True):
def create_transactions(self, *, source=None, is_new=False, positions=None, fees=None,
dt_now=None, migrated=False, _backfill_before_cancellation=False, save=True):
dt_now = dt_now or now()
if source is not None and (not isinstance(source, tuple) or len(source) != 2 or not all(isinstance(a, str) or a is None for a in source)):
return ValueError("source needs to be a 2-tuple of (source_type(str), source_identifier(str))")
# Count the transactions we already have
current_transaction_count = Counter()
if not is_new:
@@ -1105,6 +1092,8 @@ class Order(LockModel, LoggedModel):
tax_value=taxvalue,
fee_type=feetype,
internal_type=internaltype,
source_type=source[0] if source else None,
source_identifier=source[1] if source else None,
))
create.sort(key=lambda t: (0 if t.count < 0 else 1, t.positionid or 0))
if save:
@@ -1510,9 +1499,6 @@ class OrderPayment(models.Model):
:type info: str
:param fee: The ``OrderFee`` object used to track the fee for this order.
:type fee: pretix.base.models.OrderFee
:param process_initiated: Only for internal use inside pretix.presale to check which payments have started
the execution process.
:type process_initiated: bool
"""
PAYMENT_STATE_CREATED = 'created'
PAYMENT_STATE_PENDING = 'pending'
@@ -1563,9 +1549,6 @@ class OrderPayment(models.Model):
null=True, blank=True, related_name='payments', on_delete=models.SET_NULL
)
migrated = models.BooleanField(default=False)
process_initiated = models.BooleanField(
null=True # null = created before this field was introduced
)
objects = ScopedManager(organizer='order__event__organizer')
@@ -1595,7 +1578,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_inner(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False, source=None):
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:
@@ -1618,7 +1601,9 @@ class OrderPayment(models.Model):
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order)
if status_change:
self.order.create_transactions()
self.order.create_transactions(
source=source or ('pretix.payment', None),
)
def fail(self, info=None, user=None, auth=None, log_data=None):
"""
@@ -1652,7 +1637,7 @@ class OrderPayment(models.Model):
}, user=user, auth=auth)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
ignore_date=False, lock=True, payment_date=None, generate_invoice=True):
ignore_date=False, lock=True, payment_date=None, source=None):
"""
Marks the payment as complete. If possible, this also marks the order as paid if no further
payment is required
@@ -1716,10 +1701,10 @@ 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,
generate_invoice)
source)
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, allow_generate_invoice=True):
ignore_date=False, lock=True, payment_refund_sum=0, source=None):
from pretix.base.services.invoices import (
generate_invoice, invoice_qualified,
)
@@ -1733,10 +1718,10 @@ class OrderPayment(models.Model):
with lockfn():
self._mark_paid_inner(force, count_waitinglist, user, auth, overpaid=payment_refund_sum > self.order.total,
ignore_date=ignore_date)
ignore_date=ignore_date, source=source)
invoice = None
if invoice_qualified(self.order) and allow_generate_invoice:
if invoice_qualified(self.order):
invoices = self.order.invoices.filter(is_cancellation=False).count()
cancellations = self.order.invoices.filter(is_cancellation=True).count()
gen_invoice = (
@@ -1761,8 +1746,8 @@ class OrderPayment(models.Model):
with language(self.order.locale, self.order.event.settings.region):
email_template = self.order.event.settings.mail_text_order_paid_attendee
email_subject = self.order.event.settings.mail_subject_order_paid_attendee
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
try:
position.send_mail(
email_subject, email_template, email_context,
@@ -1779,8 +1764,8 @@ class OrderPayment(models.Model):
with language(self.order.locale, self.order.event.settings.region):
email_template = self.order.event.settings.mail_text_order_paid
email_subject = self.order.event.settings.mail_subject_order_paid
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
try:
self.order.send_mail(
email_subject, email_template, email_context,
@@ -2415,7 +2400,7 @@ class OrderPosition(AbstractPosition):
:param attach_ical: Attach relevant ICS files
"""
from pretix.base.services.mail import (
SendMailException, mail, render_mail,
SendMailException, TolerantDict, mail, render_mail,
)
if not self.attendee_email:
@@ -2428,7 +2413,7 @@ class OrderPosition(AbstractPosition):
recipient = self.attendee_email
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
subject = str(subject).format_map(TolerantDict(context))
mail(
recipient, subject, template, context,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
@@ -2460,7 +2445,7 @@ class OrderPosition(AbstractPosition):
with language(self.order.locale, self.order.event.settings.region):
email_template = self.event.settings.mail_text_resend_link
email_context = get_email_context(event=self.order.event, order=self.order, position=self)
email_subject = self.event.settings.mail_subject_resend_link
email_subject = _('Your event registration: %(code)s') % {'code': self.order.code}
self.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.resend', user=user, auth=auth,
@@ -2506,6 +2491,8 @@ class Transaction(models.Model):
:param id: ID of the transaction
:param order: Order the transaction belongs to
:param source_type: Functionality that caused the transaction to be created, usually the name of a module or plugin
:param source_identifier: Identifier of the entity that caused the transaction to be created, as defined by the module or plugin noted in ``source_type``.
:param datetime: Date and time of the transaction
:param migrated: Whether this object was reconstructed because the order was created before transactions where introduced
:param positionid: Affected Position ID, in case this transaction represents a change in an order position
@@ -2528,6 +2515,12 @@ class Transaction(models.Model):
related_name='transactions',
on_delete=models.PROTECT
)
source_type = models.CharField(
max_length=190, db_index=True, null=True, blank=True
)
source_identifier = models.CharField(
max_length=190, db_index=True, null=True, blank=True
)
created = models.DateTimeField(
auto_now_add=True,
db_index=True,
@@ -2745,7 +2738,6 @@ class CartPosition(AbstractPosition):
tax_rule=self.item.tax_rule,
invoice_address=invoice_address,
bundled_sum=sum([b.price_after_voucher for b in bundled_positions]),
is_bundled=self.is_bundled,
)
if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate:
self.line_price_gross = line_price.gross

View File

@@ -137,8 +137,6 @@ class Voucher(LoggedModel):
:type max_usages: int
:param redeemed: The number of times this voucher already has been redeemed
:type redeemed: int
:param min_usages: The minimum number of times this voucher must be redeemed
:type min_usages: int
:param valid_until: The expiration date of this voucher (optional)
:type valid_until: datetime
:param block_quota: If set to true, this voucher will reserve quota for its holder
@@ -201,14 +199,6 @@ class Voucher(LoggedModel):
verbose_name=_("Redeemed"),
default=0
)
min_usages = models.PositiveIntegerField(
verbose_name=_("Minimum usages"),
help_text=_("If set to more than one, the voucher must be redeemed for this many products when it is used for "
"the first time. On later usages, it can also be used for lower numbers of products. Note that "
"this means that the total number of usages in some cases can be lower than this limit, e.g. in "
"case of cancellations."),
default=1
)
budget = models.DecimalField(
verbose_name=_("Maximum discount budget"),
help_text=_("This is the maximum monetary amount that will be discounted using this voucher across all usages. "
@@ -360,10 +350,6 @@ class Voucher(LoggedModel):
'redeemed': redeemed
}
)
if data.get('max_usages', 1) < data.get('min_usages', 1):
raise ValidationError(
_('The maximum number of usages may not be lower than the minimum number of usages.'),
)
@staticmethod
def clean_subevent(data, event):
@@ -478,7 +464,7 @@ class Voucher(LoggedModel):
if quota:
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
if data.get('max_usages', 1) > 1 or data.get('min_usages', 1) > 1:
if data.get('max_usages', 1) > 1:
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
if item and seat.product != item:
@@ -581,10 +567,6 @@ class Voucher(LoggedModel):
else:
return bool(subevent.seating_plan) if subevent else self.event.seating_plan
@property
def min_usages_remaining(self):
return max(1, self.min_usages - self.redeemed)
@classmethod
def annotate_budget_used_orders(cls, qs):
opq = OrderPosition.objects.filter(

View File

@@ -216,7 +216,7 @@ class WaitingListEntry(LoggedModel):
with language(self.locale, self.event.settings.region):
mail(
self.email,
self.event.settings.mail_subject_waiting_list,
_('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
self.event.settings.mail_text_waiting_list,
get_email_context(event=self.event, waiting_list_entry=self),
self.event,

View File

@@ -63,14 +63,14 @@ from pretix.base.models import (
OrderRefund, Quota,
)
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
from pretix.base.services.cart import get_fees
from pretix.base.settings import SettingsSandbox
from pretix.base.signals import register_payment_providers
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
from pretix.presale.views import get_cart, get_cart_total
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
@@ -138,50 +138,6 @@ class BasePaymentProvider:
"""
return self.settings.get('_enabled', as_type=bool)
@property
def multi_use_supported(self) -> bool:
"""
Returns whether or whether not this payment provider supports being used multiple times in the same
checkout, or in addition to a different payment provider. This is usually only useful for payment providers
that represent gift cards, i.e. payment methods with an upper limit per payment instrument that can usually
be combined with other instruments.
If you set this property to ``True``, the behavior of how pretix interacts with your payment provider changes
and you will need to respect the following rules:
- ``payment_form_render`` must not depend on session state, it must always allow a user to add a new payment.
Editing a payment is not possible, but pretix will give users an option to delete it.
- Returning ``True`` from ``checkout_prepare`` is no longer enough. Instead, you must *also* call
``pretix.base.services.cart.add_payment_to_cart(request, provider, min_value, max_value, info_data)``
to add the payment to the session. You are still allowed to do a redirect from ``checkout_prepare`` and then
call this function upon return.
- Unlike in the general case, when ``checkout_prepare`` is called, the ``cart['total']`` parameter will _not yet_
include payment fees charged by your provider as we don't yet know the amount of the charge, so you need to
take care of that yourself when setting your maximum amount.
- ``payment_is_valid_session`` will not be called during checkout, don't rely on it. If you called
``add_payment_to_cart``, we'll trust the payment is okay and your next chance to change that will be
``execute_payment``.
The changed behavior currently only affects the behavior during initial checkout (i.e. ``checkout_prepare``),
for ``payment_prepare`` the regular behavior applies and you are expected to just modify the amount of the
``OrderPayment`` object if you need to.
"""
return False
@property
def execute_payment_needs_user(self) -> bool:
"""
Set this to ``True`` if your ``execute_payment`` function needs to be triggered by a user request, i.e. either
needs the ``request`` object or might require a browser redirect. If this is ``False``, you will not receive
a ``request`` and may not redirect since execute_payment might be called server-side. You should ensure that
your ``execute_payment`` method has a limited execution time (i.e. by using ``timeout`` for all external calls)
and handles all error cases appropriately.
"""
return True
@property
def test_mode_message(self) -> str:
"""
@@ -325,6 +281,16 @@ class BasePaymentProvider:
help_text=_('Users will not be able to choose this payment provider after the given date.'),
required=False,
)),
('_invoice_text',
I18nFormField(
label=_('Text on invoices'),
help_text=_('Will be printed just below the payment figures and above the closing text on invoices. '
'This will only be used if the invoice is generated before the order is paid. If the '
'invoice is generated later, it will show a text stating that it has already been paid.'),
required=False,
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}}
)),
('_total_min',
forms.DecimalField(
label=_('Minimum order total'),
@@ -372,16 +338,6 @@ class BasePaymentProvider:
'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'),
required=False
)),
('_invoice_text',
I18nFormField(
label=_('Text on invoices'),
help_text=_('Will be printed just below the payment figures and above the closing text on invoices. '
'This will only be used if the invoice is generated before the order is paid. If the '
'invoice is generated later, it will show a text stating that it has already been paid.'),
required=False,
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}}
)),
('_restricted_countries',
forms.MultipleChoiceField(
label=_('Restrict to countries'),
@@ -618,7 +574,7 @@ class BasePaymentProvider:
ctx = {'request': request, 'form': form}
return template.render(ctx)
def checkout_confirm_render(self, request, order: Order=None, info_data: dict=None) -> str:
def checkout_confirm_render(self, request, order: Order=None) -> str:
"""
If the user has successfully filled in their payment data, they will be redirected
to a confirmation page which lists all details of their order for a final review.
@@ -628,9 +584,7 @@ class BasePaymentProvider:
In most cases, this should include a short summary of the user's input and
a short explanation on how the payment process will continue.
:param request: The current HTTP request.
:param order: Only set when this is a change to a new payment method for an existing order.
:param info_data: The ``info_data`` dictionary you set during ``add_payment_to_cart`` (only filled if ``multi_use_supported`` is set)
"""
raise NotImplementedError() # NOQA
@@ -664,10 +618,6 @@ class BasePaymentProvider:
.. IMPORTANT:: If this is called, the user has not yet confirmed their order.
You may NOT do anything which actually moves money.
Note: The behavior of this method changes significantly when you set
``multi_use_supported``. Please refer to the ``multi_use_supported`` documentation
for more information.
:param cart: This dictionary contains at least the following keys:
positions:
@@ -707,9 +657,9 @@ class BasePaymentProvider:
You will be passed an :py:class:`pretix.base.models.OrderPayment` object that contains
the amount of money that should be paid.
If you need any special behavior, you can return a string containing the URL the user will be redirected to.
If you are done with your process you should return the user to the order's detail page. Redirection is not
allowed if you set ``execute_payment_needs_user`` to ``True``.
If you need any special behavior, you can return a string
containing the URL the user will be redirected to. If you are done with your process
you should return the user to the order's detail page.
If the payment is completed, you should call ``payment.confirm()``. Please note that this might
raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this order is over and
@@ -721,7 +671,7 @@ class BasePaymentProvider:
On errors, you should raise a ``PaymentException``.
:param request: A HTTP request, except if ``execute_payment_needs_user`` is ``False``
:param order: The order object
:param payment: An ``OrderPayment`` instance
"""
return None
@@ -927,15 +877,6 @@ class BasePaymentProvider:
"""
return {}
def api_refund_details(self, refund: OrderRefund):
"""
Will be called to populate the ``details`` parameter of the refund in the REST API.
:param refund: The refund in question.
:return: A serializable dictionary
"""
return {}
def matching_id(self, payment: OrderPayment):
"""
Will be called to get an ID for matching this payment when comparing pretix records with records of an external
@@ -955,7 +896,6 @@ class FreeOrderProvider(BasePaymentProvider):
is_implicit = True
is_enabled = True
identifier = "free"
execute_payment_needs_user = False
def checkout_confirm_render(self, request: HttpRequest) -> str:
return _("No payment is required as this order only includes products which are free of charge.")
@@ -1019,9 +959,6 @@ class BoxOfficeProvider(BasePaymentProvider):
"payment_data": payment.info_data.get('payment_data', {}),
}
def api_refund_details(self, refund: OrderRefund):
return self.api_payment_details(refund)
def payment_control_render(self, request, payment) -> str:
if not payment.info:
return
@@ -1042,7 +979,6 @@ class BoxOfficeProvider(BasePaymentProvider):
class ManualPayment(BasePaymentProvider):
identifier = 'manual'
verbose_name = _('Manual payment')
execute_payment_needs_user = False
@property
def test_mode_message(self):
@@ -1123,12 +1059,12 @@ class ManualPayment(BasePaymentProvider):
}
def order_pending_mail_render(self, order, payment) -> str:
msg = format_map(self.settings.get('email_instructions', as_type=LazyI18nString), self.format_map(order, payment))
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order, payment))
return msg
def payment_pending_render(self, request, payment) -> str:
return rich_text(
format_map(self.settings.get('pending_description', as_type=LazyI18nString), self.format_map(payment.order, payment))
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(payment.order, payment))
)
@@ -1183,42 +1119,18 @@ class OffsettingProvider(BasePaymentProvider):
class GiftCardPayment(BasePaymentProvider):
identifier = "giftcard"
priority = 10
multi_use_supported = True
execute_payment_needs_user = False
verbose_name = _("Gift card")
@property
def public_name(self) -> str:
return str(self.settings.get("public_name", as_type=LazyI18nString)) or _(
"Gift card"
)
priority = 10
@property
def settings_form_fields(self):
fields = [
(
"public_name",
I18nFormField(
label=_("Payment method name"), widget=I18nTextInput, required=False
),
),
(
"public_description",
I18nFormField(
label=_("Payment method description"), widget=I18nTextarea, required=False
),
),
]
f = OrderedDict(fields + list(super().settings_form_fields.items()))
f = super().settings_form_fields
del f['_fee_abs']
del f['_fee_percent']
del f['_fee_reverse_calc']
del f['_total_min']
del f['_total_max']
del f['_invoice_text']
f.move_to_end("_enabled", last=False)
return f
@property
@@ -1232,14 +1144,10 @@ class GiftCardPayment(BasePaymentProvider):
return super().order_change_allowed(order) and self.event.organizer.has_gift_cards
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
return get_template('pretixcontrol/giftcards/checkout.html').render({
'request': request,
})
return get_template('pretixcontrol/giftcards/checkout.html').render({})
def checkout_confirm_render(self, request, order=None, info_data=None) -> str:
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({
'info_data': info_data,
})
def checkout_confirm_render(self, request) -> str:
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({})
def refund_control_render(self, request, refund) -> str:
from .models import GiftCard
@@ -1283,9 +1191,6 @@ class GiftCardPayment(BasePaymentProvider):
}
}
def api_refund_details(self, refund: OrderRefund):
return self.api_payment_details(refund)
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
return True
@@ -1293,8 +1198,6 @@ class GiftCardPayment(BasePaymentProvider):
return True
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
from pretix.base.services.cart import add_payment_to_cart
for p in get_cart(request):
if p.item.issue_giftcard:
messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
@@ -1303,7 +1206,7 @@ class GiftCardPayment(BasePaymentProvider):
cs = cart_session(request)
try:
gc = self.event.organizer.accepted_gift_cards.get(
secret=request.POST.get("giftcard").strip()
secret=request.POST.get("giftcard")
)
if gc.currency != self.event.currency:
messages.error(request, _("This gift card does not support this currency."))
@@ -1320,22 +1223,34 @@ class GiftCardPayment(BasePaymentProvider):
if gc.value <= Decimal("0.00"):
messages.error(request, _("All credit on this gift card has been used."))
return
if 'gift_cards' not in cs:
cs['gift_cards'] = []
elif gc.pk in cs['gift_cards']:
messages.error(request, _("This gift card is already used for your payment."))
return
cs['gift_cards'] = cs['gift_cards'] + [gc.pk]
for p in cs.get('payments', []):
if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk:
messages.error(request, _("This gift card is already used for your payment."))
return
add_payment_to_cart(
request,
self,
max_value=gc.value,
info_data={
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
}
total = sum(p.total for p in cart['positions'])
# Recompute fees. Some plugins, e.g. pretix-servicefees, change their fee schedule if a gift card is
# applied.
fees = get_fees(
self.event, request, total, cart['invoice_address'], cs.get('payment'),
cart['raw']
)
return True
total += sum([f.value for f in fees])
remainder = total
if remainder > Decimal('0.00'):
del cs['payment']
messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format(
money_filter(remainder, self.event.currency)
))
else:
messages.success(request, _("Your gift card has been applied."))
kwargs = {'step': 'payment'}
if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs:
kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace']
return eventreverse(self.event, 'presale:event.checkout', kwargs=kwargs)
except GiftCard.DoesNotExist:
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
@@ -1353,7 +1268,7 @@ class GiftCardPayment(BasePaymentProvider):
try:
gc = self.event.organizer.accepted_gift_cards.get(
secret=request.POST.get("giftcard").strip()
secret=request.POST.get("giftcard")
)
if gc.currency != self.event.currency:
messages.error(request, _("This gift card does not support this currency."))
@@ -1372,7 +1287,6 @@ class GiftCardPayment(BasePaymentProvider):
return
payment.info_data = {
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
'retry': True
}
payment.amount = min(payment.amount, gc.value)
@@ -1380,7 +1294,7 @@ class GiftCardPayment(BasePaymentProvider):
return True
except GiftCard.DoesNotExist:
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard").strip()).exists():
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
"the product selection."))
else:
@@ -1388,46 +1302,37 @@ class GiftCardPayment(BasePaymentProvider):
except GiftCard.MultipleObjectsReturned:
messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
def execute_payment(self, request: HttpRequest, payment: OrderPayment, is_early_special_case=False) -> str:
for p in payment.order.positions.all():
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
# This method will only be called when retrying payments, e.g. after a payment_prepare call. It is not called
# during the order creation phase because this payment provider is a special case.
for p in payment.order.positions.all(): # noqa - just a safeguard
if p.item.issue_giftcard:
raise PaymentException(_("You cannot pay with gift cards when buying a gift card."))
gcpk = payment.info_data.get('gift_card')
if not gcpk:
if not gcpk or not payment.info_data.get('retry'):
raise PaymentException("Invalid state, should never occur.")
try:
with transaction.atomic():
try:
gc = GiftCard.objects.select_for_update().get(pk=gcpk)
except GiftCard.DoesNotExist:
raise PaymentException(_("This gift card does not support this currency."))
if gc.currency != self.event.currency: # noqa - just a safeguard
raise PaymentException(_("This gift card does not support this currency."))
if not gc.accepted_by(self.event.organizer):
raise PaymentException(_("This gift card is not accepted by this event organizer."))
if payment.amount > gc.value:
raise PaymentException(_("This gift card was used in the meantime. Please try again."))
if gc.testmode and not payment.order.testmode:
raise PaymentException(_("This gift card can only be used in test mode."))
if not gc.testmode and payment.order.testmode:
raise PaymentException(_("Only test gift cards can be used in test mode."))
if gc.expires and gc.expires < now():
raise PaymentException(_("This gift card is no longer valid."))
trans = gc.transactions.create(
value=-1 * payment.amount,
order=payment.order,
payment=payment
)
payment.info_data = {
'gift_card': gc.pk,
'transaction_id': trans.pk,
}
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)
except PaymentException as e:
payment.fail(info={'error': str(e)})
raise e
with transaction.atomic():
gc = GiftCard.objects.select_for_update().get(pk=gcpk)
if gc.currency != self.event.currency: # noqa - just a safeguard
raise PaymentException(_("This gift card does not support this currency."))
if not gc.accepted_by(self.event.organizer): # noqa - just a safeguard
raise PaymentException(_("This gift card is not accepted by this event organizer."))
if payment.amount > gc.value: # noqa - just a safeguard
raise PaymentException(_("This gift card was used in the meantime. Please try again."))
if gc.expires and gc.expires < now(): # noqa - just a safeguard
messages.error(request, _("This gift card is no longer valid."))
return
trans = gc.transactions.create(
value=-1 * payment.amount,
order=payment.order,
payment=payment
)
payment.info_data = {
'gift_card': gc.pk,
'transaction_id': trans.pk,
}
payment.confirm()
def payment_is_valid_session(self, request: HttpRequest) -> bool:
return True

View File

@@ -40,7 +40,6 @@ import os
import re
import subprocess
import tempfile
import unicodedata
import uuid
from collections import OrderedDict
from functools import partial
@@ -746,8 +745,6 @@ class Renderer:
def replace(x):
if x.group(1).startswith('itemmeta:'):
if op.variation_id:
return op.variation.meta_data.get(x.group(1)[9:]) or ''
return op.item.meta_data.get(x.group(1)[9:]) or ''
elif x.group(1).startswith('meta:'):
return ev.meta_data.get(x.group(1)[5:]) or ''
@@ -768,8 +765,6 @@ class Renderer:
return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text)
elif o['content'].startswith('itemmeta:'):
if op.variation_id:
return op.variation.meta_data.get(o['content'][9:]) or ''
return op.item.meta_data.get(o['content'][9:]) or ''
elif o['content'].startswith('meta:'):
@@ -832,13 +827,6 @@ class Renderer:
if o['italic']:
font += ' I'
try:
ad = getAscentDescent(font, float(o['fontsize']))
except KeyError: # font not known, fall back
logger.warning(f'Use of unknown font "{font}"')
font = 'Open Sans'
ad = getAscentDescent(font, float(o['fontsize']))
align_map = {
'left': TA_LEFT,
'center': TA_CENTER,
@@ -865,12 +853,10 @@ class Renderer:
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
# reportlab does not support unicode combination characters
text = unicodedata.normalize("NFKC", text)
p = Paragraph(text, style=style)
w, h = p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
# p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
ad = getAscentDescent(font, float(o['fontsize']))
canvas.saveState()
# The ascent/descent offsets here are not really proven to be correct, they're just empirical values to get
# reportlab render similarly to browser canvas.

View File

@@ -65,14 +65,7 @@ def get_all_plugins(event=None) -> List[type]:
)
class PluginConfigMeta(type):
def __getattribute__(cls, item):
if item == "default" and cls is PluginConfig:
return False
return super().__getattribute__(item)
class PluginConfig(AppConfig, metaclass=PluginConfigMeta):
class PluginConfig(AppConfig):
IGNORE = False
def __init__(self, *args, **kwargs):

View File

@@ -35,13 +35,12 @@ from pretix.base.models import (
SubEvent, User, WaitingListEntry,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException, mail
from pretix.base.services.mail import SendMailException, TolerantDict, mail
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
)
from pretix.base.services.tasks import ProfiledEventTask
from pretix.celery_app import app
from pretix.helpers.format import format_map
logger = logging.getLogger(__name__)
@@ -52,7 +51,7 @@ def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: Lazy
try:
mail(
wle.email,
format_map(subject, email_context),
str(subject).format_map(TolerantDict(email_context)),
message,
email_context,
wle.event,
@@ -72,7 +71,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
order=order, position_or_address=ia, event=order.event)
real_subject = format_map(subject, email_context)
real_subject = str(subject).format_map(TolerantDict(email_context))
try:
order.send_mail(
real_subject, message, email_context,
@@ -87,7 +86,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
real_subject = format_map(subject, email_context)
real_subject = str(subject).format_map(TolerantDict(email_context))
email_context = get_email_context(event_or_subevent=p.subevent or order.event,
event=order.event,
refund_amount=refund_amount,
@@ -211,7 +210,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
fee += min(p.price, Decimal(keep_fee_per_ticket))
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects,
source=("pretix.cancelevent", None))
refund_amount = o.payment_refund_sum
try:

View File

@@ -31,7 +31,7 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import uuid
from collections import Counter, defaultdict, namedtuple
from datetime import datetime, time, timedelta
from decimal import Decimal
@@ -110,11 +110,6 @@ error_messages = {
'positions have been removed from your cart.'),
'price_too_high': _('The entered price is to high.'),
'voucher_invalid': _('This voucher code is not known in our database.'),
'voucher_min_usages': _('The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
'matching products.'),
'voucher_min_usages_removed': _('The voucher code "%(voucher)s" can only be used if you select at least '
'%(number)s matching products. We have therefore removed some positions from '
'your cart that can no longer be purchased like this.'),
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
'voucher_redeemed_cart': _('This voucher code is currently locked since it is already contained in a cart. This '
'might mean that someone else is redeeming this voucher right now, or that you tried '
@@ -195,7 +190,7 @@ class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
'price_after_voucher', 'custom_price_input',
'custom_price_input_is_net', 'voucher_ignored'))
'custom_price_input_is_net'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price_after_voucher'))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'voucher',
@@ -330,16 +325,12 @@ class CartManager:
(isinstance(op, self.ExtendOperation) and op.position.is_bundled)
):
if op.item.require_voucher and op.voucher is None:
if getattr(op, 'voucher_ignored', False):
raise CartError(error_messages['voucher_redeemed'])
raise CartError(error_messages['voucher_required'])
if (
(op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher)) and
(op.voucher is None or not op.voucher.show_hidden_items)
):
if getattr(op, 'voucher_ignored', False):
raise CartError(error_messages['voucher_redeemed'])
raise CartError(error_messages['voucher_required'])
if not op.item.is_available() or (op.variation and not op.variation.is_available()):
@@ -453,15 +444,12 @@ class CartManager:
if cp.is_bundled:
bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first()
if bundle:
listed_price = bundle.designated_price or Decimal('0.00')
listed_price = bundle.designated_price or 0
else:
listed_price = cp.price
price_after_voucher = listed_price
else:
if cp.addon_to_id and is_included_for_free(cp.item, cp.addon_to):
listed_price = Decimal('0.00')
else:
listed_price = get_listed_price(cp.item, cp.variation, cp.subevent)
listed_price = get_listed_price(cp.item, cp.variation, cp.subevent)
if cp.voucher:
price_after_voucher = cp.voucher.calculate_price(listed_price)
else:
@@ -487,7 +475,7 @@ class CartManager:
self._check_item_constraints(op)
if cp.voucher:
self._voucher_use_diff[cp.voucher] += 2
self._voucher_use_diff[cp.voucher] += 1
self._operations.append(op)
return err
@@ -536,15 +524,6 @@ class CartManager:
voucher_use_diff[voucher] += 1
ops.append((listed_price - price_after_voucher, self.VoucherOperation(p, voucher, price_after_voucher)))
for voucher, cnt in list(voucher_use_diff.items()):
if 0 < cnt < voucher.min_usages_remaining:
raise CartError(
_(error_messages['voucher_min_usages']) % {
'voucher': voucher.code,
'number': voucher.min_usages_remaining,
}
)
# If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits
# the user the most.
ops.sort(key=lambda k: k[0], reverse=True)
@@ -593,7 +572,6 @@ class CartManager:
item = self._items_cache[i['item']]
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
voucher = None
voucher_ignored = False
if i.get('voucher'):
try:
@@ -603,24 +581,6 @@ class CartManager:
else:
voucher_use_diff[voucher] += i['count']
if i.get('voucher_ignore_if_redeemed', False):
# This is a special case handling for when a user clicks "+" on an existing line in their cart
# that has a voucher attached. If the voucher still has redemptions left, we'll add another line
# with the same voucher, but if it does not we silently continue as if there was no voucher,
# leading to either a higher-priced ticket or an error. Still, this leads to less error cases
# than either of the possible default assumptions.
predicted_redeemed_after = (
voucher.redeemed +
CartPosition.objects.filter(voucher=voucher, expires__gte=self.now_dt).count() +
self._voucher_use_diff[voucher] +
voucher_use_diff[voucher]
)
if predicted_redeemed_after > voucher.max_usages:
i.pop('voucher')
voucher_ignored = True
voucher = None
voucher_use_diff[voucher] -= i['count']
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
quotas = list(item.quotas.filter(subevent=subevent)
if variation is None else variation.quotas.filter(subevent=subevent))
@@ -667,7 +627,6 @@ class CartManager:
price_after_voucher=bundle.designated_price,
custom_price_input=None,
custom_price_input_is_net=False,
voucher_ignored=False,
)
self._check_item_constraints(bop, operations)
bundled.append(bop)
@@ -697,7 +656,6 @@ class CartManager:
price_after_voucher=price_after_voucher,
custom_price_input=custom_price,
custom_price_input_is_net=self.event.settings.display_net_prices,
voucher_ignored=voucher_ignored,
)
self._check_item_constraints(op, operations)
operations.append(op)
@@ -829,7 +787,6 @@ class CartManager:
price_after_voucher=listed_price,
custom_price_input=custom_price,
custom_price_input_is_net=self.event.settings.display_net_prices,
voucher_ignored=False,
)
self._check_item_constraints(op, operations)
operations.append(op)
@@ -958,41 +915,6 @@ class CartManager:
)
return err
def _check_min_per_voucher(self):
vouchers = Counter()
for p in self.positions:
vouchers[p.voucher] += 1
for op in self._operations:
if isinstance(op, self.AddOperation):
vouchers[op.voucher] += op.count
elif isinstance(op, self.RemoveOperation):
vouchers[op.position.voucher] -= 1
err = None
for voucher, count in vouchers.items():
if not voucher or count == 0:
continue
if count < voucher.min_usages_remaining:
self._operations = [o for o in self._operations if not (
isinstance(o, self.AddOperation) and o.voucher and o.voucher.pk == voucher.pk
)]
removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)]
for p in self.positions:
if p.voucher_id == voucher.pk and p.pk not in removals:
self._operations.append(self.RemoveOperation(position=p))
err = _(error_messages['voucher_min_usages_removed']) % {
'voucher': voucher.code,
'number': voucher.min_usages_remaining,
}
if not err:
raise CartError(
_(error_messages['voucher_min_usages']) % {
'voucher': voucher.code,
'number': voucher.min_usages_remaining,
}
)
return err
def _perform_operations(self):
vouchers_ok = self._get_voucher_availability()
quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt)
@@ -1249,7 +1171,6 @@ class CartManager:
err = self._delete_out_of_timeframe()
err = self.extend_expired_positions() or err
err = err or self._check_min_per_voucher()
lockfn = NoLockManager
if self._require_locking():
@@ -1265,71 +1186,44 @@ class CartManager:
raise CartError(err)
def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
"""
:param request: The current HTTP request context.
:param provider: The instance of your payment provider.
:param min_value: The minimum value this payment instrument supports, or ``None`` for unlimited.
:param max_value: The maximum value this payment instrument supports, or ``None`` for unlimited. Highly discouraged
to use for payment providers which charge a payment fee, as this can be very user-unfriendly if
users need a second payment method just for the payment fee of the first method.
:param info_data: A dictionary of information that will be passed through to the ``OrderPayment.info_data`` attribute.
:return:
"""
def get_fees(event, request, total, invoice_address, provider, positions):
from pretix.presale.views.cart import cart_session
cs = cart_session(request)
cs.setdefault('payments', [])
cs['payments'].append({
'id': str(uuid.uuid4()),
'provider': provider.identifier,
'multi_use_supported': provider.multi_use_supported,
'min_value': str(min_value) if min_value is not None else None,
'max_value': str(max_value) if max_value is not None else None,
'info_data': info_data or {},
})
def get_fees(event, request, total, invoice_address, payments, positions):
if payments and not isinstance(payments, list):
raise TypeError("payments must now be a list")
fees = []
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
total=total, positions=positions, payment_requests=payments):
total=total, positions=positions):
if resp:
fees += resp
total = total + sum(f.value for f in fees)
if total != 0 and payments:
total_remaining = total
for p in payments:
# This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base:
# - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
cs = cart_session(request)
if cs.get('gift_cards'):
gcs = cs['gift_cards']
gc_qs = event.organizer.accepted_gift_cards.filter(pk__in=cs.get('gift_cards'), currency=event.currency)
for gc in gc_qs:
if gc.testmode != event.testmode:
gcs.remove(gc.pk)
continue
fval = Decimal(gc.value) # TODO: don't require an extra query
fval = min(fval, total)
if fval > 0:
total -= fval
fees.append(OrderFee(
fee_type=OrderFee.FEE_TYPE_GIFTCARD,
internal_type='giftcard',
description=gc.secret,
value=-1 * fval,
tax_rate=Decimal('0.00'),
tax_value=Decimal('0.00'),
tax_rule=TaxRule.zero()
))
cs['gift_cards'] = gcs
to_pay = total_remaining
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
pprov = event.get_payment_providers(cached=True).get(p['provider'])
if not pprov:
continue
payment_fee = pprov.calculate_fee(to_pay)
total_remaining += payment_fee
to_pay += payment_fee
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
total_remaining -= to_pay
if provider and total != 0:
provider = event.get_payment_providers().get(provider)
if provider:
payment_fee = provider.calculate_fee(total)
if payment_fee:
payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero()

View File

@@ -452,19 +452,17 @@ def build_preview_invoice_pdf(event):
if event.tax_rules.exists():
for i, tr in enumerate(event.tax_rules.all()):
for j in range(5):
tax = tr.tax(Decimal('100.00'), base_price_is='gross')
InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product {}").format(i + 1),
gross_value=tax.gross, tax_value=tax.tax,
tax_rate=tax.rate
)
else:
for i in range(5):
tax = tr.tax(Decimal('100.00'), base_price_is='gross')
InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product A"),
gross_value=100, tax_value=0, tax_rate=0
invoice=invoice, description=_("Sample product {}").format(i + 1),
gross_value=tax.gross, tax_value=tax.tax,
tax_rate=tax.rate
)
else:
InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product A"),
gross_value=100, tax_value=0, tax_rate=0
)
return event.invoice_renderer.generate(invoice)

View File

@@ -163,7 +163,7 @@ def lock_event_redis(event):
retries = 5
for i in range(retries):
try:
if lock.acquire(blocking=False):
if lock.acquire(False):
return True
except RedisError:
logger.exception('Error locking an event')

View File

@@ -63,7 +63,6 @@ from django.utils.timezone import now, override
from django.utils.translation import gettext as _, pgettext
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from text_unidecode import unidecode
from pretix.base.email import ClassicMailRenderer
from pretix.base.i18n import language
@@ -76,7 +75,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.format import format_map
from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals
@@ -99,8 +97,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
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,
plain_text_only=False, no_order_links=False):
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None):
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -111,7 +108,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param template: The filename of a template to be used. It will be rendered with the locale given in the locale
argument and the context given in the next argument. Alternatively, you can pass a LazyI18nString and
``context`` will be used as the argument to a ``pretix.helpers.format.format_map(template, context)`` call on the template.
``context`` will be used as the argument to a Python ``.format_map()`` call on the template.
:param context: The context for rendering the template (see ``template`` parameter)
@@ -150,21 +147,12 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param attach_other_files: A list of file paths on our storage to attach.
:param plain_text_only: If set to ``True``, rendering a HTML version will be skipped.
:param no_order_links: If set to ``True``, no link to the order confirmation page will be auto-appended. Currently
only allowed to use together with ``plain_text_only`` since HTML renderers add their own
links.
: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.
"""
if email == INVALID_ADDRESS:
return
if no_order_links and not plain_text_only:
raise ValueError('If you set no_order_links, you also need to set plain_text_only.')
headers = headers or {}
if auto_email:
headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN'
@@ -208,7 +196,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
else:
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
subject = raw_subject = str(subject).replace('\n', ' ').replace('\r', '')[:900]
subject = raw_subject = str(subject)
signature = ""
bcc = []
@@ -253,7 +241,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
if order and order.testmode:
subject = "[TESTMODE] " + subject
if order and position and not no_order_links:
if order and position:
body_plain += _(
"You are receiving this email because someone placed an order for {event} for you."
).format(event=event.name)
@@ -269,7 +257,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
}
)
)
elif order and not no_order_links:
elif order:
body_plain += _(
"You are receiving this email because you placed an order for {event}."
).format(event=event.name)
@@ -289,9 +277,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
with override(timezone):
try:
if plain_text_only:
body_html = None
elif 'position' in inspect.signature(renderer.render).parameters:
if 'position' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, raw_subject, order, position)
else:
# Backwards compatibility
@@ -445,9 +431,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
}
)
if attach_ical:
fname = re.sub('[^a-zA-Z0-9 ]', '-', unidecode(pgettext('attachment_filename', 'Calendar invite')))
for i, cal in enumerate(get_private_icals(event, [position] if position else order.positions.all())):
email.attach('{}{}.ics'.format(fname, f'-{i + 1}' if i > 0 else ''), cal.serialize(), 'text/calendar')
email.attach('event-{}.ics'.format(i), cal.serialize(), 'text/calendar')
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
@@ -621,7 +606,7 @@ def render_mail(template, context):
if isinstance(template, LazyI18nString):
body = str(template)
if context:
body = format_map(body, context)
body = body.format_map(TolerantDict(context))
else:
tpl = get_template(template)
body = tpl.render(context)

View File

@@ -195,7 +195,8 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
user=user,
data={'source': 'import'}
)
save_transactions += o.create_transactions(is_new=True, fees=[], positions=o._positions, save=False)
save_transactions += o.create_transactions(is_new=True, fees=[], positions=o._positions, save=False,
source=('pretix.orderimport', None))
Transaction.objects.bulk_create(save_transactions)
for o in orders:

View File

@@ -54,7 +54,7 @@ from django.db.transaction import get_connection
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _
from django.utils.translation import gettext as _, gettext_lazy
from django_scopes import scopes_disabled
from pretix.api.models import OAuthApplication
@@ -74,7 +74,7 @@ from pretix.base.models.orders import (
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.payment import GiftCardPayment, PaymentException
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
@@ -115,8 +115,6 @@ error_messages = {
'server was too busy. Please try again.'),
'not_started': _('The booking period for this event has not yet started.'),
'ended': _('The booking period has ended.'),
'voucher_min_usages': _('The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
'matching products.'),
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
'number of times allowed. We removed this item from your cart.'),
@@ -150,7 +148,7 @@ def mark_order_paid(*args, **kwargs):
raise NotImplementedError("This method is no longer supported since pretix 1.17.")
def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None):
def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None, source=None):
"""
Reactivates a canceled order. If ``force`` is not set to ``True``, this will fail if there is not
enough quota.
@@ -191,7 +189,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
for m in position.granted_memberships.all():
m.canceled = False
m.save()
order.create_transactions()
order.create_transactions(source=source)
else:
raise OrderError(is_available)
@@ -204,7 +202,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
generate_invoice(order)
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None):
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None, source=None):
"""
Extends the deadline of an order. If the order is already expired, the quota will be checked to
see if this is actually still possible. If ``force`` is set to ``True``, the result of this check
@@ -233,7 +231,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
num_invoices = order.invoices.filter(is_cancellation=False).count()
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices and invoice_qualified(order):
generate_invoice(order)
order.create_transactions()
order.create_transactions(source=source)
if order.status == Order.STATUS_PENDING:
change(was_expired=False)
@@ -247,16 +245,17 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
@transaction.atomic
def mark_order_refunded(order, user=None, auth=None, api_token=None):
def mark_order_refunded(order, user=None, auth=None, api_token=None, source=None):
oautha = auth.pk if isinstance(auth, OAuthApplication) else None
device = auth.pk if isinstance(auth, Device) else None
api_token = (api_token.pk if api_token else None) or (auth if isinstance(auth, TeamAPIToken) else None)
return _cancel_order(
order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha
order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha,
source=source
)
def mark_order_expired(order, user=None, auth=None):
def mark_order_expired(order, user=None, auth=None, source=None):
"""
Mark this order as expired. This sets the payment status and returns the order object.
:param order: The order to change
@@ -275,13 +274,13 @@ def mark_order_expired(order, user=None, auth=None):
i = order.invoices.filter(is_cancellation=False).last()
if i and not i.refered.exists():
generate_cancellation(i)
order.create_transactions()
order.create_transactions(source=source)
order_expired.send(order.event, order=order)
return order
def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False):
def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False, source=None):
"""
Mark this order as approved
:param order: The order to change
@@ -294,7 +293,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
order.require_approval = False
order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
order.save(update_fields=['require_approval', 'expires'])
order.create_transactions()
order.create_transactions(source=source)
order.log_action('pretix.event.order.approved', user=user, auth=auth)
if order.total == Decimal('0.00'):
@@ -324,10 +323,10 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
with language(order.locale, order.event.settings.region):
if order.total == Decimal('0.00'):
email_template = order.event.settings.mail_text_order_approved_free
email_subject = order.event.settings.mail_subject_order_approved_free
email_subject = _('Order approved and confirmed: %(code)s') % {'code': order.code}
else:
email_template = order.event.settings.mail_text_order_approved
email_subject = order.event.settings.mail_subject_order_approved
email_subject = _('Order approved and awaiting payment: %(code)s') % {'code': order.code}
email_context = get_email_context(event=order.event, order=order)
try:
@@ -343,7 +342,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
return order.pk
def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None, source=None):
"""
Mark this order as canceled
:param order: The order to change
@@ -367,15 +366,15 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
order.create_transactions()
order.create_transactions(source=source)
order_denied.send(order.event, order=order)
if send_mail:
email_template = order.event.settings.mail_text_order_denied
email_subject = order.event.settings.mail_subject_order_denied
email_context = get_email_context(event=order.event, order=order, comment=comment)
with language(order.locale, order.event.settings.region):
email_subject = _('Order denied: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
@@ -388,7 +387,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None):
cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None, source=None):
"""
Mark this order as canceled
:param order: The order to change
@@ -462,13 +461,9 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
f._calculate_tax()
f.save()
if cancellation_fee > order.total:
raise OrderError(_('The cancellation fee cannot be higher than the total amount of this order.'))
elif order.payment_refund_sum < cancellation_fee:
order.status = Order.STATUS_PENDING
order.set_expires()
else:
order.status = Order.STATUS_PAID
if order.payment_refund_sum < cancellation_fee:
raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
order.status = Order.STATUS_PAID
order.total = cancellation_fee
order.cancellation_date = now()
order.save(update_fields=['status', 'cancellation_date', 'total'])
@@ -492,13 +487,13 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
data={'cancellation_fee': cancellation_fee, 'comment': comment})
order.cancellation_requests.all().delete()
order.create_transactions()
order.create_transactions(source=source)
if send_mail:
email_template = order.event.settings.mail_text_order_canceled
with language(order.locale, order.event.settings.region):
email_template = order.event.settings.mail_text_order_canceled
email_subject = order.event.settings.mail_subject_order_canceled
email_context = get_email_context(event=order.event, order=order, comment=comment or "")
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
@@ -575,7 +570,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
products_seen = Counter()
q_avail = Counter()
v_avail = Counter()
v_usages = Counter()
v_budget = {}
deleted_positions = set()
seats_seen = set()
@@ -613,7 +607,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
break
if cp.voucher:
v_usages[cp.voucher] += 1
if cp.voucher not in v_avail:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
@@ -725,13 +718,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
# Sorry, can't let you keep that!
delete(cp)
for voucher, cnt in v_usages.items():
if 0 < cnt < voucher.min_usages_remaining:
raise OrderError(error_messages['voucher_min_usages'], {
'voucher': voucher.code,
'number': voucher.min_usages_remaining,
})
# Check prices
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
old_total = sum(cp.price for cp in sorted_positions)
@@ -793,75 +779,68 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
raise OrderError(err, errargs)
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
meta_info: dict, event: Event, require_approval=False):
def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvider, address: InvoiceAddress,
meta_info: dict, event: Event, gift_cards: List[GiftCard]):
fees = []
total = sum([c.price for c in positions])
gift_cards = [] # for backwards compatibility
for p in payment_requests:
if p['provider'] == 'giftcard':
gift_cards.append(GiftCard.objects.get(pk=p['info_data']['gift_card']))
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total, payment_requests=payment_requests,
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
meta_info=meta_info, positions=positions, gift_cards=gift_cards):
if resp:
fees += resp
total += sum(f.value for f in fees)
total_remaining = total
for p in payment_requests:
# This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base:
# - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
p['payment_amount'] = Decimal('0.00')
continue
gift_card_values = {}
for gc in gift_cards:
fval = Decimal(gc.value) # TODO: don't require an extra query
fval = min(fval, total)
if fval > 0:
total -= fval
gift_card_values[gc] = fval
to_pay = total_remaining
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
if payment_provider:
payment_fee = payment_provider.calculate_fee(total)
else:
payment_fee = 0
pf = None
if payment_fee:
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=payment_provider.identifier)
fees.append(pf)
payment_fee = p['pprov'].calculate_fee(to_pay)
total_remaining += payment_fee
to_pay += payment_fee
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
total_remaining -= to_pay
p['payment_amount'] = to_pay
if payment_fee:
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=p['pprov'].identifier)
fees.append(pf)
p['fee'] = pf
if total_remaining != Decimal('0.00') and not require_approval:
raise OrderError(_("The selected payment methods do not cover the total balance."))
return fees
return fees, pf, gift_card_values
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], locale: str=None, address: InvoiceAddress=None,
meta_info: dict=None, sales_channel: str='web', shown_total=None,
customer=None):
payments = []
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
meta_info: dict=None, sales_channel: str='web', gift_cards: list=None, shown_total=None,
customer=None, source=None):
p = None
sales_channel = get_all_sales_channels()[sales_channel]
with transaction.atomic():
checked_gift_cards = []
if gift_cards:
gc_qs = GiftCard.objects.select_for_update().filter(pk__in=gift_cards)
for gc in gc_qs:
if gc.currency != event.currency:
raise OrderError(_("This gift card does not support this currency."))
if gc.testmode and not event.testmode:
raise OrderError(_("This gift card can only be used in test mode."))
if not gc.testmode and event.testmode:
raise OrderError(_("Only test gift cards can be used in test mode."))
if not gc.accepted_by(event.organizer):
raise OrderError(_("This gift card is not accepted by this event organizer."))
checked_gift_cards.append(gc)
if checked_gift_cards and any(c.item.issue_giftcard for c in positions):
raise OrderError(_("You cannot pay with gift cards when buying a gift card."))
try:
validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode)
except ValidationError as e:
raise OrderError(e.message)
require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
fees, pf, gift_card_values = _get_fees(positions, payment_provider, address, meta_info, event, checked_gift_cards)
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
order = Order(
@@ -874,7 +853,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=require_approval,
require_approval=any(p.requires_approval(invoice_address=address) for p in positions),
sales_channel=sales_channel.identifier,
customer=customer,
)
@@ -898,11 +877,28 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
fee.tax_rule = None # TODO: deprecate
fee.save()
for gc, val in gift_card_values.items():
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='giftcard',
amount=val,
fee=pf
)
trans = gc.transactions.create(
value=-1 * val,
order=order,
payment=p
)
p.info_data = {
'gift_card': gc.pk,
'transaction_id': trans.pk,
}
p.save()
pending_sum -= val
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
# pressed "Confirm purchase"? If not, we should better warn the user and show the confirmation page again.
# We used to have a *known* case where this happened is if a gift card is used in two concurrent sessions,
# but this is now a payment error instead. So currently this code branch is usually only triggered by bugs
# in other places (e.g. tax calculation).
# The only *known* case where this happens is if a gift card is used in two concurrent sessions.
if shown_total is not None:
if Decimal(shown_total) != pending_sum:
raise OrderError(
@@ -911,20 +907,16 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
'check the prices below and try again.')
)
if payment_requests and not order.require_approval:
for p in payment_requests:
if not p.get('multi_use_supported') or p['payment_amount'] > Decimal('0.00'):
payments.append(order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=p['provider'],
amount=p['payment_amount'],
fee=p.get('fee'),
info=json.dumps(p['info_data']),
process_initiated=False,
))
if payment_provider and not order.require_approval:
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=payment_provider.identifier,
amount=pending_sum,
fee=pf
)
orderpositions = OrderPosition.transform_cart_positions(positions, order)
order.create_transactions(positions=orderpositions, fees=fees, is_new=True)
order.create_transactions(positions=orderpositions, fees=fees, is_new=True, source=source, dt_now=now_dt)
order.log_action('pretix.event.order.placed')
if order.require_approval:
order.log_action('pretix.event.order.placed.require_approval')
@@ -933,15 +925,16 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
order.log_action('pretix.event.order.consent', data={'msg': msg})
order_placed.send(event, order=order)
return order, payments
return order, p
def _order_placed_email(event: Event, order: Order, email_template, subject_template,
log_entry: str, invoice, payments: List[OrderPayment], is_free=False):
email_context = get_email_context(event=event, order=order, payments=payments)
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
invoice, payment: OrderPayment, is_free=False):
email_context = get_email_context(event=event, order=order, payment=payment if pprov else None)
email_subject = gettext_lazy('Your order: {code}')
try:
order.send_mail(
subject_template, email_template, email_context,
email_subject, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True,
@@ -954,13 +947,13 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp
logger.exception('Order received email could not be sent')
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template,
log_entry: str, is_free=False):
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str, is_free=False):
email_context = get_email_context(event=event, order=order, position=position)
email_subject = gettext_lazy('Your event registration: {code}')
try:
position.send_mail(
subject_template, email_template, email_context,
email_subject, email_template, email_context,
log_entry,
invoices=[],
attach_tickets=True,
@@ -973,13 +966,15 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
logger.exception('Order received email could not be sent to attendee')
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
shown_total=None, customer=None):
for p in payment_requests:
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
if not p['pprov']:
gift_cards: list=None, shown_total=None, customer=None, source=None):
if payment_provider:
pprov = event.get_payment_providers().get(payment_provider)
if not pprov:
raise OrderError(error_messages['internal'])
else:
pprov = None
if customer:
customer = event.organizer.customers.get(pk=customer)
@@ -1009,17 +1004,8 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
id__in=position_ids, event=event
)
validate_order.send(
event,
payment_provider=payment_requests[0]['provider'] if payment_requests else None, # only for backwards compatibility
payments=payment_requests,
email=email,
positions=positions,
locale=locale,
invoice_address=addr,
meta_info=meta_info,
customer=customer,
)
validate_order.send(event, payment_provider=pprov, email=email, positions=positions, locale=locale,
invoice_address=addr, meta_info=meta_info, customer=customer)
lockfn = NoLockManager
locked = False
@@ -1029,9 +1015,6 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
locked = True
lockfn = event.lock
warnings = []
any_payment_failed = False
with lockfn() as now_dt:
positions = list(
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
@@ -1042,55 +1025,21 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if len(position_ids) != len(positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel, customer=customer)
order, payment_objs = _create_order(event, email, positions, now_dt, payment_requests,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
shown_total=shown_total, customer=customer)
try:
for p in payment_objs:
if p.provider == 'free':
p.confirm(send_mail=False, lock=not locked, generate_invoice=False)
except Quota.QuotaExceededException:
pass
order, payment = _create_order(event, email, positions, now_dt, pprov,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
gift_cards=gift_cards, shown_total=shown_total, customer=customer, source=source)
# We give special treatment to GiftCardPayment here because our invoice renderer expects gift cards to already be
# processed, and because we historically treat gift card orders like free orders with regards to email texts.
# It would be great to give external gift card plugins the same special treatment, but it feels to risky for now, as
# (a) there would be no email at all if the plugin fails in a weird way and (b) we'd be able to run into
# contradictions when a plugin set both execute_payment_needs_user=False as well as requires_invoice_immediately=True
for p in payment_objs:
if isinstance(p.payment_provider, GiftCardPayment):
free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval
if free_order_flow:
try:
p.process_initiated = True
p.save(update_fields=['process_initiated'])
p.payment_provider.execute_payment(None, p, is_early_special_case=True)
except PaymentException as e:
warnings.append(str(e))
any_payment_failed = True
except Exception:
logger.exception('Error during payment attempt')
pending_sum = order.pending_sum
free_order_flow = (
payment_objs and
(
any(p['provider'] == 'free' for p in payment_requests) or
all(p['provider'] == 'giftcard' for p in payment_requests)
) and
pending_sum == Decimal('0.00') and
not order.require_approval
)
payment.confirm(send_mail=False, lock=not locked)
except Quota.QuotaExceededException:
pass
invoice = order.invoices.last() # Might be generated by plugin already
if not invoice and invoice_qualified(order):
invoice_required = (
event.settings.get('invoice_generate') == 'True' or (
event.settings.get('invoice_generate') == 'paid' and (
any(p['pprov'].requires_invoice_immediately for p in payment_requests) or
pending_sum <= Decimal('0.00')
)
)
)
if invoice_required:
if event.settings.get('invoice_generate') == 'True' or (
event.settings.get('invoice_generate') == 'paid' and payment.payment_provider.requires_invoice_immediately):
invoice = generate_invoice(
order,
trigger_pdf=not event.settings.invoice_email_attachment or not order.email
@@ -1100,62 +1049,32 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if order.email:
if order.require_approval:
email_template = event.settings.mail_text_order_placed_require_approval
subject_template = event.settings.mail_subject_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval'
email_attendees = False
elif free_order_flow:
email_template = event.settings.mail_text_order_free
subject_template = event.settings.mail_subject_order_free
log_entry = 'pretix.event.order.email.order_free'
email_attendees = event.settings.mail_send_order_free_attendee
email_attendees_template = event.settings.mail_text_order_free_attendee
subject_attendees_template = event.settings.mail_subject_order_free_attendee
else:
email_template = event.settings.mail_text_order_placed
subject_template = event.settings.mail_subject_order_placed
log_entry = 'pretix.event.order.email.order_placed'
email_attendees = event.settings.mail_send_order_placed_attendee
email_attendees_template = event.settings.mail_text_order_placed_attendee
subject_attendees_template = event.settings.mail_subject_order_placed_attendee
if sales_channel in event.settings.mail_sales_channel_placed_paid:
_order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs,
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment,
is_free=free_order_flow)
if email_attendees:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
_order_placed_email_attendee(event, order, p, email_attendees_template, subject_attendees_template, log_entry,
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry,
is_free=free_order_flow)
if not any_payment_failed:
for p in payment_objs:
if not p.payment_provider.execute_payment_needs_user and not p.process_initiated:
try:
p.process_initiated = True
p.save(update_fields=['process_initiated'])
resp = p.payment_provider.execute_payment(None, p)
if isinstance(resp, str):
logger.warning('Payment provider returned URL from execute_payment even though execute_payment_needs_user is not set')
except PaymentException as e:
warnings.append(str(e))
any_payment_failed = True
except Exception:
logger.exception('Error during payment attempt')
if any_payment_failed:
# Cancel all other payments because their amount might be wrong now.
for p in payment_objs:
if p.state == OrderPayment.PAYMENT_STATE_CREATED:
p.state = OrderPayment.PAYMENT_STATE_CANCELED
p.save(update_fields=['state'])
return {
'order_id': order.id,
'warnings': warnings,
}
return order.id
@receiver(signal=periodic_task)
@@ -1164,21 +1083,13 @@ def expire_orders(sender, **kwargs):
event_id = None
expire = None
qs = Order.objects.filter(
expires__lt=now(),
status=Order.STATUS_PENDING,
require_approval=False
).exclude(
Exists(
OrderFee.objects.filter(order_id=OuterRef('pk'), fee_type=OrderFee.FEE_TYPE_CANCELLATION)
)
).select_related('event').order_by('event_id')
for o in qs:
for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING,
require_approval=False).select_related('event').order_by('event_id'):
if o.event_id != event_id:
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
event_id = o.event_id
if expire:
mark_order_expired(o)
mark_order_expired(o, source=("pretix.periodic", None))
@receiver(signal=periodic_task)
@@ -1214,9 +1125,9 @@ def send_expiry_warnings(sender, **kwargs):
email_template = settings.mail_text_order_expire_warning
email_context = get_email_context(event=o.event, order=o)
if settings.payment_term_expire_automatically:
email_subject = settings.mail_subject_order_expire_warning
email_subject = _('Your order is about to expire: %(code)s') % {'code': o.code}
else:
email_subject = settings.mail_subject_order_pending_warning
email_subject = _('Your order is pending payment: %(code)s') % {'code': o.code}
try:
o.send_mail(
@@ -1289,8 +1200,8 @@ def send_download_reminders(sender, **kwargs):
o.download_reminder_sent = True
o.save(update_fields=['download_reminder_sent'])
email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o)
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
try:
o.send_mail(
email_subject, email_template, email_context,
@@ -1313,7 +1224,6 @@ def send_download_reminders(sender, **kwargs):
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p)
try:
o.send_mail(
@@ -1329,7 +1239,7 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
with language(order.locale, order.event.settings.region):
email_template = order.event.settings.mail_text_order_changed
email_context = get_email_context(event=order.event, order=order)
email_subject = order.event.settings.mail_subject_order_changed
email_subject = _('Your order has been changed: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
@@ -1372,7 +1282,7 @@ class OrderChangeManager:
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee', 'price_diff'))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, source=None):
self.order = order
self.user = user
self.auth = auth
@@ -1387,6 +1297,7 @@ class OrderChangeManager:
self.notify = notify
self._invoice_dirty = False
self._invoices = []
self.source = source
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
@@ -2422,9 +2333,9 @@ class OrderChangeManager:
self._reissue_invoice()
self._clear_tickets_cache()
self.order.touch()
self.order.create_transactions()
self.order.create_transactions(source=self.source)
if self.split_order:
self.split_order.create_transactions()
self.split_order.create_transactions(source=self.source)
if self.notify:
notify_user_changed_order(
@@ -2457,14 +2368,14 @@ class OrderChangeManager:
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def perform_order(self, event: Event, payments: List[dict], positions: List[str],
def perform_order(self, event: Event, payment_provider: str, positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web', shown_total=None, customer=None):
sales_channel: str='web', gift_cards: list=None, shown_total=None, customer=None, source=None):
with language(locale):
try:
try:
return _perform_order(event, payments, positions, email, locale, address, meta_info,
sales_channel, shown_total, customer)
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
sales_channel, gift_cards, shown_total, customer, source=source)
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
@@ -2594,11 +2505,12 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
@scopes_disabled()
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False,
email_comment=None, refund_comment=None, cancel_invoice=True):
email_comment=None, refund_comment=None, cancel_invoice=True, source=None):
try:
try:
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
cancellation_fee, cancel_invoice=cancel_invoice, comment=email_comment)
cancellation_fee, cancel_invoice=cancel_invoice, comment=email_comment,
source=source)
if try_auto_refund:
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
comment=refund_comment)
@@ -2610,7 +2522,7 @@ def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_tok
def change_payment_provider(order: Order, payment_provider, amount=None, new_payment=None, create_log=True,
recreate_invoices=True):
recreate_invoices=True, source=None):
if not get_connection().in_atomic_block:
raise Exception('change_payment_provider should only be called in atomic transaction!')
@@ -2698,7 +2610,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
generate_cancellation(i)
generate_invoice(order)
order.create_transactions()
order.create_transactions(source=source)
return old_fee, new_fee, fee, new_payment

View File

@@ -117,7 +117,7 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice:
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal) -> TaxedPrice:
if not tax_rule:
tax_rule = TaxRule(
name='',
@@ -135,8 +135,7 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum,
base_price_is='gross' if is_bundled else 'auto')
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
return price

View File

@@ -112,7 +112,7 @@ def dictsum(*dicts) -> dict:
def order_overview(
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
admission_only=False, base_qs=None
admission_only=False
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
items = event.items.all().select_related(
'category', # for re-grouping
@@ -120,7 +120,7 @@ def order_overview(
'variations'
).order_by('category__position', 'category_id', 'position', 'name')
qs = OrderPosition.all if base_qs is None else base_qs
qs = OrderPosition.all
if isinstance(subevent, (list, QuerySet)):
qs = qs.filter(subevent__in=subevent)
elif subevent:

View File

@@ -743,18 +743,6 @@ DEFAULTS = {
'default': 'True',
'type': bool
},
'payment_giftcard_public_name': {
'default': LazyI18nString.from_gettext(gettext_noop('Gift card')),
'type': LazyI18nString
},
'payment_giftcard_public_description': {
'default': LazyI18nString.from_gettext(gettext_noop(
'If you have a gift card, please enter the gift card code here. If the gift card does not have '
'enough credit to pay for the full order, you will be shown this page again and you can either '
'redeem another gift card or select a different payment method for the difference.'
)),
'type': LazyI18nString
},
'payment_resellers__restrict_to_sales_channels': {
'default': ['resellers'],
'type': list
@@ -1433,45 +1421,6 @@ DEFAULTS = {
label=_("Customers can cancel their unpaid orders"),
)
},
'cancel_allow_user_unpaid_keep': {
'default': '0.00',
'type': Decimal,
'form_class': forms.DecimalField,
'serializer_class': serializers.DecimalField,
'serializer_kwargs': dict(
max_digits=10, decimal_places=2
),
'form_kwargs': dict(
label=_("Charge a fixed cancellation fee"),
help_text=_("Only affects orders pending payments, a cancellation fee for free orders is never charged. "
"Note that it will be your responsibility to claim the cancellation fee from the user."),
)
},
'cancel_allow_user_unpaid_keep_fees': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Charge payment, shipping and service fees"),
help_text=_("Only affects orders pending payments, a cancellation fee for free orders is never charged. "
"Note that it will be your responsibility to claim the cancellation fee from the user."),
)
},
'cancel_allow_user_unpaid_keep_percentage': {
'default': '0.00',
'type': Decimal,
'form_class': forms.DecimalField,
'serializer_class': serializers.DecimalField,
'serializer_kwargs': dict(
max_digits=10, decimal_places=2
),
'form_kwargs': dict(
label=_("Charge a percentual cancellation fee"),
help_text=_("Only affects orders pending payments, a cancellation fee for free orders is never charged. "
"Note that it will be your responsibility to claim the cancellation fee from the user."),
)
},
'cancel_allow_user_until': {
'default': None,
'type': RelativeDateWrapper,
@@ -1759,14 +1708,6 @@ DEFAULTS = {
'type': LazyI18nString,
'default': ""
},
'mail_subject_resend_link': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your order: {code}")),
},
'mail_subject_resend_link_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your event registration: {code}")),
},
'mail_text_resend_link': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -1780,10 +1721,6 @@ You can change your order details and view the status of your order at
Best regards,
Your {event} team"""))
},
'mail_subject_resend_all_links': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your orders for {event}")),
},
'mail_text_resend_all_links': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -1796,10 +1733,6 @@ The list is as follows:
Best regards,
Your {event} team"""))
},
'mail_subject_order_free_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your event registration: {code}")),
},
'mail_text_order_free_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name},
@@ -1812,14 +1745,6 @@ You can view the details and status of your ticket here:
Best regards,
Your {event} team"""))
},
'mail_send_order_free_attendee': {
'type': bool,
'default': 'False'
},
'mail_subject_order_free': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your order: {code}")),
},
'mail_text_order_free': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -1833,9 +1758,9 @@ You can change your order details and view the status of your order at
Best regards,
Your {event} team"""))
},
'mail_subject_order_placed_require_approval': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your order: {code}")),
'mail_send_order_free_attendee': {
'type': bool,
'default': 'False'
},
'mail_text_order_placed_require_approval': {
'type': LazyI18nString,
@@ -1851,10 +1776,6 @@ You can change your order details and view the status of your order at
Best regards,
Your {event} team"""))
},
'mail_subject_order_placed': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your order: {code}")),
},
'mail_text_order_placed': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -1898,10 +1819,6 @@ Your {event} team"""))
'type': bool,
'default': 'False'
},
'mail_subject_order_placed_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your event registration: {code}")),
},
'mail_text_order_placed_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name},
@@ -1914,10 +1831,6 @@ You can view the details and status of your ticket here:
Best regards,
Your {event} team"""))
},
'mail_subject_order_changed': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your order has been changed: {code}")),
},
'mail_text_order_changed': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -1930,10 +1843,6 @@ You can view the status of your order at
Best regards,
Your {event} team"""))
},
'mail_subject_order_paid': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Payment received for your order: {code}")),
},
'mail_text_order_paid': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -1952,10 +1861,6 @@ Your {event} team"""))
'type': bool,
'default': 'False'
},
'mail_subject_order_paid_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Event registration confirmed: {code}")),
},
'mail_text_order_paid_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name},
@@ -1983,14 +1888,6 @@ Your {event} team"""))
'type': int,
'default': '3'
},
'mail_subject_order_expire_warning': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your order is about to expire: {code}")),
},
'mail_subject_order_pending_warning': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your order is pending payment: {code}")),
},
'mail_text_order_expire_warning': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -2005,10 +1902,6 @@ You can view the payment information and the status of your order at
Best regards,
Your {event} team"""))
},
'mail_subject_waiting_list': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("You have been selected from the waitinglist for {event}")),
},
'mail_text_waiting_list': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -2038,10 +1931,6 @@ as possible to the next person on the waiting list:
Best regards,
Your {event} team"""))
},
'mail_subject_order_canceled': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Order canceled: {code}")),
},
'mail_text_order_canceled': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -2056,10 +1945,6 @@ You can view the details of your order at
Best regards,
Your {event} team"""))
},
'mail_subject_order_approved': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Order approved and awaiting payment: {code}")),
},
'mail_text_order_approved': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -2076,10 +1961,6 @@ You can select a payment method and perform the payment here:
Best regards,
Your {event} team"""))
},
'mail_subject_order_approved_free': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Order approved and confirmed: {code}")),
},
'mail_text_order_approved_free': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -2093,10 +1974,6 @@ You can change your order details and view the status of your order at
Best regards,
Your {event} team"""))
},
'mail_subject_order_denied': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Order denied: {code}")),
},
'mail_text_order_denied': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -2130,10 +2007,6 @@ Your {event} team"""))
'type': bool,
'default': 'False'
},
'mail_subject_download_reminder_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your ticket is ready for download: {code}")),
},
'mail_text_download_reminder_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name},
@@ -2146,10 +2019,6 @@ Your {event} team"""))
Best regards,
Your {event} team"""))
},
'mail_subject_download_reminder': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Your ticket is ready for download: {code}")),
},
'mail_text_download_reminder': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
@@ -2162,10 +2031,6 @@ If you did not do so already, you can download your ticket here:
Best regards,
Your {event} team"""))
},
'mail_subject_customer_registration': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Activate your account at {organizer}")),
},
'mail_text_customer_registration': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
@@ -2184,10 +2049,6 @@ Best regards,
Your {organizer} team"""))
},
'mail_subject_customer_email_change': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Confirm email address for your account at {organizer}")),
},
'mail_text_customer_email_change': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
@@ -2206,10 +2067,6 @@ Best regards,
Your {organizer} team"""))
},
'mail_subject_customer_reset': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Set a new password for your account at {organizer}")),
},
'mail_text_customer_reset': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},

View File

@@ -64,10 +64,6 @@ class EventPluginSignal(django.dispatch.Signal):
# Send to all events!
return True
# If sentry packed this in a wrapper, unpack that
if "sentry" in receiver.__module__:
receiver = receiver.__wrapped__
# Find the Django application this belongs to
searchpath = receiver.__module__
core_module = any([searchpath.startswith(cm) for cm in settings.CORE_MODULES])
@@ -307,7 +303,7 @@ The ``sender`` keyword argument will contain an organizer.
validate_order = EventPluginSignal(
)
"""
Arguments: ``payments``, ``positions``, ``email``, ``locale``, ``invoice_address``,
Arguments: ``payment_provider``, ``positions``, ``email``, ``locale``, ``invoice_address``,
``meta_info``, ``customer``
This signal is sent out when the user tries to confirm the order, before we actually create
@@ -316,9 +312,6 @@ but you can raise an OrderError with an appropriate exception message if you lik
the order. We strongly discourage making changes to the order here.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
**DEPRECTATION:** Stop listening to the ``payment_provider`` attribute, it will be removed
in the future, as the ``payments`` attribute gives more information.
"""
validate_cart = EventPluginSignal()
@@ -567,7 +560,7 @@ an OrderedDict of (setting name, form field).
order_fee_calculation = EventPluginSignal()
"""
Arguments: ``positions``, ``invoice_address``, ``meta_info``, ``total``, ``gift_cards``, ``payment_requests``
Arguments: ``positions``, ``invoice_address``, ``meta_info``, ``total``, ``gift_cards``
This signals allows you to add fees to an order while it is being created. You are expected to
return a list of ``OrderFee`` objects that are not yet saved to the database
@@ -577,10 +570,8 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
argument will contain the cart positions and ``invoice_address`` the invoice address (useful for
tax calculation). The argument ``meta_info`` contains the order's meta dictionary. The ``total``
keyword argument will contain the total cart sum without any fees. You should not rely on this
``total`` value for fee calculations as other fees might interfere. The ``gift_cards`` argument
lists the gift cards in use.
**DEPRECTATION:** Stop listening to the ``gift_cards`` attribute, it will be removed in the future.
``total`` value for fee calculations as other fees might interfere. The ``gift_cards`` argument lists
the gift cards in use.
"""
order_fee_type_name = EventPluginSignal()

View File

@@ -28,7 +28,7 @@ from django.urls import reverse
def _is_samesite_referer(request):
referer = request.headers.get('referer')
referer = request.META.get('HTTP_REFERER')
if referer is None:
return False

View File

@@ -666,9 +666,6 @@ class CancelSettingsForm(SettingsForm):
'cancel_allow_user_until',
'cancel_allow_user_paid',
'cancel_allow_user_paid_until',
'cancel_allow_user_unpaid_keep',
'cancel_allow_user_unpaid_keep_fees',
'cancel_allow_user_unpaid_keep_percentage',
'cancel_allow_user_paid_keep',
'cancel_allow_user_paid_keep_fees',
'cancel_allow_user_paid_keep_percentage',
@@ -930,11 +927,6 @@ class MailSettingsForm(SettingsForm):
required=True,
choices=[]
)
mail_subject_order_placed = I18nFormField(
label=_("Subject sent to order contact address"),
required=False,
widget=I18nTextInput,
)
mail_text_order_placed = I18nFormField(
label=_("Text sent to order contact address"),
required=False,
@@ -946,22 +938,12 @@ class MailSettingsForm(SettingsForm):
'tickets, the following email will be sent out to the attendees.'),
required=False,
)
mail_subject_order_placed_attendee = I18nFormField(
label=_("Subject sent to attendees"),
required=False,
widget=I18nTextInput,
)
mail_text_order_placed_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
)
mail_subject_order_paid = I18nFormField(
label=_("Subject sent to order contact address"),
required=False,
widget=I18nTextInput,
)
mail_text_order_paid = I18nFormField(
label=_("Text sent to order contact address"),
required=False,
@@ -973,22 +955,12 @@ class MailSettingsForm(SettingsForm):
'tickets, the following email will be sent out to the attendees.'),
required=False,
)
mail_subject_order_paid_attendee = I18nFormField(
label=_("Subject sent to attendees"),
required=False,
widget=I18nTextInput,
)
mail_text_order_paid_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
)
mail_subject_order_free = I18nFormField(
label=_("Subject sent to order contact address"),
required=False,
widget=I18nTextInput,
)
mail_text_order_free = I18nFormField(
label=_("Text sent to order contact address"),
required=False,
@@ -1000,47 +972,22 @@ class MailSettingsForm(SettingsForm):
'tickets, the following email will be sent out to the attendees.'),
required=False,
)
mail_subject_order_free_attendee = I18nFormField(
label=_("Subject sent to attendees"),
required=False,
widget=I18nTextInput,
)
mail_text_order_free_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
)
mail_subject_order_changed = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
)
mail_text_order_changed = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
)
mail_subject_resend_link = I18nFormField(
label=_("Subject (sent by admin)"),
required=False,
widget=I18nTextInput,
)
mail_subject_resend_link_attendee = I18nFormField(
label=_("Subject (sent by admin to attendee)"),
required=False,
widget=I18nTextInput,
)
mail_text_resend_link = I18nFormField(
label=_("Text (sent by admin)"),
required=False,
widget=I18nTextarea,
)
mail_subject_resend_all_links = I18nFormField(
label=_("Subject (requested by user)"),
required=False,
widget=I18nTextInput,
)
mail_text_resend_all_links = I18nFormField(
label=_("Text (requested by user)"),
required=False,
@@ -1058,31 +1005,11 @@ class MailSettingsForm(SettingsForm):
required=False,
widget=I18nTextarea,
)
mail_subject_order_expire_warning = I18nFormField(
label=_("Subject (if order will expire automatically)"),
required=False,
widget=I18nTextInput,
)
mail_subject_order_pending_warning = I18nFormField(
label=_("Subject (if order will not expire automatically)"),
required=False,
widget=I18nTextInput,
)
mail_subject_waiting_list = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
)
mail_text_waiting_list = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
)
mail_subject_order_canceled = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
)
mail_text_order_canceled = I18nFormField(
label=_("Text"),
required=False,
@@ -1093,11 +1020,6 @@ class MailSettingsForm(SettingsForm):
required=False,
widget=I18nTextarea,
)
mail_subject_download_reminder = I18nFormField(
label=_("Subject sent to order contact address"),
required=False,
widget=I18nTextInput,
)
mail_text_download_reminder = I18nFormField(
label=_("Text sent to order contact address"),
required=False,
@@ -1109,11 +1031,6 @@ class MailSettingsForm(SettingsForm):
'tickets, the following email will be sent out to the attendees.'),
required=False,
)
mail_subject_download_reminder_attendee = I18nFormField(
label=_("Subject sent to attendees"),
required=False,
widget=I18nTextInput,
)
mail_text_download_reminder_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
@@ -1126,90 +1043,50 @@ class MailSettingsForm(SettingsForm):
help_text=_("This email will be sent out this many days before the order event starts. If the "
"field is empty, the mail will never be sent.")
)
mail_subject_order_placed_require_approval = I18nFormField(
label=_("Subject for received order"),
required=False,
widget=I18nTextInput,
)
mail_text_order_placed_require_approval = I18nFormField(
label=_("Text for received order"),
label=_("Received order"),
required=False,
widget=I18nTextarea,
)
mail_subject_order_approved = I18nFormField(
label=_("Subject for approved order"),
required=False,
widget=I18nTextInput,
)
mail_text_order_approved = I18nFormField(
label=_("Text for approved order"),
label=_("Approved order"),
required=False,
widget=I18nTextarea,
help_text=_("This will only be sent out for non-free orders. Free orders will receive the free order "
"template from below instead."),
)
mail_subject_order_approved_free = I18nFormField(
label=_("Subject for approved free order"),
required=False,
widget=I18nTextInput,
)
mail_text_order_approved_free = I18nFormField(
label=_("Text for approved free order"),
label=_("Approved free order"),
required=False,
widget=I18nTextarea,
help_text=_("This will only be sent out for free orders. Non-free orders will receive the non-free order "
"template from above instead."),
)
mail_subject_order_denied = I18nFormField(
label=_("Subject for denied order"),
required=False,
widget=I18nTextInput,
)
mail_text_order_denied = I18nFormField(
label=_("Text for denied order"),
label=_("Denied order"),
required=False,
widget=I18nTextarea,
)
base_context = {
'mail_text_order_placed': ['event', 'order', 'payments'],
'mail_subject_order_placed': ['event', 'order', 'payments'],
'mail_text_order_placed': ['event', 'order', 'payment'],
'mail_text_order_placed_attendee': ['event', 'order', 'position'],
'mail_subject_order_placed_attendee': ['event', 'order', 'position'],
'mail_text_order_placed_require_approval': ['event', 'order'],
'mail_subject_order_placed_require_approval': ['event', 'order'],
'mail_text_order_approved': ['event', 'order'],
'mail_subject_order_approved': ['event', 'order'],
'mail_text_order_approved_free': ['event', 'order'],
'mail_subject_order_approved_free': ['event', 'order'],
'mail_text_order_denied': ['event', 'order', 'comment'],
'mail_subject_order_denied': ['event', 'order', 'comment'],
'mail_text_order_paid': ['event', 'order', 'payment_info'],
'mail_subject_order_paid': ['event', 'order', 'payment_info'],
'mail_text_order_paid_attendee': ['event', 'order', 'position'],
'mail_subject_order_paid_attendee': ['event', 'order', 'position'],
'mail_text_order_free': ['event', 'order'],
'mail_subject_order_free': ['event', 'order'],
'mail_text_order_free_attendee': ['event', 'order', 'position'],
'mail_subject_order_free_attendee': ['event', 'order', 'position'],
'mail_text_order_changed': ['event', 'order'],
'mail_subject_order_changed': ['event', 'order'],
'mail_text_order_canceled': ['event', 'order', 'comment'],
'mail_subject_order_canceled': ['event', 'order', 'comment'],
'mail_text_order_expire_warning': ['event', 'order'],
'mail_subject_order_expire_warning': ['event', 'order'],
'mail_subject_order_pending_warning': ['event', 'order'],
'mail_text_order_custom_mail': ['event', 'order'],
'mail_text_download_reminder': ['event', 'order'],
'mail_subject_download_reminder': ['event', 'order'],
'mail_text_download_reminder_attendee': ['event', 'order', 'position'],
'mail_subject_download_reminder_attendee': ['event', 'order', 'position'],
'mail_text_resend_link': ['event', 'order'],
'mail_subject_resend_link': ['event', 'order'],
'mail_subject_resend_link_attendee': ['event', 'order'],
'mail_text_waiting_list': ['event', 'waiting_list_entry'],
'mail_subject_waiting_list': ['event', 'waiting_list_entry'],
'mail_text_resend_all_links': ['event', 'orders'],
'mail_subject_resend_all_links': ['event', 'orders'],
'mail_attach_ical_description': ['event', 'event_or_subevent'],
}

View File

@@ -77,31 +77,6 @@ def get_all_payment_providers():
if PAYMENT_PROVIDERS:
return PAYMENT_PROVIDERS
class FakeSettings:
def __init__(self, orig_settings):
self.orig_settings = orig_settings
def set(self, *args, **kwargs):
pass
def __getattr__(self, item):
return getattr(self.orig_settings, item)
class FakeEvent:
def __init__(self, orig_event):
self.orig_event = orig_event
@property
def settings(self):
return FakeSettings(self.orig_event.settings)
def __getattr__(self, item):
return getattr(self.orig_event, item)
@property
def __class__(self): # hackhack
return Event
with rolledback_transaction():
event = Event.objects.create(
plugins=",".join([app.name for app in apps.get_app_configs()]),
@@ -109,7 +84,6 @@ def get_all_payment_providers():
date_from=now(),
organizer=Organizer.objects.create(name="INTERNAL")
)
event = FakeEvent(event)
provs = register_payment_providers.send(
sender=event
)
@@ -792,12 +766,6 @@ class OrderSearchFilterForm(OrderFilterForm):
)
)
def use_query_hack(self):
return (
self.cleaned_data.get('query') or
self.cleaned_data.get('status') in ('overpaid', 'partially_paid', 'underpaid', 'pendingpaid')
)
def filter_qs(self, qs):
fdata = self.cleaned_data
qs = super().filter_qs(qs)
@@ -838,8 +806,7 @@ class OrderSearchFilterForm(OrderFilterForm):
# We ignore superuser permissions here. This is intentional we do not want to show super
# users a form with all meta properties ever assigned.
return EventMetaProperty.objects.filter(
organizer_id__in=self.request.user.teams.values_list('organizer', flat=True),
filter_allowed=True,
organizer_id__in=self.request.user.teams.values_list('organizer', flat=True)
)
@@ -1578,13 +1545,12 @@ class EventFilterForm(FilterForm):
@cached_property
def meta_properties(self):
if self.organizer:
return self.organizer.meta_properties.filter(filter_allowed=True)
return self.organizer.meta_properties.all()
else:
# We ignore superuser permissions here. This is intentional we do not want to show super
# users a form with all meta properties ever assigned.
return EventMetaProperty.objects.filter(
organizer_id__in=self.request.user.teams.values_list('organizer', flat=True),
filter_allowed=True,
organizer_id__in=self.request.user.teams.values_list('organizer', flat=True)
)

View File

@@ -38,13 +38,10 @@ from decimal import Decimal
from urllib.parse import urlencode
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile
from django.db.models import Max
from django.forms.formsets import DELETION_FIELD_NAME
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import (
@@ -64,8 +61,7 @@ from pretix.base.models import (
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data
from pretix.control.forms import (
ItemMultipleChoiceField, SizeValidationMixin, SplitDateTimeField,
SplitDateTimePickerWidget,
ItemMultipleChoiceField, SplitDateTimeField, SplitDateTimePickerWidget,
)
from pretix.control.forms.widgets import Select2
from pretix.helpers.models import modelcopy
@@ -437,21 +433,12 @@ class ItemCreateForm(I18nModelForm):
v.pk = None
v.item = instance
v.save()
for mv in variation.meta_values.all():
mv.pk = None
mv.variation = v
mv.save(force_insert=True)
else:
ItemVariation.objects.create(
item=instance, value=__('Standard')
)
if self.cleaned_data.get('copy_from'):
for mv in self.cleaned_data['copy_from'].meta_values.all():
mv.pk = None
mv.item = instance
mv.save(force_insert=True)
for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance)
question.log_action('pretix.event.question.changed', user=self.user, data={
@@ -597,14 +584,6 @@ class ItemUpdateForm(I18nModelForm):
)
return d
def clean_picture(self):
value = self.cleaned_data.get('picture')
if isinstance(value, UploadedFile) and value.size > settings.FILE_UPLOAD_MAX_SIZE_IMAGE:
raise forms.ValidationError(_("Please do not upload files larger than {size}!").format(
size=SizeValidationMixin._sizeof_fmt(settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
))
return value
class Meta:
model = Item
localized_fields = '__all__'
@@ -737,31 +716,6 @@ class ItemVariationForm(I18nModelForm):
del self.fields['require_membership']
del self.fields['require_membership_types']
self.meta_fields = []
meta_defaults = {}
if self.instance.pk:
for mv in self.instance.meta_values.all():
meta_defaults[mv.property_id] = mv.value
for p in self.meta_properties:
self.initial[f'meta_{p.name}'] = meta_defaults.get(p.pk)
self.fields[f'meta_{p.name}'] = forms.CharField(
label=p.name,
widget=forms.TextInput(
attrs={
'placeholder': _('Use value from product'),
'data-typeahead-url': reverse('control:event.items.meta.typeahead', kwargs={
'organizer': self.event.organizer.slug,
'event': self.event.slug
}) + '?' + urlencode({
'property': p.name,
}),
},
),
required=False,
)
self.meta_fields.append(f'meta_{p.name}')
class Meta:
model = ItemVariation
localized_fields = '__all__'
@@ -792,26 +746,6 @@ class ItemVariationForm(I18nModelForm):
}),
}
def save(self, commit=True):
instance = super().save(commit)
self.meta_fields = []
current_values = {v.property_id: v for v in instance.meta_values.all()}
for p in self.meta_properties:
if self.cleaned_data[f'meta_{p.name}']:
if p.pk in current_values:
current_values[p.pk].value = self.cleaned_data[f'meta_{p.name}']
current_values[p.pk].save()
else:
instance.meta_values.create(property=p, value=self.cleaned_data[f'meta_{p.name}'])
elif p.pk in current_values:
current_values[p.pk].delete()
@property
def meta_properties(self):
if not hasattr(self.event, '_cached_item_meta_properties'):
self.event._cached_item_meta_properties = self.event.item_meta_properties.all()
return self.event._cached_item_meta_properties
class ItemAddOnsFormSet(I18nFormSet):
title = _('Add-ons')
@@ -834,6 +768,10 @@ class ItemAddOnsFormSet(I18nFormSet):
if self._should_delete_form(form):
# This form is going to be deleted so any of its errors
# should not cause the entire formset to be invalid.
try:
categories.remove(form.cleaned_data['addon_category'].pk)
except KeyError:
pass
continue
if 'addon_category' in form.cleaned_data:
@@ -900,7 +838,6 @@ class ItemBundleFormSet(I18nFormSet):
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
kwargs['item'] = self.item
kwargs['item_qs'] = self.item_qs
return super()._construct_form(i, **kwargs)
@property
@@ -912,17 +849,12 @@ class ItemBundleFormSet(I18nFormSet):
empty_permitted=True,
use_required_attribute=False,
locales=self.locales,
item_qs=self.item_qs,
item=self.item,
event=self.event
)
self.add_fields(form, None)
return form
@cached_property
def item_qs(self):
return self.event.items.prefetch_related('variations').all()
def clean(self):
super().clean()
ivs = set()
@@ -950,7 +882,6 @@ class ItemBundleForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.item = kwargs.pop('item')
self.item_qs = kwargs.pop('item_qs')
super().__init__(*args, **kwargs)
instance = kwargs.get('instance', None)
initial = kwargs.get('initial', {})
@@ -968,7 +899,7 @@ class ItemBundleForm(I18nModelForm):
super().__init__(*args, **kwargs)
choices = []
for i in self.item_qs:
for i in self.event.items.prefetch_related('variations').all():
pname = str(i)
if not i.is_available():
pname += ' ({})'.format(_('inactive'))

View File

@@ -158,7 +158,7 @@ class CancelForm(ForceQuotaConfirmationForm):
localize=True,
label=_('Keep a cancellation fee of'),
help_text=_('If you keep a fee, all positions within this order will be canceled and the order will be reduced '
'to a cancellation fee. Payment and shipping fees will be canceled as well, so include them '
'to a paid cancellation fee. Payment and shipping fees will be canceled as well, so include them '
'in your cancellation fee if you want to keep them. Please always enter a gross value, '
'tax will be calculated automatically.'),
)
@@ -176,19 +176,23 @@ class CancelForm(ForceQuotaConfirmationForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency)
self.fields['cancellation_fee'].widget.attrs['placeholder'] = floatformat(
Decimal('0.00'),
settings.CURRENCY_PLACES.get(self.instance.event.currency, 2)
)
self.fields['cancellation_fee'].max_value = self.instance.total
prs = self.instance.payment_refund_sum
if prs > 0:
change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency)
self.fields['cancellation_fee'].widget.attrs['placeholder'] = floatformat(
Decimal('0.00'),
settings.CURRENCY_PLACES.get(self.instance.event.currency, 2)
)
self.fields['cancellation_fee'].max_value = prs
else:
del self.fields['cancellation_fee']
if not self.instance.invoices.exists():
del self.fields['cancel_invoice']
def clean_cancellation_fee(self):
val = self.cleaned_data['cancellation_fee'] or Decimal('0.00')
if val > self.instance.total:
raise ValidationError(_('The cancellation fee cannot be higher than the total amount of this order.'))
if val > self.instance.payment_refund_sum:
raise ValidationError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
return val

View File

@@ -45,9 +45,7 @@ from django.utils.crypto import get_random_string
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import (
I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
)
from i18nfield.forms import I18nFormField, I18nFormSetMixin, I18nTextarea
from phonenumber_field.formfields import PhoneNumberField
from pytz import common_timezones
@@ -182,7 +180,7 @@ class OrganizerUpdateForm(OrganizerForm):
class EventMetaPropertyForm(forms.ModelForm):
class Meta:
model = EventMetaProperty
fields = ['name', 'default', 'required', 'protected', 'allowed_values', 'filter_allowed']
fields = ['name', 'default', 'required', 'protected', 'allowed_values']
widgets = {
'default': forms.TextInput()
}
@@ -459,31 +457,16 @@ class MailSettingsForm(SettingsForm):
}}
)
mail_subject_customer_registration = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
)
mail_text_customer_registration = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
)
mail_subject_customer_email_change = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
)
mail_text_customer_email_change = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
)
mail_subject_customer_reset = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
)
mail_text_customer_reset = I18nFormField(
label=_("Text"),
required=False,
@@ -492,11 +475,8 @@ class MailSettingsForm(SettingsForm):
base_context = {
'mail_text_customer_registration': ['customer', 'url'],
'mail_subject_customer_registration': ['customer', 'url'],
'mail_text_customer_email_change': ['customer', 'url'],
'mail_subject_customer_email_change': ['customer', 'url'],
'mail_text_customer_reset': ['customer', 'url'],
'mail_subject_customer_reset': ['customer', 'url'],
}
def _get_sample_context(self, base_parameters):

View File

@@ -72,7 +72,7 @@ class VoucherForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
'comment', 'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
]
field_classes = {
'valid_until': SplitDateTimeField,
@@ -308,7 +308,7 @@ class VoucherBulkForm(VoucherForm):
localized_fields = '__all__'
fields = [
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
]
field_classes = {
'valid_until': SplitDateTimeField,
@@ -345,11 +345,8 @@ class VoucherBulkForm(VoucherForm):
if ',' in raw or ';' in raw:
if '@' in r[0]:
raise ValidationError(_('CSV input needs to contain a header row in the first line.'))
try:
dialect = csv.Sniffer().sniff(raw[:1024])
reader = csv.DictReader(StringIO(raw), dialect=dialect)
except csv.Error as e:
raise ValidationError(_('CSV parsing failed: {error}.').format(error=str(e)))
dialect = csv.Sniffer().sniff(raw[:1024])
reader = csv.DictReader(StringIO(raw), dialect=dialect)
if 'email' not in reader.fieldnames:
raise ValidationError(_('CSV input needs to contain a field with the header "{header}".').format(header="email"))
unknown_fields = [f for f in reader.fieldnames if f not in ('email', 'name', 'tag', 'number')]

View File

@@ -1,56 +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.urls import reverse
from django_scopes.forms import SafeModelChoiceField
from pretix.base.forms import I18nModelForm
from pretix.base.models import WaitingListEntry
from pretix.control.forms.widgets import Select2
class WaitingListEntryTransferForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.event.has_subevents:
self.fields['subevent'].required = True
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
class Meta:
model = WaitingListEntry
fields = [
'subevent',
]
field_classes = {
'subevent': SafeModelChoiceField,
}

View File

@@ -487,7 +487,6 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.'),
'pretix.event.orders.waitinglist.deleted': _('An entry has been removed from the waiting list.'),
'pretix.event.orders.waitinglist.transferred': _('An entry has been transferred to another waiting list.'),
'pretix.event.orders.waitinglist.changed': _('An entry has been changed on the waiting list.'),
'pretix.event.orders.waitinglist.added': _('An entry has been added to the waiting list.'),
'pretix.team.created': _('The team has been created.'),

View File

@@ -11,7 +11,7 @@
{% trans "You configured your account to require authentication with a second medium, e.g. your phone. Please enter your verification code here:" %}
</p>
<div class="form-group">
<input class="form-control" name="token" placeholder="{% trans "Token" %}" autocomplete="one-time-code"
<input class="form-control" name="token" placeholder="{% trans "Token" %}"
type="text" required="required" autofocus="autofocus" id="webauthn-response">
</div>
<div class="sr-only alert alert-danger" id="webauthn-error">

View File

@@ -116,12 +116,8 @@
</form>
{{ items|json_script:"items" }}
{% if DEBUG %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
{% else %}
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
{% endif %}
{% compress js %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3.v6.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-color.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-dispatch.v2.js" %}"></script>
@@ -132,8 +128,6 @@
<script type="text/javascript" src="{% static "d3/d3-transition.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
{% endcompress %}
{% compress js %}
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/datetimefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/timefield.vue' %}"></script>

View File

@@ -11,9 +11,6 @@
<legend>{% trans "Unpaid or free orders" %}</legend>
{% bootstrap_field form.cancel_allow_user layout="control" %}
{% bootstrap_field form.cancel_allow_user_until layout="control" %}
{% bootstrap_field form.cancel_allow_user_unpaid_keep layout="control" %}
{% bootstrap_field form.cancel_allow_user_unpaid_keep_percentage layout="control" %}
{% bootstrap_field form.cancel_allow_user_unpaid_keep_fees layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Paid orders" %}</legend>

View File

@@ -88,37 +88,37 @@
<h4>{% trans "Text" %}</h4>
<div class="panel-group" id="questions_group">
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_subject_order_placed,mail_text_order_placed,mail_send_order_placed_attendee,mail_subject_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed,mail_send_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
{% blocktrans asvar title_paid_order %}Paid order{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_subject_order_paid,mail_text_order_paid,mail_send_order_paid_attendee,mail_subject_order_paid_attendee,mail_text_order_paid_attendee" exclude="mail_send_order_paid_attendee" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_text_order_paid,mail_send_order_paid_attendee,mail_text_order_paid_attendee" exclude="mail_send_order_paid_attendee" %}
{% blocktrans asvar title_free_order %}Free order{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_subject_order_free,mail_text_order_free,mail_send_order_free_attendee,mail_subject_order_free_attendee,mail_text_order_free_attendee" exclude="mail_send_order_free_attendee" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_text_order_free,mail_send_order_free_attendee,mail_text_order_free_attendee" exclude="mail_send_order_free_attendee" %}
{% blocktrans asvar title_resend_link %}Resend link{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="resend_link" title=title_resend_link items="mail_subject_resend_link,mail_subject_resend_link_attendee,mail_text_resend_link,mail_subject_resend_all_links,mail_text_resend_all_links" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="resend_link" title=title_resend_link items="mail_text_resend_link,mail_text_resend_all_links" %}
{% blocktrans asvar title_order_changed %}Order changed{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_subject_order_changed,mail_text_order_changed" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_text_order_changed" %}
{% blocktrans asvar title_payment_reminder %}Payment reminder{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_subject_order_expire_warning,mail_subject_order_pending_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
{% blocktrans asvar title_waiting_list_notification %}Waiting list notification{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_subject_waiting_list,mail_text_waiting_list" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_text_waiting_list" %}
{% blocktrans asvar title_order_canceled %}Order canceled{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_canceled" title=title_order_canceled items="mail_subject_order_canceled,mail_text_order_canceled" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_canceled" title=title_order_canceled items="mail_text_order_canceled" %}
{% blocktrans asvar title_order_custom_mail %}Order custom mail{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="custom_mail" title=title_order_custom_mail items="mail_text_order_custom_mail" %}
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_subject_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_subject_download_reminder_attendee,mail_text_download_reminder_attendee,mail_sales_channel_download_reminder" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee,mail_sales_channel_download_reminder" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_text_download_reminder_attendee,mail_sales_channel_download_reminder" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee,mail_sales_channel_download_reminder" %}
{% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_subject_order_placed_require_approval,mail_text_order_placed_require_approval,mail_subject_order_approved,mail_text_order_approved,mail_subject_order_approved_free,mail_text_order_approved_free,mail_subject_order_denied,mail_text_order_denied" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_approved_free,mail_text_order_denied" %}
</div>
<h4>{% trans "Attachments" %}</h4>
{% bootstrap_field form.mail_attachment_new_order layout="control" %}

View File

@@ -1,5 +1,10 @@
{% load i18n %}
{% load rich_text %}
{{ request.event.settings.payment_giftcard_public_description|rich_text }}
<p>
{% blocktrans %}
If you have a gift card, please enter the gift card code here. If the gift card does not have
enough credit to pay for the full order, you will be shown this page again and you can either
redeem another gift card or select a different payment method for the difference.
{% endblocktrans %}
</p>
<input name="giftcard" class="form-control" placeholder="{% trans "Gift card code" %}">

View File

@@ -1,7 +1,8 @@
{% load i18n %}
<p>
{% blocktrans trimmed with card=info_data.gift_card_secret %}
Your gift card {{ card }} will be used to pay for this order.
{% blocktrans %}
Your gift card will be used to pay for this order. If the credit on the gift card is lower than the order total, you will be able to pay the
difference with a different payment method. If the credit is higher than the order total, you will be able to re-use the gift card in the future.
{% endblocktrans %}
</p>

View File

@@ -1,7 +1,6 @@
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% load getitem %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" id="item_variations">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
@@ -30,20 +29,16 @@
{% endif %}
</div>
<div class="col-md-2 col-xs-6">
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Only available in a limited timeframe" %}"></span>
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Only visible with a voucher" %}"></span>
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Require a valid membership" %}"></span>
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden" data-toggle="tooltip" title="{% trans "Only available in a limited timeframe" %}"></span>
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden" data-toggle="tooltip"
title="{% trans "Only visible with a voucher" %}"></span>
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden" data-toggle="tooltip"
title="{% trans "Require a valid membership" %}"></span>
</div>
<div class="col-md-2 col-xs-6">
{% for k, c in sales_channels.items %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden"
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
{% endfor %}
</div>
<div class="col-md-1 col-xs-6 text-right flip variation-price">
@@ -74,27 +69,6 @@
{% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.description layout="control" %}
{% if form.meta_fields %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>
<div class="col-md-9">
{% for fname in form.meta_fields %}
{% with form|getitem:fname as field %}
<div class="row">
<div class="col-md-4">
<label for="{{ field.id_for_label }}">
{{ field.label }}
</label>
</div>
<div class="col-md-8">
{% bootstrap_field field layout="inline" %}
</div>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endif %}
{% bootstrap_field form.available_from layout="control" %}
{% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %}
@@ -136,20 +110,16 @@
{% endif %}
</div>
<div class="col-md-2 col-xs-6">
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Only available in a limited timeframe" %}"></span>
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Only visible with a voucher" %}"></span>
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Require a valid membership" %}"></span>
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden" data-toggle="tooltip" title="{% trans "Only available in a limited timeframe" %}"></span>
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden" data-toggle="tooltip"
title="{% trans "Only visible with a voucher" %}"></span>
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden" data-toggle="tooltip"
title="{% trans "Require a valid membership" %}"></span>
</div>
<div class="col-md-2 col-xs-6">
{% for k, c in sales_channels.items %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden"
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
{% endfor %}
</div>
<div class="col-md-1 col-xs-6 text-right flip variation-price">
@@ -171,27 +141,6 @@
{% bootstrap_field formset.empty_form.default_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field formset.empty_form.original_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field formset.empty_form.description layout="control" %}
{% if formset.empty_form.meta_fields %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>
<div class="col-md-9">
{% for fname in formset.empty_form.meta_fields %}
{% with formset.empty_form|getitem:fname as field %}
<div class="row">
<div class="col-md-4">
<label for="{{ field.id_for_label }}">
{{ field.label }}
</label>
</div>
<div class="col-md-8">
{% bootstrap_field field layout="inline" %}
</div>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endif %}
{% bootstrap_field formset.empty_form.available_from layout="control" %}
{% bootstrap_field formset.empty_form.available_until layout="control" %}
{% bootstrap_field formset.empty_form.sales_channels layout="control" %}

View File

@@ -190,13 +190,7 @@
</dd>
{% if order.status == "n" %}
<dt>{% trans "Expiry date" %}</dt>
<dd>
{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}
{% if has_cancellation_fee and request.event.settings.payment_term_expire_automatically %}
<span class="fa fa-warning text-danger" data-toggle="tooltip"
title="{% trans "This order will not expire automatically as it has an open cancellation fee." %}"></span>
{% endif %}
</dd>
<dd>{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %}
{% if request.organizer.settings.customer_accounts %}
<dt>{% trans "Customer account" %}</dt>

View File

@@ -22,72 +22,54 @@
</h3>
</div>
<div class="panel-body">
<form action="" method="post" class="row">
<form action="" method="post">
{% csrf_token %}
<dl class="dl-horizontal col-lg-6 col-sm-12">
<dt>{% trans "Customer ID" %}</dt>
<dd>#{{ customer.identifier }}</dd>
{% if customer.provider %}
<dt>{% trans "SSO provider" %}</dt>
<dd>{{ customer.provider.name }}</dd>
{% endif %}
{% if customer.external_identifier %}
<dt>{% trans "External identifier" %}</dt>
<dd>{{ customer.external_identifier }}</dd>
{% endif %}
<dt>{% trans "Status" %}</dt>
<dd>
{% if not customer.is_active %}
{% trans "disabled" %}
{% elif not customer.is_verified %}
{% trans "not yet activated" %}
{% else %}
{% trans "active" %}
{% endif %}
</dd>
<dt>{% trans "E-mail" %}</dt>
<dd>
{{ customer.email|default_if_none:"" }}
{% if customer.email and not customer.provider %}
<button type="submit" name="action" value="pwreset" class="btn btn-xs btn-default">
{% trans "Send password reset link" %}
</button>
{% endif %}
</dd>
<dt>{% trans "Name" %}</dt>
<dd>{{ customer.name }}</dd>
{% if customer.phone %}
<dt>{% trans "Phone" %}</dt>
<dd>{{ customer.phone }}</dd>
<dl class="dl-horizontal">
<dt>{% trans "Customer ID" %}</dt>
<dd>#{{ customer.identifier }}</dd>
{% if customer.provider %}
<dt>{% trans "SSO provider" %}</dt>
<dd>{{ customer.provider.name }}</dd>
{% endif %}
<dt>{% trans "Locale" %}</dt>
<dd>{{ display_locale }}</dd>
<dt>{% trans "Registration date" %}</dt>
<dd>{{ customer.date_joined|date:"SHORT_DATETIME_FORMAT" }}</dd>
<dt>{% trans "Last login" %}</dt>
<dd>{% if customer.last_login %}{{ customer.last_login|date:"SHORT_DATETIME_FORMAT" }}{% else %}
{% endif %}</dd>
{% if customer.notes %}
<dt>{% trans "Notes" %}</dt>
<dd>{{ customer.notes|linebreaks }}</dd>
{% if customer.external_identifier %}
<dt>{% trans "External identifier" %}</dt>
<dd>{{ customer.external_identifier }}</dd>
{% endif %}
</dl>
<dl class="col-lg-6 col-sm-12 text-right">
<dt class="text-muted"
data-toggle="tooltip"
title="{% trans "This includes all paid orders by this customer across all your events." %}">
{% trans "Lifetime spending" %}
</dt>
{% if lifetime_spending %}
{% for s in lifetime_spending %}
{% if s.spending >= 0 %}
<dd class="text-success text-h3">{{ s.spending|money:s.currency }}</dd>
{% elif s.spending < 0 %}
<dd class="text-error text-h3">{{ s.spending|money:s.currency }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>
{% if not customer.is_active %}
{% trans "disabled" %}
{% elif not customer.is_verified %}
{% trans "not yet activated" %}
{% else %}
{% trans "active" %}
{% endif %}
{% endfor %}
{% else %}
<dd class="text-muted text-h3">{{ 0|floatformat:2 }}</dd>
</dd>
<dt>{% trans "E-mail" %}</dt>
<dd>
{{ customer.email|default_if_none:"" }}
{% if customer.email and not customer.provider %}
<button type="submit" name="action" value="pwreset" class="btn btn-xs btn-default">
{% trans "Send password reset link" %}
</button>
{% endif %}
</dd>
<dt>{% trans "Name" %}</dt>
<dd>{{ customer.name }}</dd>
{% if customer.phone %}
<dt>{% trans "Phone" %}</dt>
<dd>{{ customer.phone }}</dd>
{% endif %}
<dt>{% trans "Locale" %}</dt>
<dd>{{ display_locale }}</dd>
<dt>{% trans "Registration date" %}</dt>
<dd>{{ customer.date_joined|date:"SHORT_DATETIME_FORMAT" }}</dd>
<dt>{% trans "Last login" %}</dt>
<dd>{% if customer.last_login %}{{ customer.last_login|date:"SHORT_DATETIME_FORMAT" }}{% else %}
{% endif %}</dd>
{% if customer.notes %}
<dt>{% trans "Notes" %}</dt>
<dd>{{ customer.notes|linebreaks }}</dd>
{% endif %}
</dl>
</form>

View File

@@ -58,13 +58,13 @@
<legend>{% trans "E-mail content" %}</legend>
<div class="panel-group" id="questions_group">
{% blocktrans asvar title_customer_registration %}Customer account registration{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="customer_registration" title=title_customer_registration items="mail_subject_customer_registration,mail_text_customer_registration" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="customer_registration" title=title_customer_registration items="mail_text_customer_registration" %}
{% blocktrans asvar title_email_change %}Customer account email change{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="email_change" title=title_email_change items="mail_subject_customer_email_change,mail_text_customer_email_change" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="email_change" title=title_email_change items="mail_text_customer_email_change" %}
{% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_subject_customer_reset,mail_text_customer_reset" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_text_customer_reset" %}
</div>
</fieldset>
</div>

Some files were not shown because too many files have changed in this diff Show More