Compare commits

..

5 Commits

Author SHA1 Message Date
Richard Schreiber
bbca2f6c40 remove console.log 2022-08-05 11:04:09 +02:00
Richard Schreiber
6c0f19a1f6 use event.target instead of this 2022-08-05 11:01:23 +02:00
Richard Schreiber
37454dd84e add prefill fix to qr-code as well 2022-08-04 17:55:39 +02:00
Richard Schreiber
9bba61a495 use content from other if switching between other/other-i18n 2022-08-04 17:13:31 +02:00
Richard Schreiber
4cd4b94a4b set toolbox other/other_i18n input.value to empty string if not needed 2022-08-04 17:12:57 +02:00
430 changed files with 198349 additions and 294501 deletions

23
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,23 @@
---
name: Bug report
about: Please only create issues for bug reports. Feature requests or general questions
should start as a "Discussion" on GitHub.
title: ''
labels: ''
assignees: ''
---
<!-- Please only create issues for bug reports. Feature requests or general questions should start as a "Discussion" on GitHub. -->
**Describe the bug**
A clear and concise description of what the bug is.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View File

@@ -1,53 +0,0 @@
name: Bug report
description: Please only create issues for bug reports. Feature requests or general questions should start as a "Discussion" on GitHub.
body:
- type: markdown
attributes:
value: Please make sure to search our issues for similar bugs first! If bug has been reported already, react with a thumbs-up, and/or leave a comment providing further details.
- type: textarea
id: current
attributes:
label: Problem and impact
description: What problem you're running into? What impact does it have on you / your event?
placeholder: When trying to do ____, pretix suddenly shows me an error saying "...".
- type: textarea
id: expected
attributes:
label: Expected behaviour
description: Sometimes bugs are subtle and the expected behaviour may need some explanation. Leave empty if it's just "Don't be broken."
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: "Please give as much context as possible: Are there any settings that impact this behaviour?"
placeholder: |
1.
2.
3.
4.
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If possible, show screenshots of the problem.
- type: input
id: link
attributes:
label: Link
description: Link to the page where the bug occurs
- type: input
id: browser
attributes:
label: Browser (software, desktop or mobile?) and version
description: Leave empty for backend problems
- type: input
id: os
attributes:
label: Operating system, dependency versions
description: Leave empty for frontend problems
- type: input
id: version
attributes:
label: Version
description: The pretix version in use. (Leave empty if unknown.)

View File

@@ -1,8 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Community Support
url: https://github.com/pretix/pretix/discussions/categories/q-a
about: Not sure how to do Y? Please post your support requests in the Q&A section of our GitHub Discussions instead!
- name: Feature ideas
url: https://github.com/pretix/pretix/discussions/categories/ideas
about: Please post your idea in the Ideas section of our GitHub Discussions instead!

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

@@ -0,0 +1 @@
-r doc/requirements.txt

View File

@@ -1,15 +0,0 @@
version: 2
sphinx:
configuration: doc/conf.py
build:
os: ubuntu-22.04
tools:
python: "3.8"
nodejs: "16"
apt_packages:
- gettext
python:
install:
- method: pip
path: ./src/
- requirements: doc/requirements.rtd.txt

View File

@@ -1,20 +0,0 @@
# Security policy
## Reporting a vulnerability
If you discover a vulnerability with our software or server systems, please report it to us in private. Do not to attempt to harm our users, customer's data or our system's availability when looking for vulneratbilities.
Please contact us at security@pretix.eu with full details and steps to reproduce and allow reasonable time for us to resolve the issue before publishing your findings. If you wish to encrypt your email, you can find our GPG key [here](https://pretix.eu/.well-known/security@pretix.eu.asc).
We're not large enough to run a formal bug bounty program, but if you find a serious vulnerability in our service, we will find a way to show our gratitude.
## Version support
Security support is provided for the current stable release as well as the two previous stable releases.
Be sure to keep your pretix installation up to date.
New releases and security issues will be announced on our [blog](https://pretix.eu/about/en/blog/). If you
subscribe to our [newsletter](https://pretix.eu/about/en/blog/) in the "News about self-hosting pretix"
category, we will also send you an email on security issues.
Past security issues are listed [on our website](https://pretix.eu/about/en/security).

View File

@@ -2,7 +2,6 @@
file=/tmp/supervisor.sock
[supervisord]
environment = AUTOMIGRATE="skip"
logfile=/dev/stdout
logfile_maxbytes=0
loglevel=info

View File

@@ -6067,10 +6067,6 @@ url('../opensans_regular_macroman/OpenSans-Regular-webfont.svg#open_sansregular'
img.screenshot, a.screenshot img {
box-shadow: 0 4px 18px 0 rgba(0,0,0,0.1), 0 6px 20px 0 rgba(0,0,0,0.09);
}
section > a.screenshot {
display: block;
margin-bottom: 24px;
}
/* Changes */
.versionchanged {

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

@@ -105,37 +105,6 @@ following endpoint:
You will receive a response equivalent to the response of your initialization request.
Device Information
------------------
You can request information about your device and the server with one call:
.. sourcecode:: http
GET /api/v1/device/info HTTP/1.1
Host: pretix.eu
The response will look like this:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"device": {
"organizer": "foo",
"device_id": 5,
"unique_serial": "HHZ9LW9JWP390VFZ",
"api_token": "1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd",
"name": "Bar",
"gate": {
"id": 3,
"name": "South entrance"
}
}
}
Creating a new API key
----------------------

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

@@ -17,8 +17,8 @@ The cart position resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the cart position
cart_id string Identifier of the cart this belongs to, needs to end
in "@api" for API-created positions
cart_id string Identifier of the cart this belongs to. Needs to end
in "@api" for API-created positions.
datetime datetime Time of creation
expires datetime The cart position will expire at this time and no longer block quota
item integer ID of the item
@@ -29,23 +29,22 @@ attendee_name_parts object of strings Composition of
attendee_email string Specified attendee email address for this position (or ``null``)
voucher integer Internal ID of the voucher used for this position (or ``null``)
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
is_bundled boolean If ``addon_to`` is set, this shows whether this is a bundled product or an addon product
subevent integer ID of the date inside an event series this position belongs to (or ``null``)
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
answers list of objects Answers to user-defined questions
├ question integer Internal ID of the answered question
├ answer string Text representation of the answer
├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat (or ``null``)
seat objects The assigned seat. Can be ``null``.
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
===================================== ========================== =======================================================
.. versionchanged:: 4.14
.. versionchanged:: 3.0
This ``is_bundled`` attribute has been added and the cart creation endpoints have been updated.
This ``seat`` attribute has been added.
Cart position endpoints
@@ -88,7 +87,6 @@ Cart position endpoints
"attendee_email": null,
"voucher": null,
"addon_to": null,
"is_bundled": false,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
@@ -135,7 +133,6 @@ Cart position endpoints
"attendee_email": null,
"voucher": null,
"addon_to": null,
"is_bundled": false,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
@@ -171,7 +168,7 @@ Cart position endpoints
* does not validate if the event's ticket sales are already over or haven't started
* does not validate constraints on add-on products at the moment
* does not support add-on products at the moment
* does not check or calculate prices but believes any prices you send
@@ -179,8 +176,6 @@ Cart position endpoints
* does not support file upload questions
Note that more validation might be added in the future, so please do not rely on missing validation.
You can supply the following fields of the resource:
* ``cart_id`` (optional, needs to end in ``@api``)
@@ -195,8 +190,6 @@ Cart position endpoints
* ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
* ``sales_channel`` (optional)
* ``voucher`` (optional, expect a voucher code)
* ``addons`` (optional, expect a list of nested objects of cart positions)
* ``bundled`` (optional, expect a list of nested objects of cart positions)
* ``answers``
* ``question``
@@ -228,12 +221,6 @@ Cart position endpoints
"options": []
}
],
"addons": [
{
"item": 2,
"variation": null,
}
],
"subevent": null
}
@@ -245,7 +232,7 @@ Cart position endpoints
Vary: Accept
Content-Type: application/json
(Full cart position resource, see above, with additional nested objects "addons" and "bundled".)
(Full cart position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event to create a position for
:param event: The ``slug`` field of the event to create a position for
@@ -257,8 +244,8 @@ Cart position endpoints
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/bulk_create/
Creates multiple new cart position. **This operation is deliberately not atomic, so each cart position can succeed
or fail individually, so the response code of the response is not the only thing to look at!**
Creates multiple new cart position. This operation is deliberately not atomic, so each cart position can succeed
or fail individually, so the response code of the response is not the only thing to look at!
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.

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

@@ -14,10 +14,7 @@ The customer resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
identifier string Internal ID of the customer
external_identifier string External ID of the customer (or ``null``). This field can
be changed for customers created manually or through
the API, but is read-only for customers created through a
SSO integration.
external_identifier string External ID of the customer (or ``null``)
email string Customer email address
name string Name of this customer (or ``null``)
name_parts object of strings Decomposition of name (i.e. given name, family name)
@@ -29,16 +26,10 @@ date_joined datetime Date and time o
locale string Preferred language of the customer
last_modified datetime Date and time of modification of the record
notes string Internal notes and comments (or ``null``)
password string Can only be set during creation of a new customer, will
not be included in any responses.
===================================== ========================== =======================================================
.. versionadded:: 4.0
.. versionchanged:: 4.3
Passwords can now be set through the API during customer creation.
Endpoints
---------
@@ -155,7 +146,6 @@ Endpoints
{
"email": "test@example.org",
"password": "verysecret",
"send_email": true
}

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.
@@ -1879,7 +1925,7 @@ otherwise, such as splitting an order or changing fees.
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/change/ HTTP/1.1
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
@@ -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

@@ -36,16 +36,10 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.order.canceled``
* ``pretix.event.order.reactivated``
* ``pretix.event.order.expired``
* ``pretix.event.order.expirychanged``
* ``pretix.event.order.modified``
* ``pretix.event.order.contact.changed``
* ``pretix.event.order.changed.*``
* ``pretix.event.order.refund.created``
* ``pretix.event.order.refund.created.externally``
* ``pretix.event.order.refund.requested``
* ``pretix.event.order.refund.done``
* ``pretix.event.order.refund.canceled``
* ``pretix.event.order.refund.failed``
* ``pretix.event.order.approved``
* ``pretix.event.order.denied``
* ``pretix.event.checkin``
@@ -56,10 +50,6 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.subevent.added``
* ``pretix.subevent.changed``
* ``pretix.subevent.deleted``
* ``pretix.event.live.activated``
* ``pretix.event.live.deactivated``
* ``pretix.event.testmode.activated``
* ``pretix.event.testmode.deactivated``
Installed plugins might register more valid values.

View File

@@ -92,10 +92,9 @@ If any other status code is returned, we will assume you did not receive the cal
or ``304 Not Modified`` response will be treated as a failure. pretix will not follow any ``301`` or ``302`` redirect
headers and pretix will ignore all other information in your response headers or body.
If we do not receive a status code in the range of ``200`` and ``299`` or do not receive any response within a 30 second
time frame, pretix will retry to deliver for up to three days with an exponential back off. Therefore, we recommend that
you implement your endpoint in a way where calling it multiple times for the same event due to a perceived error does
not do any harm.
If we do not receive a status code in the range of ``200`` and ``299``, pretix will retry to deliver for up to three
days with an exponential back off. Therefore, we recommend that you implement your endpoint in a way where calling it
multiple times for the same event due to a perceived error does not do any harm.
There is only one exception: If status code ``410 Gone`` is returned, we will assume the
endpoint does not exist any more and automatically disable the webhook.

View File

@@ -60,13 +60,7 @@ The exporter class
.. py:attribute:: BaseExporter.event
The default constructor sets this property to the event we are currently
working for. This will be ``None`` if the exporter is run for multiple
events.
.. py:attribute:: BaseExporter.events
The default constructor sets this property to the list of events to work
on, regardless of whether the exporter is called for one or multiple events.
working for.
.. autoattribute:: identifier

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,10 +91,8 @@ 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``.
description multi-lingual string A public description of the item. May contain Markdown
syntax and is not required.
available_from datetime The first date time at which this content will be shown
@@ -146,7 +144,6 @@ API Endpoints
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"file": null,
"description": {
"en": "Watch our event live here on YouTube!"
},
@@ -194,7 +191,6 @@ API Endpoints
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"file": null,
"description": {
"en": "Watch our event live here on YouTube!"
},
@@ -233,7 +229,6 @@ API Endpoints
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"file": null,
"description": {
"en": "Watch our event live here on YouTube!"
},
@@ -260,7 +255,6 @@ API Endpoints
},
"content_type": "link",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"file": null,
"description": {
"en": "Watch our event live here on YouTube!"
},
@@ -315,7 +309,6 @@ API Endpoints
},
"content_type": "link",
"url": "https://mywebsite.com",
"file": null,
"description": {
"en": "Watch our event live here on YouTube!"
},

View File

@@ -1,10 +0,0 @@
sphinx==2.3.*
jinja2==3.0.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
sphinxcontrib-images
sphinxcontrib-spelling==4.*
sphinxemoji
pygments-markdown-lexer
# See https://github.com/rfk/pyenchant/pull/130
git+https://github.com/raphaelm/pyenchant.git@patch-1#egg=pyenchant

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -66,7 +66,6 @@ iterable
Jimdo
jwt
JWT
JWTs
libpretixprint
libsass
linters
@@ -89,9 +88,7 @@ nginx
nodejs
NotificationType
npm
OIDC
ons
OpenID
optimizations
overpayment
param
@@ -136,7 +133,6 @@ serializer
serializers
sexualized
SQL
SSO
startup
stdout
stylesheet
@@ -163,8 +159,6 @@ untrusted
uptime
username
url
URI
URIs
validator
versa
versioning

View File

@@ -1,210 +0,0 @@
.. _customers:
Customer accounts
=================
By default, pretix only offers guest checkout, i.e. ticket buyers do not sign up and sign back in, but create a new
checkout session every time. In some situations it may be convenient to allow ticket buyers to create
accounts that they can later log in to again. Working with customer accounts is even required for some advanced
use cases such as described in the :ref:`seasontickets` article.
Enabling customer accounts
--------------------------
To enable customer accounts, head to your organizer page in the backend and then select "Settings" → "General" →
"Customer accounts" and turn on the checkbox "Allow customers to create accounts".
Using the other settings on the same tab you can fine-tune how the customer account system behaves:
.. thumbnail:: ../../screens/organizer/edit_customer.png
:align: center
:class: screenshot
Allow customers to log in with email address and password
In all simple setups, this option should be checked. If this checkbox is removed, it is impossible to log in or
sign up unless you connect a SSO provider (see below).
Match orders based on email address
If this option is selected, customers will see orders made with their email address within their account even if
they did not make those orders while logged in.
Name format, Allowed titles
This controls how we'll ask your customers for their name, similar to the respective settings on event level.
Managing customer accounts
--------------------------
After customer accounts have been enabled, you will find a new menu option "Customer accounts" in the organizer-level
main menu. The first sub-item, "Customers", allows you to search and inspect the list of your customer accounts, as well
as to create a new customer account from the backend:
.. thumbnail:: ../../screens/organizer/customers.png
:align: center
:class: screenshot
If you click on a customer ID, you can see all details of this customer account, including registration information,
active memberships, past ticket orders, and account history:
.. thumbnail:: ../../screens/organizer/customer.png
:align: center
:class: screenshot
You can also perform various actions from this view, such as:
- Send a password reset link
- Change registration information
- Anonymize the customer account (does not anonymize connected orders)
When creating or changing a customer, you will be presented with the following form:
.. thumbnail:: ../../screens/organizer/customer_edit.png
:align: center
:class: screenshot
Most fields, such as name, e-mail address, phone number, and language should be self-explanatory. The following fields
might require some explanation:
Account active
If this checkbox is removed, the customer will not be able to log in.
External identifier
This field can be used to cross-reference your customer database with other sources. For example, if the customer
already has a number in another system, you can insert that number here. This can be especially powerful if you
use our API for synchronization with an external system.
Verified email address
This checkbox signifies whether you have verified that this customer in fact controls the given email address.
This will automatically be checked after a successful registration or after a successful password reset. Before it
is checked, the customer will not be able to log in. You should usually not modify this field manually.
Notes
Entries in this field will only be visible to you and your team, not to the customer.
Single-Sign-On (SSO)
--------------------
"Single-Sign-On" (SSO) is a technical term for a situation in which a person can log in to multiple systems using just
one login. This can be convenient if you have multiple applications that are exposed to your customers: They won't have
to remember multiple passwords or understand how your application landscape is structured, they can just always log in
with the same credentials whenever they see your brand.
In this scenario, pretix can be **either** the "SSO provider" **or** the "SSO client".
If pretix is the SSO provider, pretix will be the central source of truth for your customer accounts and your other
applications can connect to pretix to use pretix's login functionality.
If pretix is the SSO client, one of your existing systems will be the source of truth for the customer accounts and
pretix will use that system's login functionality.
All SSO support for customer accounts in pretix is currently built on the `OpenID Connect`_ standard, a modern and
widely accepted standard for SSO in all industries.
Connecting SSO clients (pretix as the SSO provider)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To connect an external application as a SSO client, go to "Customer accounts" → "SSO clients" → "Create a new SSO client"
in your organizer account.
.. thumbnail:: ../../screens/organizer/customer_ssoclient_add.png
:align: center
:class: screenshot
You will need to fill out the following fields:
Active
If this checkbox is removed, the SSO client can not be used.
Application name
The name of your external application, e.g. "digital event marketplace".
Client type
For a server-side application which is able to store a secret that will be inaccessible to end users, chose
"confidential". For a client-side application, such as many mobile apps, choose "public".
Grant type
This value depends on the OpenID Connect implementation of your software.
Redirection URIs
One or multiple URIs that the user might be redirected to after the successful or failed login.
Allowed access scopes
The types of data the SSO client may access about the customer.
After you submitted all data, you will receive a client ID as well as a client secret. The client secret is shown
in the green success message and will only ever be shown once. If you need it again, use the option "Invalidate old
client secret and generate a new one".
You will need the client ID and client secret to configure your external application. The application will also likely
need some other information from you, such as your **issuer URI**. If you use pretix Hosted and your organizer account
does not have a custom domain, your issuer will be ``https://pretix.eu/myorgname``, where ``myorgname`` is the short
form of your organizer account. If you use a custom domain, such as ``tickets.mycompany.net``, then your issuer will be
``https://tickets.mycompany.net``.
Technical details
"""""""""""""""""
We implement `OpenID Connect Core 1.0`_, except for some optional parts that do not make sense for pretix or bring no
additional value. For example, we do not currently support encrypted tokens, offline access, refresh tokens, or passing
request parameters as JWTs.
We implement the provider metadata section from `OpenID Connect Discovery 1.0`_. You can find the endpoint relative
to the issuer URI as described above, for example ``http://pretix.eu/demo/.well-known/openid-configuration``.
We implement all three OpenID Connect Core flows:
- Authorization Code Flow (response type ``code``)
- Implicit Flow (response types ``id_token token`` and ``id_token``)
- Hybrid Flow (response types ``code id_token``, ``code id_token token``, and ``code token``)
We implement the response modes ``query`` and ``fragment``.
We currently offer the following scopes: ``openid``, ``profile``, ``email``, ``phone``
As well as the following standardized claims: ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time``, ``nonce``, ``c_hash``,
``at_hash``, ``sub``, ``locale``, ``name``, ``given_name``, ``family_name``, ``middle_name``, ``nickname``, ``email``,
``email_verified``, ``phone_number``.
The various endpoints are located relative to the issuer URI as described above:
- Authorization: ``<issuer>/oauth2/v1/authorize``
- Token: ``<issuer>/oauth2/v1/token``
- User info: ``<issuer>/oauth2/v1/userinfo``
- Keys: ``<issuer>/oauth2/v1/keys``
We currently do not reproduce their documentation here as they follow the OpenID Connect and OAuth specifications
without any special behavior.
Connecting SSO providers (pretix as the SSO client)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To connect an external application as a SSO client, go to "Customer accounts" → "SSO providers" → "Create a new SSO provider"
in your organizer account.
.. thumbnail:: ../../screens/organizer/customer_ssoprovider_add.png
:align: center
:class: screenshot
The "Provider name" and "Login button label" is what we'll use to show the new login option to the user. For the actual
connection, we will require information such as the issuer URL, client ID, client secret, scope, and field (or claim)
names that you will receive from your SSO provider.
.. note::
If you want your customers to *only* use your SSO provider, it makes sense to turn off the "Allow customers to log in
with email address and password" settings option (see above).
Technical details
"""""""""""""""""
We assume that SSO providers fulfill the following requirements:
- Implementation according to `OpenID Connect Core 1.0`_.
- Published meta-data document at ``<issuer>/.well-known/openid-configuration`` as specified in `OpenID Connect Discovery 1.0`_.
- Support for Authorization code flow (``response_type=code``) with ``response_mode=query``.
- Support for client authentication using client ID and client secret and without public key cryptography.
.. _OpenID Connect: https://en.wikipedia.org/wiki/OpenID#OpenID_Connect_(OIDC)
.. _OpenID Connect Core 1.0: https://openid.net/specs/openid-connect-core-1_0.html
.. _OpenID Connect Discovery 1.0: https://openid.net/specs/openid-connect-discovery-1_0.html

View File

@@ -87,18 +87,6 @@ website. If you confident to have a good reason for not using SSL, you can overr
<pretix-widget event="https://pretix.eu/demo/democon/" skip-ssl-check></pretix-widget>
Seating plans
-------------
By default, events with seating plans just show a button that opens the seating plan. You can also have the seating
plan embedded into the widget directly by using::
<pretix-widget event="https://pretix.eu/demo/democon/" seating-embedded></pretix-widget>
Note that the seating plan will only be embedded if the widget has enough space (currently min. 992 pixels width, may change
in the future) and that the seating plan part of the widget can unfortunately *not* be styled with CSS like the rest of
the widget.
Always open a new tab
---------------------
@@ -423,7 +411,7 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
};
</script>
If you use ``analytics.js`` (Universal Analytics)::
If you use ```analytics.js` (Universal Analytics)::
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
@@ -459,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

@@ -12,7 +12,6 @@ wanting to use pretix to sell tickets.
events/settings
events/structureguide
events/widget
customers/index
events/giftcards
faq
markdown

6
readthedocs.yml Normal file
View File

@@ -0,0 +1,6 @@
build:
image: latest
python:
version: 3.6

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.13.0.dev0"

View File

@@ -46,7 +46,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('GET', 'api-v1:device.info'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -81,7 +80,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('GET', 'api-v1:device.info'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -114,7 +112,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('GET', 'api-v1:device.info'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -148,7 +145,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('GET', 'api-v1:device.info'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -196,7 +192,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,29 +0,0 @@
# Generated by Django 3.2.12 on 2022-09-13 14:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0218_checkinlist_addon_match'),
('pretixapi', '0007_alter_webhookcall_target_url'),
]
operations = [
migrations.CreateModel(
name='WebHookCallRetry',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('retry_not_before', models.DateTimeField(auto_now_add=True)),
('retry_count', models.PositiveIntegerField(default=0)),
('action_type', models.CharField(max_length=255)),
('logentry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhook_retries', to='pretixbase.logentry')),
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='retries', to='pretixapi.webhook')),
],
options={
'unique_together': {('webhook', 'logentry')},
},
),
]

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,
)
@@ -147,18 +133,6 @@ class WebHookCall(models.Model):
ordering = ("-datetime",)
class WebHookCallRetry(models.Model):
id = models.BigAutoField(primary_key=True)
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='retries')
logentry = models.ForeignKey('pretixbase.LogEntry', on_delete=models.CASCADE, related_name='webhook_retries')
retry_not_before = models.DateTimeField(auto_now_add=True)
retry_count = models.PositiveIntegerField(default=0)
action_type = models.CharField(max_length=255)
class Meta:
unique_together = (('webhook', 'logentry'),)
class ApiCall(models.Model):
idempotency_key = models.CharField(max_length=190, db_index=True)
auth_hash = models.CharField(max_length=190, db_index=True)

View File

@@ -23,7 +23,8 @@ import os
from datetime import timedelta
from django.core.files import File
from django.db.models import prefetch_related_objects
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
from rest_framework import serializers
@@ -33,7 +34,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
)
from pretix.base.models import Seat, Voucher
from pretix.base.models import Quota, Seat, Voucher
from pretix.base.models.orders import CartPosition
@@ -51,18 +52,148 @@ class CartPositionSerializer(I18nAwareModelSerializer):
model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers', 'seat', 'is_bundled')
'answers', 'seat')
class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
class CartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.CharField(required=False, default='sales_channel')
includes_tax = serializers.BooleanField(required=False, allow_null=True)
voucher = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
fields = ('item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'includes_tax', 'answers')
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel', 'voucher')
def create(self, validated_data):
answers_data = validated_data.pop('answers')
if not validated_data.get('cart_id'):
cid = "{}@api".format(get_random_string(48))
while CartPosition.objects.filter(cart_id=cid).exists():
cid = "{}@api".format(get_random_string(48))
validated_data['cart_id'] = cid
if not validated_data.get('expires'):
validated_data['expires'] = now() + timedelta(
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
)
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
if validated_data.get('variation')
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
if len(new_quotas) == 0:
raise ValidationError(
gettext_lazy('The product "{}" is not assigned to a quota.').format(
str(validated_data.get('item'))
)
)
for quota in new_quotas:
avail = quota.availability(_cache=self.context['quota_cache'])
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
raise ValidationError(
gettext_lazy('There is not enough quota available on quota "{}" to perform '
'the operation.').format(
quota.name
)
)
for quota in new_quotas:
oldsize = self.context['quota_cache'][quota.pk][1]
newsize = oldsize - 1 if oldsize is not None else None
self.context['quota_cache'][quota.pk] = (
Quota.AVAILABILITY_OK if newsize is None or newsize > 0 else Quota.AVAILABILITY_GONE,
newsize
)
attendee_name = validated_data.pop('attendee_name', '')
if attendee_name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
if validated_data.get('seat'):
if not seated:
raise ValidationError('The specified product does not allow to choose a seat.')
try:
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError('The specified seat does not exist.')
except Seat.MultipleObjectsReturned:
raise ValidationError('The specified seat ID is not unique.')
else:
validated_data['seat'] = seat
elif seated:
raise ValidationError('The specified product requires to choose a seat.')
if validated_data.get('voucher'):
try:
voucher = self.context['event'].vouchers.get(code__iexact=validated_data.get('voucher'))
except Voucher.DoesNotExist:
raise ValidationError('The specified voucher does not exist.')
if voucher and not voucher.applies_to(validated_data.get('item'), validated_data.get('variation')):
raise ValidationError('The specified voucher is not valid for the given item and variation.')
if voucher and voucher.seat and voucher.seat != validated_data.get('seat'):
raise ValidationError('The specified voucher is not valid for this seat.')
if voucher and voucher.subevent_id and (not validated_data.get('subevent') or voucher.subevent_id != validated_data['subevent'].pk):
raise ValidationError('The specified voucher is not valid for this subevent.')
if voucher.valid_until is not None and voucher.valid_until < now():
raise ValidationError('The specified voucher is expired.')
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=voucher) & Q(event=self.context['event']) & Q(expires__gte=now())
)
cart_count = redeemed_in_carts.count()
v_avail = voucher.max_usages - voucher.redeemed - cart_count
if v_avail < 1:
raise ValidationError('The specified voucher has already been used the maximum number of times.')
validated_data['voucher'] = voucher
if validated_data.get('seat'):
if not validated_data['seat'].is_available(
sales_channel=validated_data.get('sales_channel', 'web'),
distance_ignore_cart_id=validated_data['cart_id'],
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
):
raise ValidationError(
gettext_lazy('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name))
validated_data.pop('sales_channel')
# todo: does this make sense?
validated_data['custom_price_input'] = validated_data['price']
# todo: listed price, etc?
# currently does not matter because there is no way to transform an API cart position into an order that keeps
# prices, cart positions are just quota/voucher placeholders
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
options = answ_data.pop('options')
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
answ = cp.answers.create(**answ_data, answer='')
answ.file.save(os.path.basename(an.name), an, save=False)
answ.answer = 'file://' + answ.file.name
answ.save()
an.close()
else:
answ = cp.answers.create(**answ_data)
answ.options.add(*options)
return cp
def validate_cart_id(self, cid):
if cid and not cid.endswith('@api'):
raise ValidationError('Cart ID should end in @api or be empty.')
return cid
def validate_item(self, item):
if item.event != self.context['event']:
@@ -109,180 +240,4 @@ class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if not data.get('expires'):
data['expires'] = now() + timedelta(
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
)
quotas_for_item_cache = self.context.get('quotas_for_item_cache', {})
quotas_for_variation_cache = self.context.get('quotas_for_variation_cache', {})
seated = data.get('item').seat_category_mappings.filter(subevent=data.get('subevent')).exists()
if data.get('seat'):
if not seated:
raise ValidationError({'seat': ['The specified product does not allow to choose a seat.']})
try:
seat = self.context['event'].seats.get(seat_guid=data['seat'], subevent=data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError({'seat': ['The specified seat does not exist.']})
except Seat.MultipleObjectsReturned:
raise ValidationError({'seat': ['The specified seat ID is not unique.']})
else:
data['seat'] = seat
elif seated:
raise ValidationError({'seat': ['The specified product requires to choose a seat.']})
if data.get('voucher'):
try:
voucher = self.context['event'].vouchers.get(code__iexact=data['voucher'])
except Voucher.DoesNotExist:
raise ValidationError({'voucher': ['The specified voucher does not exist.']})
if voucher and not voucher.applies_to(data['item'], data.get('variation')):
raise ValidationError({'voucher': ['The specified voucher is not valid for the given item and variation.']})
if voucher and voucher.seat and voucher.seat != data.get('seat'):
raise ValidationError({'voucher': ['The specified voucher is not valid for this seat.']})
if voucher and voucher.subevent_id and (not data.get('subevent') or voucher.subevent_id != data['subevent'].pk):
raise ValidationError({'voucher': ['The specified voucher is not valid for this subevent.']})
if voucher.valid_until is not None and voucher.valid_until < now():
raise ValidationError({'voucher': ['The specified voucher is expired.']})
data['voucher'] = voucher
if not data.get('voucher') or (not data['voucher'].allow_ignore_quota and not data['voucher'].block_quota):
if data.get('variation'):
if data['variation'].pk not in quotas_for_variation_cache:
quotas_for_variation_cache[data['variation'].pk] = data['variation'].quotas.filter(subevent=data.get('subevent'))
data['_quotas'] = quotas_for_variation_cache[data['variation'].pk]
else:
if data['item'].pk not in quotas_for_item_cache:
quotas_for_item_cache[data['item'].pk] = data['item'].quotas.filter(subevent=data.get('subevent'))
data['_quotas'] = quotas_for_item_cache[data['item'].pk]
if len(data['_quotas']) == 0:
raise ValidationError(
gettext_lazy('The product "{}" is not assigned to a quota.').format(
str(data.get('item'))
)
)
else:
data['_quotas'] = []
return data
def create(self, validated_data):
validated_data.pop('_quotas')
answers_data = validated_data.pop('answers')
attendee_name = validated_data.pop('attendee_name', '')
if attendee_name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
# todo: does this make sense?
validated_data['custom_price_input'] = validated_data['price']
# todo: listed price, etc?
# currently does not matter because there is no way to transform an API cart position into an order that keeps
# prices, cart positions are just quota/voucher placeholders
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
options = answ_data.pop('options')
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
answ = cp.answers.create(**answ_data, answer='')
answ.file.save(os.path.basename(an.name), an, save=False)
answ.answer = 'file://' + answ.file.name
answ.save()
an.close()
else:
answ = cp.answers.create(**answ_data)
answ.options.add(*options)
return cp
class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
expires = serializers.DateTimeField(required=False)
addons = BaseCartPositionCreateSerializer(many=True, required=False)
bundled = BaseCartPositionCreateSerializer(many=True, required=False)
seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.CharField(required=False, default='sales_channel')
voucher = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
fields = BaseCartPositionCreateSerializer.Meta.fields + (
'cart_id', 'expires', 'addons', 'bundled', 'seat', 'sales_channel', 'voucher'
)
def validate_cart_id(self, cid):
if cid and not cid.endswith('@api'):
raise ValidationError('Cart ID should end in @api or be empty.')
return cid
def create(self, validated_data):
validated_data.pop('sales_channel')
addons_data = validated_data.pop('addons', None)
bundled_data = validated_data.pop('bundled', None)
cp = super().create(validated_data)
if addons_data:
for addon_data in addons_data:
addon_data['addon_to'] = cp
addon_data['is_bundled'] = False
addon_data['cart_id'] = cp.cart_id
super().create(addon_data)
if bundled_data:
for bundle_data in bundled_data:
bundle_data['addon_to'] = cp
bundle_data['is_bundled'] = True
bundle_data['cart_id'] = cp.cart_id
super().create(bundle_data)
return cp
def validate(self, data):
data = super().validate(data)
# This is currently only a very basic validation of add-ons and bundled products, we don't validate their number
# or price. We can always go stricter, as the endpoint is documented as experimental.
# However, this serializer should always be *at least* as strict as the order creation serializer.
if data.get('item') and data.get('addons'):
prefetch_related_objects([data['item']], 'addons')
for sub_data in data['addons']:
if not any(a.addon_category_id == sub_data['item'].category_id for a in data['item'].addons.all()):
raise ValidationError({
'addons': [
'The product "{prod}" can not be used as an add-on product for "{main}".'.format(
prod=str(sub_data['item']),
main=str(data['item']),
)
]
})
if data.get('item') and data.get('bundled'):
prefetch_related_objects([data['item']], 'bundles')
for sub_data in data['bundled']:
if not any(
a.bundled_item_id == sub_data['item'].pk and
a.bundled_variation_id == (sub_data['variation'].pk if sub_data.get('variation') else None)
for a in data['item'].bundles.all()
):
raise ValidationError({
'bundled': [
'The product "{prod}" can not be used as an bundled product for "{main}".'.format(
prod=str(sub_data['item']),
main=str(data['item']),
)
]
})
return data

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

@@ -23,8 +23,6 @@ from django import forms
from django.http import QueryDict
from rest_framework import serializers
from pretix.base.exporter import OrganizerLevelExportMixin
class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs):
@@ -51,6 +49,7 @@ simple_mappings = (
(forms.EmailField, serializers.EmailField, ()),
(forms.UUIDField, serializers.UUIDField, ()),
(forms.URLField, serializers.URLField, ()),
(forms.NullBooleanField, serializers.NullBooleanField, ()),
(forms.BooleanField, serializers.BooleanField, ()),
)
@@ -88,7 +87,7 @@ class JobRunSerializer(serializers.Serializer):
ex = kwargs.pop('exporter')
events = kwargs.pop('events', None)
super().__init__(*args, **kwargs)
if events is not None and not isinstance(ex, OrganizerLevelExportMixin):
if events is not None:
self.fields["events"] = serializers.SlugRelatedField(
queryset=events,
required=True,
@@ -107,12 +106,6 @@ class JobRunSerializer(serializers.Serializer):
)
break
if isinstance(v, forms.NullBooleanField):
self.fields[k] = serializers.BooleanField(
required=v.required,
allow_null=True,
validators=v.validators,
)
if isinstance(v, forms.ModelMultipleChoiceField):
self.fields[k] = PrimaryKeyRelatedField(
queryset=v.queryset,

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):
@@ -1143,10 +1086,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if pos_data.get('addon_to'):
errs[i]['seat'] = ['Seats are currently not supported for add-on products.']
continue
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
@@ -1342,9 +1281,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if not simulate:
for cp in delete_cps:
if cp.addon_to_id:
continue
cp.addons.all().delete()
cp.delete()
order.total = sum([p.price for p in pos_map.values()])

View File

@@ -74,19 +74,13 @@ class CustomerSerializer(I18nAwareModelSerializer):
fields = ('identifier', 'external_identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
'locale', 'last_modified', 'notes')
def update(self, instance, validated_data):
if instance and instance.provider_id:
validated_data['external_identifier'] = instance.external_identifier
return super().update(instance, validated_data)
class CustomerCreateSerializer(CustomerSerializer):
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
password = serializers.CharField(write_only=True, required=False, allow_null=True)
class Meta:
model = Customer
fields = CustomerSerializer.Meta.fields + ('send_email', 'password')
fields = CustomerSerializer.Meta.fields + ('send_email',)
class MembershipTypeSerializer(I18nAwareModelSerializer):
@@ -119,21 +113,20 @@ class GiftCardSerializer(I18nAwareModelSerializer):
def validate(self, data):
data = super().validate(data)
if 'secret' in data:
s = data['secret']
qs = GiftCard.objects.filter(
secret=s
).filter(
Q(issuer=self.context["organizer"]) | Q(
issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
s = data['secret']
qs = GiftCard.objects.filter(
secret=s
).filter(
Q(issuer=self.context["organizer"]) | Q(
issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise ValidationError(
{'secret': _(
'A gift card with the same secret already exists in your or an affiliated organizer account.')}
)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise ValidationError(
{'secret': _(
'A gift card with the same secret already exists in your or an affiliated organizer account.')}
)
return data
class Meta:
@@ -289,7 +282,6 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class OrganizerSettingsSerializer(SettingsSerializer):
default_fields = [
'customer_accounts',
'customer_accounts_native',
'customer_accounts_link_by_email',
'invoice_regenerate_allowed',
'contact_mail',

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
@@ -139,7 +138,6 @@ urlpatterns = [
re_path(r"^device/update$", device.UpdateView.as_view(), name="device.update"),
re_path(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
re_path(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
re_path(r"^device/info$", device.InfoView.as_view(), name="device.info"),
re_path(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
re_path(r"^idempotency_query$", idempotency.IdempotencyQueryView.as_view(), name="idempotency.query"),
re_path(r"^upload$", upload.UploadView.as_view(), name="upload"),

View File

@@ -19,28 +19,19 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from collections import Counter
from typing import List
from django.db import transaction
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
from rest_framework.response import Response
from rest_framework.serializers import as_serializer_error
from rest_framework.settings import api_settings
from pretix.api.serializers.cart import (
CartPositionCreateSerializer, CartPositionSerializer,
)
from pretix.base.models import CartPosition
from pretix.base.services.cart import (
_get_quota_availability, _get_voucher_availability, error_messages,
)
from pretix.base.services.locking import NoLockManager
@@ -63,17 +54,18 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['quotas_for_item_cache'] = {}
ctx['quotas_for_variation_cache'] = {}
ctx['quota_cache'] = {}
return ctx
def create(self, request, *args, **kwargs):
ctx = self.get_serializer_context()
serializer = CartPositionCreateSerializer(data=request.data, context=ctx)
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
results = self._create(serializers=[serializer], raise_exception=True, ctx=ctx)
with transaction.atomic(), self.request.event.lock():
self.perform_create(serializer)
cp = serializer.instance
serializer = CartPositionSerializer(cp, context=serializer.context)
headers = self.get_success_headers(serializer.data)
return Response(results[0]['data'], status=status.HTTP_201_CREATED, headers=headers)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@action(detail=False, methods=['POST'])
def bulk_create(self, request, *args, **kwargs):
@@ -81,163 +73,42 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
return Response({"error": "Please supply a list"}, status=status.HTTP_400_BAD_REQUEST)
ctx = self.get_serializer_context()
serializers = [
CartPositionCreateSerializer(data=d, context=ctx)
for d in request.data
]
with transaction.atomic():
serializers = [
CartPositionCreateSerializer(data=d, context=ctx)
for d in request.data
]
lockfn = self.request.event.lock
if not any(s.is_valid(raise_exception=False) for s in serializers):
lockfn = NoLockManager
results = []
with lockfn():
for s in serializers:
if s.is_valid(raise_exception=False):
try:
cp = s.save()
except ValidationError as e:
results.append({
'success': False,
'data': None,
'errors': {api_settings.NON_FIELD_ERRORS_KEY: e.detail},
})
else:
results.append({
'success': True,
'data': CartPositionSerializer(cp, context=ctx).data,
'errors': None,
})
else:
results.append({
'success': False,
'data': None,
'errors': s.errors,
})
results = self._create(serializers=serializers, raise_exception=False, ctx=ctx)
return Response({'results': results}, status=status.HTTP_200_OK)
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
return True
if quota_diff and any(q.size is not None for q in quota_diff):
# If any quotas are affected that are not unlimited, we lock
return True
return False
@cached_property
def _create_default_cart_id(self):
cid = "{}@api".format(get_random_string(48))
while CartPosition.objects.filter(cart_id=cid).exists():
cid = "{}@api".format(get_random_string(48))
return cid
def _create(self, serializers: List[CartPositionCreateSerializer], ctx, raise_exception=False):
voucher_use_diff = Counter()
quota_diff = Counter()
seat_diff = Counter()
results = [{} for pserializer in serializers]
for i, pserializer in enumerate(serializers):
if not pserializer.is_valid(raise_exception=raise_exception):
results[i] = {
'success': False,
'data': None,
'errors': pserializer.errors,
}
for pserializer in serializers:
if pserializer.errors:
continue
validated_data = pserializer.validated_data
if not validated_data.get('cart_id'):
validated_data['cart_id'] = self._create_default_cart_id
if validated_data.get('voucher'):
voucher_use_diff[validated_data['voucher']] += 1
if validated_data.get('seat'):
seat_diff[validated_data['seat']] += 1
for q in validated_data['_quotas']:
quota_diff[q] += 1
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
for q in sub_data['_quotas']:
quota_diff[q] += 1
seats_seen = set()
lockfn = NoLockManager
if self._require_locking(quota_diff, voucher_use_diff, seat_diff):
lockfn = self.request.event.lock
with lockfn() as now_dt, transaction.atomic():
vouchers_ok, vouchers_depend_on_cart = _get_voucher_availability(
self.request.event,
voucher_use_diff,
now_dt,
exclude_position_ids=[],
)
quotas_ok = _get_quota_availability(quota_diff, now_dt)
for i, pserializer in enumerate(serializers):
if results[i]:
continue
try:
validated_data = pserializer.validated_data
if validated_data.get('seat'):
# Assumption: Add-ons currently can't have seats
if validated_data['seat'] in seats_seen:
raise ValidationError(error_messages['seat_multiple'])
seats_seen.add(validated_data['seat'])
quotas_needed = Counter()
for q in validated_data['_quotas']:
quotas_needed[q] += 1
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
for q in sub_data['_quotas']:
quotas_needed[q] += 1
for q, needed in quotas_needed.items():
if quotas_ok[q] < needed:
raise ValidationError(
_('There is not enough quota available on quota "{}" to perform the operation.').format(
q.name
)
)
if validated_data.get('voucher'):
# Assumption: Add-ons currently can't have vouchers, thus we only need to check the main voucher
if vouchers_ok[validated_data['voucher']] < 1:
raise ValidationError(
{'voucher': [_('The specified voucher has already been used the maximum number of times.')]}
)
if validated_data.get('seat'):
# Assumption: Add-ons currently can't have seats, thus we only need to check the main product
if not validated_data['seat'].is_available(
sales_channel=validated_data.get('sales_channel', 'web'),
distance_ignore_cart_id=validated_data['cart_id'],
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
):
raise ValidationError(
{'seat': [_('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name)]}
)
for q, needed in quotas_needed.items():
quotas_ok[q] -= needed
if validated_data.get('voucher'):
vouchers_ok[validated_data['voucher']] -= 1
if any(qa < 0 for qa in quotas_ok.values()):
# Safeguard, should never happen because of conditions above
raise ValidationError(error_messages['unavailable'])
cp = pserializer.create(validated_data)
d = CartPositionSerializer(cp, context=ctx).data
addons = sorted(cp.addons.all(), key=lambda a: a.pk) # order of creation, safe since they are created in the same transaction
d['addons'] = CartPositionSerializer([a for a in addons if not a.is_bundled], many=True, context=ctx).data
d['bundled'] = CartPositionSerializer([a for a in addons if a.is_bundled], many=True, context=ctx).data
results[i] = {
'success': True,
'data': d,
'errors': None,
}
except ValidationError as e:
if raise_exception:
raise
results[i] = {
'success': False,
'data': None,
'errors': as_serializer_error(e),
}
return results
serializer.save()

View File

@@ -29,9 +29,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
from pretix import __version__
from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.api.views.version import numeric_version
from pretix.base.models import CheckinList, Device, SubEvent
from pretix.base.models.devices import Gate, generate_api_token
@@ -153,24 +151,6 @@ class RevokeKeyView(APIView):
return Response(serializer.data)
class InfoView(APIView):
authentication_classes = (DeviceTokenAuthentication,)
def get(self, request, format=None):
device = request.auth
serializer = DeviceSerializer(device)
return Response({
'device': serializer.data,
'server': {
'version': {
'pretix': __version__,
'pretix_numeric': numeric_version(__version__),
}
}
})
class EventSelectionView(APIView):
authentication_classes = (DeviceTokenAuthentication,)

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

@@ -35,8 +35,7 @@ from rest_framework.reverse import reverse
from pretix.api.serializers.exporters import (
ExporterSerializer, JobRunSerializer,
)
from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.models import CachedFile, Device, Event, TeamAPIToken
from pretix.base.models import CachedFile, Device, TeamAPIToken
from pretix.base.services.export import export, multiexport
from pretix.base.signals import (
register_data_exporters, register_multievent_data_exporters,
@@ -156,19 +155,7 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
organizer=self.request.organizer
)
responses = register_multievent_data_exporters.send(self.request.organizer)
raw_exporters = [
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else events, self.request.organizer)
for r, response in responses
if response
]
raw_exporters = [
ex for ex in raw_exporters
if (
not isinstance(ex, OrganizerLevelExportMixin) or
perm_holder.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request)
)
]
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
for ex in sorted([response(events, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex, events=events)
exporters.append(ex)
return exporters

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,12 @@ from pretix.api.serializers.orderchange import (
OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer,
)
from pretix.api.views import RichOrderingFilter
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue,
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 +190,6 @@ class OrderViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['exclude'] = self.request.query_params.getlist('exclude')
ctx['include'] = self.request.query_params.getlist('include')
return ctx
def get_queryset(self):
@@ -233,9 +230,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',
@@ -684,33 +679,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 +930,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 +992,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',
@@ -1618,17 +1604,6 @@ 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(

View File

@@ -515,8 +515,8 @@ class CustomerViewSet(viewsets.ModelViewSet):
raise MethodNotAllowed("Customers cannot be deleted.")
@transaction.atomic()
def perform_create(self, serializer, send_email=False, password=None):
customer = serializer.save(organizer=self.request.organizer, password=make_password(password))
def perform_create(self, serializer, send_email=False):
customer = serializer.save(organizer=self.request.organizer, password=make_password(None))
serializer.instance.log_action(
'pretix.customer.created',
user=self.request.user,
@@ -530,7 +530,7 @@ class CustomerViewSet(viewsets.ModelViewSet):
def create(self, request, *args, **kwargs):
serializer = CustomerCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
self.perform_create(serializer, send_email=serializer.validated_data.pop('send_email', False), password=serializer.validated_data.pop('password', None))
self.perform_create(serializer, send_email=serializer.validated_data.pop('send_email', False))
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

View File

@@ -23,24 +23,19 @@ import json
import logging
import time
from collections import OrderedDict
from datetime import timedelta
import requests
from django.db import DatabaseError, connection, transaction
from celery.exceptions import MaxRetriesExceededError
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import scope, scopes_disabled
from requests import RequestException
from pretix.api.models import (
WebHook, WebHookCall, WebHookCallRetry, WebHookEventListener,
)
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
from pretix.api.signals import register_webhook_events
from pretix.base.models import LogEntry
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.base.signals import periodic_task
from pretix.celery_app import app
logger = logging.getLogger(__name__)
@@ -224,10 +219,6 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.order.expired',
_('Order expired'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.expirychanged',
_('Order expiry date changed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.modified',
_('Order information changed'),
@@ -240,30 +231,10 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.order.changed.*',
_('Order changed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.created',
_('Refund of payment created'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.created.externally',
_('External refund of payment'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.requested',
_('Refund of payment requested by customer'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.done',
_('Refund of payment completed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.canceled',
_('Refund of payment canceled'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.refund.failed',
_('Refund of payment failed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.approved',
_('Order approved'),
@@ -304,22 +275,6 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.subevent.deleted',
pgettext_lazy('subevent', 'Event series date deleted'),
),
ParametrizedEventWebhookEvent(
'pretix.event.live.activated',
_('Shop taken live'),
),
ParametrizedEventWebhookEvent(
'pretix.event.live.deactivated',
_('Shop taken offline'),
),
ParametrizedEventWebhookEvent(
'pretix.event.testmode.activated',
_('Test-Mode of shop has been activated'),
),
ParametrizedEventWebhookEvent(
'pretix.event.testmode.deactivated',
_('Test-Mode of shop has been deactivated'),
),
)
@@ -361,163 +316,59 @@ def notify_webhooks(logentry_ids: list):
send_webhook.apply_async(args=(logentry.id, notification_type.action_type, wh.pk))
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=60, acks_late=True, autoretry_for=(DatabaseError,),)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int, retry_count: int = 0):
"""
Sends out a specific webhook using adequate retry and error handling logic.
Our retry logic is a little complex since we have different constraints here:
1. We historically documented that we retry for up to three days, so we want to keep that
promise. We want to use (approximately) exponentially increasing times to keep load
manageable.
2. We want to use Celery's ``acks_late=True`` options which prevents lost tasks if a worker
crashes.
3. A limitation of Celery's redis broker implementation is that it can not properly handle
tasks that *run or wait* longer than `visibility_timeout`, which defaults to 1h, when
``acks_late`` is enabled. So any task with a *retry interval* of >1h will be restarted
many times because celery believes the worker has crashed.
4. We do like that the first few retries happen within a few seconds to work around very
intermittent connectivity issues quickly. For the longer retries with multiple hours,
we don't care if they are emitted a few minutes too late.
We therefore have a two-phase retry process:
- For all retry intervals below 5 minutes, which is the first 3 retries currently, we
schedule a new celery task directly with an increased retry_count. We do *not* use
celery's retry() call currently to make the retry process in both phases more similar,
there should not be much of a difference though (except that the initial task will be in
SUCCESS state, but we don't check that status anywhere).
- For all retry intervals of at least 5 minutes, we create a database entry. Then, the
periodic task ``schedule_webhook_retries_on_celery`` will schedule celery tasks for them
once their time has come.
"""
retry_intervals = (
5, # + 5 seconds
30, # + 30 seconds
60, # + 1 minute
300, # + 5 minutes
1200, # + 20 minutes
3600, # + 60 minutes
1440, # + 4 hours
21600, # + 6 hours
43200, # + 12 hours
43200, # + 24 hours
86400, # + 24 hours
) # added up, these are approximately 3 days, as documented
retry_celery_cutoff = 300
@app.task(base=ProfiledTask, bind=True, max_retries=9, acks_late=True)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
# 9 retries with 2**(2*x) timing is roughly 72 hours
with scopes_disabled():
webhook = WebHook.objects.get(id=webhook_id)
with scope(organizer=webhook.organizer), transaction.atomic():
with scope(organizer=webhook.organizer):
logentry = LogEntry.all.get(id=logentry_id)
types = get_all_webhook_events()
event_type = types.get(action_type)
if not event_type or not webhook.enabled:
return 'obsolete-webhook' # Ignore, e.g. plugin not installed
return # Ignore, e.g. plugin not installed
payload = event_type.build_payload(logentry)
if payload is None:
# Content object deleted?
return 'obsolete-payload'
return
t = time.time()
try:
resp = requests.post(
webhook.target_url,
json=payload,
allow_redirects=False,
timeout=30,
)
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=resp.status_code,
payload=json.dumps(payload),
response_body=resp.text[:1024 * 1024],
success=200 <= resp.status_code <= 299
)
if resp.status_code == 410:
webhook.enabled = False
webhook.save()
return 'gone'
elif resp.status_code > 299:
if retry_count >= len(retry_intervals):
return 'retry-given-up'
elif retry_intervals[retry_count] < retry_celery_cutoff:
send_webhook.apply_async(args=(logentry_id, action_type, webhook_id, retry_count + 1),
countdown=retry_intervals[retry_count])
return 'retry-via-celery'
else:
webhook.retries.update_or_create(
logentry=logentry,
defaults=dict(
retry_not_before=now() + timedelta(seconds=retry_intervals[retry_count]),
retry_count=retry_count + 1,
action_type=action_type,
),
)
return 'retry-via-db'
return 'ok'
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=0,
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
if retry_count >= len(retry_intervals):
return 'retry-given-up'
elif retry_intervals[retry_count] < retry_celery_cutoff:
send_webhook.apply_async(args=(logentry_id, action_type, webhook_id, retry_count + 1))
return 'retry-via-celery'
else:
webhook.retries.update_or_create(
logentry=logentry,
defaults=dict(
retry_not_before=now() + timedelta(seconds=retry_intervals[retry_count]),
retry_count=retry_count + 1,
action_type=action_type,
),
try:
resp = requests.post(
webhook.target_url,
json=payload,
allow_redirects=False
)
return 'retry-via-db'
@app.task(base=TransactionAwareTask)
def manually_retry_all_calls(webhook_id: int):
with scopes_disabled():
webhook = WebHook.objects.get(id=webhook_id)
with scope(organizer=webhook.organizer), transaction.atomic():
for whcr in webhook.retries.select_for_update(
skip_locked=connection.features.has_select_for_update_skip_locked
):
send_webhook.apply_async(
args=(whcr.logentry_id, whcr.action_type, whcr.webhook_id, whcr.retry_count),
)
whcr.delete()
@receiver(signal=periodic_task, dispatch_uid='pretixapi_schedule_webhook_retries_on_celery')
@scopes_disabled()
def schedule_webhook_retries_on_celery(sender, **kwargs):
with transaction.atomic():
for whcr in WebHookCallRetry.objects.select_for_update(
skip_locked=connection.features.has_select_for_update_skip_locked
).filter(retry_not_before__lt=now()):
send_webhook.apply_async(
args=(whcr.logentry_id, whcr.action_type, whcr.webhook_id, whcr.retry_count),
)
whcr.delete()
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=resp.status_code,
payload=json.dumps(payload),
response_body=resp.text[:1024 * 1024],
success=200 <= resp.status_code <= 299
)
if resp.status_code == 410:
webhook.enabled = False
webhook.save()
elif resp.status_code > 299:
raise self.retry(countdown=2 ** (self.request.retries * 2)) # max is 2 ** (8*2) = 65536 seconds = ~18 hours
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=0,
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
raise self.retry(countdown=2 ** (self.request.retries * 2)) # max is 2 ** (8*2) = 65536 seconds = ~18 hours
except MaxRetriesExceededError:
pass

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

@@ -1,21 +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/>.
#

View File

@@ -1,295 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import base64
import hashlib
import logging
import time
from datetime import datetime
from urllib.parse import urlencode, urljoin
import jwt
import requests
from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key
from cryptography.hazmat.primitives.serialization import (
Encoding, NoEncryption, PrivateFormat, PublicFormat,
)
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from requests import RequestException
from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger(__name__)
"""
This module contains utilities for implementing OpenID Connect for customer authentication both as a receiving party (RP)
as well as an OpenID Provider (OP).
"""
def _urljoin(base, path):
if not base.endswith("/"):
base += "/"
return urljoin(base, path)
def oidc_validate_and_complete_config(config):
for k in ("base_url", "client_id", "client_secret", "uid_field", "email_field", "scope"):
if not config.get(k):
raise ValidationError(_('Configuration option "{name}" is missing.').format(name=k))
conf_url = _urljoin(config["base_url"], ".well-known/openid-configuration")
try:
resp = requests.get(conf_url, timeout=10)
resp.raise_for_status()
provider_config = resp.json()
except RequestException as e:
raise ValidationError(_('Unable to retrieve configuration from "{url}". Error message: "{error}".').format(
url=conf_url,
error=str(e)
))
except ValueError as e:
raise ValidationError(_('Unable to retrieve configuration from "{url}". Error message: "{error}".').format(
url=conf_url,
error=str(e)
))
if not provider_config.get("authorization_endpoint"):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error="authorization_endpoint not set"
))
if not provider_config.get("userinfo_endpoint"):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error="userinfo_endpoint not set"
))
if not provider_config.get("token_endpoint"):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error="token_endpoint not set"
))
if "code" not in provider_config.get("response_types_supported", []):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error=f"provider supports response types {','.join(provider_config.get('response_types_supported', []))}, but we only support 'code'."
))
if "query" not in provider_config.get("response_modes_supported", ["query", "fragment"]):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error=f"provider supports response modes {','.join(provider_config.get('response_modes_supported', []))}, but we only support 'query'."
))
if "authorization_code" not in provider_config.get("grant_types_supported", ["authorization_code", "implicit"]):
raise ValidationError(_('Incompatible SSO provider: "{error}".').format(
error=f"provider supports grant types {','.join(provider_config.get('grant_types_supported', ''))}, but we only support 'authorization_code'."
))
if "openid" not in config["scope"].split(" "):
raise ValidationError(
_('You are not requesting "{scope}".').format(
scope="openid",
))
for scope in config["scope"].split(" "):
if scope not in provider_config.get("scopes_supported", []):
raise ValidationError(_('You are requesting scope "{scope}" but provider only supports these: {scopes}.').format(
scope=scope,
scopes=", ".join(provider_config.get("scopes_supported", []))
))
for k, v in config.items():
if k.endswith('_field') and v:
if v not in provider_config.get("claims_supported", []): # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
raise ValidationError(_('You are requesting field "{field}" but provider only supports these: {fields}.').format(
field=v,
fields=", ".join(provider_config.get("claims_supported", []))
))
config['provider_config'] = provider_config
return config
def oidc_authorize_url(provider, state, redirect_uri):
endpoint = provider.configuration['provider_config']['authorization_endpoint']
params = {
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
# https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint
'response_type': 'code',
'client_id': provider.configuration['client_id'],
'scope': provider.configuration['scope'],
'state': state,
'redirect_uri': redirect_uri,
}
return endpoint + '?' + urlencode(params)
def oidc_validate_authorization(provider, code, redirect_uri):
endpoint = provider.configuration['provider_config']['token_endpoint']
params = {
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
# https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirect_uri,
}
try:
resp = requests.post(
endpoint,
data=params,
headers={
'Accept': 'application/json',
},
auth=(provider.configuration['client_id'], provider.configuration['client_secret']),
)
resp.raise_for_status()
data = resp.json()
except RequestException:
logger.exception('Could not retrieve authorization token')
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not reach login provider',
)
)
if 'access_token' not in data:
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='access token missing',
)
)
endpoint = provider.configuration['provider_config']['userinfo_endpoint']
try:
# https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
resp = requests.get(
endpoint,
headers={
'Authorization': f'Bearer {data["access_token"]}'
},
)
resp.raise_for_status()
userinfo = resp.json()
except RequestException:
logger.exception('Could not retrieve user info')
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not fetch user info',
)
)
if 'email_verified' in userinfo and not userinfo['email_verified']:
# todo: how universal is this, do we need to make this configurable?
raise ValidationError(_('The email address on this account is not yet verified. Please first confirm the '
'email address in your customer account.'))
profile = {}
for k, v in provider.configuration.items():
if k.endswith('_field'):
profile[k[:-6]] = userinfo.get(v)
if not profile.get('uid'):
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not fetch user id',
)
)
if not profile.get('email'):
raise ValidationError(
_('Login was not successful. Error message: "{error}".').format(
error='could not fetch user email',
)
)
return profile
def _hash_scheme(value):
# As described in https://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken
digest = hashlib.sha256(value.encode()).digest()
digest_truncated = digest[:(len(digest) // 2)]
return base64.urlsafe_b64encode(digest_truncated).decode().rstrip("=")
def customer_claims(customer, scope):
scope = scope.split(' ')
claims = {
'sub': customer.identifier,
'locale': customer.locale,
}
if 'profile' in scope:
if customer.name:
claims['name'] = customer.name
if 'given_name' in customer.name_parts:
claims['given_name'] = customer.name_parts['given_name']
if 'family_name' in customer.name_parts:
claims['family_name'] = customer.name_parts['family_name']
if 'middle_name' in customer.name_parts:
claims['middle_name'] = customer.name_parts['middle_name']
if 'calling_name' in customer.name_parts:
claims['nickname'] = customer.name_parts['calling_name']
if 'email' in scope and customer.email:
claims['email'] = customer.email
claims['email_verified'] = customer.is_verified
if 'phone' in scope and customer.phone:
claims['phone_number'] = customer.phone.as_international
return claims
def _get_or_create_server_keypair(organizer):
if not organizer.settings.sso_server_signing_key_rsa256_private:
privkey = generate_private_key(key_size=4096, public_exponent=65537)
pubkey = privkey.public_key()
organizer.settings.sso_server_signing_key_rsa256_private = privkey.private_bytes(
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
).decode()
organizer.settings.sso_server_signing_key_rsa256_public = pubkey.public_bytes(
Encoding.PEM, PublicFormat.SubjectPublicKeyInfo
).decode()
return organizer.settings.sso_server_signing_key_rsa256_private, organizer.settings.sso_server_signing_key_rsa256_public
def generate_id_token(customer, client, auth_time, nonce, scope, expires: datetime, scope_claims=False, with_code=None, with_access_token=None):
payload = {
'iss': build_absolute_uri(client.organizer, 'presale:organizer.index').rstrip('/'),
'aud': client.client_id,
'exp': int(expires.timestamp()),
'iat': int(time.time()),
'auth_time': auth_time,
**customer_claims(customer, client.evaluated_scope(scope) if scope_claims else ''),
}
if nonce:
payload['nonce'] = nonce
if with_code:
payload['c_hash'] = _hash_scheme(with_code)
if with_access_token:
payload['at_hash'] = _hash_scheme(with_access_token)
privkey, pubkey = _get_or_create_server_keypair(client.organizer)
return jwt.encode(
payload,
privkey,
headers={
"kid": hashlib.sha256(pubkey.encode()).hexdigest()[:16]
},
algorithm="RS256",
)

View File

@@ -43,7 +43,6 @@ from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
)
from pretix.base.models import Event
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import (
register_html_mail_renderers, register_mail_placeholders,
@@ -300,8 +299,7 @@ def get_email_context(**kwargs):
kwargs.setdefault("position_or_address", kwargs['position'])
if 'order' in kwargs:
try:
if not kwargs.get('invoice_address'):
kwargs['invoice_address'] = kwargs['order'].invoice_address
kwargs['invoice_address'] = kwargs['order'].invoice_address
except InvoiceAddress.DoesNotExist:
kwargs['invoice_address'] = InvoiceAddress(order=kwargs['order'])
finally:
@@ -320,18 +318,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 +374,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),
@@ -484,19 +469,6 @@ def base_placeholders(sender, **kwargs):
}
),
),
SimpleFunctionalMailTextPlaceholder(
'order_modification_deadline_date_and_time', ['order', 'event'],
lambda order, event:
date_format(order.modify_deadline.astimezone(event.timezone), 'SHORT_DATETIME_FORMAT')
if order.modify_deadline
else '',
lambda event: date_format(
event.settings.get(
'last_order_modification_date', as_type=RelativeDateWrapper
).datetime(event).astimezone(event.timezone),
'SHORT_DATETIME_FORMAT'
) if event.settings.get('last_order_modification_date') else '',
),
SimpleFunctionalMailTextPlaceholder(
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
lambda event: str(event.location or ''),
@@ -630,7 +602,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

@@ -51,7 +51,7 @@ from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
)
__ = excel_safe # just so the compatibility import above is "used" and doesn't get removed by linter
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
class BaseExporter:
@@ -80,7 +80,7 @@ class BaseExporter:
def verbose_name(self) -> str:
"""
A human-readable name for this exporter. This should be short but
self-explaining. Good examples include 'Orders as JSON' or 'Orders as Microsoft Excel'.
self-explaining. Good examples include 'JSON' or 'Microsoft Excel'.
"""
raise NotImplementedError() # NOQA
@@ -137,16 +137,6 @@ class BaseExporter:
raise NotImplementedError() # NOQA
class OrganizerLevelExportMixin:
@property
def organizer_required_permission(self) -> str:
"""
The permission level required to use this exporter. Only useful for organizer-level exports,
not for event-level exports.
"""
return 'can_view_orders'
class ListExporter(BaseExporter):
ProgressSetTotal = namedtuple('ProgressSetTotal', 'total')

View File

@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
from .answers import * # noqa
from .customers import * # noqa
from .dekodi import * # noqa
from .events import * # noqa
from .invoices import * # noqa

View File

@@ -1,113 +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/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Benjamin Hättasch, Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from collections import OrderedDict
from django.dispatch import receiver
from django.utils.timezone import get_current_timezone
from django.utils.translation import gettext as _, gettext_lazy
from pretix.base.settings import PERSON_NAME_SCHEMES
from ..exporter import ListExporter, OrganizerLevelExportMixin
from ..signals import register_multievent_data_exporters
class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'customerlist'
verbose_name = gettext_lazy('Customer accounts')
organizer_required_permission = 'can_manage_customers'
@property
def additional_form_fields(self):
return OrderedDict(
[]
)
def iterate_list(self, form_data):
qs = self.organizer.customers.prefetch_related('provider')
headers = [
_('Customer ID'),
_('SSO provider'),
_('External identifier'),
_('E-mail'),
_('Phone number'),
_('Full name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
headers.append(_('Name') + ': ' + str(label))
headers += [
_('Account active'),
_('Verified email address'),
_('Last login'),
_('Registration date'),
_('Language'),
_('Notes'),
]
yield headers
tz = get_current_timezone()
for obj in qs:
row = [
obj.identifier,
obj.provider.name if obj.provider else None,
obj.external_identifier,
obj.email or '',
obj.phone or '',
obj.name,
]
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(obj.name_parts.get(k, ''))
row += [
_('Yes') if obj.is_active else _('No'),
_('Yes') if obj.is_verified else _('No'),
obj.last_login.astimezone(tz).date().strftime('%Y-%m-%d') if obj.last_login else '',
obj.date_joined.astimezone(tz).date().strftime('%Y-%m-%d') if obj.date_joined else '',
obj.get_locale_display(),
obj.notes or '',
]
yield row
def get_filename(self):
return '{}_customers'.format(self.organizer.slug)
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_customerlist")
def register_multievent_i_customerlist_exporter(sender, **kwargs):
return CustomerListExporter

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

@@ -60,9 +60,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat
from ...helpers.iter import chunked_iterable
from ..exporter import (
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
)
from ..exporter import ListExporter, MultiSheetListExporter
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
@@ -303,8 +301,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 +414,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 +463,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 +510,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 +531,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']:
@@ -620,10 +610,7 @@ class OrderListExporter(MultiSheetListExporter):
for k, label, w in name_scheme['fields']:
headers.append(_('Invoice address name') + ': ' + str(label))
headers += [
_('Invoice address street'), _('Invoice address ZIP code'), _('Invoice address city'),
_('Invoice address country'),
pgettext('address', 'Invoice address state'),
_('VAT ID'),
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
]
headers += [
_('Sales channel'), _('Order locale'),
@@ -632,10 +619,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 +742,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):
@@ -904,75 +881,76 @@ class QuotaListExporter(ListExporter):
return '{}_quotas'.format(self.event.slug)
class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardtransactionlist'
verbose_name = gettext_lazy('Gift card transactions')
organizer_required_permission = 'can_manage_gift_cards'
def generate_GiftCardTransactionListExporter(organizer): # hackhack
class GiftcardTransactionListExporter(ListExporter):
identifier = 'giftcardtransactionlist'
verbose_name = gettext_lazy('Gift card transactions')
@property
def additional_form_fields(self):
d = [
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
]
d = OrderedDict(d)
return d
def iterate_list(self, form_data):
qs = GiftCardTransaction.objects.filter(
card__issuer=self.organizer,
).order_by('datetime').select_related('card', 'order', 'order__event')
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
)
headers = [
_('Gift card code'),
_('Test mode'),
_('Date'),
_('Amount'),
_('Currency'),
_('Order'),
]
yield headers
for obj in qs:
row = [
obj.card.secret,
_('TEST MODE') if obj.card.testmode else '',
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
obj.value,
obj.card.currency,
obj.order.full_code if obj.order else None,
@property
def additional_form_fields(self):
d = [
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
]
yield row
d = OrderedDict(d)
return d
def get_filename(self):
return '{}_giftcardtransactions'.format(self.organizer.slug)
def iterate_list(self, form_data):
qs = GiftCardTransaction.objects.filter(
card__issuer=organizer,
).order_by('datetime').select_related('card', 'order', 'order__event')
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
)
headers = [
_('Gift card code'),
_('Test mode'),
_('Date'),
_('Amount'),
_('Currency'),
_('Order'),
]
yield headers
for obj in qs:
row = [
obj.card.secret,
_('TEST MODE') if obj.card.testmode else '',
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
obj.value,
obj.card.currency,
obj.order.full_code if obj.order else None,
]
yield row
def get_filename(self):
return '{}_giftcardtransactions'.format(organizer.slug)
return GiftcardTransactionListExporter
class GiftcardRedemptionListExporter(ListExporter):
@@ -1019,112 +997,114 @@ class GiftcardRedemptionListExporter(ListExporter):
return '{}_giftcardredemptions'.format(self.event.slug)
class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardlist'
verbose_name = gettext_lazy('Gift cards')
organizer_required_permission = 'can_manage_gift_cards'
def generate_GiftCardListExporter(organizer): # hackhack
class GiftcardListExporter(ListExporter):
identifier = 'giftcardlist'
verbose_name = gettext_lazy('Gift cards')
@property
def additional_form_fields(self):
return OrderedDict(
[
('date', forms.DateTimeField(
label=_('Show value at'),
initial=now(),
)),
('testmode', forms.ChoiceField(
label=_('Test mode'),
choices=(
('', _('All')),
('yes', _('Test mode')),
('no', _('Live')),
),
initial='no',
required=False
)),
('state', forms.ChoiceField(
label=_('Status'),
choices=(
('', _('All')),
('empty', _('Empty')),
('valid_value', _('Valid and with value')),
('expired_value', _('Expired and with value')),
('expired', _('Expired')),
),
initial='valid_value',
required=False
))
@property
def additional_form_fields(self):
return OrderedDict(
[
('date', forms.DateTimeField(
label=_('Show value at'),
initial=now(),
)),
('testmode', forms.ChoiceField(
label=_('Test mode'),
choices=(
('', _('All')),
('yes', _('Test mode')),
('no', _('Live')),
),
initial='no',
required=False
)),
('state', forms.ChoiceField(
label=_('Status'),
choices=(
('', _('All')),
('empty', _('Empty')),
('valid_value', _('Valid and with value')),
('expired_value', _('Expired and with value')),
('expired', _('Expired')),
),
initial='valid_value',
required=False
))
]
)
def iterate_list(self, form_data):
s = GiftCardTransaction.objects.filter(
card=OuterRef('pk'),
datetime__lte=form_data['date']
).order_by().values('card').annotate(s=Sum('value')).values('s')
qs = organizer.issued_gift_cards.filter(
issuance__lte=form_data['date']
).annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
).order_by('issuance').prefetch_related(
'transactions', 'transactions__order', 'transactions__order__event', 'transactions__order__invoices'
)
if form_data.get('testmode') == 'yes':
qs = qs.filter(testmode=True)
elif form_data.get('testmode') == 'no':
qs = qs.filter(testmode=False)
if form_data.get('state') == 'empty':
qs = qs.filter(cached_value=0)
elif form_data.get('state') == 'valid_value':
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
elif form_data.get('state') == 'expired_value':
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
elif form_data.get('state') == 'expired':
qs = qs.filter(expires__lt=form_data['date'])
headers = [
_('Gift card code'),
_('Test mode card'),
_('Creation date'),
_('Expiry date'),
_('Special terms and conditions'),
_('Currency'),
_('Current value'),
_('Created in order'),
_('Last invoice number of order'),
_('Last invoice date of order'),
]
)
yield headers
def iterate_list(self, form_data):
s = GiftCardTransaction.objects.filter(
card=OuterRef('pk'),
datetime__lte=form_data['date']
).order_by().values('card').annotate(s=Sum('value')).values('s')
qs = self.organizer.issued_gift_cards.filter(
issuance__lte=form_data['date']
).annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
).order_by('issuance').prefetch_related(
'transactions', 'transactions__order', 'transactions__order__event', 'transactions__order__invoices'
)
tz = get_current_timezone()
for obj in qs:
o = None
i = None
trans = list(obj.transactions.all())
if trans:
o = trans[0].order
if o:
invs = list(o.invoices.all())
if invs:
i = invs[-1]
row = [
obj.secret,
_('Yes') if obj.testmode else _('No'),
obj.issuance.astimezone(tz).date().strftime('%Y-%m-%d'),
obj.expires.astimezone(tz).date().strftime('%Y-%m-%d') if obj.expires else '',
obj.conditions or '',
obj.currency,
obj.cached_value,
o.full_code if o else '',
i.number if i else '',
i.date.strftime('%Y-%m-%d') if i else '',
]
yield row
if form_data.get('testmode') == 'yes':
qs = qs.filter(testmode=True)
elif form_data.get('testmode') == 'no':
qs = qs.filter(testmode=False)
def get_filename(self):
return '{}_giftcards'.format(organizer.slug)
if form_data.get('state') == 'empty':
qs = qs.filter(cached_value=0)
elif form_data.get('state') == 'valid_value':
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
elif form_data.get('state') == 'expired_value':
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
elif form_data.get('state') == 'expired':
qs = qs.filter(expires__lt=form_data['date'])
headers = [
_('Gift card code'),
_('Test mode card'),
_('Creation date'),
_('Expiry date'),
_('Special terms and conditions'),
_('Currency'),
_('Current value'),
_('Created in order'),
_('Last invoice number of order'),
_('Last invoice date of order'),
]
yield headers
tz = get_current_timezone()
for obj in qs:
o = None
i = None
trans = list(obj.transactions.all())
if trans:
o = trans[0].order
if o:
invs = list(o.invoices.all())
if invs:
i = invs[-1]
row = [
obj.secret,
_('Yes') if obj.testmode else _('No'),
obj.issuance.astimezone(tz).date().strftime('%Y-%m-%d'),
obj.expires.astimezone(tz).date().strftime('%Y-%m-%d') if obj.expires else '',
obj.conditions or '',
obj.currency,
obj.cached_value,
o.full_code if o else '',
i.number if i else '',
i.date.strftime('%Y-%m-%d') if i else '',
]
yield row
def get_filename(self):
return '{}_giftcards'.format(self.organizer.slug)
return GiftcardListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
@@ -1164,9 +1144,9 @@ def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardlist")
def register_multievent_i_giftcardlist_exporter(sender, **kwargs):
return GiftcardListExporter
return generate_GiftCardListExporter(sender)
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardtransactionlist")
def register_multievent_i_giftcardtransactionlist_exporter(sender, **kwargs):
return GiftcardTransactionListExporter
return generate_GiftCardTransactionListExporter(sender)

View File

@@ -51,7 +51,6 @@ from django.core.validators import (
)
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -135,10 +134,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:
@@ -434,7 +429,7 @@ class PortraitImageWidget(UploadedFileWidget):
def value_from_datadict(self, data, files, name):
d = super().value_from_datadict(data, files, name)
if d is not None and d is not False and d is not FILE_INPUT_CONTRADICTION:
if d is not None and d is not False:
d._cropdata = json.loads(data.get(name + '_cropdata', '{}') or '{}')
return d
@@ -919,7 +914,6 @@ class BaseQuestionsForm(forms.Form):
class BaseInvoiceAddressForm(forms.ModelForm):
vat_warning = False
address_validation = False
class Meta:
model = InvoiceAddress
@@ -1055,9 +1049,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 +1064,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))
@@ -540,7 +533,6 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
tstyledata = [
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
('FONTNAME', (0, 0), (-1, 0), self.font_bold),
('FONTNAME', (0, -1), (-1, -1), self.font_bold),
('LEFTPADDING', (0, 0), (0, -1), 0),
@@ -561,47 +553,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 +639,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 +776,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

@@ -1,38 +0,0 @@
# Generated by Django 3.2.12 on 2022-07-06 09:13
import django.db.models.deletion
import i18nfield.fields
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0218_checkinlist_addon_match'),
]
operations = [
migrations.CreateModel(
name='CustomerSSOProvider',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', i18nfield.fields.I18nCharField(max_length=200)),
('is_active', models.BooleanField(default=True)),
('button_label', i18nfield.fields.I18nCharField(max_length=200)),
('method', models.CharField(max_length=190)),
('configuration', models.JSONField()),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_providers', to='pretixbase.organizer')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.AddField(
model_name='customer',
name='provider',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='pretixbase.customerssoprovider'),
),
]

View File

@@ -1,68 +0,0 @@
# Generated by Django 3.2.12 on 2022-08-11 10:02
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
import pretix.base.models.customers
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0219_auto_20220706_0913'),
]
operations = [
migrations.CreateModel(
name='CustomerSSOClient',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('is_active', models.BooleanField(default=True)),
('client_id', models.CharField(db_index=True, default=pretix.base.models.customers.generate_client_id, max_length=100, unique=True)),
('client_secret', models.CharField(max_length=255)),
('client_type', models.CharField(default='confidential', max_length=32)),
('authorization_grant_type', models.CharField(default='authorization-code', max_length=32)),
('redirect_uris', models.TextField()),
('allowed_scopes', pretix.base.models.fields.MultiStringField(default=['openid', 'profile', 'email', 'phone'])),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_clients', to='pretixbase.organizer')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.AlterField(
model_name='customer',
name='provider',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='customers', to='pretixbase.customerssoprovider'),
),
migrations.CreateModel(
name='CustomerSSOGrant',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('code', models.CharField(max_length=255, unique=True)),
('nonce', models.CharField(max_length=255, null=True)),
('auth_time', models.IntegerField()),
('expires', models.DateTimeField()),
('redirect_uri', models.TextField()),
('scope', models.TextField()),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='grants', to='pretixbase.customerssoclient')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_grants', to='pretixbase.customer')),
],
),
migrations.CreateModel(
name='CustomerSSOAccessToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('from_code', models.CharField(max_length=255, null=True)),
('token', models.CharField(max_length=255, unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField()),
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to='pretixbase.customerssoclient')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_access_tokens', to='pretixbase.customer')),
],
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 3.2.4 on 2021-12-01 11:55
from django.db import migrations
from django.db.models import Count
def change_unique_identifiers(apps, schema_editor):
# We cannot really know if a position was bundled or an add-on, but we can at least guess
Question = apps.get_model("pretixbase", "Question")
for r in Question.objects.values('event', 'identifier').order_by().annotate(c=Count('*')).filter(c__gt=1):
qs = Question.objects.filter(identifier=r['identifier'], event_id=r['event'])
for i, q in enumerate(qs[1:]):
q.identifier += f'_{i + 2}'
q.save(update_fields=['identifier'])
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0220_auto_20220811_1002'),
]
operations = [
migrations.RunPython(
change_unique_identifiers,
migrations.RunPython.noop,
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 3.2.4 on 2021-12-01 12:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0221_clean_nonunique_question_identifiers'),
]
operations = [
migrations.AlterUniqueTogether(
name='question',
unique_together={('event', 'identifier')},
),
]

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

@@ -24,61 +24,28 @@ from django.conf import settings
from django.contrib.auth.hashers import (
check_password, is_password_usable, make_password,
)
from django.core.validators import RegexValidator, URLValidator
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import F, Q
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField
from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned
from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.organizer import Organizer
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.helpers.countries import FastCountryField
class CustomerSSOProvider(LoggedModel):
METHOD_OIDC = 'oidc'
METHODS = (
(METHOD_OIDC, 'OpenID Connect'),
)
id = models.BigAutoField(primary_key=True)
organizer = models.ForeignKey(Organizer, related_name='sso_providers', on_delete=models.CASCADE)
name = I18nCharField(
max_length=200,
verbose_name=_("Provider name"),
)
is_active = models.BooleanField(default=True, verbose_name=_('Active'))
button_label = I18nCharField(
max_length=200,
verbose_name=_("Login button label"),
)
method = models.CharField(
max_length=190,
verbose_name=_("Single-sign-on method"),
null=False, blank=False,
choices=METHODS,
)
configuration = models.JSONField()
def allow_delete(self):
return not self.customers.exists()
class Customer(LoggedModel):
"""
Represents a registered customer of an organizer.
"""
id = models.BigAutoField(primary_key=True)
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
provider = models.ForeignKey(CustomerSSOProvider, related_name='customers', on_delete=models.PROTECT, null=True, blank=True)
identifier = models.CharField(
verbose_name=_('Customer ID'),
max_length=190,
db_index=True,
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
@@ -262,7 +229,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,
@@ -350,134 +317,3 @@ class AttendeeProfile(models.Model):
parts.append(f'{a["field_label"]}: {val}')
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
def generate_client_id():
return get_random_string(40)
def generate_client_secret():
return get_random_string(40)
class CustomerSSOClient(LoggedModel):
CLIENT_CONFIDENTIAL = "confidential"
CLIENT_PUBLIC = "public"
CLIENT_TYPES = (
(CLIENT_CONFIDENTIAL, pgettext_lazy("openidconnect", "Confidential")),
(CLIENT_PUBLIC, pgettext_lazy("openidconnect", "Public")),
)
GRANT_AUTHORIZATION_CODE = "authorization-code"
GRANT_IMPLICIT = "implicit"
GRANT_TYPES = (
(GRANT_AUTHORIZATION_CODE, pgettext_lazy("openidconnect", "Authorization code")),
(GRANT_IMPLICIT, pgettext_lazy("openidconnect", "Implicit")),
)
SCOPE_CHOICES = (
('openid', _('OpenID Connect access (required)')),
('profile', _('Profile data (name, addresses)')),
('email', _('E-mail address')),
('phone', _('Phone number')),
)
id = models.BigAutoField(primary_key=True)
organizer = models.ForeignKey(Organizer, related_name='sso_clients', on_delete=models.CASCADE)
name = models.CharField(verbose_name=_("Application name"), max_length=255, blank=False)
is_active = models.BooleanField(default=True, verbose_name=_('Active'))
client_id = models.CharField(
verbose_name=_("Client ID"),
max_length=100, unique=True, default=generate_client_id, db_index=True
)
client_secret = models.CharField(
max_length=255, blank=False,
)
client_type = models.CharField(
max_length=32, choices=CLIENT_TYPES, verbose_name=_("Client type"), default=CLIENT_CONFIDENTIAL,
)
authorization_grant_type = models.CharField(
max_length=32, choices=GRANT_TYPES, verbose_name=_("Grant type"), default=GRANT_AUTHORIZATION_CODE,
)
redirect_uris = models.TextField(
blank=False,
verbose_name=_("Redirection URIs"),
help_text=_("Allowed URIs list, space separated")
)
allowed_scopes = MultiStringField(
default=['openid', 'profile', 'email', 'phone'],
delimiter=" ",
blank=True,
verbose_name=_('Allowed access scopes'),
help_text=_('Separate multiple values with spaces'),
)
def is_usable(self):
return self.is_active
def allow_redirect_uri(self, redirect_uri):
return self.redirect_uris and any(r.strip() == redirect_uri for r in self.redirect_uris.split(' '))
def allow_delete(self):
return True
def evaluated_scope(self, scope):
scope = set(scope.split(' '))
allowed_scopes = set(self.allowed_scopes)
return ' '.join(scope & allowed_scopes)
def clean(self):
redirect_uris = self.redirect_uris.strip().split()
if redirect_uris:
validator = URLValidator()
for uri in redirect_uris:
validator(uri)
def set_client_secret(self):
secret = get_random_string(64)
self.client_secret = make_password(secret)
return secret
def check_client_secret(self, raw_secret):
"""
Return a boolean of whether the ra_secret was correct. Handles
hashing formats behind the scenes.
"""
def setter(raw_secret):
self.client_secret = make_password(raw_secret)
self.save(update_fields=["client_secret"])
return check_password(raw_secret, self.client_secret, setter)
class CustomerSSOGrant(models.Model):
id = models.BigAutoField(primary_key=True)
client = models.ForeignKey(
CustomerSSOClient, on_delete=models.CASCADE, related_name="grants"
)
customer = models.ForeignKey(
Customer, on_delete=models.CASCADE, related_name="sso_grants"
)
code = models.CharField(max_length=255, unique=True)
nonce = models.CharField(max_length=255, null=True, blank=True)
auth_time = models.IntegerField()
expires = models.DateTimeField()
redirect_uri = models.TextField()
scope = models.TextField(blank=True)
class CustomerSSOAccessToken(models.Model):
id = models.BigAutoField(primary_key=True)
client = models.ForeignKey(
CustomerSSOClient, on_delete=models.CASCADE, related_name="access_tokens"
)
customer = models.ForeignKey(
Customer, on_delete=models.CASCADE, related_name="sso_access_tokens"
)
from_code = models.CharField(max_length=255, null=True, blank=True)
token = models.CharField(max_length=255, unique=True)
expires = models.DateTimeField()
scope = models.TextField(blank=True)

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
@@ -336,7 +327,7 @@ class Discount(LoggedModel):
candidates = []
cardinality = None
for se, l in subevent_to_idx.items():
l = [ll for ll in l if ll in initial_candidates and ll not in current_group]
l = [ll for ll in l if ll not in current_group]
if cardinality and len(l) != cardinality:
continue
if se not in {positions[idx][1] for idx in current_group}:

View File

@@ -69,7 +69,6 @@ from pretix.base.reldate import RelativeDateWrapper
from pretix.base.validators import EventSlugBanlistValidator
from pretix.helpers.database import GroupConcat
from pretix.helpers.daterange import daterange
from pretix.helpers.hierarkey import clean_filename
from pretix.helpers.json import safe_string
from pretix.helpers.thumb import get_thumbnail
@@ -123,16 +122,6 @@ class EventMixin:
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
)
def get_weekday_from_display(self, tz=None, short=False) -> str:
"""
Returns a formatted string containing the weekday of the start date of the event with respect
to the current locale.
"""
tz = tz or self.timezone
return _date(
self.date_from.astimezone(tz), ("D" if short else "l")
)
def get_time_from_display(self, tz=None) -> str:
"""
Returns a formatted string containing the start time of the event, ignoring
@@ -157,18 +146,6 @@ class EventMixin:
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
)
def get_weekday_to_display(self, tz=None, short=False) -> str:
"""
Returns a formatted string containing the weekday of the end date of the event with respect
to the current locale.
"""
tz = tz or self.timezone
if not self.settings.show_date_to or not self.date_to:
return ""
return _date(
self.date_to.astimezone(tz), ("D" if short else "l")
)
def get_date_range_display(self, tz=None, force_show_end=False, as_html=False) -> str:
"""
Returns a formatted string containing the start date and the end date
@@ -590,7 +567,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 +704,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 +780,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]
@@ -947,13 +917,11 @@ class Event(EventMixin, LoggedModel):
s.object = self
s.pk = None
if s.value.startswith('file://'):
fi = default_storage.open(s.value[len('file://'):], 'rb')
fi = default_storage.open(s.value[7:], 'rb')
nonce = get_random_string(length=8)
fname_base = clean_filename(os.path.basename(s.value))
# TODO: make sure pub is always correct
fname = 'pub/%s/%s/%s.%s.%s' % (
self.organizer.slug, self.slug, fname_base, nonce, s.value.split('.')[-1]
self.organizer.slug, self.slug, s.key, nonce, s.value.split('.')[-1]
)
newname = default_storage.save(fname, fi)
s.value = 'file://' + newname
@@ -1586,11 +1554,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)

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