forked from CGM_Public/pretix_original
Compare commits
4 Commits
logentryty
...
pdf-layout
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1263f4359f | ||
|
|
645df63e66 | ||
|
|
3416366763 | ||
|
|
bbefb59036 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- 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
|
||||
run: pip3 install -U setuptools build pip check-manifest
|
||||
- name: Run check-manifest
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- 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
|
||||
run: pip3 install -Ur requirements.txt
|
||||
working-directory: ./doc
|
||||
|
||||
6
.github/workflows/strings.yml
vendored
6
.github/workflows/strings.yml
vendored
@@ -35,9 +35,9 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system packages
|
||||
run: sudo apt update && sudo apt -y install gettext
|
||||
run: sudo apt update && sudo apt install gettext
|
||||
- name: Install Dependencies
|
||||
run: pip3 install uv && uv pip install --system -e ".[dev]"
|
||||
run: pip3 install -e ".[dev]"
|
||||
- name: Compile messages
|
||||
run: python manage.py compilemessages
|
||||
working-directory: ./src
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
- name: Install system packages
|
||||
run: sudo apt update && sudo apt install enchant-2 hunspell hunspell-de-de aspell-en aspell-de
|
||||
- name: Install Dependencies
|
||||
run: pip3 install uv && uv pip install --system -e ".[dev]"
|
||||
run: pip3 install -e ".[dev]"
|
||||
- name: Spellcheck translations
|
||||
run: potypo
|
||||
working-directory: ./src
|
||||
|
||||
4
.github/workflows/style.yml
vendored
4
.github/workflows/style.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- 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
|
||||
run: isort -c .
|
||||
working-directory: ./src
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- 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
|
||||
run: flake8 .
|
||||
working-directory: ./src
|
||||
|
||||
32
.github/workflows/tests.yml
vendored
32
.github/workflows/tests.yml
vendored
@@ -30,21 +30,15 @@ jobs:
|
||||
python-version: "3.9"
|
||||
- database: sqlite
|
||||
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:
|
||||
- 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 }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -56,9 +50,9 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- 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
|
||||
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
|
||||
run: python manage.py check
|
||||
working-directory: ./src
|
||||
@@ -70,15 +64,15 @@ jobs:
|
||||
run: make all compress
|
||||
- name: Run tests
|
||||
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
|
||||
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'
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
file: src/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
fail_ci_if_error: true
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.11'
|
||||
|
||||
@@ -10,7 +10,7 @@ tests:
|
||||
- cd src
|
||||
- python manage.py check
|
||||
- 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:
|
||||
- pypi
|
||||
pypi:
|
||||
|
||||
@@ -288,7 +288,6 @@ Example::
|
||||
[django]
|
||||
secret=j1kjps5a5&4ilpn912s7a1!e2h!duz^i3&idu@_907s$wrz@x-
|
||||
debug=off
|
||||
passwords_argon2=on
|
||||
|
||||
``secret``
|
||||
The secret to be used by Django for signing and verification purposes. If this
|
||||
@@ -304,10 +303,6 @@ Example::
|
||||
|
||||
.. 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``
|
||||
Enable code profiling for a random subset of requests. Disabled by default, see
|
||||
:ref:`perf-monitoring` for details.
|
||||
|
||||
@@ -231,10 +231,11 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ipv6only=on ssl default_server;
|
||||
listen 443 default_server;
|
||||
listen [::]:443 ipv6only=on default_server;
|
||||
server_name pretix.mydomain.com;
|
||||
|
||||
ssl on;
|
||||
ssl_certificate /path/to/cert.chain.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
|
||||
@@ -216,10 +216,11 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ipv6only=on ssl default_server;
|
||||
listen 443 default_server;
|
||||
listen [::]:443 ipv6only=on default_server;
|
||||
server_name pretix.mydomain.com;
|
||||
|
||||
ssl on;
|
||||
ssl_certificate /path/to/cert.chain.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ Endpoints
|
||||
"mode": "placed",
|
||||
"all_sales_channels": false,
|
||||
"limit_sales_channels": ["web"],
|
||||
"all_products": false,
|
||||
"all_products": False,
|
||||
"limit_products": [2, 3],
|
||||
"limit_variations": [456],
|
||||
"all_payment_methods": true,
|
||||
@@ -113,7 +113,7 @@ Endpoints
|
||||
"mode": "placed",
|
||||
"all_sales_channels": false,
|
||||
"limit_sales_channels": ["web"],
|
||||
"all_products": false,
|
||||
"all_products": False,
|
||||
"limit_products": [2, 3],
|
||||
"limit_variations": [456],
|
||||
"all_payment_methods": true,
|
||||
@@ -146,7 +146,7 @@ Endpoints
|
||||
"mode": "placed",
|
||||
"all_sales_channels": false,
|
||||
"limit_sales_channels": ["web"],
|
||||
"all_products": false,
|
||||
"all_products": False,
|
||||
"limit_products": [2, 3],
|
||||
"limit_variations": [456],
|
||||
"all_payment_methods": true,
|
||||
@@ -167,7 +167,7 @@ Endpoints
|
||||
"mode": "placed",
|
||||
"all_sales_channels": false,
|
||||
"limit_sales_channels": ["web"],
|
||||
"all_products": false,
|
||||
"all_products": False,
|
||||
"limit_products": [2, 3],
|
||||
"limit_variations": [456],
|
||||
"all_payment_methods": true,
|
||||
@@ -216,7 +216,7 @@ Endpoints
|
||||
"mode": "placed",
|
||||
"all_sales_channels": false,
|
||||
"limit_sales_channels": ["web"],
|
||||
"all_products": false,
|
||||
"all_products": False,
|
||||
"limit_products": [2, 3],
|
||||
"limit_variations": [456],
|
||||
"all_payment_methods": true,
|
||||
|
||||
@@ -23,22 +23,6 @@ position integer An integer, use
|
||||
is_addon boolean If ``true``, items within this category are not on sale
|
||||
on their own but the category provides a source for
|
||||
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": "",
|
||||
"description": {"en": "Tickets are what you need to get in."},
|
||||
"position": 1,
|
||||
"is_addon": false,
|
||||
"cross_selling_mode": null,
|
||||
"cross_selling_condition": null,
|
||||
"cross_selling_match_products": []
|
||||
"is_addon": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -121,10 +102,7 @@ Endpoints
|
||||
"internal_name": "",
|
||||
"description": {"en": "Tickets are what you need to get in."},
|
||||
"position": 1,
|
||||
"is_addon": false,
|
||||
"cross_selling_mode": null,
|
||||
"cross_selling_condition": null,
|
||||
"cross_selling_match_products": []
|
||||
"is_addon": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -152,10 +130,7 @@ Endpoints
|
||||
"internal_name": "",
|
||||
"description": {"en": "Tickets are what you need to get in."},
|
||||
"position": 1,
|
||||
"is_addon": false,
|
||||
"cross_selling_mode": null,
|
||||
"cross_selling_condition": null,
|
||||
"cross_selling_match_products": []
|
||||
"is_addon": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -172,10 +147,7 @@ Endpoints
|
||||
"internal_name": "",
|
||||
"description": {"en": "Tickets are what you need to get in."},
|
||||
"position": 1,
|
||||
"is_addon": false,
|
||||
"cross_selling_mode": null,
|
||||
"cross_selling_condition": null,
|
||||
"cross_selling_match_products": []
|
||||
"is_addon": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create a category for
|
||||
@@ -221,10 +193,7 @@ Endpoints
|
||||
"internal_name": "",
|
||||
"description": {"en": "Tickets are what you need to get in."},
|
||||
"position": 1,
|
||||
"is_addon": true,
|
||||
"cross_selling_mode": null,
|
||||
"cross_selling_condition": null,
|
||||
"cross_selling_match_products": []
|
||||
"is_addon": true
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
|
||||
@@ -31,6 +31,8 @@ subevent integer ID of the date
|
||||
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).
|
||||
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_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.
|
||||
@@ -89,7 +91,10 @@ Endpoints
|
||||
"allow_entry_after_exit": true,
|
||||
"exit_all_at": null,
|
||||
"rules": {},
|
||||
"addon_match": false
|
||||
"addon_match": false,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -141,7 +146,10 @@ Endpoints
|
||||
"allow_entry_after_exit": true,
|
||||
"exit_all_at": null,
|
||||
"rules": {},
|
||||
"addon_match": false
|
||||
"addon_match": false,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -238,7 +246,10 @@ Endpoints
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"addon_match": false
|
||||
"addon_match": false,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -260,7 +271,10 @@ Endpoints
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"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
|
||||
@@ -312,7 +326,10 @@ Endpoints
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"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
|
||||
@@ -325,7 +342,7 @@ Endpoints
|
||||
|
||||
.. 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**:
|
||||
|
||||
|
||||
@@ -352,12 +352,12 @@ Fetching individual invoices
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer 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 401: Authentication failure
|
||||
: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.
|
||||
|
||||
@@ -384,7 +384,7 @@ Fetching individual invoices
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer 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 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
@@ -397,7 +397,7 @@ Modifying invoices
|
||||
|
||||
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.
|
||||
|
||||
@@ -419,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 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 400: The invoice has already been canceled
|
||||
:statuscode 401: Authentication failure
|
||||
: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.
|
||||
|
||||
@@ -447,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 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 400: The invoice has already been canceled
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
@@ -104,10 +104,6 @@ url string The full URL to
|
||||
payments list of objects List of payment processes (see below)
|
||||
refunds list of objects List of refund processes (see below)
|
||||
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.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -155,9 +151,6 @@ cancellation_date datetime Time of order c
|
||||
|
||||
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.
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -213,17 +206,6 @@ checkins list of objects List of **succe
|
||||
├ 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_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
|
||||
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
|
||||
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
|
||||
└ url string Download URL
|
||||
@@ -251,10 +233,6 @@ pdf_data object Data object req
|
||||
|
||||
The attributes ``blocked``, ``valid_from`` and ``valid_until`` have been added.
|
||||
|
||||
.. versionchanged:: 2024.9
|
||||
|
||||
The attribute ``print_logs`` has been added.
|
||||
|
||||
.. _order-payment-resource:
|
||||
|
||||
Order payment resource
|
||||
@@ -421,21 +399,10 @@ List of all orders
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"device_id": 1,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
],
|
||||
"print_logs": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "badge",
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"device_id": 1,
|
||||
"source": "pretixSCAN",
|
||||
"info": {}
|
||||
}
|
||||
],
|
||||
"answers": [
|
||||
{
|
||||
"question": 12,
|
||||
@@ -471,15 +438,14 @@ List of all orders
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
],
|
||||
"refunds": [],
|
||||
"cancellation_date": null
|
||||
"refunds": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
: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``,
|
||||
``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 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)
|
||||
@@ -660,22 +626,10 @@ Fetching individual orders
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"device_id": 1,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"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": [
|
||||
{
|
||||
"question": 12,
|
||||
@@ -711,8 +665,7 @@ Fetching individual orders
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
],
|
||||
"refunds": [],
|
||||
"cancellation_date": null
|
||||
"refunds": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -1024,8 +977,8 @@ Creating orders
|
||||
* ``internal_reference``
|
||||
* ``vat_id``
|
||||
* ``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
|
||||
trigger reverse charge taxation. Don't forget to set ``is_business`` as well!
|
||||
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!
|
||||
|
||||
* ``positions``
|
||||
|
||||
@@ -1628,22 +1581,10 @@ List of all order positions
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"device_id": 1,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"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": [
|
||||
{
|
||||
"question": 12,
|
||||
@@ -1754,22 +1695,10 @@ Fetching individual positions
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"device_id": 1,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"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": [
|
||||
{
|
||||
"question": 12,
|
||||
@@ -1866,10 +1795,6 @@ Manipulating individual positions
|
||||
|
||||
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)/
|
||||
|
||||
Updates specific fields on an order position. Currently, only the following fields are supported:
|
||||
@@ -2129,59 +2054,6 @@ Manipulating individual positions
|
||||
:statuscode 401: Authentication failure
|
||||
: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
|
||||
-----------------------
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Endpoints for organizer 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.
|
||||
|
||||
|
||||
.. _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
|
||||
@@ -1,4 +1,4 @@
|
||||
.. _`rest-seats`:
|
||||
.. _`rest-reusablemedia`:
|
||||
|
||||
Seats
|
||||
=====
|
||||
@@ -249,7 +249,7 @@ Endpoints
|
||||
"orderposition": null,
|
||||
"cartposition": null,
|
||||
"voucher": null
|
||||
}
|
||||
},
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
@@ -260,114 +260,3 @@ Endpoints
|
||||
: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_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.
|
||||
|
||||
@@ -136,7 +136,6 @@ Endpoints
|
||||
}
|
||||
|
||||
: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 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.
|
||||
@@ -468,7 +467,6 @@ Endpoints
|
||||
}
|
||||
|
||||
: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 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.
|
||||
|
||||
@@ -17,7 +17,6 @@ First, you need to declare that you are using non-essential cookies by respondin
|
||||
signal:
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:no-index:
|
||||
:members: register_cookie_providers
|
||||
|
||||
You are expected to return a list of ``CookieProvider`` objects instantiated from the following class:
|
||||
|
||||
@@ -22,14 +22,12 @@ Order events
|
||||
There are multiple signals that will be sent out in the ordering cycle:
|
||||
|
||||
.. 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
|
||||
|
||||
Check-ins
|
||||
"""""""""
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
:members: checkin_created
|
||||
|
||||
|
||||
@@ -41,21 +39,18 @@ Frontend
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:no-index:
|
||||
:members: order_info, order_info_top, order_meta_from_request, order_api_meta_from_request
|
||||
|
||||
Request flow
|
||||
""""""""""""
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:no-index:
|
||||
:members: process_request, process_response
|
||||
|
||||
Vouchers
|
||||
""""""""
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:no-index:
|
||||
:members: voucher_redeem_info
|
||||
|
||||
Backend
|
||||
@@ -67,28 +62,24 @@ Backend
|
||||
item_formsets, order_search_filter_q, order_search_forms
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events, orderposition_blocked_display, customer_created, customer_signed_in
|
||||
|
||||
Vouchers
|
||||
""""""""
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:no-index:
|
||||
:members: item_forms, voucher_form_class, voucher_form_html, voucher_form_validation
|
||||
|
||||
Dashboards
|
||||
""""""""""
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:no-index:
|
||||
:members: event_dashboard_widgets, user_dashboard_widgets, event_dashboard_top
|
||||
|
||||
Ticket designs
|
||||
""""""""""""""
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
:members: layout_text_variables, layout_image_variables
|
||||
|
||||
.. automodule:: pretix.plugins.ticketoutputpdf.signals
|
||||
@@ -98,9 +89,4 @@ API
|
||||
---
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
:members: validate_event_settings, api_event_settings_fields
|
||||
|
||||
.. automodule:: pretix.api.signals
|
||||
:no-index:
|
||||
:members: register_device_security_profile
|
||||
|
||||
@@ -60,7 +60,6 @@ that we'll provide in this plugin:
|
||||
Similar signals exist for other objects:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
:members: voucher_import_columns
|
||||
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ convenient to you:
|
||||
|
||||
.. automethod:: _register_fonts
|
||||
|
||||
.. automethod:: _register_event_fonts
|
||||
|
||||
.. automethod:: _on_first_page
|
||||
|
||||
.. automethod:: _on_other_page
|
||||
|
||||
@@ -86,10 +86,7 @@ Signals
|
||||
-------
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
:members: register_text_placeholders
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
:members: register_mail_placeholders
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@ This will automatically make pretix discover this plugin as soon as it is instal
|
||||
through ``pip``. During development, you can just run ``python setup.py develop`` inside
|
||||
your plugin source directory to make it discoverable.
|
||||
|
||||
.. _`signals`:
|
||||
Signals
|
||||
-------
|
||||
|
||||
@@ -154,25 +153,6 @@ in the ``installed`` method:
|
||||
Note that ``installed`` will *not* be called if the plugin is indirectly activated for an event
|
||||
because the event is created with settings copied from another event.
|
||||
|
||||
.. _`registries`:
|
||||
Registries
|
||||
----------
|
||||
|
||||
Many signals in pretix are used so that plugins can "register" a class, e.g. a payment provider or a
|
||||
ticket renderer.
|
||||
|
||||
However, for some of them (types of :ref:`Log Entries <logging>`) we use a different method to keep track of them:
|
||||
In a ``Registry``, classes are collected at application startup, along with a unique key (in case
|
||||
of LogEntryType, the ``action_type``) as well as which plugin registered them.
|
||||
|
||||
To register a class, you can use one of several decorators provided by the Registry object:
|
||||
|
||||
.. autoclass:: pretix.base.logentrytypes.LogEntryTypeRegistry
|
||||
:members: register, new, new_from_dict
|
||||
|
||||
All files in which classes are registered need to be imported in the ``AppConfig.ready`` as explained
|
||||
in `Signals <signals>`_ above.
|
||||
|
||||
Views
|
||||
-----
|
||||
|
||||
|
||||
@@ -20,8 +20,7 @@ To actually log an action, you can just call the ``log_action`` method on your o
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
order.log_action('pretix.event.order.comment', user=user,
|
||||
data={"new_comment": "Hello, world."})
|
||||
order.log_action('pretix.event.order.canceled', user=user, data={})
|
||||
|
||||
The positional ``action`` argument should represent the type of action and should be globally unique, we
|
||||
recommend to prefix it with your package name, e.g. ``paypal.payment.rejected``. The ``user`` argument is
|
||||
@@ -73,101 +72,24 @@ following ready-to-include template::
|
||||
{% include "pretixcontrol/includes/logs.html" with obj=order %}
|
||||
|
||||
We now need a way to translate the action codes like ``pretix.event.changed`` into human-readable
|
||||
strings. The :py:attr:`pretix.base.logentrytypes.log_entry_types` :ref:`registry <registries>` allows you to do so. A simple
|
||||
strings. The :py:attr:`pretix.base.signals.logentry_display` signals allows you to do so. A simple
|
||||
implementation could look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from pretix.base.logentrytypes import log_entry_types
|
||||
|
||||
|
||||
@log_entry_types.new_from_dict({
|
||||
'pretix.event.order.comment': _('The order\'s internal comment has been updated to: {new_comment}'),
|
||||
'pretix.event.order.paid': _('The order has been marked as paid.'),
|
||||
# ...
|
||||
})
|
||||
class CoreOrderLogEntryType(OrderLogEntryType):
|
||||
pass
|
||||
|
||||
Please note that you always need to define your own inherited ``LogEntryType`` class in your plugin. If you would just
|
||||
register an instance of a ``LogEntryType`` class defined in pretix core, it cannot be automatically detected as belonging
|
||||
to your plugin, leading to confusing user interface situations.
|
||||
|
||||
|
||||
Customizing log entry display
|
||||
"""""""""""""""""""""""""""""
|
||||
|
||||
The base ``LogEntryType`` classes allow for varying degree of customization in their descendants.
|
||||
|
||||
If you want to add another log message for an existing core object (e.g. an :class:`Order <pretix.base.models.Order>`,
|
||||
:class:`Item <pretix.base.models.Item>`, or :class:`Voucher <pretix.base.models.Voucher>`), you can inherit
|
||||
from its predefined :class:`LogEntryType <pretix.base.logentrytypes.LogEntryType>`, e.g.
|
||||
:class:`OrderLogEntryType <pretix.base.logentrytypes.OrderLogEntryType>`, and just specify a new plaintext string.
|
||||
You can use format strings to insert information from the LogEntry's `data` object as shown in the section above.
|
||||
|
||||
If you define a new model object in your plugin, you should make sure proper object links in the user interface are
|
||||
displayed for it. If your model object belongs logically to a pretix :class:`Event <pretix.base.models.Event>`, you can inherit from :class:`EventLogEntryType <pretix.base.logentrytypes.EventLogEntryType>`,
|
||||
and set the ``object_link_*`` fields accordingly. ``object_link_viewname`` refers to a django url name, which needs to
|
||||
accept the arguments `organizer` and `event`, containing the respective slugs, and additional arguments provided by
|
||||
``object_link_args``. The default implementation of ``object_link_args`` will return an argument named by
|
||||
````object_link_argname``, with a value of ``content_object.pk`` (the primary key of the model object).
|
||||
If you want to customize the name displayed for the object (instead of the result of calling ``str()`` on it),
|
||||
overwrite ``object_link_display_name``.
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class ItemLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _('Product {val}')
|
||||
|
||||
# link will be generated as reverse('control:event.item', {'organizer': ..., 'event': ..., 'item': item.pk})
|
||||
object_link_viewname = 'control:event.item'
|
||||
object_link_argname = 'item'
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class OrderLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _('Order {val}')
|
||||
|
||||
# link will be generated as reverse('control:event.order', {'organizer': ..., 'event': ..., 'code': order.code})
|
||||
object_link_viewname = 'control:event.order'
|
||||
|
||||
def object_link_args(self, order):
|
||||
return {'code': order.code}
|
||||
|
||||
def object_link_display_name(self, order):
|
||||
return order.code
|
||||
|
||||
To show more sophisticated message strings, e.g. varying the message depending on information from the :class:`LogEntry <pretix.base.models.log.LogEntry>`'s
|
||||
`data` object, override the `display` method:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@log_entry_types.new()
|
||||
class PaypalEventLogEntryType(EventLogEntryType):
|
||||
action_type = 'pretix.plugins.paypal.event'
|
||||
|
||||
def display(self, logentry):
|
||||
event_type = logentry.parsed_data.get('event_type')
|
||||
text = {
|
||||
'PAYMENT.SALE.COMPLETED': _('Payment completed.'),
|
||||
'PAYMENT.SALE.DENIED': _('Payment denied.'),
|
||||
# ...
|
||||
}.get(event_type, f"({event_type})")
|
||||
return _('PayPal reported an event: {}').format(text)
|
||||
|
||||
.. automethod:: pretix.base.logentrytypes.LogEntryType.display
|
||||
|
||||
If your new model object does not belong to an :class:`Event <pretix.base.models.Event>`, you need to inherit directly from ``LogEntryType`` instead
|
||||
of ``EventLogEntryType``, providing your own implementation of ``get_object_link_info`` if object links should be
|
||||
displayed.
|
||||
|
||||
.. autoclass:: pretix.base.logentrytypes.LogEntryType
|
||||
:members: get_object_link_info
|
||||
|
||||
from pretix.base.signals import logentry_display
|
||||
|
||||
@receiver(signal=logentry_display)
|
||||
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
|
||||
plains = {
|
||||
'pretix.event.order.paid': _('The order has been marked as paid.'),
|
||||
'pretix.event.order.refunded': _('The order has been refunded.'),
|
||||
'pretix.event.order.canceled': _('The order has been canceled.'),
|
||||
...
|
||||
}
|
||||
if logentry.action_type in plains:
|
||||
return plains[logentry.action_type]
|
||||
|
||||
Sending notifications
|
||||
---------------------
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
KulturPass
|
||||
==========
|
||||
=========
|
||||
|
||||
.. note::
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ expects and - more importantly - supports.
|
||||
for a sample configuration in an academic context.
|
||||
|
||||
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 -
|
||||
for example because one institution is returning SAML 1.0 and other
|
||||
institutions are returning SAML 2.0 style attributes. Typically, this only
|
||||
|
||||
@@ -29,7 +29,7 @@ dependencies = [
|
||||
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
||||
"babel",
|
||||
"BeautifulSoup4==4.12.*",
|
||||
"bleach==6.2.*",
|
||||
"bleach==5.0.*",
|
||||
"celery==5.4.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=3.4.2",
|
||||
@@ -43,8 +43,8 @@ dependencies = [
|
||||
"django-formset-js-improved==0.5.0.3",
|
||||
"django-formtools==2.5.1",
|
||||
"django-hierarkey==1.2.*",
|
||||
"django-hijack==3.7.*",
|
||||
"django-i18nfield==1.9.*,>=1.9.5",
|
||||
"django-hijack==3.6.*",
|
||||
"django-i18nfield==1.9.*,>=1.9.4",
|
||||
"django-libsass==0.9",
|
||||
"django-localflavor==4.0",
|
||||
"django-markup",
|
||||
@@ -53,9 +53,9 @@ dependencies = [
|
||||
"django-phonenumber-field==7.3.*",
|
||||
"django-redis==5.4.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-statici18n==2.6.*",
|
||||
"django-statici18n==2.5.*",
|
||||
"djangorestframework==3.15.*",
|
||||
"dnspython==2.7.*",
|
||||
"dnspython==2.6.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==4.*",
|
||||
"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.*",
|
||||
"PyJWT==2.9.*",
|
||||
"phonenumberslite==8.13.*",
|
||||
"Pillow==11.0.*",
|
||||
"Pillow==10.4.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==5.29.*",
|
||||
"protobuf==5.28.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==2.22",
|
||||
"pycryptodome==3.21.*",
|
||||
"pypdf==5.1.*",
|
||||
"pypdf==5.0.*",
|
||||
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.9.*",
|
||||
"pytz",
|
||||
"pytz-deprecation-shim==0.1.*",
|
||||
"pyuca",
|
||||
"qrcode==8.0",
|
||||
"redis==5.2.*",
|
||||
"redis==5.1.*",
|
||||
"reportlab==4.2.*",
|
||||
"requests==2.31.*",
|
||||
"sentry-sdk==2.18.*",
|
||||
"sentry-sdk==2.15.*",
|
||||
"sepaxml==2.6.*",
|
||||
"slimit",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2020041600",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==1.0.*",
|
||||
"ua-parser==0.18.*",
|
||||
"vat_moss_forked==2020.3.20.0.11.0",
|
||||
"vobject==0.9.*",
|
||||
"webauthn==2.3.*",
|
||||
"zeep==4.3.*"
|
||||
"webauthn==2.2.*",
|
||||
"zeep==4.2.*"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
memcached = ["pylibmc"]
|
||||
dev = [
|
||||
"aiohttp==3.11.*",
|
||||
"aiohttp==3.10.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.26.*",
|
||||
"fakeredis==2.24.*",
|
||||
"flake8==7.1.*",
|
||||
"freezegun",
|
||||
"isort==5.13.*",
|
||||
"pep8-naming==0.14.*",
|
||||
"potypo",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-asyncio",
|
||||
"pytest-cache",
|
||||
"pytest-cov",
|
||||
"pytest-django==4.*",
|
||||
"pytest-mock==3.14.*",
|
||||
"pytest-rerunfailures==14.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.6.*",
|
||||
"pytest==8.3.*",
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2024.12.0.dev0"
|
||||
__version__ = "2024.10.0.dev0"
|
||||
|
||||
@@ -75,14 +75,6 @@ FORMAT_MODULE_PATH = [
|
||||
'pretix.helpers.formats',
|
||||
]
|
||||
|
||||
CORE_MODULES = {
|
||||
"pretix.base",
|
||||
"pretix.presale",
|
||||
"pretix.control",
|
||||
"pretix.plugins.checkinlists",
|
||||
"pretix.plugins.reports",
|
||||
}
|
||||
|
||||
ALL_LANGUAGES = [
|
||||
('en', _('English')),
|
||||
('de', _('German')),
|
||||
|
||||
@@ -27,7 +27,7 @@ from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
|
||||
from pretix.api.auth.devicesecurity import (
|
||||
FullAccessSecurityProfile, get_all_security_profiles,
|
||||
DEVICE_SECURITY_PROFILES, FullAccessSecurityProfile,
|
||||
)
|
||||
from pretix.base.models import Device
|
||||
|
||||
@@ -58,8 +58,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
|
||||
def authenticate(self, request):
|
||||
r = super().authenticate(request)
|
||||
if r and isinstance(r[1], Device):
|
||||
profiles = get_all_security_profiles()
|
||||
profile = profiles.get(r[1].security_profile, FullAccessSecurityProfile())
|
||||
profile = DEVICE_SECURITY_PROFILES.get(r[1].security_profile, FullAccessSecurityProfile)
|
||||
if not profile.is_allowed(request):
|
||||
raise exceptions.PermissionDenied('Request denied by device security profile.')
|
||||
return r
|
||||
|
||||
@@ -20,40 +20,13 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.api.signals import register_device_security_profile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_ALL_PROFILES = None
|
||||
|
||||
|
||||
class BaseSecurityProfile:
|
||||
@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):
|
||||
class FullAccessSecurityProfile:
|
||||
identifier = 'full'
|
||||
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
|
||||
|
||||
|
||||
class AllowListSecurityProfile(BaseSecurityProfile):
|
||||
class AllowListSecurityProfile:
|
||||
allowlist = ()
|
||||
|
||||
def is_allowed(self, request):
|
||||
@@ -104,7 +77,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:blockedsecrets-list'),
|
||||
('GET', 'api-v1:order-list'),
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('POST', 'api-v1:orderposition-printlog'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
@@ -140,7 +112,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:blockedsecrets-list'),
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('POST', 'api-v1:orderposition-printlog'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
@@ -176,7 +147,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:blockedsecrets-list'),
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('POST', 'api-v1:orderposition-printlog'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
@@ -184,28 +154,87 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
)
|
||||
|
||||
|
||||
def get_all_security_profiles():
|
||||
global _ALL_PROFILES
|
||||
|
||||
if _ALL_PROFILES:
|
||||
return _ALL_PROFILES
|
||||
|
||||
types = OrderedDict()
|
||||
for recv, ret in register_device_security_profile.send(None):
|
||||
if isinstance(ret, (list, tuple)):
|
||||
for r in ret:
|
||||
types[r.identifier] = r
|
||||
else:
|
||||
types[ret.identifier] = ret
|
||||
_ALL_PROFILES = types
|
||||
return types
|
||||
|
||||
|
||||
@receiver(register_device_security_profile, dispatch_uid="base_register_default_security_profiles")
|
||||
def register_default_webhook_events(sender, **kwargs):
|
||||
return (
|
||||
FullAccessSecurityProfile(),
|
||||
PretixScanSecurityProfile(),
|
||||
PretixScanNoSyncSecurityProfile(),
|
||||
PretixScanNoSyncNoSearchSecurityProfile(),
|
||||
class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
identifier = 'pretixpos'
|
||||
verbose_name = _('pretixPOS')
|
||||
allowlist = (
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('GET', 'api-v1:device.info'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
('GET', 'api-v1:event-list'),
|
||||
('GET', 'api-v1:event-detail'),
|
||||
('GET', 'api-v1:subevent-list'),
|
||||
('GET', 'api-v1:subevent-detail'),
|
||||
('GET', 'api-v1:itemcategory-list'),
|
||||
('GET', 'api-v1:item-list'),
|
||||
('GET', 'api-v1:question-list'),
|
||||
('GET', 'api-v1:quota-list'),
|
||||
('GET', 'api-v1:taxrule-list'),
|
||||
('GET', 'api-v1:ticketlayout-list'),
|
||||
('GET', 'api-v1:ticketlayoutitem-list'),
|
||||
('GET', 'api-v1:badgelayout-list'),
|
||||
('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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,10 +110,6 @@ class SalesChannelMigrationMixin:
|
||||
data["all_sales_channels"] = False
|
||||
data["limit_sales_channels"] = data["sales_channels"]
|
||||
del data["sales_channels"]
|
||||
|
||||
if data.get("all_sales_channels"):
|
||||
data["limit_sales_channels"] = []
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def to_representation(self, value):
|
||||
|
||||
@@ -235,7 +235,7 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
|
||||
return cid
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop('sales_channel', None)
|
||||
validated_data.pop('sales_channel')
|
||||
addons_data = validated_data.pop('addons', None)
|
||||
bundled_data = validated_data.pop('bundled', None)
|
||||
|
||||
|
||||
@@ -26,22 +26,31 @@ from rest_framework.exceptions import ValidationError
|
||||
from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
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):
|
||||
checkin_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:
|
||||
model = CheckinList
|
||||
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')
|
||||
|
||||
def __init__(self, *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'):
|
||||
self.fields['subevent'] = SubEventSerializer(read_only=True)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
import logging
|
||||
|
||||
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.utils.crypto import get_random_string
|
||||
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 pytz import common_timezones
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import ChoiceField, Field
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
|
||||
@@ -773,7 +772,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_address_company_required',
|
||||
'invoice_address_beneficiary',
|
||||
'invoice_address_custom_field',
|
||||
'invoice_address_custom_field_helptext',
|
||||
'invoice_name_required',
|
||||
'invoice_address_not_asked_free',
|
||||
'invoice_show_payments',
|
||||
@@ -918,7 +916,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'invoice_address_company_required',
|
||||
'invoice_address_beneficiary',
|
||||
'invoice_address_custom_field',
|
||||
'invoice_address_custom_field_helptext',
|
||||
'invoice_name_required',
|
||||
'invoice_address_not_asked_free',
|
||||
'invoice_address_from_name',
|
||||
@@ -990,40 +987,6 @@ def prefetch_by_id(items, qs, id_attr, target_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):
|
||||
orderposition = serializers.IntegerField(source='orderposition_id')
|
||||
cartposition = serializers.IntegerField(source='cartposition_id')
|
||||
|
||||
@@ -19,8 +19,57 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.conf import settings
|
||||
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):
|
||||
@@ -35,10 +84,3 @@ class I18nURLField(I18nField):
|
||||
else:
|
||||
URLValidator()(value.data)
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"I18nAwareModelSerializer", # for backwards compatibility
|
||||
"I18nField", # for backwards compatibility
|
||||
"I18nURLField",
|
||||
]
|
||||
|
||||
@@ -369,7 +369,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
require_membership_types = validated_data.pop('require_membership_types', [])
|
||||
limit_sales_channels = validated_data.pop('limit_sales_channels', [])
|
||||
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)
|
||||
if picture:
|
||||
item.picture.save(os.path.basename(picture.name), picture)
|
||||
@@ -441,22 +441,7 @@ class ItemCategorySerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ItemCategory
|
||||
fields = (
|
||||
'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
|
||||
fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon')
|
||||
|
||||
|
||||
class QuestionOptionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -55,7 +55,7 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
PrintLog, RevokedTicketSecret,
|
||||
RevokedTicketSecret,
|
||||
)
|
||||
from pretix.base.pdf import get_images, get_variables
|
||||
from pretix.base.services.cart import error_messages
|
||||
@@ -284,26 +284,6 @@ class CheckinSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', '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):
|
||||
error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False)
|
||||
raw_barcode = serializers.CharField(required=True, allow_null=False)
|
||||
@@ -496,7 +476,6 @@ class OrderPositionListSerializer(serializers.ListSerializer):
|
||||
|
||||
class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
checkins = CheckinSerializer(many=True, read_only=True)
|
||||
print_logs = PrintLogSerializer(many=True, read_only=True)
|
||||
answers = AnswerSerializer(many=True)
|
||||
downloads = PositionDownloadsField(source='*', read_only=True)
|
||||
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
||||
@@ -511,7 +490,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
|
||||
'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',
|
||||
'valid_from', 'valid_until', 'blocked', 'voucher_budget_use')
|
||||
read_only_fields = (
|
||||
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
|
||||
@@ -598,9 +577,9 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat',
|
||||
'require_attention', 'order__status', 'order__valid_if_pending', 'order__require_approval',
|
||||
'valid_from', 'valid_until', 'blocked')
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
|
||||
'order__status', 'order__valid_if_pending', 'order__require_approval', 'valid_from', 'valid_until',
|
||||
'blocked')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -753,12 +732,12 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
|
||||
'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 = (
|
||||
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
|
||||
'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):
|
||||
@@ -1515,7 +1494,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
pos.answers = answers
|
||||
pos.pseudonymization_id = "PREVIEW"
|
||||
pos.checkins = []
|
||||
pos.print_logs = []
|
||||
pos_map[pos.positionid] = pos
|
||||
else:
|
||||
if pos.voucher:
|
||||
|
||||
@@ -29,7 +29,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
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.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import CompatibleJSONField
|
||||
@@ -298,7 +297,6 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
revoked = serializers.BooleanField(read_only=True)
|
||||
initialized = serializers.DateTimeField(read_only=True)
|
||||
initialization_token = serializers.DateTimeField(read_only=True)
|
||||
security_profile = serializers.ChoiceField(choices=[], required=False, default="full")
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
@@ -308,10 +306,6 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
'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 Meta:
|
||||
|
||||
@@ -32,17 +32,10 @@ from pretix.helpers.periodic import minimum_interval
|
||||
register_webhook_events = Signal()
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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)
|
||||
@scopes_disabled()
|
||||
|
||||
@@ -62,7 +62,6 @@ from pretix.base.models import (
|
||||
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.orders import PrintLog
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
||||
)
|
||||
@@ -116,7 +115,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
if 'subevent' in self.request.query_params.getlist('expand'):
|
||||
qs = qs.prefetch_related(
|
||||
'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
|
||||
|
||||
@@ -143,9 +142,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def perform_destroy(self, instance):
|
||||
instance.checkins.all().delete()
|
||||
instance.log_action(
|
||||
'pretix.event.checkinlist.deleted',
|
||||
user=self.request.user,
|
||||
@@ -368,9 +365,8 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
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',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
@@ -382,7 +378,6 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||
'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(
|
||||
Prefetch(
|
||||
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',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import base64
|
||||
import copy
|
||||
import logging
|
||||
|
||||
from cryptography.hazmat.backends.openssl.backend import Backend
|
||||
@@ -147,8 +146,6 @@ class InitializeView(APIView):
|
||||
permission_classes = ()
|
||||
|
||||
def post(self, request, format=None):
|
||||
from pretix.base.signals import device_info_updated
|
||||
|
||||
serializer = InitializationRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
@@ -163,8 +160,6 @@ class InitializeView(APIView):
|
||||
if device.revoked:
|
||||
raise ValidationError({'token': ['This initialization token has been revoked.']})
|
||||
|
||||
old_instance = copy.copy(device)
|
||||
|
||||
device.initialized = now()
|
||||
device.hardware_brand = serializer.validated_data.get('hardware_brand')
|
||||
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_info_updated.send(
|
||||
sender=Device, old_device=old_instance, new_device=device
|
||||
)
|
||||
|
||||
serializer = DeviceSerializer(device)
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -191,12 +182,9 @@ class UpdateView(APIView):
|
||||
authentication_classes = (DeviceTokenAuthentication,)
|
||||
|
||||
def post(self, request, format=None):
|
||||
from pretix.base.signals import device_info_updated
|
||||
|
||||
serializer = UpdateRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
device = request.auth
|
||||
old_instance = copy.copy(device)
|
||||
device.hardware_brand = serializer.validated_data.get('hardware_brand')
|
||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||
device.os_name = serializer.validated_data.get('os_name')
|
||||
@@ -212,8 +200,9 @@ class UpdateView(APIView):
|
||||
device.save()
|
||||
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)
|
||||
|
||||
from ...base.signals import device_info_updated
|
||||
device_info_updated.send(
|
||||
sender=Device, old_device=old_instance, new_device=device
|
||||
sender=Device, old_device=request.auth, new_device=device
|
||||
)
|
||||
|
||||
serializer = DeviceSerializer(device)
|
||||
|
||||
@@ -40,7 +40,6 @@ from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import serializers, views, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import (
|
||||
NotFound, PermissionDenied, ValidationError,
|
||||
)
|
||||
@@ -51,9 +50,8 @@ from pretix.api.auth.permission import EventCRUDPermission
|
||||
from pretix.api.pagination import TotalOrderingFilter
|
||||
from pretix.api.serializers.event import (
|
||||
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
|
||||
EventSettingsSerializer, ItemMetaPropertiesSerializer,
|
||||
SeatBulkBlockInputSerializer, SeatSerializer, SubEventSerializer,
|
||||
TaxRuleSerializer,
|
||||
EventSettingsSerializer, ItemMetaPropertiesSerializer, SeatSerializer,
|
||||
SubEventSerializer, TaxRuleSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
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}
|
||||
changed = merge_dicts(enabled, disabled)
|
||||
|
||||
for module, operation in changed.items():
|
||||
for module, action in changed.items():
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.plugins.' + operation,
|
||||
'pretix.event.plugins.' + action,
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
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:
|
||||
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:
|
||||
serializer.instance.set_defaults()
|
||||
|
||||
@@ -373,7 +370,7 @@ with scopes_disabled():
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ['is_public', 'active', 'event__live']
|
||||
fields = ['active', 'event__live']
|
||||
|
||||
def ends_after_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
@@ -746,24 +743,3 @@ class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
auth=self.request.auth,
|
||||
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)
|
||||
|
||||
@@ -42,7 +42,6 @@ from pretix.base.models import (
|
||||
Checkin, GiftCard, GiftCardAcceptance, GiftCardTransaction, OrderPosition,
|
||||
ReusableMedium,
|
||||
)
|
||||
from pretix.base.models.orders import PrintLog
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
@@ -80,7 +79,6 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
'order', 'order__event', 'order__event__organizer', 'seat',
|
||||
).prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
),
|
||||
|
||||
@@ -57,8 +57,7 @@ from pretix.api.serializers.order import (
|
||||
OrderPaymentCreateSerializer, OrderPaymentSerializer,
|
||||
OrderPositionSerializer, OrderRefundCreateSerializer,
|
||||
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
|
||||
PrintLogSerializer, RevokedTicketSecretSerializer,
|
||||
SimulatedOrderSerializer,
|
||||
RevokedTicketSecretSerializer, SimulatedOrderSerializer,
|
||||
)
|
||||
from pretix.api.serializers.orderchange import (
|
||||
BlockNameSerializer, OrderChangeOperationSerializer,
|
||||
@@ -76,7 +75,7 @@ from pretix.base.models import (
|
||||
TeamAPIToken, generate_secret,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, PrintLog, QuestionAnswer, RevokedTicketSecret,
|
||||
BlockedTicketSecret, QuestionAnswer, RevokedTicketSecret,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.pdf import get_images
|
||||
@@ -215,7 +214,7 @@ class OrderViewSetMixin:
|
||||
queryset = Order.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
ordering = ('datetime',)
|
||||
ordering_fields = ('datetime', 'code', 'status', 'last_modified', 'cancellation_date')
|
||||
ordering_fields = ('datetime', 'code', 'status', 'last_modified')
|
||||
filterset_class = OrderFilter
|
||||
lookup_field = 'code'
|
||||
|
||||
@@ -260,7 +259,6 @@ class OrderViewSetMixin:
|
||||
'positions',
|
||||
opq.all().prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||
Prefetch('item', queryset=self.request.event.items.prefetch_related(
|
||||
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached')
|
||||
)),
|
||||
@@ -282,7 +280,6 @@ class OrderViewSetMixin:
|
||||
'positions',
|
||||
opq.all().prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||
'item', 'variation',
|
||||
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
|
||||
'seat',
|
||||
@@ -1096,7 +1093,6 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.select_related("device")),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||
Prefetch('item', queryset=self.request.event.items.prefetch_related(
|
||||
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
|
||||
to_attr='meta_values_cached')
|
||||
@@ -1140,7 +1136,6 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.select_related("device")),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
|
||||
@@ -1259,34 +1254,6 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
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>[^/]+)')
|
||||
def pdf_image(self, request, key, **kwargs):
|
||||
pos = self.get_object()
|
||||
|
||||
@@ -152,7 +152,7 @@ class NativeAuthBackend(BaseAuthBackend):
|
||||
to log in.
|
||||
"""
|
||||
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'}))),
|
||||
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
|
||||
max_length=4096)),
|
||||
|
||||
@@ -35,7 +35,6 @@ from django.utils.translation import get_language, gettext_lazy as _
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.signals import register_html_mail_renderers
|
||||
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
|
||||
get_available_placeholders, PlaceholderContext
|
||||
@@ -69,7 +68,7 @@ def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
|
||||
|
||||
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):
|
||||
@@ -80,7 +79,7 @@ class BaseHTMLMailRenderer:
|
||||
return self.identifier
|
||||
|
||||
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.
|
||||
|
||||
@@ -89,7 +88,6 @@ class BaseHTMLMailRenderer:
|
||||
:param subject: The email subject.
|
||||
: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 context: Context to use to render placeholders in the plain body
|
||||
:return: An HTML string
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -136,10 +134,8 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
def compile_markdown(self, 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)
|
||||
if context:
|
||||
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
|
||||
htmlctx = {
|
||||
'site': settings.PRETIX_INSTANCE_NAME,
|
||||
'site_url': settings.SITE_URL,
|
||||
|
||||
@@ -64,7 +64,7 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
_('Customer ID'),
|
||||
_('SSO provider'),
|
||||
_('External identifier'),
|
||||
_('Email'),
|
||||
_('E-mail'),
|
||||
_('Phone number'),
|
||||
_('Full name'),
|
||||
]
|
||||
|
||||
@@ -199,7 +199,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
_('Invoice number'),
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('Email address'),
|
||||
_('E-mail address'),
|
||||
_('Invoice type'),
|
||||
_('Cancellation of'),
|
||||
_('Language'),
|
||||
@@ -326,7 +326,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
_('Event start date'),
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('Email address'),
|
||||
_('E-mail address'),
|
||||
_('Invoice type'),
|
||||
_('Cancellation of'),
|
||||
_('Invoice sender:') + ' ' + _('Name'),
|
||||
|
||||
@@ -284,7 +284,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Comment'))
|
||||
headers.append(_('Follow-up date'))
|
||||
headers.append(_('Positions'))
|
||||
headers.append(_('Email address verified'))
|
||||
headers.append(_('E-mail address verified'))
|
||||
headers.append(_('External customer ID'))
|
||||
headers.append(_('Payment providers'))
|
||||
if form_data.get('include_payment_amounts'):
|
||||
@@ -655,7 +655,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers += [
|
||||
_('Sales channel'),
|
||||
_('Order locale'),
|
||||
_('Email address verified'),
|
||||
_('E-mail address verified'),
|
||||
_('External customer ID'),
|
||||
_('Check-in lists'),
|
||||
_('Payment providers'),
|
||||
|
||||
@@ -254,7 +254,7 @@ class PasswordRecoverForm(forms.Form):
|
||||
|
||||
class PasswordForgotForm(forms.Form):
|
||||
email = forms.EmailField(
|
||||
label=_('Email'),
|
||||
label=_('E-mail'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -54,7 +54,6 @@ from django.core.validators import (
|
||||
from django.db.models import QuerySet
|
||||
from django.forms import Select, widgets
|
||||
from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -78,7 +77,7 @@ from pretix.base.i18n import (
|
||||
get_babel_locale, get_language_without_region, language,
|
||||
)
|
||||
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 (
|
||||
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
|
||||
)
|
||||
@@ -277,10 +276,6 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
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):
|
||||
initial = None
|
||||
|
||||
@@ -607,7 +602,6 @@ class BaseQuestionsForm(forms.Form):
|
||||
questions = pos.item.questions_to_ask
|
||||
event = kwargs.pop('event')
|
||||
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)
|
||||
|
||||
@@ -682,7 +676,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
|
||||
if item.ask_attendee_data and event.settings.attendee_addresses_asked:
|
||||
add_fields['street'] = forms.CharField(
|
||||
required=self.attendee_addresses_required,
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('Address'),
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
@@ -692,7 +686,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial=(cartpos.street if cartpos else orderpos.street),
|
||||
)
|
||||
add_fields['zipcode'] = forms.CharField(
|
||||
required=False,
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
max_length=30,
|
||||
label=_('ZIP code'),
|
||||
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
|
||||
@@ -701,7 +695,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
}),
|
||||
)
|
||||
add_fields['city'] = forms.CharField(
|
||||
required=False,
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('City'),
|
||||
max_length=255,
|
||||
initial=(cartpos.city if cartpos else orderpos.city),
|
||||
@@ -713,12 +707,11 @@ class BaseQuestionsForm(forms.Form):
|
||||
add_fields['country'] = CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=self.attendee_addresses_required,
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('Country'),
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'country',
|
||||
'data-country-information-url': reverse('js_helpers.states'),
|
||||
}),
|
||||
)
|
||||
c = [('', pgettext_lazy('address', 'Select state'))]
|
||||
@@ -953,9 +946,9 @@ class BaseQuestionsForm(forms.Form):
|
||||
d = super().clean()
|
||||
|
||||
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'):
|
||||
self.add_error('state', _('This field is required.'))
|
||||
|
||||
@@ -1012,7 +1005,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
'street': forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
'placeholder': _('Street and Number'),
|
||||
'autocomplete': 'street-address',
|
||||
'autocomplete': 'street-address'
|
||||
}),
|
||||
'beneficiary': forms.Textarea(attrs={'rows': 3}),
|
||||
'country': forms.Select(attrs={
|
||||
@@ -1028,7 +1021,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
'data-display-dependency': '#id_is_business_1',
|
||||
'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,
|
||||
}
|
||||
labels = {
|
||||
@@ -1062,7 +1055,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
])
|
||||
|
||||
self.fields['country'].choices = CachedCountries()
|
||||
self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states')
|
||||
|
||||
c = [('', pgettext_lazy('address', 'Select state'))]
|
||||
fprefix = self.prefix + '-' if self.prefix else ''
|
||||
@@ -1091,10 +1083,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
)
|
||||
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.
|
||||
if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data:
|
||||
self.data = self.data.copy()
|
||||
@@ -1134,7 +1122,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
if 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:
|
||||
del self.fields['custom_field']
|
||||
|
||||
@@ -1147,19 +1134,16 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
validate_address # local import to prevent impact on startup time
|
||||
|
||||
data = self.cleaned_data
|
||||
|
||||
if not data.get('is_business'):
|
||||
data['company'] = ''
|
||||
data['vat_id'] = ''
|
||||
if data.get('is_business') and not ask_for_vat_id(data.get('country')):
|
||||
data['vat_id'] = ''
|
||||
if self.address_validation and self.event.settings.invoice_address_required and not self.all_optional:
|
||||
if self.event.settings.invoice_address_required:
|
||||
if data.get('is_business') and not data.get('company'):
|
||||
raise ValidationError({"company": _('You need to provide a company name.')})
|
||||
if not data.get('is_business') and name_parts_is_empty(data.get('name_parts', {})):
|
||||
raise ValidationError(_('You need to provide a company name.'))
|
||||
if not data.get('is_business') and not data.get('name_parts'):
|
||||
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'):
|
||||
self.instance.vat_id_validated = False
|
||||
@@ -1171,7 +1155,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
if all(
|
||||
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!
|
||||
self.cleaned_data['country'] = ''
|
||||
|
||||
|
||||
@@ -48,10 +48,10 @@ from pretix.control.forms import SingleLanguageWidget
|
||||
|
||||
class UserSettingsForm(forms.ModelForm):
|
||||
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."),
|
||||
'pw_current': _("Please enter your current password if you want to change your email address "
|
||||
"or password."),
|
||||
'pw_current': _("Please enter your current password if you want to change your e-mail "
|
||||
"address or password."),
|
||||
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||
'pw_mismatch': _("Please enter the same password twice"),
|
||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||
|
||||
@@ -289,7 +289,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
def _clean_text(self, text, tags=None):
|
||||
return self._normalize(bleach.clean(
|
||||
text,
|
||||
tags=set(tags) if tags else set()
|
||||
tags=tags or []
|
||||
).strip().replace('<br>', '<br />').replace('\n', '<br />\n'))
|
||||
|
||||
|
||||
@@ -461,7 +461,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
def _draw_event(self, canvas):
|
||||
def shorten(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_size = p.wrap(self.event_width, self.event_height)
|
||||
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from collections import defaultdict
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.signals import EventPluginRegistry
|
||||
|
||||
|
||||
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
|
||||
if a_map:
|
||||
if is_active:
|
||||
a_map['val'] = '<a href="{href}">{val}</a>'.format_map(a_map)
|
||||
elif event and plugin_name:
|
||||
a_map['val'] = (
|
||||
'<i>{val}</i> <a href="{plugin_href}">'
|
||||
'<span data-toggle="tooltip" title="{errmes}" class="fa fa-warning fa-fw"></span></a>'
|
||||
).format_map({
|
||||
**a_map,
|
||||
"errmes": _("The relevant plugin is currently not active. To activate it, click here to go to the plugin settings."),
|
||||
"plugin_href": reverse('control:event.settings.plugins', kwargs={
|
||||
'organizer': event.organizer.slug,
|
||||
'event': event.slug,
|
||||
}) + '#plugin_' + plugin_name,
|
||||
})
|
||||
else:
|
||||
a_map['val'] = '<i>{val}</i> <span data-toggle="tooltip" title="{errmes}" class="fa fa-warning fa-fw"></span>'.format_map({
|
||||
**a_map,
|
||||
"errmes": _("The relevant plugin is currently not active."),
|
||||
})
|
||||
return wrapper.format_map(a_map)
|
||||
|
||||
|
||||
class LogEntryTypeRegistry(EventPluginRegistry):
|
||||
def __init__(self):
|
||||
super().__init__({'action_type': lambda o: getattr(o, 'action_type')})
|
||||
|
||||
def register(self, *objs):
|
||||
for obj in objs:
|
||||
if not isinstance(obj, LogEntryType):
|
||||
raise TypeError('Entries must be derived from LogEntryType')
|
||||
|
||||
if obj.__module__ == LogEntryType.__module__:
|
||||
raise TypeError('Must not register base classes, only derived ones')
|
||||
|
||||
return super().register(*objs)
|
||||
|
||||
def new_from_dict(self, data):
|
||||
"""
|
||||
Register multiple instance of a `LogEntryType` class with different `action_type`
|
||||
and plain text strings, as given by the items of the specified data dictionary.
|
||||
|
||||
This method is designed to be used as a decorator as follows:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@log_entry_types.new_from_dict({
|
||||
'pretix.event.item.added': _('The product has been created.'),
|
||||
'pretix.event.item.changed': _('The product has been changed.'),
|
||||
# ...
|
||||
})
|
||||
class CoreItemLogEntryType(ItemLogEntryType):
|
||||
# ...
|
||||
|
||||
:param data: action types and descriptions
|
||||
``{"some_action_type": "Plain text description", ...}``
|
||||
"""
|
||||
def reg(clz):
|
||||
for action_type, plain in data.items():
|
||||
self.register(clz(action_type=action_type, plain=plain))
|
||||
return clz
|
||||
return reg
|
||||
|
||||
|
||||
"""
|
||||
Registry for LogEntry types.
|
||||
|
||||
Each entry in this registry should be an instance of a subclass of ``LogEntryType``.
|
||||
They are annotated with their ``action_type`` and the defining ``plugin``.
|
||||
"""
|
||||
log_entry_types = LogEntryTypeRegistry()
|
||||
|
||||
|
||||
class LogEntryType:
|
||||
"""
|
||||
Base class for a type of LogEntry, identified by its action_type.
|
||||
"""
|
||||
|
||||
def __init__(self, action_type=None, plain=None):
|
||||
if action_type:
|
||||
self.action_type = action_type
|
||||
if plain:
|
||||
self.plain = plain
|
||||
|
||||
def display(self, logentry):
|
||||
"""
|
||||
Returns the message to be displayed for a given logentry of this type.
|
||||
|
||||
:return: `str` or `LazyI18nString`
|
||||
"""
|
||||
if hasattr(self, 'plain'):
|
||||
plain = str(self.plain)
|
||||
if '{' in plain:
|
||||
data = defaultdict(lambda: '?', logentry.parsed_data)
|
||||
return plain.format_map(data)
|
||||
else:
|
||||
return plain
|
||||
|
||||
def get_object_link_info(self, logentry) -> dict:
|
||||
"""
|
||||
Return information to generate a link to the `content_object` of a given log entry.
|
||||
|
||||
Not implemented in the base class, causing the object link to be omitted.
|
||||
|
||||
:return: Dictionary with the keys ``href`` (containing a URL to view/edit the object) and ``val`` (containing the
|
||||
escaped text for the anchor element)
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_object_link(self, logentry):
|
||||
a_map = self.get_object_link_info(logentry)
|
||||
return make_link(a_map, self.object_link_wrapper)
|
||||
|
||||
object_link_wrapper = '{val}'
|
||||
|
||||
def shred_pii(self, logentry):
|
||||
"""
|
||||
To be used for shredding personally identified information contained in the data field of a LogEntry of this
|
||||
type.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class EventLogEntryType(LogEntryType):
|
||||
"""
|
||||
Base class for any `LogEntry` type whose `content_object` is either an `Event` itself or belongs to a specific `Event`.
|
||||
"""
|
||||
|
||||
def get_object_link_info(self, logentry) -> dict:
|
||||
if hasattr(self, 'object_link_viewname') and logentry.content_object:
|
||||
return {
|
||||
'href': reverse(self.object_link_viewname, kwargs={
|
||||
'event': logentry.event.slug,
|
||||
'organizer': logentry.event.organizer.slug,
|
||||
**self.object_link_args(logentry.content_object),
|
||||
}),
|
||||
'val': escape(self.object_link_display_name(logentry.content_object)),
|
||||
}
|
||||
|
||||
def object_link_args(self, content_object):
|
||||
"""Return the kwargs for the url used in a link to content_object."""
|
||||
if hasattr(self, 'object_link_argname'):
|
||||
return {self.object_link_argname: content_object.pk}
|
||||
return {}
|
||||
|
||||
def object_link_display_name(self, content_object):
|
||||
"""Return the display name to refer to content_object in the user interface."""
|
||||
return str(content_object)
|
||||
|
||||
|
||||
class OrderLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _('Order {val}')
|
||||
object_link_viewname = 'control:event.order'
|
||||
|
||||
def object_link_args(self, order):
|
||||
return {'code': order.code}
|
||||
|
||||
def object_link_display_name(self, order):
|
||||
return order.code
|
||||
|
||||
|
||||
class VoucherLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _('Voucher {val}…')
|
||||
object_link_viewname = 'control:event.voucher'
|
||||
object_link_argname = 'voucher'
|
||||
|
||||
def object_link_display_name(self, voucher):
|
||||
if len(voucher.code) > 6:
|
||||
return voucher.code[:6] + "…"
|
||||
return voucher.code
|
||||
|
||||
|
||||
class ItemLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _('Product {val}')
|
||||
object_link_viewname = 'control:event.item'
|
||||
object_link_argname = 'item'
|
||||
|
||||
|
||||
class SubEventLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = pgettext_lazy('subevent', 'Date {val}')
|
||||
object_link_viewname = 'control:event.subevent'
|
||||
object_link_argname = 'subevent'
|
||||
|
||||
|
||||
class QuotaLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _('Quota {val}')
|
||||
object_link_viewname = 'control:event.items.quotas.show'
|
||||
object_link_argname = 'quota'
|
||||
|
||||
|
||||
class DiscountLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _('Discount {val}')
|
||||
object_link_viewname = 'control:event.items.discounts.edit'
|
||||
object_link_argname = 'discount'
|
||||
|
||||
|
||||
class ItemCategoryLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _('Category {val}')
|
||||
object_link_viewname = 'control:event.items.categories.edit'
|
||||
object_link_argname = 'category'
|
||||
|
||||
|
||||
class QuestionLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _('Question {val}')
|
||||
object_link_viewname = 'control:event.items.questions.show'
|
||||
object_link_argname = 'question'
|
||||
|
||||
|
||||
class TaxRuleLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _('Tax rule {val}')
|
||||
object_link_viewname = 'control:event.settings.tax.edit'
|
||||
object_link_argname = 'rule'
|
||||
|
||||
|
||||
class NoOpShredderMixin:
|
||||
def shred_pii(self, logentry):
|
||||
pass
|
||||
|
||||
|
||||
class ClearDataShredderMixin:
|
||||
def shred_pii(self, logentry):
|
||||
logentry.data = None
|
||||
@@ -36,7 +36,6 @@ import time
|
||||
import traceback
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.dispatch.dispatcher import NO_RECEIVERS
|
||||
|
||||
@@ -58,8 +57,6 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
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:
|
||||
return
|
||||
|
||||
@@ -81,7 +78,7 @@ class Command(BaseCommand):
|
||||
try:
|
||||
r = receiver(signal=periodic_task, sender=self)
|
||||
except Exception as err:
|
||||
if isinstance(err, KeyboardInterrupt):
|
||||
if isinstance(Exception, KeyboardInterrupt):
|
||||
raise err
|
||||
if settings.SENTRY_ENABLED:
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
@@ -37,16 +37,6 @@ class BaseMediaType:
|
||||
def verbose_name(self):
|
||||
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):
|
||||
if self.medium_created_by_server:
|
||||
raise NotImplementedError()
|
||||
@@ -69,7 +59,6 @@ class BaseMediaType:
|
||||
class BarcodePlainMediaType(BaseMediaType):
|
||||
identifier = 'barcode'
|
||||
verbose_name = _('Barcode / QR-Code')
|
||||
icon = 'qrcode'
|
||||
medium_created_by_server = True
|
||||
supports_giftcard = False
|
||||
supports_orderposition = True
|
||||
@@ -86,7 +75,6 @@ class BarcodePlainMediaType(BaseMediaType):
|
||||
class NfcUidMediaType(BaseMediaType):
|
||||
identifier = 'nfc_uid'
|
||||
verbose_name = _('NFC UID-based')
|
||||
icon = 'pretixbase/img/media/nfc_uid.svg'
|
||||
medium_created_by_server = False
|
||||
supports_giftcard = True
|
||||
supports_orderposition = False
|
||||
@@ -126,7 +114,6 @@ class NfcUidMediaType(BaseMediaType):
|
||||
class NfcMf0aesMediaType(BaseMediaType):
|
||||
identifier = 'nfc_mf0aes'
|
||||
verbose_name = 'NFC Mifare Ultralight AES'
|
||||
icon = 'pretixbase/img/media/nfc_secure.svg'
|
||||
medium_created_by_server = False
|
||||
supports_giftcard = True
|
||||
supports_orderposition = False
|
||||
|
||||
@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
|
||||
('password', models.CharField(verbose_name='password', max_length=128)),
|
||||
('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.')),
|
||||
('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)),
|
||||
('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)),
|
||||
|
||||
@@ -9,7 +9,6 @@ from decimal import Decimal
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from argon2.exceptions import HashingError
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.db import migrations, models
|
||||
@@ -26,14 +25,7 @@ def initial_user(apps, schema_editor):
|
||||
user = User(email='admin@localhost')
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
try:
|
||||
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.password = make_password('admin')
|
||||
user.save()
|
||||
|
||||
|
||||
@@ -56,7 +48,7 @@ class Migration(migrations.Migration):
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('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')),
|
||||
('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')),
|
||||
('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')),
|
||||
('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')),
|
||||
('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')),
|
||||
('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')),
|
||||
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
|
||||
('datetime', models.DateTimeField(verbose_name='Date')),
|
||||
|
||||
@@ -187,7 +187,7 @@ class Migration(migrations.Migration):
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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')),
|
||||
('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')),
|
||||
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
|
||||
('datetime', models.DateTimeField(verbose_name='Date')),
|
||||
|
||||
@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('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')),
|
||||
('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)),
|
||||
('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')),
|
||||
|
||||
@@ -35,7 +35,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('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')),
|
||||
('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)),
|
||||
('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')),
|
||||
|
||||
@@ -163,7 +163,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
|
||||
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -256,9 +256,6 @@ class SubeventColumnMixin:
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value in self._subevent_cache:
|
||||
return self._subevent_cache[value]
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ from pretix.base.signals import order_import_columns
|
||||
|
||||
class EmailColumn(ImportColumn):
|
||||
identifier = 'email'
|
||||
verbose_name = gettext_lazy('Email address')
|
||||
verbose_name = gettext_lazy('E-mail address')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
@@ -322,7 +322,7 @@ class AttendeeNamePart(ImportColumn):
|
||||
|
||||
class AttendeeEmail(ImportColumn):
|
||||
identifier = 'attendee_email'
|
||||
verbose_name = gettext_lazy('Attendee email address')
|
||||
verbose_name = gettext_lazy('Attendee e-mail address')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
|
||||
@@ -241,7 +241,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
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,
|
||||
verbose_name=_('Full name'))
|
||||
is_active = models.BooleanField(default=True,
|
||||
|
||||
@@ -99,6 +99,14 @@ class CheckinList(LoggedModel):
|
||||
verbose_name=_('Automatically check out everyone at'),
|
||||
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)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
@@ -133,7 +141,7 @@ class CheckinList(LoggedModel):
|
||||
return self.positions_query(ignore_status=False)
|
||||
|
||||
@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:
|
||||
c_q = []
|
||||
else:
|
||||
@@ -141,7 +149,7 @@ class CheckinList(LoggedModel):
|
||||
|
||||
if "postgresql" not in settings.DATABASES["default"]["ENGINE"]:
|
||||
# Use a simple approach that works on all databases
|
||||
qs = qs.annotate(
|
||||
qs = self.positions_query(ignore_status=ignore_status).annotate(
|
||||
last_entry=Subquery(
|
||||
Checkin.objects.filter(
|
||||
*c_q,
|
||||
@@ -194,7 +202,7 @@ class CheckinList(LoggedModel):
|
||||
.values("position_id", "type", "datetime", "cnt_exists_after")
|
||||
.query.sql_with_params()
|
||||
)
|
||||
return qs.filter(
|
||||
return self.positions_query(ignore_status=ignore_status).filter(
|
||||
pk__in=RawSQL(
|
||||
f"""
|
||||
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
|
||||
def positions_inside(self):
|
||||
return self.positions_inside_query(None)
|
||||
|
||||
@@ -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'))
|
||||
password = models.CharField(verbose_name=_('Password'), max_length=128)
|
||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
@@ -392,7 +392,7 @@ class CustomerSSOClient(LoggedModel):
|
||||
SCOPE_CHOICES = (
|
||||
('openid', _('OpenID Connect access (required)')),
|
||||
('profile', _('Profile data (name, addresses)')),
|
||||
('email', _('Email address')),
|
||||
('email', _('E-mail address')),
|
||||
('phone', _('Phone number')),
|
||||
)
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.api.auth.devicesecurity import DEVICE_SECURITY_PROFILES
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
|
||||
@@ -160,6 +161,7 @@ class Device(LoggedModel):
|
||||
)
|
||||
security_profile = models.CharField(
|
||||
max_length=190,
|
||||
choices=[(k, v.verbose_name) for k, v in DEVICE_SECURITY_PROFILES.items()],
|
||||
default='full',
|
||||
null=True,
|
||||
blank=False
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from collections import defaultdict, namedtuple
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from math import ceil, inf
|
||||
from typing import Dict
|
||||
from math import ceil
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
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.models.base import LoggedModel
|
||||
|
||||
PositionInfo = namedtuple('PositionInfo', ['item_id', 'subevent_id', 'line_price_gross', 'is_addon_to', 'voucher_discount'])
|
||||
|
||||
|
||||
class Discount(LoggedModel):
|
||||
SUBEVENT_MODE_MIXED = 'mixed'
|
||||
@@ -247,26 +245,22 @@ class Discount(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
|
||||
if self.condition_min_value and sum(positions[idx].line_price_gross for idx in condition_idx_group) < self.condition_min_value:
|
||||
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result):
|
||||
if self.condition_min_value and sum(positions[idx][2] for idx in condition_idx_group) < self.condition_min_value:
|
||||
return
|
||||
|
||||
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
|
||||
raise ValueError('Validation invariant violated.')
|
||||
|
||||
for idx in benefit_idx_group:
|
||||
previous_price = positions[idx].line_price_gross
|
||||
previous_price = positions[idx][2]
|
||||
new_price = round_decimal(
|
||||
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
|
||||
self.event.currency,
|
||||
)
|
||||
result[idx] = new_price
|
||||
|
||||
if collect_potential_discounts is not None:
|
||||
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):
|
||||
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result):
|
||||
if len(condition_idx_group) < self.condition_min_count:
|
||||
return
|
||||
|
||||
@@ -274,53 +268,23 @@ class Discount(LoggedModel):
|
||||
raise ValueError('Validation invariant violated.')
|
||||
|
||||
if self.benefit_only_apply_to_cheapest_n_matches:
|
||||
# sort by line_price
|
||||
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
||||
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
||||
if not self.condition_min_count:
|
||||
raise ValueError('Validation invariant violated.')
|
||||
|
||||
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
|
||||
# want to match multiples of 3
|
||||
|
||||
# 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)
|
||||
n_groups = min(len(condition_idx_group) // self.condition_min_count, ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches))
|
||||
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]
|
||||
|
||||
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:
|
||||
consume_idx = condition_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:
|
||||
previous_price = positions[idx].line_price_gross
|
||||
previous_price = positions[idx][2]
|
||||
new_price = round_decimal(
|
||||
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
|
||||
self.event.currency,
|
||||
@@ -328,16 +292,15 @@ class Discount(LoggedModel):
|
||||
result[idx] = new_price
|
||||
|
||||
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],
|
||||
collect_potential_discounts=None) -> Dict[int, Decimal]:
|
||||
def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]]) -> Dict[int, Decimal]:
|
||||
"""
|
||||
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.
|
||||
: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
|
||||
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.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:
|
||||
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:
|
||||
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
|
||||
# to each group
|
||||
@@ -394,11 +357,11 @@ class Discount(LoggedModel):
|
||||
candidate_groups = [(k, list(g)) for k, g in _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:
|
||||
self._apply_min_count(positions, g, benefit_g, result, collect_potential_discounts, subevent_id)
|
||||
self._apply_min_count(positions, g, benefit_g, result)
|
||||
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:
|
||||
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
|
||||
subevent_to_idx = defaultdict(list)
|
||||
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():
|
||||
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)
|
||||
|
||||
# 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]
|
||||
if cardinality and len(l) != cardinality:
|
||||
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
|
||||
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
|
||||
# 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):
|
||||
candidate = candidates[0]
|
||||
else:
|
||||
@@ -452,14 +415,14 @@ class Discount(LoggedModel):
|
||||
if len(current_group) >= max(self.condition_min_count, 1):
|
||||
candidate_groups.append(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 = []
|
||||
|
||||
# Distribute "leftovers"
|
||||
for se in subevent_order:
|
||||
if subevent_to_idx[se]:
|
||||
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())
|
||||
if not subevent_to_idx[se]:
|
||||
break
|
||||
@@ -469,8 +432,6 @@ class Discount(LoggedModel):
|
||||
positions,
|
||||
[idx for idx in g if idx in condition_candidates],
|
||||
[idx for idx in g if idx in benefit_candidates],
|
||||
result,
|
||||
None,
|
||||
None
|
||||
result
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -823,9 +823,6 @@ class Event(EventMixin, LoggedModel):
|
||||
self.save()
|
||||
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:
|
||||
self.limit_sales_channels.set(
|
||||
self.organizer.sales_channels.filter(
|
||||
@@ -873,12 +870,10 @@ class Event(EventMixin, LoggedModel):
|
||||
for i in Item.objects.filter(event=other).prefetch_related(
|
||||
'variations', 'limit_sales_channels', 'require_membership_types',
|
||||
'variations__limit_sales_channels', 'variations__require_membership_types',
|
||||
'matched_by_cross_selling_categories',
|
||||
):
|
||||
vars = list(i.variations.all())
|
||||
require_membership_types = list(i.require_membership_types.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
|
||||
i.pk = None
|
||||
i.event = self
|
||||
@@ -916,9 +911,6 @@ class Event(EventMixin, LoggedModel):
|
||||
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]))
|
||||
|
||||
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):
|
||||
i.hidden_if_item_available = item_map[i.hidden_if_item_available_id]
|
||||
i.save()
|
||||
@@ -1027,9 +1019,10 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
checkin_list_map = {}
|
||||
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())
|
||||
auto_checkin_sales_channels = list(cl.auto_checkin_sales_channels.all())
|
||||
checkin_list_map[cl.pk] = cl
|
||||
cl.pk = None
|
||||
cl._prefetched_objects_cache = {}
|
||||
@@ -1041,6 +1034,8 @@ class Event(EventMixin, LoggedModel):
|
||||
cl.log_action('pretix.object.cloned')
|
||||
for i in items:
|
||||
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.organizer_id == self.organizer_id:
|
||||
|
||||
@@ -63,13 +63,14 @@ from django_countries.fields import Country
|
||||
from django_scopes import ScopedManager
|
||||
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.fields import MultiStringField
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
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):
|
||||
@@ -110,33 +111,6 @@ class ItemCategory(LoggedModel):
|
||||
'only be bought in combination with a product that has this category configured as a possible '
|
||||
'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:
|
||||
verbose_name = _("Product category")
|
||||
@@ -145,31 +119,19 @@ class ItemCategory(LoggedModel):
|
||||
|
||||
def __str__(self):
|
||||
name = self.internal_name or self.name
|
||||
if self.category_type != 'normal':
|
||||
return _('{category} ({category_type})').format(category=str(name),
|
||||
category_type=self.get_category_type_display())
|
||||
if self.is_addon:
|
||||
return _('{category} (Add-On products)').format(category=str(name))
|
||||
return str(name)
|
||||
|
||||
def get_category_type_display(self):
|
||||
if self.is_addon:
|
||||
return _('Add-on category')
|
||||
elif self.cross_selling_mode:
|
||||
return self.get_cross_selling_mode_display()
|
||||
return _('Add-On products')
|
||||
else:
|
||||
return _('Normal category')
|
||||
return None
|
||||
|
||||
@property
|
||||
def category_type(self):
|
||||
return 'addon' if self.is_addon else self.cross_selling_mode or '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
|
||||
return 'addon' if self.is_addon else 'normal'
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
@@ -308,7 +270,7 @@ class SubEventItemVariation(models.Model):
|
||||
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
|
||||
# makes the query SIGNIFICANTLY faster
|
||||
from .organizer import SalesChannel
|
||||
@@ -329,8 +291,6 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_
|
||||
|
||||
if not allow_addons:
|
||||
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.item_id:
|
||||
@@ -344,8 +304,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_
|
||||
|
||||
|
||||
class ItemQuerySet(models.QuerySet):
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
|
||||
return filter_available(self, channel, voucher, allow_addons, allow_cross_sell)
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||
return filter_available(self, channel, voucher, allow_addons)
|
||||
|
||||
|
||||
class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__):
|
||||
@@ -353,8 +313,8 @@ class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__)
|
||||
super().__init__()
|
||||
self._queryset_class = ItemQuerySet
|
||||
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
|
||||
return filter_available(self.get_queryset(), channel, voucher, allow_addons, allow_cross_sell)
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||
return filter_available(self.get_queryset(), channel, voucher, allow_addons)
|
||||
|
||||
|
||||
class Item(LoggedModel):
|
||||
@@ -1118,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.
|
||||
:type original_price: decimal.Decimal
|
||||
:param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after
|
||||
approval by an administrator
|
||||
approval by an administrator
|
||||
: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.
|
||||
:type all_sales_channels: bool
|
||||
:param limit_sales_channels: A list of sales channel identifiers, that this variation is available for sale on.
|
||||
:type limit_sales_channels: list
|
||||
|
||||
"""
|
||||
item = models.ForeignKey(
|
||||
Item,
|
||||
|
||||
@@ -33,15 +33,16 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.logentrytypes import log_entry_types, make_link
|
||||
from pretix.base.signals import is_app_active, logentry_object_link
|
||||
from pretix.base.signals import logentry_object_link
|
||||
|
||||
|
||||
class VisibleOnlyManager(models.Manager):
|
||||
@@ -91,10 +92,6 @@ class LogEntry(models.Model):
|
||||
indexes = [models.Index(fields=["datetime", "id"])]
|
||||
|
||||
def display(self):
|
||||
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
|
||||
if log_entry_type:
|
||||
return log_entry_type.display(self)
|
||||
|
||||
from ..signals import logentry_display
|
||||
|
||||
for receiver, response in logentry_display.send(self.event, logentry=self):
|
||||
@@ -129,18 +126,10 @@ class LogEntry(models.Model):
|
||||
@cached_property
|
||||
def display_object(self):
|
||||
from . import (
|
||||
Discount, Event, Item, Order, Question, Quota, SubEvent, Voucher,
|
||||
Discount, Event, Item, ItemCategory, Order, Question, Quota,
|
||||
SubEvent, TaxRule, Voucher,
|
||||
)
|
||||
|
||||
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
|
||||
if log_entry_type:
|
||||
link_info = log_entry_type.get_object_link_info(self)
|
||||
if is_app_active(self.event, meta['plugin']):
|
||||
return make_link(link_info, log_entry_type.object_link_wrapper)
|
||||
else:
|
||||
return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False,
|
||||
event=self.event, plugin_name=meta['plugin'] and getattr(meta['plugin'], 'name'))
|
||||
|
||||
try:
|
||||
if self.content_type.model_class() is Event:
|
||||
return ''
|
||||
@@ -148,15 +137,110 @@ class LogEntry(models.Model):
|
||||
co = self.content_object
|
||||
except:
|
||||
return ''
|
||||
a_map = None
|
||||
a_text = None
|
||||
|
||||
for receiver, response in logentry_object_link.send(self.event, logentry=self):
|
||||
if response:
|
||||
return response
|
||||
if isinstance(co, Order):
|
||||
a_text = _('Order {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.order', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'code': co.code
|
||||
}),
|
||||
'val': escape(co.code),
|
||||
}
|
||||
elif isinstance(co, Voucher):
|
||||
a_text = _('Voucher {val}…')
|
||||
a_map = {
|
||||
'href': reverse('control:event.voucher', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'voucher': co.id
|
||||
}),
|
||||
'val': escape(co.code[:6]),
|
||||
}
|
||||
elif isinstance(co, Item):
|
||||
a_text = _('Product {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.item', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'item': co.id
|
||||
}),
|
||||
'val': escape(co.name),
|
||||
}
|
||||
elif isinstance(co, SubEvent):
|
||||
a_text = pgettext_lazy('subevent', 'Date {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.subevent', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'subevent': co.id
|
||||
}),
|
||||
'val': escape(str(co))
|
||||
}
|
||||
elif isinstance(co, Quota):
|
||||
a_text = _('Quota {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.items.quotas.show', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'quota': co.id
|
||||
}),
|
||||
'val': escape(co.name),
|
||||
}
|
||||
elif isinstance(co, Discount):
|
||||
a_text = _('Discount {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.items.discounts.edit', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'discount': co.id
|
||||
}),
|
||||
'val': escape(co.internal_name),
|
||||
}
|
||||
elif isinstance(co, ItemCategory):
|
||||
a_text = _('Category {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.items.categories.edit', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'category': co.id
|
||||
}),
|
||||
'val': escape(co.name),
|
||||
}
|
||||
elif isinstance(co, Question):
|
||||
a_text = _('Question {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.items.questions.show', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'question': co.id
|
||||
}),
|
||||
'val': escape(co.question),
|
||||
}
|
||||
elif isinstance(co, TaxRule):
|
||||
a_text = _('Tax rule {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.settings.tax.edit', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'rule': co.id
|
||||
}),
|
||||
'val': escape(co.name),
|
||||
}
|
||||
|
||||
if isinstance(co, (Order, Voucher, Item, SubEvent, Quota, Discount, Question)):
|
||||
logging.warning("LogEntryType missing or ill-defined: %s", self.action_type)
|
||||
|
||||
return ''
|
||||
if a_text and a_map:
|
||||
a_map['val'] = '<a href="{href}">{val}</a>'.format_map(a_map)
|
||||
return a_text.format_map(a_map)
|
||||
elif a_text:
|
||||
return a_text
|
||||
else:
|
||||
for receiver, response in logentry_object_link.send(self.event, logentry=self):
|
||||
if response:
|
||||
return response
|
||||
return ''
|
||||
|
||||
@cached_property
|
||||
def parsed_data(self):
|
||||
|
||||
@@ -159,24 +159,10 @@ class Membership(models.Model):
|
||||
de = date_format(self.date_end, 'SHORT_DATE_FORMAT')
|
||||
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
|
||||
def attendee_name(self):
|
||||
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):
|
||||
if valid_from_not_chosen:
|
||||
return not self.canceled and self.date_end >= time_machine_now()
|
||||
|
||||
@@ -43,7 +43,7 @@ class NotificationSetting(models.Model):
|
||||
:type enabled: bool
|
||||
"""
|
||||
CHANNELS = (
|
||||
('mail', _('Email')),
|
||||
('mail', _('E-mail')),
|
||||
)
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE,
|
||||
related_name='notification_settings')
|
||||
|
||||
@@ -242,7 +242,7 @@ class Order(LockModel, LoggedModel):
|
||||
)
|
||||
email = models.EmailField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Email')
|
||||
verbose_name=_('E-mail')
|
||||
)
|
||||
phone = PhoneNumberField(
|
||||
null=True, blank=True,
|
||||
@@ -317,7 +317,7 @@ class Order(LockModel, LoggedModel):
|
||||
)
|
||||
email_known_to_work = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Email address verified')
|
||||
verbose_name=_('E-mail address verified')
|
||||
)
|
||||
invoice_dirty = models.BooleanField(
|
||||
# Invoice needs to be re-issued when the order is paid again
|
||||
@@ -2275,7 +2275,6 @@ class OrderFee(models.Model):
|
||||
FEE_TYPE_SERVICE = "service"
|
||||
FEE_TYPE_CANCELLATION = "cancellation"
|
||||
FEE_TYPE_INSURANCE = "insurance"
|
||||
FEE_TYPE_LATE = "late"
|
||||
FEE_TYPE_OTHER = "other"
|
||||
FEE_TYPE_GIFTCARD = "giftcard"
|
||||
FEE_TYPES = (
|
||||
@@ -2284,7 +2283,6 @@ class OrderFee(models.Model):
|
||||
(FEE_TYPE_SERVICE, _("Service fee")),
|
||||
(FEE_TYPE_CANCELLATION, _("Cancellation fee")),
|
||||
(FEE_TYPE_INSURANCE, _("Insurance fee")),
|
||||
(FEE_TYPE_LATE, _("Late fee")),
|
||||
(FEE_TYPE_OTHER, _("Other fees")),
|
||||
(FEE_TYPE_GIFTCARD, _("Gift card")),
|
||||
)
|
||||
@@ -3206,9 +3204,9 @@ class InvoiceAddress(models.Model):
|
||||
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_parts = models.JSONField(default=dict)
|
||||
street = models.TextField(verbose_name=_('Address'), blank=True)
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True)
|
||||
street = models.TextField(verbose_name=_('Address'), blank=False)
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), 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'),
|
||||
countries=CachedCountries)
|
||||
@@ -3393,74 +3391,6 @@ class BlockedTicketSecret(models.Model):
|
||||
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)
|
||||
def cachedticket_delete(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
|
||||
@@ -53,30 +53,6 @@ class SeatingPlanLayoutValidator:
|
||||
e = str(e).replace('%', '%%')
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -73,7 +73,7 @@ class WaitingListEntry(LoggedModel):
|
||||
blank=True, default=dict
|
||||
)
|
||||
email = models.EmailField(
|
||||
verbose_name=_("Email address")
|
||||
verbose_name=_("E-mail address")
|
||||
)
|
||||
phone = PhoneNumberField(
|
||||
null=True, blank=True,
|
||||
|
||||
@@ -1419,51 +1419,50 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
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]:
|
||||
from pretix.base.services.cart import add_payment_to_cart
|
||||
|
||||
for p in get_cart(request):
|
||||
if p.item.issue_giftcard:
|
||||
messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
|
||||
return
|
||||
|
||||
cs = cart_session(request)
|
||||
try:
|
||||
gc = self.event.organizer.accepted_gift_cards.get(
|
||||
secret=request.POST.get("giftcard").strip()
|
||||
)
|
||||
cs = cart_session(request)
|
||||
try:
|
||||
self._add_giftcard_to_cart(cs, gc)
|
||||
return True
|
||||
except ValidationError as e:
|
||||
messages.error(request, str(e.message))
|
||||
if gc.currency != self.event.currency:
|
||||
messages.error(request, _("This gift card does not support this currency."))
|
||||
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:
|
||||
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 "
|
||||
|
||||
@@ -343,13 +343,11 @@ class CartManager:
|
||||
err = error_messages['some_subevent_not_started']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
continue
|
||||
|
||||
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']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
continue
|
||||
|
||||
if cp.subevent:
|
||||
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
@@ -362,7 +360,6 @@ class CartManager:
|
||||
err = error_messages['some_subevent_ended']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
continue
|
||||
return err
|
||||
|
||||
def _update_subevents_cache(self, se_ids: List[int]):
|
||||
@@ -1426,28 +1423,6 @@ class CartManager:
|
||||
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):
|
||||
"""
|
||||
: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
|
||||
|
||||
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):
|
||||
@@ -1558,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,))
|
||||
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 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):
|
||||
try:
|
||||
@@ -1581,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,))
|
||||
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 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):
|
||||
try:
|
||||
@@ -1605,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,))
|
||||
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 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):
|
||||
try:
|
||||
@@ -1626,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,))
|
||||
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:
|
||||
"""
|
||||
Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to
|
||||
ensure the requested addon state.
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
: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 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):
|
||||
ia = False
|
||||
@@ -1652,7 +1635,6 @@ def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: L
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
|
||||
cm.set_addons(addons)
|
||||
cm.add_new_items(add_to_cart_items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
|
||||
@@ -57,7 +57,7 @@ from pretix.base.models import (
|
||||
Checkin, CheckinList, Device, Event, Gate, Item, ItemVariation, Order,
|
||||
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.jsonlogic import Logic
|
||||
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")
|
||||
@scopes_disabled()
|
||||
def process_exit_all(sender, **kwargs):
|
||||
|
||||
@@ -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
|
||||
@@ -76,7 +76,7 @@ from pretix.base.services.tasks import TransactionAwareTask
|
||||
from pretix.base.services.tickets import get_tickets_for_order
|
||||
from pretix.base.signals import email_filter, global_email_filter
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.format import SafeFormatter, format_map
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.helpers.hierarkey import clean_filename
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.presale.ical import get_private_icals
|
||||
@@ -311,17 +311,11 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
try:
|
||||
if plain_text_only:
|
||||
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:
|
||||
# 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)
|
||||
else:
|
||||
# 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.',
|
||||
DeprecationWarning)
|
||||
body_html = renderer.render(content_plain, signature, raw_subject, order)
|
||||
@@ -329,8 +323,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
logger.exception('Could not render HTML body')
|
||||
body_html = None
|
||||
|
||||
body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
|
||||
|
||||
send_task = mail_send_task.si(
|
||||
to=[email] if isinstance(email, str) else list(email),
|
||||
cc=cc,
|
||||
@@ -663,7 +655,7 @@ def render_mail(template, context):
|
||||
if isinstance(template, LazyI18nString):
|
||||
body = str(template)
|
||||
if context:
|
||||
body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH)
|
||||
body = format_map(body, context)
|
||||
else:
|
||||
tpl = get_template(template)
|
||||
body = tpl.render(context)
|
||||
|
||||
@@ -26,7 +26,6 @@ from decimal import Decimal
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.timezone import now
|
||||
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 (
|
||||
register_mail_placeholders, register_text_placeholders,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
from pretix.helpers.format import PlainHtmlAlternativeString, SafeFormatter
|
||||
from pretix.helpers.format import SafeFormatter
|
||||
|
||||
logger = logging.getLogger('pretix.base.services.placeholders')
|
||||
|
||||
@@ -109,91 +107,6 @@ class SimpleFunctionalTextPlaceholder(BaseTextPlaceholder):
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
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 = [
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event', ['event'], lambda event: event.name, lambda event: event.name
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
|
||||
_event_sample,
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event_series_name', ['event', 'event_or_subevent'], lambda event, event_or_subevent: event.name,
|
||||
lambda event: event.name
|
||||
lambda event_or_subevent: event_or_subevent.name
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
|
||||
@@ -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(
|
||||
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
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(
|
||||
'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri(
|
||||
event,
|
||||
@@ -732,8 +592,8 @@ def base_placeholders(sender, **kwargs):
|
||||
|
||||
|
||||
class FormPlaceholderMixin:
|
||||
def _set_field_placeholders(self, fn, base_parameters, rich=False):
|
||||
placeholders = get_available_placeholders(self.event, base_parameters, rich=rich)
|
||||
def _set_field_placeholders(self, fn, base_parameters):
|
||||
placeholders = get_available_placeholders(self.event, base_parameters)
|
||||
ht = format_placeholders_help_text(placeholders, self.event)
|
||||
if self.fields[fn].help_text:
|
||||
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:
|
||||
base_parameters.append('invoice_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)):
|
||||
val = [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):
|
||||
params[v.identifier] = v
|
||||
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
|
||||
|
||||
@@ -20,9 +20,8 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
@@ -32,7 +31,6 @@ from pretix.base.models import (
|
||||
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
|
||||
SalesChannel, Voucher,
|
||||
)
|
||||
from pretix.base.models.discount import Discount, PositionInfo
|
||||
from pretix.base.models.event import Event, SubEvent
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
@@ -157,22 +155,14 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
|
||||
return price
|
||||
|
||||
|
||||
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||
positions: List[Tuple[int, Optional[int], Decimal, bool, bool, Decimal]],
|
||||
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]:
|
||||
def apply_discounts(event: Event, sales_channel: str,
|
||||
positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]:
|
||||
"""
|
||||
Applies any dynamic discounts to a cart
|
||||
|
||||
:param event: Event the cart belongs to
|
||||
: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 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
|
||||
"""
|
||||
if isinstance(sales_channel, SalesChannel):
|
||||
@@ -187,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')
|
||||
for discount in discount_qs:
|
||||
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)
|
||||
if not is_bundled and idx not in new_prices
|
||||
}, collect_potential_discounts)
|
||||
})
|
||||
for k in result.keys():
|
||||
result[k] = (result[k], discount)
|
||||
new_prices.update(result)
|
||||
|
||||
@@ -53,7 +53,7 @@ def vouchers_send(event: Event, vouchers: list, subject: str, message: str, reci
|
||||
v.tag = r.get('tag')
|
||||
if v.comment:
|
||||
v.comment += '\n\n'
|
||||
v.comment += gettext('The voucher has been sent to {recipient}.').format(recipient=r['email'])
|
||||
v.comment = gettext('The voucher has been sent to {recipient}.').format(recipient=r['email'])
|
||||
logs.append(v.log_action(
|
||||
'pretix.voucher.sent',
|
||||
user=user,
|
||||
|
||||
@@ -56,7 +56,6 @@ from django.utils.translation import (
|
||||
from django_countries.fields import Country
|
||||
from hierarkey.models import GlobalSettingsBase, Hierarkey
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||
from i18nfield.rest_framework import I18nField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from phonenumbers import PhoneNumber, parse
|
||||
from rest_framework import serializers
|
||||
@@ -64,7 +63,7 @@ from rest_framework import serializers
|
||||
from pretix.api.serializers.fields import (
|
||||
ListMultipleChoiceField, UploadedFileField,
|
||||
)
|
||||
from pretix.api.serializers.i18n import I18nURLField
|
||||
from pretix.api.serializers.i18n import I18nField, I18nURLField
|
||||
from pretix.base.forms import I18nMarkdownTextarea, I18nURLFormField
|
||||
from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule
|
||||
from pretix.base.reldate import (
|
||||
@@ -551,7 +550,7 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'type': bool,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require a business address"),
|
||||
label=_("Require a business addresses"),
|
||||
help_text=_('This will require users to enter a company name.'),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_required'}),
|
||||
)
|
||||
@@ -572,7 +571,7 @@ DEFAULTS = {
|
||||
'form_class': I18nFormField,
|
||||
'serializer_class': I18nField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Custom recipient field label"),
|
||||
label=_("Custom recipient field"),
|
||||
widget=I18nTextInput,
|
||||
help_text=_("If you want to add a custom text field, e.g. for a country-specific registration number, to "
|
||||
"your invoice address form, please fill in the label here. This label will both be used for "
|
||||
@@ -581,18 +580,6 @@ DEFAULTS = {
|
||||
"The field will not be required.")
|
||||
)
|
||||
},
|
||||
'invoice_address_custom_field_helptext': {
|
||||
'default': '',
|
||||
'type': LazyI18nString,
|
||||
'form_class': I18nFormField,
|
||||
'serializer_class': I18nField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Custom recipient field help text"),
|
||||
widget=I18nTextInput,
|
||||
help_text=_("If you use the custom recipient field, you can specify a help text which will be displayed "
|
||||
"underneath the field. It will not be displayed on the invoice.")
|
||||
)
|
||||
},
|
||||
'invoice_address_vatid': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
|
||||
@@ -287,9 +287,9 @@ class PhoneNumberShredder(BaseDataShredder):
|
||||
|
||||
|
||||
class EmailAddressShredder(BaseDataShredder):
|
||||
verbose_name = _('Emails')
|
||||
verbose_name = _('E-mails')
|
||||
identifier = 'order_emails'
|
||||
description = _('This will remove all email addresses from orders and attendees, as well as logged email '
|
||||
description = _('This will remove all e-mail addresses from orders and attendees, as well as logged email '
|
||||
'contents. This will also remove the association to customer accounts.')
|
||||
|
||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||
|
||||
@@ -52,50 +52,6 @@ def _populate_app_cache():
|
||||
app_cache[ac.name] = ac
|
||||
|
||||
|
||||
def get_defining_app(o):
|
||||
# If sentry packed this in a wrapper, unpack that
|
||||
if "sentry" in o.__module__:
|
||||
o = o.__wrapped__
|
||||
|
||||
# Find the Django application this belongs to
|
||||
searchpath = o.__module__
|
||||
|
||||
# Core modules are always active
|
||||
if any(searchpath.startswith(cm) for cm in settings.CORE_MODULES):
|
||||
return 'CORE'
|
||||
|
||||
if not app_cache:
|
||||
_populate_app_cache()
|
||||
|
||||
while True:
|
||||
app = app_cache.get(searchpath)
|
||||
if "." not in searchpath or app:
|
||||
break
|
||||
searchpath, _ = searchpath.rsplit(".", 1)
|
||||
return app
|
||||
|
||||
|
||||
def is_app_active(sender, app):
|
||||
if app == 'CORE':
|
||||
return True
|
||||
|
||||
excluded = settings.PRETIX_PLUGINS_EXCLUDE
|
||||
if sender and app and app.name in sender.get_plugins() and app.name not in excluded:
|
||||
if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_receiver_active(sender, receiver):
|
||||
if sender is None:
|
||||
# Send to all events!
|
||||
return True
|
||||
|
||||
app = get_defining_app(receiver)
|
||||
|
||||
return is_app_active(sender, app)
|
||||
|
||||
|
||||
class EventPluginSignal(django.dispatch.Signal):
|
||||
"""
|
||||
This is an extension to Django's built-in signals which differs in a way that it sends
|
||||
@@ -103,6 +59,33 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
Event.
|
||||
"""
|
||||
|
||||
def _is_active(self, sender, receiver):
|
||||
if sender is None:
|
||||
# Send to all events!
|
||||
return True
|
||||
|
||||
# If sentry packed this in a wrapper, unpack that
|
||||
if "sentry" in receiver.__module__:
|
||||
receiver = receiver.__wrapped__
|
||||
|
||||
# Find the Django application this belongs to
|
||||
searchpath = receiver.__module__
|
||||
core_module = any([searchpath.startswith(cm) for cm in settings.CORE_MODULES])
|
||||
app = None
|
||||
if not core_module:
|
||||
while True:
|
||||
app = app_cache.get(searchpath)
|
||||
if "." not in searchpath or app:
|
||||
break
|
||||
searchpath, _ = searchpath.rsplit(".", 1)
|
||||
|
||||
# Only fire receivers from active plugins and core modules
|
||||
excluded = settings.PRETIX_PLUGINS_EXCLUDE
|
||||
if core_module or (sender and app and app.name in sender.get_plugins() and app.name not in excluded):
|
||||
if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors:
|
||||
return True
|
||||
return False
|
||||
|
||||
def send(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
|
||||
"""
|
||||
Send signal from sender to all connected receivers that belong to
|
||||
@@ -121,7 +104,7 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in self._sorted_receivers(sender):
|
||||
if is_receiver_active(sender, receiver):
|
||||
if self._is_active(sender, receiver):
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
responses.append((receiver, response))
|
||||
return responses
|
||||
@@ -145,7 +128,7 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in self._sorted_receivers(sender):
|
||||
if is_receiver_active(sender, receiver):
|
||||
if self._is_active(sender, receiver):
|
||||
named[chain_kwarg_name] = response
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
return response
|
||||
@@ -172,7 +155,7 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in self._sorted_receivers(sender):
|
||||
if is_receiver_active(sender, receiver):
|
||||
if self._is_active(sender, receiver):
|
||||
try:
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
except Exception as err:
|
||||
@@ -219,122 +202,6 @@ class DeprecatedSignal(django.dispatch.Signal):
|
||||
super().connect(receiver, sender=None, weak=True, dispatch_uid=None)
|
||||
|
||||
|
||||
class Registry:
|
||||
"""
|
||||
A Registry is a collection of objects (entries), annotated with metadata. Entries can be searched and filtered by
|
||||
metadata keys, and metadata is returned as part of the result.
|
||||
|
||||
Entry metadata is generated during registration using to the accessor functions given to the Registry
|
||||
constructor.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
animal_sound_registry = Registry({"animal": lambda s: s.animal})
|
||||
|
||||
@animal_sound_registry.new("dog", "woof")
|
||||
@animal_sound_registry.new("cricket", "chirp")
|
||||
class AnimalSound:
|
||||
def __init__(self, animal, sound):
|
||||
self.animal = animal
|
||||
self.sound = sound
|
||||
|
||||
def make_sound(self):
|
||||
return self.sound
|
||||
|
||||
@animal_sound_registry.new()
|
||||
class CatSound(AnimalSound):
|
||||
def __init__(self):
|
||||
super().__init__(animal="cat", sound=["meow", "meww", "miaou"])
|
||||
|
||||
def make_sound(self):
|
||||
return random.choice(self.sound)
|
||||
"""
|
||||
|
||||
def __init__(self, keys):
|
||||
"""
|
||||
:param keys: Dictionary with `{key: accessor_function}`
|
||||
When a new entry is registered, all accessor functions are called with the new entry as parameter.
|
||||
Their return value is stored as the metadata value for that key.
|
||||
"""
|
||||
self.registered_entries = dict()
|
||||
self.keys = keys
|
||||
self.by_key = {key: {} for key in self.keys.keys()}
|
||||
|
||||
def register(self, *objs):
|
||||
"""
|
||||
Register one or more entries in this registry.
|
||||
|
||||
Usable as a regular method or as decorator on a class or function. If used on a class, the class type object
|
||||
itself is registered, not an instance of the class. To register an instance, use the ``new`` method.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@some_registry.register
|
||||
def my_new_entry(foo):
|
||||
# ...
|
||||
"""
|
||||
for obj in objs:
|
||||
if obj in self.registered_entries:
|
||||
raise RuntimeError('Object already registered: {}'.format(obj))
|
||||
|
||||
meta = {k: accessor(obj) for k, accessor in self.keys.items()}
|
||||
tup = (obj, meta)
|
||||
for key, value in meta.items():
|
||||
self.by_key[key][value] = tup
|
||||
self.registered_entries[obj] = meta
|
||||
|
||||
if len(objs) == 1:
|
||||
return objs[0]
|
||||
|
||||
def new(self, *args, **kwargs):
|
||||
"""
|
||||
Instantiate the decorated class with the given `*args` and `**kwargs`, and register the instance in this registry.
|
||||
May be used multiple times.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@animal_sound_registry.new("meow")
|
||||
@animal_sound_registry.new("woof")
|
||||
class AnimalSound:
|
||||
def __init__(self, sound):
|
||||
# ...
|
||||
"""
|
||||
def reg(clz):
|
||||
obj = clz(*args, **kwargs)
|
||||
self.register(obj)
|
||||
return clz
|
||||
return reg
|
||||
|
||||
def get(self, **kwargs):
|
||||
(key, value), = kwargs.items()
|
||||
return self.by_key.get(key).get(value, (None, None))
|
||||
|
||||
def filter(self, **kwargs):
|
||||
return (
|
||||
(entry, meta)
|
||||
for entry, meta in self.registered_entries.items()
|
||||
if all(value == meta[key] for key, value in kwargs.items())
|
||||
)
|
||||
|
||||
|
||||
class EventPluginRegistry(Registry):
|
||||
"""
|
||||
A Registry which automatically annotates entries with a "plugin" key, specifying which plugin
|
||||
the entry is defined in. This allows the consumer of entries to determine whether an entry is
|
||||
enabled for a given event, or filter only for entries defined by enabled plugins.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
logtype, meta = my_registry.find(action_type="foo.bar.baz")
|
||||
# meta["plugin"] contains the django app name of the defining plugin
|
||||
"""
|
||||
|
||||
def __init__(self, keys):
|
||||
super().__init__({"plugin": lambda o: get_defining_app(o), **keys})
|
||||
|
||||
|
||||
event_live_issues = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to determine whether an event can be taken live. If you want to
|
||||
@@ -500,7 +367,7 @@ validate_cart_addons = EventPluginSignal()
|
||||
Arguments: ``addons``, ``base_position``, ``iao``
|
||||
|
||||
This signal is sent when a user tries to select a combination of addons. In contrast to
|
||||
``validate_cart``, this is executed before the cart is actually modified. You are passed
|
||||
``validate_cart``, this is executed before the cart is actually modified. You are passed
|
||||
an argument ``addons`` containing a dict of ``(item, variation or None) → count`` tuples as well
|
||||
as the ``ItemAddOn`` object as the argument ``iao`` and the base cart position as
|
||||
``base_position``.
|
||||
@@ -640,16 +507,41 @@ logentry_display = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``logentry``
|
||||
|
||||
**DEPRECTATION:** Please do not use this signal for new LogEntry types. Use the log_entry_types
|
||||
registry instead, as described in https://docs.pretix.eu/en/latest/development/implementation/logging.html
|
||||
To display an instance of the ``LogEntry`` model to a human user,
|
||||
``pretix.base.signals.logentry_display`` will be sent out with a ``logentry`` argument.
|
||||
|
||||
The first received response that is not ``None`` will be used to display the log entry
|
||||
to the user. The receivers are expected to return plain text.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
logentry_object_link = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``logentry``
|
||||
|
||||
**DEPRECTATION:** Please do not use this signal for new LogEntry types. Use the log_entry_types
|
||||
registry instead, as described in https://docs.pretix.eu/en/latest/development/implementation/logging.html
|
||||
To display the relationship of an instance of the ``LogEntry`` model to another model
|
||||
to a human user, ``pretix.base.signals.logentry_object_link`` will be sent out with a
|
||||
``logentry`` argument.
|
||||
|
||||
The first received response that is not ``None`` will be used to display the related object
|
||||
to the user. The receivers are expected to return a HTML link. The internal implementation
|
||||
builds the links like this::
|
||||
|
||||
a_text = _('Tax rule {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.settings.tax.edit', kwargs={
|
||||
'event': sender.slug,
|
||||
'organizer': sender.organizer.slug,
|
||||
'rule': logentry.content_object.id
|
||||
}),
|
||||
'val': escape(logentry.content_object.name),
|
||||
}
|
||||
a_map['val'] = '<a href="{href}">{val}</a>'.format_map(a_map)
|
||||
return a_text.format_map(a_map)
|
||||
|
||||
Make sure that any user content in the HTML code you return is properly escaped!
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
requiredaction_display = EventPluginSignal()
|
||||
|
||||
@@ -131,9 +131,6 @@
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
.content table td.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
a.button {
|
||||
display: inline-block;
|
||||
@@ -181,9 +178,6 @@
|
||||
pre, pre code {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.text-right, .content table td.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
{% if rtl %}
|
||||
body {
|
||||
@@ -192,9 +186,6 @@
|
||||
.content {
|
||||
text-align: right;
|
||||
}
|
||||
.text-right, .content table td.text-right {
|
||||
text-align: left;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
{% block addcss %}{% endblock %}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django import template
|
||||
from django.utils.html import format_html
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def icon(key, *args, **kwargs):
|
||||
return format_html(
|
||||
'<span class="fa fa-{} {}" aria-hidden="true"></span>',
|
||||
key,
|
||||
kwargs["class"] if "class" in kwargs else "",
|
||||
)
|
||||
@@ -52,12 +52,12 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
|
||||
# would make the numbers incorrect. If this branch executes, it's likely a bug in
|
||||
# pretix, but we won't show wrong numbers!
|
||||
if hide_currency:
|
||||
return floatformat(value, "2g")
|
||||
return floatformat(value, 2)
|
||||
else:
|
||||
return '{} {}'.format(arg, floatformat(value, "2g"))
|
||||
return '{} {}'.format(arg, floatformat(value, 2))
|
||||
|
||||
if hide_currency:
|
||||
return floatformat(value, f"{places}g")
|
||||
return floatformat(value, places)
|
||||
|
||||
locale_parts = translation.get_language().split("-", 1)
|
||||
locale = locale_parts[0]
|
||||
@@ -70,7 +70,7 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
|
||||
try:
|
||||
return format_currency(value, arg, locale=locale)
|
||||
except:
|
||||
return '{} {}'.format(arg, floatformat(value, f"{places}g"))
|
||||
return '{} {}'.format(arg, floatformat(value, places))
|
||||
|
||||
|
||||
@register.filter("money_numberfield")
|
||||
|
||||
@@ -54,7 +54,7 @@ from tlds import tld_set
|
||||
|
||||
register = template.Library()
|
||||
|
||||
ALLOWED_TAGS_SNIPPET = {
|
||||
ALLOWED_TAGS_SNIPPET = [
|
||||
'a',
|
||||
'abbr',
|
||||
'acronym',
|
||||
@@ -68,8 +68,8 @@ ALLOWED_TAGS_SNIPPET = {
|
||||
'strike',
|
||||
's',
|
||||
# Update doc/user/markdown.rst if you change this!
|
||||
}
|
||||
ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET | {
|
||||
]
|
||||
ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET + [
|
||||
'blockquote',
|
||||
'li',
|
||||
'ol',
|
||||
@@ -91,7 +91,7 @@ ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET | {
|
||||
'h6',
|
||||
'pre',
|
||||
# Update doc/user/markdown.rst if you change this!
|
||||
}
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
'a': ['href', 'title', 'class'],
|
||||
@@ -106,7 +106,7 @@ ALLOWED_ATTRIBUTES = {
|
||||
# Update doc/user/markdown.rst if you change this!
|
||||
}
|
||||
|
||||
ALLOWED_PROTOCOLS = {'http', 'https', 'mailto', 'tel'}
|
||||
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto', 'tel']
|
||||
|
||||
URL_RE = SimpleLazyObject(lambda: build_url_re(tlds=sorted(tld_set, key=len, reverse=True)))
|
||||
|
||||
@@ -211,9 +211,9 @@ class CleanPostprocessor(Postprocessor):
|
||||
def run(self, text):
|
||||
return bleach.clean(
|
||||
text,
|
||||
tags=set(self.tags),
|
||||
tags=self.tags,
|
||||
attributes=self.attributes,
|
||||
protocols=set(self.protocols),
|
||||
protocols=self.protocols,
|
||||
strip=self.strip
|
||||
)
|
||||
|
||||
@@ -305,11 +305,10 @@ def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes
|
||||
source,
|
||||
extensions=[
|
||||
'markdown.extensions.sane_lists',
|
||||
'markdown.extensions.tables',
|
||||
EmailNl2BrExtension(),
|
||||
LinkifyAndCleanExtension(
|
||||
linker,
|
||||
tags=set(allowed_tags),
|
||||
tags=allowed_tags,
|
||||
attributes=allowed_attributes,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
strip=False,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django import template
|
||||
from django.utils.html import format_html, mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def textbubble(type, *args, **kwargs):
|
||||
return format_html(
|
||||
'<span class="textbubble-{}">{}',
|
||||
type or "info",
|
||||
"" if "icon" not in kwargs else format_html(
|
||||
'<i class="fa fa-{}" aria-hidden="true"></i> ',
|
||||
kwargs["icon"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def endtextbubble():
|
||||
return mark_safe('</span>')
|
||||
@@ -22,30 +22,16 @@
|
||||
import pycountry
|
||||
from django.http import JsonResponse
|
||||
|
||||
from pretix.base.addressvalidation import (
|
||||
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
|
||||
)
|
||||
from pretix.base.models.tax import VAT_ID_COUNTRIES
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
|
||||
|
||||
def states(request):
|
||||
cc = request.GET.get("country", "DE")
|
||||
info = {
|
||||
'street': {'required': True},
|
||||
'zipcode': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
|
||||
'city': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
|
||||
'state': {'visible': cc in COUNTRIES_WITH_STATE_IN_ADDRESS, 'required': cc in COUNTRIES_WITH_STATE_IN_ADDRESS},
|
||||
'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False},
|
||||
}
|
||||
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
return JsonResponse({'data': [], **info, })
|
||||
return JsonResponse({'data': []})
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
return JsonResponse({
|
||||
'data': [
|
||||
{'name': s.name, 'code': s.code[3:]}
|
||||
for s in sorted(statelist, key=lambda s: s.name)
|
||||
],
|
||||
**info,
|
||||
})
|
||||
return JsonResponse({'data': [
|
||||
{'name': s.name, 'code': s.code[3:]}
|
||||
for s in sorted(statelist, key=lambda s: s.name)
|
||||
]})
|
||||
|
||||
@@ -36,7 +36,6 @@ import sys
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
from django.urls import Resolver404, get_script_prefix, resolve
|
||||
from django.utils.translation import get_language
|
||||
@@ -153,8 +152,6 @@ def _default_context(request):
|
||||
ctx['warning_update_available'] = True
|
||||
if not gs.settings.update_check_ack and 'runserver' not in sys.argv:
|
||||
ctx['warning_update_check_active'] = True
|
||||
if not cache.get('pretix_runperiodic_executed') and not settings.DEBUG:
|
||||
ctx['warning_cronjob'] = True
|
||||
|
||||
ctx['ie_deprecation_warning'] = 'MSIE' in request.headers.get('User-Agent', '') or 'Trident/' in request.headers.get('User-Agent', '')
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user