Compare commits

..

1 Commits

Author SHA1 Message Date
Mira Weller
3ceea9d0c9 Escape HTML in placeholder samples in mail preview 2024-08-22 14:25:28 +02:00
431 changed files with 179066 additions and 283864 deletions

View File

@@ -38,7 +38,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
- name: Install system dependencies - name: Install system dependencies
run: sudo apt update && sudo apt install -y gettext unzip run: sudo apt update && sudo apt install gettext unzip
- name: Install Python dependencies - name: Install Python dependencies
run: pip3 install -U setuptools build pip check-manifest run: pip3 install -U setuptools build pip check-manifest
- name: Run check-manifest - name: Run check-manifest

View File

@@ -37,7 +37,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
- name: Install system packages - name: Install system packages
run: sudo apt update && sudo apt install -y enchant-2 hunspell aspell-en run: sudo apt update && sudo apt install enchant-2 hunspell aspell-en
- name: Install Dependencies - name: Install Dependencies
run: pip3 install -Ur requirements.txt run: pip3 install -Ur requirements.txt
working-directory: ./doc working-directory: ./doc

View File

@@ -35,9 +35,9 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
- name: Install system packages - name: Install system packages
run: sudo apt update && sudo apt -y install gettext run: sudo apt update && sudo apt install gettext
- name: Install Dependencies - name: Install Dependencies
run: pip3 install uv && uv pip install --system -e ".[dev]" run: pip3 install -e ".[dev]"
- name: Compile messages - name: Compile messages
run: python manage.py compilemessages run: python manage.py compilemessages
working-directory: ./src working-directory: ./src
@@ -62,7 +62,7 @@ jobs:
- name: Install system packages - 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-2 hunspell hunspell-de-de aspell-en aspell-de
- name: Install Dependencies - name: Install Dependencies
run: pip3 install uv && uv pip install --system -e ".[dev]" run: pip3 install -e ".[dev]"
- name: Spellcheck translations - name: Spellcheck translations
run: potypo run: potypo
working-directory: ./src working-directory: ./src

View File

@@ -35,7 +35,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
- name: Install Dependencies - name: Install Dependencies
run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary run: pip3 install -e ".[dev]" psycopg2-binary
- name: Run isort - name: Run isort
run: isort -c . run: isort -c .
working-directory: ./src working-directory: ./src
@@ -55,7 +55,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
- name: Install Dependencies - name: Install Dependencies
run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary run: pip3 install -e ".[dev]" psycopg2-binary
- name: Run flake8 - name: Run flake8
run: flake8 . run: flake8 .
working-directory: ./src working-directory: ./src

View File

@@ -30,21 +30,15 @@ jobs:
python-version: "3.9" python-version: "3.9"
- database: sqlite - database: sqlite
python-version: "3.10" python-version: "3.10"
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: pretix
options: >-
--health-cmd "pg_isready -U postgres -d pretix"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: harmon758/postgresql-action@v1
with:
postgresql version: '15'
postgresql db: 'pretix'
postgresql user: 'postgres'
postgresql password: 'postgres'
if: matrix.database == 'postgres'
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
@@ -56,9 +50,9 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
- name: Install system dependencies - name: Install system dependencies
run: sudo apt update && sudo apt install -y gettext run: sudo apt update && sudo apt install gettext
- name: Install Python dependencies - name: Install Python dependencies
run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary run: pip3 install --ignore-requires-python -e ".[dev]" psycopg2-binary # We ignore that flake8 needs newer python as we don't run flake8 during tests
- name: Run checks - name: Run checks
run: python manage.py check run: python manage.py check
working-directory: ./src working-directory: ./src
@@ -70,15 +64,15 @@ jobs:
run: make all compress run: make all compress
- name: Run tests - name: Run tests
working-directory: ./src working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --maxfail=100 run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml --reruns 3 tests --maxfail=100
- name: Run concurrency tests - name: Run concurrency tests
working-directory: ./src working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reruns 0 --reuse-db
if: matrix.database == 'postgres' if: matrix.database == 'postgres'
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v1
with: with:
file: src/coverage.xml file: src/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false fail_ci_if_error: true
if: matrix.database == 'postgres' && matrix.python-version == '3.11' if: matrix.database == 'postgres' && matrix.python-version == '3.11'

View File

@@ -10,7 +10,7 @@ tests:
- cd src - cd src
- python manage.py check - python manage.py check
- make all compress - make all compress
- PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --maxfail=100 - PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg py.test --reruns 3 -n 3 tests --maxfail=100
except: except:
- pypi - pypi
pypi: pypi:

View File

@@ -60,14 +60,6 @@ http {
deny all; deny all;
return 404; return 404;
} }
location /static/staticfiles.json {
deny all;
return 404;
}
location /static/CACHE/manifest.json {
deny all;
return 404;
}
location /static/ { location /static/ {
alias /pretix/src/pretix/static.dist/; alias /pretix/src/pretix/static.dist/;
access_log off; access_log off;

View File

@@ -288,26 +288,17 @@ Example::
[django] [django]
secret=j1kjps5a5&4ilpn912s7a1!e2h!duz^i3&idu@_907s$wrz@x- secret=j1kjps5a5&4ilpn912s7a1!e2h!duz^i3&idu@_907s$wrz@x-
debug=off debug=off
passwords_argon2=on
``secret`` ``secret``
The secret to be used by Django for signing and verification purposes. If this The secret to be used by Django for signing and verification purposes. If this
setting is not provided, pretix will generate a random secret on the first start setting is not provided, pretix will generate a random secret on the first start
and will store it in the filesystem for later usage. and will store it in the filesystem for later usage.
``secret_fallback0`` ... ``secret_fallback9``
Prior versions of the secret to be used by Django for signing and verification purposes that will still
be accepted but no longer be used for new signing.
``debug`` ``debug``
Whether or not to run in debug mode. Default is ``False``. Whether or not to run in debug mode. Default is ``False``.
.. WARNING:: Never set this to ``True`` in production! .. WARNING:: Never set this to ``True`` in production!
``passwords_argon``
Use the ``argon2`` algorithm for password hashing. Disable on systems with a small number of CPU cores (currently
less than 8).
``profile`` ``profile``
Enable code profiling for a random subset of requests. Disabled by default, see Enable code profiling for a random subset of requests. Disabled by default, see
:ref:`perf-monitoring` for details. :ref:`perf-monitoring` for details.

View File

@@ -231,10 +231,11 @@ The following snippet is an example on how to configure a nginx proxy for pretix
} }
} }
server { server {
listen 443 ssl default_server; listen 443 default_server;
listen [::]:443 ipv6only=on ssl default_server; listen [::]:443 ipv6only=on default_server;
server_name pretix.mydomain.com; server_name pretix.mydomain.com;
ssl on;
ssl_certificate /path/to/cert.chain.pem; ssl_certificate /path/to/cert.chain.pem;
ssl_certificate_key /path/to/key.pem; ssl_certificate_key /path/to/key.pem;

View File

@@ -216,10 +216,11 @@ The following snippet is an example on how to configure a nginx proxy for pretix
} }
} }
server { server {
listen 443 ssl default_server; listen 443 default_server;
listen [::]:443 ipv6only=on ssl default_server; listen [::]:443 ipv6only=on default_server;
server_name pretix.mydomain.com; server_name pretix.mydomain.com;
ssl on;
ssl_certificate /path/to/cert.chain.pem; ssl_certificate /path/to/cert.chain.pem;
ssl_certificate_key /path/to/key.pem; ssl_certificate_key /path/to/key.pem;
@@ -248,14 +249,6 @@ The following snippet is an example on how to configure a nginx proxy for pretix
return 404; return 404;
} }
location /static/staticfiles.json {
deny all;
return 404;
}
location /static/CACHE/manifest.json {
deny all;
return 404;
}
location /static/ { location /static/ {
alias /var/pretix/venv/lib/python3.11/site-packages/pretix/static.dist/; alias /var/pretix/venv/lib/python3.11/site-packages/pretix/static.dist/;
access_log off; access_log off;

View File

@@ -71,7 +71,7 @@ Endpoints
"mode": "placed", "mode": "placed",
"all_sales_channels": false, "all_sales_channels": false,
"limit_sales_channels": ["web"], "limit_sales_channels": ["web"],
"all_products": false, "all_products": False,
"limit_products": [2, 3], "limit_products": [2, 3],
"limit_variations": [456], "limit_variations": [456],
"all_payment_methods": true, "all_payment_methods": true,
@@ -113,7 +113,7 @@ Endpoints
"mode": "placed", "mode": "placed",
"all_sales_channels": false, "all_sales_channels": false,
"limit_sales_channels": ["web"], "limit_sales_channels": ["web"],
"all_products": false, "all_products": False,
"limit_products": [2, 3], "limit_products": [2, 3],
"limit_variations": [456], "limit_variations": [456],
"all_payment_methods": true, "all_payment_methods": true,
@@ -146,7 +146,7 @@ Endpoints
"mode": "placed", "mode": "placed",
"all_sales_channels": false, "all_sales_channels": false,
"limit_sales_channels": ["web"], "limit_sales_channels": ["web"],
"all_products": false, "all_products": False,
"limit_products": [2, 3], "limit_products": [2, 3],
"limit_variations": [456], "limit_variations": [456],
"all_payment_methods": true, "all_payment_methods": true,
@@ -167,7 +167,7 @@ Endpoints
"mode": "placed", "mode": "placed",
"all_sales_channels": false, "all_sales_channels": false,
"limit_sales_channels": ["web"], "limit_sales_channels": ["web"],
"all_products": false, "all_products": False,
"limit_products": [2, 3], "limit_products": [2, 3],
"limit_variations": [456], "limit_variations": [456],
"all_payment_methods": true, "all_payment_methods": true,
@@ -216,7 +216,7 @@ Endpoints
"mode": "placed", "mode": "placed",
"all_sales_channels": false, "all_sales_channels": false,
"limit_sales_channels": ["web"], "limit_sales_channels": ["web"],
"all_products": false, "all_products": False,
"limit_products": [2, 3], "limit_products": [2, 3],
"limit_variations": [456], "limit_variations": [456],
"all_payment_methods": true, "all_payment_methods": true,

View File

@@ -23,22 +23,6 @@ position integer An integer, use
is_addon boolean If ``true``, items within this category are not on sale is_addon boolean If ``true``, items within this category are not on sale
on their own but the category provides a source for on their own but the category provides a source for
defining add-ons for other products. defining add-ons for other products.
cross_selling_mode string If ``null``, cross-selling is disabled for this category.
If ``"only"``, it is only visible in the cross-selling
step.
If ``"both"``, it is visible on the normal index page
as well.
Only available if ``is_addon`` is ``false``.
cross_selling_condition string Only relevant if ``cross_selling_mode`` is not ``null``.
If ``"always"``, always show in cross-selling step.
If ``"products"``, only show if the cart contains one of
the products listed in ``cross_selling_match_products``.
If ``"discounts"``, only show products that qualify for
a discount according to discount rules.
cross_selling_match_products list of integer Only relevant if ``cross_selling_condition`` is
``"products"``. Internal ID of the items of which at
least one needs to be in the cart for this category to
be shown.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -76,10 +60,7 @@ Endpoints
"internal_name": "", "internal_name": "",
"description": {"en": "Tickets are what you need to get in."}, "description": {"en": "Tickets are what you need to get in."},
"position": 1, "position": 1,
"is_addon": false, "is_addon": false
"cross_selling_mode": null,
"cross_selling_condition": null,
"cross_selling_match_products": []
} }
] ]
} }
@@ -121,10 +102,7 @@ Endpoints
"internal_name": "", "internal_name": "",
"description": {"en": "Tickets are what you need to get in."}, "description": {"en": "Tickets are what you need to get in."},
"position": 1, "position": 1,
"is_addon": false, "is_addon": false
"cross_selling_mode": null,
"cross_selling_condition": null,
"cross_selling_match_products": []
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -152,10 +130,7 @@ Endpoints
"internal_name": "", "internal_name": "",
"description": {"en": "Tickets are what you need to get in."}, "description": {"en": "Tickets are what you need to get in."},
"position": 1, "position": 1,
"is_addon": false, "is_addon": false
"cross_selling_mode": null,
"cross_selling_condition": null,
"cross_selling_match_products": []
} }
**Example response**: **Example response**:
@@ -172,10 +147,7 @@ Endpoints
"internal_name": "", "internal_name": "",
"description": {"en": "Tickets are what you need to get in."}, "description": {"en": "Tickets are what you need to get in."},
"position": 1, "position": 1,
"is_addon": false, "is_addon": false
"cross_selling_mode": null,
"cross_selling_condition": null,
"cross_selling_match_products": []
} }
:param organizer: The ``slug`` field of the organizer of the event to create a category for :param organizer: The ``slug`` field of the organizer of the event to create a category for
@@ -221,10 +193,7 @@ Endpoints
"internal_name": "", "internal_name": "",
"description": {"en": "Tickets are what you need to get in."}, "description": {"en": "Tickets are what you need to get in."},
"position": 1, "position": 1,
"is_addon": true, "is_addon": true
"cross_selling_mode": null,
"cross_selling_condition": null,
"cross_selling_match_products": []
} }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify

View File

@@ -31,6 +31,8 @@ subevent integer ID of the date
position_count integer Number of tickets that match this list (read-only). position_count integer Number of tickets that match this list (read-only).
checkin_count integer Number of check-ins performed on this list (read-only). checkin_count integer Number of check-ins performed on this list (read-only).
include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state. include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state.
auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels.
**Deprecated, will be removed in pretix 2024.10.** Use :ref:`rest-autocheckinrules`: instead.
allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in. allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in.
allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan. allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan.
rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged. rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged.
@@ -89,7 +91,10 @@ Endpoints
"allow_entry_after_exit": true, "allow_entry_after_exit": true,
"exit_all_at": null, "exit_all_at": null,
"rules": {}, "rules": {},
"addon_match": false "addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
] ]
} }
@@ -141,7 +146,10 @@ Endpoints
"allow_entry_after_exit": true, "allow_entry_after_exit": true,
"exit_all_at": null, "exit_all_at": null,
"rules": {}, "rules": {},
"addon_match": false "addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -238,7 +246,10 @@ Endpoints
"subevent": null, "subevent": null,
"allow_multiple_entries": false, "allow_multiple_entries": false,
"allow_entry_after_exit": true, "allow_entry_after_exit": true,
"addon_match": false "addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
**Example response**: **Example response**:
@@ -260,7 +271,10 @@ Endpoints
"subevent": null, "subevent": null,
"allow_multiple_entries": false, "allow_multiple_entries": false,
"allow_entry_after_exit": true, "allow_entry_after_exit": true,
"addon_match": false "addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer of the event/item to create a list for :param organizer: The ``slug`` field of the organizer of the event/item to create a list for
@@ -312,7 +326,10 @@ Endpoints
"subevent": null, "subevent": null,
"allow_multiple_entries": false, "allow_multiple_entries": false,
"allow_entry_after_exit": true, "allow_entry_after_exit": true,
"addon_match": false "addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
@@ -325,7 +342,7 @@ Endpoints
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/checkinlist/(id)/ .. http:delete:: /api/v1/organizers/(organizer)/events/(event)/checkinlist/(id)/
Delete a check-in list. **Note that this also deletes the information on all check-ins performed via this list.** Delete a check-in list. Note that this also deletes the information on all check-ins performed via this list.
**Example request**: **Example request**:

View File

@@ -97,7 +97,6 @@ lines list of objects The actual invo
├ gross_value money (string) Price including taxes ├ gross_value money (string) Price including taxes
├ tax_value money (string) Tax amount included ├ tax_value money (string) Tax amount included
├ tax_name string Name of used tax rate (e.g. "VAT") ├ tax_name string Name of used tax rate (e.g. "VAT")
├ tax_code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`.
└ tax_rate decimal (string) Used tax rate └ tax_rate decimal (string) Used tax rate
foreign_currency_display string If the invoice should also show the total and tax foreign_currency_display string If the invoice should also show the total and tax
amount in a different currency, this contains the amount in a different currency, this contains the
@@ -127,10 +126,6 @@ internal_reference string Customer's refe
The ``event`` attribute has been added. The organizer-level endpoint has been added. The ``event`` attribute has been added. The organizer-level endpoint has been added.
.. versionchanged:: 2024.8
The ``tax_code`` attribute has been added.
List of all invoices List of all invoices
-------------------- --------------------
@@ -208,7 +203,6 @@ List of all invoices
"gross_value": "23.00", "gross_value": "23.00",
"tax_value": "0.00", "tax_value": "0.00",
"tax_name": "VAT", "tax_name": "VAT",
"tax_code": "S/standard",
"tax_rate": "0.00" "tax_rate": "0.00"
} }
], ],
@@ -348,7 +342,6 @@ Fetching individual invoices
"gross_value": "23.00", "gross_value": "23.00",
"tax_value": "0.00", "tax_value": "0.00",
"tax_name": "VAT", "tax_name": "VAT",
"tax_code": "S/standard",
"tax_rate": "0.00" "tax_rate": "0.00"
} }
], ],
@@ -359,12 +352,12 @@ Fetching individual invoices
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to fetch :param invoice_no: The ``invoice_no`` field of the invoice to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/download/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/download/
Download an invoice in PDF format. Download an invoice in PDF format.
@@ -391,7 +384,7 @@ Fetching individual invoices
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to fetch :param invoice_no: The ``invoice_no`` field of the invoice to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
@@ -404,7 +397,7 @@ Modifying invoices
Invoices cannot be edited directly, but the following actions can be triggered: Invoices cannot be edited directly, but the following actions can be triggered:
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/reissue/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/
Cancels the invoice and creates a new one. Cancels the invoice and creates a new one.
@@ -426,13 +419,13 @@ Invoices cannot be edited directly, but the following actions can be triggered:
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to reissue :param invoice_no: The ``invoice_no`` field of the invoice to reissue
:statuscode 200: no error :statuscode 200: no error
:statuscode 400: The invoice has already been canceled :statuscode 400: The invoice has already been canceled
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/regenerate/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/regenerate/
Re-generates the invoice from order data. Re-generates the invoice from order data.
@@ -454,7 +447,7 @@ Invoices cannot be edited directly, but the following actions can be triggered:
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to regenerate :param invoice_no: The ``invoice_no`` field of the invoice to regenerate
:statuscode 200: no error :statuscode 200: no error
:statuscode 400: The invoice has already been canceled :statuscode 400: The invoice has already been canceled
:statuscode 401: Authentication failure :statuscode 401: Authentication failure

View File

@@ -84,7 +84,6 @@ fees list of objects List of fees in
├ tax_rate decimal (string) VAT rate applied for this fee ├ tax_rate decimal (string) VAT rate applied for this fee
├ tax_value money (string) VAT included in this fee ├ tax_value money (string) VAT included in this fee
├ tax_rule integer The ID of the used tax rule (or ``null``) ├ tax_rule integer The ID of the used tax rule (or ``null``)
├ tax_code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`.
└ canceled boolean Whether or not this fee has been canceled. └ canceled boolean Whether or not this fee has been canceled.
downloads list of objects List of ticket download options for order-wise ticket downloads list of objects List of ticket download options for order-wise ticket
downloading. This might be a multi-page PDF or a ZIP downloading. This might be a multi-page PDF or a ZIP
@@ -105,10 +104,6 @@ url string The full URL to
payments list of objects List of payment processes (see below) payments list of objects List of payment processes (see below)
refunds list of objects List of refund processes (see below) refunds list of objects List of refund processes (see below)
last_modified datetime Last modification of this object last_modified datetime Last modification of this object
cancellation_date datetime Time of order cancellation (or ``null``). **Note**:
Will not be set for partial cancellations and is not
reliable for orders that have been cancelled,
reactivated and cancelled again.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -156,13 +151,6 @@ cancellation_date datetime Time of order c
The ``expires`` attribute can now be passed during order creation. The ``expires`` attribute can now be passed during order creation.
.. versionchanged:: 2024.11
The ``cancellation_date`` attribute has been added and can also be used as an ordering key.
.. versionchanged:: 2025.1
The ``tax_code`` attribute has been added.
.. _order-position-resource: .. _order-position-resource:
@@ -200,7 +188,6 @@ voucher_budget_use money (string) Amount of money
are changed *after* the order was created. Can be ``null``. are changed *after* the order was created. Can be ``null``.
tax_rate decimal (string) VAT rate applied for this position tax_rate decimal (string) VAT rate applied for this position
tax_value money (string) VAT included in this position tax_value money (string) VAT included in this position
tax_code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`.
tax_rule integer The ID of the used tax rule (or ``null``) tax_rule integer The ID of the used tax rule (or ``null``)
secret string Secret code printed on the tickets for validation 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``) addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
@@ -216,20 +203,8 @@ checkins list of objects List of **succe
├ datetime datetime Time of check-in ├ datetime datetime Time of check-in
├ type string Type of scan (defaults to ``entry``) ├ type string Type of scan (defaults to ``entry``)
├ gate integer Internal ID of the gate. Can be ``null``. ├ gate integer Internal ID of the gate. Can be ``null``.
├ device integer Internal ID of the device. Can be ``null``. **Deprecated**, since this ID is not otherwise used in the API and is therefore not very useful. ├ device integer Internal ID of the device. Can be ``null``.
├ device_id integer Attribute ``device_id`` of the device. Can be ``null``.
└ auto_checked_in boolean Indicates if this check-in been performed automatically by the system └ auto_checked_in boolean Indicates if this check-in been performed automatically by the system
print_logs list of objects List of print jobs recorded e.g. by the pretix apps
├ id integer Internal ID of the print job
├ successful boolean Whether the print job successfully resulted in a print.
This is not expected to be 100 % reliable information (since
printer feedback is never perfect) and there is no guarantee
that unsuccessful jobs will be logged.
├ device_id integer Attribute ``device_id`` of the device that recorded the print. Can be ``null``.
├ datetime datetime Time of printing
├ source string Source of print job, e.g. name of the app used.
├ type string Type of print (currently ``badge``, ``ticket``, ``certificate``, or ``other``)
└ info object Additional data with client-dependent structure.
downloads list of objects List of ticket download options downloads list of objects List of ticket download options
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) ├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
└ url string Download URL └ url string Download URL
@@ -257,14 +232,6 @@ pdf_data object Data object req
The attributes ``blocked``, ``valid_from`` and ``valid_until`` have been added. The attributes ``blocked``, ``valid_from`` and ``valid_until`` have been added.
.. versionchanged:: 2024.9
The attribute ``print_logs`` has been added.
.. versionchanged:: 2025.1
The ``tax_code`` attribute has been added.
.. _order-payment-resource: .. _order-payment-resource:
Order payment resource Order payment resource
@@ -416,7 +383,6 @@ List of all orders
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_value": "0.00", "tax_value": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_code": null,
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
@@ -432,21 +398,10 @@ List of all orders
"type": "entry", "type": "entry",
"gate": null, "gate": null,
"device": 2, "device": 2,
"device_id": 1,
"datetime": "2017-12-25T12:45:23Z", "datetime": "2017-12-25T12:45:23Z",
"auto_checked_in": false "auto_checked_in": false
} }
], ],
"print_logs": [
{
"id": 1,
"type": "badge",
"datetime": "2017-12-25T12:45:23Z",
"device_id": 1,
"source": "pretixSCAN",
"info": {}
}
],
"answers": [ "answers": [
{ {
"question": 12, "question": 12,
@@ -482,15 +437,14 @@ List of all orders
"provider": "banktransfer" "provider": "banktransfer"
} }
], ],
"refunds": [], "refunds": []
"cancellation_date": null
} }
] ]
} }
:query integer page: The page number in case of a multi-page result set, default is 1 :query integer page: The page number in case of a multi-page result set, default is 1
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``code``, :query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``code``,
``last_modified``, ``status`` and ``cancellation_date``. Default: ``datetime`` ``last_modified``, and ``status``. Default: ``datetime``
:query string code: Only return orders that match the given order code :query string code: Only return orders that match the given order code
:query string status: Only return orders in the given order status (see above) :query string status: Only return orders in the given order status (see above)
:query string search: Only return orders matching a given search query (matching for names, email addresses, and company names) :query string search: Only return orders matching a given search query (matching for names, email addresses, and company names)
@@ -656,7 +610,6 @@ Fetching individual orders
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_value": "0.00", "tax_value": "0.00",
"tax_code": null,
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
@@ -672,22 +625,10 @@ Fetching individual orders
"type": "entry", "type": "entry",
"gate": null, "gate": null,
"device": 2, "device": 2,
"device_id": 1,
"datetime": "2017-12-25T12:45:23Z", "datetime": "2017-12-25T12:45:23Z",
"auto_checked_in": false "auto_checked_in": false
} }
], ],
"print_logs": [
{
"id": 1,
"type": "badge",
"successful": true,
"datetime": "2017-12-25T12:45:23Z",
"device_id": 1,
"source": "pretixSCAN",
"info": {}
}
],
"answers": [ "answers": [
{ {
"question": 12, "question": 12,
@@ -723,8 +664,7 @@ Fetching individual orders
"provider": "banktransfer" "provider": "banktransfer"
} }
], ],
"refunds": [], "refunds": []
"cancellation_date": null
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -855,7 +795,7 @@ Generating new secrets
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/regenerate_secrets/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/regenerate_secrets/
Triggers generation of new ``secret`` and ``ẁeb_secret`` attributes for both the order and all order positions. Triggers generation of new ``secret`` attributes for both the order and all order positions.
**Example request**: **Example request**:
@@ -886,7 +826,7 @@ Generating new secrets
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/regenerate_secrets/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/regenerate_secrets/
Triggers generation of a new ``secret`` and ``web_secret`` attribute for a single order position. Triggers generation of a new ``secret`` attribute for a single order position.
**Example request**: **Example request**:
@@ -1036,8 +976,8 @@ Creating orders
* ``internal_reference`` * ``internal_reference``
* ``vat_id`` * ``vat_id``
* ``vat_id_validated`` (optional) If you need support for reverse charge (rarely the case), you need to check * ``vat_id_validated`` (optional) If you need support for reverse charge (rarely the case), you need to check
yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will
trigger reverse charge taxation. Don't forget to set ``is_business`` as well! trigger reverse charge taxation. Don't forget to set ``is_business`` as well!
* ``positions`` * ``positions``
@@ -1625,7 +1565,6 @@ List of all order positions
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_value": "0.00", "tax_value": "0.00",
"tax_code": null,
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"discount": null, "discount": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
@@ -1641,22 +1580,10 @@ List of all order positions
"type": "entry", "type": "entry",
"gate": null, "gate": null,
"device": 2, "device": 2,
"device_id": 1,
"datetime": "2017-12-25T12:45:23Z", "datetime": "2017-12-25T12:45:23Z",
"auto_checked_in": false "auto_checked_in": false
} }
], ],
"print_logs": [
{
"id": 1,
"type": "badge",
"successful": true,
"datetime": "2017-12-25T12:45:23Z",
"device_id": 1,
"source": "pretixSCAN",
"info": {}
}
],
"answers": [ "answers": [
{ {
"question": 12, "question": 12,
@@ -1752,7 +1679,6 @@ Fetching individual positions
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_value": "0.00", "tax_value": "0.00",
"tax_code": null,
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
@@ -1768,22 +1694,10 @@ Fetching individual positions
"type": "entry", "type": "entry",
"gate": null, "gate": null,
"device": 2, "device": 2,
"device_id": 1,
"datetime": "2017-12-25T12:45:23Z", "datetime": "2017-12-25T12:45:23Z",
"auto_checked_in": false "auto_checked_in": false
} }
], ],
"print_logs": [
{
"id": 1,
"type": "badge",
"successful": true,
"datetime": "2017-12-25T12:45:23Z",
"device_id": 1,
"source": "pretixSCAN",
"info": {}
}
],
"answers": [ "answers": [
{ {
"question": 12, "question": 12,
@@ -1880,10 +1794,6 @@ Manipulating individual positions
The endpoints to manage blocks have been added. The endpoints to manage blocks have been added.
.. versionchanged:: 2024.9
The API now supports logging ticket and badge prints.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ .. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Updates specific fields on an order position. Currently, only the following fields are supported: Updates specific fields on an order position. Currently, only the following fields are supported:
@@ -2143,59 +2053,6 @@ Manipulating individual positions
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/printlog/
Creates a print log, stating that this ticket has been printed.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/printlog/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"datetime": "2024-09-19T13:37:00+02:00",
"source": "pretixPOS",
"type": "badge",
"info": {
"cashier": 1234
}
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/pdf
{
"id": 1234,
"device_id": null,
"datetime": "2024-09-19T13:37:00+02:00",
"source": "pretixPOS",
"type": "badge",
"info": {
"cashier": 1234
}
}
:param organizer: The ``slug`` field of the organizer to create a log for
:param event: The ``slug`` field of the event to create a log for
:param id: The ``id`` field of the order position to create a log for
:statuscode 201: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource
**or** downloads are not available for this order position at this time. The response content will
contain more details.
:statuscode 404: The requested order position or download provider does not exist.
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
Changing order contents Changing order contents
----------------------- -----------------------

View File

@@ -51,7 +51,7 @@ Endpoints
"results": [ "results": [
{ {
"identifier": "web", "identifier": "web",
"label": { "name": {
"en": "Online shop" "en": "Online shop"
}, },
"type": "web", "type": "web",
@@ -88,7 +88,7 @@ Endpoints
{ {
"identifier": "web", "identifier": "web",
"label": { "name": {
"en": "Online shop" "en": "Online shop"
}, },
"type": "web", "type": "web",
@@ -116,7 +116,7 @@ Endpoints
{ {
"identifier": "api.custom", "identifier": "api.custom",
"label": { "name": {
"en": "Custom integration" "en": "Custom integration"
}, },
"type": "api", "type": "api",
@@ -133,7 +133,7 @@ Endpoints
{ {
"identifier": "api.custom", "identifier": "api.custom",
"label": { "name": {
"en": "Custom integration" "en": "Custom integration"
}, },
"type": "api", "type": "api",
@@ -178,7 +178,7 @@ Endpoints
{ {
"identifier": "web", "identifier": "web",
"label": { "name": {
"en": "Online shop" "en": "Online shop"
}, },
"type": "web", "type": "web",

View File

@@ -313,7 +313,7 @@ Endpoints for event exports
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
Endpoints for organizer exports Endpoints for organizer exports
------------------------------- ---------------------------
.. http:get:: /api/v1/organizers/(organizer)/scheduled_exports/ .. http:get:: /api/v1/organizers/(organizer)/scheduled_exports/
@@ -553,4 +553,4 @@ Endpoints for organizer exports
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource. :statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.
.. _RFC 5545: https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5.3 .. _RFC 5545: https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5.3

View File

@@ -1,4 +1,4 @@
.. _`rest-seats`: .. _`rest-reusablemedia`:
Seats Seats
===== =====
@@ -249,7 +249,7 @@ Endpoints
"orderposition": null, "orderposition": null,
"cartposition": null, "cartposition": null,
"voucher": null "voucher": null
} },
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify :param event: The ``slug`` field of the event to modify
@@ -260,114 +260,3 @@ Endpoints
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource. :statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa. :statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_block/
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_block/
Set the ``blocked`` attribute to ``true`` for a large number of seats at once.
You can pass either a list of ``id`` values or a list of ``seat_guid`` values.
You can pass up to 10,000 seats in one request.
The endpoint will return an error if you pass a seat ID that does not exist.
However, it will not return an error if one of the passed seats is already blocked or sold.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"ids": [12, 45, 56]
}
or
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param subevent_id: The ``id`` field of the subevent to modify
:statuscode 200: no error
:statuscode 400: The seat could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_unblock/
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_unblock/
Set the ``blocked`` attribute to ``false`` for a large number of seats at once.
You can pass either a list of ``id`` values or a list of ``seat_guid`` values.
You can pass up to 10,000 seats in one request.
The endpoint will return an error if you pass a seat ID that does not exist.
However, it will not return an error if one of the passed seat is already unblocked or is sold.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"ids": [12, 45, 56]
}
or
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param subevent_id: The ``id`` field of the subevent to modify
:statuscode 200: no error
:statuscode 400: The seat could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.

View File

@@ -136,7 +136,6 @@ Endpoints
} }
:query page: The page number in case of a multi-page result set, default is 1 :query page: The page number in case of a multi-page result set, default is 1
:query is_public: If set to ``true``/``false``, only subevents with a matching value of ``is_public`` are returned.
:query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned. :query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned.
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. :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 is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
@@ -468,7 +467,6 @@ Endpoints
} }
:query page: The page number in case of a multi-page result set, default is 1 :query page: The page number in case of a multi-page result set, default is 1
:query is_public: If set to ``true``/``false``, only subevents with a matching value of ``is_public`` are returned.
:query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned. :query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned.
:query event__live: If set to ``true``/``false``, only events with a matching value of ``live`` on the parent event are returned. :query event__live: If set to ``true``/``false``, only events with a matching value of ``live`` on the parent event are returned.
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. :query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.

View File

@@ -1,8 +1,3 @@
.. spelling:word-list::
EN16931
DSFinV-K
.. _rest-taxrules: .. _rest-taxrules:
Tax rules Tax rules
@@ -23,12 +18,10 @@ id integer Internal ID of
name multi-lingual string The tax rules' name name multi-lingual string The tax rules' name
internal_name string An optional name that is only used in the backend internal_name string An optional name that is only used in the backend
rate decimal (string) Tax rate in percent rate decimal (string) Tax rate in percent
code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`.
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
the specified product price the specified product price
eu_reverse_charge boolean **DEPRECATED**. If ``true``, EU reverse charge rules eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied. Will
are applied. Will be ignored if custom rules are set. be ignored if custom rules are set.
Use custom rules instead.
home_country string Merchant country (required for reverse charge), can be home_country string Merchant country (required for reverse charge), can be
``null`` or empty string ``null`` or empty string
keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom
@@ -48,42 +41,6 @@ custom_rules object Dynamic rules s
The ``custom_rules`` attribute has been added. The ``custom_rules`` attribute has been added.
.. versionchanged:: 2023.8
The ``code`` attribute has been added.
.. _rest-taxcodes:
Tax codes
---------
For integration with external systems, such as electronic invoicing or bookkeeping systems, the tax rate itself is often
not sufficient information. For example, there could be many different reasons why a sale has a tax rate of 0 %, but the
external handling of the transaction depends on which reason applies. Therefore, pretix allows to supply a codified
reason that allows us to understand what the specific legal situation is. These tax codes are modeled after a combination
of the code lists from the European standard EN16931 and the German standard DSFinV-K.
The following codes are supported:
- ``S/standard`` -- Standard VAT rate in the merchant country
- ``S/reduced`` -- Reduced VAT rate in the merchant country
- ``S/averaged`` -- Averaged VAT rate in the merchant country (known use case: agricultural businesses in Germany)
- ``AE`` -- Reverse charge
- ``O`` -- Services outside of scope of tax
- ``E`` -- Exempt from tax (no reason given)
- ``E/<reason>`` -- Exempt from tax, where ``<reason>`` is one of the codes listed in the `VATEX code list`_ version 5.0.
- ``Z`` -- Zero-rated goods
- ``G`` -- Free export item, VAT not charged
- ``K`` -- VAT exempt for EEA intra-community supply of goods and services
- ``L`` -- Canary Islands general indirect tax
- ``M`` -- Tax for production, services and importation in Ceuta and Melilla
- ``B`` -- Transferred (VAT), only in Italy
The code set in the ``code`` attribute of the tax rule is used by default. When ``eu_reverse_charge`` is active, the
code is replaced by ``AE`` for reverse charge sales and by ``O`` for non-EU sales. When configuring custom rules, you
should actively set a ``"code"`` key on each rule. Only for ``"action": "reverse"`` we automatically apply the code
``AE``, in all other cases the default ``code`` of the tax rule is selected.
Endpoints Endpoints
--------- ---------
@@ -116,7 +73,6 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "VAT"}, "name": {"en": "VAT"},
"internal_name": "VAT", "internal_name": "VAT",
"code": "S/standard",
"rate": "19.00", "rate": "19.00",
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
@@ -158,7 +114,6 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "VAT"}, "name": {"en": "VAT"},
"internal_name": "VAT", "internal_name": "VAT",
"code": "S/standard",
"rate": "19.00", "rate": "19.00",
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
@@ -208,7 +163,6 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "VAT"}, "name": {"en": "VAT"},
"internal_name": "VAT", "internal_name": "VAT",
"code": "S/standard",
"rate": "19.00", "rate": "19.00",
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
@@ -257,7 +211,6 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "VAT"}, "name": {"en": "VAT"},
"internal_name": "VAT", "internal_name": "VAT",
"code": "S/standard",
"rate": "20.00", "rate": "20.00",
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
@@ -304,4 +257,3 @@ Endpoints
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this tax rule cannot be deleted since it is currently in use. :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this tax rule cannot be deleted since it is currently in use.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/tax-rules-custom.schema.json .. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/tax-rules-custom.schema.json
.. _VATEX code list: https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists

View File

@@ -17,7 +17,6 @@ First, you need to declare that you are using non-essential cookies by respondin
signal: signal:
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
:no-index:
:members: register_cookie_providers :members: register_cookie_providers
You are expected to return a list of ``CookieProvider`` objects instantiated from the following class: You are expected to return a list of ``CookieProvider`` objects instantiated from the following class:

View File

@@ -14,7 +14,7 @@ Core
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification, :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification,
item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter, item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter,
register_ticket_secret_generators, gift_card_transaction_display, register_ticket_secret_generators, gift_card_transaction_display,
register_text_placeholders, register_mail_placeholders, device_info_updated register_text_placeholders, register_mail_placeholders
Order events Order events
"""""""""""" """"""""""""
@@ -22,14 +22,12 @@ Order events
There are multiple signals that will be sent out in the ordering cycle: There are multiple signals that will be sent out in the ordering cycle:
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index:
:members: validate_cart, validate_cart_addons, validate_order, order_valid_if_pending, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text :members: validate_cart, validate_cart_addons, validate_order, order_valid_if_pending, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text
Check-ins Check-ins
""""""""" """""""""
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index:
:members: checkin_created :members: checkin_created
@@ -41,21 +39,18 @@ Frontend
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
:no-index:
:members: order_info, order_info_top, order_meta_from_request, order_api_meta_from_request :members: order_info, order_info_top, order_meta_from_request, order_api_meta_from_request
Request flow Request flow
"""""""""""" """"""""""""
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
:no-index:
:members: process_request, process_response :members: process_request, process_response
Vouchers Vouchers
"""""""" """"""""
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
:no-index:
:members: voucher_redeem_info :members: voucher_redeem_info
Backend Backend
@@ -67,28 +62,24 @@ Backend
item_formsets, order_search_filter_q, order_search_forms item_formsets, order_search_filter_q, order_search_forms
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index:
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events, orderposition_blocked_display, customer_created, customer_signed_in :members: logentry_display, logentry_object_link, requiredaction_display, timeline_events, orderposition_blocked_display, customer_created, customer_signed_in
Vouchers Vouchers
"""""""" """"""""
.. automodule:: pretix.control.signals .. automodule:: pretix.control.signals
:no-index:
:members: item_forms, voucher_form_class, voucher_form_html, voucher_form_validation :members: item_forms, voucher_form_class, voucher_form_html, voucher_form_validation
Dashboards Dashboards
"""""""""" """"""""""
.. automodule:: pretix.control.signals .. automodule:: pretix.control.signals
:no-index:
:members: event_dashboard_widgets, user_dashboard_widgets, event_dashboard_top :members: event_dashboard_widgets, user_dashboard_widgets, event_dashboard_top
Ticket designs Ticket designs
"""""""""""""" """"""""""""""
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index:
:members: layout_text_variables, layout_image_variables :members: layout_text_variables, layout_image_variables
.. automodule:: pretix.plugins.ticketoutputpdf.signals .. automodule:: pretix.plugins.ticketoutputpdf.signals
@@ -98,9 +89,4 @@ API
--- ---
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index:
:members: validate_event_settings, api_event_settings_fields :members: validate_event_settings, api_event_settings_fields
.. automodule:: pretix.api.signals
:no-index:
:members: register_device_security_profile

View File

@@ -60,7 +60,6 @@ that we'll provide in this plugin:
Similar signals exist for other objects: Similar signals exist for other objects:
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index:
:members: voucher_import_columns :members: voucher_import_columns

View File

@@ -84,6 +84,8 @@ convenient to you:
.. automethod:: _register_fonts .. automethod:: _register_fonts
.. automethod:: _register_event_fonts
.. automethod:: _on_first_page .. automethod:: _on_first_page
.. automethod:: _on_other_page .. automethod:: _on_other_page

View File

@@ -86,10 +86,7 @@ Signals
------- -------
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index:
:members: register_text_placeholders :members: register_text_placeholders
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index:
:members: register_mail_placeholders :members: register_mail_placeholders

View File

@@ -1,5 +1,5 @@
KulturPass KulturPass
========== =========
.. note:: .. note::

View File

@@ -158,7 +158,7 @@ expects and - more importantly - supports.
for a sample configuration in an academic context. for a sample configuration in an academic context.
Note, that you can have multiple attributes with the same ``friendlyName`` Note, that you can have multiple attributes with the same ``friendlyName``
but different ``name`` value. This is often used in systems, where the same but different ``name``s. This is often used in systems, where the same
information (for example a persons name) is saved in different fields - information (for example a persons name) is saved in different fields -
for example because one institution is returning SAML 1.0 and other for example because one institution is returning SAML 1.0 and other
institutions are returning SAML 2.0 style attributes. Typically, this only institutions are returning SAML 2.0 style attributes. Typically, this only

View File

@@ -29,8 +29,8 @@ item_assignments list of objects Products this l
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
Layout endpoints Endpoints
---------------- ---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/
@@ -268,75 +268,5 @@ Layout endpoints
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
Ticket rendering endpoint
-----------------------------
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/ticketpdfrenderer/render_batch/
With this API call, you can instruct the system to render a set of tickets into one combined PDF file. To specify
which tickets to render, you need to submit a list of "parts". For every part, the following fields are supported:
* ``orderposition`` (``integer``, required): The ID of the order position to render.
* ``override_channel`` (``string``, optional): The sales channel ID to be used for layout selection instead of the
original channel of the order.
* ``override_layout`` (``integer``, optional): The ticket layout ID to be used instead of the auto-selected one.
If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
The body points you to the download URL of the result. Running a ``GET`` request on that result URL will
yield one of the following status codes:
* ``200 OK`` The export succeeded. The body will be your resulting file. Might be large!
* ``409 Conflict`` Your export is still running. The body will be JSON with the structure ``{"status": "running"}``. ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
* ``410 Gone`` Running the export has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
* ``404 Not Found`` The export does not exist / is expired.
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
.. note:: To avoid performance issues, a maximum number of 1000 parts is currently allowed.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/ticketpdfrenderer/render_batch/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"parts": [
{
"orderposition": 55412
},
{
"orderposition": 55412,
"override_channel": "web"
},
{
"orderposition": 55412,
"override_layout": 56
}
]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/ticketpdfrenderer/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 202: no error
:statuscode 400: Invalid input options
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/pdf-layout.schema.json .. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/pdf-layout.schema.json

View File

@@ -175,7 +175,7 @@ without any special behavior.
Connecting SSO providers (pretix as the SSO client) Connecting SSO providers (pretix as the SSO client)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To connect an external application as a SSO provider, go to "Customer accounts" → "SSO providers" → "Create a new SSO provider" To connect an external application as a SSO client, go to "Customer accounts" → "SSO providers" → "Create a new SSO provider"
in your organizer account. in your organizer account.
.. thumbnail:: ../../screens/organizer/customer_ssoprovider_add.png .. thumbnail:: ../../screens/organizer/customer_ssoprovider_add.png

View File

@@ -29,22 +29,22 @@ dependencies = [
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab "arabic-reshaper==3.0.0", # Support for Arabic in reportlab
"babel", "babel",
"BeautifulSoup4==4.12.*", "BeautifulSoup4==4.12.*",
"bleach==6.2.*", "bleach==5.0.*",
"celery==5.4.*", "celery==5.4.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=44.0.0", "cryptography>=3.4.2",
"css-inline==0.14.*", "css-inline==0.14.*",
"defusedcsv>=1.1.0", "defusedcsv>=1.1.0",
"Django[argon2]==4.2.*,>=4.2.15", "Django[argon2]==4.2.*,>=4.2.15",
"django-bootstrap3==24.3", "django-bootstrap3==24.2",
"django-compressor==4.5.1", "django-compressor==4.5.1",
"django-countries==7.6.*", "django-countries==7.6.*",
"django-filter==24.3", "django-filter==24.3",
"django-formset-js-improved==0.5.0.3", "django-formset-js-improved==0.5.0.3",
"django-formtools==2.5.1", "django-formtools==2.5.1",
"django-hierarkey==1.2.*", "django-hierarkey==1.2.*",
"django-hijack==3.7.*", "django-hijack==3.6.*",
"django-i18nfield==1.10.*", "django-i18nfield==1.9.*,>=1.9.4",
"django-libsass==0.9", "django-libsass==0.9",
"django-localflavor==4.0", "django-localflavor==4.0",
"django-markup", "django-markup",
@@ -53,9 +53,9 @@ dependencies = [
"django-phonenumber-field==7.3.*", "django-phonenumber-field==7.3.*",
"django-redis==5.4.*", "django-redis==5.4.*",
"django-scopes==2.0.*", "django-scopes==2.0.*",
"django-statici18n==2.6.*", "django-statici18n==2.5.*",
"djangorestframework==3.15.*", "djangorestframework==3.15.*",
"dnspython==2.7.*", "dnspython==2.6.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
"geoip2==4.*", "geoip2==4.*",
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+ "importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
@@ -74,53 +74,55 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.9.*", "PyJWT==2.9.*",
"phonenumberslite==8.13.*", "phonenumberslite==8.13.*",
"Pillow==11.1.*", "Pillow==10.4.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==5.29.*", "protobuf==5.27.*",
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==2.22", "pycparser==2.22",
"pycryptodome==3.21.*", "pycryptodome==3.20.*",
"pypdf==5.1.*", "pypdf==4.3.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab "python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*", "python-dateutil==2.9.*",
"pytz", "pytz",
"pytz-deprecation-shim==0.1.*", "pytz-deprecation-shim==0.1.*",
"pyuca", "pyuca",
"qrcode==8.0", "qrcode==7.4.*",
"redis==5.2.*", "redis==5.0.*",
"reportlab==4.2.*", "reportlab==4.2.*",
"requests==2.31.*", "requests==2.31.*",
"sentry-sdk==2.18.*", "sentry-sdk==2.13.*",
"sepaxml==2.6.*", "sepaxml==2.6.*",
"slimit",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
"tlds>=2020041600", "tlds>=2020041600",
"tqdm==4.*", "tqdm==4.*",
"ua-parser==1.0.*", "ua-parser==0.18.*",
"vat_moss_forked==2020.3.20.0.11.0", "vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*", "vobject==0.9.*",
"webauthn==2.4.*", "webauthn==2.2.*",
"zeep==4.3.*" "zeep==4.2.*"
] ]
[project.optional-dependencies] [project.optional-dependencies]
memcached = ["pylibmc"] memcached = ["pylibmc"]
dev = [ dev = [
"aiohttp==3.11.*", "aiohttp==3.10.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.26.*", "fakeredis==2.23.*",
"flake8==7.1.*", "flake8==7.1.*",
"freezegun", "freezegun",
"isort==5.13.*", "isort==5.13.*",
"pep8-naming==0.14.*", "pep8-naming==0.14.*",
"potypo", "potypo",
"pytest-asyncio>=0.24", "pytest-asyncio",
"pytest-cache", "pytest-cache",
"pytest-cov", "pytest-cov",
"pytest-django==4.*", "pytest-django==4.*",
"pytest-mock==3.14.*", "pytest-mock==3.14.*",
"pytest-rerunfailures==14.*",
"pytest-sugar", "pytest-sugar",
"pytest-xdist==3.6.*", "pytest-xdist==3.6.*",
"pytest==8.3.*", "pytest==8.3.*",

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 # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2024.12.0.dev0" __version__ = "2024.8.0.dev0"

View File

@@ -80,7 +80,6 @@ ALL_LANGUAGES = [
('de', _('German')), ('de', _('German')),
('de-informal', _('German (informal)')), ('de-informal', _('German (informal)')),
('ar', _('Arabic')), ('ar', _('Arabic')),
('eu', _('Basque')),
('ca', _('Catalan')), ('ca', _('Catalan')),
('zh-hans', _('Chinese (simplified)')), ('zh-hans', _('Chinese (simplified)')),
('zh-hant', _('Chinese (traditional)')), ('zh-hant', _('Chinese (traditional)')),

View File

@@ -27,7 +27,7 @@ from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
from pretix.api.auth.devicesecurity import ( from pretix.api.auth.devicesecurity import (
FullAccessSecurityProfile, get_all_security_profiles, DEVICE_SECURITY_PROFILES, FullAccessSecurityProfile,
) )
from pretix.base.models import Device from pretix.base.models import Device
@@ -58,8 +58,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
def authenticate(self, request): def authenticate(self, request):
r = super().authenticate(request) r = super().authenticate(request)
if r and isinstance(r[1], Device): if r and isinstance(r[1], Device):
profiles = get_all_security_profiles() profile = DEVICE_SECURITY_PROFILES.get(r[1].security_profile, FullAccessSecurityProfile)
profile = profiles.get(r[1].security_profile, FullAccessSecurityProfile())
if not profile.is_allowed(request): if not profile.is_allowed(request):
raise exceptions.PermissionDenied('Request denied by device security profile.') raise exceptions.PermissionDenied('Request denied by device security profile.')
return r return r

View File

@@ -20,40 +20,13 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging import logging
from collections import OrderedDict
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.api.signals import register_device_security_profile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ALL_PROFILES = None
class BaseSecurityProfile: class FullAccessSecurityProfile:
@property
def identifier(self) -> str:
"""
Unique identifier for this profile.
"""
raise NotImplementedError()
@property
def verbose_name(self) -> str:
"""
Human-readable name (can be a ``gettext_lazy`` object).
"""
raise NotImplementedError()
def is_allowed(self, request) -> bool:
"""
Return whether a given request should be allowed.
"""
raise NotImplementedError()
class FullAccessSecurityProfile(BaseSecurityProfile):
identifier = 'full' identifier = 'full'
verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)') verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)')
@@ -61,7 +34,7 @@ class FullAccessSecurityProfile(BaseSecurityProfile):
return True return True
class AllowListSecurityProfile(BaseSecurityProfile): class AllowListSecurityProfile:
allowlist = () allowlist = ()
def is_allowed(self, request): def is_allowed(self, request):
@@ -104,7 +77,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:blockedsecrets-list'), ('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:order-list'), ('GET', 'api-v1:order-list'),
('GET', 'api-v1:orderposition-pdf_image'), ('GET', 'api-v1:orderposition-pdf_image'),
('POST', 'api-v1:orderposition-printlog'),
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
@@ -140,7 +112,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:blockedsecrets-list'), ('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:orderposition-pdf_image'), ('GET', 'api-v1:orderposition-pdf_image'),
('POST', 'api-v1:orderposition-printlog'),
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
@@ -176,7 +147,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:blockedsecrets-list'), ('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:orderposition-pdf_image'), ('GET', 'api-v1:orderposition-pdf_image'),
('POST', 'api-v1:orderposition-printlog'),
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
@@ -184,28 +154,87 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
) )
def get_all_security_profiles(): class PretixPosSecurityProfile(AllowListSecurityProfile):
global _ALL_PROFILES identifier = 'pretixpos'
verbose_name = _('pretixPOS')
if _ALL_PROFILES: allowlist = (
return _ALL_PROFILES ('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
types = OrderedDict() ('GET', 'api-v1:idempotency.query'),
for recv, ret in register_device_security_profile.send(None): ('GET', 'api-v1:device.info'),
if isinstance(ret, (list, tuple)): ('POST', 'api-v1:device.update'),
for r in ret: ('POST', 'api-v1:device.revoke'),
types[r.identifier] = r ('POST', 'api-v1:device.roll'),
else: ('GET', 'api-v1:event-list'),
types[ret.identifier] = ret ('GET', 'api-v1:event-detail'),
_ALL_PROFILES = types ('GET', 'api-v1:subevent-list'),
return types ('GET', 'api-v1:subevent-detail'),
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
@receiver(register_device_security_profile, dispatch_uid="base_register_default_security_profiles") ('GET', 'api-v1:question-list'),
def register_default_webhook_events(sender, **kwargs): ('GET', 'api-v1:quota-list'),
return ( ('GET', 'api-v1:taxrule-list'),
FullAccessSecurityProfile(), ('GET', 'api-v1:ticketlayout-list'),
PretixScanSecurityProfile(), ('GET', 'api-v1:ticketlayoutitem-list'),
PretixScanNoSyncSecurityProfile(), ('GET', 'api-v1:badgelayout-list'),
PretixScanNoSyncNoSearchSecurityProfile(), ('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:voucher-list'),
('GET', 'api-v1:voucher-detail'),
('GET', 'api-v1:order-list'),
('POST', 'api-v1:order-list'),
('GET', 'api-v1:order-detail'),
('DELETE', 'api-v1:orderposition-detail'),
('PATCH', 'api-v1:orderposition-detail'),
('GET', 'api-v1:orderposition-list'),
('GET', 'api-v1:orderposition-answer'),
('GET', 'api-v1:orderposition-pdf_image'),
('POST', 'api-v1:order-mark-canceled'),
('POST', 'api-v1:orderpayment-list'),
('POST', 'api-v1:orderrefund-list'),
('POST', 'api-v1:orderrefund-done'),
('POST', 'api-v1:cartposition-list'),
('POST', 'api-v1:cartposition-bulk-create'),
('GET', 'api-v1:checkinlist-list'),
('POST', 'api-v1:checkinlistpos-redeem'),
('POST', 'plugins:pretix_posbackend:order.posprintlog'),
('POST', 'plugins:pretix_posbackend:order.poslock'),
('DELETE', 'plugins:pretix_posbackend:order.poslock'),
('DELETE', 'api-v1:cartposition-detail'),
('GET', 'api-v1:giftcard-list'),
('POST', 'api-v1:giftcard-transact'),
('PATCH', 'api-v1:giftcard-detail'),
('GET', 'plugins:pretix_posbackend:posclosing-list'),
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
('POST', 'plugins:pretix_posbackend:posclosing-list'),
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
('POST', 'plugins:pretix_posbackend:posdebuglogentry-list'),
('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:blockedsecrets-list'),
('GET', 'api-v1:event.settings'),
('GET', 'plugins:pretix_seating:event.event'),
('GET', 'plugins:pretix_seating:event.event.subevent'),
('GET', 'plugins:pretix_seating:event.plan'),
('GET', 'plugins:pretix_seating:selection.simple'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('GET', 'api-v1:checkinrpc.search'),
('POST', 'api-v1:reusablemedium-lookup'),
('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-list'),
) )
DEVICE_SECURITY_PROFILES = {
k.identifier: k() for k in (
FullAccessSecurityProfile,
PretixScanSecurityProfile,
PretixScanNoSyncSecurityProfile,
PretixScanNoSyncNoSearchSecurityProfile,
PretixPosSecurityProfile,
)
}

View File

@@ -88,32 +88,24 @@ class SalesChannelMigrationMixin:
} }
if data.get("all_sales_channels") and set(data["sales_channels"]) != all_channels: if data.get("all_sales_channels") and set(data["sales_channels"]) != all_channels:
raise ValidationError({ raise ValidationError(
"limit_sales_channels": [ "If 'all_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"If 'all_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to " "the list of all sales channels."
"the list of all sales channels." )
]
})
if data.get("limit_sales_channels") and set(data["sales_channels"]) != set(data["limit_sales_channels"]): if data.get("limit_sales_channels") and set(data["sales_channels"]) != set(data["limit_sales_channels"]):
raise ValidationError({ raise ValidationError(
"limit_sales_channels": [ "If 'limit_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"If 'limit_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to " "the same list."
"the same list." )
]
})
if set(data["sales_channels"]) == all_channels: if data["sales_channels"] == all_channels:
data["all_sales_channels"] = True data["all_sales_channels"] = True
data["limit_sales_channels"] = [] data["limit_sales_channels"] = []
else: else:
data["all_sales_channels"] = False data["all_sales_channels"] = False
data["limit_sales_channels"] = data["sales_channels"] data["limit_sales_channels"] = data["sales_channels"]
del data["sales_channels"] del data["sales_channels"]
if data.get("all_sales_channels"):
data["limit_sales_channels"] = []
return super().to_internal_value(data) return super().to_internal_value(data)
def to_representation(self, value): def to_representation(self, value):

View File

@@ -235,7 +235,7 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
return cid return cid
def create(self, validated_data): def create(self, validated_data):
validated_data.pop('sales_channel', None) validated_data.pop('sales_channel')
addons_data = validated_data.pop('addons', None) addons_data = validated_data.pop('addons', None)
bundled_data = validated_data.pop('bundled', None) bundled_data = validated_data.pop('bundled', None)

View File

@@ -26,22 +26,31 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, CheckinList from pretix.base.models import Checkin, CheckinList, SalesChannel
class CheckinListSerializer(I18nAwareModelSerializer): class CheckinListSerializer(I18nAwareModelSerializer):
checkin_count = serializers.IntegerField(read_only=True) checkin_count = serializers.IntegerField(read_only=True)
position_count = serializers.IntegerField(read_only=True) position_count = serializers.IntegerField(read_only=True)
auto_checkin_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta: class Meta:
model = CheckinList model = CheckinList
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count', fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
'include_pending', 'allow_multiple_entries', 'allow_entry_after_exit', 'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit',
'rules', 'exit_all_at', 'addon_match', 'ignore_in_statistics', 'consider_tickets_used') 'rules', 'exit_all_at', 'addon_match', 'ignore_in_statistics', 'consider_tickets_used')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['auto_checkin_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
if 'subevent' in self.context['request'].query_params.getlist('expand'): if 'subevent' in self.context['request'].query_params.getlist('expand'):
self.fields['subevent'] = SubEventSerializer(read_only=True) self.fields['subevent'] = SubEventSerializer(read_only=True)

View File

@@ -35,7 +35,7 @@
import logging import logging
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction from django.db import transaction
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -43,7 +43,6 @@ from django.utils.translation import gettext as _
from django_countries.serializers import CountryFieldMixin from django_countries.serializers import CountryFieldMixin
from pytz import common_timezones from pytz import common_timezones
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.fields import ChoiceField, Field from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
@@ -437,8 +436,7 @@ class CloneEventSerializer(EventSerializer):
testmode = validated_data.pop('testmode', None) testmode = validated_data.pop('testmode', None)
has_subevents = validated_data.pop('has_subevents', None) has_subevents = validated_data.pop('has_subevents', None)
tz = validated_data.pop('timezone', None) tz = validated_data.pop('timezone', None)
all_sales_channels = validated_data.pop('all_sales_channels', None) sales_channels = validated_data.pop('sales_channels', None)
limit_sales_channels = validated_data.pop('limit_sales_channels', None)
date_admission = validated_data.pop('date_admission', None) date_admission = validated_data.pop('date_admission', None)
new_event = super().create({**validated_data, 'plugins': None}) new_event = super().create({**validated_data, 'plugins': None})
@@ -451,9 +449,8 @@ class CloneEventSerializer(EventSerializer):
new_event.is_public = is_public new_event.is_public = is_public
if testmode is not None: if testmode is not None:
new_event.testmode = testmode new_event.testmode = testmode
if all_sales_channels is not None or limit_sales_channels is not None: if sales_channels is not None:
new_event.all_sales_channels = all_sales_channels new_event.sales_channels = sales_channels
new_event.limit_sales_channels.set(limit_sales_channels)
if has_subevents is not None: if has_subevents is not None:
new_event.has_subevents = has_subevents new_event.has_subevents = has_subevents
if has_subevents is not None: if has_subevents is not None:
@@ -681,8 +678,8 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class Meta: class Meta:
model = TaxRule model = TaxRule
fields = ('id', 'name', 'rate', 'code', 'price_includes_tax', 'eu_reverse_charge', 'home_country', fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name',
'internal_name', 'keep_gross_if_rate_changes', 'custom_rules') 'keep_gross_if_rate_changes', 'custom_rules')
class EventSettingsSerializer(SettingsSerializer): class EventSettingsSerializer(SettingsSerializer):
@@ -775,7 +772,6 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_address_company_required', 'invoice_address_company_required',
'invoice_address_beneficiary', 'invoice_address_beneficiary',
'invoice_address_custom_field', 'invoice_address_custom_field',
'invoice_address_custom_field_helptext',
'invoice_name_required', 'invoice_name_required',
'invoice_address_not_asked_free', 'invoice_address_not_asked_free',
'invoice_show_payments', 'invoice_show_payments',
@@ -900,7 +896,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'locale', 'locale',
'last_order_modification_date', 'last_order_modification_date',
'show_quota_left', 'show_quota_left',
'show_dates_on_frontpage',
'max_items_per_order', 'max_items_per_order',
'attendee_names_asked', 'attendee_names_asked',
'attendee_names_required', 'attendee_names_required',
@@ -920,7 +915,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'invoice_address_company_required', 'invoice_address_company_required',
'invoice_address_beneficiary', 'invoice_address_beneficiary',
'invoice_address_custom_field', 'invoice_address_custom_field',
'invoice_address_custom_field_helptext',
'invoice_name_required', 'invoice_name_required',
'invoice_address_not_asked_free', 'invoice_address_not_asked_free',
'invoice_address_from_name', 'invoice_address_from_name',
@@ -992,40 +986,6 @@ def prefetch_by_id(items, qs, id_attr, target_attr):
setattr(item, target_attr, result.get(getattr(item, id_attr))) setattr(item, target_attr, result.get(getattr(item, id_attr)))
class SeatBulkBlockInputSerializer(serializers.Serializer):
ids = serializers.ListField(child=serializers.IntegerField(), required=False, allow_empty=True)
seat_guids = serializers.ListField(child=serializers.CharField(), required=False, allow_empty=True)
def to_internal_value(self, data):
data = super().to_internal_value(data)
if data.get("seat_guids") and data.get("ids"):
raise ValidationError("Please pass either seat_guids or ids.")
if data.get("seat_guids"):
seat_ids = data["seat_guids"]
if len(seat_ids) > 10000:
raise ValidationError({"seat_guids": ["Please do not pass over 10000 seats."]})
seats = {s.seat_guid: s for s in self.context["queryset"].filter(seat_guid__in=seat_ids)}
for s in seat_ids:
if s not in seats:
raise ValidationError({"seat_guids": [f"The seat '{s}' does not exist."]})
elif data.get("ids"):
seat_ids = data["ids"]
if len(seat_ids) > 10000:
raise ValidationError({"ids": ["Please do not pass over 10000 seats."]})
seats = self.context["queryset"].in_bulk(seat_ids)
for s in seat_ids:
if s not in seats:
raise ValidationError({"ids": [f"The seat '{s}' does not exist."]})
else:
raise ValidationError("Please pass either seat_guids or ids.")
return {"seats": seats.values()}
class SeatSerializer(I18nAwareModelSerializer): class SeatSerializer(I18nAwareModelSerializer):
orderposition = serializers.IntegerField(source='orderposition_id') orderposition = serializers.IntegerField(source='orderposition_id')
cartposition = serializers.IntegerField(source='cartposition_id') cartposition = serializers.IntegerField(source='cartposition_id')

View File

@@ -19,8 +19,57 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from django.conf import settings
from django.core.validators import URLValidator from django.core.validators import URLValidator
from i18nfield.rest_framework import I18nAwareModelSerializer, I18nField from i18nfield.fields import I18nCharField, I18nTextField
from i18nfield.strings import LazyI18nString
from rest_framework.exceptions import ValidationError
from rest_framework.fields import Field
from rest_framework.serializers import ModelSerializer
class I18nField(Field):
def __init__(self, **kwargs):
self.allow_blank = kwargs.pop('allow_blank', False)
self.trim_whitespace = kwargs.pop('trim_whitespace', True)
self.max_length = kwargs.pop('max_length', None)
self.min_length = kwargs.pop('min_length', None)
super().__init__(**kwargs)
def to_representation(self, value):
if hasattr(value, 'data'):
if isinstance(value.data, dict):
return value.data
elif value.data is None:
return None
else:
return {
settings.LANGUAGE_CODE: str(value.data)
}
elif value is None:
return None
else:
return {
settings.LANGUAGE_CODE: str(value)
}
def to_internal_value(self, data):
if isinstance(data, str):
return LazyI18nString(data)
elif isinstance(data, dict):
if any([k not in dict(settings.LANGUAGES) for k in data.keys()]):
raise ValidationError('Invalid languages included.')
return LazyI18nString(data)
else:
raise ValidationError('Invalid data type.')
class I18nAwareModelSerializer(ModelSerializer):
pass
I18nAwareModelSerializer.serializer_field_mapping[I18nCharField] = I18nField
I18nAwareModelSerializer.serializer_field_mapping[I18nTextField] = I18nField
class I18nURLField(I18nField): class I18nURLField(I18nField):
@@ -35,10 +84,3 @@ class I18nURLField(I18nField):
else: else:
URLValidator()(value.data) URLValidator()(value.data)
return value return value
__all__ = [
"I18nAwareModelSerializer", # for backwards compatibility
"I18nField", # for backwards compatibility
"I18nURLField",
]

View File

@@ -369,7 +369,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
require_membership_types = validated_data.pop('require_membership_types', []) require_membership_types = validated_data.pop('require_membership_types', [])
limit_sales_channels = validated_data.pop('limit_sales_channels', []) limit_sales_channels = validated_data.pop('limit_sales_channels', [])
item = Item.objects.create(**validated_data) item = Item.objects.create(**validated_data)
if limit_sales_channels and not validated_data.get('all_sales_channels'): if limit_sales_channels:
item.limit_sales_channels.add(*limit_sales_channels) item.limit_sales_channels.add(*limit_sales_channels)
if picture: if picture:
item.picture.save(os.path.basename(picture.name), picture) item.picture.save(os.path.basename(picture.name), picture)
@@ -441,22 +441,7 @@ class ItemCategorySerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = ItemCategory model = ItemCategory
fields = ( fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon')
'id', 'name', 'internal_name', 'description', 'position',
'is_addon', 'cross_selling_mode',
'cross_selling_condition', 'cross_selling_match_products'
)
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('is_addon') and full_data.get('cross_selling_mode'):
raise ValidationError('is_addon and cross_selling_mode are mutually exclusive')
return data
class QuestionOptionSerializer(I18nAwareModelSerializer): class QuestionOptionSerializer(I18nAwareModelSerializer):

View File

@@ -55,7 +55,7 @@ from pretix.base.models import (
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
PrintLog, RevokedTicketSecret, RevokedTicketSecret,
) )
from pretix.base.pdf import get_images, get_variables from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages from pretix.base.services.cart import error_messages
@@ -273,35 +273,9 @@ class AnswerSerializer(I18nAwareModelSerializer):
class CheckinSerializer(I18nAwareModelSerializer): class CheckinSerializer(I18nAwareModelSerializer):
device_id = serializers.SlugRelatedField(
source='device',
slug_field='device_id',
read_only=True,
)
class Meta: class Meta:
model = Checkin model = Checkin
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', 'type') fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'type')
class PrintLogSerializer(serializers.ModelSerializer):
device_id = serializers.SlugRelatedField(
source='device',
slug_field='device_id',
read_only=True,
)
class Meta:
model = PrintLog
fields = (
"id",
"successful",
"datetime",
"source",
"type",
"device_id",
"info",
)
class FailedCheckinSerializer(I18nAwareModelSerializer): class FailedCheckinSerializer(I18nAwareModelSerializer):
@@ -496,7 +470,6 @@ class OrderPositionListSerializer(serializers.ListSerializer):
class OrderPositionSerializer(I18nAwareModelSerializer): class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = CheckinSerializer(many=True, read_only=True) checkins = CheckinSerializer(many=True, read_only=True)
print_logs = PrintLogSerializer(many=True, read_only=True)
answers = AnswerSerializer(many=True) answers = AnswerSerializer(many=True)
downloads = PositionDownloadsField(source='*', read_only=True) downloads = PositionDownloadsField(source='*', read_only=True)
order = serializers.SlugRelatedField(slug_field='code', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True)
@@ -511,13 +484,12 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 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', 'discount',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled',
'print_logs', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id', 'pdf_data', 'seat', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use')
'canceled', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use')
read_only_fields = ( read_only_fields = (
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret', 'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id', 'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
'pdf_data', 'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use' 'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use'
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -599,9 +571,9 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'company', 'street', 'zipcode', 'city', 'country', 'state',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
'require_attention', 'order__status', 'order__valid_if_pending', 'order__require_approval', 'order__status', 'order__valid_if_pending', 'order__require_approval', 'valid_from', 'valid_until',
'valid_from', 'valid_until', 'blocked') 'blocked')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -643,8 +615,7 @@ class OrderPaymentDateField(serializers.DateField):
class OrderFeeSerializer(I18nAwareModelSerializer): class OrderFeeSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = OrderFee model = OrderFee
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
'tax_code', 'canceled')
class PaymentURLField(serializers.URLField): class PaymentURLField(serializers.URLField):
@@ -755,12 +726,12 @@ class OrderSerializer(I18nAwareModelSerializer):
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', 'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads', 'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date' 'url', 'customer', 'valid_if_pending', 'api_meta'
) )
read_only_fields = ( read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer', 'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer',
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date' 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel'
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -1517,7 +1488,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos.answers = answers pos.answers = answers
pos.pseudonymization_id = "PREVIEW" pos.pseudonymization_id = "PREVIEW"
pos.checkins = [] pos.checkins = []
pos.print_logs = []
pos_map[pos.positionid] = pos pos_map[pos.positionid] = pos
else: else:
if pos.voucher: if pos.voucher:
@@ -1678,7 +1648,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = InvoiceLine model = InvoiceLine
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from', fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_code', 'tax_name', 'fee_type', 'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
'fee_internal_type', 'event_location') 'fee_internal_type', 'event_location')

View File

@@ -29,7 +29,6 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from pretix.api.auth.devicesecurity import get_all_security_profiles
from pretix.api.serializers import AsymmetricField from pretix.api.serializers import AsymmetricField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField from pretix.api.serializers.order import CompatibleJSONField
@@ -298,7 +297,6 @@ class DeviceSerializer(serializers.ModelSerializer):
revoked = serializers.BooleanField(read_only=True) revoked = serializers.BooleanField(read_only=True)
initialized = serializers.DateTimeField(read_only=True) initialized = serializers.DateTimeField(read_only=True)
initialization_token = serializers.DateTimeField(read_only=True) initialization_token = serializers.DateTimeField(read_only=True)
security_profile = serializers.ChoiceField(choices=[], required=False, default="full")
class Meta: class Meta:
model = Device model = Device
@@ -308,10 +306,6 @@ class DeviceSerializer(serializers.ModelSerializer):
'os_name', 'os_version', 'software_brand', 'software_version', 'security_profile' 'os_name', 'os_version', 'software_brand', 'software_version', 'security_profile'
) )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['security_profile'].choices = [(k, v.verbose_name) for k, v in get_all_security_profiles().items()]
class TeamInviteSerializer(serializers.ModelSerializer): class TeamInviteSerializer(serializers.ModelSerializer):
class Meta: class Meta:

View File

@@ -32,17 +32,10 @@ from pretix.helpers.periodic import minimum_interval
register_webhook_events = Signal() register_webhook_events = Signal()
""" """
This signal is sent out to get all known webhook events. Receivers should return an This signal is sent out to get all known webhook events. Receivers should return an
instance of a subclass of ``pretix.api.webhooks.WebhookEvent`` or a list of such instance of a subclass of pretix.api.webhooks.WebhookEvent or a list of such
instances. instances.
""" """
register_device_security_profile = Signal()
"""
This signal is sent out to get all known device security_profiles. Receivers should
return an instance of a subclass of ``pretix.api.auth.devicesecurity.BaseSecurityProfile``
or a list of such instances.
"""
@receiver(periodic_task) @receiver(periodic_task)
@scopes_disabled() @scopes_disabled()

View File

@@ -62,7 +62,6 @@ from pretix.base.models import (
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition, CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken, Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
) )
from pretix.base.models.orders import PrintLog
from pretix.base.services.checkin import ( from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
) )
@@ -116,7 +115,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
if 'subevent' in self.request.query_params.getlist('expand'): if 'subevent' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related( qs = qs.prefetch_related(
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set', 'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values', 'subevent__seat_category_mappings', 'subevent__meta_values', 'auto_checkin_sales_channels'
) )
return qs return qs
@@ -143,9 +142,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.checkins.all().delete()
instance.log_action( instance.log_action(
'pretix.event.checkinlist.deleted', 'pretix.event.checkinlist.deleted',
user=self.request.user, user=self.request.user,
@@ -368,9 +365,8 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
qs = qs.prefetch_related( qs = qs.prefetch_related(
Prefetch( Prefetch(
lookup='checkins', lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device') queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists])
), ),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question', 'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')), Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related( Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
@@ -381,8 +377,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
Prefetch( Prefetch(
'positions', 'positions',
OrderPosition.objects.prefetch_related( OrderPosition.objects.prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related('device')), Prefetch('checkins', queryset=Checkin.objects.all()),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'item', 'variation', 'answers', 'answers__options', 'answers__question', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
) )
) )
@@ -394,9 +389,8 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
qs = qs.prefetch_related( qs = qs.prefetch_related(
Prefetch( Prefetch(
lookup='checkins', lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device') queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists])
), ),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question', 'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat') ).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')

View File

@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import base64 import base64
import copy
import logging import logging
from cryptography.hazmat.backends.openssl.backend import Backend from cryptography.hazmat.backends.openssl.backend import Backend
@@ -147,8 +146,6 @@ class InitializeView(APIView):
permission_classes = () permission_classes = ()
def post(self, request, format=None): def post(self, request, format=None):
from pretix.base.signals import device_info_updated
serializer = InitializationRequestSerializer(data=request.data) serializer = InitializationRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@@ -163,8 +160,6 @@ class InitializeView(APIView):
if device.revoked: if device.revoked:
raise ValidationError({'token': ['This initialization token has been revoked.']}) raise ValidationError({'token': ['This initialization token has been revoked.']})
old_instance = copy.copy(device)
device.initialized = now() device.initialized = now()
device.hardware_brand = serializer.validated_data.get('hardware_brand') device.hardware_brand = serializer.validated_data.get('hardware_brand')
device.hardware_model = serializer.validated_data.get('hardware_model') device.hardware_model = serializer.validated_data.get('hardware_model')
@@ -179,10 +174,6 @@ class InitializeView(APIView):
device.log_action('pretix.device.initialized', data=serializer.validated_data, auth=device) device.log_action('pretix.device.initialized', data=serializer.validated_data, auth=device)
device_info_updated.send(
sender=Device, old_device=old_instance, new_device=device
)
serializer = DeviceSerializer(device) serializer = DeviceSerializer(device)
return Response(serializer.data) return Response(serializer.data)
@@ -191,12 +182,9 @@ class UpdateView(APIView):
authentication_classes = (DeviceTokenAuthentication,) authentication_classes = (DeviceTokenAuthentication,)
def post(self, request, format=None): def post(self, request, format=None):
from pretix.base.signals import device_info_updated
serializer = UpdateRequestSerializer(data=request.data) serializer = UpdateRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
device = request.auth device = request.auth
old_instance = copy.copy(device)
device.hardware_brand = serializer.validated_data.get('hardware_brand') device.hardware_brand = serializer.validated_data.get('hardware_brand')
device.hardware_model = serializer.validated_data.get('hardware_model') device.hardware_model = serializer.validated_data.get('hardware_model')
device.os_name = serializer.validated_data.get('os_name') device.os_name = serializer.validated_data.get('os_name')
@@ -212,10 +200,6 @@ class UpdateView(APIView):
device.save() device.save()
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device) device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)
device_info_updated.send(
sender=Device, old_device=old_instance, new_device=device
)
serializer = DeviceSerializer(device) serializer = DeviceSerializer(device)
return Response(serializer.data) return Response(serializer.data)

View File

@@ -40,7 +40,6 @@ from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import serializers, views, viewsets from rest_framework import serializers, views, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ( from rest_framework.exceptions import (
NotFound, PermissionDenied, ValidationError, NotFound, PermissionDenied, ValidationError,
) )
@@ -51,9 +50,8 @@ from pretix.api.auth.permission import EventCRUDPermission
from pretix.api.pagination import TotalOrderingFilter from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.event import ( from pretix.api.serializers.event import (
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer, CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
EventSettingsSerializer, ItemMetaPropertiesSerializer, EventSettingsSerializer, ItemMetaPropertiesSerializer, SeatSerializer,
SeatBulkBlockInputSerializer, SeatSerializer, SubEventSerializer, SubEventSerializer, TaxRuleSerializer,
TaxRuleSerializer,
) )
from pretix.api.views import ConditionalListView from pretix.api.views import ConditionalListView
from pretix.base.models import ( from pretix.base.models import (
@@ -239,9 +237,9 @@ class EventViewSet(viewsets.ModelViewSet):
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value} disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
changed = merge_dicts(enabled, disabled) changed = merge_dicts(enabled, disabled)
for module, operation in changed.items(): for module, action in changed.items():
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.plugins.' + operation, 'pretix.event.plugins.' + action,
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={'plugin': module} data={'plugin': module}
@@ -299,8 +297,7 @@ class EventViewSet(viewsets.ModelViewSet):
if 'all_sales_channels' in serializer.validated_data and 'sales_channels' in serializer.validated_data: if 'all_sales_channels' in serializer.validated_data and 'sales_channels' in serializer.validated_data:
new_event.all_sales_channels = serializer.validated_data['all_sales_channels'] new_event.all_sales_channels = serializer.validated_data['all_sales_channels']
if not new_event.all_sales_channels: new_event.limit_sales_channels.set(serializer.validated_data['limit_sales_channels'])
new_event.limit_sales_channels.set(serializer.validated_data['limit_sales_channels'])
else: else:
serializer.instance.set_defaults() serializer.instance.set_defaults()
@@ -373,7 +370,7 @@ with scopes_disabled():
class Meta: class Meta:
model = SubEvent model = SubEvent
fields = ['is_public', 'active', 'event__live'] fields = ['active', 'event__live']
def ends_after_qs(self, queryset, name, value): def ends_after_qs(self, queryset, name, value):
expr = Q( expr = Q(
@@ -746,24 +743,3 @@ class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
auth=self.request.auth, auth=self.request.auth,
data={"seats": [serializer.instance.pk]}, data={"seats": [serializer.instance.pk]},
) )
def bulk_change_blocked(self, blocked):
s = SeatBulkBlockInputSerializer(
data=self.request.data,
context={"event": self.request.event, "queryset": self.get_queryset()},
)
s.is_valid(raise_exception=True)
seats = s.validated_data["seats"]
for seat in seats:
seat.blocked = blocked
Seat.objects.bulk_update(seats, ["blocked"], batch_size=1000)
return Response({})
@action(methods=["POST"], detail=False)
def bulk_block(self, request, *args, **kwargs):
return self.bulk_change_blocked(True)
@action(methods=["POST"], detail=False)
def bulk_unblock(self, request, *args, **kwargs):
return self.bulk_change_blocked(False)

View File

@@ -42,7 +42,6 @@ from pretix.base.models import (
Checkin, GiftCard, GiftCardAcceptance, GiftCardTransaction, OrderPosition, Checkin, GiftCard, GiftCardAcceptance, GiftCardTransaction, OrderPosition,
ReusableMedium, ReusableMedium,
) )
from pretix.base.models.orders import PrintLog
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
@@ -79,8 +78,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
queryset=OrderPosition.objects.select_related( queryset=OrderPosition.objects.select_related(
'order', 'order__event', 'order__event__organizer', 'seat', 'order', 'order__event', 'order__event__organizer', 'seat',
).prefetch_related( ).prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related('device')), Prefetch('checkins', queryset=Checkin.objects.all()),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question', 'answers', 'answers__options', 'answers__question',
) )
), ),

View File

@@ -35,7 +35,6 @@ from django.db.models import (
from django.db.models.functions import Coalesce, Concat from django.db.models.functions import Coalesce, Concat
from django.http import FileResponse, HttpResponse from django.http import FileResponse, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import formats
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
@@ -58,8 +57,7 @@ from pretix.api.serializers.order import (
OrderPaymentCreateSerializer, OrderPaymentSerializer, OrderPaymentCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer, OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer, OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
PrintLogSerializer, RevokedTicketSecretSerializer, RevokedTicketSecretSerializer, SimulatedOrderSerializer,
SimulatedOrderSerializer,
) )
from pretix.api.serializers.orderchange import ( from pretix.api.serializers.orderchange import (
BlockNameSerializer, OrderChangeOperationSerializer, BlockNameSerializer, OrderChangeOperationSerializer,
@@ -68,7 +66,6 @@ from pretix.api.serializers.orderchange import (
OrderPositionInfoPatchSerializer, OrderPositionInfoPatchSerializer,
) )
from pretix.api.views import RichOrderingFilter from pretix.api.views import RichOrderingFilter
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue, CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue,
@@ -78,7 +75,7 @@ from pretix.base.models import (
TeamAPIToken, generate_secret, TeamAPIToken, generate_secret,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
BlockedTicketSecret, PrintLog, QuestionAnswer, RevokedTicketSecret, BlockedTicketSecret, QuestionAnswer, RevokedTicketSecret,
) )
from pretix.base.payment import PaymentException from pretix.base.payment import PaymentException
from pretix.base.pdf import get_images from pretix.base.pdf import get_images
@@ -99,6 +96,7 @@ from pretix.base.services.tickets import generate
from pretix.base.signals import ( from pretix.base.signals import (
order_modified, order_paid, order_placed, register_ticket_outputs, order_modified, order_paid, order_placed, register_ticket_outputs,
) )
from pretix.base.templatetags.money import money_filter
from pretix.control.signals import order_search_filter_q from pretix.control.signals import order_search_filter_q
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
@@ -216,7 +214,7 @@ class OrderViewSetMixin:
queryset = Order.objects.none() queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter) filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
ordering = ('datetime',) ordering = ('datetime',)
ordering_fields = ('datetime', 'code', 'status', 'last_modified', 'cancellation_date') ordering_fields = ('datetime', 'code', 'status', 'last_modified')
filterset_class = OrderFilter filterset_class = OrderFilter
lookup_field = 'code' lookup_field = 'code'
@@ -260,8 +258,7 @@ class OrderViewSetMixin:
return Prefetch( return Prefetch(
'positions', 'positions',
opq.all().prefetch_related( opq.all().prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related('device')), Prefetch('checkins', queryset=Checkin.objects.all()),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
Prefetch('item', queryset=self.request.event.items.prefetch_related( Prefetch('item', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached') Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached')
)), )),
@@ -282,8 +279,7 @@ class OrderViewSetMixin:
return Prefetch( return Prefetch(
'positions', 'positions',
opq.all().prefetch_related( opq.all().prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related('device')), Prefetch('checkins', queryset=Checkin.objects.all()),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'item', 'variation', 'item', 'variation',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')), Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
'seat', 'seat',
@@ -647,8 +643,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
order = self.get_object() order = self.get_object()
order.secret = generate_secret() order.secret = generate_secret()
for op in order.all_positions.all(): for op in order.all_positions.all():
op.web_secret = generate_secret()
op.save(update_fields=["web_secret"])
assign_ticket_secret( assign_ticket_secret(
request.event, op, force_invalidate=True, save=True request.event, op, force_invalidate=True, save=True
) )
@@ -1098,8 +1092,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
'item_meta_properties', 'item_meta_properties',
) )
qs = qs.prefetch_related( qs = qs.prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related("device")), Prefetch('checkins', queryset=Checkin.objects.all()),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
Prefetch('item', queryset=self.request.event.items.prefetch_related( Prefetch('item', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached') to_attr='meta_values_cached')
@@ -1118,7 +1111,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
Prefetch( Prefetch(
'positions', 'positions',
qs.prefetch_related( qs.prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related('device')), Prefetch('checkins', queryset=Checkin.objects.all()),
Prefetch('item', queryset=self.request.event.items.prefetch_related( Prefetch('item', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached') to_attr='meta_values_cached')
@@ -1142,8 +1135,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
) )
else: else:
qs = qs.prefetch_related( qs = qs.prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related("device")), Prefetch('checkins', queryset=Checkin.objects.all()),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question', 'answers', 'answers__options', 'answers__question',
).select_related( ).select_related(
'item', 'order', 'order__event', 'order__event__organizer', 'seat' 'item', 'order', 'order__event', 'order__event__organizer', 'seat'
@@ -1231,10 +1223,9 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
price = get_price(**kwargs) price = get_price(**kwargs)
tr = kwargs.get('tax_rule', kwargs.get('item').tax_rule) tr = kwargs.get('tax_rule', kwargs.get('item').tax_rule)
with language(data.get('locale') or self.request.event.settings.locale, self.request.event.settings.region): with language(data.get('locale') or self.request.event.settings.locale, self.request.event.settings.region):
gross_formatted = formats.localize_input(round_decimal(price.gross, self.request.event.currency))
return Response({ return Response({
'gross': price.gross, 'gross': price.gross,
'gross_formatted': gross_formatted, 'gross_formatted': money_filter(price.gross, self.request.event.currency, hide_currency=True),
'net': price.net, 'net': price.net,
'rate': price.rate, 'rate': price.rate,
'name': str(price.name), 'name': str(price.name),
@@ -1263,34 +1254,6 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
) )
return resp return resp
@action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"])
def printlog(self, request, **kwargs):
pos = self.get_object()
serializer = PrintLogSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
with transaction.atomic():
serializer.save(
position=pos,
device=request.auth if isinstance(request.auth, Device) else None,
user=request.user if request.user.is_authenticated else None,
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
)
pos.order.log_action(
"pretix.event.order.print",
data={
"position": pos.pk,
"positionid": pos.positionid,
**serializer.validated_data,
},
auth=request.auth,
user=request.user,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, url_name='pdf_image', url_path=r'pdf_image/(?P<key>[^/]+)') @action(detail=True, url_name='pdf_image', url_path=r'pdf_image/(?P<key>[^/]+)')
def pdf_image(self, request, key, **kwargs): def pdf_image(self, request, key, **kwargs):
pos = self.get_object() pos = self.get_object()

View File

@@ -32,16 +32,13 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
import string
from collections import OrderedDict from collections import OrderedDict
from importlib import import_module from importlib import import_module
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.contrib.auth.hashers import check_password, make_password from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _, ngettext
def get_auth_backends(): def get_auth_backends():
@@ -152,7 +149,7 @@ class NativeAuthBackend(BaseAuthBackend):
to log in. to log in.
""" """
d = OrderedDict([ d = OrderedDict([
('email', forms.EmailField(label=_("Email"), max_length=254, ('email', forms.EmailField(label=_("E-mail"), max_length=254,
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))), widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput, ('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
max_length=4096)), max_length=4096)),
@@ -163,62 +160,3 @@ class NativeAuthBackend(BaseAuthBackend):
u = authenticate(request=request, email=form_data['email'].lower(), password=form_data['password']) u = authenticate(request=request, email=form_data['email'].lower(), password=form_data['password'])
if u and u.auth_backend == self.identifier: if u and u.auth_backend == self.identifier:
return u return u
class NumericAndAlphabeticPasswordValidator:
def validate(self, password, user=None):
has_numeric = any(c in string.digits for c in password)
has_alpha = any(c in string.ascii_letters for c in password)
if not has_numeric or not has_alpha:
raise ValidationError(
_(
"Your password must contain both numeric and alphabetic characters.",
),
code="password_numeric_and_alphabetic",
)
def get_help_text(self):
return _(
"Your password must contain both numeric and alphabetic characters.",
)
class HistoryPasswordValidator:
def __init__(self, history_length=4):
self.history_length = history_length
def validate(self, password, user=None):
from pretix.base.models import User
if not user or not user.pk or not isinstance(user, User):
return
for hp in user.historic_passwords.order_by("-created")[:self.history_length]:
if check_password(password, hp.password):
raise ValidationError(
ngettext(
"Your password may not be the same as your previous password.",
"Your password may not be the same as one of your %(history_length)s previous passwords.",
self.history_length,
),
code="password_history",
params={"history_length": self.history_length},
)
def get_help_text(self):
return ngettext(
"Your password may not be the same as your previous password.",
"Your password may not be the same as one of your %(history_length)s previous passwords.",
self.history_length,
) % {"history_length": self.history_length}
def password_changed(self, password, user=None):
if not user:
pass
user.historic_passwords.create(password=make_password(password))
user.historic_passwords.filter(
pk__in=user.historic_passwords.order_by("-created")[self.history_length:].values_list("pk", flat=True),
).delete()

View File

@@ -46,8 +46,6 @@ This module contains utilities for implementing OpenID Connect for customer auth
as well as an OpenID Provider (OP). as well as an OpenID Provider (OP).
""" """
pretix_token_endpoint_auth_methods = ['client_secret_basic', 'client_secret_post']
def _urljoin(base, path): def _urljoin(base, path):
if not base.endswith("/"): if not base.endswith("/"):
@@ -129,16 +127,6 @@ def oidc_validate_and_complete_config(config):
fields=", ".join(provider_config.get("claims_supported", [])) fields=", ".join(provider_config.get("claims_supported", []))
)) ))
if "token_endpoint_auth_methods_supported" in provider_config:
token_endpoint_auth_methods_supported = provider_config.get("token_endpoint_auth_methods_supported",
["client_secret_basic"])
if not any(x in pretix_token_endpoint_auth_methods for x in token_endpoint_auth_methods_supported):
raise ValidationError(
_(f'No supported Token Endpoint Auth Methods supported: {token_endpoint_auth_methods_supported}').format(
token_endpoint_auth_methods_supported=", ".join(token_endpoint_auth_methods_supported)
)
)
config['provider_config'] = provider_config config['provider_config'] = provider_config
return config return config
@@ -159,18 +147,6 @@ def oidc_authorize_url(provider, state, redirect_uri):
def oidc_validate_authorization(provider, code, redirect_uri): def oidc_validate_authorization(provider, code, redirect_uri):
endpoint = provider.configuration['provider_config']['token_endpoint'] endpoint = provider.configuration['provider_config']['token_endpoint']
# Wall of shame and RFC ignorant IDPs
if endpoint == 'https://www.linkedin.com/oauth/v2/accessToken':
token_endpoint_auth_method = 'client_secret_post'
else:
token_endpoint_auth_methods = provider.configuration['provider_config'].get(
'token_endpoint_auth_methods_supported', ['client_secret_basic']
)
token_endpoint_auth_method = [
x for x in pretix_token_endpoint_auth_methods if x in token_endpoint_auth_methods
][0]
params = { params = {
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
# https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint # https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint
@@ -178,11 +154,6 @@ def oidc_validate_authorization(provider, code, redirect_uri):
'code': code, 'code': code,
'redirect_uri': redirect_uri, 'redirect_uri': redirect_uri,
} }
if token_endpoint_auth_method == 'client_secret_post':
params['client_id'] = provider.configuration['client_id']
params['client_secret'] = provider.configuration['client_secret']
try: try:
resp = requests.post( resp = requests.post(
endpoint, endpoint,
@@ -190,10 +161,7 @@ def oidc_validate_authorization(provider, code, redirect_uri):
headers={ headers={
'Accept': 'application/json', 'Accept': 'application/json',
}, },
auth=( auth=(provider.configuration['client_id'], provider.configuration['client_secret']),
provider.configuration['client_id'],
provider.configuration['client_secret']
) if token_endpoint_auth_method == 'client_secret_basic' else None,
) )
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()

View File

@@ -35,7 +35,6 @@ from django.utils.translation import get_language, gettext_lazy as _
from pretix.base.models import Event from pretix.base.models import Event
from pretix.base.signals import register_html_mail_renderers from pretix.base.signals import register_html_mail_renderers
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.helpers.format import SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext get_available_placeholders, PlaceholderContext
@@ -69,7 +68,7 @@ def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
class BaseHTMLMailRenderer: class BaseHTMLMailRenderer:
""" """
This is the base class for all HTML email renderers. This is the base class for all HTML e-mail renderers.
""" """
def __init__(self, event: Event, organizer=None): def __init__(self, event: Event, organizer=None):
@@ -80,7 +79,7 @@ class BaseHTMLMailRenderer:
return self.identifier return self.identifier
def render(self, plain_body: str, plain_signature: str, subject: str, order=None, def render(self, plain_body: str, plain_signature: str, subject: str, order=None,
position=None, context=None) -> str: position=None) -> str:
""" """
This method should generate the HTML part of the email. This method should generate the HTML part of the email.
@@ -89,7 +88,6 @@ class BaseHTMLMailRenderer:
:param subject: The email subject. :param subject: The email subject.
:param order: The order if this email is connected to one, otherwise ``None``. :param order: The order if this email is connected to one, otherwise ``None``.
:param position: The order position if this email is connected to one, otherwise ``None``. :param position: The order position if this email is connected to one, otherwise ``None``.
:param context: Context to use to render placeholders in the plain body
:return: An HTML string :return: An HTML string
""" """
raise NotImplementedError() raise NotImplementedError()
@@ -136,10 +134,8 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def compile_markdown(self, plaintext): def compile_markdown(self, plaintext):
return markdown_compile_email(plaintext) return markdown_compile_email(plaintext)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str:
body_md = self.compile_markdown(plain_body) body_md = self.compile_markdown(plain_body)
if context:
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL, 'site_url': settings.SITE_URL,

View File

@@ -64,7 +64,7 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
_('Customer ID'), _('Customer ID'),
_('SSO provider'), _('SSO provider'),
_('External identifier'), _('External identifier'),
_('Email'), _('E-mail'),
_('Phone number'), _('Phone number'),
_('Full name'), _('Full name'),
] ]

View File

@@ -199,7 +199,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Invoice number'), _('Invoice number'),
_('Date'), _('Date'),
_('Order code'), _('Order code'),
_('Email address'), _('E-mail address'),
_('Invoice type'), _('Invoice type'),
_('Cancellation of'), _('Cancellation of'),
_('Language'), _('Language'),
@@ -326,7 +326,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Event start date'), _('Event start date'),
_('Date'), _('Date'),
_('Order code'), _('Order code'),
_('Email address'), _('E-mail address'),
_('Invoice type'), _('Invoice type'),
_('Cancellation of'), _('Cancellation of'),
_('Invoice sender:') + ' ' + _('Name'), _('Invoice sender:') + ' ' + _('Name'),

View File

@@ -284,7 +284,7 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Comment')) headers.append(_('Comment'))
headers.append(_('Follow-up date')) headers.append(_('Follow-up date'))
headers.append(_('Positions')) headers.append(_('Positions'))
headers.append(_('Email address verified')) headers.append(_('E-mail address verified'))
headers.append(_('External customer ID')) headers.append(_('External customer ID'))
headers.append(_('Payment providers')) headers.append(_('Payment providers'))
if form_data.get('include_payment_amounts'): if form_data.get('include_payment_amounts'):
@@ -655,7 +655,7 @@ class OrderListExporter(MultiSheetListExporter):
headers += [ headers += [
_('Sales channel'), _('Sales channel'),
_('Order locale'), _('Order locale'),
_('Email address verified'), _('E-mail address verified'),
_('External customer ID'), _('External customer ID'),
_('Check-in lists'), _('Check-in lists'),
_('Payment providers'), _('Payment providers'),

View File

@@ -100,7 +100,7 @@ class MarkdownTextarea(forms.Textarea):
class I18nMarkdownTextarea(i18nfield.forms.I18nTextarea): class I18nMarkdownTextarea(i18nfield.forms.I18nTextarea):
def format_output(self, rendered_widgets, id_) -> str: def format_output(self, rendered_widgets) -> str:
rendered_widgets = rendered_widgets + [ rendered_widgets = rendered_widgets + [
'<div class="i18n-field-markdown-note">%s</div>' % ( '<div class="i18n-field-markdown-note">%s</div>' % (
_("You can use {markup_name} in this field.").format( _("You can use {markup_name} in this field.").format(
@@ -108,11 +108,11 @@ class I18nMarkdownTextarea(i18nfield.forms.I18nTextarea):
) )
) )
] ]
return super().format_output(rendered_widgets, id_) return super().format_output(rendered_widgets)
class I18nMarkdownTextInput(i18nfield.forms.I18nTextInput): class I18nMarkdownTextInput(i18nfield.forms.I18nTextInput):
def format_output(self, rendered_widgets, id_) -> str: def format_output(self, rendered_widgets) -> str:
rendered_widgets = rendered_widgets + [ rendered_widgets = rendered_widgets + [
'<div class="i18n-field-markdown-note">%s</div>' % ( '<div class="i18n-field-markdown-note">%s</div>' % (
_("You can use {markup_name} in this field.").format( _("You can use {markup_name} in this field.").format(
@@ -120,7 +120,7 @@ class I18nMarkdownTextInput(i18nfield.forms.I18nTextInput):
) )
) )
] ]
return super().format_output(rendered_widgets, id_) return super().format_output(rendered_widgets)
SECRET_REDACTED = '*****' SECRET_REDACTED = '*****'

View File

@@ -254,7 +254,7 @@ class PasswordRecoverForm(forms.Form):
class PasswordForgotForm(forms.Form): class PasswordForgotForm(forms.Form):
email = forms.EmailField( email = forms.EmailField(
label=_('Email'), label=_('E-mail'),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@@ -54,7 +54,6 @@ from django.core.validators import (
from django.db.models import QuerySet from django.db.models import QuerySet
from django.forms import Select, widgets from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.urls import reverse
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@@ -78,7 +77,7 @@ from pretix.base.i18n import (
get_babel_locale, get_language_without_region, language, get_babel_locale, get_language_without_region, language,
) )
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models.tax import ask_for_vat_id from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id
from pretix.base.services.tax import ( from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, validate_vat_id, VATIDFinalError, VATIDTemporaryError, validate_vat_id,
) )
@@ -277,10 +276,6 @@ class NamePartsFormField(forms.MultiValueField):
return value return value
def name_parts_is_empty(name_parts_dict):
return not any(k != "_scheme" and v for k, v in name_parts_dict.items())
class WrappedPhonePrefixSelect(Select): class WrappedPhonePrefixSelect(Select):
initial = None initial = None
@@ -607,7 +602,6 @@ class BaseQuestionsForm(forms.Form):
questions = pos.item.questions_to_ask questions = pos.item.questions_to_ask
event = kwargs.pop('event') event = kwargs.pop('event')
self.all_optional = kwargs.pop('all_optional', False) self.all_optional = kwargs.pop('all_optional', False)
self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -682,7 +676,7 @@ class BaseQuestionsForm(forms.Form):
if item.ask_attendee_data and event.settings.attendee_addresses_asked: if item.ask_attendee_data and event.settings.attendee_addresses_asked:
add_fields['street'] = forms.CharField( add_fields['street'] = forms.CharField(
required=self.attendee_addresses_required, required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Address'), label=_('Address'),
widget=forms.Textarea(attrs={ widget=forms.Textarea(attrs={
'rows': 2, 'rows': 2,
@@ -692,7 +686,7 @@ class BaseQuestionsForm(forms.Form):
initial=(cartpos.street if cartpos else orderpos.street), initial=(cartpos.street if cartpos else orderpos.street),
) )
add_fields['zipcode'] = forms.CharField( add_fields['zipcode'] = forms.CharField(
required=False, required=event.settings.attendee_addresses_required and not self.all_optional,
max_length=30, max_length=30,
label=_('ZIP code'), label=_('ZIP code'),
initial=(cartpos.zipcode if cartpos else orderpos.zipcode), initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
@@ -701,7 +695,7 @@ class BaseQuestionsForm(forms.Form):
}), }),
) )
add_fields['city'] = forms.CharField( add_fields['city'] = forms.CharField(
required=False, required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('City'), label=_('City'),
max_length=255, max_length=255,
initial=(cartpos.city if cartpos else orderpos.city), initial=(cartpos.city if cartpos else orderpos.city),
@@ -713,12 +707,11 @@ class BaseQuestionsForm(forms.Form):
add_fields['country'] = CountryField( add_fields['country'] = CountryField(
countries=CachedCountries countries=CachedCountries
).formfield( ).formfield(
required=self.attendee_addresses_required, required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Country'), label=_('Country'),
initial=country, initial=country,
widget=forms.Select(attrs={ widget=forms.Select(attrs={
'autocomplete': 'country', 'autocomplete': 'country',
'data-country-information-url': reverse('js_helpers.states'),
}), }),
) )
c = [('', pgettext_lazy('address', 'Select state'))] c = [('', pgettext_lazy('address', 'Select state'))]
@@ -953,9 +946,9 @@ class BaseQuestionsForm(forms.Form):
d = super().clean() d = super().clean()
if self.address_validation: if self.address_validation:
self.cleaned_data = d = validate_address(d, all_optional=not self.attendee_addresses_required) self.cleaned_data = d = validate_address(d, True)
if d.get('street') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS: if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not d.get('state'): if not d.get('state'):
self.add_error('state', _('This field is required.')) self.add_error('state', _('This field is required.'))
@@ -1012,7 +1005,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'street': forms.Textarea(attrs={ 'street': forms.Textarea(attrs={
'rows': 2, 'rows': 2,
'placeholder': _('Street and Number'), 'placeholder': _('Street and Number'),
'autocomplete': 'street-address', 'autocomplete': 'street-address'
}), }),
'beneficiary': forms.Textarea(attrs={'rows': 3}), 'beneficiary': forms.Textarea(attrs={'rows': 3}),
'country': forms.Select(attrs={ 'country': forms.Select(attrs={
@@ -1028,25 +1021,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'data-display-dependency': '#id_is_business_1', 'data-display-dependency': '#id_is_business_1',
'autocomplete': 'organization', 'autocomplete': 'organization',
}), }),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}), 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-with-vat-id': ','.join(VAT_ID_COUNTRIES)}),
'internal_reference': forms.TextInput, 'internal_reference': forms.TextInput,
} }
labels = { labels = {
'is_business': '' 'is_business': ''
} }
@property
def ask_vat_id(self):
return self.event.settings.invoice_address_vatid
@property
def address_required(self):
return self.event.settings.invoice_address_required
@property
def company_required(self):
return self.event.settings.invoice_address_company_required
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.event = event = kwargs.pop('event') self.event = event = kwargs.pop('event')
self.request = kwargs.pop('request', None) self.request = kwargs.pop('request', None)
@@ -1058,11 +1039,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
kwargs['initial']['country'] = guess_country_from_request(self.request, self.event) kwargs['initial']['country'] = guess_country_from_request(self.request, self.event)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid:
self.fields["company"].widget.attrs["data-display-dependency"] = f'#id_{self.add_prefix("is_business")}_1'
self.fields["vat_id"].widget.attrs["data-display-dependency"] = f'#id_{self.add_prefix("is_business")}_1'
if not self.ask_vat_id:
del self.fields['vat_id'] del self.fields['vat_id']
elif self.validate_vat_id: elif self.validate_vat_id:
self.fields['vat_id'].help_text = '<br/>'.join([ self.fields['vat_id'].help_text = '<br/>'.join([
@@ -1078,7 +1055,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
]) ])
self.fields['country'].choices = CachedCountries() self.fields['country'].choices = CachedCountries()
self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states')
c = [('', pgettext_lazy('address', 'Select state'))] c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = self.prefix + '-' if self.prefix else '' fprefix = self.prefix + '-' if self.prefix else ''
@@ -1107,22 +1083,18 @@ class BaseInvoiceAddressForm(forms.ModelForm):
) )
self.fields['state'].widget.is_required = True self.fields['state'].widget.is_required = True
self.fields['street'].required = False
self.fields['zipcode'].required = False
self.fields['city'].required = False
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected. # Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data: if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data:
self.data = self.data.copy() self.data = self.data.copy()
del self.data[fprefix + 'vat_id'] del self.data[fprefix + 'vat_id']
if not self.address_required or self.all_optional: if not event.settings.invoice_address_required or self.all_optional:
for k, f in self.fields.items(): for k, f in self.fields.items():
f.required = False f.required = False
f.widget.is_required = False f.widget.is_required = False
if 'required' in f.widget.attrs: if 'required' in f.widget.attrs:
del f.widget.attrs['required'] del f.widget.attrs['required']
elif self.company_required and not self.all_optional: elif event.settings.invoice_address_company_required and not self.all_optional:
self.initial['is_business'] = True self.initial['is_business'] = True
self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True) self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True)
@@ -1139,18 +1111,17 @@ class BaseInvoiceAddressForm(forms.ModelForm):
label=_('Name'), label=_('Name'),
initial=self.instance.name_parts, initial=self.instance.name_parts,
) )
if self.address_required and not self.company_required and not self.all_optional: if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
if not event.settings.invoice_name_required: if not event.settings.invoice_name_required:
self.fields['name_parts'].widget.attrs['data-required-if'] = f'#id_{self.add_prefix("is_business")}_0' self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1' self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
self.fields['company'].widget.attrs['data-required-if'] = f'#id_{self.add_prefix("is_business")}_1' self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
if not event.settings.invoice_address_beneficiary: if not event.settings.invoice_address_beneficiary:
del self.fields['beneficiary'] del self.fields['beneficiary']
if event.settings.invoice_address_custom_field: if event.settings.invoice_address_custom_field:
self.fields['custom_field'].label = event.settings.invoice_address_custom_field self.fields['custom_field'].label = event.settings.invoice_address_custom_field
self.fields['custom_field'].help_text = event.settings.invoice_address_custom_field_helptext
else: else:
del self.fields['custom_field'] del self.fields['custom_field']
@@ -1163,19 +1134,16 @@ class BaseInvoiceAddressForm(forms.ModelForm):
validate_address # local import to prevent impact on startup time validate_address # local import to prevent impact on startup time
data = self.cleaned_data data = self.cleaned_data
if not data.get('is_business'): if not data.get('is_business'):
data['company'] = '' data['company'] = ''
data['vat_id'] = '' data['vat_id'] = ''
if data.get('is_business') and not ask_for_vat_id(data.get('country')): if data.get('is_business') and not ask_for_vat_id(data.get('country')):
data['vat_id'] = '' data['vat_id'] = ''
if self.address_validation and self.address_required and not self.all_optional: if self.event.settings.invoice_address_required:
if data.get('is_business') and not data.get('company'): if data.get('is_business') and not data.get('company'):
raise ValidationError({"company": _('You need to provide a company name.')}) raise ValidationError(_('You need to provide a company name.'))
if not data.get('is_business') and name_parts_is_empty(data.get('name_parts', {})): if not data.get('is_business') and not data.get('name_parts'):
raise ValidationError(_('You need to provide your name.')) raise ValidationError(_('You need to provide your name.'))
if not data.get('street') and not data.get('zipcode') and not data.get('city'):
raise ValidationError({"street": _('This field is required.')})
if 'vat_id' in self.changed_data or not data.get('vat_id'): if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
@@ -1187,7 +1155,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if all( if all(
not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts') not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts')
) and name_parts_is_empty(data.get('name_parts', {})): ) and len(data.get('name_parts', {})) == 1:
# Do not save the country if it is the only field set -- we don't know the user even checked it! # Do not save the country if it is the only field set -- we don't know the user even checked it!
self.cleaned_data['country'] = '' self.cleaned_data['country'] = ''

View File

@@ -48,10 +48,10 @@ from pretix.control.forms import SingleLanguageWidget
class UserSettingsForm(forms.ModelForm): class UserSettingsForm(forms.ModelForm):
error_messages = { error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. " 'duplicate_identifier': _("There already is an account associated with this e-mail address. "
"Please choose a different one."), "Please choose a different one."),
'pw_current': _("Please enter your current password if you want to change your email address " 'pw_current': _("Please enter your current password if you want to change your e-mail "
"or password."), "address or password."),
'pw_current_wrong': _("The current password you entered was not correct."), 'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"), 'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."), 'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),

View File

@@ -289,7 +289,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
def _clean_text(self, text, tags=None): def _clean_text(self, text, tags=None):
return self._normalize(bleach.clean( return self._normalize(bleach.clean(
text, text,
tags=set(tags) if tags else set() tags=tags or []
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')) ).strip().replace('<br>', '<br />').replace('\n', '<br />\n'))
@@ -461,7 +461,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_event(self, canvas): def _draw_event(self, canvas):
def shorten(txt): def shorten(txt):
txt = str(txt) txt = str(txt)
txt = bleach.clean(txt, tags=set()).strip() txt = bleach.clean(txt, tags=[]).strip()
p = Paragraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal']) p = Paragraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height)

View File

@@ -36,7 +36,6 @@ import time
import traceback import traceback
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.dispatch.dispatcher import NO_RECEIVERS from django.dispatch.dispatcher import NO_RECEIVERS
@@ -51,23 +50,17 @@ class Command(BaseCommand):
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('--tasks', action='store', type=str, help='Only execute the tasks with this name ' parser.add_argument('--tasks', action='store', type=str, help='Only execute the tasks with this name '
'(dotted path, comma separation)') '(dotted path, comma separation)')
parser.add_argument('--list-tasks', action='store_true', help='Only list all tasks')
parser.add_argument('--exclude', action='store', type=str, help='Exclude the tasks with this name ' parser.add_argument('--exclude', action='store', type=str, help='Exclude the tasks with this name '
'(dotted path, comma separation)') '(dotted path, comma separation)')
def handle(self, *args, **options): def handle(self, *args, **options):
verbosity = int(options['verbosity']) verbosity = int(options['verbosity'])
cache.set("pretix_runperiodic_executed", True, 3600 * 12)
if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS: if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS:
return return
for receiver in periodic_task._live_receivers(self): for receiver in periodic_task._live_receivers(self):
name = f'{receiver.__module__}.{receiver.__name__}' name = f'{receiver.__module__}.{receiver.__name__}'
if options['list_tasks']:
print(name)
continue
if options.get('tasks'): if options.get('tasks'):
if name not in options.get('tasks').split(','): if name not in options.get('tasks').split(','):
continue continue
@@ -81,7 +74,7 @@ class Command(BaseCommand):
try: try:
r = receiver(signal=periodic_task, sender=self) r = receiver(signal=periodic_task, sender=self)
except Exception as err: except Exception as err:
if isinstance(err, KeyboardInterrupt): if isinstance(Exception, KeyboardInterrupt):
raise err raise err
if settings.SENTRY_ENABLED: if settings.SENTRY_ENABLED:
from sentry_sdk import capture_exception from sentry_sdk import capture_exception

View File

@@ -37,16 +37,6 @@ class BaseMediaType:
def verbose_name(self): def verbose_name(self):
raise NotImplementedError() raise NotImplementedError()
@property
def icon(self):
"""
This can be:
- The name of a Font Awesome icon to represent this channel type.
- The name of a SVG icon file that is resolvable through the static file system. We recommend to design for a size of 18x14 pixels.
"""
return "circle"
def generate_identifier(self, organizer): def generate_identifier(self, organizer):
if self.medium_created_by_server: if self.medium_created_by_server:
raise NotImplementedError() raise NotImplementedError()
@@ -69,7 +59,6 @@ class BaseMediaType:
class BarcodePlainMediaType(BaseMediaType): class BarcodePlainMediaType(BaseMediaType):
identifier = 'barcode' identifier = 'barcode'
verbose_name = _('Barcode / QR-Code') verbose_name = _('Barcode / QR-Code')
icon = 'qrcode'
medium_created_by_server = True medium_created_by_server = True
supports_giftcard = False supports_giftcard = False
supports_orderposition = True supports_orderposition = True
@@ -86,7 +75,6 @@ class BarcodePlainMediaType(BaseMediaType):
class NfcUidMediaType(BaseMediaType): class NfcUidMediaType(BaseMediaType):
identifier = 'nfc_uid' identifier = 'nfc_uid'
verbose_name = _('NFC UID-based') verbose_name = _('NFC UID-based')
icon = 'pretixbase/img/media/nfc_uid.svg'
medium_created_by_server = False medium_created_by_server = False
supports_giftcard = True supports_giftcard = True
supports_orderposition = False supports_orderposition = False
@@ -126,7 +114,6 @@ class NfcUidMediaType(BaseMediaType):
class NfcMf0aesMediaType(BaseMediaType): class NfcMf0aesMediaType(BaseMediaType):
identifier = 'nfc_mf0aes' identifier = 'nfc_mf0aes'
verbose_name = 'NFC Mifare Ultralight AES' verbose_name = 'NFC Mifare Ultralight AES'
icon = 'pretixbase/img/media/nfc_secure.svg'
medium_created_by_server = False medium_created_by_server = False
supports_giftcard = True supports_giftcard = True
supports_orderposition = False supports_orderposition = False

View File

@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
('password', models.CharField(verbose_name='password', max_length=128)), ('password', models.CharField(verbose_name='password', max_length=128)),
('last_login', models.DateTimeField(verbose_name='last login', blank=True, null=True)), ('last_login', models.DateTimeField(verbose_name='last login', blank=True, null=True)),
('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')), ('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')),
('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='Email', null=True, ('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='E-mail', null=True,
db_index=True)), db_index=True)),
('givenname', models.CharField(verbose_name='Given name', max_length=255, blank=True, null=True)), ('givenname', models.CharField(verbose_name='Given name', max_length=255, blank=True, null=True)),
('familyname', models.CharField(verbose_name='Family name', max_length=255, blank=True, null=True)), ('familyname', models.CharField(verbose_name='Family name', max_length=255, blank=True, null=True)),

View File

@@ -9,7 +9,6 @@ from decimal import Decimal
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
import i18nfield.fields import i18nfield.fields
from argon2.exceptions import HashingError
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.db import migrations, models from django.db import migrations, models
@@ -26,14 +25,7 @@ def initial_user(apps, schema_editor):
user = User(email='admin@localhost') user = User(email='admin@localhost')
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
try: user.password = make_password('admin')
user.password = make_password('admin')
except HashingError:
raise Exception(
"Could not hash password of initial user with argon2id. If this is a system with less than 8 CPU cores, "
"you might need to disable argon2id by setting `passwords_argon2=off` in the `[django]` section of the "
"pretix.cfg configuration file."
)
user.save() user.save()
@@ -56,7 +48,7 @@ class Migration(migrations.Migration):
('password', models.CharField(max_length=128, verbose_name='password')), ('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='Email')), ('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='E-mail')),
('givenname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Given name')), ('givenname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Given name')),
('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')), ('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')),
('is_active', models.BooleanField(default=True, verbose_name='Is active')), ('is_active', models.BooleanField(default=True, verbose_name='Is active')),
@@ -240,7 +232,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=16, verbose_name='Order code')), ('code', models.CharField(max_length=16, verbose_name='Order code')),
('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')), ('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')), ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')),
('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')), ('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')),
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)), ('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
('datetime', models.DateTimeField(verbose_name='Date')), ('datetime', models.DateTimeField(verbose_name='Date')),

View File

@@ -187,7 +187,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=16, verbose_name='Order code')), ('code', models.CharField(max_length=16, verbose_name='Order code')),
('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')), ('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')), ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')),
('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')), ('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')),
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)), ('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
('datetime', models.DateTimeField(verbose_name='Date')), ('datetime', models.DateTimeField(verbose_name='Date')),

View File

@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')), ('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')),
('email', models.EmailField(max_length=254, verbose_name='Email address')), ('email', models.EmailField(max_length=254, verbose_name='E-mail address')),
('locale', models.CharField(default='en', max_length=190)), ('locale', models.CharField(default='en', max_length=190)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')), ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')),
('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')), ('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')),

View File

@@ -35,7 +35,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')), ('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')),
('email', models.EmailField(max_length=254, verbose_name='Email address')), ('email', models.EmailField(max_length=254, verbose_name='E-mail address')),
('locale', models.CharField(default='en', max_length=190)), ('locale', models.CharField(default='en', max_length=190)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')), ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')),
('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')), ('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')),

View File

@@ -163,7 +163,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(max_length=255)), ('action_type', models.CharField(max_length=255)),
('method', models.CharField(choices=[('mail', 'Email')], max_length=255)), ('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to='pretixbase.Event')), to='pretixbase.Event')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),

View File

@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(max_length=255)), ('action_type', models.CharField(max_length=255)),
('method', models.CharField(choices=[('mail', 'Email')], max_length=255)), ('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')), ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('enabled', models.BooleanField(default=True)), ('enabled', models.BooleanField(default=True)),

View File

@@ -1,36 +0,0 @@
# Generated by Django 4.2.15 on 2024-09-16 15:10
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0269_order_api_meta"),
]
operations = [
migrations.CreateModel(
name="HistoricPassword",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("created", models.DateTimeField(auto_now_add=True)),
("password", models.CharField(max_length=128)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="historic_passwords",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@@ -1,32 +0,0 @@
# Generated by Django 4.2.11 on 2024-05-27 13:19
from django.db import migrations, models
import pretix.base.models.orders
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0270_historicpassword"),
]
operations = [
migrations.AddField(
model_name="itemcategory",
name="cross_selling_condition",
field=models.CharField(null=True, max_length=10),
),
migrations.AddField(
model_name="itemcategory",
name="cross_selling_mode",
field=models.CharField(null=True, max_length=5),
),
migrations.AddField(
model_name="itemcategory",
name="cross_selling_match_products",
field=models.ManyToManyField(
related_name="matched_by_cross_selling_categories", to="pretixbase.item"
),
),
]

View File

@@ -1,79 +0,0 @@
# Generated by Django 4.2.16 on 2024-09-19 10:41
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
("pretixbase", "0271_itemcategory_cross_selling"),
]
operations = [
migrations.CreateModel(
name="PrintLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("datetime", models.DateTimeField(default=django.utils.timezone.now)),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("successful", models.BooleanField(default=True)),
("source", models.CharField(max_length=255)),
("type", models.CharField(max_length=255)),
("info", models.JSONField(default=dict)),
(
"api_token",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="pretixbase.teamapitoken",
),
),
(
"device",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="print_logs",
to="pretixbase.device",
),
),
(
"oauth_application",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL,
),
),
(
"position",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="print_logs",
to="pretixbase.orderposition",
),
),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="print_logs",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ("-datetime",),
},
),
]

View File

@@ -1,48 +0,0 @@
# Generated by Django 4.2.16 on 2024-10-29 15:03
from django.db import migrations
def migrate_autocheckin(apps, schema_editor):
CheckinList = apps.get_model("pretixbase", "CheckinList")
AutoCheckinRule = apps.get_model("autocheckin", "AutoCheckinRule")
for cl in CheckinList.objects.filter(auto_checkin_sales_channels__isnull=False).select_related("event", "event__organizer"):
sales_channels = cl.auto_checkin_sales_channels.all()
all_sales_channels = cl.event.organizer.sales_channels.all()
if "pretix.plugins.autocheckin" not in cl.event.plugins:
cl.event.plugins = cl.event.plugins + ",pretix.plugins.autocheckin"
cl.event.save()
r = AutoCheckinRule.objects.get_or_create(
list=cl,
event=cl.event,
all_products=True,
all_payment_methods=True,
defaults=dict(
mode="placed",
all_sales_channels=len(sales_channels) == len(all_sales_channels),
)
)[0]
if len(sales_channels) != len(all_sales_channels):
r.limit_sales_channels.set(sales_channels)
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0272_printlog"),
("autocheckin", "0001_initial"),
]
operations = [
migrations.RunPython(
migrate_autocheckin,
migrations.RunPython.noop,
),
migrations.RemoveField(
model_name="checkinlist",
name="auto_checkin_sales_channels",
),
]

View File

@@ -1,41 +0,0 @@
# Generated by Django 4.2.8 on 2024-07-02 10:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0273_remove_checkinlist_auto_checkin_sales_channels",
),
]
operations = [
migrations.AddField(
model_name="invoiceline",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="orderfee",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="orderposition",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="taxrule",
name="code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="transaction",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
]

View File

@@ -213,13 +213,7 @@ class DatetimeColumnMixin:
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
else: else:
try: raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
d = datetime.datetime.fromisoformat(value)
if not d.tzinfo:
d = d.replace(tzinfo=self.timezone)
return d
except (ValueError, TypeError):
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
class DecimalColumnMixin: class DecimalColumnMixin:
@@ -256,9 +250,6 @@ class SubeventColumnMixin:
] ]
def clean(self, value, previous_values): def clean(self, value, previous_values):
if not value:
return None
if value in self._subevent_cache: if value in self._subevent_cache:
return self._subevent_cache[value] return self._subevent_cache[value]

View File

@@ -40,8 +40,8 @@ from phonenumbers import SUPPORTED_REGIONS
from pretix.base.forms.questions import guess_country from pretix.base.forms.questions import guess_country
from pretix.base.modelimport import ( from pretix.base.modelimport import (
BooleanColumnMixin, DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin,
SubeventColumnMixin, i18n_flat, i18n_flat,
) )
from pretix.base.models import ( from pretix.base.models import (
Customer, ItemVariation, OrderPosition, Question, QuestionAnswer, Customer, ItemVariation, OrderPosition, Question, QuestionAnswer,
@@ -56,7 +56,7 @@ from pretix.base.signals import order_import_columns
class EmailColumn(ImportColumn): class EmailColumn(ImportColumn):
identifier = 'email' identifier = 'email'
verbose_name = gettext_lazy('Email address') verbose_name = gettext_lazy('E-mail address')
def clean(self, value, previous_values): def clean(self, value, previous_values):
if value: if value:
@@ -322,7 +322,7 @@ class AttendeeNamePart(ImportColumn):
class AttendeeEmail(ImportColumn): class AttendeeEmail(ImportColumn):
identifier = 'attendee_email' identifier = 'attendee_email'
verbose_name = gettext_lazy('Attendee email address') verbose_name = gettext_lazy('Attendee e-mail address')
def clean(self, value, previous_values): def clean(self, value, previous_values):
if value: if value:
@@ -441,7 +441,6 @@ class Price(DecimalColumnMixin, ImportColumn):
position.price = p.gross position.price = p.gross
position.tax_rule = position.item.tax_rule position.tax_rule = position.item.tax_rule
position.tax_rate = p.rate position.tax_rate = p.rate
position.tax_code = p.code
position.tax_value = p.tax position.tax_value = p.tax
@@ -585,7 +584,7 @@ class SeatColumn(ImportColumn):
raise ValidationError(_('Multiple matching seats were found.')) raise ValidationError(_('Multiple matching seats were found.'))
except Seat.DoesNotExist: except Seat.DoesNotExist:
raise ValidationError(_('No matching seat was found.')) raise ValidationError(_('No matching seat was found.'))
if not value.is_available(sales_channel=previous_values.get('sales_channel')) or value in self._cached: if not value.is_available() or value in self._cached:
raise ValidationError( raise ValidationError(
_('The seat you selected has already been taken. Please select a different seat.')) _('The seat you selected has already been taken. Please select a different seat.'))
self._cached.add(value) self._cached.add(value)
@@ -605,22 +604,6 @@ class Comment(ImportColumn):
order.comment = value or '' order.comment = value or ''
class CheckinAttentionColumn(BooleanColumnMixin, ImportColumn):
identifier = 'checkin_attention'
verbose_name = gettext_lazy('Requires special attention')
def assign(self, value, order, position, invoice_address, **kwargs):
order.checkin_attention = value
class CheckinTextColumn(ImportColumn):
identifier = 'checkin_text'
verbose_name = gettext_lazy('Check-in text')
def assign(self, value, order, position, invoice_address, **kwargs):
order.checkin_text = value
class QuestionColumn(ImportColumn): class QuestionColumn(ImportColumn):
def __init__(self, event, q): def __init__(self, event, q):
self.q = q self.q = q
@@ -754,13 +737,11 @@ def get_order_import_columns(event):
AttendeeState(event), AttendeeState(event),
Price(event), Price(event),
Secret(event), Secret(event),
Saleschannel(event),
SeatColumn(event), SeatColumn(event),
ValidFrom(event), ValidFrom(event),
ValidUntil(event), ValidUntil(event),
Locale(event), Locale(event),
CheckinAttentionColumn(event), Saleschannel(event),
CheckinTextColumn(event),
Expires(event), Expires(event),
Comment(event), Comment(event),
] ]

View File

@@ -241,7 +241,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
email = models.EmailField(unique=True, db_index=True, null=True, blank=True, email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('Email'), max_length=190) verbose_name=_('E-mail'), max_length=190)
fullname = models.CharField(max_length=255, blank=True, null=True, fullname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name')) verbose_name=_('Full name'))
is_active = models.BooleanField(default=True, is_active = models.BooleanField(default=True,
@@ -571,23 +571,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
def get_session_auth_hash(self): def get_session_auth_hash(self):
""" """
Return an HMAC that needs to be the same throughout the session, used e.g. for forced Return an HMAC that needs to
logout after every password change.
"""
return self._get_session_auth_hash(secret=settings.SECRET_KEY)
def get_session_auth_fallback_hash(self):
for fallback_secret in settings.SECRET_KEY_FALLBACKS:
yield self._get_session_auth_hash(secret=fallback_secret)
def _get_session_auth_hash(self, secret):
"""
""" """
key_salt = "pretix.base.models.User.get_session_auth_hash" key_salt = "pretix.base.models.User.get_session_auth_hash"
payload = self.password payload = self.password
payload += self.email payload += self.email
payload += self.session_token payload += self.session_token
return salted_hmac(key_salt, payload, secret=secret).hexdigest() return salted_hmac(key_salt, payload).hexdigest()
def update_session_token(self): def update_session_token(self):
self.session_token = generate_session_token() self.session_token = generate_session_token()
@@ -664,9 +654,3 @@ class WebAuthnDevice(Device):
@property @property
def webauthnpubkey(self): def webauthnpubkey(self):
return websafe_decode(self.pub_key) return websafe_decode(self.pub_key)
class HistoricPassword(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="historic_passwords")
created = models.DateTimeField(auto_now_add=True)
password = models.CharField(verbose_name=_("Password"), max_length=128)

View File

@@ -99,6 +99,14 @@ class CheckinList(LoggedModel):
verbose_name=_('Automatically check out everyone at'), verbose_name=_('Automatically check out everyone at'),
null=True, blank=True null=True, blank=True
) )
auto_checkin_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_('Sales channels to automatically check in'),
help_text=_('This option is deprecated and will be removed in the next months. As a replacement, our new plugin '
'"Auto check-in" can be used. When we remove this option, we will automatically migrate your event '
'to use the new plugin.'),
blank=True,
)
rules = models.JSONField(default=dict, blank=True) rules = models.JSONField(default=dict, blank=True)
objects = ScopedManager(organizer='event__organizer') objects = ScopedManager(organizer='event__organizer')
@@ -133,7 +141,7 @@ class CheckinList(LoggedModel):
return self.positions_query(ignore_status=False) return self.positions_query(ignore_status=False)
@scopes_disabled() @scopes_disabled()
def _filter_positions_inside(self, qs, at_time=None): def positions_inside_query(self, ignore_status=False, at_time=None):
if at_time is None: if at_time is None:
c_q = [] c_q = []
else: else:
@@ -141,7 +149,7 @@ class CheckinList(LoggedModel):
if "postgresql" not in settings.DATABASES["default"]["ENGINE"]: if "postgresql" not in settings.DATABASES["default"]["ENGINE"]:
# Use a simple approach that works on all databases # Use a simple approach that works on all databases
qs = qs.annotate( qs = self.positions_query(ignore_status=ignore_status).annotate(
last_entry=Subquery( last_entry=Subquery(
Checkin.objects.filter( Checkin.objects.filter(
*c_q, *c_q,
@@ -194,7 +202,7 @@ class CheckinList(LoggedModel):
.values("position_id", "type", "datetime", "cnt_exists_after") .values("position_id", "type", "datetime", "cnt_exists_after")
.query.sql_with_params() .query.sql_with_params()
) )
return qs.filter( return self.positions_query(ignore_status=ignore_status).filter(
pk__in=RawSQL( pk__in=RawSQL(
f""" f"""
SELECT "position_id" SELECT "position_id"
@@ -206,10 +214,6 @@ class CheckinList(LoggedModel):
) )
) )
@scopes_disabled()
def positions_inside_query(self, ignore_status=False, at_time=None):
return self._filter_positions_inside(self.positions_query(ignore_status=ignore_status), at_time=at_time)
@property @property
def positions_inside(self): def positions_inside(self):
return self.positions_inside_query(None) return self.positions_inside_query(None)

View File

@@ -91,7 +91,7 @@ class Customer(LoggedModel):
), ),
], ],
) )
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('Email'), max_length=190) email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number')) phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
password = models.CharField(verbose_name=_('Password'), max_length=128) password = models.CharField(verbose_name=_('Password'), max_length=128)
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True) name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
@@ -219,24 +219,13 @@ class Customer(LoggedModel):
return is_password_usable(self.password) return is_password_usable(self.password)
def get_session_auth_hash(self): def get_session_auth_hash(self):
"""
Return an HMAC that needs to be the same throughout the session, used e.g. for forced
logout after every password change.
"""
return self._get_session_auth_hash(secret=settings.SECRET_KEY)
def get_session_auth_fallback_hash(self):
for fallback_secret in settings.SECRET_KEY_FALLBACKS:
yield self._get_session_auth_hash(secret=fallback_secret)
def _get_session_auth_hash(self, secret):
""" """
Return an HMAC of the password field. Return an HMAC of the password field.
""" """
key_salt = "pretix.base.models.customers.Customer.get_session_auth_hash" key_salt = "pretix.base.models.customers.Customer.get_session_auth_hash"
payload = self.password payload = self.password
payload += self.email payload += self.email
return salted_hmac(key_salt, payload, secret=secret).hexdigest() return salted_hmac(key_salt, payload).hexdigest()
def get_email_context(self): def get_email_context(self):
from pretix.base.settings import get_name_parts_localized from pretix.base.settings import get_name_parts_localized
@@ -392,7 +381,7 @@ class CustomerSSOClient(LoggedModel):
SCOPE_CHOICES = ( SCOPE_CHOICES = (
('openid', _('OpenID Connect access (required)')), ('openid', _('OpenID Connect access (required)')),
('profile', _('Profile data (name, addresses)')), ('profile', _('Profile data (name, addresses)')),
('email', _('Email address')), ('email', _('E-mail address')),
('phone', _('Phone number')), ('phone', _('Phone number')),
) )

View File

@@ -28,6 +28,7 @@ from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from pretix.api.auth.devicesecurity import DEVICE_SECURITY_PROFILES
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
@@ -160,6 +161,7 @@ class Device(LoggedModel):
) )
security_profile = models.CharField( security_profile = models.CharField(
max_length=190, max_length=190,
choices=[(k, v.verbose_name) for k, v in DEVICE_SECURITY_PROFILES.items()],
default='full', default='full',
null=True, null=True,
blank=False blank=False

View File

@@ -20,11 +20,11 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from collections import defaultdict, namedtuple from collections import defaultdict
from decimal import Decimal from decimal import Decimal
from itertools import groupby from itertools import groupby
from math import ceil, inf from math import ceil
from typing import Dict from typing import Dict, Optional, Tuple
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@@ -36,8 +36,6 @@ from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
PositionInfo = namedtuple('PositionInfo', ['item_id', 'subevent_id', 'line_price_gross', 'is_addon_to', 'voucher_discount'])
class Discount(LoggedModel): class Discount(LoggedModel):
SUBEVENT_MODE_MIXED = 'mixed' SUBEVENT_MODE_MIXED = 'mixed'
@@ -247,26 +245,22 @@ class Discount(LoggedModel):
return False return False
return True return True
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id): def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result):
if self.condition_min_value and sum(positions[idx].line_price_gross for idx in condition_idx_group) < self.condition_min_value: if self.condition_min_value and sum(positions[idx][2] for idx in condition_idx_group) < self.condition_min_value:
return return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches: if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.') raise ValueError('Validation invariant violated.')
for idx in benefit_idx_group: for idx in benefit_idx_group:
previous_price = positions[idx].line_price_gross previous_price = positions[idx][2]
new_price = round_decimal( new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'), previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency, self.event.currency,
) )
result[idx] = new_price result[idx] = new_price
if collect_potential_discounts is not None: def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result):
for idx in condition_idx_group:
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
if len(condition_idx_group) < self.condition_min_count: if len(condition_idx_group) < self.condition_min_count:
return return
@@ -274,53 +268,23 @@ class Discount(LoggedModel):
raise ValueError('Validation invariant violated.') raise ValueError('Validation invariant violated.')
if self.benefit_only_apply_to_cheapest_n_matches: if self.benefit_only_apply_to_cheapest_n_matches:
# sort by line_price if not self.condition_min_count:
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx)) raise ValueError('Validation invariant violated.')
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only # Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3 # want to match multiples of 3
n_groups = min(len(condition_idx_group) // self.condition_min_count, ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches))
# how many discount applications are allowed according to condition products in cart
possible_applications_cond = len(condition_idx_group) // self.condition_min_count
# how many discount applications are possible according to benefitting products in cart
possible_applications_benefit = ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches)
n_groups = min(possible_applications_cond, possible_applications_benefit)
consume_idx = condition_idx_group[:n_groups * self.condition_min_count] consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches] benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
if collect_potential_discounts is not None:
if n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
# partially used discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
# but only 1 t-shirt) -> 1 shirt definitiv potential discount
for idx in consume_idx:
collect_potential_discounts[idx] = [
(self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1, subevent_id)
]
if possible_applications_cond * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
# unused discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
# but 0 t-shirts) -> 2 shirt maybe potential discount (if the 1 ticket is not consumed by a later discount)
for i, idx in enumerate(condition_idx_group[
n_groups * self.condition_min_count:
possible_applications_cond * self.condition_min_count
]):
collect_potential_discounts[idx] += [
(self, self.benefit_only_apply_to_cheapest_n_matches, i // self.condition_min_count, subevent_id)
]
else: else:
consume_idx = condition_idx_group consume_idx = condition_idx_group
benefit_idx = benefit_idx_group benefit_idx = benefit_idx_group
if collect_potential_discounts is not None:
for idx in consume_idx:
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
for idx in benefit_idx: for idx in benefit_idx:
previous_price = positions[idx].line_price_gross previous_price = positions[idx][2]
new_price = round_decimal( new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'), previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency, self.event.currency,
@@ -328,16 +292,15 @@ class Discount(LoggedModel):
result[idx] = new_price result[idx] = new_price
for idx in consume_idx: for idx in consume_idx:
result.setdefault(idx, positions[idx].line_price_gross) result.setdefault(idx, positions[idx][2])
def apply(self, positions: Dict[int, PositionInfo], def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]]) -> Dict[int, Decimal]:
collect_potential_discounts=None) -> Dict[int, Decimal]:
""" """
Tries to apply this discount to a cart Tries to apply this discount to a cart
:param positions: Dictionary mapping IDs to PositionInfo tuples. :param positions: Dictionary mapping IDs to tuples of the form
``(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)``.
Bundled positions may not be included. Bundled positions may not be included.
:param collect_potential_discounts: For detailed description, see pretix.base.services.pricing.apply_discounts
:return: A dictionary mapping keys from the input dictionary to new prices. All positions :return: A dictionary mapping keys from the input dictionary to new prices. All positions
contained in this dictionary are considered "consumed" and should not be considered contained in this dictionary are considered "consumed" and should not be considered
@@ -379,13 +342,13 @@ class Discount(LoggedModel):
if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events
if self.condition_min_count: if self.condition_min_count:
self._apply_min_count(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts, None) self._apply_min_count(positions, condition_candidates, benefit_candidates, result)
else: else:
self._apply_min_value(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts, None) self._apply_min_value(positions, condition_candidates, benefit_candidates, result)
elif self.subevent_mode == self.SUBEVENT_MODE_SAME: elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx): def key(idx):
return positions[idx].subevent_id or 0 return positions[idx][1] or 0 # subevent_id
# Build groups of candidates with the same subevent, then apply our regular algorithm # Build groups of candidates with the same subevent, then apply our regular algorithm
# to each group # to each group
@@ -394,11 +357,11 @@ class Discount(LoggedModel):
candidate_groups = [(k, list(g)) for k, g in _groups] candidate_groups = [(k, list(g)) for k, g in _groups]
for subevent_id, g in candidate_groups: for subevent_id, g in candidate_groups:
benefit_g = [idx for idx in benefit_candidates if positions[idx].subevent_id == subevent_id] benefit_g = [idx for idx in benefit_candidates if positions[idx][1] == subevent_id]
if self.condition_min_count: if self.condition_min_count:
self._apply_min_count(positions, g, benefit_g, result, collect_potential_discounts, subevent_id) self._apply_min_count(positions, g, benefit_g, result)
else: else:
self._apply_min_value(positions, g, benefit_g, result, collect_potential_discounts, subevent_id) self._apply_min_value(positions, g, benefit_g, result)
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT: elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value or not self.benefit_same_products: if self.condition_min_value or not self.benefit_same_products:
@@ -414,9 +377,9 @@ class Discount(LoggedModel):
# Build a list of subevent IDs in descending order of frequency # Build a list of subevent IDs in descending order of frequency
subevent_to_idx = defaultdict(list) subevent_to_idx = defaultdict(list)
for idx, p in positions.items(): for idx, p in positions.items():
subevent_to_idx[p.subevent_id].append(idx) subevent_to_idx[p[1]].append(idx)
for v in subevent_to_idx.values(): for v in subevent_to_idx.values():
v.sort(key=lambda idx: positions[idx].line_price_gross) v.sort(key=lambda idx: positions[idx][2])
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True) subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
# Build groups of exactly condition_min_count distinct subevents # Build groups of exactly condition_min_count distinct subevents
@@ -431,7 +394,7 @@ class Discount(LoggedModel):
l = [ll for ll in l if ll in condition_candidates and ll not in current_group] l = [ll for ll in l if ll in condition_candidates and ll not in current_group]
if cardinality and len(l) != cardinality: if cardinality and len(l) != cardinality:
continue continue
if se not in {positions[idx].subevent_id for idx in current_group}: if se not in {positions[idx][1] for idx in current_group}:
candidates += l candidates += l
cardinality = len(l) cardinality = len(l)
@@ -440,7 +403,7 @@ class Discount(LoggedModel):
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start # Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
# and 2 from the end" scheme to optimize price distribution among groups # and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross) candidates = sorted(candidates, key=lambda idx: positions[idx][2])
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0): if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0] candidate = candidates[0]
else: else:
@@ -452,14 +415,14 @@ class Discount(LoggedModel):
if len(current_group) >= max(self.condition_min_count, 1): if len(current_group) >= max(self.condition_min_count, 1):
candidate_groups.append(current_group) candidate_groups.append(current_group)
for c in current_group: for c in current_group:
subevent_to_idx[positions[c].subevent_id].remove(c) subevent_to_idx[positions[c][1]].remove(c)
current_group = [] current_group = []
# Distribute "leftovers" # Distribute "leftovers"
for se in subevent_order: for se in subevent_order:
if subevent_to_idx[se]: if subevent_to_idx[se]:
for group in candidate_groups: for group in candidate_groups:
if se not in {positions[idx].subevent_id for idx in group}: if se not in {positions[idx][1] for idx in group}:
group.append(subevent_to_idx[se].pop()) group.append(subevent_to_idx[se].pop())
if not subevent_to_idx[se]: if not subevent_to_idx[se]:
break break
@@ -469,8 +432,6 @@ class Discount(LoggedModel):
positions, positions,
[idx for idx in g if idx in condition_candidates], [idx for idx in g if idx in condition_candidates],
[idx for idx in g if idx in benefit_candidates], [idx for idx in g if idx in benefit_candidates],
result, result
None,
None
) )
return result return result

View File

@@ -60,6 +60,7 @@ from django.urls import reverse
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
@@ -179,10 +180,14 @@ class EventMixin:
""" """
tz = tz or self.timezone tz = tz or self.timezone
if (not self.settings.show_date_to and not force_show_end) or not self.date_to: if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
df, dt = self.date_from, self.date_from if as_html:
else: return format_html(
df, dt = self.date_from, self.date_to "<time datetime=\"{}\">{}</time>",
return daterange(df.astimezone(tz), dt.astimezone(tz), as_html) _date(self.date_from.astimezone(tz), "Y-m-d"),
_date(self.date_from.astimezone(tz), "DATE_FORMAT"),
)
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz), as_html)
def get_date_range_display_as_html(self, tz=None, force_show_end=False) -> str: def get_date_range_display_as_html(self, tz=None, force_show_end=False) -> str:
return self.get_date_range_display(tz, force_show_end, as_html=True) return self.get_date_range_display(tz, force_show_end, as_html=True)
@@ -823,9 +828,6 @@ class Event(EventMixin, LoggedModel):
self.save() self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk}) self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
if hasattr(other, 'alternative_domain_assignment'):
other.alternative_domain_assignment.domain.event_assignments.create(event=self)
if not self.all_sales_channels: if not self.all_sales_channels:
self.limit_sales_channels.set( self.limit_sales_channels.set(
self.organizer.sales_channels.filter( self.organizer.sales_channels.filter(
@@ -873,12 +875,10 @@ class Event(EventMixin, LoggedModel):
for i in Item.objects.filter(event=other).prefetch_related( for i in Item.objects.filter(event=other).prefetch_related(
'variations', 'limit_sales_channels', 'require_membership_types', 'variations', 'limit_sales_channels', 'require_membership_types',
'variations__limit_sales_channels', 'variations__require_membership_types', 'variations__limit_sales_channels', 'variations__require_membership_types',
'matched_by_cross_selling_categories',
): ):
vars = list(i.variations.all()) vars = list(i.variations.all())
require_membership_types = list(i.require_membership_types.all()) require_membership_types = list(i.require_membership_types.all())
limit_sales_channels = list(i.limit_sales_channels.all()) limit_sales_channels = list(i.limit_sales_channels.all())
matched_by_cross_selling_categories = list(i.matched_by_cross_selling_categories.all())
item_map[i.pk] = i item_map[i.pk] = i
i.pk = None i.pk = None
i.event = self i.event = self
@@ -916,9 +916,6 @@ class Event(EventMixin, LoggedModel):
if not v.all_sales_channels: if not v.all_sales_channels:
v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels])) v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
if matched_by_cross_selling_categories:
i.matched_by_cross_selling_categories.set([category_map[c.pk] for c in matched_by_cross_selling_categories])
for i in self.items.filter(hidden_if_item_available__isnull=False): for i in self.items.filter(hidden_if_item_available__isnull=False):
i.hidden_if_item_available = item_map[i.hidden_if_item_available_id] i.hidden_if_item_available = item_map[i.hidden_if_item_available_id]
i.save() i.save()
@@ -1027,9 +1024,10 @@ class Event(EventMixin, LoggedModel):
checkin_list_map = {} checkin_list_map = {}
for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related( for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related(
'limit_products' 'limit_products', 'auto_checkin_sales_channels'
): ):
items = list(cl.limit_products.all()) items = list(cl.limit_products.all())
auto_checkin_sales_channels = list(cl.auto_checkin_sales_channels.all())
checkin_list_map[cl.pk] = cl checkin_list_map[cl.pk] = cl
cl.pk = None cl.pk = None
cl._prefetched_objects_cache = {} cl._prefetched_objects_cache = {}
@@ -1041,6 +1039,8 @@ class Event(EventMixin, LoggedModel):
cl.log_action('pretix.object.cloned') cl.log_action('pretix.object.cloned')
for i in items: for i in items:
cl.limit_products.add(item_map[i.pk]) cl.limit_products.add(item_map[i.pk])
if auto_checkin_sales_channels:
cl.auto_checkin_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in auto_checkin_sales_channels]))
if other.seating_plan: if other.seating_plan:
if other.seating_plan.organizer_id == self.organizer_id: if other.seating_plan.organizer_id == self.organizer_id:

View File

@@ -362,7 +362,6 @@ class InvoiceLine(models.Model):
tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00')) tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00'))
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00')) tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
tax_name = models.CharField(max_length=190) tax_name = models.CharField(max_length=190)
tax_code = models.CharField(max_length=190, null=True, blank=True)
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT) subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
event_date_from = models.DateTimeField(null=True) event_date_from = models.DateTimeField(null=True)
event_date_to = models.DateTimeField(null=True) event_date_to = models.DateTimeField(null=True)

View File

@@ -63,13 +63,14 @@ from django_countries.fields import Country
from django_scopes import ScopedManager from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Event, SubEvent
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice from pretix.base.models.tax import TaxedPrice
from pretix.base.timemachine import time_machine_now from pretix.base.timemachine import time_machine_now
from pretix.helpers.images import ImageSizeValidator
from ...helpers.images import ImageSizeValidator
from ..media import MEDIA_TYPES
from .event import Event, SubEvent
class ItemCategory(LoggedModel): class ItemCategory(LoggedModel):
@@ -110,33 +111,6 @@ class ItemCategory(LoggedModel):
'only be bought in combination with a product that has this category configured as a possible ' 'only be bought in combination with a product that has this category configured as a possible '
'source for add-ons.') 'source for add-ons.')
) )
CROSS_SELLING_MODES = (
(None, _('Normal category')),
('both', _('Normal + cross-selling category')),
('only', _('Cross-selling category')),
)
cross_selling_mode = models.CharField(
choices=CROSS_SELLING_MODES,
null=True,
max_length=5
)
CROSS_SELLING_CONDITION = (
('always', _('Always show in cross-selling step')),
('discounts', _('Only show products that qualify for a discount according to discount rules')),
('products', _('Only show if the cart contains one of the following products')),
)
cross_selling_condition = models.CharField(
verbose_name=_("Cross-selling condition"),
choices=CROSS_SELLING_CONDITION,
null=True,
max_length=10,
)
cross_selling_match_products = models.ManyToManyField(
'pretixbase.Item',
blank=True,
verbose_name=_("Cross-selling condition products"),
related_name="matched_by_cross_selling_categories",
)
class Meta: class Meta:
verbose_name = _("Product category") verbose_name = _("Product category")
@@ -145,31 +119,19 @@ class ItemCategory(LoggedModel):
def __str__(self): def __str__(self):
name = self.internal_name or self.name name = self.internal_name or self.name
if self.category_type != 'normal': if self.is_addon:
return _('{category} ({category_type})').format(category=str(name), return _('{category} (Add-On products)').format(category=str(name))
category_type=self.get_category_type_display())
return str(name) return str(name)
def get_category_type_display(self): def get_category_type_display(self):
if self.is_addon: if self.is_addon:
return _('Add-on category') return _('Add-On products')
elif self.cross_selling_mode:
return self.get_cross_selling_mode_display()
else: else:
return _('Normal category') return None
@property @property
def category_type(self): def category_type(self):
return 'addon' if self.is_addon else self.cross_selling_mode or 'normal' return 'addon' if self.is_addon else 'normal'
@category_type.setter
def category_type(self, new_value):
if new_value == 'addon':
self.is_addon = True
self.cross_selling_mode = None
else:
self.is_addon = False
self.cross_selling_mode = None if new_value == 'normal' else new_value
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
@@ -308,7 +270,7 @@ class SubEventItemVariation(models.Model):
return True return True
def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False): def filter_available(qs, channel='web', voucher=None, allow_addons=False):
# Channel can currently be a SalesChannel or a str, since we need that compatibility, but a SalesChannel # Channel can currently be a SalesChannel or a str, since we need that compatibility, but a SalesChannel
# makes the query SIGNIFICANTLY faster # makes the query SIGNIFICANTLY faster
from .organizer import SalesChannel from .organizer import SalesChannel
@@ -329,8 +291,6 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_
if not allow_addons: if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False)) q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
if not allow_cross_sell:
q &= Q(Q(category__isnull=True) | ~Q(category__cross_selling_mode='only'))
if voucher: if voucher:
if voucher.item_id: if voucher.item_id:
@@ -344,8 +304,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_
class ItemQuerySet(models.QuerySet): class ItemQuerySet(models.QuerySet):
def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False): def filter_available(self, channel='web', voucher=None, allow_addons=False):
return filter_available(self, channel, voucher, allow_addons, allow_cross_sell) return filter_available(self, channel, voucher, allow_addons)
class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__): class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__):
@@ -353,8 +313,8 @@ class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__)
super().__init__() super().__init__()
self._queryset_class = ItemQuerySet self._queryset_class = ItemQuerySet
def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False): def filter_available(self, channel='web', voucher=None, allow_addons=False):
return filter_available(self.get_queryset(), channel, voucher, allow_addons, allow_cross_sell) return filter_available(self.get_queryset(), channel, voucher, allow_addons)
class Item(LoggedModel): class Item(LoggedModel):
@@ -837,7 +797,7 @@ class Item(LoggedModel):
if not self.tax_rule: if not self.tax_rule:
t = TaxedPrice(gross=price - bundled_sum, net=price - bundled_sum, tax=Decimal('0.00'), t = TaxedPrice(gross=price - bundled_sum, net=price - bundled_sum, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='', code=None) rate=Decimal('0.00'), name='')
else: else:
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address, t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
override_tax_rate=override_tax_rate, currency=currency or self.event.currency, override_tax_rate=override_tax_rate, currency=currency or self.event.currency,
@@ -845,7 +805,6 @@ class Item(LoggedModel):
if bundled_sum: if bundled_sum:
t.name = "MIXED!" t.name = "MIXED!"
t.code = None
t.gross += bundled_sum t.gross += bundled_sum
t.net += bundled_sum_net t.net += bundled_sum_net
t.tax += bundled_sum_tax t.tax += bundled_sum_tax
@@ -1119,12 +1078,13 @@ class ItemVariation(models.Model):
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown. :param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
:type original_price: decimal.Decimal :type original_price: decimal.Decimal
:param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after :param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after
approval by an administrator approval by an administrator
:type require_approval: bool :type require_approval: bool
:param all_sales_channels: A flag indicating that this variation is available on all channels and limit_sales_channels will be ignored. :param all_sales_channels: A flag indicating that this variation is available on all channels and limit_sales_channels will be ignored.
:type all_sales_channels: bool :type all_sales_channels: bool
:param limit_sales_channels: A list of sales channel identifiers, that this variation is available for sale on. :param limit_sales_channels: A list of sales channel identifiers, that this variation is available for sale on.
:type limit_sales_channels: list :type limit_sales_channels: list
""" """
item = models.ForeignKey( item = models.ForeignKey(
Item, Item,
@@ -1259,7 +1219,7 @@ class ItemVariation(models.Model):
if not self.item.tax_rule: if not self.item.tax_rule:
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='', code=None) rate=Decimal('0.00'), name='')
else: else:
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency, t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency,
override_tax_rate=override_tax_rate, override_tax_rate=override_tax_rate,
@@ -1281,7 +1241,6 @@ class ItemVariation(models.Model):
t.net += bprice.net - compare_price.net t.net += bprice.net - compare_price.net
t.tax += bprice.tax - compare_price.tax t.tax += bprice.tax - compare_price.tax
t.name = "MIXED!" t.name = "MIXED!"
t.code = None
return t return t

View File

@@ -159,24 +159,10 @@ class Membership(models.Model):
de = date_format(self.date_end, 'SHORT_DATE_FORMAT') de = date_format(self.date_end, 'SHORT_DATE_FORMAT')
return f'{self.membership_type.name}: {self.attendee_name} ({ds} {de})' return f'{self.membership_type.name}: {self.attendee_name} ({ds} {de})'
@property
def percentage_used(self):
if self.membership_type.max_usages and self.usages:
return int(self.usages / self.membership_type.max_usages * 100)
return 0
@property @property
def attendee_name(self): def attendee_name(self):
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme) return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme)
@property
def expired(self):
return time_machine_now() > self.date_end
@property
def not_yet_valid(self):
return time_machine_now() < self.date_start
def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False): def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False):
if valid_from_not_chosen: if valid_from_not_chosen:
return not self.canceled and self.date_end >= time_machine_now() return not self.canceled and self.date_end >= time_machine_now()

View File

@@ -43,7 +43,7 @@ class NotificationSetting(models.Model):
:type enabled: bool :type enabled: bool
""" """
CHANNELS = ( CHANNELS = (
('mail', _('Email')), ('mail', _('E-mail')),
) )
user = models.ForeignKey('User', on_delete=models.CASCADE, user = models.ForeignKey('User', on_delete=models.CASCADE,
related_name='notification_settings') related_name='notification_settings')

View File

@@ -40,7 +40,6 @@ import json
import logging import logging
import operator import operator
import string import string
import warnings
from collections import Counter from collections import Counter
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
@@ -62,10 +61,9 @@ from django.db.models.signals import post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.crypto import get_random_string, salted_hmac from django.utils.crypto import get_random_string, salted_hmac
from django.utils.encoding import escape_uri_path, force_str from django.utils.encoding import escape_uri_path
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.hashable import make_hashable
from django.utils.timezone import get_current_timezone, make_aware, now from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries.fields import Country from django_countries.fields import Country
@@ -243,7 +241,7 @@ class Order(LockModel, LoggedModel):
) )
email = models.EmailField( email = models.EmailField(
null=True, blank=True, null=True, blank=True,
verbose_name=_('Email') verbose_name=_('E-mail')
) )
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, blank=True, null=True, blank=True,
@@ -318,7 +316,7 @@ class Order(LockModel, LoggedModel):
) )
email_known_to_work = models.BooleanField( email_known_to_work = models.BooleanField(
default=False, default=False,
verbose_name=_('Email address verified') verbose_name=_('E-mail address verified')
) )
invoice_dirty = models.BooleanField( invoice_dirty = models.BooleanField(
# Invoice needs to be re-issued when the order is paid again # Invoice needs to be re-issued when the order is paid again
@@ -383,28 +381,8 @@ class Order(LockModel, LoggedModel):
self.event.cache.delete('complain_testmode_orders') self.event.cache.delete('complain_testmode_orders')
self.delete() self.delete()
def email_confirm_secret(self):
return self.tagged_secret("email_confirm", 9)
def email_confirm_hash(self): def email_confirm_hash(self):
warnings.warn('Use email_confirm_secret() instead of email_confirm_hash().', return hashlib.sha256(settings.SECRET_KEY.encode() + self.secret.encode()).hexdigest()[:9]
DeprecationWarning)
return self.email_confirm_secret()
def check_email_confirm_secret(self, received_secret):
return (
hmac.compare_digest(
self.tagged_secret("email_confirm", 9),
received_secret[:9].lower()
) or any(
# TODO: remove this clause after a while (compatibility with old secrets currently in flight)
hmac.compare_digest(
hashlib.sha256(sk.encode() + self.secret.encode()).hexdigest()[:9],
received_secret
)
for sk in [settings.SECRET_KEY, *settings.SECRET_KEY_FALLBACKS]
)
)
def get_extended_status_display(self): def get_extended_status_display(self):
# Changes in this method should to be replicated in pretixcontrol/orders/fragment_order_status.html # Changes in this method should to be replicated in pretixcontrol/orders/fragment_order_status.html
@@ -1257,7 +1235,7 @@ class Order(LockModel, LoggedModel):
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys()) keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
create = [] create = []
for k in keys: for k in keys:
positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype, taxcode = k positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype = k
d = target_transaction_count[k] - current_transaction_count[k] d = target_transaction_count[k] - current_transaction_count[k]
if d: if d:
create.append(Transaction( create.append(Transaction(
@@ -1273,7 +1251,6 @@ class Order(LockModel, LoggedModel):
tax_rate=taxrate, tax_rate=taxrate,
tax_rule_id=taxruleid, tax_rule_id=taxruleid,
tax_value=taxvalue, tax_value=taxvalue,
tax_code=taxcode,
fee_type=feetype, fee_type=feetype,
internal_type=internaltype, internal_type=internaltype,
)) ))
@@ -2277,7 +2254,6 @@ class OrderFee(models.Model):
FEE_TYPE_SERVICE = "service" FEE_TYPE_SERVICE = "service"
FEE_TYPE_CANCELLATION = "cancellation" FEE_TYPE_CANCELLATION = "cancellation"
FEE_TYPE_INSURANCE = "insurance" FEE_TYPE_INSURANCE = "insurance"
FEE_TYPE_LATE = "late"
FEE_TYPE_OTHER = "other" FEE_TYPE_OTHER = "other"
FEE_TYPE_GIFTCARD = "giftcard" FEE_TYPE_GIFTCARD = "giftcard"
FEE_TYPES = ( FEE_TYPES = (
@@ -2286,7 +2262,6 @@ class OrderFee(models.Model):
(FEE_TYPE_SERVICE, _("Service fee")), (FEE_TYPE_SERVICE, _("Service fee")),
(FEE_TYPE_CANCELLATION, _("Cancellation fee")), (FEE_TYPE_CANCELLATION, _("Cancellation fee")),
(FEE_TYPE_INSURANCE, _("Insurance fee")), (FEE_TYPE_INSURANCE, _("Insurance fee")),
(FEE_TYPE_LATE, _("Late fee")),
(FEE_TYPE_OTHER, _("Other fees")), (FEE_TYPE_OTHER, _("Other fees")),
(FEE_TYPE_GIFTCARD, _("Gift card")), (FEE_TYPE_GIFTCARD, _("Gift card")),
) )
@@ -2315,10 +2290,6 @@ class OrderFee(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, blank=True null=True, blank=True
) )
tax_code = models.CharField(
max_length=190,
null=True, blank=True,
)
tax_value = models.DecimalField( tax_value = models.DecimalField(
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
@@ -2346,16 +2317,6 @@ class OrderFee(models.Model):
self._transaction_key_reset() self._transaction_key_reset()
return super().refresh_from_db(using, fields) return super().refresh_from_db(using, fields)
def get_tax_code_display(self):
from pretix.base.models.tax import get_tax_code_labels
if self.tax_code:
choices_dict = get_tax_code_labels()
return force_str(
choices_dict.get(make_hashable(self.tax_code), self.tax_code), strings_only=True
)
return ""
def _transaction_key_reset(self): def _transaction_key_reset(self):
self.__initial_transaction_key = Transaction.key(self) self.__initial_transaction_key = Transaction.key(self)
self.__initial_canceled = self.canceled self.__initial_canceled = self.canceled
@@ -2386,11 +2347,9 @@ class OrderFee(models.Model):
if self.tax_rule: if self.tax_rule:
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True) tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
self.tax_rate = tax.rate self.tax_rate = tax.rate
self.tax_code = tax.code
self.tax_value = tax.tax self.tax_value = tax.tax
else: else:
self.tax_value = Decimal('0.00') self.tax_value = Decimal('0.00')
self.tax_code = None
self.tax_rate = Decimal('0.00') self.tax_rate = Decimal('0.00')
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@@ -2399,7 +2358,6 @@ class OrderFee(models.Model):
if self.tax_rate is None: if self.tax_rate is None:
self._calculate_tax() self._calculate_tax()
self.order.touch() self.order.touch()
if not self.get_deferred_fields(): if not self.get_deferred_fields():
@@ -2487,10 +2445,6 @@ class OrderPosition(AbstractPosition):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, blank=True null=True, blank=True
) )
tax_code = models.CharField(
max_length=190,
null=True, blank=True,
)
tax_value = models.DecimalField( tax_value = models.DecimalField(
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
@@ -2548,16 +2502,6 @@ class OrderPosition(AbstractPosition):
models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq") models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq")
] ]
def get_tax_code_display(self):
from pretix.base.models.tax import get_tax_code_labels
if self.tax_code:
choices_dict = get_tax_code_labels()
return force_str(
choices_dict.get(make_hashable(self.tax_code), self.tax_code), strings_only=True
)
return ""
@cached_property @cached_property
def sort_key(self): def sort_key(self):
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0, self.positionid return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0, self.positionid
@@ -2730,13 +2674,11 @@ class OrderPosition(AbstractPosition):
if self.tax_rule: if self.tax_rule:
tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross', force_fixed_gross_price=True) tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross', force_fixed_gross_price=True)
self.tax_rate = tax.rate self.tax_rate = tax.rate
self.tax_code = tax.code
self.tax_value = tax.tax self.tax_value = tax.tax
if tax.gross != self.price: if tax.gross != self.price:
raise ValueError('Invalid tax calculation') raise ValueError('Invalid tax calculation')
else: else:
self.tax_value = Decimal('0.00') self.tax_value = Decimal('0.00')
self.tax_code = None
self.tax_rate = Decimal('0.00') self.tax_rate = Decimal('0.00')
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@@ -2893,14 +2835,6 @@ class OrderPosition(AbstractPosition):
(self.order.event.settings.change_allow_user_addons and ItemAddOn.objects.filter(base_item_id__in=[op.item_id for op in positions]).exists()) (self.order.event.settings.change_allow_user_addons and ItemAddOn.objects.filter(base_item_id__in=[op.item_id for op in positions]).exists())
) )
@property
def code(self):
"""
A ticket code which is unique among all events of a single organizer,
built by the order code and the position number.
"""
return '{order_code}-{position}'.format(order_code=self.order.code, position=self.positionid)
class Transaction(models.Model): class Transaction(models.Model):
""" """
@@ -3007,10 +2941,6 @@ class Transaction(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, blank=True null=True, blank=True
) )
tax_code = models.CharField(
max_length=190,
null=True, blank=True,
)
tax_value = models.DecimalField( tax_value = models.DecimalField(
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
@@ -3031,27 +2961,17 @@ class Transaction(models.Model):
raise ValidationError('Should set either item or fee type') raise ValidationError('Should set either item or fee type')
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def get_tax_code_display(self):
from pretix.base.models.tax import get_tax_code_labels
if self.tax_code:
choices_dict = get_tax_code_labels()
return force_str(
choices_dict.get(make_hashable(self.tax_code), self.tax_code), strings_only=True
)
return ""
@staticmethod @staticmethod
def key(obj): def key(obj):
if isinstance(obj, Transaction): if isinstance(obj, Transaction):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type)
elif isinstance(obj, OrderPosition): elif isinstance(obj, OrderPosition):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code) obj.tax_rule_id, obj.tax_value, None, None)
elif isinstance(obj, OrderFee): elif isinstance(obj, OrderFee):
return (None, None, None, None, obj.value, obj.tax_rate, return (None, None, None, None, obj.value, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type)
raise ValueError('invalid state') # noqa raise ValueError('invalid state') # noqa
@property @property
@@ -3215,7 +3135,6 @@ class CartPosition(AbstractPosition):
if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate: if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate:
self.line_price_gross = line_price.gross self.line_price_gross = line_price.gross
self.tax_rate = line_price.rate self.tax_rate = line_price.rate
self.tax_code = line_price.code
self.save(update_fields=['line_price_gross', 'tax_rate']) self.save(update_fields=['line_price_gross', 'tax_rate'])
@property @property
@@ -3256,9 +3175,9 @@ class InvoiceAddress(models.Model):
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name')) company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True) name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
name_parts = models.JSONField(default=dict) name_parts = models.JSONField(default=dict)
street = models.TextField(verbose_name=_('Address'), blank=True) street = models.TextField(verbose_name=_('Address'), blank=False)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True) zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True) city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False) country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'), country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
countries=CachedCountries) countries=CachedCountries)
@@ -3443,74 +3362,6 @@ class BlockedTicketSecret(models.Model):
unique_together = (('event', 'secret'),) unique_together = (('event', 'secret'),)
class PrintLog(models.Model):
"""
A print log object is created when a ticket or badge is printed with our apps.
"""
TYPE_BADGE = 'badge'
TYPE_TICKET = 'ticket'
TYPE_CERTIFICATE = 'certificate'
TYPE_OTHER = 'other'
PRINT_TYPES = (
(TYPE_BADGE, _('Badge')),
(TYPE_TICKET, _('Ticket')),
(TYPE_CERTIFICATE, _('Certificate')),
(TYPE_OTHER, _('Other')),
)
position = models.ForeignKey(
'pretixbase.OrderPosition',
related_name='print_logs',
on_delete=models.CASCADE,
)
successful = models.BooleanField(
default=True,
)
# Datetime of checkin, might be different from created if past scans are uploaded
datetime = models.DateTimeField(default=now)
# Datetime of creation on server
created = models.DateTimeField(auto_now_add=True, null=True, blank=True)
# Who printed?
device = models.ForeignKey('Device', related_name='print_logs', null=True, blank=True, on_delete=models.PROTECT)
user = models.ForeignKey('User', related_name='print_logs', null=True, blank=True, on_delete=models.PROTECT)
api_token = models.ForeignKey('TeamAPIToken', null=True, blank=True, on_delete=models.PROTECT)
oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT)
# Source = Tag field with undefined values, e.g. name of app ("pretixscan")
source = models.CharField(max_length=255)
# Type = Type of object printed ("badge", "ticket")
type = models.CharField(max_length=255, choices=PRINT_TYPES)
info = models.JSONField(default=dict)
objects = ScopedManager(organizer='position__order__event__organizer')
class Meta:
ordering = (('-datetime'),)
def __repr__(self):
return "<PrintLog: pos {} at {} from {}>".format(
self.position, self.datetime, self.source
)
def save(self, **kwargs):
super().save(**kwargs)
if self.position:
self.position.order.touch()
def delete(self, **kwargs):
super().delete(**kwargs)
self.position.order.touch()
@property
def is_late_upload(self):
return self.created and abs(self.created - self.datetime) > timedelta(minutes=2)
@receiver(post_delete, sender=CachedTicket) @receiver(post_delete, sender=CachedTicket)
def cachedticket_delete(sender, instance, **kwargs): def cachedticket_delete(sender, instance, **kwargs):
if instance.file: if instance.file:

View File

@@ -53,30 +53,6 @@ class SeatingPlanLayoutValidator:
e = str(e).replace('%', '%%') e = str(e).replace('%', '%%')
raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(e)) raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(e))
try:
seat_guids = set()
for z in val["zones"]:
for r in z["rows"]:
for s in r["seats"]:
if not s.get("seat_guid"):
raise ValidationError(
_("Seat with zone {zone}, row {row}, and number {number} has no seat ID.").format(
zone=z["name"],
row=r["row_number"],
number=s["seat_number"],
)
)
elif s["seat_guid"] in seat_guids:
raise ValidationError(
_("Multiple seats have the same ID: {id}").format(
id=s["seat_guid"],
)
)
seat_guids.add(s["seat_guid"])
except ValidationError as e:
raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(", ".join(e.message for e in e.error_list)))
class SeatingPlan(LoggedModel): class SeatingPlan(LoggedModel):
""" """

View File

@@ -21,7 +21,6 @@
# #
import json import json
from decimal import Decimal from decimal import Decimal
from typing import Optional
import jsonschema import jsonschema
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
@@ -30,10 +29,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from django.utils.formats import localize from django.utils.formats import localize
from django.utils.functional import lazy from django.utils.translation import gettext_lazy as _, pgettext
from django.utils.hashable import make_hashable
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy
from i18nfield.fields import I18nCharField from i18nfield.fields import I18nCharField
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
@@ -44,7 +40,7 @@ from pretix.helpers.countries import FastCountryField
class TaxedPrice: class TaxedPrice:
def __init__(self, *, gross: Decimal, net: Decimal, tax: Decimal, rate: Decimal, name: str, code: Optional[str]): def __init__(self, *, gross: Decimal, net: Decimal, tax: Decimal, rate: Decimal, name: str):
if net + tax != gross: if net + tax != gross:
raise ValueError('Net value and tax value need to add to the gross value') raise ValueError('Net value and tax value need to add to the gross value')
self.gross = gross self.gross = gross
@@ -52,7 +48,6 @@ class TaxedPrice:
self.tax = tax self.tax = tax
self.rate = rate self.rate = rate
self.name = name self.name = name
self.code = code
def __repr__(self): def __repr__(self):
return '{} + {}% = {}'.format(localize(self.net), localize(self.rate), localize(self.gross)) return '{} + {}% = {}'.format(localize(self.net), localize(self.rate), localize(self.gross))
@@ -75,7 +70,6 @@ class TaxedPrice:
tax=newgross - newnet, tax=newgross - newnet,
rate=self.rate, rate=self.rate,
name=self.name, name=self.name,
code=self.code,
) )
def __mul__(self, other): def __mul__(self, other):
@@ -89,7 +83,6 @@ class TaxedPrice:
tax=newgross - newnet, tax=newgross - newnet,
rate=self.rate, rate=self.rate,
name=self.name, name=self.name,
code=self.code,
) )
def __eq__(self, other): def __eq__(self, other):
@@ -98,8 +91,7 @@ class TaxedPrice:
self.net == other.net and self.net == other.net and
self.tax == other.tax and self.tax == other.tax and
self.rate == other.rate and self.rate == other.rate and
self.name == other.name and self.name == other.name
self.code == other.code
) )
@@ -108,8 +100,7 @@ TAXED_ZERO = TaxedPrice(
net=Decimal('0.00'), net=Decimal('0.00'),
tax=Decimal('0.00'), tax=Decimal('0.00'),
rate=Decimal('0.00'), rate=Decimal('0.00'),
name='', name=''
code=None,
) )
EU_COUNTRIES = { EU_COUNTRIES = {
@@ -129,154 +120,6 @@ EU_CURRENCIES = {
} }
VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH', 'NO'} VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH', 'NO'}
format_html_lazy = lazy(format_html, str)
TAX_CODE_LISTS = (
# Sources:
# https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists#RegistryofsupportingartefactstoimplementEN16931-Codelists
# https://docs.peppol.eu/poacc/billing/3.0/codelist/vatex/
# https://docs.peppol.eu/poacc/billing/3.0/codelist/UNCL5305/
# https://www.bzst.de/DE/Unternehmen/Aussenpruefungen/DigitaleSchnittstelleFinV/digitaleschnittstellefinv_node.html#js-toc-entry2
#
# !! When changed, also update tax-rules-custom.schema.json and doc/api/resources/taxrules.rst !!
(
_("Standard rates"),
(
# Standard rate in any country, such as 19% in Germany or 20% in Austria
# DSFinV-K mapping: 1
("S/standard", pgettext_lazy("tax_code", "Standard rate")),
# Reduced rate in any country, such as 7% in Germany or both 10% and 13% in Austria
# DSFinV-K mapping: 2
("S/reduced", pgettext_lazy("tax_code", "Reduced rate")),
# Averaged rate, for example Germany § 24 (1) Nr. 3 UStG "für die übrigen Umsätze" in agricultural and silvicultural businesses
# DSFinV-K mapping: 3
("S/averaged", pgettext_lazy("tax_code", "Averaged rate (other revenue in a agricultural and silvicultural business)")),
# We ignore the German special case of the actual silvicultural products as they won't be sold through pretix (DSFinV-K mapping: 4)
)
),
(
_("Reverse charge"),
(
("AE", pgettext_lazy("tax_code", "Reverse charge")),
)
),
(
_("Tax free"),
(
# DSFinV-K mapping: 5
("O", pgettext_lazy("tax_code", "Services outside of scope of tax")),
# DSFinV-K mapping: 6
("E", pgettext_lazy("tax_code", "Exempt from tax (no reason given)")),
# DSFinV-K mapping: 6
("Z", pgettext_lazy("tax_code", "Zero-rated goods")),
# DSFinV-K mapping: 5
("G", pgettext_lazy("tax_code", "Free export item, VAT not charged")),
# DSFinV-K mapping: 6?
("K", pgettext_lazy("tax_code", "VAT exempt for EEA intra-community supply of goods and services")),
)
),
(
_("Special cases"),
(
("L", pgettext_lazy("tax_code", "Canary Islands general indirect tax")),
("M", pgettext_lazy("tax_code", "Tax for production, services and importation in Ceuta and Melilla")),
("B", pgettext_lazy("tax_code", "Transferred (VAT), only in Italy")),
)
),
(
_("Exempt with specific reason"),
(
("E/VATEX-EU-79-C",
pgettext_lazy("tax_code", "Exempt based on article 79, point c of Council Directive 2006/112/EC")),
*[
(
f"E/VATEX-EU-132-1{letter.upper()}",
lazy(
lambda let: pgettext(
"tax_code",
"Exempt based on article {article}, section {section} ({letter}) of Council "
"Directive 2006/112/EC"
).format(article="132", section="1", letter=let),
str
)(letter)
) for letter in ("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q")
],
*[
(
f"E/VATEX-EU-143-1{letter.upper()}",
lazy(
lambda let: pgettext(
"tax_code",
"Exempt based on article {article}, section {section} ({letter}) of Council "
"Directive 2006/112/EC"
).format(article="143", section="1", letter=let),
str
)(letter)
) for letter in ("a", "b", "c", "d", "e", "f", "fa", "g", "h", "i", "j", "k", "l")
],
*[
(
f"E/VATEX-EU-148-{letter.upper()}",
lazy(
lambda let: pgettext(
"tax_code",
"Exempt based on article {article}, section ({letter}) of Council "
"Directive 2006/112/EC"
).format(article="148", letter=let),
str
)(letter)
) for letter in ("a", "b", "c", "d", "e", "f", "g")
],
*[
(
f"E/VATEX-EU-151-1{letter.upper()}",
lazy(
lambda let: pgettext(
"tax_code",
"Exempt based on article {article}, section {section} ({letter}) of Council "
"Directive 2006/112/EC"
).format(article="151", section="1", letter=let),
str
)(letter)
) for letter in ("a", "aa", "b", "c", "d", "e")
],
("E/VATEX-EU-309",
pgettext_lazy("tax_code", "Exempt based on article 309 of Council Directive 2006/112/EC")),
("E/VATEX-EU-D",
pgettext_lazy("tax_code", "Intra-Community acquisition from second hand means of transport")),
("E/VATEX-EU-F",
pgettext_lazy("tax_code", "Intra-Community acquisition of second hand goods")),
("E/VATEX-EU-I",
pgettext_lazy("tax_code", "Intra-Community acquisition of works of art")),
("E/VATEX-EU-J",
pgettext_lazy("tax_code", "Intra-Community acquisition of collectors items and antiques")),
("E/VATEX-FR-FRANCHISE",
pgettext_lazy("tax_code", "France domestic VAT franchise in base")),
("E/VATEX-FR-CNWVAT",
pgettext_lazy("tax_code", "France domestic Credit Notes without VAT, due to supplier forfeit of VAT for discount")),
)
),
)
def get_tax_code_labels():
flat = []
for choice, value in TAX_CODE_LISTS:
if isinstance(value, (list, tuple)):
flat.extend(value)
else:
flat.append((choice, value))
return dict(make_hashable(flat))
def is_eu_country(cc): def is_eu_country(cc):
cc = str(cc) cc = str(cc)
@@ -326,14 +169,6 @@ class TaxRule(LoggedModel):
help_text=_('Should be short, e.g. "VAT"'), help_text=_('Should be short, e.g. "VAT"'),
max_length=190, max_length=190,
) )
code = models.CharField(
verbose_name=_('Tax code'),
help_text=_('If you help us understand what this tax rules legally is, we can use this information for '
'eInvoices, exporting to accounting system, etc.'),
null=True, blank=True,
max_length=190,
choices=TAX_CODE_LISTS,
)
rate = models.DecimalField( rate = models.DecimalField(
max_digits=10, max_digits=10,
decimal_places=2, decimal_places=2,
@@ -358,17 +193,11 @@ class TaxRule(LoggedModel):
eu_reverse_charge = models.BooleanField( eu_reverse_charge = models.BooleanField(
verbose_name=_("Use EU reverse charge taxation rules"), verbose_name=_("Use EU reverse charge taxation rules"),
default=False, default=False,
help_text=format_html_lazy( help_text=_("Not recommended. Most events will NOT be qualified for reverse charge since the place of "
'<span class="label label-warning" data-toggle="tooltip" title="{}">{}</span> {}', "taxation is the location of the event. This option disables charging VAT for all customers "
_('This feature will be removed in the future as it does not handle VAT for non-business customers in ' "outside the EU and for business customers in different EU countries who entered a valid EU VAT "
'other EU countries in a way that works for all organizers. Use custom rules instead.'), "ID. Only enable this option after consulting a tax counsel. No warranty given for correct tax "
_('DEPRECATED'), "calculation. USE AT YOUR OWN RISK.")
_("Not recommended. Most events will NOT be qualified for reverse charge since the place of "
"taxation is the location of the event. This option disables charging VAT for all customers "
"outside the EU and for business customers in different EU countries who entered a valid EU VAT "
"ID. Only enable this option after consulting a tax counsel. No warranty given for correct tax "
"calculation. USE AT YOUR OWN RISK.")
),
) )
home_country = FastCountryField( home_country = FastCountryField(
verbose_name=_('Merchant country'), verbose_name=_('Merchant country'),
@@ -411,16 +240,6 @@ class TaxRule(LoggedModel):
if self.eu_reverse_charge and not self.home_country: if self.eu_reverse_charge and not self.home_country:
raise ValidationError(_('You need to set your home country to use the reverse charge feature.')) raise ValidationError(_('You need to set your home country to use the reverse charge feature.'))
if self.rate != Decimal("0.00") and self.code and (self.code.split("/")[0] in ("O", "E", "Z", "G", "K", "AE")):
raise ValidationError({
"code": _("A combination of this tax code with a non-zero tax rate does not make sense.")
})
if self.rate == Decimal("0.00") and self.code and (self.code.split("/")[0] in ("S", "L", "M", "B")):
raise ValidationError({
"code": _("A combination of this tax code with a zero tax rate does not make sense.")
})
def __str__(self): def __str__(self):
if self.price_includes_tax: if self.price_includes_tax:
s = _('incl. {rate}% {name}').format(rate=self.rate, name=self.name) s = _('incl. {rate}% {name}').format(rate=self.rate, name=self.name)
@@ -447,9 +266,8 @@ class TaxRule(LoggedModel):
return Decimal(rule.get('rate')) return Decimal(rule.get('rate'))
return Decimal(self.rate) return Decimal(self.rate)
def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, override_tax_code=None, def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, invoice_address=None,
invoice_address=None, subtract_from_gross=Decimal('0.00'), gross_price_is_tax_rate: Decimal = None, subtract_from_gross=Decimal('0.00'), gross_price_is_tax_rate: Decimal = None, force_fixed_gross_price=False):
force_fixed_gross_price=False):
from .event import Event from .event import Event
try: try:
currency = currency or self.event.currency currency = currency or self.event.currency
@@ -457,13 +275,6 @@ class TaxRule(LoggedModel):
pass pass
rate = Decimal(self.rate) rate = Decimal(self.rate)
code = self.code
if override_tax_code is not None:
code = override_tax_code
elif invoice_address:
code = self.tax_code_for(invoice_address)
if override_tax_rate is not None: if override_tax_rate is not None:
rate = override_tax_rate rate = override_tax_rate
elif invoice_address: elif invoice_address:
@@ -483,21 +294,10 @@ class TaxRule(LoggedModel):
subtract_from_gross = Decimal('0.00') subtract_from_gross = Decimal('0.00')
rate = adjust_rate rate = adjust_rate
def _limit_subtract(base_price, subtract_from_gross):
if not subtract_from_gross:
return base_price
if base_price >= Decimal('0.00'):
# For positive prices, make sure they don't go negative because of bundles
return max(Decimal('0.00'), base_price - subtract_from_gross)
else:
# If the price is already negative, we don't really care any more
return base_price - subtract_from_gross
if rate == Decimal('0.00'): if rate == Decimal('0.00'):
gross = _limit_subtract(base_price, subtract_from_gross)
return TaxedPrice( return TaxedPrice(
net=gross, gross=gross, tax=Decimal('0.00'), net=base_price - subtract_from_gross, gross=base_price - subtract_from_gross, tax=Decimal('0.00'),
rate=rate, name=self.name, code=code, rate=rate, name=self.name
) )
if base_price_is == 'auto': if base_price_is == 'auto':
@@ -507,14 +307,19 @@ class TaxRule(LoggedModel):
base_price_is = 'net' base_price_is = 'net'
if base_price_is == 'gross': if base_price_is == 'gross':
gross = _limit_subtract(base_price, subtract_from_gross) if base_price >= Decimal('0.00'):
# For positive prices, make sure they don't go negative because of bundles
gross = max(Decimal('0.00'), base_price - subtract_from_gross)
else:
# If the price is already negative, we don't really care any more
gross = base_price - subtract_from_gross
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))), net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
currency) currency)
elif base_price_is == 'net': elif base_price_is == 'net':
net = base_price net = base_price
gross = round_decimal((net * (1 + rate / 100)), currency) gross = round_decimal((net * (1 + rate / 100)), currency)
if subtract_from_gross: if subtract_from_gross:
gross = _limit_subtract(gross, subtract_from_gross) gross -= subtract_from_gross
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))), net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
currency) currency)
else: else:
@@ -522,7 +327,7 @@ class TaxRule(LoggedModel):
return TaxedPrice( return TaxedPrice(
net=net, gross=gross, tax=gross - net, net=net, gross=gross, tax=gross - net,
rate=rate, name=self.name, code=code, rate=rate, name=self.name
) )
@property @property
@@ -603,38 +408,6 @@ class TaxRule(LoggedModel):
return True return True
return False return False
def tax_code_for(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get("code"):
return rule["code"]
if rule.get("action", "vat") == "reverse":
return "AE"
return self.code
if not self.eu_reverse_charge:
# No reverse charge rules? Always apply VAT!
return self.code
if not invoice_address or not invoice_address.country:
# No country specified? Always apply VAT!
return self.code
if not is_eu_country(invoice_address.country):
# Non-EU country? "Non-taxable" since not in scope
return "O"
if invoice_address.country == self.home_country:
# Within same EU country? Always apply VAT!
return self.code
if invoice_address.is_business and invoice_address.vat_id and invoice_address.vat_id_validated:
# Reverse charge case
return "AE"
# Consumer in different EU country / invalid VAT
return self.code
def _tax_applicable(self, invoice_address): def _tax_applicable(self, invoice_address):
if self._custom_rules: if self._custom_rules:
rule = self.get_matching_rule(invoice_address) rule = self.get_matching_rule(invoice_address)

View File

@@ -73,7 +73,7 @@ class WaitingListEntry(LoggedModel):
blank=True, default=dict blank=True, default=dict
) )
email = models.EmailField( email = models.EmailField(
verbose_name=_("Email address") verbose_name=_("E-mail address")
) )
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, blank=True, null=True, blank=True,

View File

@@ -1419,51 +1419,50 @@ class GiftCardPayment(BasePaymentProvider):
def payment_refund_supported(self, payment: OrderPayment) -> bool: def payment_refund_supported(self, payment: OrderPayment) -> bool:
return True return True
def _add_giftcard_to_cart(self, cs, gc):
from pretix.base.services.cart import add_payment_to_cart_session
if gc.currency != self.event.currency:
raise ValidationError(_("This gift card does not support this currency."))
if gc.testmode and not self.event.testmode:
raise ValidationError(_("This gift card can only be used in test mode."))
if not gc.testmode and self.event.testmode:
raise ValidationError(_("Only test gift cards can be used in test mode."))
if gc.expires and gc.expires < time_machine_now():
raise ValidationError(_("This gift card is no longer valid."))
if gc.value <= Decimal("0.00"):
raise ValidationError(_("All credit on this gift card has been used."))
for p in cs.get('payments', []):
if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk:
raise ValidationError(_("This gift card is already used for your payment."))
add_payment_to_cart_session(
cs,
self,
max_value=gc.value,
info_data={
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
}
)
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]: def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
from pretix.base.services.cart import add_payment_to_cart
for p in get_cart(request): for p in get_cart(request):
if p.item.issue_giftcard: if p.item.issue_giftcard:
messages.error(request, _("You cannot pay with gift cards when buying a gift card.")) messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
return return
cs = cart_session(request)
try: try:
gc = self.event.organizer.accepted_gift_cards.get( gc = self.event.organizer.accepted_gift_cards.get(
secret=request.POST.get("giftcard").strip() secret=request.POST.get("giftcard").strip()
) )
cs = cart_session(request) if gc.currency != self.event.currency:
try: messages.error(request, _("This gift card does not support this currency."))
self._add_giftcard_to_cart(cs, gc)
return True
except ValidationError as e:
messages.error(request, str(e.message))
return return
if gc.testmode and not self.event.testmode:
messages.error(request, _("This gift card can only be used in test mode."))
return
if not gc.testmode and self.event.testmode:
messages.error(request, _("Only test gift cards can be used in test mode."))
return
if gc.expires and gc.expires < time_machine_now():
messages.error(request, _("This gift card is no longer valid."))
return
if gc.value <= Decimal("0.00"):
messages.error(request, _("All credit on this gift card has been used."))
return
for p in cs.get('payments', []):
if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk:
messages.error(request, _("This gift card is already used for your payment."))
return
add_payment_to_cart(
request,
self,
max_value=gc.value,
info_data={
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
}
)
return True
except GiftCard.DoesNotExist: except GiftCard.DoesNotExist:
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists(): if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "

View File

@@ -343,13 +343,11 @@ class CartManager:
err = error_messages['some_subevent_not_started'] err = error_messages['some_subevent_not_started']
cp.addons.all().delete() cp.addons.all().delete()
cp.delete() cp.delete()
continue
if cp.subevent and cp.subevent.presale_end and time_machine_now(self.real_now_dt) > cp.subevent.presale_end: if cp.subevent and cp.subevent.presale_end and time_machine_now(self.real_now_dt) > cp.subevent.presale_end:
err = error_messages['some_subevent_ended'] err = error_messages['some_subevent_ended']
cp.addons.all().delete() cp.addons.all().delete()
cp.delete() cp.delete()
continue
if cp.subevent: if cp.subevent:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
@@ -362,7 +360,6 @@ class CartManager:
err = error_messages['some_subevent_ended'] err = error_messages['some_subevent_ended']
cp.addons.all().delete() cp.addons.all().delete()
cp.delete() cp.delete()
continue
return err return err
def _update_subevents_cache(self, se_ids: List[int]): def _update_subevents_cache(self, se_ids: List[int]):
@@ -1426,28 +1423,6 @@ class CartManager:
raise CartError(err) raise CartError(err)
def add_payment_to_cart_session(cart_session, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
"""
:param cart_session: The current cart session.
:param provider: The instance of your payment provider.
:param min_value: The minimum value this payment instrument supports, or ``None`` for unlimited.
:param max_value: The maximum value this payment instrument supports, or ``None`` for unlimited. Highly discouraged
to use for payment providers which charge a payment fee, as this can be very user-unfriendly if
users need a second payment method just for the payment fee of the first method.
:param info_data: A dictionary of information that will be passed through to the ``OrderPayment.info_data`` attribute.
:return:
"""
cart_session.setdefault('payments', [])
cart_session['payments'].append({
'id': str(uuid.uuid4()),
'provider': provider.identifier,
'multi_use_supported': provider.multi_use_supported,
'min_value': str(min_value) if min_value is not None else None,
'max_value': str(max_value) if max_value is not None else None,
'info_data': info_data or {},
})
def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
""" """
:param request: The current HTTP request context. :param request: The current HTTP request context.
@@ -1462,7 +1437,16 @@ def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: D
from pretix.presale.views.cart import cart_session from pretix.presale.views.cart import cart_session
cs = cart_session(request) cs = cart_session(request)
add_payment_to_cart_session(cs, provider, min_value, max_value, info_data) cs.setdefault('payments', [])
cs['payments'].append({
'id': str(uuid.uuid4()),
'provider': provider.identifier,
'multi_use_supported': provider.multi_use_supported,
'min_value': str(min_value) if min_value is not None else None,
'max_value': str(max_value) if max_value is not None else None,
'info_data': info_data or {},
})
def get_fees(event, request, total, invoice_address, payments, positions): def get_fees(event, request, total, invoice_address, payments, positions):
@@ -1513,7 +1497,6 @@ def get_fees(event, request, total, invoice_address, payments, positions):
value=payment_fee, value=payment_fee,
tax_rate=payment_fee_tax.rate, tax_rate=payment_fee_tax.rate,
tax_value=payment_fee_tax.tax, tax_value=payment_fee_tax.tax,
tax_code=payment_fee_tax.code,
tax_rule=payment_fee_tax_rule tax_rule=payment_fee_tax_rule
)) ))
@@ -1559,9 +1542,10 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
""" """
Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param voucher: A voucher code :param voucher: A voucher code
:param cart_id: The cart ID of the cart to modify :param session: Session ID of a guest
""" """
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
@@ -1582,10 +1566,10 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
""" """
Removes an item specified by its position ID from a user's cart. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param position: A cart position ID :param position: A cart position ID
:param cart_id: The cart ID of the cart to modify :param session: Session ID of a guest
""" """
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
@@ -1606,9 +1590,9 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
""" """
Removes all items from a user's cart. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param cart_id: The cart ID of the cart to modify :param session: Session ID of a guest
""" """
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
@@ -1627,15 +1611,13 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en', def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None: invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:
""" """
Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to Removes a list of items from a user's cart.
ensure the requested addon state.
:param event: The event ID in question :param event: The event ID in question
:param addons: A list of dicts with the keys addon_to, item, variation :param addons: A list of dicts with the keys addon_to, item, variation
:param add_to_cart_items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID :param session: Session ID of a guest
:param cart_id: The cart ID of the cart to modify
""" """
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
ia = False ia = False
@@ -1653,7 +1635,6 @@ def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: L
try: try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel) cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
cm.set_addons(addons) cm.set_addons(addons)
cm.add_new_items(add_to_cart_items)
cm.commit() cm.commit()
except LockTimeoutException: except LockTimeoutException:
self.retry() self.retry()

View File

@@ -57,7 +57,7 @@ from pretix.base.models import (
Checkin, CheckinList, Device, Event, Gate, Item, ItemVariation, Order, Checkin, CheckinList, Device, Event, Gate, Item, ItemVariation, Order,
OrderPosition, QuestionOption, OrderPosition, QuestionOption,
) )
from pretix.base.signals import checkin_created, periodic_task from pretix.base.signals import checkin_created, order_placed, periodic_task
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.jsonlogic import Logic from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
@@ -1154,6 +1154,23 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
) )
@receiver(order_placed, dispatch_uid="legacy_autocheckin_order_placed")
def order_placed(sender, **kwargs):
order = kwargs['order']
event = sender
cls = list(event.checkin_lists.filter(auto_checkin_sales_channels=order.sales_channel).prefetch_related(
'limit_products'))
if not cls:
return
for op in order.positions.all():
for cl in cls:
if cl.all_products or op.item_id in {i.pk for i in cl.limit_products.all()}:
if not cl.subevent_id or cl.subevent_id == op.subevent_id:
ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True, type=Checkin.TYPE_ENTRY)
checkin_created.send(event, checkin=ci)
@receiver(periodic_task, dispatch_uid="autocheckout_exit_all") @receiver(periodic_task, dispatch_uid="autocheckout_exit_all")
@scopes_disabled() @scopes_disabled()
def process_exit_all(sender, **kwargs): def process_exit_all(sender, **kwargs):
@@ -1165,11 +1182,10 @@ def process_exit_all(sender, **kwargs):
positions = cl.positions_inside_query(ignore_status=True, at_time=cl.exit_all_at) positions = cl.positions_inside_query(ignore_status=True, at_time=cl.exit_all_at)
for p in positions: for p in positions:
with scope(organizer=cl.event.organizer): with scope(organizer=cl.event.organizer):
ci, created = Checkin.objects.get_or_create( ci = Checkin.objects.create(
position=p, list=cl, auto_checked_in=True, type=Checkin.TYPE_EXIT, datetime=cl.exit_all_at position=p, list=cl, auto_checked_in=True, type=Checkin.TYPE_EXIT, datetime=cl.exit_all_at
) )
if created: checkin_created.send(cl.event, checkin=ci)
checkin_created.send(cl.event, checkin=ci)
d = cl.exit_all_at.astimezone(cl.event.timezone) d = cl.exit_all_at.astimezone(cl.event.timezone)
if cl.event.settings.get(f'autocheckin_dst_hack_{cl.pk}'): # move time back if yesterday was DST switch if cl.event.settings.get(f'autocheckin_dst_hack_{cl.pk}'): # move time back if yesterday was DST switch
d -= timedelta(hours=1) d -= timedelta(hours=1)

View File

@@ -1,234 +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 decimal import Decimal
from itertools import groupby
from math import inf
from typing import List
from django.utils.functional import cached_property
from pretix.base.models import CartPosition, ItemCategory, SalesChannel
from pretix.presale.views.event import get_grouped_items
class DummyCategory:
"""
Used to create fake category objects for displaying the same cross-selling category multiple times,
once for each subevent
"""
def __init__(self, category: ItemCategory, subevent):
self.id = category.id
self.name = str(category.name)
self.subevent_name = str(subevent)
self.description = category.description
class CrossSellingService:
def __init__(self, event, sales_channel: SalesChannel, cartpositions: List[CartPosition], customer):
self.event = event
self.sales_channel = sales_channel
self.cartpositions = cartpositions
self.customer = customer
def get_data(self):
if self.event.has_subevents:
subevents = set(pos.subevent for pos in self.cartpositions)
result = (
(DummyCategory(category, subevent),
self._prepare_items(subevent, items_qs, discount_info),
f'subevent_{subevent.pk}_')
for subevent in subevents
for (category, items_qs, discount_info) in self._applicable_categories(subevent.pk)
)
else:
result = (
(category,
self._prepare_items(None, items_qs, discount_info),
'')
for (category, items_qs, discount_info) in self._applicable_categories(0)
)
result = [(category, items, form_prefix) for (category, items, form_prefix) in result if len(items) > 0]
for category, items, form_prefix in result:
category.category_has_discount = any(item.original_price or (
item.has_variations and any(var.original_price for var in item.available_variations)
) for item in items)
return result
def _applicable_categories(self, subevent_id):
return [
(c, products_qs, discount_info) for (c, products_qs, discount_info) in
(
(c, *self._get_visible_items_for_category(subevent_id, c))
for c in self.event.categories.filter(cross_selling_mode__isnull=False).prefetch_related('items')
)
if products_qs is not None
]
def _get_visible_items_for_category(self, filter_subevent_id, category: ItemCategory):
"""
If this category should be visible in the cross-selling step for a given cart and sales_channel, this method
returns a queryset of the items that should be displayed, as well as a dict giving additional information on them.
:returns: (QuerySet<Item>, dict<(subevent_id, item_pk): (max_count, discount_rule)>)
max_count is `inf` if the item should not be limited
discount_rule is None if the item will not be discounted
"""
if category.cross_selling_mode is None:
return None, {}
if category.cross_selling_condition == 'always':
return category.items.all(), {}
if category.cross_selling_condition == 'products':
match = set(match.pk for match in category.cross_selling_match_products.only('pk')) # TODO prefetch this
return (category.items.all(), {}) if any(pos.item.pk in match for pos in self.cartpositions) else (None, {})
if category.cross_selling_condition == 'discounts':
my_item_pks = [item.id for item in category.items.all()]
potential_discount_items = {
item.pk: (max_count, discount_rule)
for subevent_id, item, max_count, discount_rule in self._potential_discounts_by_subevent_and_item_for_current_cart
if max_count > 0 and item.pk in my_item_pks and item.is_available() and (subevent_id == filter_subevent_id or subevent_id is None)
}
return category.items.filter(pk__in=potential_discount_items), potential_discount_items
@cached_property
def _potential_discounts_by_subevent_and_item_for_current_cart(self):
potential_discounts_by_cartpos = defaultdict(list)
from ..services.pricing import apply_discounts
self._discounted_prices = apply_discounts(
self.event,
self.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled,
cp.listed_price - cp.price_after_voucher)
for cp in self.cartpositions
],
collect_potential_discounts=potential_discounts_by_cartpos
)
# flatten potential_discounts_by_cartpos (a dict of lists of potential discounts) into a set of potential discounts
# (which is technically stored as a dict, but we use it as an OrderedSet here)
potential_discount_set = dict.fromkeys(
info for lst in potential_discounts_by_cartpos.values() for info in lst)
# sum up the max_counts and pass them on (also pass on the discount_rules so we can calculate actual discounted prices later):
# group by benefit product
# - max_count for product: sum up max_counts
# - discount_rule for product: take first discount_rule
def discount_info(subevent_id, item, infos_for_item):
infos_for_item = list(infos_for_item)
return (
subevent_id,
item,
sum(max_count for (subevent_id, item, discount_rule, max_count, i) in infos_for_item),
next(discount_rule for (subevent_id, item, discount_rule, max_count, i) in infos_for_item),
)
return [
discount_info(subevent_id, item, infos_for_item) for (subevent_id, item), infos_for_item in
groupby(
sorted(
(
(subevent_id, item, discount_rule, max_count, i)
for (discount_rule, max_count, i, subevent_id) in potential_discount_set.keys()
for item in discount_rule.benefit_limit_products.all()
),
key=lambda tup: (tup[0], tup[1].pk)
),
lambda tup: (tup[0], tup[1]))
]
def _prepare_items(self, subevent, items_qs, discount_info):
items, _btn = get_grouped_items(
self.event,
subevent=subevent,
voucher=None,
channel=self.sales_channel,
base_qs=items_qs,
allow_addons=False,
allow_cross_sell=True,
memberships=(
self.customer.usable_memberships(
for_event=subevent or self.event,
testmode=self.event.testmode
)
if self.customer else None
),
)
new_items = list()
for item in items:
max_count = inf
if item.pk in discount_info:
(max_count, discount_rule) = discount_info[item.pk]
# only benefit_only_apply_to_cheapest_n_matches discounted items have a max_count, all others get 'inf'
if not max_count:
max_count = inf
# calculate discounted price
if discount_rule and discount_rule.benefit_discount_matching_percent > 0:
if not item.has_variations:
item.original_price = item.original_price or item.display_price
previous_price = item.display_price
new_price = (
previous_price * (
(Decimal('100.00') - discount_rule.benefit_discount_matching_percent) / Decimal('100.00'))
)
item.display_price = new_price
else:
# discounts always match "whole" items, not specific variations -> we apply the discount to all
# available variations of the item
for var in item.available_variations:
var.original_price = var.original_price or var.display_price
previous_price = var.display_price
new_price = (
previous_price * (
(Decimal('100.00') - discount_rule.benefit_discount_matching_percent) / Decimal('100.00'))
)
var.display_price = new_price
if not item.has_variations:
# reduce order_max by number of items already in cart (prevent recommending a product the user can't add anyway)
item.order_max = min(
item.order_max - sum(1 for pos in self.cartpositions if pos.item_id == item.pk),
max_count
)
if item.order_max > 0:
new_items.append(item)
else:
new_vars = list()
for var in item.available_variations:
# reduce order_max by number of items already in cart (prevent recommending a product the user can't add anyway)
var.order_max = min(
var.order_max - sum(1 for pos in self.cartpositions if pos.item_id == item.pk and pos.variation_id == var.pk),
max_count
)
if var.order_max > 0:
new_vars.append(var)
if len(new_vars):
item.available_variations = new_vars
new_items.append(item)
return new_items

View File

@@ -271,9 +271,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from, event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from,
event_date_to=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to, event_date_to=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to,
event_location=location if invoice.event.settings.invoice_event_location else None, event_location=location if invoice.event.settings.invoice_event_location else None,
tax_rate=p.tax_rate, tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
tax_code=p.tax_code,
tax_name=p.tax_rule.name if p.tax_rule else ''
) )
if p.tax_rule and p.tax_rule.is_reverse_charge(ia) and p.price and not p.tax_value: if p.tax_rule and p.tax_rule.is_reverse_charge(ia) and p.price and not p.tax_value:
@@ -307,7 +305,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
), ),
tax_value=fee.tax_value, tax_value=fee.tax_value,
tax_rate=fee.tax_rate, tax_rate=fee.tax_rate,
tax_code=fee.tax_code,
tax_name=fee.tax_rule.name if fee.tax_rule else '', tax_name=fee.tax_rule.name if fee.tax_rule else '',
fee_type=fee.fee_type, fee_type=fee.fee_type,
fee_internal_type=fee.internal_type or None, fee_internal_type=fee.internal_type or None,
@@ -494,13 +491,13 @@ def build_preview_invoice_pdf(event):
InvoiceLine.objects.create( InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product {}").format(i + 1), invoice=invoice, description=_("Sample product {}").format(i + 1),
gross_value=tax.gross, tax_value=tax.tax, gross_value=tax.gross, tax_value=tax.tax,
tax_rate=tax.rate, tax_name=tax.name, tax_code=tax.code, tax_rate=tax.rate, tax_name=tax.name
) )
else: else:
for i in range(5): for i in range(5):
InvoiceLine.objects.create( InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product A"), invoice=invoice, description=_("Sample product A"),
gross_value=100, tax_value=0, tax_rate=0, tax_code=None, gross_value=100, tax_value=0, tax_rate=0
) )
return event.invoice_renderer.generate(invoice) return event.invoice_renderer.generate(invoice)

View File

@@ -58,7 +58,6 @@ from django.core.mail import (
from django.core.mail.message import SafeMIMEText from django.core.mail.message import SafeMIMEText
from django.db import transaction from django.db import transaction
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.html import escape
from django.utils.timezone import now, override from django.utils.timezone import now, override
from django.utils.translation import gettext as _, pgettext from django.utils.translation import gettext as _, pgettext
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
@@ -76,7 +75,7 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.format import SafeFormatter, format_map from pretix.helpers.format import format_map
from pretix.helpers.hierarkey import clean_filename from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals from pretix.presale.ical import get_private_icals
@@ -110,22 +109,6 @@ def clean_sender_name(sender_name: str) -> str:
return sender_name return sender_name
def prefix_subject(settings_holder, subject, highlight=False):
prefix = settings_holder.settings.get('mail_prefix')
if prefix and prefix.startswith('[') and prefix.endswith(']'):
prefix = prefix[1:-1]
if prefix:
prefix = f"[{prefix}]"
if highlight:
prefix = '<span class="placeholder" title="{}">{}</span>'.format(
_('This prefix has been set in your event or organizer settings.'),
escape(prefix)
)
subject = f"{prefix} {subject}"
return subject
def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString], def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None, context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None, position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
@@ -257,7 +240,11 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
and settings_holder.settings.contact_mail and not headers.get('Reply-To'): and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = settings_holder.settings.contact_mail headers['Reply-To'] = settings_holder.settings.contact_mail
subject = prefix_subject(settings_holder, subject) prefix = settings_holder.settings.get('mail_prefix')
if prefix and prefix.startswith('[') and prefix.endswith(']'):
prefix = prefix[1:-1]
if prefix:
subject = "[%s] %s" % (prefix, subject)
body_plain += "\r\n\r\n-- \r\n" body_plain += "\r\n\r\n-- \r\n"
@@ -301,7 +288,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
order.event, 'presale:event.order.open', kwargs={ order.event, 'presale:event.order.open', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
'hash': order.email_confirm_secret() 'hash': order.email_confirm_hash()
} }
) )
) )
@@ -311,17 +298,11 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
try: try:
if plain_text_only: if plain_text_only:
body_html = None body_html = None
elif 'context' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, raw_subject, order, position, context)
elif 'position' in inspect.signature(renderer.render).parameters: elif 'position' in inspect.signature(renderer.render).parameters:
# Backwards compatibility
warnings.warn('Email renderer called without context argument because context argument is not '
'supported.',
DeprecationWarning)
body_html = renderer.render(content_plain, signature, raw_subject, order, position) body_html = renderer.render(content_plain, signature, raw_subject, order, position)
else: else:
# Backwards compatibility # Backwards compatibility
warnings.warn('Email renderer called without position argument because position argument is not ' warnings.warn('E-mail renderer called without position argument because position argument is not '
'supported.', 'supported.',
DeprecationWarning) DeprecationWarning)
body_html = renderer.render(content_plain, signature, raw_subject, order) body_html = renderer.render(content_plain, signature, raw_subject, order)
@@ -329,8 +310,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
logger.exception('Could not render HTML body') logger.exception('Could not render HTML body')
body_html = None body_html = None
body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
send_task = mail_send_task.si( send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email), to=[email] if isinstance(email, str) else list(email),
cc=cc, cc=cc,
@@ -663,7 +642,7 @@ def render_mail(template, context):
if isinstance(template, LazyI18nString): if isinstance(template, LazyI18nString):
body = str(template) body = str(template)
if context: if context:
body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH) body = format_map(body, context)
else: else:
tpl = get_template(template) tpl = get_template(template)
body = tpl.render(context) body = tpl.render(context)

View File

@@ -118,7 +118,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
c.assign(record.get(c.identifier), order, position, order._address) c.assign(record.get(c.identifier), order, position, order._address)
if position.seat is not None: if position.seat is not None:
lock_seats.append((order.sales_channel, position.seat)) lock_seats.append(position.seat)
except (ValidationError, ImportError) as e: except (ValidationError, ImportError) as e:
raise DataImportError( raise DataImportError(
_('Invalid data in row {row}: {message}').format(row=i, message=str(e)) _('Invalid data in row {row}: {message}').format(row=i, message=str(e))
@@ -128,9 +128,9 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
with transaction.atomic(): with transaction.atomic():
# We don't support vouchers, quotas, or memberships here, so we only need to lock if seats are in use # We don't support vouchers, quotas, or memberships here, so we only need to lock if seats are in use
if lock_seats: if lock_seats:
lock_objects([s for c, s in lock_seats], shared_lock_objects=[event]) lock_objects(lock_seats, shared_lock_objects=[event])
for c, s in lock_seats: for s in lock_seats:
if not s.is_available(sales_channel=c): if not s.is_available():
raise DataImportError(_('The seat you selected has already been taken. Please select a different seat.')) raise DataImportError(_('The seat you selected has already been taken. Please select a different seat.'))
save_transactions = [] save_transactions = []

View File

@@ -1721,17 +1721,16 @@ class OrderChangeManager:
try: try:
new_rate = tax_rule.tax_rate_for(ia) new_rate = tax_rule.tax_rate_for(ia)
new_code = tax_rule.tax_code_for(ia)
except TaxRule.SaleNotAllowed: except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['tax_rule_country_blocked']) raise OrderError(error_messages['tax_rule_country_blocked'])
# We use override_tax_rate to make sure .tax() doesn't get clever and re-adjusts the pricing itself # We use override_tax_rate to make sure .tax() doesn't get clever and re-adjusts the pricing itself
if new_rate != pos.tax_rate or new_code != pos.tax_code: if new_rate != pos.tax_rate:
if keep == 'net': if keep == 'net':
new_tax = tax_rule.tax(pos.price - pos.tax_value, base_price_is='net', currency=self.event.currency, new_tax = tax_rule.tax(pos.price - pos.tax_value, base_price_is='net', currency=self.event.currency,
override_tax_rate=new_rate, override_tax_code=new_code) override_tax_rate=new_rate)
else: else:
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency, new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
override_tax_rate=new_rate, override_tax_code=new_code) override_tax_rate=new_rate)
self._totaldiff += new_tax.gross - pos.price self._totaldiff += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price)) self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
self._invoice_dirty = True self._invoice_dirty = True
@@ -2305,7 +2304,6 @@ class OrderChangeManager:
op.position.price = op.price.gross op.position.price = op.price.gross
op.position.tax_rate = op.price.rate op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax op.position.tax_value = op.price.tax
op.position.tax_code = op.price.code
op.position.save() op.position.save()
elif isinstance(op, self.TaxRuleOperation): elif isinstance(op, self.TaxRuleOperation):
if isinstance(op.position, OrderPosition): if isinstance(op.position, OrderPosition):
@@ -2402,7 +2400,7 @@ class OrderChangeManager:
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create( pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to, item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, price=op.price.gross, order=self.order, tax_rate=op.price.rate,
tax_value=op.price.tax, tax_rule=op.item.tax_rule, tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat, positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
@@ -2425,8 +2423,6 @@ class OrderChangeManager:
elif isinstance(op, self.SplitOperation): elif isinstance(op, self.SplitOperation):
split_positions.append(op.position) split_positions.append(op.position)
elif isinstance(op, self.RegenerateSecretOperation): elif isinstance(op, self.RegenerateSecretOperation):
op.position.web_secret = generate_secret()
op.position.save(update_fields=["web_secret"])
assign_ticket_secret( assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=True, save=True event=self.event, position=op.position, force_invalidate=True, save=True
) )
@@ -2533,7 +2529,6 @@ class OrderChangeManager:
'new_order': split_order.code, 'new_order': split_order.code,
}) })
op.order = split_order op.order = split_order
op.web_secret = generate_secret()
assign_ticket_secret( assign_ticket_secret(
self.event, position=op, force_invalidate=True, self.event, position=op, force_invalidate=True,
) )

View File

@@ -26,7 +26,6 @@ from decimal import Decimal
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -40,8 +39,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from pretix.base.signals import ( from pretix.base.signals import (
register_mail_placeholders, register_text_placeholders, register_mail_placeholders, register_text_placeholders,
) )
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.helpers.format import SafeFormatter
from pretix.helpers.format import PlainHtmlAlternativeString, SafeFormatter
logger = logging.getLogger('pretix.base.services.placeholders') logger = logging.getLogger('pretix.base.services.placeholders')
@@ -109,91 +107,6 @@ class SimpleFunctionalTextPlaceholder(BaseTextPlaceholder):
return self._sample return self._sample
class BaseRichTextPlaceholder(BaseTextPlaceholder):
"""
This is the base class for all placeholders which can render either to plain text
or to a rich HTML element.
"""
def __init__(self, identifier, args):
self._identifier = identifier
self._args = args
@property
def identifier(self):
return self._identifier
@property
def required_context(self):
return self._args
@property
def is_block(self):
return False
def render(self, context):
return PlainHtmlAlternativeString(
self.render_plain(**{k: context[k] for k in self._args}),
self.render_html(**{k: context[k] for k in self._args}),
self.is_block,
)
def render_html(self, **kwargs):
"""
HTML rendering of the placeholder. Should return "safe" HTML, i.e. everything needs to be
escaped.
"""
raise NotImplementedError
def render_plain(self, **kwargs):
"""
Plain text rendering of the placeholder.
"""
raise NotImplementedError
def render_sample(self, event):
return PlainHtmlAlternativeString(
self.render_sample_plain(event=event),
self.render_sample_html(event=event),
self.is_block,
)
def render_sample_html(self, event):
raise NotImplementedError
def render_sample_plain(self, event):
raise NotImplementedError
class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
def __init__(self, identifier, args, url_func, text_func, sample_url_func, sample_text_func):
super().__init__(identifier, args)
self._url_func = url_func
self._text_func = text_func
self._sample_url_func = sample_url_func
self._sample_text_func = sample_text_func
def render_html(self, **context):
text = self._text_func(**{k: context[k] for k in self._args})
url = self._url_func(**{k: context[k] for k in self._args})
return f'<a href="{url}" class="button">{escape(text)}</a>'
def render_plain(self, **context):
text = self._text_func(**{k: context[k] for k in self._args})
url = self._url_func(**{k: context[k] for k in self._args})
return f'{text}: {url}'
def render_sample_html(self, event):
text = self._sample_text_func(event)
url = self._sample_url_func(event)
return f'<a href="{url}" class="button">{escape(text)}</a>'
def render_sample_plain(self, event):
text = self._sample_text_func(event)
url = self._sample_url_func(event)
return f'{text}: {url}'
class PlaceholderContext(SafeFormatter): class PlaceholderContext(SafeFormatter):
""" """
Holds the contextual arguments and corresponding list of available placeholders for formatting Holds the contextual arguments and corresponding list of available placeholders for formatting
@@ -296,24 +209,13 @@ def get_best_name(position_or_address, parts=False):
def base_placeholders(sender, **kwargs): def base_placeholders(sender, **kwargs):
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
def _event_sample(event):
if event.has_subevents:
se = event.subevents.first()
if se:
return se.name
return event.name
ph = [ ph = [
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'event', ['event'], lambda event: event.name, lambda event: event.name 'event', ['event'], lambda event: event.name, lambda event: event.name
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name, 'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
_event_sample, lambda event_or_subevent: event_or_subevent.name
),
SimpleFunctionalTextPlaceholder(
'event_series_name', ['event', 'event_or_subevent'], lambda event, event_or_subevent: event.name,
lambda event: event.name
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug 'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
@@ -360,7 +262,7 @@ def base_placeholders(sender, **kwargs):
'presale:event.order.open', kwargs={ 'presale:event.order.open', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
'hash': order.email_confirm_secret() 'hash': order.email_confirm_hash()
} }
), lambda event: build_absolute_uri( ), lambda event: build_absolute_uri(
event, event,
@@ -371,27 +273,6 @@ def base_placeholders(sender, **kwargs):
} }
), ),
), ),
SimpleButtonPlaceholder(
'url_button', ['order', 'event'],
url_func=lambda order, event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_secret()
}
),
text_func=lambda order, event: _("View order details"),
sample_url_func=lambda event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'hash': '98kusd8ofsj8dnkd'
}
),
sample_text_func=lambda event: _("View order details"),
),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri( 'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event, event,
@@ -456,27 +337,6 @@ def base_placeholders(sender, **kwargs):
} }
), ),
), ),
SimpleButtonPlaceholder(
'url_button', ['event', 'position'],
url_func=lambda event, position: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': position.order.code,
'secret': position.web_secret,
'position': position.positionid
}
),
text_func=lambda event, position: _("View registration details"),
sample_url_func=lambda event: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'position': '123'
}
),
sample_text_func=lambda event: _("View registration details"),
),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri( 'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri(
event, event,
@@ -583,7 +443,7 @@ def base_placeholders(sender, **kwargs):
'organizer': event.organizer.slug, 'organizer': event.organizer.slug,
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
'hash': order.email_confirm_secret(), 'hash': order.email_confirm_hash(),
}), }),
) )
for order in orders for order in orders
@@ -732,8 +592,8 @@ def base_placeholders(sender, **kwargs):
class FormPlaceholderMixin: class FormPlaceholderMixin:
def _set_field_placeholders(self, fn, base_parameters, rich=False): def _set_field_placeholders(self, fn, base_parameters):
placeholders = get_available_placeholders(self.event, base_parameters, rich=rich) placeholders = get_available_placeholders(self.event, base_parameters)
ht = format_placeholders_help_text(placeholders, self.event) ht = format_placeholders_help_text(placeholders, self.event)
if self.fields[fn].help_text: if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht) self.fields[fn].help_text += ' ' + str(ht)
@@ -744,7 +604,7 @@ class FormPlaceholderMixin:
) )
def get_available_placeholders(event, base_parameters, rich=False): def get_available_placeholders(event, base_parameters):
if 'order' in base_parameters: if 'order' in base_parameters:
base_parameters.append('invoice_address') base_parameters.append('invoice_address')
base_parameters.append('position_or_address') base_parameters.append('position_or_address')
@@ -753,35 +613,6 @@ def get_available_placeholders(event, base_parameters, rich=False):
if not isinstance(val, (list, tuple)): if not isinstance(val, (list, tuple)):
val = [val] val = [val]
for v in val: for v in val:
if isinstance(v, BaseRichTextPlaceholder) and not rich:
continue
if all(rp in base_parameters for rp in v.required_context): if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v params[v.identifier] = v
return params return params
def get_sample_context(event, context_parameters, rich=True):
context_dict = {}
lbl = _('This value will be replaced based on dynamic parameters.')
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items():
sample = v.render_sample(event)
if isinstance(sample, PlainHtmlAlternativeString):
context_dict[k] = PlainHtmlAlternativeString(
sample.plain,
'<{el} class="placeholder placeholder-html" title="{title}">{html}</{el}>'.format(
el='div' if sample.is_block else 'span',
title=lbl,
html=sample.html,
)
)
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
context_dict[k] = '<div class="placeholder" title="{}">{}</div>'.format(
lbl,
markdown_compile_email(str(sample))
)
else:
context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
lbl,
escape(sample)
)
return context_dict

View File

@@ -20,9 +20,8 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import re import re
from collections import defaultdict
from decimal import Decimal from decimal import Decimal
from typing import List, Optional, Tuple, Union from typing import List, Optional, Tuple
from django import forms from django import forms
from django.db.models import Q from django.db.models import Q
@@ -32,7 +31,6 @@ from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
SalesChannel, Voucher, SalesChannel, Voucher,
) )
from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent from pretix.base.models.event import Event, SubEvent
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.timemachine import time_machine_now from pretix.base.timemachine import time_machine_now
@@ -91,11 +89,9 @@ def get_price(item: Item, variation: ItemVariation = None,
if custom_price_is_net: if custom_price_is_net:
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net', override_tax_rate=price.rate, price = tax_rule.tax(max(custom_price, price.net), base_price_is='net', override_tax_rate=price.rate,
override_tax_code=price.code,
invoice_address=invoice_address, subtract_from_gross=bundled_sum) invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else: else:
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', override_tax_rate=price.rate, price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', override_tax_rate=price.rate,
override_tax_code=price.code,
invoice_address=invoice_address, subtract_from_gross=bundled_sum) invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else: else:
price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum) price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
@@ -148,12 +144,10 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
if custom_price_input_is_net: if custom_price_input_is_net:
price = tax_rule.tax(max(custom_price_input, price.net), base_price_is='net', override_tax_rate=price.rate, price = tax_rule.tax(max(custom_price_input, price.net), base_price_is='net', override_tax_rate=price.rate,
override_tax_code=price.code, invoice_address=invoice_address, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
subtract_from_gross=bundled_sum)
else: else:
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate, price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
override_tax_code=price.code, invoice_address=invoice_address, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
subtract_from_gross=bundled_sum)
else: else:
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum, price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum,
base_price_is='gross' if is_bundled else 'auto') base_price_is='gross' if is_bundled else 'auto')
@@ -161,22 +155,14 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
return price return price
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], def apply_discounts(event: Event, sales_channel: str,
positions: List[Tuple[int, Optional[int], Decimal, bool, bool, Decimal]], positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]:
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]:
""" """
Applies any dynamic discounts to a cart Applies any dynamic discounts to a cart
:param event: Event the cart belongs to :param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with :param sales_channel: Sales channel the cart was created with
:param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)`` :param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
of tuples describing the discounts that could be applied in the form `(discount, max_count, grouping_id)`.
`max_count` is either the maximum number of benefitting items that the discount would apply to, or `inf` if that number
is not limited. The `grouping_id` can be used to distinguish several occurrences of the same discount.
:return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input :return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input
""" """
if isinstance(sales_channel, SalesChannel): if isinstance(sales_channel, SalesChannel):
@@ -191,10 +177,10 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs: for discount in discount_qs:
result = discount.apply({ result = discount.apply({
idx: PositionInfo(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions) for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions)
if not is_bundled and idx not in new_prices if not is_bundled and idx not in new_prices
}, collect_potential_discounts) })
for k in result.keys(): for k in result.keys():
result[k] = (result[k], discount) result[k] = (result[k], discount)
new_prices.update(result) new_prices.update(result)

View File

@@ -21,7 +21,6 @@
# #
import logging import logging
import os import os
from decimal import Decimal
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.timezone import now from django.utils.timezone import now
@@ -98,9 +97,9 @@ def preview(event: int, provider: str):
event = Event.objects.get(id=event) event = Event.objects.get(id=event)
with rolledback_transaction(), language(event.settings.locale, event.settings.region): with rolledback_transaction(), language(event.settings.locale, event.settings.region):
item = event.items.create(name=_("Sample product"), default_price=Decimal('42.23'), item = event.items.create(name=_("Sample product"), default_price=42.23,
description=_("Sample product description")) description=_("Sample product description"))
item2 = event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40')) item2 = event.items.create(name=_("Sample workshop"), default_price=23.40)
from pretix.base.models import Order from pretix.base.models import Order
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(), order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),

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