mirror of
https://github.com/pretix/pretix.git
synced 2026-11-12 01:37:07 +00:00
Compare commits
152
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a2d732925 | ||
|
|
ceff9de5ee | ||
|
|
38b467cef7 | ||
|
|
ae95df7ea2 | ||
|
|
d362f16417 | ||
|
|
7f1a0fd58f | ||
|
|
6d05cfea8b | ||
|
|
c1b8259075 | ||
|
|
e394054ae3 | ||
|
|
dda2a18fdf | ||
|
|
68bd182f79 | ||
|
|
2ee6ea7692 | ||
|
|
a37305f8f4 | ||
|
|
bd4b049c16 | ||
|
|
cb852a8fa7 | ||
|
|
86dd5619e0 | ||
|
|
b1a1d66156 | ||
|
|
e229697d2a | ||
|
|
f9c7378da7 | ||
|
|
86152a182a | ||
|
|
76899600b0 | ||
|
|
04a0b009a6 | ||
|
|
4e9d60d238 | ||
|
|
fe0d0ceb2e | ||
|
|
8457603555 | ||
|
|
ae35af596f | ||
|
|
47c3493372 | ||
|
|
85d65d17b4 | ||
|
|
e50c845e09 | ||
|
|
c84d9061da | ||
|
|
25c9659655 | ||
|
|
b6bce961c3 | ||
|
|
1de0455ebc | ||
|
|
9fdafa57de | ||
|
|
50bcfc8c74 | ||
|
|
fa552fe7d1 | ||
|
|
cbfc24dd64 | ||
|
|
6e0c2d69a5 | ||
|
|
34ed175abe | ||
|
|
a9aaf8638e | ||
|
|
a613affa05 | ||
|
|
cd0ed78b76 | ||
|
|
4889abd75c | ||
|
|
0a73faa8bd | ||
|
|
def5042661 | ||
|
|
e6f8f178af | ||
|
|
5f5380e8b1 | ||
|
|
ff102d561d | ||
|
|
1c7d0233fd | ||
|
|
936977972e | ||
|
|
46f2baa103 | ||
|
|
1308630f6c | ||
|
|
45bd124629 | ||
|
|
a1b9a557e0 | ||
|
|
1860e24f8c | ||
|
|
cfbc903595 | ||
|
|
9632f69edb | ||
|
|
87e3b33537 | ||
|
|
a4cbb58217 | ||
|
|
15527fc5de | ||
|
|
17fc69af18 | ||
|
|
da622e6752 | ||
|
|
b18dde855c | ||
|
|
9dce205e12 | ||
|
|
5d47ebd8b8 | ||
|
|
c550a35e62 | ||
|
|
76c69e785e | ||
|
|
59b207b756 | ||
|
|
c9f7bf8680 | ||
|
|
2fcb51bf2f | ||
|
|
6a762854e2 | ||
|
|
b29fb69790 | ||
|
|
c7a23da055 | ||
|
|
a3704c54e9 | ||
|
|
764d49482a | ||
|
|
e188796a81 | ||
|
|
1ca41269cd | ||
|
|
9581c08949 | ||
|
|
5393aff5f0 | ||
|
|
03e488ef20 | ||
|
|
619c68e51a | ||
|
|
10c1d5106d | ||
|
|
876566759d | ||
|
|
c6a0e06e87 | ||
|
|
39e4a6c9f7 | ||
|
|
431bd038fc | ||
|
|
2374a33424 | ||
|
|
463de6ae12 | ||
|
|
117b300c86 | ||
|
|
36d7f684c3 | ||
|
|
9ceab479a4 | ||
|
|
6229b4a848 | ||
|
|
16d0f17587 | ||
|
|
a1f012805a | ||
|
|
b5ae2ccb25 | ||
|
|
f196417005 | ||
|
|
090a73d02a | ||
|
|
e4b29f56a4 | ||
|
|
531758d444 | ||
|
|
b1ec585fc8 | ||
|
|
a076461eae | ||
|
|
8048859fb4 | ||
|
|
2e4e60db34 | ||
|
|
6b7f2ad684 | ||
|
|
54b249acf5 | ||
|
|
0cf2ba763b | ||
|
|
b44c3d707e | ||
|
|
45603c6624 | ||
|
|
ba36d649c6 | ||
|
|
ca90b2af09 | ||
|
|
fe43862fa4 | ||
|
|
411cd764a6 | ||
|
|
6618e3a039 | ||
|
|
b7cd97c33e | ||
|
|
eae3229e68 | ||
|
|
d291cd02e7 | ||
|
|
e5dcb50229 | ||
|
|
9d333bc2cf | ||
|
|
45038d07f3 | ||
|
|
00f0dfec88 | ||
|
|
1975a9d891 | ||
|
|
02b9b6941d | ||
|
|
da2ac6c386 | ||
|
|
b9fbe699ab | ||
|
|
8e3f3cc8c8 | ||
|
|
945f80b288 | ||
|
|
25799da70d | ||
|
|
6b01023d16 | ||
|
|
98644db976 | ||
|
|
814ae7f4d9 | ||
|
|
ae39a1e112 | ||
|
|
61b94f1128 | ||
|
|
dbb586ad5a | ||
|
|
295b0443fb | ||
|
|
31031225d3 | ||
|
|
d600115ea5 | ||
|
|
3d9ebb3072 | ||
|
|
c9667e77c3 | ||
|
|
b2b797f4d3 | ||
|
|
693adbc727 | ||
|
|
8d9840be8d | ||
|
|
73c0c61b6d | ||
|
|
a813269d1f | ||
|
|
d6bc1fa977 | ||
|
|
249edfa2c4 | ||
|
|
0c28151d30 | ||
|
|
5bb3b7e35e | ||
|
|
4119d04fba | ||
|
|
84858bc048 | ||
|
|
e8c091741b | ||
|
|
ebdfd21b0f | ||
|
|
ed0b3cab7f |
No files matched your search
@@ -173,7 +173,7 @@ Datetime String in ISO 8601 format ``"2017-12-27T10:00:00Z"``
|
||||
with timezone (normally UTC) ``"2017-12-27T10:00:00.596934Z"``,
|
||||
``"2017-12-27T10:00:00+02:00"``
|
||||
Date String in ISO 8601 format ``2017-12-27``
|
||||
Multi-lingual string Object of strings ``{"en": "red", "de": "rot", "de_Informal": "rot"}``
|
||||
Multi-lingual string Object of strings ``{"en": "red", "de": "rot", "de-informal": "rot"}``
|
||||
Money String with decimal number ``"23.42"``
|
||||
Currency String with ISO 4217 code ``"EUR"``, ``"USD"``
|
||||
Relative datetime *either* String in ISO 8601 ``"2017-12-27T10:00:00.596934Z"``,
|
||||
|
||||
@@ -56,7 +56,7 @@ calculated automatically.
|
||||
},
|
||||
"answers": [
|
||||
{
|
||||
"question": 1,
|
||||
"datafield": "A1B2C3D4",
|
||||
"answer": "23",
|
||||
"options": []
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
Cart positions
|
||||
==============
|
||||
|
||||
.. note::
|
||||
|
||||
This API is affected by an upcoming larger change to our API. The API documentation already reflects the future
|
||||
version. Features or fields not yet available are marked with **NEW**.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted pretix with
|
||||
version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
|
||||
The API provides limited access to the cart position data model. This API currently only allows creating and deleting
|
||||
cart positions to reserve quota.
|
||||
|
||||
@@ -31,12 +39,13 @@ voucher integer Internal ID of
|
||||
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
|
||||
is_bundled boolean If ``addon_to`` is set, this shows whether this is a bundled product or an addon product
|
||||
subevent integer ID of the date inside an event series this position belongs to (or ``null``)
|
||||
answers list of objects Answers to user-defined questions
|
||||
├ question integer Internal ID of the answered question
|
||||
answers list of objects Values of data fields (answers to user-defined questions)
|
||||
├ datafield string **NEW** The data field's ``identifier`` (internal reference)
|
||||
├ answer string Text representation of the answer
|
||||
├ question_identifier string The question's ``identifier`` field
|
||||
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
|
||||
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||
├ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||
├ question integer **Deprecated.** Internal ID of the data field
|
||||
└ question_identifier string **Read-only, deprecated.** The data field's ``identifier`` (internal reference)
|
||||
seat objects The assigned seat (or ``null``)
|
||||
├ id integer Internal ID of the seat instance
|
||||
├ name string Human-readable seat name
|
||||
@@ -199,7 +208,8 @@ Cart position endpoints
|
||||
* ``bundled`` (optional, expect a list of nested objects of cart positions)
|
||||
* ``answers``
|
||||
|
||||
* ``question``
|
||||
* ``datafield`` **NEW**
|
||||
* ``question`` **DEPRECATED**
|
||||
* ``answer``
|
||||
* ``options``
|
||||
|
||||
@@ -223,7 +233,7 @@ Cart position endpoints
|
||||
"attendee_email": null,
|
||||
"answers": [
|
||||
{
|
||||
"question": 1,
|
||||
"datafield": "A1B2C3D4",
|
||||
"answer": "23",
|
||||
"options": []
|
||||
}
|
||||
@@ -285,7 +295,7 @@ Cart position endpoints
|
||||
"attendee_email": null,
|
||||
"answers": [
|
||||
{
|
||||
"question": 1,
|
||||
"datafield": "A1B2C3D4",
|
||||
"answer": "23",
|
||||
"options": []
|
||||
}
|
||||
@@ -303,7 +313,7 @@ Cart position endpoints
|
||||
"attendee_email": null,
|
||||
"answers": [
|
||||
{
|
||||
"question": 1,
|
||||
"datafield": "A1B2C3D4",
|
||||
"answer": "23",
|
||||
"options": []
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
Check-in
|
||||
========
|
||||
|
||||
.. note::
|
||||
|
||||
This API is affected by an upcoming larger change to our API. The API documentation already reflects the future
|
||||
version. Features or fields not yet available are marked with **NEW**.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted pretix with
|
||||
version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
|
||||
This page describes special APIs built for ticket scanning apps. For managing check-in configuration or other operations,
|
||||
please also see :ref:`rest-checkinlists`. The check-in list API also contains endpoints to obtain statistics or log
|
||||
failed scans.
|
||||
@@ -24,41 +32,43 @@ Checking a ticket in
|
||||
passed needs to be from a distinct event.
|
||||
|
||||
:query string expand: Expand a field inside the ``position`` object into a full object. Currently ``subevent``, ``item``, ``variation``, and ``answers.question`` are supported. Can be passed multiple times.
|
||||
:<json string secret: Scanned QR code corresponding to the ``secret`` attribute of a ticket.
|
||||
:<json string source_type: Type of source the ``secret`` was obtained form. Defaults to ``"barcode"``.
|
||||
:<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
|
||||
:<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
|
||||
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
|
||||
:<json boolean force: Specifies that the check-in should succeed regardless of revoked barcode, previous check-ins or required
|
||||
:>json string secret: Scanned QR code corresponding to the ``secret`` attribute of a ticket.
|
||||
:>json string source_type: Type of source the ``secret`` was obtained form. Defaults to ``"barcode"``.
|
||||
:>json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
|
||||
:>json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
|
||||
:>json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
|
||||
:>json boolean force: Specifies that the check-in should succeed regardless of revoked barcode, previous check-ins or required
|
||||
questions that have not been filled. This is usually used to upload offline scans that already happened,
|
||||
because there's no point in validating them since they happened whether they are valid or not. Defaults to ``false``.
|
||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
:>json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
you do not implement question handling in your user interface, you **must**
|
||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||
to ``true``.
|
||||
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
|
||||
:>json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
|
||||
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in
|
||||
list.
|
||||
:<json object answers: If questions are supported/required, you may/must supply a mapping of question IDs to their
|
||||
respective answers. The answers should always be strings. In case of (multiple-)choice-type
|
||||
:>json object answers: If questions are supported/required, you may/must supply a mapping of ``identifier`` (`internal
|
||||
reference`) of data fields to their respective answers (**NEW**).
|
||||
For backwards compatibility, a mapping of data field ``id`` to answer is supported, as well.
|
||||
The answers should always be strings. In case of (multiple-)choice-type
|
||||
answers, the string should contain the (comma-separated) IDs of the selected options.
|
||||
:<json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending
|
||||
:>json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending
|
||||
this request twice with the same nonce, the second request will also succeed but will always
|
||||
create only one check-in object even when the previous request was successful as well. This
|
||||
allows for a certain level of idempotency and enables you to re-try after a connection failure.
|
||||
:<json string exchange_medium_type: To perform an exchange to a reusable medium, pass the type of the new reusable medium
|
||||
:<json string exchange_medium_identifier: To perform an exchange to a reusable media, pass the identifier of the new medium
|
||||
:<json boolean use_order_locale: Specifies that pretix should use the customer's language (``locale`` field from the
|
||||
:>json string exchange_medium_type: To perform an exchange to a reusable medium, pass the type of the new reusable medium
|
||||
:>json string exchange_medium_identifier: To perform an exchange to a reusable media, pass the identifier of the new medium
|
||||
:>json boolean use_order_locale: Specifies that pretix should use the customer's language (``locale`` field from the
|
||||
order) when building texts (currently only the ``reason_explanation`` response field).
|
||||
Defaults to ``false`` in which case the server will determine the language (currently
|
||||
the event default language, might change in the future with support for the
|
||||
``Accept-Language`` header).
|
||||
:<json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
|
||||
:>json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
|
||||
object will not reflect the simulated changes.
|
||||
:>json string status: ``"ok"``, ``"incomplete"``, ``"exchange"``, or ``"error"``
|
||||
:>json string reason: Reason code, only set on status ``"error"``, see below for possible values.
|
||||
:>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
|
||||
:>json object position: Copy of the matching order position (if any was found). The contents are the same as the
|
||||
:<json string status: ``"ok"``, ``"incomplete"``, ``"exchange"``, or ``"error"``
|
||||
:<json string reason: Reason code, only set on status ``"error"``, see below for possible values.
|
||||
:<json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
|
||||
:<json object position: Copy of the matching order position (if any was found). The contents are the same as the
|
||||
:ref:`order-position-resource`, with the following differences: (1) The ``checkins`` value
|
||||
will only include check-ins for the selected list. (2) An additional boolean property
|
||||
``require_attention`` will inform you whether either the order or the item have the
|
||||
@@ -66,13 +76,13 @@ Checking a ticket in
|
||||
back to values from a parent product or from invoice addresses. (4) Additional properties
|
||||
``order__status``, ``order__valid_if_pending``, ``order__require_approval``, and
|
||||
``order__locale`` are included with details form the order for convenience.
|
||||
:>json boolean require_attention: Whether or not the ``require_attention`` flag is set on the item or order.
|
||||
:>json list checkin_texts: List of additional texts to show to the user.
|
||||
:>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found),
|
||||
:<json boolean require_attention: Whether or not the ``require_attention`` flag is set on the item or order.
|
||||
:<json list checkin_texts: List of additional texts to show to the user.
|
||||
:<json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found),
|
||||
including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``.
|
||||
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
|
||||
:>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
|
||||
:>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
|
||||
:<json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
|
||||
:<json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
|
||||
:<json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -92,7 +102,7 @@ Checking a ticket in
|
||||
"datetime": null,
|
||||
"questions_supported": true,
|
||||
"answers": {
|
||||
"4": "XS"
|
||||
"A1B2C3D4": "XS"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +328,7 @@ Performing a ticket search
|
||||
],
|
||||
"answers": [
|
||||
{
|
||||
"datafield": "A1B2C3D4",
|
||||
"question": 12,
|
||||
"answer": "Foo",
|
||||
"options": []
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
Check-in lists
|
||||
==============
|
||||
|
||||
.. note::
|
||||
|
||||
This API is affected by an upcoming larger change to our API. The API documentation already reflects the future
|
||||
version. Features or fields not yet available are marked with **NEW**.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted pretix with
|
||||
version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
@@ -569,6 +577,7 @@ Order position endpoints
|
||||
],
|
||||
"answers": [
|
||||
{
|
||||
"datafield": "A1B2C3D4",
|
||||
"question": 12,
|
||||
"answer": "Foo",
|
||||
"options": []
|
||||
@@ -609,27 +618,29 @@ Order position endpoints
|
||||
:query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never
|
||||
as an ``id``. This should be always set if you are passing through untrusted, scanned
|
||||
data to avoid guessing of ticket IDs.
|
||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
:>json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
you do not implement question handling in your user interface, you **must**
|
||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||
to ``true``.
|
||||
:<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
|
||||
:>json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
|
||||
returned. Otherwise, canceled orders will return ``unpaid``. (**Deprecated**, in
|
||||
the future, this will be ignored and ``canceled`` may always be returned.)
|
||||
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
|
||||
:<json boolean force: Specifies that the check-in should succeed regardless of revoked barcode, previous check-ins or required
|
||||
:>json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
|
||||
:>json boolean force: Specifies that the check-in should succeed regardless of revoked barcode, previous check-ins or required
|
||||
questions that have not been filled. This is usually used to upload offline scans that already happened,
|
||||
because there's no point in validating them since they happened whether they are valid or not. Defaults to ``false``.
|
||||
:<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
|
||||
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
|
||||
:>json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
|
||||
:>json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
|
||||
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in
|
||||
list.
|
||||
:<json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending
|
||||
:>json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending
|
||||
this request twice with the same nonce, the second request will also succeed but will always
|
||||
create only one check-in object even when the previous request was successful as well. This
|
||||
allows for a certain level of idempotency and enables you to re-try after a connection failure.
|
||||
:<json object answers: If questions are supported/required, you may/must supply a mapping of question IDs to their
|
||||
respective answers. The answers should always be strings. In case of (multiple-)choice-type
|
||||
:>json object answers: If questions are supported/required, you may/must supply a mapping of ``identifier`` (`internal
|
||||
reference`) of data fields to their respective answers (**NEW**).
|
||||
For backwards compatibility, a mapping of data field ``id`` to answer is supported, as well.
|
||||
The answers should always be strings. In case of (multiple-)choice-type
|
||||
answers, the string should contain the (comma-separated) IDs of the selected options.
|
||||
|
||||
**Example request**:
|
||||
@@ -648,7 +659,7 @@ Order position endpoints
|
||||
"questions_supported": true,
|
||||
"canceled_supported": true,
|
||||
"answers": {
|
||||
"4": "XS"
|
||||
"A1B2C3D4": "XS"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
|
||||
.. _rest-datafieldoptions:
|
||||
|
||||
Data field options
|
||||
================
|
||||
|
||||
.. note::
|
||||
|
||||
This API is part of an upcoming larger change to our API. The API documentation already reflects the future
|
||||
version. Until the change, this resource is still available at ``/questions/(id)/options/``.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted pretix with
|
||||
version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
:ref:`Data fields <rest-datafields>` of type "choice" or "multiple choice" can have different options attached.
|
||||
|
||||
As with data fields, data field options exist on two resource paths, depending on whether
|
||||
the data field is attached to orders or order positions. Therefore, each endpoint below has two
|
||||
different URLs.
|
||||
|
||||
The options resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the option
|
||||
position integer An integer, used for sorting
|
||||
identifier string An arbitrary string that can be used for matching with
|
||||
other sources.
|
||||
answer multi-lingual string The displayed value of this option
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/datafields/position/(datafield)/options/
|
||||
/api/v1/organizers/(organizer)/events/(event)/datafields/order/(datafield)/options/
|
||||
|
||||
Returns a list of all options for a given data field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/datafields/position/11/options/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 2,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 1,
|
||||
"answer": {"en": "S"}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"identifier": "DFEMJWMJ",
|
||||
"position": 2,
|
||||
"answer": {"en": "M"}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"identifier": "W9AH7RDE",
|
||||
"position": 3,
|
||||
"answer": {"en": "L"}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query boolean active: If set to ``true`` or ``false``, only data fields with this value for the field ``active`` will be
|
||||
returned.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param datafield: The ``id`` field of the data field to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/data field does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/datafields/position/(datafield)/options/(id)/
|
||||
/api/v1/organizers/(organizer)/events/(event)/datafields/order/(datafield)/options/(id)/
|
||||
|
||||
Returns information on one option, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/datafields/position/1/options/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 1,
|
||||
"answer": {"en": "S"}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param datafield: The ``id`` field of the data field to fetch
|
||||
:param id: The ``id`` field of the option 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:post:: /api/v1/organizers/(organizer)/events/(event)/datafields/position/(datafield)/options/
|
||||
/api/v1/organizers/(organizer)/events/(event)/datafields/order/(datafield)/options/
|
||||
|
||||
Create a new option
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/datafields/position/1/options/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 1,
|
||||
"answer": {"en": "S"}
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 1,
|
||||
"answer": {"en": "S"}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/data field to create a option for
|
||||
:param event: The ``slug`` field of the event to create a option for
|
||||
:param datafield: The ``id`` field of the data field to create a option for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The option could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/datafields/position/(datafield)/options/(id)/
|
||||
/api/v1/organizers/(organizer)/events/(event)/datafields/order/(datafield)/options/(id)/
|
||||
|
||||
Update an option. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/datafields/position/1/options/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"position": 3
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 1,
|
||||
"answer": {"en": "S"}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the data field to modify
|
||||
:param id: The ``id`` field of the option to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The option could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/datafields/position/(datafield)/options/(id)/
|
||||
/api/v1/organizers/(organizer)/events/(event)/datafields/order/(datafield)/options/(id)/
|
||||
|
||||
Delete an option.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/datafields/position/1/options/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param datafield: The ``id`` field of the data field to modify
|
||||
:param id: The ``id`` field of the option to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
@@ -0,0 +1,428 @@
|
||||
|
||||
.. _rest-datafields:
|
||||
|
||||
Data fields
|
||||
=========
|
||||
|
||||
.. note::
|
||||
|
||||
This API is part of an upcoming larger change to our API. The API documentation already reflects the future
|
||||
version. Until the change, please keep using the endpoints at ``/questions/``.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted pretix with
|
||||
version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
Data fields define additional fields related to an order or an order position.
|
||||
By referencing data fields in a questionnaire, you can have customers or staff fill them out -- during or
|
||||
after checkout (web or POS), during check-in in the ticket scanner app, or in the backend.
|
||||
|
||||
There are two different resource paths for data fields, depending on whether the data field is attached
|
||||
to orders or order positions. Therefore, each endpoint below has two different URLs.
|
||||
|
||||
The data field resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the data field
|
||||
internal_name string **Required.** An internal field label
|
||||
identifier string An arbitrary string that can be used for matching with
|
||||
other sources.
|
||||
Must be unique per event, across all types of data fields.
|
||||
type string **Required.** The expected type of data. Valid options:
|
||||
|
||||
* ``N`` – number
|
||||
* ``S`` – one-line string
|
||||
* ``T`` – multi-line string
|
||||
* ``B`` – boolean
|
||||
* ``C`` – choice from a list
|
||||
* ``M`` – multiple choice from a list
|
||||
* ``F`` – file upload
|
||||
* ``D`` – date
|
||||
* ``H`` – time
|
||||
* ``W`` – date and time
|
||||
* ``CC`` – country code (ISO 3666-1 alpha-2)
|
||||
* ``TEL`` – telephone number
|
||||
show_during_checkin boolean If ``true``, the value of the data field will be shown
|
||||
during check-in (if the check-in client supports it).
|
||||
*Only supported on per-position data fields.*
|
||||
print_on_invoice boolean If ``true``, the data field will be shown on
|
||||
invoices.
|
||||
*Only supported on per-position data fields.*
|
||||
print_on_invoice_label multi-lingual string **Required if ``print_on_invoice`` is ``true``.** Label
|
||||
to print on the invoice before the value of this field.
|
||||
Otherwise `null`.
|
||||
options list of objects In case of data field type ``C`` or ``M``, this lists the
|
||||
available objects. Only writable during creation,
|
||||
use :ref:`separate endpoint <rest-datafieldoptions>` to modify this later.
|
||||
├ id integer Internal ID of the option
|
||||
├ position integer An integer, used for sorting
|
||||
├ identifier string An arbitrary string that can be used for matching with
|
||||
other sources.
|
||||
└ answer multi-lingual string The displayed value of this option
|
||||
valid_number_min string Minimum value for number fields (optional)
|
||||
valid_number_max string Maximum value for number fields (optional)
|
||||
valid_date_min date Minimum value for date fields (optional)
|
||||
valid_date_max date Maximum value for date fields (optional)
|
||||
valid_datetime_min datetime Minimum value for date and time fields (optional)
|
||||
valid_datetime_max datetime Maximum value for date and time fields (optional)
|
||||
valid_file_portrait boolean Turn on file validation for portrait photos
|
||||
valid_string_length_min integer Minimum length for string fields (optional)
|
||||
valid_string_length_max integer Maximum length for string fields (optional)
|
||||
referenced_by_questionnaires list of integers **Read-only.** List of questionnaire IDs that reference
|
||||
this data field
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/datafields/position/
|
||||
/api/v1/organizers/(organizer)/events/(event)/datafields/order/
|
||||
|
||||
Returns a list of all data fields within a given event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/datafields/position/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"internal_name": "T-Shirt size",
|
||||
"type": "C",
|
||||
"identifier": "WY3TP9SL",
|
||||
"show_during_checkin": false,
|
||||
"print_on_invoice": false,
|
||||
"print_on_invoice_label": null,
|
||||
"valid_number_min": null,
|
||||
"valid_number_max": null,
|
||||
"valid_date_min": null,
|
||||
"valid_date_max": null,
|
||||
"valid_datetime_min": null,
|
||||
"valid_datetime_max": null,
|
||||
"valid_string_length_min": null,
|
||||
"valid_string_length_max": null,
|
||||
"valid_file_portrait": false,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 0,
|
||||
"answer": {"en": "S"}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"identifier": "DFEMJWMJ",
|
||||
"position": 1,
|
||||
"answer": {"en": "M"}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"identifier": "W9AH7RDE",
|
||||
"position": 2,
|
||||
"answer": {"en": "L"}
|
||||
}
|
||||
],
|
||||
"referenced_by_questionnaires": [1]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
|
||||
Default: ``position``
|
||||
:query string identifier: Only return questions with the given identifier string
|
||||
:query boolean ask_during_checkin: Only return questions that are or are not to be asked during check-in
|
||||
:query boolean required: Only return questions that are or are not required to fill in
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event 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)/datafields/position/(id)/
|
||||
/api/v1/organizers/(organizer)/events/(event)/datafields/order/(id)/
|
||||
|
||||
Returns information on one data field, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/datafields/position/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"internal_name": "T-Shirt size",
|
||||
"type": "C",
|
||||
"identifier": "WY3TP9SL",
|
||||
"show_during_checkin": false,
|
||||
"print_on_invoice": false,
|
||||
"print_on_invoice_label": null,
|
||||
"valid_number_min": null,
|
||||
"valid_number_max": null,
|
||||
"valid_date_min": null,
|
||||
"valid_date_max": null,
|
||||
"valid_datetime_min": null,
|
||||
"valid_datetime_max": null,
|
||||
"valid_string_length_min": null,
|
||||
"valid_string_length_max": null,
|
||||
"valid_file_portrait": false,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 0,
|
||||
"answer": {"en": "S"}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"identifier": "DFEMJWMJ",
|
||||
"position": 1,
|
||||
"answer": {"en": "M"}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"identifier": "W9AH7RDE",
|
||||
"position": 2,
|
||||
"answer": {"en": "L"}
|
||||
}
|
||||
],
|
||||
"referenced_by_questionnaires": [1]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the question 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:post:: /api/v1/organizers/(organizer)/events/(event)/datafields/position/
|
||||
/api/v1/organizers/(organizer)/events/(event)/datafields/order/
|
||||
|
||||
Create a new question
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/datafields/position/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"internal_name": "T-Shirt size",
|
||||
"type": "C",
|
||||
"show_during_checkin": false,
|
||||
"print_on_invoice": false,
|
||||
"print_on_invoice_label": null,
|
||||
"options": [
|
||||
{
|
||||
"answer": {"en": "S"}
|
||||
},
|
||||
{
|
||||
"answer": {"en": "M"}
|
||||
},
|
||||
{
|
||||
"answer": {"en": "L"}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"internal_name": "T-Shirt size",
|
||||
"type": "C",
|
||||
"identifier": "WY3TP9SL",
|
||||
"show_during_checkin": false,
|
||||
"print_on_invoice": false,
|
||||
"print_on_invoice_label": null,
|
||||
"valid_number_min": null,
|
||||
"valid_number_max": null,
|
||||
"valid_date_min": null,
|
||||
"valid_date_max": null,
|
||||
"valid_datetime_min": null,
|
||||
"valid_datetime_max": null,
|
||||
"valid_string_length_min": null,
|
||||
"valid_string_length_max": null,
|
||||
"valid_file_portrait": false,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 0,
|
||||
"answer": {"en": "S"}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"identifier": "DFEMJWMJ",
|
||||
"position": 1,
|
||||
"answer": {"en": "M"}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"identifier": "W9AH7RDE",
|
||||
"position": 2,
|
||||
"answer": {"en": "L"}
|
||||
}
|
||||
],
|
||||
"referenced_by_questionnaires": [1]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create an item for
|
||||
:param event: The ``slug`` field of the event to create an item for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The item could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/datafields/position/(id)/
|
||||
/api/v1/organizers/(organizer)/events/(event)/datafields/order/(id)/
|
||||
|
||||
Update a data field. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``options`` field. If
|
||||
you need to update/delete options please use the nested dedicated endpoints.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/datafields/position/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"internal_name": "Clothing size"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"internal_name": "Clothing size",
|
||||
"type": "C",
|
||||
"identifier": "WY3TP9SL",
|
||||
"show_during_checkin": false,
|
||||
"print_on_invoice": false,
|
||||
"print_on_invoice_label": null,
|
||||
"valid_number_min": null,
|
||||
"valid_number_max": null,
|
||||
"valid_date_min": null,
|
||||
"valid_date_max": null,
|
||||
"valid_datetime_min": null,
|
||||
"valid_datetime_max": null,
|
||||
"valid_string_length_min": null,
|
||||
"valid_string_length_max": null,
|
||||
"valid_file_portrait": false,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 0,
|
||||
"answer": {"en": "S"}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"identifier": "DFEMJWMJ",
|
||||
"position": 1,
|
||||
"answer": {"en": "M"}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"identifier": "W9AH7RDE",
|
||||
"position": 2,
|
||||
"answer": {"en": "L"}
|
||||
}
|
||||
],
|
||||
"referenced_by_questionnaires": [1]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the question to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The question could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/datafields/position/(id)/
|
||||
/api/v1/organizers/(organizer)/events/(event)/datafields/order/(id)/
|
||||
|
||||
Delete a data field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/datafields/position/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the question to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ at :ref:`plugin-docs`.
|
||||
item_add-ons
|
||||
item_meta_properties
|
||||
item_program_times
|
||||
datafields
|
||||
datafield_options
|
||||
questionnaires
|
||||
questions
|
||||
question_options
|
||||
quotas
|
||||
|
||||
@@ -37,12 +37,9 @@ tax_rule integer The internal
|
||||
admission boolean ``true`` for items that grant admission to the event
|
||||
(such as primary tickets) and ``false`` for others
|
||||
(such as add-ons or merchandise).
|
||||
personalized boolean ``true`` for items that require personalization according
|
||||
personalized boolean **DEPRECATED.** ``true`` for items that require personalization according
|
||||
to event settings. Only affects system-level fields, not
|
||||
custom questions. Currently only allowed for products with
|
||||
``admission`` set to ``true``. For backwards compatibility,
|
||||
when creating new items and this field is not given, it defaults
|
||||
to the same value as ``admission``.
|
||||
custom questions. Defaults to ``false``.
|
||||
position integer An integer, used for sorting
|
||||
picture file A product picture to be displayed in the shop
|
||||
(can be ``null``).
|
||||
@@ -233,6 +230,17 @@ meta_data object Values set fo
|
||||
|
||||
The ``program_times`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2027.1
|
||||
|
||||
The ``personalized`` attribute has been deprecated.
|
||||
|
||||
For compatibility with existing applications:
|
||||
|
||||
* When retrieving products, `personalized` is `True` if any attached questionnaire asks for `attendee_name` and/or `attendee_email`.
|
||||
* When creating a new product, setting the `personalized` field to `True` will attach the "Attendee data" questionnaire to the new product. If no such questionnaire exists, it will be created.
|
||||
Before, the `personalized` field defaulted to the same value as `admission`. Now, it defaults to `false`.
|
||||
* When updating a product, the `personalized` field cannot be changed, returning an HTTP 400 error if attempted.
|
||||
|
||||
Notes
|
||||
-----
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
Orders
|
||||
======
|
||||
|
||||
.. note::
|
||||
|
||||
This API is affected by an upcoming larger change to our API. The API documentation already reflects the future
|
||||
version. Features or fields not yet available are marked with **NEW**.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted pretix with
|
||||
version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
|
||||
Order resource
|
||||
--------------
|
||||
|
||||
@@ -119,6 +127,11 @@ cancellation_date datetime Time of order c
|
||||
plugin_data object Additional data added by plugins.
|
||||
use_gift_cards list of strings List of unique gift card secrets that are used to pay
|
||||
for this order.
|
||||
answers list of objects **NEW** Values of data fields (answers to user-defined questions)
|
||||
├ datafield string The data field's ``identifier`` (internal reference)
|
||||
├ answer string Text representation of the answer
|
||||
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
|
||||
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -162,6 +175,10 @@ use_gift_cards list of strings List of unique
|
||||
|
||||
The ``use_gift_cards`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2027.1
|
||||
|
||||
The ``answers`` attribute has been added.
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
Order position resource
|
||||
@@ -231,12 +248,13 @@ print_logs list of objects List of print j
|
||||
downloads list of objects List of ticket download options
|
||||
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
|
||||
└ url string Download URL
|
||||
answers list of objects Answers to user-defined questions
|
||||
├ question integer Internal ID of the answered question
|
||||
├ answer string Text representation of the answer (URL if answer is a file)
|
||||
├ question_identifier string The question's ``identifier`` field
|
||||
answers list of objects Values of data fields (answers to user-defined questions)
|
||||
├ datafield string **NEW** The data field's ``identifier`` (internal reference)
|
||||
├ answer string Text representation of the answer
|
||||
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
|
||||
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||
├ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||
├ question integer **Deprecated.** Internal ID of the data field
|
||||
└ question_identifier string **Read-only, deprecated.** The data field's ``identifier`` (internal reference)
|
||||
seat objects The assigned seat. Can be ``null``.
|
||||
├ id integer Internal ID of the seat instance
|
||||
├ name string Human-readable seat name
|
||||
@@ -452,10 +470,11 @@ List of all orders
|
||||
],
|
||||
"answers": [
|
||||
{
|
||||
"datafield": "WY3TP9SL",
|
||||
"question": 12,
|
||||
"question_identifier": "WY3TP9SL",
|
||||
"answer": "Foo",
|
||||
"option_idenfiters": [],
|
||||
"option_identifiers": [],
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
@@ -488,7 +507,15 @@ List of all orders
|
||||
],
|
||||
"refunds": [],
|
||||
"cancellation_date": null,
|
||||
"plugin_data": {}
|
||||
"plugin_data": {},
|
||||
"answers": [
|
||||
{
|
||||
"datafield": "XYZ12345",
|
||||
"answer": "42",
|
||||
"option_identifiers": [],
|
||||
"options": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -699,6 +726,7 @@ Fetching individual orders
|
||||
],
|
||||
"answers": [
|
||||
{
|
||||
"datafield": "WY3TP9SL",
|
||||
"question": 12,
|
||||
"question_identifier": "WY3TP9SL",
|
||||
"answer": "Foo",
|
||||
@@ -735,7 +763,15 @@ Fetching individual orders
|
||||
],
|
||||
"refunds": [],
|
||||
"cancellation_date": null,
|
||||
"plugin_data": {}
|
||||
"plugin_data": {},
|
||||
"answers": [
|
||||
{
|
||||
"datafield": "XYZ12345",
|
||||
"answer": "42",
|
||||
"option_identifiers": [],
|
||||
"options": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -1079,7 +1115,8 @@ Creating orders
|
||||
* ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run)
|
||||
* ``answers``
|
||||
|
||||
* ``question``
|
||||
* ``datafield`` (**NEW**)
|
||||
* ``question`` (**DEPRECATED**)
|
||||
* ``answer``
|
||||
* ``options``
|
||||
|
||||
@@ -1114,6 +1151,12 @@ Creating orders
|
||||
order isn't completely paid after its creation with ``use_gift_cards``, then a subsequent request to the payment
|
||||
endpoint is needed.
|
||||
|
||||
* **NEW** ``answers`` (optional). Values for order-level data fields.
|
||||
|
||||
* ``datafield``
|
||||
* ``answer``
|
||||
* ``options``
|
||||
|
||||
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
||||
to incrementing integers starting with ``1``. Then, you can reference one of these
|
||||
IDs in the ``addon_to`` field of another position. Note that all add_ons for a specific position need to come
|
||||
@@ -1161,6 +1204,12 @@ Creating orders
|
||||
"internal_reference": "",
|
||||
"vat_id": ""
|
||||
},
|
||||
"answers": [
|
||||
{
|
||||
"datafield": "XYZ12345",
|
||||
"answer": "42"
|
||||
}
|
||||
],
|
||||
"positions": [
|
||||
{
|
||||
"positionid": 1,
|
||||
@@ -1174,7 +1223,7 @@ Creating orders
|
||||
"addon_to": null,
|
||||
"answers": [
|
||||
{
|
||||
"question": 1,
|
||||
"datafield": "WY3TP9SL",
|
||||
"answer": "23",
|
||||
"options": []
|
||||
}
|
||||
@@ -1684,6 +1733,7 @@ List of all order positions
|
||||
],
|
||||
"answers": [
|
||||
{
|
||||
"datafield": "WY3TP9SL",
|
||||
"question": 12,
|
||||
"question_identifier": "WY3TP9SL",
|
||||
"answer": "Foo",
|
||||
@@ -1863,6 +1913,7 @@ Fetching individual positions
|
||||
],
|
||||
"answers": [
|
||||
{
|
||||
"datafield": "WY3TP9SL",
|
||||
"question": 12,
|
||||
"question_identifier": "WY3TP9SL",
|
||||
"answer": "Foo",
|
||||
@@ -1971,7 +2022,7 @@ Manipulating individual positions
|
||||
|
||||
* ``answers``: If specified, you will need to provide **all** answers for this order position.
|
||||
Validation is handled the same way as when creating orders through the API. You are therefore
|
||||
expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier``
|
||||
expected to provide ``datafield``, ``answer``, and possibly ``options``. ``question`` is deprecated. ``question_identifier``
|
||||
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
|
||||
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
|
||||
|
||||
@@ -2067,7 +2118,7 @@ Manipulating individual positions
|
||||
* ``state``
|
||||
|
||||
* ``answers``: Validation is handled the same way as when creating orders through the API. You are therefore
|
||||
expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier``
|
||||
expected to provide ``datafield``, ``answer``, and possibly ``options``. ``question`` is deprecated. ``question_identifier``
|
||||
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
|
||||
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
|
||||
|
||||
|
||||
@@ -1,229 +1,7 @@
|
||||
Question options
|
||||
================
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
.. warning:: This resource is **deprecated**.
|
||||
|
||||
Questions of type "choice" or "multiple choice" can have different options attached.
|
||||
The options resource contains the following public fields:
|
||||
New applications must use the :ref:`rest-datafieldoptions` resource.
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the option
|
||||
position integer An integer, used for sorting
|
||||
identifier string An arbitrary string that can be used for matching with
|
||||
other sources.
|
||||
answer multi-lingual string The displayed value of this option
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/(question)/options/
|
||||
|
||||
Returns a list of all options for a given question.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/questions/11/options/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 2,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 1,
|
||||
"answer": {"en": "S"}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"identifier": "DFEMJWMJ",
|
||||
"position": 2,
|
||||
"answer": {"en": "M"}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"identifier": "W9AH7RDE",
|
||||
"position": 3,
|
||||
"answer": {"en": "L"}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query boolean active: If set to ``true`` or ``false``, only questions with this value for the field ``active`` will be
|
||||
returned.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param question: The ``id`` field of the question to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/question does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/(question)/options/(id)/
|
||||
|
||||
Returns information on one option, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/questions/1/options/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 1,
|
||||
"answer": {"en": "S"}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param question: The ``id`` field of the question to fetch
|
||||
:param id: The ``id`` field of the option 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:post:: /api/v1/organizers/(organizer)/events/(event)/questions/(question)/options/
|
||||
|
||||
Creates a new option
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/questions/1/options/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 1,
|
||||
"answer": {"en": "S"}
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 1,
|
||||
"answer": {"en": "S"}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/question to create a option for
|
||||
:param event: The ``slug`` field of the event to create a option for
|
||||
:param question: The ``id`` field of the question to create a option for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The option could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/questions/(question)/options/(id)/
|
||||
|
||||
Update an option. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/questions/1/options/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"position": 3
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 1,
|
||||
"answer": {"en": "S"}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the question to modify
|
||||
:param id: The ``id`` field of the option to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The option could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/questions/(id)/options/(id)/
|
||||
|
||||
Delete an option.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/questions/1/options/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the question to modify
|
||||
:param id: The ``id`` field of the option to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
@@ -0,0 +1,379 @@
|
||||
|
||||
.. _rest-questionnaires:
|
||||
|
||||
Questionnaires
|
||||
=========
|
||||
|
||||
.. note::
|
||||
|
||||
This API is part of an upcoming larger change to our API. The API documentation already reflects the future
|
||||
version. Until the change, please keep using the endpoints at ``/questions/``.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted pretix with
|
||||
version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
|
||||
Questionnaires are attached to one or more products, and a product can have multiple questionnaires.
|
||||
They consist of questionnaire rows, each of which can be one of the following:
|
||||
|
||||
Reference to system field
|
||||
To reference a system field, use one of the identifier strings from the table below in the row's `datafield` attribute.
|
||||
Currently, system fields are only defined on order positions, not on orders.
|
||||
|
||||
=============== =========================== ============================
|
||||
Container Identifier Label
|
||||
=============== =========================== ============================
|
||||
Order position `"@attendee_name_parts"` Attendee name
|
||||
Order position `"@attendee_email"` Attendee email
|
||||
Order position `"@company"` Company
|
||||
Order position `"@street"` Street
|
||||
Order position `"@zipcode"` ZIP code
|
||||
Order position `"@city"` City
|
||||
Order position `"@state"` State
|
||||
Order position `"@country"` Country
|
||||
=============== =========================== ============================
|
||||
|
||||
|
||||
Reference to user-defined data field
|
||||
To reference a :ref:`user-defined data field <rest-datafields>`, use its internal reference (`identifier`)
|
||||
in the row's `datafield` attribute.
|
||||
|
||||
|
||||
Static explanation text / subheading
|
||||
To add a static text block, set the row's `datafield` attribute to `null`. In this case, the text
|
||||
from the `label` attribute is displayed as a subheading, and the `help_text` attribute as a normal paragraph.
|
||||
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the questionnaire
|
||||
type string The type of questionnaire. Valid options:
|
||||
|
||||
* ``order_purchase`` — Order-wide, before purchase
|
||||
* ``position_purchase`` — Per product, before purchase
|
||||
* ``position_attendee`` — Per product, via attendee link
|
||||
* ``position_checkin`` — Per product, at check-in
|
||||
* ``position_hidden`` — Per product, hidden
|
||||
internal_name string Internal label for the questionnaire.
|
||||
items list of integers List of item IDs this questionnaire is assigned to.
|
||||
*Only supported on per-product questionnaires.*
|
||||
position integer An integer, used for sorting
|
||||
all_sales_channels boolean If ``true`` (default), the questions from this questionnaire
|
||||
are asked on all sales channels.
|
||||
limit_sales_channels list of strings List of sales channel identifiers the questions are asked on
|
||||
if ``all_sales_channels`` is ``false``.
|
||||
rows list of objects List of all questionnaire rows.
|
||||
├ datafield string Reference to a data field (see above)
|
||||
├ required boolean If ``true``, the question needs to be filled out.
|
||||
├ label multi-lingual string The field label shown to the customer. In case of a static
|
||||
text block, the subheading.
|
||||
├ help_text multi-lingual string The help text shown to the customer.
|
||||
├ dependency_datafield string ``datafield`` reference of a different row in the same
|
||||
questionnaire.
|
||||
The current question will only be shown if the question
|
||||
given in this attribute is set to one of the values given
|
||||
in ``dependency_values``.
|
||||
├ dependency_values list of strings If ``dependency_datafield`` is set to reference a boolean
|
||||
data field, this should be ``["True"]`` or ``["False"]``.
|
||||
Otherwise, it should be a list of ``identifier`` values
|
||||
of question options.
|
||||
├ rendered_help_text string **Read-only.** Markdown contents from help_text rendered
|
||||
to HTML
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questionnaires/
|
||||
|
||||
Returns a list of all questionnaires within a given event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/questionnaires/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 2,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "position_purchase",
|
||||
"internal_name": "Attendee data",
|
||||
"items": [1, 2],
|
||||
"position": 0,
|
||||
"all_sales_channels": true,
|
||||
"limit_sales_channels": [],
|
||||
"rows": [
|
||||
{
|
||||
"datafield": "@attendee_name_parts",
|
||||
"required": false,
|
||||
"label": {"en": "Attendee name"},
|
||||
"help_text": {},
|
||||
"dependency_datafield": null,
|
||||
"dependency_values": [],
|
||||
"rendered_help_text": ""
|
||||
},
|
||||
{
|
||||
"datafield": "@attendee_email",
|
||||
"required": false,
|
||||
"label": {"en": "Attendee email"},
|
||||
"help_text": {"en": "*We'll send you our program guide a few days before the event.*"},
|
||||
"dependency_datafield": null,
|
||||
"dependency_values": [],
|
||||
"rendered_help_text": "<em>We'll send you our program guide a few days before the event.</em>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "order_purchase",
|
||||
"internal_name": "Per-order questions",
|
||||
"items": [],
|
||||
"position": 0,
|
||||
"all_sales_channels": true,
|
||||
"limit_sales_channels": [],
|
||||
"rows": [
|
||||
{
|
||||
"datafield": "87Y8DCS9",
|
||||
"required": false,
|
||||
"label": {"en": "How did you find us?"},
|
||||
"help_text": {},
|
||||
"dependency_datafield": null,
|
||||
"dependency_values": [],
|
||||
"rendered_help_text": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
: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 ``id`` and ``position``.
|
||||
Default: ``position``
|
||||
:query string type: Only return questionnaires with the given type
|
||||
:query string items: Only return questionnaires associated with the given item (internal item ID)
|
||||
:query boolean sales_channel: Only return questionnaires visible on the given sales channel (sales channel identifier string)
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event 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)/questionnaires/(id)/
|
||||
|
||||
Returns information on one questionnaire, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/questionnaires/2/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"type": "order_purchase",
|
||||
"internal_name": "Per-order questions",
|
||||
"items": [],
|
||||
"position": 0,
|
||||
"all_sales_channels": true,
|
||||
"limit_sales_channels": [],
|
||||
"rows": [
|
||||
{
|
||||
"datafield": "87Y8DCS9",
|
||||
"required": false,
|
||||
"label": {"en": "How did you find us?"},
|
||||
"help_text": {},
|
||||
"dependency_datafield": null,
|
||||
"dependency_values": [],
|
||||
"rendered_help_text": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the questionnaire 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:post:: /api/v1/organizers/(organizer)/events/(event)/questionnaires/
|
||||
|
||||
Create a new questionnaire
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/questionnaires/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": "order_purchase",
|
||||
"internal_name": "Per-order questions",
|
||||
"position": 0,
|
||||
"all_sales_channels": true,
|
||||
"rows": [
|
||||
{
|
||||
"datafield": "87Y8DCS9",
|
||||
"required": false,
|
||||
"label": {"en": "How did you find us?"}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"type": "order_purchase",
|
||||
"internal_name": "Per-order questions",
|
||||
"items": [],
|
||||
"position": 0,
|
||||
"all_sales_channels": true,
|
||||
"limit_sales_channels": [],
|
||||
"rows": [
|
||||
{
|
||||
"datafield": "87Y8DCS9",
|
||||
"required": false,
|
||||
"label": {"en": "How did you find us?"},
|
||||
"help_text": {},
|
||||
"dependency_datafield": null,
|
||||
"dependency_values": [],
|
||||
"rendered_help_text": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create a questionnaire for
|
||||
:param event: The ``slug`` field of the event to create a questionnaire for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The questionnaire could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/questionnaires/(id)/
|
||||
|
||||
Update a questionnaire. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can only change all ``rows`` en bloc, or keep all of them unchanged in case of a ``PATCH``. If you
|
||||
change ``rows``, you need to specify all fields of the resource.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/questionnaires/2/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"internal_name": "Questions asked per order"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"type": "order_purchase",
|
||||
"internal_name": "Questions asked per order",
|
||||
"items": [],
|
||||
"position": 0,
|
||||
"all_sales_channels": true,
|
||||
"limit_sales_channels": [],
|
||||
"rows": [
|
||||
{
|
||||
"datafield": "87Y8DCS9",
|
||||
"required": false,
|
||||
"label": {"en": "How did you find us?"},
|
||||
"help_text": {},
|
||||
"dependency_datafield": null,
|
||||
"dependency_values": [],
|
||||
"rendered_help_text": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the questionnaire to modify
|
||||
:param event: The ``slug`` field of the event of the questionnaire to modify
|
||||
:param id: The ``id`` field of the questionnaire to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The questionnaire could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/questionnaires/(id)/
|
||||
|
||||
Delete a questionnaire.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/questionnaires/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the questionnaire to modify
|
||||
:param event: The ``slug`` field of the event of the questionnaire to modify
|
||||
:param id: The ``id`` field of the questionnaire to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
@@ -8,10 +8,23 @@
|
||||
Questions
|
||||
=========
|
||||
|
||||
.. warning:: This resource is **deprecated**. It is only provided for compatibility with existing applications
|
||||
and should not be used in new development.
|
||||
|
||||
New applications must use the :ref:`rest-datafields` and :ref:`rest-questionnaires` resources.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted pretix with
|
||||
version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
|
||||
This resource will continue to work in read-only mode on events where the
|
||||
:ref:`questionnaire configuration <rest-questionnaires>` is compatible with the old data model.
|
||||
If incompatible configuration is detected, all endpoints return HTTP 410.
|
||||
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
Questions define additional fields that need to be filled out by customers during checkout.
|
||||
Questions define additional fields related to an order position that need to be filled out by customers during checkout.
|
||||
The question resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
@@ -84,6 +97,10 @@ dependency_value string An old version
|
||||
|
||||
The ``show_during_checkin`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2026.[TODO]
|
||||
|
||||
The questions resource was deprecated and replaced by the :ref:`rest-datafields` and :ref:`rest-questionnaires` resources.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -249,6 +266,13 @@ Endpoints
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/questions/
|
||||
|
||||
.. warning:: This endpoint is **deprecated** and will be removed as part of an upcoming API change.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted
|
||||
pretix with version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
After the change, instead create a :ref:`data field <rest-datafields>` first, then add
|
||||
it to a :ref:`questionnaire <rest-questionnaires>`.
|
||||
|
||||
Creates a new question
|
||||
|
||||
**Example request**:
|
||||
@@ -351,6 +375,13 @@ Endpoints
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/questions/(id)/
|
||||
|
||||
.. warning:: This endpoint is **deprecated** and will be removed as part of an upcoming API change.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted
|
||||
pretix with version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
After the change, instead update the relevant :ref:`data field <rest-datafields>`, or
|
||||
the reference to it inside a :ref:`questionnaire <rest-questionnaires>`.
|
||||
|
||||
Update a question. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
@@ -437,6 +468,14 @@ Endpoints
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/questions/(id)/
|
||||
|
||||
|
||||
.. warning:: This endpoint is **deprecated** and will be removed as part of an upcoming API change.
|
||||
The new version is expected to be released for hosted pretix early December and for self-hosted
|
||||
pretix with version 2027.1.
|
||||
You can find more information `on our blog <https://pretix.eu/about/en/blog/20261110-questionnaires/>`_.
|
||||
After the change, insteadremove the questionnaire child from its :ref:`questionnaire <rest-questionnaires>`,
|
||||
or delete the relevant :ref:`data field <rest-datafields>`.
|
||||
|
||||
Delete a question.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -11,7 +11,8 @@ Core
|
||||
----
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification,
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter,
|
||||
register_notification_types, event_notification_sent, organizer_notification_sent,
|
||||
item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter,
|
||||
register_ticket_secret_generators, gift_card_transaction_display,
|
||||
register_text_placeholders, register_mail_placeholders, device_info_updated,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
sphinx==9.1.*
|
||||
sphinx==9.0.*
|
||||
sphinx-rtd-theme~=3.1.0
|
||||
sphinxcontrib-httpdomain~=2.0.0
|
||||
sphinxcontrib-httpdomain~=2.1.0
|
||||
sphinxcontrib-images~=1.0.1
|
||||
sphinxcontrib-jquery~=4.1
|
||||
sphinxcontrib-spelling~=8.0.2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-e ../
|
||||
sphinx==9.1.*
|
||||
sphinx==9.0.*
|
||||
sphinx-rtd-theme~=3.1.0
|
||||
sphinxcontrib-httpdomain~=2.0.0
|
||||
sphinxcontrib-httpdomain~=2.1.0
|
||||
sphinxcontrib-images~=1.0.1
|
||||
sphinxcontrib-jquery~=4.1
|
||||
sphinxcontrib-spelling~=8.0.2
|
||||
|
||||
Generated
+244
-310
@@ -9,8 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.30",
|
||||
"vue-slicksort": "^2.0.5"
|
||||
"vue": "^3.5.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -41,30 +40,30 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||
"version": "7.29.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
|
||||
"integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -74,13 +73,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -93,40 +92,6 @@
|
||||
"dev": true,
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
@@ -379,30 +344,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -442,13 +388,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
@@ -772,10 +718,27 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -790,9 +753,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -807,9 +770,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -824,9 +787,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -841,9 +804,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -858,9 +821,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -878,9 +841,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -898,9 +861,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -918,9 +881,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -938,9 +901,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -958,9 +921,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -978,9 +941,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -994,29 +957,10 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1031,9 +975,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1075,17 +1019,6 @@
|
||||
"eslint": "^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -1448,53 +1381,53 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
|
||||
"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz",
|
||||
"integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/shared": "3.5.30",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@vue/shared": "3.5.43",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
|
||||
"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz",
|
||||
"integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
"@vue/compiler-core": "3.5.43",
|
||||
"@vue/shared": "3.5.43"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
|
||||
"integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz",
|
||||
"integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/compiler-core": "3.5.30",
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-ssr": "3.5.30",
|
||||
"@vue/shared": "3.5.30",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@vue/compiler-core": "3.5.43",
|
||||
"@vue/compiler-dom": "3.5.43",
|
||||
"@vue/compiler-ssr": "3.5.43",
|
||||
"@vue/shared": "3.5.43",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss": "^8.5.28",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
|
||||
"integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz",
|
||||
"integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
"@vue/compiler-dom": "3.5.43",
|
||||
"@vue/shared": "3.5.43"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/eslint-config-typescript": {
|
||||
@@ -1538,53 +1471,51 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
|
||||
"integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz",
|
||||
"integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.30"
|
||||
"@vue/shared": "3.5.43"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
|
||||
"integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz",
|
||||
"integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
"@vue/reactivity": "3.5.43",
|
||||
"@vue/shared": "3.5.43"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
|
||||
"integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz",
|
||||
"integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.30",
|
||||
"@vue/runtime-core": "3.5.30",
|
||||
"@vue/shared": "3.5.30",
|
||||
"@vue/reactivity": "3.5.43",
|
||||
"@vue/runtime-core": "3.5.43",
|
||||
"@vue/shared": "3.5.43",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
|
||||
"integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz",
|
||||
"integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.30"
|
||||
"@vue/compiler-ssr": "3.5.43",
|
||||
"@vue/runtime-dom": "3.5.43",
|
||||
"@vue/shared": "3.5.43"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
|
||||
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz",
|
||||
"integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
@@ -2811,9 +2742,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
@@ -2827,23 +2758,23 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2862,9 +2793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2883,9 +2814,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2904,9 +2835,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2925,9 +2856,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2946,13 +2877,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2967,13 +2901,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2988,13 +2925,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3009,13 +2949,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3030,9 +2973,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3051,9 +2994,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3191,9 +3134,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3354,9 +3297,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3367,9 +3310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"version": "8.5.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
|
||||
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -3386,18 +3329,18 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
|
||||
"integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz",
|
||||
"integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3643,13 +3586,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@oxc-project/types": "=0.155.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3659,21 +3602,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||
"@rolldown/binding-android-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
@@ -4163,9 +4106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
|
||||
"integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
|
||||
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
@@ -4186,9 +4129,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4498,16 +4441,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4524,7 +4467,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -4593,16 +4536,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"version": "3.5.43",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz",
|
||||
"integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
"@vue/runtime-dom": "3.5.30",
|
||||
"@vue/server-renderer": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
"@vue/compiler-dom": "3.5.43",
|
||||
"@vue/compiler-sfc": "3.5.43",
|
||||
"@vue/runtime-dom": "3.5.43",
|
||||
"@vue/server-renderer": "3.5.43",
|
||||
"@vue/shared": "3.5.43"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
@@ -4650,15 +4593,6 @@
|
||||
"vue-eslint-parser": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-slicksort": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/vue-slicksort/-/vue-slicksort-2.0.5.tgz",
|
||||
"integrity": "sha512-fXz1YrNjhUbJK7o0tMk27mIr4pMAZYLSYvtmLazCtfpvz+zafPCn34ILDL8B7hT7WLVZKreYs6JVe5VWymqmzA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
+1
-2
@@ -27,8 +27,7 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.30",
|
||||
"vue-slicksort": "^2.0.5"
|
||||
"vue": "^3.5.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
+21
-24
@@ -27,26 +27,26 @@ classifiers = [
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab
|
||||
"arabic-reshaper==3.1.0", # Support for Arabic in reportlab
|
||||
"babel",
|
||||
"BeautifulSoup4==4.15.*",
|
||||
"bleach==6.4.*",
|
||||
"celery==5.6.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=50.0.1",
|
||||
"css-inline==0.21.*",
|
||||
"cryptography>=50.0.2",
|
||||
"css-inline==0.23.*",
|
||||
"defusedcsv>=3.0.0",
|
||||
"dnspython==2.*",
|
||||
"dnspython==2.9.*",
|
||||
"Django[argon2]==5.2.*,>=5.2.17",
|
||||
"django-bootstrap3==26.2",
|
||||
"django-bootstrap3==26.3",
|
||||
"django-compressor==4.6.0",
|
||||
"django-countries==9.1.*",
|
||||
"django-filter==26.1",
|
||||
"django-filter==26.2",
|
||||
"django-formset-js-improved==0.5.0.5",
|
||||
"django-formtools==2.7",
|
||||
"django-hierarkey==2.0.*,>=2.0.2",
|
||||
"django-hijack==3.7.*",
|
||||
"django-i18nfield==1.11.*",
|
||||
"django-i18nfield==1.12.*",
|
||||
"django-libsass==0.9",
|
||||
"django-localflavor==5.1",
|
||||
"django-markup",
|
||||
@@ -57,18 +57,15 @@ dependencies = [
|
||||
"django-redis==7.0.*",
|
||||
"django-scopes==2.1.*",
|
||||
"django-statici18n==2.8.*",
|
||||
"djangorestframework==3.17.*",
|
||||
"dnspython==2.8.*",
|
||||
"djangorestframework>=3.18.3",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==5.*",
|
||||
"importlib_metadata==9.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"isoweek",
|
||||
"jsonschema",
|
||||
"kombu==5.6.*",
|
||||
"libsass==0.23.*",
|
||||
"lxml",
|
||||
"markdown==3.11", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
||||
"markdown==3.11.2",
|
||||
"mt-940==4.30.*",
|
||||
"oauthlib==3.3.*",
|
||||
"openpyxl==3.1.*",
|
||||
@@ -77,28 +74,28 @@ dependencies = [
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.15.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==12.3.*",
|
||||
"Pillow==13.0.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==7.36.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==3.0",
|
||||
"pycryptodome==3.23.*",
|
||||
"pypdf==6.19.*",
|
||||
"pycparser==3.12",
|
||||
"pycryptodome==3.24.*",
|
||||
"pypdf==6.20.*",
|
||||
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.9.*",
|
||||
"python-dateutil==4.0.*",
|
||||
"pytz",
|
||||
"pytz-deprecation-shim==0.1.*",
|
||||
"pyuca",
|
||||
"qrcode==8.2",
|
||||
"redis==7.4.*",
|
||||
"reportlab==5.0.*",
|
||||
"reportlab==5.1.*",
|
||||
"requests==2.34.*",
|
||||
"sentry-sdk==2.70.*",
|
||||
"sentry-sdk==2.71.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2026072401",
|
||||
"tlds>=2026102900",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==1.0.*",
|
||||
"vobject==0.9.*",
|
||||
@@ -112,17 +109,17 @@ dev = [
|
||||
"aiohttp==3.14.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.38.*",
|
||||
"flake8==7.4.*",
|
||||
"fakeredis==2.40.*",
|
||||
"flake8==7.5.*",
|
||||
"freezegun",
|
||||
"isort==9.0.*",
|
||||
"pep8-naming==0.15.*",
|
||||
"potypo",
|
||||
"pytest-asyncio>=1.4.0",
|
||||
"pytest-asyncio>=1.4.1.post1",
|
||||
"pytest-cache",
|
||||
"pytest-cov",
|
||||
"pytest-django==4.*",
|
||||
"pytest-mock==3.15.*",
|
||||
"pytest-mock==3.17.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.8.*",
|
||||
"pytest-playwright",
|
||||
|
||||
@@ -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__ = "2026.9.0.dev0"
|
||||
__version__ = "2026.10.0.dev0"
|
||||
@@ -76,7 +76,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
|
||||
except DeviceLastSeen.DoesNotExist:
|
||||
# First request from device, create model, ignore result. Use get_or_create to be safe
|
||||
# against concurrent create requests
|
||||
DeviceLastSeen.objects.get_or_create(device=device, last_seen=now())
|
||||
DeviceLastSeen.objects.get_or_create(device=device, defaults={"last_seen": now()})
|
||||
else:
|
||||
if now() - last_seen_obj.last_seen < timedelta(seconds=10):
|
||||
# We don't need to know the last seen info of a device to more precision than this,
|
||||
|
||||
@@ -100,10 +100,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:itemcategory-list'),
|
||||
('GET', 'api-v1:item-list'),
|
||||
('GET', 'api-v1:question-list'),
|
||||
('GET', 'api-v1:compatquestion-list'),
|
||||
('GET', 'api-v1:datafield-list'),
|
||||
('GET', 'api-v1:questionnaire-list'),
|
||||
('GET', 'api-v1:questionnairechild-list'),
|
||||
('GET', 'api-v1:badgelayout-list'),
|
||||
('GET', 'api-v1:badgeitem-list'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
@@ -145,10 +141,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:itemcategory-list'),
|
||||
('GET', 'api-v1:item-list'),
|
||||
('GET', 'api-v1:question-list'),
|
||||
('GET', 'api-v1:compatquestion-list'),
|
||||
('GET', 'api-v1:datafield-list'),
|
||||
('GET', 'api-v1:questionnaire-list'),
|
||||
('GET', 'api-v1:questionnairechild-list'),
|
||||
('GET', 'api-v1:badgelayout-list'),
|
||||
('GET', 'api-v1:badgeitem-list'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
@@ -185,10 +177,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:itemcategory-list'),
|
||||
('GET', 'api-v1:item-list'),
|
||||
('GET', 'api-v1:question-list'),
|
||||
('GET', 'api-v1:compatquestion-list'),
|
||||
('GET', 'api-v1:datafield-list'),
|
||||
('GET', 'api-v1:questionnaire-list'),
|
||||
('GET', 'api-v1:questionnairechild-list'),
|
||||
('GET', 'api-v1:badgelayout-list'),
|
||||
('GET', 'api-v1:badgeitem-list'),
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
# 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/>.
|
||||
#
|
||||
def get_session_key_for_api_auth(user, auth):
|
||||
def get_session_key_for_api_auth(user, auth, prefix='api-upload'):
|
||||
if user.is_authenticated:
|
||||
return f'api-upload-User-{user.pk}'
|
||||
return f'{prefix}-User-{user.pk}'
|
||||
else:
|
||||
return f'api-upload-{str(type(auth))}-{auth.pk}'
|
||||
return f'{prefix}-{str(type(auth))}-{auth.pk}'
|
||||
|
||||
|
||||
def get_session_key_for_api_request(request):
|
||||
return get_session_key_for_api_auth(request.user, request.auth)
|
||||
def get_session_key_for_api_request(request, prefix='api-upload'):
|
||||
return get_session_key_for_api_auth(request.user, request.auth, prefix)
|
||||
@@ -1,80 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix 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 functools import reduce
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.exceptions import CompatException
|
||||
|
||||
|
||||
class MergeField(serializers.Field):
|
||||
"""
|
||||
Read-only field that iterates over a list of related objects, picks a value from each, and merges the values
|
||||
in a configurable way (mode parameter).
|
||||
|
||||
:param mode: list: returns a list of all picked values
|
||||
consensus: returns the value only if it is equal on all related objects, otherwise
|
||||
raises or returns an error object
|
||||
union: only supported if picked values are iterables - generates union of all sub-items
|
||||
bool_or: returns a boolean that is true if at least one picked value is truthy
|
||||
"""
|
||||
def __init__(self, related_name, inner_field, mode, **kwargs):
|
||||
self.inner_field = inner_field
|
||||
self.related_name = related_name
|
||||
self.merge_mode = mode
|
||||
assert mode in ('list', 'consensus', 'union', 'bool_or',)
|
||||
kwargs['source'] = '*'
|
||||
kwargs['read_only'] = True
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def bind(self, field_name, parent):
|
||||
self.field_name = field_name
|
||||
self.parent = parent
|
||||
self.inner_field.bind(field_name, parent)
|
||||
|
||||
def get_attribute(self, instance):
|
||||
vals = []
|
||||
for ref in getattr(instance, self.related_name).all():
|
||||
val = self.inner_field.get_attribute(ref)
|
||||
if val is not None:
|
||||
val = self.inner_field.to_representation(val)
|
||||
vals.append(val)
|
||||
if self.merge_mode == 'list':
|
||||
return vals
|
||||
elif self.merge_mode == 'consensus':
|
||||
if len(vals) >= 1 and all(val == vals[0] for val in vals):
|
||||
return vals[0]
|
||||
else:
|
||||
errmes = 'Questionnaire configuration incompatible with old data model due to conflicting values of ' + self.field_name
|
||||
if self.context['ignore_errors']:
|
||||
return {'_error': errmes, '_values': vals}
|
||||
else:
|
||||
raise CompatException({'detail': errmes, '_values': vals})
|
||||
elif self.merge_mode == 'union':
|
||||
return sorted(set(sum(vals, [])))
|
||||
elif self.merge_mode == 'bool_or':
|
||||
return reduce(lambda a, b: a or bool(b), vals, False)
|
||||
else:
|
||||
raise Exception('Unknown merge mode: ' + self.merge_mode)
|
||||
|
||||
def to_representation(self, value):
|
||||
return value
|
||||
@@ -1,28 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix 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 rest_framework.exceptions import APIException
|
||||
|
||||
|
||||
class CompatException(APIException):
|
||||
status = 410
|
||||
default_code = 'incompatible_data'
|
||||
|
||||
@@ -32,7 +32,6 @@ from rest_framework.exceptions import ValidationError
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
|
||||
MixedAnswerListSerializer,
|
||||
)
|
||||
from pretix.base.models import SalesChannel, Seat, Voucher
|
||||
from pretix.base.models.orders import CartPosition
|
||||
@@ -44,7 +43,7 @@ class TaxIncludedField(serializers.Field):
|
||||
|
||||
|
||||
class CartPositionSerializer(I18nAwareModelSerializer):
|
||||
answers = MixedAnswerListSerializer(child=AnswerSerializer(), source='*')
|
||||
answers = AnswerSerializer(many=True)
|
||||
seat = InlineSeatSerializer()
|
||||
includes_tax = TaxIncludedField(source='*')
|
||||
|
||||
@@ -56,7 +55,7 @@ class CartPositionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
|
||||
class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = MixedAnswerListSerializer(child=AnswerCreateSerializer(required=False), source='*', required=False)
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
includes_tax = serializers.BooleanField(required=False, allow_null=True)
|
||||
|
||||
@@ -194,7 +193,7 @@ class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options', [])
|
||||
options = answ_data.pop('options')
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = cp.answers.create(**answ_data, answer='')
|
||||
|
||||
@@ -35,10 +35,13 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.exceptions import (
|
||||
PermissionDenied, ValidationError as DjangoValidationError,
|
||||
)
|
||||
from django.db import transaction
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.html import strip_tags
|
||||
from django.utils.translation import gettext as _
|
||||
from django_countries.serializers import CountryFieldMixin
|
||||
from pytz import common_timezones
|
||||
@@ -218,8 +221,6 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
if self.instance is None:
|
||||
raise ValidationError(_('Events cannot be created as \'live\'. Quotas and payment must be added to the '
|
||||
'event before sales can go live.'))
|
||||
else:
|
||||
self.instance.clean_live()
|
||||
return value
|
||||
|
||||
@cached_property
|
||||
@@ -429,6 +430,14 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
event.set_active_plugins(plugins)
|
||||
event.save()
|
||||
|
||||
live = validated_data.get('live') or event.live
|
||||
if live:
|
||||
event.refresh_from_db()
|
||||
try:
|
||||
event.clean_live()
|
||||
except DjangoValidationError as e:
|
||||
raise ValidationError({'live': [strip_tags(m) for m in e.messages]})
|
||||
|
||||
return event
|
||||
|
||||
|
||||
@@ -764,6 +773,15 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'event_calendar_future_only',
|
||||
'frontpage_text',
|
||||
'event_info_text',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
'attendee_emails_asked',
|
||||
'attendee_emails_required',
|
||||
'attendee_addresses_asked',
|
||||
'attendee_addresses_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'attendee_data_explanation_text',
|
||||
'confirm_texts',
|
||||
'order_email_asked_twice',
|
||||
'order_phone_asked',
|
||||
@@ -932,6 +950,14 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'show_quota_left',
|
||||
'show_dates_on_frontpage',
|
||||
'max_items_per_order',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
'attendee_emails_asked',
|
||||
'attendee_emails_required',
|
||||
'attendee_addresses_asked',
|
||||
'attendee_addresses_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'ticket_download',
|
||||
'ticket_download_addons',
|
||||
'ticket_download_nonadm',
|
||||
@@ -962,6 +988,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'reusable_media_type_nfc_mf0aes',
|
||||
'reusable_media_type_nfc_mf0aes_random_uid',
|
||||
'reusable_media_usage_enforced',
|
||||
'system_question_order',
|
||||
'tax_rule_payment',
|
||||
'tax_rule_cancellation',
|
||||
]
|
||||
@@ -986,18 +1013,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
)
|
||||
)
|
||||
|
||||
# Provide fixed values for old configuration fields to ensure apps never use their built-in FakeQuestions, only the
|
||||
# ones we provide from POSCompatQuestionViewSet
|
||||
self.fields['system_question_order'] = serializers.JSONField(read_only=True, default=dict(), source='_invalid')
|
||||
self.fields['attendee_names_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
|
||||
self.fields['attendee_names_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
|
||||
self.fields['attendee_emails_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
|
||||
self.fields['attendee_emails_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
|
||||
self.fields['attendee_addresses_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
|
||||
self.fields['attendee_addresses_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
|
||||
self.fields['attendee_company_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
|
||||
self.fields['attendee_company_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
|
||||
|
||||
|
||||
class MultiLineStringField(serializers.Field):
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ from collections import OrderedDict
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
|
||||
from pretix.api.auth.utils import get_session_key_for_api_request
|
||||
|
||||
@@ -134,75 +133,3 @@ class PluginsField(serializers.Field):
|
||||
return {
|
||||
'plugins': data
|
||||
}
|
||||
|
||||
|
||||
class WritableSerializerMethodField(SerializerMethodField):
|
||||
"""
|
||||
A field that get its representation from calling a method on the
|
||||
parent serializer class. The method called will be of the form
|
||||
"get_{field_name}", and should take a single argument, which is the
|
||||
object being serialized.
|
||||
|
||||
When the field is written, a method of the form "put_{field_name}"
|
||||
is called, with the instance and the incoming data as arguments. It
|
||||
should return a dictionary of data to be update validated_data with.
|
||||
|
||||
For example:
|
||||
|
||||
class ExampleSerializer(Serializer):
|
||||
extra_info = WritableSerializerMethodField()
|
||||
|
||||
def get_extra_info(self, obj):
|
||||
return ... # Calculate some data to return.
|
||||
|
||||
def put_extra_info(self, obj, new_value):
|
||||
# Store new value
|
||||
"""
|
||||
|
||||
def __init__(self, method_name=None, put_method_name=None, **kwargs):
|
||||
self.put_method_name = put_method_name
|
||||
super().__init__(method_name, **kwargs)
|
||||
self.read_only = False
|
||||
|
||||
def bind(self, field_name, parent):
|
||||
# The method name defaults to `get_{field_name}`.
|
||||
if self.method_name is None:
|
||||
self.method_name = f'get_{field_name}'
|
||||
if self.put_method_name is None:
|
||||
self.put_method_name = f'put_{field_name}'
|
||||
|
||||
super().bind(field_name, parent)
|
||||
|
||||
def to_representation(self, value):
|
||||
method = getattr(self.parent, self.method_name)
|
||||
return method(value)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
method = getattr(self.parent, self.put_method_name)
|
||||
return method(self.parent.instance, data)
|
||||
|
||||
|
||||
class ContextAwareQueryMixin:
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
assert not kwargs.get('read_only', False), (
|
||||
'Scoped related fields are useless when read-only, '
|
||||
'use a regular RelatedField instead.'
|
||||
)
|
||||
assert callable(kwargs.get('queryset', None)), (
|
||||
'Scoped related fields must provide a callable `queryset`.'
|
||||
)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset(**self.context)
|
||||
|
||||
|
||||
class PrimaryKeyScopedRelatedField(ContextAwareQueryMixin, serializers.PrimaryKeyRelatedField):
|
||||
pass
|
||||
|
||||
|
||||
class SlugScopedRelatedField(ContextAwareQueryMixin, serializers.SlugRelatedField):
|
||||
pass
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import copy
|
||||
import logging
|
||||
|
||||
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
||||
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
#
|
||||
@@ -34,44 +33,26 @@ import logging
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
import os.path
|
||||
from decimal import Decimal
|
||||
from itertools import zip_longest
|
||||
|
||||
import rest_framework
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction, models
|
||||
from django.db import transaction
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.functional import cached_property, lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from i18nfield.rest_framework import I18nField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError as DrfValidationError
|
||||
from rest_framework.serializers import as_serializer_error
|
||||
|
||||
from pretix.api.compat import MergeField
|
||||
from pretix.api.serializers import (
|
||||
CompatDecimalField, SalesChannelMigrationMixin,
|
||||
)
|
||||
from pretix.api.serializers.event import MetaDataField
|
||||
from pretix.api.serializers.fields import PrimaryKeyScopedRelatedField, \
|
||||
UploadedFileField, \
|
||||
WritableSerializerMethodField
|
||||
from pretix.api.serializers.fields import UploadedFileField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.forms.questions import REQUIRED_NAME_PARTS
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime,
|
||||
ItemVariation, ItemVariationMetaValue, Question, QuestionAnswer,
|
||||
QuestionOption, Quota, SalesChannel,
|
||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||
SalesChannel,
|
||||
)
|
||||
from pretix.base.models.items import Questionnaire, QuestionnaireRow
|
||||
from pretix.base.settings import (
|
||||
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, LazyI18nStringList,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.helpers.i18n import i18n_all_from_gettext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
@@ -280,7 +261,9 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
ItemAddOn.clean_max_min_count(data.get('max_count'), data.get('min_count'))
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
ItemAddOn.clean_max_min_count(full_data.get('max_count'), full_data.get('min_count'))
|
||||
|
||||
return data
|
||||
|
||||
@@ -312,9 +295,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True, max_digits=7, decimal_places=4)
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
personalized = WritableSerializerMethodField(required=False)
|
||||
questionnaires = PrimaryKeyScopedRelatedField(many=True, allow_null=True, required=False,
|
||||
queryset=lambda event, **kw: event.questionnaires.filter(type__startswith='position_'))
|
||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
|
||||
@@ -342,7 +322,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
'grant_membership_duration_months', 'validity_mode', 'validity_fixed_from', 'validity_fixed_until',
|
||||
'validity_dynamic_duration_minutes', 'validity_dynamic_duration_hours', 'validity_dynamic_duration_days',
|
||||
'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit',
|
||||
'media_policy', 'media_type', 'questionnaires')
|
||||
'media_policy', 'media_type')
|
||||
read_only_fields = ('has_variations',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -355,36 +335,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
|
||||
self.fields['variations'].child.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
|
||||
|
||||
def get_personalized(self, item):
|
||||
if 'questionnaires' in getattr(item, '_prefetched_objects_cache', {}):
|
||||
if all(hasattr(q, 'has_attendee_data') for q in item.questionnaires.all()):
|
||||
return any(q.has_attendee_data for q in item.questionnaires.all())
|
||||
|
||||
return QuestionnaireRow.objects.filter(questionnaire__items=item, system_datafield__in=('@attendee_name_parts', '@attendee_email')).exists()
|
||||
|
||||
def put_personalized(self, item, new_value):
|
||||
if item and new_value != self.get_personalized(item):
|
||||
raise ValidationError({'personalized': 'Deprecated field "personalized" can not be updated.'})
|
||||
if new_value and not item:
|
||||
try:
|
||||
return {'questionnaires': [self._get_or_create_compat_personalization_questionnaire()]}
|
||||
except Questionnaire.DoesNotExist:
|
||||
raise ValidationError({'personalized': 'To create new products with personalized=True, a questionnaire named "Attendee data" must exist.'})
|
||||
return {}
|
||||
|
||||
def _get_or_create_compat_personalization_questionnaire(self):
|
||||
event = self.context['request'].event
|
||||
try:
|
||||
return event.questionnaires.get(type=Questionnaire.QuestionnaireType.POSITION_PURCHASE, compat_personalization=True)
|
||||
except Questionnaire.DoesNotExist:
|
||||
q = event.questionnaires.create(internal_name=str(_('Attendee data')), type=Questionnaire.QuestionnaireType.POSITION_PURCHASE, position=0, compat_personalization=True)
|
||||
system_datafields_asked = {df: (label, helptext, required) for df, label, helptext, required in QuestionnaireRow.objects.filter(
|
||||
questionnaire__event=event, system_datafield__isnull=False, questionnaire__type=Questionnaire.QuestionnaireType.POSITION_PURCHASE
|
||||
).values_list('system_datafield', 'label', 'help_text', 'required')}
|
||||
for i, (df, (label, helptext, required)) in enumerate(system_datafields_asked.items()):
|
||||
q.rows.create(label=label, help_text=helptext, required=required, system_datafield=df, position=i)
|
||||
return q
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data or 'program_times' in data):
|
||||
@@ -395,6 +345,15 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
Item.clean_available(data.get('available_from'), data.get('available_until'))
|
||||
Item.clean_media_settings(self.context['event'], data.get('media_policy'), data.get('media_type'), data.get('issue_giftcard'))
|
||||
|
||||
if data.get('personalized') and not data.get('admission'):
|
||||
raise ValidationError(_('Only admission products can currently be personalized.'))
|
||||
|
||||
if data.get('admission') and 'personalized' not in data and not self.instance:
|
||||
# Backwards compatibility
|
||||
data['personalized'] = True
|
||||
elif 'admission' in data and not data['admission']:
|
||||
data['personalized'] = False
|
||||
|
||||
if data.get('issue_giftcard'):
|
||||
if data.get('tax_rule') and data.get('tax_rule').rate > 0:
|
||||
raise ValidationError(
|
||||
@@ -419,7 +378,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
def validate_bundles(self, value):
|
||||
if not self.instance:
|
||||
for b_data in value:
|
||||
ItemBundle.clean_itemvar(self.context['event'], b_data['bundled_item'], b_data['bundled_variation'])
|
||||
ItemBundle.clean_itemvar(self.context['event'], b_data.get('bundled_item'), b_data.get('bundled_variation'))
|
||||
return value
|
||||
|
||||
def validate_addons(self, value):
|
||||
@@ -460,11 +419,9 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
picture = validated_data.pop('picture', None)
|
||||
require_membership_types = validated_data.pop('require_membership_types', [])
|
||||
limit_sales_channels = validated_data.pop('limit_sales_channels', [])
|
||||
questionnaires = validated_data.pop('questionnaires', [])
|
||||
item = Item.objects.create(**validated_data)
|
||||
if limit_sales_channels and not validated_data.get('all_sales_channels'):
|
||||
item.limit_sales_channels.add(*limit_sales_channels)
|
||||
item.questionnaires.add(*questionnaires)
|
||||
if picture:
|
||||
item.picture.save(os.path.basename(picture.name), picture)
|
||||
if require_membership_types:
|
||||
@@ -503,7 +460,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
value=value,
|
||||
item=item
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -576,151 +532,27 @@ class InlineQuestionOptionSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'identifier', 'answer', 'position')
|
||||
|
||||
|
||||
class DatafieldRelatedField(serializers.SlugRelatedField):
|
||||
default_error_messages = {
|
||||
'invalid_choice': _('"{input}" is not a valid choice.')
|
||||
}
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(slug_field='identifier', **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.context['event'].questions.all()
|
||||
|
||||
def to_representation(self, qc):
|
||||
if not qc:
|
||||
return None
|
||||
elif qc.system_datafield:
|
||||
return qc.system_datafield
|
||||
elif qc.user_datafield_id:
|
||||
return qc.user_datafield.identifier
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data is None:
|
||||
return {'user_datafield': None, 'system_datafield': None}
|
||||
elif type(data) == str and data.startswith('@'):
|
||||
if data not in QuestionnaireRow.SystemDatafield.values:
|
||||
self.fail("invalid_choice", input=data)
|
||||
return {'user_datafield': None, 'system_datafield': data}
|
||||
elif type(data) == str:
|
||||
return {'user_datafield': super().to_internal_value(data), 'system_datafield': None}
|
||||
else:
|
||||
self.fail('incorrect_type', data_type=type(data).__name__)
|
||||
|
||||
def use_pk_only_optimization(self):
|
||||
return self.source == '*'
|
||||
|
||||
|
||||
class LegacyDependencyValueField(serializers.CharField):
|
||||
|
||||
def to_representation(self, obj):
|
||||
return obj[0] if obj else None
|
||||
|
||||
|
||||
class QuestionFormSerializer(I18nAwareModelSerializer):
|
||||
datafield = DatafieldRelatedField(allow_null=True, source='*')
|
||||
question = I18nField(source='label', allow_null=True)
|
||||
type = serializers.CharField(source='user_datafield.type', allow_null=True, default=Question.FieldType.STRING)
|
||||
identifier = serializers.SerializerMethodField(read_only=True)
|
||||
show_during_checkin = serializers.BooleanField(source='user_datafield.show_during_checkin', allow_null=True, default=False)
|
||||
|
||||
print_on_invoice = serializers.BooleanField(source='user_datafield.print_on_invoice', allow_null=True, default=False)
|
||||
valid_number_min = serializers.IntegerField(source='user_datafield.valid_number_min', allow_null=True)
|
||||
valid_number_max = serializers.IntegerField(source='user_datafield.valid_number_max', allow_null=True)
|
||||
valid_date_min = serializers.DateField(source='user_datafield.valid_date_min', allow_null=True)
|
||||
valid_date_max = serializers.DateField(source='user_datafield.valid_date_max', allow_null=True)
|
||||
valid_datetime_min = serializers.DateTimeField(source='user_datafield.valid_datetime_min', allow_null=True)
|
||||
valid_datetime_max = serializers.DateTimeField(source='user_datafield.valid_datetime_max', allow_null=True)
|
||||
valid_string_length_max = serializers.IntegerField(source='user_datafield.valid_string_length_max', allow_null=True)
|
||||
valid_string_length_min = serializers.IntegerField(source='user_datafield.valid_string_length_min', allow_null=True)
|
||||
valid_file_portrait = serializers.BooleanField(source='user_datafield.valid_file_portrait', allow_null=True)
|
||||
|
||||
dependency_question = DatafieldRelatedField(allow_null=True, required=False)
|
||||
items = serializers.PrimaryKeyRelatedField(source='questionnaire.items', many=True, queryset=Item.objects.none(), allow_null=True, required=False)
|
||||
questionnaire_id = serializers.IntegerField(source='questionnaire.id', read_only=True)
|
||||
|
||||
options = InlineQuestionOptionSerializer(source='user_datafield.options', many=True, required=False, default=[])
|
||||
|
||||
class Meta:
|
||||
model = QuestionnaireRow
|
||||
fields = ('datafield', 'label', 'type', 'required', 'help_text', 'items', 'options', 'position',
|
||||
'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'print_on_invoice', 'valid_number_min',
|
||||
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
|
||||
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait', 'questionnaire_id')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['items'].child_relation.queryset = self.context['event'].items.all() if 'event' in self.context else Item.objects.none()
|
||||
|
||||
def get_attribute(self, instance):
|
||||
if isinstance(instance, QuestionAnswer):
|
||||
position = instance.cartposition if instance.cartposition_id else instance.orderposition
|
||||
print('searching ', repr(instance), repr(position), repr(instance.question), instance.pk, position.item_id, self.context) # TODO remove print
|
||||
qcs = list(QuestionnaireRow.objects.filter(user_datafield_id=instance.question_id, questionnaire__items__in=[position.item_id]))
|
||||
return next((qc for qc in qcs if qc.questionnaire.type == self.context.get('preferred_questionnaire_type', Questionnaire.QuestionnaireType.POSITION_PURCHASE)), qcs[0])
|
||||
|
||||
return super().get_attribute(instance)
|
||||
|
||||
def cook_dependency_question_id(self, qc):
|
||||
if qc.dependency_question:
|
||||
return qc.dependency_question.cooked_id
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_identifier(self, qc):
|
||||
return qc.user_datafield.identifier if qc.user_datafield else qc.system_datafield
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
results = super().data
|
||||
mangled_results = []
|
||||
pos_offset = 0
|
||||
for row in results:
|
||||
row['position'] += pos_offset
|
||||
if row['identifier'] == '@attendee_name_parts' and self.context.get('expand_attendee_name'):
|
||||
settings = self.context['event'].settings
|
||||
for k, v, w, *__ in PERSON_NAME_SCHEMES.get(settings.name_scheme)['fields']:
|
||||
part = dict(row)
|
||||
part["identifier"] += ":" + k
|
||||
part["question"] = i18n_all_from_gettext(v, settings.locales)
|
||||
if k == "title" and settings.name_scheme_titles:
|
||||
part["type"] = Question.FieldType.CHOICE
|
||||
part["options"] = [{"id": idx + 1, "identifier": k, "answer": i18n_all_from_gettext(k, settings.locales), "position": idx + 1} for idx, k in
|
||||
enumerate(PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1])]
|
||||
elif k == "salutation":
|
||||
part["type"] = Question.FieldType.CHOICE
|
||||
part["options"] = [{"id": idx + 1, "identifier": k, "answer": i18n_all_from_gettext(v, settings.locales), "position": idx + 1} for idx, (k, v) in
|
||||
enumerate(PERSON_NAME_SALUTATIONS)]
|
||||
part["id"] = QuestionnaireRow.cook_id_from_parts(row['questionnaire_id'], None, part["identifier"])
|
||||
part["required"] = part["required"] and k in REQUIRED_NAME_PARTS
|
||||
mangled_results.append(part)
|
||||
pos_offset += 1
|
||||
row["position"] += 1
|
||||
else:
|
||||
if row['identifier'] == '@country':
|
||||
row['type'] = Question.FieldType.COUNTRYCODE
|
||||
mangled_results.append(row)
|
||||
return mangled_results
|
||||
def to_internal_value(self, data):
|
||||
return [data] if data else []
|
||||
|
||||
|
||||
class DatafieldSerializer(I18nAwareModelSerializer):
|
||||
class QuestionSerializer(I18nAwareModelSerializer):
|
||||
options = InlineQuestionOptionSerializer(many=True, required=False)
|
||||
identifier = serializers.CharField(allow_null=True)
|
||||
|
||||
#referenced_by_questionnaires = MergeField('referenced_by', serializers.IntegerField(source='questionnaire.id'), mode='list')
|
||||
#questionnaire_types = MergeField('referenced_by', serializers.CharField(source='questionnaire.type'), mode='list')
|
||||
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ('id', 'internal_name', 'identifier', 'type',
|
||||
'show_during_checkin',
|
||||
'print_on_invoice', 'print_on_invoice_label', 'valid_number_min',
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
|
||||
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
|
||||
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait',
|
||||
#'referenced_by_questionnaires', 'questionnaire_types',
|
||||
'options')
|
||||
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait')
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
@@ -731,6 +563,14 @@ class DatafieldSerializer(I18nAwareModelSerializer):
|
||||
self.instance.clean_type_change(self.instance.type, value)
|
||||
return value
|
||||
|
||||
def validate_dependency_question(self, value):
|
||||
if value:
|
||||
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
|
||||
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
|
||||
if value == self.instance:
|
||||
raise ValidationError('A question cannot depend on itself.')
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if self.instance and 'options' in data:
|
||||
@@ -742,6 +582,21 @@ class DatafieldSerializer(I18nAwareModelSerializer):
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||
raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
dep = full_data.get('dependency_question')
|
||||
if dep:
|
||||
if dep.ask_during_checkin:
|
||||
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
|
||||
|
||||
seen_ids = {self.instance.pk} if self.instance else set()
|
||||
while dep:
|
||||
if dep.pk in seen_ids:
|
||||
raise ValidationError(_('Circular dependency between questions detected.'))
|
||||
seen_ids.add(dep.pk)
|
||||
dep = dep.dependency_question
|
||||
|
||||
if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||
raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||
|
||||
@@ -764,378 +619,15 @@ class DatafieldSerializer(I18nAwareModelSerializer):
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
options_data = validated_data.pop('options') if 'options' in validated_data else []
|
||||
items = validated_data.pop('items', [])
|
||||
|
||||
question = Question.objects.create(**validated_data, container_type=self.context['container_type'])
|
||||
question = Question.objects.create(**validated_data, container_type=Question.ContainerType.ORDERPOSITION)
|
||||
question.items.set(items)
|
||||
for opt_data in options_data:
|
||||
QuestionOption.objects.create(question=question, **opt_data)
|
||||
return question
|
||||
|
||||
|
||||
class CompatQuestionSerializer(DatafieldSerializer):
|
||||
question = MergeField('referenced_by', I18nField(source='label'), mode='consensus')
|
||||
required = MergeField('referenced_by', serializers.BooleanField(), mode='consensus')
|
||||
items = MergeField('referenced_by', serializers.PrimaryKeyRelatedField(source='questionnaire.items', many=True, queryset=Item.objects.none(), allow_null=True, required=False), mode='union')
|
||||
position = serializers.IntegerField(required=False, read_only=True, default=0)
|
||||
ask_during_checkin = MergeField('referenced_by', serializers.BooleanField(source='questionnaire.ask_during_checkin'), mode='bool_or')
|
||||
hidden = MergeField('referenced_by', serializers.BooleanField(source='questionnaire.hidden'), mode='bool_or')
|
||||
|
||||
dependency_question = MergeField('referenced_by', serializers.IntegerField(source='dependency_question.user_datafield_id', required=False, default=None, allow_null=True), mode='consensus')
|
||||
dependency_values = MergeField('referenced_by', serializers.JSONField(), mode='consensus')
|
||||
dependency_value = MergeField('referenced_by', LegacyDependencyValueField(source='dependency_values'), mode='consensus')
|
||||
help_text = MergeField('referenced_by', I18nField(), mode='consensus')
|
||||
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = (
|
||||
'id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
|
||||
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
|
||||
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait',
|
||||
# new
|
||||
#'internal_name', 'referenced_by_questionnaires', 'questionnaire_types',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.fields['items'].inner_field.child_relation.queryset = kwargs['context']['event'].items.all()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class POSCompatQuestionSerializer(QuestionFormSerializer):
|
||||
id = serializers.IntegerField(source='cooked_id', read_only=True)
|
||||
question = I18nField(source='label', allow_null=True)
|
||||
type = serializers.CharField(source='user_datafield.type', allow_null=True, default=Question.FieldType.STRING)
|
||||
identifier = serializers.SerializerMethodField(read_only=True)
|
||||
show_during_checkin = serializers.BooleanField(source='user_datafield.show_during_checkin', allow_null=True, default=False)
|
||||
ask_during_checkin = serializers.BooleanField(source='questionnaire.ask_during_checkin')
|
||||
hidden = serializers.BooleanField(source='questionnaire.hidden')
|
||||
print_on_invoice = serializers.BooleanField(source='user_datafield.print_on_invoice', allow_null=True, default=False)
|
||||
valid_number_min = serializers.IntegerField(source='user_datafield.valid_number_min', allow_null=True)
|
||||
valid_number_max = serializers.IntegerField(source='user_datafield.valid_number_max', allow_null=True)
|
||||
valid_date_min = serializers.DateField(source='user_datafield.valid_date_min', allow_null=True)
|
||||
valid_date_max = serializers.DateField(source='user_datafield.valid_date_max', allow_null=True)
|
||||
valid_datetime_min = serializers.DateTimeField(source='user_datafield.valid_datetime_min', allow_null=True)
|
||||
valid_datetime_max = serializers.DateTimeField(source='user_datafield.valid_datetime_max', allow_null=True)
|
||||
valid_string_length_max = serializers.IntegerField(source='user_datafield.valid_string_length_max', allow_null=True)
|
||||
valid_string_length_min = serializers.IntegerField(source='user_datafield.valid_string_length_min', allow_null=True)
|
||||
valid_file_portrait = serializers.BooleanField(source='user_datafield.valid_file_portrait', allow_null=True)
|
||||
dependency_question = serializers.SerializerMethodField('cook_dependency_question_id', read_only=True)
|
||||
dependency_value = serializers.SerializerMethodField('empty', read_only=True)
|
||||
position = serializers.SerializerMethodField(read_only=True)
|
||||
items = serializers.PrimaryKeyRelatedField(source='questionnaire.items', many=True, queryset=Item.objects.none(), allow_null=True, required=False)
|
||||
questionnaire_id = serializers.IntegerField(source='questionnaire.id', read_only=True)
|
||||
|
||||
options = InlineQuestionOptionSerializer(source='user_datafield.options', many=True, required=False, default=[])
|
||||
|
||||
class Meta:
|
||||
model = QuestionnaireRow
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
|
||||
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
|
||||
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait', 'questionnaire_id')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['items'].child_relation.queryset = self.context['event'].items.all() if 'event' in self.context else Item.objects.none()
|
||||
|
||||
def get_attribute(self, instance):
|
||||
if isinstance(instance, QuestionAnswer):
|
||||
position = instance.cartposition if instance.cartposition_id else instance.orderposition
|
||||
print('searching ', repr(instance), repr(position), repr(instance.question), instance.pk, position.item_id, self.context) # TODO remove print
|
||||
qcs = list(QuestionnaireRow.objects.filter(user_datafield_id=instance.question_id, questionnaire__items__in=[position.item_id]))
|
||||
return next((qc for qc in qcs if qc.questionnaire.type == self.context.get('preferred_questionnaire_type', Questionnaire.QuestionnaireType.POSITION_PURCHASE)), qcs[0])
|
||||
|
||||
return super().get_attribute(instance)
|
||||
|
||||
def cook_dependency_question_id(self, qc):
|
||||
if qc.dependency_question:
|
||||
return qc.dependency_question.cooked_id
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_identifier(self, qc):
|
||||
return qc.user_datafield.identifier if qc.user_datafield else qc.system_datafield
|
||||
|
||||
def empty(self, qc):
|
||||
return None
|
||||
|
||||
def get_position(self, qc):
|
||||
return qc.questionnaire.position * 1000000000 + qc.position
|
||||
|
||||
@staticmethod
|
||||
def mangle_results(event, results):
|
||||
mangled_results = []
|
||||
pos_offset = 0
|
||||
for row in results:
|
||||
row['position'] += pos_offset
|
||||
if row['identifier'] == '@attendee_name_parts':
|
||||
settings = event.settings
|
||||
for k, v, w, *__ in PERSON_NAME_SCHEMES.get(settings.name_scheme)['fields']:
|
||||
part = dict(row)
|
||||
part["identifier"] += ":" + k
|
||||
part["question"] = i18n_all_from_gettext(v, settings.locales)
|
||||
if k == "title" and settings.name_scheme_titles:
|
||||
part["type"] = Question.FieldType.CHOICE
|
||||
part["options"] = [{"id": idx + 1, "identifier": k, "answer": i18n_all_from_gettext(k, settings.locales), "position": idx + 1} for idx, k in
|
||||
enumerate(PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1])]
|
||||
elif k == "salutation":
|
||||
part["type"] = Question.FieldType.CHOICE
|
||||
part["options"] = [{"id": idx + 1, "identifier": k, "answer": i18n_all_from_gettext(v, settings.locales), "position": idx + 1} for idx, (k, v) in
|
||||
enumerate(PERSON_NAME_SALUTATIONS)]
|
||||
part["id"] = QuestionnaireRow.cook_id_from_parts(row['questionnaire_id'], None, part["identifier"])
|
||||
part["required"] = part["required"] and k in REQUIRED_NAME_PARTS
|
||||
mangled_results.append(part)
|
||||
pos_offset += 1
|
||||
row["position"] += 1
|
||||
else:
|
||||
if row['identifier'] == '@country':
|
||||
row['type'] = Question.FieldType.COUNTRYCODE
|
||||
mangled_results.append(row)
|
||||
return mangled_results
|
||||
|
||||
|
||||
class RenderedMarkdownField(serializers.CharField):
|
||||
def to_representation(self, value):
|
||||
return rich_text(value)
|
||||
|
||||
|
||||
class InlineUserDatafieldSerializer(I18nAwareModelSerializer):
|
||||
options = InlineQuestionOptionSerializer(many=True, required=False)
|
||||
identifier = serializers.CharField(allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ('id', 'internal_name', 'identifier', 'type',
|
||||
'show_during_checkin',
|
||||
'print_on_invoice', 'print_on_invoice_label', 'valid_number_min',
|
||||
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
|
||||
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait', 'options')
|
||||
|
||||
|
||||
class InlineDatafieldField(serializers.Field):
|
||||
def to_representation(self, value):
|
||||
if value.user_datafield:
|
||||
return DatafieldSerializer().to_representation(instance=value.user_datafield)
|
||||
elif value.system_datafield:
|
||||
part = {
|
||||
'show_during_checkin': False,
|
||||
'print_on_invoice': False,
|
||||
'print_on_invoice_label': {},
|
||||
'valid_number_min': None,
|
||||
'valid_number_max': None,
|
||||
'valid_date_min': None,
|
||||
'valid_date_max': None,
|
||||
'valid_datetime_min': None,
|
||||
'valid_datetime_max': None,
|
||||
'valid_string_length_max': None,
|
||||
'valid_string_length_min': None,
|
||||
'valid_file_portrait': False,
|
||||
'options': []
|
||||
}
|
||||
if value.system_datafield.startswith('@attendee_name_parts:'):
|
||||
settings = self.context['event'].settings
|
||||
part["type"] = "S"
|
||||
for fieldname, label, *__ in PERSON_NAME_SCHEMES.get(settings.name_scheme)['fields']:
|
||||
if fieldname != value.system_datafield.split(":")[1]:
|
||||
continue
|
||||
part["internal_name"] = str(QuestionnaireRow.SystemDatafield(value.system_datafield.split(":")[0]).label) + ": " + str(label)
|
||||
if fieldname == "title" and settings.name_scheme_titles:
|
||||
part["type"] = Question.FieldType.CHOICE
|
||||
part["options"] = [
|
||||
{"id": None, "identifier": k, "answer": i18n_all_from_gettext(k, settings.locales),
|
||||
"position": idx + 1} for idx, k in
|
||||
enumerate(PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1])
|
||||
]
|
||||
elif fieldname == "salutation":
|
||||
part["type"] = Question.FieldType.CHOICE
|
||||
part["options"] = [
|
||||
{"id": None, "identifier": k, "answer": i18n_all_from_gettext(v, settings.locales),
|
||||
"position": idx + 1} for idx, (k, v) in
|
||||
enumerate(PERSON_NAME_SALUTATIONS)
|
||||
]
|
||||
return {
|
||||
'id': None,
|
||||
'identifier': value.system_datafield,
|
||||
**part,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'id': None,
|
||||
'internal_name': str(QuestionnaireRow.SystemDatafield(value.system_datafield).label),
|
||||
'identifier': value.system_datafield,
|
||||
'type': QuestionnaireRow.SYSTEM_DATAFIELD_TYPES[value.system_datafield],
|
||||
**part,
|
||||
}
|
||||
|
||||
|
||||
class InlineQuestionnaireRowListSerializer(serializers.ListSerializer):
|
||||
def expanded_rows(self, data):
|
||||
for r in data:
|
||||
if r.system_datafield == '@attendee_name_parts':
|
||||
settings = self.context['event'].settings
|
||||
for k, v, w, *__ in PERSON_NAME_SCHEMES.get(settings.name_scheme)['fields']:
|
||||
copied = copy.copy(r)
|
||||
copied.system_datafield = r.system_datafield + ":" + k
|
||||
labels = LazyI18nString.from_gettext(v).data
|
||||
copied.label = LazyI18nString({
|
||||
locale: r.label.localize(locale) + ": " + labels[locale] for locale in r.label.data
|
||||
})
|
||||
copied.required = r.required and k in REQUIRED_NAME_PARTS
|
||||
yield copied
|
||||
else:
|
||||
yield r
|
||||
|
||||
def to_representation(self, data):
|
||||
iterable = data.all() if isinstance(data, models.manager.BaseManager) else data
|
||||
if self.context.get("expand_name_fields"):
|
||||
iterable = self.expanded_rows(iterable)
|
||||
|
||||
return [
|
||||
self.child.to_representation(item) for item in iterable
|
||||
]
|
||||
|
||||
|
||||
class InlineQuestionnaireRowSerializer(I18nAwareModelSerializer):
|
||||
datafield = DatafieldRelatedField(allow_null=True, source='*')
|
||||
dependency_datafield = DatafieldRelatedField(allow_null=True, required=False, source='dependency_question')
|
||||
rendered_help_text = RenderedMarkdownField(read_only=True, source='help_text')
|
||||
cooked_id = serializers.IntegerField(read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = QuestionnaireRow
|
||||
fields = ('datafield', 'required', 'label', 'help_text', 'dependency_datafield', 'dependency_values', 'rendered_help_text', 'cooked_id')
|
||||
list_serializer_class = InlineQuestionnaireRowListSerializer
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if "rows.datafield" in self.context.get("expand", {}):
|
||||
self.fields["datafield"] = InlineDatafieldField(read_only=True, source='*')
|
||||
|
||||
def validate_dependency_question(self, value):
|
||||
if value:
|
||||
if value['user_datafield']:
|
||||
if value['user_datafield'].type not in Question.TYPES_ALLOWED_AS_DEPENDENCIES:
|
||||
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
|
||||
return value
|
||||
|
||||
|
||||
class QuestionnaireSerializer(I18nAwareModelSerializer):
|
||||
items = serializers.PrimaryKeyRelatedField(many=True, queryset=Item.objects.none(), allow_null=True, required=False)
|
||||
limit_sales_channels = serializers.SlugRelatedField(
|
||||
slug_field="identifier",
|
||||
queryset=SalesChannel.objects.none(),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
many=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Questionnaire
|
||||
fields = ('id', 'type', 'internal_name', 'items', 'position', 'all_sales_channels', 'limit_sales_channels', 'compat_personalization', 'rows')
|
||||
read_only_fields = ('compat_personalization', )
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.fields['items'].child_relation.queryset = kwargs['context']['event'].items.all()
|
||||
self.fields['limit_sales_channels'].child_relation.queryset = kwargs['context']['event'].organizer.sales_channels.all()
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['rows'] = InlineQuestionnaireRowSerializer(
|
||||
many=True, required=True, context=kwargs['context'], partial=False
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
if self.context.get('expand_name_fields'):
|
||||
raise ValidationError("Expanding of name fields is not supported on write.")
|
||||
|
||||
try:
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
except rest_framework.exceptions.ValidationError as e:
|
||||
# we already have invalid state in the database, what should we do? hope it gets better after saving?
|
||||
logger.exception("Invalid state in database, ignoring some validations!")
|
||||
else:
|
||||
#if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||
# raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
#if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||
# raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||
|
||||
#if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED:
|
||||
# raise ValidationError(_('This type of question cannot be shown during check-in.'))
|
||||
|
||||
#Question.clean_items(event, full_data.get('items') or [])
|
||||
|
||||
if (full_data.get('type') == Questionnaire.QuestionnaireType.POSITION_CHECKIN
|
||||
and any(c.get('dependency_question') for c in full_data['rows'])):
|
||||
raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
if (not full_data.get('type').startswith('position_')
|
||||
and any(c['system_datafield'] for c in full_data['rows'])):
|
||||
raise ValidationError('System data fields are only supported on order positions.')
|
||||
|
||||
system_fields = set(c['system_datafield'] for c in full_data['rows'])
|
||||
if ('@zipcode' in system_fields or '@state' in system_fields or '@street' in system_fields or '@city' in system_fields) and '@country' not in system_fields:
|
||||
raise ValidationError('The system data fields "street", "zip code", "city", and "state" can only be used in combination with the "country" field.')
|
||||
|
||||
return data
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
rows_data = validated_data.pop('rows') if 'rows' in validated_data else []
|
||||
questionnaire = super().create(validated_data)
|
||||
self.set_rows(questionnaire, rows_data)
|
||||
try:
|
||||
Questionnaire.check_constraints(questionnaire.event)
|
||||
except ValidationError as e:
|
||||
raise DrfValidationError(as_serializer_error(e))
|
||||
return questionnaire
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
rows_data = validated_data.pop('rows', None)
|
||||
questionnaire = super().update(instance, validated_data)
|
||||
try:
|
||||
if rows_data is not None:
|
||||
self.set_rows(questionnaire, rows_data)
|
||||
Questionnaire.check_constraints(questionnaire.event)
|
||||
except ValidationError as e:
|
||||
raise DrfValidationError(as_serializer_error(e))
|
||||
return questionnaire
|
||||
|
||||
def set_rows(self, questionnaire, new_data):
|
||||
result = {}
|
||||
child_serializer = self.fields['rows'].child
|
||||
existing_rows = questionnaire.rows.all()
|
||||
def q(qc):
|
||||
return qc['user_datafield'] or qc['system_datafield']
|
||||
for i, d in enumerate(new_data):
|
||||
d['questionnaire'] = questionnaire
|
||||
d['position'] = i + 1
|
||||
d.setdefault('required', False)
|
||||
d.setdefault('help_text', None)
|
||||
d.setdefault('dependency_question', None)
|
||||
d.setdefault('dependency_values', None)
|
||||
for existing, update_data in zip_longest(existing_rows, new_data):
|
||||
if update_data:
|
||||
if update_data.get('dependency_question'):
|
||||
try:
|
||||
update_data['dependency_question'] = result[q(update_data['dependency_question'])]
|
||||
except KeyError:
|
||||
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
|
||||
if update_data['dependency_question'].datafield_type not in Question.TYPES_ALLOWED_AS_DEPENDENCIES:
|
||||
raise ValidationError('A question can only depend on a boolean or choice question.')
|
||||
if existing:
|
||||
result[q(update_data)] = child_serializer.update(existing, update_data)
|
||||
else:
|
||||
result[q(update_data)] = child_serializer.create(update_data)
|
||||
else:
|
||||
existing.delete()
|
||||
return result
|
||||
|
||||
|
||||
class QuotaSerializer(I18nAwareModelSerializer):
|
||||
available = serializers.BooleanField(read_only=True)
|
||||
available_number = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -38,19 +38,16 @@ from django_countries.fields import Country
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import get_attribute
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
from pretix.api.auth.utils import get_session_key_for_api_request
|
||||
from pretix.api.serializers import CompatDecimalField, CompatibleJSONField
|
||||
from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.fields import PrimaryKeyScopedRelatedField, SlugScopedRelatedField
|
||||
from pretix.api.serializers.forms import form_field_to_serializer_field
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.item import (
|
||||
InlineItemVariationSerializer, ItemSerializer, POSCompatQuestionSerializer,
|
||||
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
|
||||
)
|
||||
from pretix.api.signals import order_api_details, orderposition_api_details
|
||||
from pretix.base.decimal import round_decimal
|
||||
@@ -60,8 +57,8 @@ from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
|
||||
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
|
||||
QuestionAnswer, QuestionnaireRow, ReusableMedium, SalesChannel, Seat,
|
||||
SubEvent, TaxRule, Voucher,
|
||||
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
|
||||
Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
@@ -77,8 +74,7 @@ from pretix.base.services.pricing import (
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES,
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES,
|
||||
)
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
@@ -121,6 +117,7 @@ class TransmissionInfoSerializer(serializers.Serializer):
|
||||
super().__init__(*args, **kwargs)
|
||||
for k, v in transmission_type.invoice_address_form_fields.items():
|
||||
self.fields[k] = form_field_to_serializer_field(v)
|
||||
self.fields[k].required = False # invoice_address_form_fields_required is checked by parent serializer
|
||||
|
||||
|
||||
class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
@@ -214,19 +211,17 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class AnswerQuestionIdentifierField(serializers.Field):
|
||||
def to_representation(self, instance: QuestionAnswer):
|
||||
return instance.question.identifier
|
||||
|
||||
|
||||
class AnswerQuestionOptionsIdentifierField(serializers.Field):
|
||||
def to_representation(self, instance: QuestionAnswer):
|
||||
if isinstance(instance, WrappedModel) or instance.pk:
|
||||
return [o.identifier for o in instance.options.all()]
|
||||
return []
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if not isinstance(data, list):
|
||||
self.fail('invalid')
|
||||
if not all(isinstance(i, str) for i in data):
|
||||
self.fail('invalid')
|
||||
return {self.field_name: data}
|
||||
|
||||
|
||||
class InlineSeatSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -235,161 +230,9 @@ class InlineSeatSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'name', 'seat_guid', 'zone_name', 'row_name', 'row_label', 'seat_label', 'seat_number')
|
||||
|
||||
|
||||
class MixedAnswerListSerializer(ListSerializer):
|
||||
def __init__(self, instance=None, *args, **kwargs):
|
||||
self.parent_instance = instance
|
||||
super().__init__(instance.answers if instance else None, *args, **kwargs)
|
||||
|
||||
def get_value(self, dictionary):
|
||||
return dictionary
|
||||
|
||||
def to_representation(self, instance):
|
||||
try:
|
||||
answers_qs = get_attribute(instance, ['answers'])
|
||||
except (KeyError, AttributeError):
|
||||
return None
|
||||
answers = super().to_representation(answers_qs)
|
||||
answers_dict = {answer["question"]: answer for answer in answers}
|
||||
for qc in QuestionnaireRow.objects.filter(
|
||||
Q(system_datafield__isnull=False) | Q(user_datafield_id__isnull=False),
|
||||
questionnaire__items__id=instance.item_id
|
||||
):
|
||||
if qc.user_datafield_id:
|
||||
if answer := answers_dict.get(qc.user_datafield_id):
|
||||
answer = dict(answer)
|
||||
answer["question"] = qc.cooked_id
|
||||
answer["questionnaire_id"] = qc.questionnaire_id
|
||||
answers.append(answer)
|
||||
elif (sys_answer := instance.system_answers[qc.system_datafield]) is not None:
|
||||
if qc.system_datafield == QuestionnaireRow.SystemDatafield.ATTENDEE_NAME_PARTS:
|
||||
name_parts = instance.attendee_name_parts
|
||||
settings = self.context['event'].settings
|
||||
for k, v, w, *__ in PERSON_NAME_SCHEMES.get(settings.name_scheme)['fields']:
|
||||
if k in name_parts:
|
||||
options = []
|
||||
options_ident = []
|
||||
try:
|
||||
if k == "salutation":
|
||||
salutation_keys = [key for key, value in PERSON_NAME_SALUTATIONS]
|
||||
options = [salutation_keys.index(name_parts[k]) + 1]
|
||||
options_ident = [name_parts[k]]
|
||||
elif k == "title" and settings.name_scheme_titles:
|
||||
options = [PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1].index(name_parts[k]) + 1]
|
||||
options_ident = [name_parts[k]]
|
||||
except ValueError:
|
||||
pass
|
||||
answers.append({
|
||||
"question": QuestionnaireRow.cook_id_from_parts(qc.questionnaire_id, None, f'{qc.system_datafield}:{k}'),
|
||||
"answer": name_parts[k],
|
||||
"question_identifier": f'{qc.system_datafield}:{k}',
|
||||
"datafield": f'{qc.system_datafield}:{k}',
|
||||
"options": options,
|
||||
"option_identifiers": options_ident,
|
||||
"questionnaire_id": qc.questionnaire_id,
|
||||
})
|
||||
answers.append({
|
||||
"question": qc.cooked_id,
|
||||
"answer": sys_answer,
|
||||
"question_identifier": f'{qc.system_datafield}',
|
||||
"datafield": f'{qc.system_datafield}',
|
||||
"options": [],
|
||||
"option_identifiers": [],
|
||||
"questionnaire_id": qc.questionnaire_id,
|
||||
})
|
||||
return answers
|
||||
|
||||
def to_internal_value(self, data):
|
||||
result = {}
|
||||
if "answers" in data:
|
||||
answers = []
|
||||
attendee_name_parts = {}
|
||||
settings = self.context['event'].settings
|
||||
for answer in data["answers"]:
|
||||
if "question" in answer or ("datafield" in answer and answer["datafield"] in QuestionnaireRow.SYSTEM_DATAFIELD_NUMBERS):
|
||||
if "question" in answer:
|
||||
qid, dfid, sys_df = QuestionnaireRow.uncook_id(answer["question"])
|
||||
else:
|
||||
qid, dfid, sys_df = None, None, answer["datafield"]
|
||||
if dfid:
|
||||
answer["question"] = dfid
|
||||
answers.append(answer)
|
||||
elif sys_df == "@country":
|
||||
result['country'] = Country(answer["answer"])
|
||||
if not result['country'].name:
|
||||
raise ValidationError("Data field @country needs a valid country code.")
|
||||
elif sys_df == "@attendee_name_parts:salutation":
|
||||
valid_salutations = dict(PERSON_NAME_SALUTATIONS)
|
||||
if "options" in answer and len(answer["options"]) == 1 and answer["options"][0] <= len(PERSON_NAME_SALUTATIONS):
|
||||
attendee_name_parts['salutation'] = PERSON_NAME_SALUTATIONS[answer["options"][0] - 1][0]
|
||||
elif "option_identifiers" in answer and len(answer["option_identifiers"]) == 1 and answer["option_identifiers"][0] in valid_salutations:
|
||||
attendee_name_parts['salutation'] = answer["option_identifiers"][0]
|
||||
else:
|
||||
raise ValidationError("Data field @attendee_name_parts:salutation requires valid options")
|
||||
elif sys_df == "@attendee_name_parts:title" and settings.name_scheme_titles:
|
||||
valid_titles = PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1]
|
||||
if "options" in answer and len(answer["options"]) == 1 and answer["options"][0] <= len(valid_titles):
|
||||
attendee_name_parts['title'] = valid_titles[answer["options"][0] - 1]
|
||||
elif "option_identifiers" in answer and len(answer["option_identifiers"]) == 1 and answer["option_identifiers"][0] in valid_titles:
|
||||
attendee_name_parts['title'] = answer["option_identifiers"][0]
|
||||
else:
|
||||
raise ValidationError("Data field @attendee_name_parts:title requires valid options")
|
||||
elif sys_df.startswith("@attendee_name_parts:"):
|
||||
attendee_name_parts[sys_df[len("@attendee_name_parts:"):]] = answer["answer"]
|
||||
else:
|
||||
result[sys_df[1:]] = answer["answer"]
|
||||
else:
|
||||
answers.append(answer)
|
||||
result["answers"] = super().to_internal_value(answers)
|
||||
if attendee_name_parts:
|
||||
result["attendee_name_parts"] = attendee_name_parts
|
||||
return result
|
||||
|
||||
|
||||
class OrderAnswerListSerializer(ListSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
qs_seen = set()
|
||||
answercache = {
|
||||
a.question_id: a for a in instance.answers.all()
|
||||
}
|
||||
for answ_data in validated_data:
|
||||
if not answ_data.get('answer'):
|
||||
continue
|
||||
options = answ_data.pop('options', [])
|
||||
if answ_data['question'].pk in qs_seen:
|
||||
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
|
||||
if answ_data['question'].pk in answercache:
|
||||
a = answercache[answ_data['question'].pk]
|
||||
if isinstance(answ_data.get('answer'), File):
|
||||
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
|
||||
a.answer = 'file://' + a.file.name
|
||||
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
|
||||
pass # keep current file
|
||||
else:
|
||||
for attr, value in answ_data.items():
|
||||
setattr(a, attr, value)
|
||||
a.save()
|
||||
else:
|
||||
if isinstance(answ_data.get('answer'), File):
|
||||
an = answ_data.pop('answer')
|
||||
a = instance.answers.create(**answ_data, answer='')
|
||||
a.file.save(os.path.basename(an.name), an, save=False)
|
||||
a.answer = 'file://' + a.file.name
|
||||
a.save()
|
||||
else:
|
||||
a = instance.answers.create(**answ_data)
|
||||
a.options.set(options)
|
||||
qs_seen.add(a.question_id)
|
||||
for qid, a in answercache.items():
|
||||
if qid not in qs_seen:
|
||||
a.delete()
|
||||
|
||||
|
||||
class AnswerSerializer(I18nAwareModelSerializer):
|
||||
question = PrimaryKeyScopedRelatedField(queryset=lambda event, **kw: event.positiondatafields, required=False)
|
||||
datafield = SlugScopedRelatedField(slug_field='identifier', source='question', queryset=lambda event, **kw: event.positiondatafields, required=False)
|
||||
question_identifier = serializers.SlugRelatedField(slug_field='identifier', source='question', read_only=True)
|
||||
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', required=False)
|
||||
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
|
||||
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
r = super().to_representation(instance)
|
||||
@@ -400,18 +243,18 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
'pk': instance.orderposition.pk,
|
||||
'question': instance.question_id,
|
||||
}, request=self.context['request'])
|
||||
elif r['answer'].startswith('file://') and instance.order:
|
||||
r['answer'] = reverse('api-v1:order-answer', kwargs={
|
||||
'organizer': instance.order.event.organizer.slug,
|
||||
'event': instance.order.event.slug,
|
||||
'code': instance.order.code,
|
||||
'question': instance.question_id,
|
||||
}, request=self.context['request'])
|
||||
return r
|
||||
|
||||
class Meta:
|
||||
model = QuestionAnswer
|
||||
fields = ('datafield', 'answer', 'options', 'option_identifiers', 'question', 'question_identifier')
|
||||
fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers')
|
||||
|
||||
def validate_question(self, q):
|
||||
if q.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified question does not belong to this event.'
|
||||
)
|
||||
return q
|
||||
|
||||
def _handle_file_upload(self, data):
|
||||
if data['answer'] == 'file:keep':
|
||||
@@ -443,48 +286,34 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
|
||||
data['options'] = []
|
||||
data['answer'] = cf.file
|
||||
data.pop('option_identifiers', None)
|
||||
return data
|
||||
|
||||
def validate(self, data):
|
||||
if not data.get('question'):
|
||||
raise ValidationError('Data field not specified.')
|
||||
raise ValidationError('Question not specified.')
|
||||
elif data.get('question').type == Question.TYPE_FILE:
|
||||
return self._handle_file_upload(data)
|
||||
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
if not data.get('options') and not data.get("option_identifiers"):
|
||||
if not data.get('options'):
|
||||
raise ValidationError(
|
||||
'You need to specify either options or option_identifiers if the data field is of a choice type.'
|
||||
'You need to specify options if the question is of a choice type.'
|
||||
)
|
||||
if 'options' in data and "option_identifiers" in data:
|
||||
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1:
|
||||
raise ValidationError(
|
||||
'You need to specify either options or option_identifiers, not both.'
|
||||
'You can specify at most one option for this question.'
|
||||
)
|
||||
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options') or data.get('option_identifiers')) > 1:
|
||||
raise ValidationError(
|
||||
'You can specify at most one option for this data field.'
|
||||
)
|
||||
if 'options' in data:
|
||||
for o in data.get('options'):
|
||||
if o.question_id != data.get('question').pk:
|
||||
raise ValidationError({
|
||||
'options': 'The specified option {opt} does not belong to this data field.'.format(opt=o.pk)
|
||||
})
|
||||
data['answer'] = ", ".join([str(o) for o in data['options']])
|
||||
elif 'option_identifiers' in data:
|
||||
opt_cache = {o.identifier: o for o in data['question'].options.filter(identifier__in=data.get('option_identifiers'))}
|
||||
for o in data.get('option_identifiers'):
|
||||
if o not in opt_cache:
|
||||
raise ValidationError({
|
||||
'option_identifiers': 'The specified option "{opt}" does not belong to this data field.'.format(opt=o)
|
||||
})
|
||||
data['options'] = list(opt_cache.values())
|
||||
del data['option_identifiers']
|
||||
data['answer'] = ", ".join([str(o) for o in data['options']])
|
||||
for o in data.get('options'):
|
||||
if o.question_id != data.get('question').pk:
|
||||
raise ValidationError(
|
||||
'The specified option does not belong to this question.'
|
||||
)
|
||||
|
||||
data['answer'] = ", ".join([str(o) for o in data.get('options')])
|
||||
|
||||
else:
|
||||
if data.get('options') or data.get('option_identifiers'):
|
||||
if data.get('options'):
|
||||
raise ValidationError(
|
||||
'You should not specify options if the data field is not of a choice type.'
|
||||
'You should not specify options if the question is not of a choice type.'
|
||||
)
|
||||
|
||||
if data.get('question').type == Question.TYPE_BOOLEAN:
|
||||
@@ -494,7 +323,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
data['answer'] = 'False'
|
||||
else:
|
||||
raise ValidationError(
|
||||
'Please specify "true" or "false" for boolean data fields.'
|
||||
'Please specify "true" or "false" for boolean questions.'
|
||||
)
|
||||
elif data.get('question').type == Question.TYPE_NUMBER:
|
||||
serializers.DecimalField(
|
||||
@@ -507,20 +336,9 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
data['answer'] = serializers.TimeField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATETIME:
|
||||
data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer'))
|
||||
|
||||
data.pop('option_identifiers', None)
|
||||
return data
|
||||
|
||||
|
||||
class OrderAnswerSerializer(AnswerSerializer):
|
||||
datafield = SlugScopedRelatedField(slug_field='identifier', source='question', queryset=lambda event, **kw: event.orderdatafields, required=False)
|
||||
question = None
|
||||
|
||||
class Meta:
|
||||
model = QuestionAnswer
|
||||
fields = ('datafield', 'answer', 'options', 'option_identifiers')
|
||||
|
||||
|
||||
class InlineCheckinSerializer(I18nAwareModelSerializer):
|
||||
device_id = serializers.SlugRelatedField(
|
||||
source='device',
|
||||
@@ -774,7 +592,7 @@ class OrderPositionPluginDataField(serializers.Field):
|
||||
class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
checkins = InlineCheckinSerializer(many=True, read_only=True)
|
||||
print_logs = PrintLogSerializer(many=True, read_only=True)
|
||||
answers = MixedAnswerListSerializer(child=AnswerSerializer(), source='*')
|
||||
answers = AnswerSerializer(many=True)
|
||||
downloads = PositionDownloadsField(source='*', read_only=True)
|
||||
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
||||
pdf_data = PdfDataSerializer(source='*', read_only=True)
|
||||
@@ -875,7 +693,6 @@ class AttendeeNamePartsField(serializers.Field):
|
||||
|
||||
class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
require_attention = RequireAttentionField(source='*')
|
||||
answers = AnswerSerializer(many=True)
|
||||
attendee_name = AttendeeNameField(source='*')
|
||||
attendee_name_parts = AttendeeNamePartsField(source='*')
|
||||
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
|
||||
@@ -905,7 +722,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
self.fields['variation'] = InlineItemVariationSerializer(read_only=True, context=self.context)
|
||||
|
||||
if 'answers.question' in self.context['expand']:
|
||||
self.fields['answers'].child.fields['question'] = POSCompatQuestionSerializer(read_only=True) # TODO(questionnaires)
|
||||
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
|
||||
|
||||
if 'addons' in self.context['expand']:
|
||||
# Experimental feature, undocumented on purpose for now in case we need to remove it again
|
||||
@@ -1065,7 +882,6 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
required=False,
|
||||
)
|
||||
plugin_data = OrderPluginDataField(source='*', allow_null=True, read_only=True)
|
||||
answers = OrderAnswerListSerializer(child=OrderAnswerSerializer())
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
@@ -1075,7 +891,7 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
'payment_provider', 'fees', 'total', 'tax_rounding_mode', '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',
|
||||
'plugin_data', 'answers',
|
||||
'plugin_data',
|
||||
)
|
||||
read_only_fields = (
|
||||
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
|
||||
@@ -1159,10 +975,6 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
InvoiceAddress.objects.create(order=instance, **iadata)
|
||||
|
||||
order_answers = validated_data.pop('answers', None)
|
||||
if order_answers is not None:
|
||||
self.fields['answers'].update(instance, order_answers)
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
if attr in update_fields:
|
||||
setattr(instance, attr, value)
|
||||
@@ -1186,7 +998,6 @@ class SimulatedOrderPositionSerializer(OrderPositionSerializer):
|
||||
|
||||
|
||||
class SimulatedOrderSerializer(OrderSerializer):
|
||||
answers = SimulatedAnswerSerializer(many=True)
|
||||
positions = SimulatedOrderPositionSerializer(many=True, read_only=True)
|
||||
|
||||
|
||||
@@ -1231,7 +1042,7 @@ class OrderFeeCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
|
||||
class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = MixedAnswerListSerializer(child=AnswerCreateSerializer(required=False), source='*', required=False)
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
addon_to = serializers.IntegerField(required=False, allow_null=True)
|
||||
secret = serializers.CharField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
@@ -1408,7 +1219,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
|
||||
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
|
||||
use_gift_cards = serializers.ListField(child=serializers.CharField(required=False), required=False)
|
||||
answers = OrderAnswerListSerializer(child=OrderAnswerSerializer(required=False), required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1424,14 +1234,16 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
|
||||
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
|
||||
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode', 'use_gift_cards',
|
||||
'answers',)
|
||||
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode', 'use_gift_cards')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp is None:
|
||||
return None
|
||||
if pp not in self.context['event'].get_payment_providers():
|
||||
raise ValidationError('The given payment provider is not known.')
|
||||
if pp == "giftcard":
|
||||
raise serializers.ValidationError("Cannot create giftcard payment. Use `use_gift_cards` instead")
|
||||
|
||||
return pp
|
||||
|
||||
def validate_payment_info(self, info):
|
||||
@@ -1521,7 +1333,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
force = validated_data.pop('force', False)
|
||||
simulate = validated_data.pop('simulate', False)
|
||||
gift_card_secrets = validated_data.pop('use_gift_cards') if 'use_gift_cards' in validated_data else []
|
||||
order_answers = validated_data.pop('answers', None)
|
||||
|
||||
if (payment_provider is not None or payment_info != '{}') and len(gift_card_secrets) > 0:
|
||||
raise ValidationError({"use_gift_cards": ['The attribute use_gift_cards is not compatible with payment_provider or payment_info']})
|
||||
@@ -1784,17 +1595,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
order.invoice_address = ia
|
||||
ia.last_modified = now()
|
||||
|
||||
if simulate:
|
||||
answers = []
|
||||
for answ_data in (order_answers or []):
|
||||
options = answ_data.pop('options', [])
|
||||
answ = WrappedModel(QuestionAnswer(**answ_data))
|
||||
answ.options = WrappedList(options)
|
||||
answers.append(answ)
|
||||
order.answers = answers
|
||||
elif order_answers is not None:
|
||||
self.fields['answers'].update(order, order_answers)
|
||||
|
||||
# Generate position objects
|
||||
pos_map = {}
|
||||
for pos_data in positions_data:
|
||||
@@ -2164,10 +1964,15 @@ class OrderPaymentCreateSerializer(I18nAwareModelSerializer):
|
||||
model = OrderPayment
|
||||
fields = ('state', 'amount', 'payment_date', 'provider', 'info')
|
||||
|
||||
def validate_provider(self, value):
|
||||
if value == "giftcard":
|
||||
raise serializers.ValidationError("Cannot create giftcard payment")
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
order = OrderPayment(order=self.context['order'], **validated_data)
|
||||
order.save()
|
||||
return order
|
||||
payment = OrderPayment(order=self.context['order'], **validated_data)
|
||||
payment.save()
|
||||
return payment
|
||||
|
||||
|
||||
class OrderRefundCreateSerializer(I18nAwareModelSerializer):
|
||||
@@ -2179,6 +1984,12 @@ class OrderRefundCreateSerializer(I18nAwareModelSerializer):
|
||||
model = OrderRefund
|
||||
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info', 'comment')
|
||||
|
||||
def validate_provider(self, value):
|
||||
if value == "giftcard":
|
||||
raise serializers.ValidationError("Cannot create giftcard payment")
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
pid = validated_data.pop('payment', None)
|
||||
if pid:
|
||||
|
||||
@@ -30,8 +30,7 @@ from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.order import (
|
||||
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
|
||||
MixedAnswerListSerializer, OrderFeeCreateSerializer,
|
||||
OrderPositionCreateSerializer,
|
||||
OrderFeeCreateSerializer, OrderPositionCreateSerializer,
|
||||
)
|
||||
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
|
||||
from pretix.base.services.orders import OrderChangeManager, OrderError
|
||||
@@ -42,7 +41,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerializer):
|
||||
order = serializers.SlugRelatedField(slug_field='code', queryset=Order.objects.none(), required=True, allow_null=False)
|
||||
answers = MixedAnswerListSerializer(child=AnswerCreateSerializer(required=False), source='*', required=False)
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
addon_to = serializers.IntegerField(required=False, allow_null=True)
|
||||
secret = serializers.CharField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
@@ -155,7 +154,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
|
||||
|
||||
|
||||
class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
|
||||
answers = MixedAnswerListSerializer(child=AnswerSerializer(), source='*')
|
||||
answers = AnswerSerializer(many=True)
|
||||
country = CompatibleCountryField(source='*')
|
||||
attendee_name = serializers.CharField(required=False)
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.templatetags.static import static
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from i18nfield.rest_framework import I18nField
|
||||
@@ -42,10 +41,9 @@ from pretix.api.serializers.settings import SettingsSerializer
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.i18n import get_language_without_region
|
||||
from pretix.base.models import (
|
||||
Customer, Device, EventMetaProperty, GiftCard, GiftCardAcceptance,
|
||||
GiftCardTransaction, Membership, MembershipType, OrderPosition, Organizer,
|
||||
ReusableMedium, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite,
|
||||
User,
|
||||
Customer, Device, EventMetaProperty, GiftCard, GiftCardTransaction,
|
||||
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
|
||||
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
from pretix.base.permissions import (
|
||||
@@ -220,12 +218,10 @@ class FlexibleTicketRelatedField(serializers.PrimaryKeyRelatedField):
|
||||
|
||||
class SalesChannelSerializer(I18nAwareModelSerializer):
|
||||
type = serializers.CharField(default="api")
|
||||
icon_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = SalesChannel
|
||||
fields = ('identifier', 'type', 'label', 'position', 'icon', 'icon_url')
|
||||
read_only_fields = ('icon',)
|
||||
fields = ('identifier', 'type', 'label', 'position')
|
||||
|
||||
def validate_type(self, value):
|
||||
if (not self.instance or not self.instance.pk) and value != "api":
|
||||
@@ -249,10 +245,6 @@ class SalesChannelSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
return value
|
||||
|
||||
def get_icon_url(self, instance):
|
||||
if '.' in instance.icon:
|
||||
return static(instance.icon)
|
||||
|
||||
|
||||
class GiftCardSerializer(I18nAwareModelSerializer):
|
||||
value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00'))
|
||||
@@ -277,15 +269,7 @@ class GiftCardSerializer(I18nAwareModelSerializer):
|
||||
data = super().validate(data)
|
||||
if 'secret' in data:
|
||||
s = data['secret']
|
||||
qs = GiftCard.objects.filter(
|
||||
secret=s
|
||||
).filter(
|
||||
Q(issuer=self.context["organizer"]) |
|
||||
Q(issuer__in=GiftCardAcceptance.objects.filter(
|
||||
acceptor=self.context["organizer"],
|
||||
active=True,
|
||||
).values_list('issuer', flat=True))
|
||||
)
|
||||
qs = self.context["organizer"].accepted_gift_cards.filter(secret=s)
|
||||
if self.instance:
|
||||
qs = qs.exclude(pk=self.instance.pk)
|
||||
if qs.exists():
|
||||
|
||||
+4
-14
@@ -80,10 +80,7 @@ event_router.register(r'subevents', event.SubEventViewSet)
|
||||
event_router.register(r'clone', event.CloneEventViewSet)
|
||||
event_router.register(r'items', item.ItemViewSet)
|
||||
event_router.register(r'categories', item.ItemCategoryViewSet)
|
||||
event_router.register(r'questions', item.CompatQuestionViewSet, basename='compatquestion')
|
||||
event_router.register(r'datafields/position', item.PositionDatafieldViewSet, basename='positiondatafield')
|
||||
event_router.register(r'datafields/order', item.OrderDatafieldViewSet, basename='orderdatafield')
|
||||
event_router.register(r'questionnaires', item.QuestionnaireViewSet)
|
||||
event_router.register(r'questions', item.QuestionViewSet)
|
||||
event_router.register(r'discounts', discount.DiscountViewSet)
|
||||
event_router.register(r'quotas', item.QuotaViewSet)
|
||||
event_router.register(r'vouchers', voucher.VoucherViewSet)
|
||||
@@ -110,11 +107,8 @@ subevent_router.register(r'seats', event.SeatViewSet)
|
||||
checkinlist_router = routers.DefaultRouter()
|
||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
|
||||
|
||||
orderdatafield_router = routers.DefaultRouter()
|
||||
orderdatafield_router.register(r'options', item.OrderDatafieldOptionViewSet)
|
||||
|
||||
positiondatafield_router = routers.DefaultRouter()
|
||||
positiondatafield_router.register(r'options', item.PositionDatafieldOptionViewSet)
|
||||
question_router = routers.DefaultRouter()
|
||||
question_router.register(r'options', item.QuestionOptionViewSet)
|
||||
|
||||
item_router = routers.DefaultRouter()
|
||||
item_router.register(r'variations', item.ItemVariationViewSet)
|
||||
@@ -154,11 +148,7 @@ urlpatterns = [
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/teams/(?P<team>[^/]+)/', include(team_router.urls)),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/items/(?P<item>[^/]+)/', include(item_router.urls)),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/questions/(?P<question>[^/]+)/',
|
||||
include(positiondatafield_router.urls)),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/datafields/order/(?P<question>[^/]+)/',
|
||||
include(orderdatafield_router.urls)),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/datafields/position/(?P<question>[^/]+)/',
|
||||
include(positiondatafield_router.urls)),
|
||||
include(question_router.urls)),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
|
||||
include(checkinlist_router.urls)),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/orders/(?P<order>[^/]+)/', include(order_router.urls)),
|
||||
|
||||
@@ -55,7 +55,7 @@ from pretix.api.serializers.checkin import (
|
||||
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
|
||||
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
|
||||
)
|
||||
from pretix.api.serializers.item import POSCompatQuestionSerializer
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
CheckinListOrderPositionSerializer, CheckinSerializer,
|
||||
FailedCheckinSerializer,
|
||||
@@ -65,8 +65,7 @@ from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Question, Questionnaire, QuestionnaireRow, ReusableMedium,
|
||||
RevokedTicketSecret, TeamAPIToken,
|
||||
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.orders import PrintLog
|
||||
from pretix.base.permissions import AnyPermissionOf
|
||||
@@ -331,8 +330,6 @@ with scopes_disabled():
|
||||
|
||||
|
||||
def _handle_file_upload(data, user, auth):
|
||||
if not data:
|
||||
return None
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
file__isnull=False,
|
||||
@@ -492,7 +489,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'pdf_data': pdf_data and (
|
||||
user if user and user.is_authenticated else auth
|
||||
).has_event_permission(request.organizer, event, 'event.orders:read', request),
|
||||
'preferred_questionnaire_type': Questionnaire.QuestionnaireType.POSITION_CHECKIN,
|
||||
}
|
||||
|
||||
common_checkin_args = dict(
|
||||
@@ -801,19 +797,16 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
# 5. Pre-validate all incoming answers, handle file upload
|
||||
given_answers = {}
|
||||
if answers_data:
|
||||
for qc in QuestionnaireRow.objects.filter(
|
||||
questionnaire__items__in=[op.item], questionnaire__type=Questionnaire.QuestionnaireType.POSITION_CHECKIN
|
||||
):
|
||||
if qc.user_datafield_id and (str(qc.user_datafield_id) in answers_data or str(qc.cooked_id) in answers_data):
|
||||
for q in op.item.questions.filter(ask_during_checkin=True):
|
||||
if str(q.pk) in answers_data:
|
||||
try:
|
||||
try:
|
||||
ans = answers_data[str(qc.user_datafield_id)]
|
||||
except KeyError:
|
||||
ans = answers_data[str(qc.cooked_id)]
|
||||
if qc.user_datafield.type == Question.TYPE_FILE:
|
||||
given_answers[qc.user_datafield] = _handle_file_upload(ans, user, auth)
|
||||
if q.type == Question.TYPE_FILE:
|
||||
if answers_data[str(q.pk)]:
|
||||
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
|
||||
else:
|
||||
given_answers[q] = None
|
||||
else:
|
||||
given_answers[qc.user_datafield] = qc.clean_answer(ans)
|
||||
given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
|
||||
except (ValidationError, BaseValidationError):
|
||||
pass
|
||||
|
||||
@@ -874,13 +867,14 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
else:
|
||||
perform_checkin(**checkin_args)
|
||||
except RequiredQuestionsError as e:
|
||||
qs = POSCompatQuestionSerializer(context={'event': op.order.event})
|
||||
return Response({
|
||||
'status': 'incomplete',
|
||||
'require_attention': op.require_checkin_attention,
|
||||
'checkin_texts': op.checkin_texts,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
|
||||
'questions': POSCompatQuestionSerializer.mangle_results(op.order.event, (qs.to_representation(q) for q in e.questionnaire_rows)), # TODO(questionnaires)
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
],
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, status=400)
|
||||
except RequiredMediaExchangeError as e:
|
||||
@@ -979,7 +973,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
ctx['expand'] = self.request.query_params.getlist('expand')
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
|
||||
ctx['preferred_questionnaire_type'] = Questionnaire.QuestionnaireType.POSITION_CHECKIN
|
||||
return ctx
|
||||
|
||||
def get_filterset_kwargs(self):
|
||||
|
||||
@@ -75,7 +75,7 @@ class ExportersMixin:
|
||||
|
||||
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||
def download(self, *args, **kwargs):
|
||||
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
||||
cf = get_object_or_404(CachedFile, id=kwargs['cfid'], web_download=True)
|
||||
if not cf.allowed_for_session(self.request, "exporters-api"):
|
||||
return Response(
|
||||
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
|
||||
|
||||
+24
-379
@@ -32,43 +32,33 @@
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import logging
|
||||
from typing import Mapping
|
||||
|
||||
import django_filters
|
||||
from django.db import transaction
|
||||
from django.db.models import Q, Prefetch
|
||||
from django.db.models.expressions import Exists, OuterRef
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import generics, viewsets
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.exceptions import CompatException
|
||||
from pretix.api.pagination import TotalOrderingFilter
|
||||
from pretix.api.serializers.item import (
|
||||
CompatQuestionSerializer, DatafieldSerializer, ItemAddOnSerializer,
|
||||
ItemBundleSerializer, ItemCategorySerializer, ItemProgramTimeSerializer,
|
||||
ItemSerializer, ItemVariationSerializer, POSCompatQuestionSerializer,
|
||||
QuestionFormSerializer, QuestionnaireSerializer, QuestionOptionSerializer, QuotaSerializer,
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Item, ItemAddOn, ItemBundle, ItemCategory,
|
||||
ItemProgramTime, ItemVariation, Question, QuestionOption, Quota,
|
||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
|
||||
ItemVariation, Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.models.items import Questionnaire, QuestionnaireRow
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
from pretix.helpers.i18n import i18ncomp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
class ItemFilter(FilterSet):
|
||||
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
|
||||
@@ -117,12 +107,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
|
||||
'variations__meta_values', 'variations__meta_values__property',
|
||||
'require_membership_types', 'variations__require_membership_types',
|
||||
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times',
|
||||
Prefetch('questionnaires', Questionnaire.objects.annotate(
|
||||
has_attendee_data=Exists(QuestionnaireRow.objects.filter(
|
||||
questionnaire=OuterRef('pk'), system_datafield__in=('@attendee_name_parts', '@attendee_email')
|
||||
))
|
||||
)),
|
||||
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
|
||||
).all()
|
||||
|
||||
@transaction.atomic()
|
||||
@@ -473,282 +458,27 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
class DatafieldFilter(FilterSet):
|
||||
class QuestionFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ['identifier']
|
||||
fields = ['ask_during_checkin', 'required', 'identifier']
|
||||
|
||||
|
||||
class BaseDatafieldViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = DatafieldSerializer
|
||||
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = QuestionSerializer
|
||||
queryset = Question.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
filterset_class = DatafieldFilter
|
||||
ordering_fields = ('id')
|
||||
ordering = ('id')
|
||||
filterset_class = QuestionFilter
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = None
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.questions.filter(container_type=self.container_type).prefetch_related('options')
|
||||
|
||||
def get_object(self):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||
lookup_value = self.kwargs[lookup_url_kwarg]
|
||||
|
||||
try:
|
||||
numeric_id = int(lookup_value)
|
||||
except ValueError:
|
||||
obj = get_object_or_404(queryset, identifier=lookup_value)
|
||||
else:
|
||||
qid, dfid, sys_df = QuestionnaireRow.uncook_id(numeric_id)
|
||||
self.via_questionnaire_id = qid
|
||||
obj = get_object_or_404(queryset, pk=dfid)
|
||||
|
||||
# May raise a permission denied
|
||||
self.check_object_permissions(self.request, obj)
|
||||
|
||||
return obj
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['container_type'] = self.container_type
|
||||
return ctx
|
||||
|
||||
|
||||
class BaseWritableDatafieldViewSet(BaseDatafieldViewSet, viewsets.ModelViewSet):
|
||||
write_permission = 'event.items:write'
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.question.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.question.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
instance.log_action(
|
||||
'pretix.event.question.deleted',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class PositionDatafieldViewSet(ConditionalListView, BaseWritableDatafieldViewSet):
|
||||
container_type = Question.ContainerType.ORDERPOSITION
|
||||
|
||||
|
||||
class OrderDatafieldViewSet(ConditionalListView, BaseWritableDatafieldViewSet):
|
||||
container_type = Question.ContainerType.ORDER
|
||||
|
||||
|
||||
def ordered_union(*lists):
|
||||
from graphlib import TopologicalSorter
|
||||
items_set = {item for lst in lists for __ignore, item in lst}
|
||||
dependencies = {item: set() for item in items_set}
|
||||
for lst in lists:
|
||||
for i in range(1, len(lst)):
|
||||
current_item = lst[i][1]
|
||||
previous_item = lst[i - 1][1]
|
||||
dependencies[current_item].add(previous_item)
|
||||
ts = TopologicalSorter(dependencies)
|
||||
try:
|
||||
return list(ts.static_order())
|
||||
except ValueError:
|
||||
raise CompatException("Questionnaire configuration incompatible with old data model due to conflicting field orders.")
|
||||
|
||||
|
||||
class DeprecationWarningMixin:
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
if self.deprecation_warning and isinstance(getattr(response, 'data', None), Mapping) and not 'x-deprecated' in response.headers:
|
||||
response.data = {
|
||||
'deprecation_warning': self.deprecation_warning,
|
||||
**response.data
|
||||
}
|
||||
response.headers['x-deprecated'] = self.deprecation_warning
|
||||
return super().finalize_response(request, response, *args, **kwargs)
|
||||
|
||||
|
||||
class CompatListMixin(DeprecationWarningMixin):
|
||||
deprecation_warning = (
|
||||
f'This API endpoint is deprecated and will be removed in the future. It only works as long as '
|
||||
f'your event\'s questionnaire configuration is compatible with the old data model. '
|
||||
f'Please switch to the .../datafields/ and .../questionnaires/ endpoints.'
|
||||
)
|
||||
|
||||
@property
|
||||
def ignore_errors(self):
|
||||
return self.request.query_params.get('ignore_errors', '') == 'true'
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['ignore_errors'] = self.ignore_errors
|
||||
return ctx
|
||||
|
||||
def do_the_topo_sort(self, queryset):
|
||||
products = {}
|
||||
for df in queryset:
|
||||
for qc in df.referenced_by.all():
|
||||
for item_id in qc.questionnaire.items.values_list('pk', flat=True):
|
||||
k = (qc.questionnaire.type, item_id)
|
||||
products[k] = products.get(k, []) + [((qc.questionnaire.position, qc.position), df)]
|
||||
for q in products.values():
|
||||
q.sort()
|
||||
try:
|
||||
result = ordered_union(*products.values())
|
||||
for i, d in enumerate(result):
|
||||
d.position = i
|
||||
except ValueError:
|
||||
if self.ignore_errors:
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
return result
|
||||
|
||||
def list(self, request, **kwargs):
|
||||
logger.info('Returning compatible datafield list on %s/%s', request.organizer.slug, request.event.slug)
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
queryset = self.do_the_topo_sort(queryset)
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
resultlist = serializer.data
|
||||
return Response({
|
||||
'count': len(resultlist), 'next': None, 'previous': None,
|
||||
'results': resultlist,
|
||||
})
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
class CompatDatafieldFilter(FilterSet):
|
||||
ask_during_checkin = django_filters.BooleanFilter(method='ask_during_checkin_qs')
|
||||
required = django_filters.BooleanFilter(method='required_qs')
|
||||
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ['identifier']
|
||||
|
||||
def ask_during_checkin_qs(self, qs, name, value):
|
||||
return qs.filter(
|
||||
Exists(QuestionnaireRow.objects.filter(
|
||||
questionnaire__type=Questionnaire.QuestionnaireType.POSITION_CHECKIN if value else Questionnaire.QuestionnaireType.POSITION_PURCHASE,
|
||||
user_datafield=OuterRef('pk'),
|
||||
))
|
||||
)
|
||||
|
||||
def required_qs(self, qs, name, value):
|
||||
cond = Exists(QuestionnaireRow.objects.filter(
|
||||
required=True,
|
||||
user_datafield=OuterRef('pk'),
|
||||
))
|
||||
if value:
|
||||
return qs.filter(cond)
|
||||
else:
|
||||
return qs.filter(~cond)
|
||||
|
||||
class CompatQuestionViewSet(ConditionalListView, CompatListMixin, BaseDatafieldViewSet):
|
||||
container_type = Question.ContainerType.ORDERPOSITION
|
||||
serializer_class = CompatQuestionSerializer
|
||||
filterset_class = CompatDatafieldFilter
|
||||
pagination_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().prefetch_related('referenced_by', 'referenced_by__questionnaire')
|
||||
|
||||
def _check_defer(self, mode, request, *args, **kwargs):
|
||||
sales_channel_ident = request.query_params.get('sales_channel', None)
|
||||
if sales_channel_ident is None and isinstance(request.auth, Device):
|
||||
sales_channel_ident = 'pretixpos' if (request.auth.software_brand or '').startswith('pretixPOS') else 'web'
|
||||
if sales_channel_ident:
|
||||
logger.info('Returning app compatible question %s for %s (device: %r %s %s) on %s/%s', mode, sales_channel_ident, request.auth, getattr(request.auth, 'software_brand', ''), getattr(request.auth, 'software_version', ''), request.organizer.slug, request.event.slug)
|
||||
return POSCompatQuestionViewSet.as_view(
|
||||
{'get': mode}, sales_channel_ident=sales_channel_ident
|
||||
)(*self.args, request=request._request, **self.kwargs)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
if response := self._check_defer('list', request, *args, **kwargs):
|
||||
return response
|
||||
|
||||
logger.info('Returning compatible datafield list on %s/%s', request.organizer.slug, request.event.slug)
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
if response := self._check_defer('retrieve', request, *args, **kwargs):
|
||||
return response
|
||||
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
|
||||
class POSCompatQuestionViewSet(DeprecationWarningMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = POSCompatQuestionSerializer
|
||||
queryset = QuestionnaireRow.objects.none()
|
||||
permission = None
|
||||
sales_channel_ident = None
|
||||
|
||||
def __init__(self, sales_channel_ident, *args, **kwargs):
|
||||
self.sales_channel_ident = sales_channel_ident
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def deprecation_warning(self):
|
||||
return (
|
||||
f'This API endpoint is deprecated and will be removed in the future. '
|
||||
f'Results are generated on-the-fly, count and page size may be inaccurate. '
|
||||
f'Filtered for sales_channel "{self.sales_channel_ident}"'
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return QuestionnaireRow.objects.filter(
|
||||
# only matching sales channel
|
||||
Q(questionnaire__all_sales_channels=True) | Q(questionnaire__limit_sales_channels__identifier=self.sales_channel_ident),
|
||||
# only real questions, no text blocks
|
||||
Q(user_datafield_id__isnull=False) | Q(system_datafield__isnull=False),
|
||||
questionnaire__event=self.request.event,
|
||||
# we do legacy compat only for position-level questions
|
||||
questionnaire__type__startswith='P',
|
||||
).prefetch_related(
|
||||
'user_datafield', 'user_datafield__options', 'questionnaire'
|
||||
).order_by('questionnaire__position', 'position', 'id')
|
||||
|
||||
def list(self, request, **kwargs):
|
||||
resp = super().list(request, **kwargs)
|
||||
resp.data['results'] = POSCompatQuestionSerializer.mangle_results(self.request.event, resp.data['results'])
|
||||
return resp
|
||||
|
||||
def get_object(self):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||
|
||||
qid, dfid, sys_df = QuestionnaireRow.uncook_id(self.kwargs[lookup_url_kwarg])
|
||||
print(self.kwargs[lookup_url_kwarg], qid, dfid, sys_df)
|
||||
if qid and sys_df:
|
||||
obj = get_object_or_404(queryset, questionnaire__pk=qid, system_datafield=sys_df)
|
||||
elif qid and dfid:
|
||||
obj = get_object_or_404(queryset, questionnaire__pk=qid, user_datafield__pk=dfid)
|
||||
else:
|
||||
obj = get_object_or_404(queryset, user_datafield__pk=dfid)
|
||||
|
||||
# May raise a permission denied
|
||||
self.check_object_permissions(self.request, obj)
|
||||
|
||||
return obj
|
||||
return self.request.event.questions.filter(
|
||||
# the container_type parameter is undocumented, this API is going to change in a later release
|
||||
container_type=self.request.GET.get('container_type', Question.ContainerType.ORDERPOSITION),
|
||||
).prefetch_related('options').all()
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
@@ -785,7 +515,7 @@ class POSCompatQuestionViewSet(DeprecationWarningMixin, viewsets.ReadOnlyModelVi
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class BaseDatafieldOptionViewSet(viewsets.ModelViewSet):
|
||||
class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = QuestionOptionSerializer
|
||||
queryset = QuestionOption.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter,)
|
||||
@@ -794,25 +524,19 @@ class BaseDatafieldOptionViewSet(viewsets.ModelViewSet):
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
|
||||
def get_question(self):
|
||||
return get_object_or_404(
|
||||
Question,
|
||||
Q(pk=self.kwargs['question']) | Q(identifier=self.kwargs['question']),
|
||||
event=self.request.event, container_type=self.container_type,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.get_question().options.all()
|
||||
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
|
||||
return q.options.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['question'] = self.get_question()
|
||||
ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
q = self.get_question()
|
||||
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
|
||||
serializer.save(question=q)
|
||||
q.log_action(
|
||||
'pretix.event.question.option.added',
|
||||
@@ -842,85 +566,6 @@ class BaseDatafieldOptionViewSet(viewsets.ModelViewSet):
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class OrderDatafieldOptionViewSet(BaseDatafieldOptionViewSet):
|
||||
container_type = Question.ContainerType.ORDER
|
||||
|
||||
|
||||
class PositionDatafieldOptionViewSet(BaseDatafieldOptionViewSet):
|
||||
container_type = Question.ContainerType.ORDERPOSITION
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
class QuestionnaireFilter(FilterSet):
|
||||
sales_channel = django_filters.CharFilter(method='sales_channel_qs')
|
||||
|
||||
class Meta:
|
||||
model = Questionnaire
|
||||
fields = ['type', 'items', 'sales_channel']
|
||||
|
||||
def sales_channel_qs(self, qs, name, value):
|
||||
return qs.filter(
|
||||
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=value)
|
||||
)
|
||||
|
||||
|
||||
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = QuestionnaireSerializer
|
||||
queryset = Questionnaire.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
filterset_class = QuestionnaireFilter
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
|
||||
def get_queryset(self):
|
||||
if 'rows.datafield' in self.request.query_params.getlist('expand'):
|
||||
prefetch_rows = Prefetch(
|
||||
'rows',
|
||||
queryset=QuestionnaireRow.objects.select_related('user_datafield').prefetch_related('user_datafield__options'),
|
||||
)
|
||||
else:
|
||||
prefetch_rows = 'rows'
|
||||
return self.request.event.questionnaires.prefetch_related(prefetch_rows).all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.questionnaire.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['expand'] = self.request.query_params.getlist('expand')
|
||||
ctx['expand_name_fields'] = self.request.query_params.get('expand_name_fields', '').lower() == 'true'
|
||||
return ctx
|
||||
|
||||
def perform_update(self, serializer):
|
||||
if new_type := serializer.validated_data.get('type'):
|
||||
if new_type.split('_', 1)[0] != serializer.instance.type.split('_', 1)[0]:
|
||||
raise ValidationError({'type': 'Cannot change container part of type field'})
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.questionnaire.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.log_action(
|
||||
'pretix.event.questionnaire.deleted',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
|
||||
pass
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ from pretix.api.serializers.media import (
|
||||
)
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
Checkin, GiftCard, GiftCardAcceptance, GiftCardTransaction, OrderPosition,
|
||||
Checkin, GiftCardAcceptance, GiftCardTransaction, OrderPosition,
|
||||
ReusableMedium,
|
||||
)
|
||||
from pretix.base.models.orders import PrintLog
|
||||
@@ -88,7 +88,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
),
|
||||
Prefetch(
|
||||
'linked_giftcard',
|
||||
queryset=GiftCard.objects.annotate(
|
||||
queryset=self.request.organizer.accepted_gift_cards.annotate(
|
||||
cached_value=Coalesce(Subquery(s), Decimal('0.00'))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -287,9 +287,7 @@ class OrderViewSetMixin:
|
||||
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')
|
||||
# TODO(questionnaires) .order_by('question__position')
|
||||
),
|
||||
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
|
||||
'seat',
|
||||
)
|
||||
)
|
||||
@@ -708,29 +706,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
@action(detail=True, url_name='answer', url_path=r'answer/(?P<question>\d+)')
|
||||
def answer(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
answer = get_object_or_404(
|
||||
QuestionAnswer,
|
||||
order=order,
|
||||
question_id=kwargs.get('question')
|
||||
)
|
||||
if not answer.file:
|
||||
raise NotFound()
|
||||
|
||||
ftype, ignored = mimetypes.guess_type(answer.file.name)
|
||||
return FileResponse(
|
||||
answer.file,
|
||||
filename='{}-{}-{}'.format(
|
||||
self.request.event.slug.upper(),
|
||||
order.code,
|
||||
os.path.basename(answer.file.name).split('.', 1)[1]
|
||||
),
|
||||
as_attachment=True,
|
||||
content_type=ftype or 'application/binary'
|
||||
)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
if 'send_mail' in request.data and 'send_email' not in request.data and isinstance(request.data, dict):
|
||||
request.data['send_email'] = request.data['send_mail']
|
||||
@@ -1324,7 +1299,7 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
|
||||
pos = self.get_object()
|
||||
answer = get_object_or_404(
|
||||
QuestionAnswer,
|
||||
orderposition=pos,
|
||||
orderposition=self.get_object(),
|
||||
question_id=kwargs.get('question')
|
||||
)
|
||||
if not answer.file:
|
||||
@@ -1708,7 +1683,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
@action(detail=True, methods=['POST'])
|
||||
def confirm(self, request, **kwargs):
|
||||
payment = self.get_object()
|
||||
force = request.data.get('force', False)
|
||||
force = request.data.get('force', False) if request.data else False
|
||||
send_mail = request.data.get('send_email', True) if request.data else True
|
||||
|
||||
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
|
||||
|
||||
@@ -33,6 +33,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from pretix.api.auth.utils import get_session_key_for_api_request
|
||||
from pretix.api.serializers.shredders import (
|
||||
JobRunSerializer, ShredderSerializer,
|
||||
)
|
||||
@@ -68,8 +69,14 @@ class ShreddersMixin:
|
||||
cf = get_object_or_404(
|
||||
CachedFile,
|
||||
id=kwargs['cfid'],
|
||||
session_key=f'api-shredder-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}'
|
||||
)
|
||||
if cf.session_key == "api-shredder-<class 'django.contrib.auth.models.AnonymousUser'>-None":
|
||||
# OK, backwards-compatibility of a security bug fixed 2026-10, delete this at some point, but should
|
||||
# also be harmless because all files with this key are expired one day after deployment of this fix
|
||||
# and no new files with this key are created
|
||||
pass
|
||||
elif cf.session_key != get_session_key_for_api_request(self.request, prefix='api-shredder'):
|
||||
raise Http404()
|
||||
if cf.file:
|
||||
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
||||
@@ -118,10 +125,16 @@ class ShreddersMixin:
|
||||
)
|
||||
|
||||
try:
|
||||
CachedFile.objects.get(
|
||||
cf = CachedFile.objects.get(
|
||||
id=kwargs['cfid'],
|
||||
session_key=f'api-shredder-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}'
|
||||
)
|
||||
if cf.session_key == "api-shredder-<class 'django.contrib.auth.models.AnonymousUser'>-None":
|
||||
# OK, backwards-compatibility of a security bug fixed 2026-10, delete this at some point, but should
|
||||
# also be harmless because all files with this key are expired one day after deployment of this fix
|
||||
# and no new files with this key are created
|
||||
pass
|
||||
elif cf.session_key != get_session_key_for_api_request(self.request, prefix='api-shredder'):
|
||||
raise CachedFile.DoesNotExist()
|
||||
except CachedFile.DoesNotExist:
|
||||
return Response(
|
||||
{'status': 'gone', 'message': 'May have succeeded or timed out'},
|
||||
@@ -140,8 +153,14 @@ class ShreddersMixin:
|
||||
cf = get_object_or_404(
|
||||
CachedFile,
|
||||
id=kwargs['cfid'],
|
||||
session_key=f'api-shredder-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}'
|
||||
)
|
||||
if cf.session_key == "api-shredder-<class 'django.contrib.auth.models.AnonymousUser'>-None":
|
||||
# OK, backwards-compatibility of a security bug fixed 2026-10, delete this at some point, but should
|
||||
# also be harmless because all files with this key are expired one day after deployment of this fix
|
||||
# and no new files with this key are created
|
||||
pass
|
||||
elif cf.session_key != get_session_key_for_api_request(self.request, prefix='api-shredder'):
|
||||
raise Http404()
|
||||
if cf.file:
|
||||
async_result = self.do_shred(cf)
|
||||
url_kwargs = {
|
||||
@@ -184,7 +203,7 @@ class ShreddersMixin:
|
||||
cf = CachedFile(web_download=False)
|
||||
cf.date = now()
|
||||
cf.expires = now() + timedelta(hours=2)
|
||||
cf.session_key = f'api-shredder-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}'
|
||||
cf.session_key = get_session_key_for_api_request(self.request, prefix='api-shredder')
|
||||
cf.save()
|
||||
d = serializer.data
|
||||
for k, v in d.items():
|
||||
@@ -224,7 +243,7 @@ class EventShreddersViewSet(ShreddersMixin, viewsets.ViewSet):
|
||||
return export.apply_async(args=(
|
||||
self.request.event.id,
|
||||
list(shredders),
|
||||
f'api-shredder-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}',
|
||||
get_session_key_for_api_request(self.request, prefix='api-shredder'),
|
||||
str(cf.pk)
|
||||
))
|
||||
|
||||
|
||||
@@ -239,8 +239,9 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
)
|
||||
)
|
||||
|
||||
if 'email_verified' in userinfo and not userinfo['email_verified']:
|
||||
# todo: how universal is this, do we need to make this configurable?
|
||||
if 'email_verified' in userinfo and userinfo['email_verified'] not in (True, 1, "1", "True", "true", "TRUE"):
|
||||
# Documented criteria for using a provider with pretix:
|
||||
# "The SSO provider only allows verified accounts to log in or the SSO provider sends the claim email_verified. "
|
||||
raise ValidationError(_('The email address on this account is not yet verified. Please first confirm the '
|
||||
'email address in your customer account.'))
|
||||
|
||||
@@ -263,6 +264,8 @@ def oidc_validate_authorization(provider, code, redirect_uri, pkce_code_verifier
|
||||
)
|
||||
)
|
||||
|
||||
profile['oidc_claims'] = userinfo
|
||||
|
||||
return profile
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from typing import List, Optional, Protocol
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.utils.functional import classproperty
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -101,9 +102,8 @@ class OutboundSyncProvider:
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def display_name(cls):
|
||||
@classproperty
|
||||
def display_name(cls): # noqa: N805
|
||||
return str(cls.identifier)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -31,7 +31,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
|
||||
|
||||
def get_position_answer(op, question_identifier=None):
|
||||
def get_answer(op, question_identifier=None):
|
||||
a = None
|
||||
if op.addon_to:
|
||||
if "answers" in getattr(op.addon_to, "_prefetched_objects_cache", {}):
|
||||
@@ -70,30 +70,6 @@ def get_position_answer(op, question_identifier=None):
|
||||
return a.answer
|
||||
|
||||
|
||||
def get_order_answer(order, question_identifier=None):
|
||||
a = None
|
||||
if "answers" in getattr(order, "_prefetched_objects_cache", {}):
|
||||
try:
|
||||
a = [
|
||||
a
|
||||
for a in order.answers.all()
|
||||
if a.question.identifier == question_identifier
|
||||
][0]
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = order.answers.filter(question__identifier=question_identifier).first()
|
||||
|
||||
if not a:
|
||||
return ""
|
||||
else:
|
||||
if a.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
return [str(o.identifier) for o in a.options.all()]
|
||||
if a.question.type == Question.TYPE_BOOLEAN:
|
||||
return a.answer == "True"
|
||||
return a.answer
|
||||
|
||||
|
||||
def get_payment_date(order):
|
||||
if order.status == Order.STATUS_PENDING:
|
||||
return None
|
||||
@@ -151,11 +127,10 @@ DataFieldCategory = namedtuple(
|
||||
|
||||
CAT_ORDER_POSITION = DataFieldCategory(10, _('Order position details'))
|
||||
CAT_ATTENDEE = DataFieldCategory(11, _('Attendee details'))
|
||||
CAT_ORDER_POSITION_DATAFIELDS = DataFieldCategory(12, _('Order position data fields'))
|
||||
CAT_QUESTIONS = DataFieldCategory(12, _('Questions'))
|
||||
CAT_PRODUCT = DataFieldCategory(20, _('Product details'))
|
||||
CAT_ORDER = DataFieldCategory(21, _('Order details'))
|
||||
CAT_ORDER_DATAFIELDS = DataFieldCategory(22, _('Order data fields'))
|
||||
CAT_INVOICE_ADDRESS = DataFieldCategory(23, _('Invoice address'))
|
||||
CAT_INVOICE_ADDRESS = DataFieldCategory(22, _('Invoice address'))
|
||||
CAT_EVENT = DataFieldCategory(30, _('Event information'))
|
||||
CAT_EVENT_OR_SUBEVENT = DataFieldCategory(31, pgettext_lazy('subevent', 'Event or date information'))
|
||||
|
||||
@@ -609,27 +584,15 @@ def get_data_fields(event, for_model=None):
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER_POSITION_DATAFIELDS,
|
||||
ORDER_POSITION,
|
||||
CAT_QUESTIONS,
|
||||
"question_" + q.identifier,
|
||||
_("Data field: {name}").format(name=str(q.internal_name)),
|
||||
_("Question: {name}").format(name=str(q.question)),
|
||||
q.type,
|
||||
get_enum_opts(q),
|
||||
partial(lambda qq, position: get_position_answer(position, qq.identifier), q),
|
||||
partial(lambda qq, position: get_answer(position, qq.identifier), q),
|
||||
)
|
||||
for q in event.positiondatafields.filter(~Q(type=Question.TYPE_FILE)).prefetch_related("options")
|
||||
]
|
||||
+ [
|
||||
DataFieldInfo(
|
||||
ORDER,
|
||||
CAT_ORDER_DATAFIELDS,
|
||||
"orderdatafield_" + q.identifier,
|
||||
_("Order data field: {name}").format(name=str(q.internal_name)),
|
||||
q.type,
|
||||
get_enum_opts(q),
|
||||
partial(lambda qq, order: get_order_answer(order, qq.identifier), q),
|
||||
)
|
||||
for q in event.orderdatafields.filter(~Q(type=Question.TYPE_FILE)).prefetch_related("options")
|
||||
for q in event.questions.filter(~Q(type=Question.TYPE_FILE)).prefetch_related("options")
|
||||
]
|
||||
)
|
||||
if not any(field_name == "given_name" for field_name, label, weight in name_scheme["fields"]):
|
||||
|
||||
@@ -62,7 +62,7 @@ class AnswerFilesExporter(BaseExporter):
|
||||
[
|
||||
('questions',
|
||||
forms.ModelMultipleChoiceField(
|
||||
queryset=self.event.positiondatafields.filter(type='F'),
|
||||
queryset=self.event.questions.filter(type='F'),
|
||||
label=_('Questions'),
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={'class': 'scrolling-multiple-choice'}
|
||||
|
||||
@@ -160,7 +160,11 @@ class CustomerListExporter(OrganizerLevelExportMixin, MultiSheetListExporter):
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(obj.attendee_name_parts.get(k, ''))
|
||||
row.append(
|
||||
obj.attendee_name_parts.get(k, '')
|
||||
if obj.attendee_name_parts
|
||||
else ''
|
||||
)
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
|
||||
@@ -84,6 +84,7 @@ class JSONExporter(BaseExporter):
|
||||
'tax_rate': item.tax_rule.rate if item.tax_rule else Decimal('0.00'),
|
||||
'tax_name': str(item.tax_rule.name) if item.tax_rule else None,
|
||||
'admission': item.admission,
|
||||
'personalized': item.personalized,
|
||||
'active': item.active,
|
||||
'sales_channels': [c.identifier for c in (all_sales_channels if item.all_sales_channels else item.limit_sales_channels.all())],
|
||||
'description': str(item.description),
|
||||
@@ -148,26 +149,13 @@ class JSONExporter(BaseExporter):
|
||||
'id': question.id,
|
||||
'identifier': question.identifier,
|
||||
'required': question.required,
|
||||
'question': str(question.internal_name),
|
||||
'question': str(question.question),
|
||||
'position': question.position,
|
||||
'show_during_checkin': question.show_during_checkin,
|
||||
'print_on_invoice': question.print_on_invoice,
|
||||
'hidden': question.hidden,
|
||||
'ask_during_checkin': question.ask_during_checkin,
|
||||
'help_text': str(question.help_text),
|
||||
'type': question.type
|
||||
} for question in self.event.positiondatafields
|
||||
],
|
||||
'orderdatafields': [
|
||||
{
|
||||
'id': question.id,
|
||||
'identifier': question.identifier,
|
||||
'required': question.required,
|
||||
'question': str(question.internal_name),
|
||||
'position': question.position,
|
||||
'show_during_checkin': question.show_during_checkin,
|
||||
'print_on_invoice': question.print_on_invoice,
|
||||
'help_text': str(question.help_text),
|
||||
'type': question.type
|
||||
} for question in self.event.orderdatafields
|
||||
} for question in self.event.questions.all()
|
||||
],
|
||||
'orders': [
|
||||
{
|
||||
|
||||
@@ -55,9 +55,10 @@ from openpyxl.comments import Comment
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
|
||||
from pretix.base.models import (
|
||||
Checkin, GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
|
||||
Checkin, GiftCardTransaction, Invoice, InvoiceAddress, Order,
|
||||
OrderPosition, Question,
|
||||
)
|
||||
from pretix.base.models.giftcards import GiftCard
|
||||
from pretix.base.models.orders import (
|
||||
OrderFee, OrderPayment, OrderRefund, Transaction,
|
||||
)
|
||||
@@ -677,16 +678,16 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
if form_data['group_multiple_choice']:
|
||||
for o in q.options.all():
|
||||
options[q.pk].append(o)
|
||||
headers.append(str(q.internal_name))
|
||||
headers.append(str(q.question))
|
||||
else:
|
||||
for o in q.options.all():
|
||||
headers.append(str(q.internal_name) + ' – ' + str(o.answer))
|
||||
headers.append(str(q.question) + ' – ' + str(o.answer))
|
||||
options[q.pk].append(o)
|
||||
else:
|
||||
if q.type == Question.TYPE_CHOICE:
|
||||
for o in q.options.all():
|
||||
options[q.pk].append(o)
|
||||
headers.append(str(q.internal_name))
|
||||
headers.append(str(q.question))
|
||||
headers += [
|
||||
_('Invoice address company'),
|
||||
_('Invoice address name'),
|
||||
@@ -1326,12 +1327,12 @@ class GiftcardRedemptionListExporter(ListExporter):
|
||||
order__event__in=self.events,
|
||||
provider='giftcard',
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
).order_by('created')
|
||||
).order_by('created').select_related("order__event__organizer")
|
||||
refunds = OrderRefund.objects.filter(
|
||||
order__event__in=self.events,
|
||||
provider='giftcard',
|
||||
state=OrderRefund.REFUND_STATE_DONE
|
||||
).order_by('created')
|
||||
).order_by('created').select_related("order__event__organizer")
|
||||
|
||||
objs = sorted(list(payments) + list(refunds), key=lambda o: (o.order.code, o.created))
|
||||
|
||||
@@ -1348,9 +1349,9 @@ class GiftcardRedemptionListExporter(ListExporter):
|
||||
obj.order.code,
|
||||
obj.full_id,
|
||||
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
gc.secret,
|
||||
gc.secret if gc.accepted_by(obj.order.event.organizer) else '?',
|
||||
obj.amount * (-1 if isinstance(obj, OrderRefund) else 1),
|
||||
gc.issuer
|
||||
gc.issuer.slug
|
||||
]
|
||||
yield row
|
||||
|
||||
|
||||
+187
-202
@@ -36,6 +36,7 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import namedtuple
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
@@ -49,13 +50,12 @@ from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import ProgrammingError
|
||||
from django.db.models import Prefetch, Q, QuerySet
|
||||
from django.db.models import QuerySet
|
||||
from django.forms import Select, widgets
|
||||
from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.html import escape, format_html
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.text import format_lazy
|
||||
from django.utils.timezone import get_current_timezone
|
||||
@@ -82,10 +82,7 @@ from pretix.base.i18n import (
|
||||
from pretix.base.invoicing.transmission import (
|
||||
get_transmission_types, transmission_types,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
InvoiceAddress, Item, Question, Questionnaire, QuestionnaireRow,
|
||||
QuestionOption,
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
|
||||
from pretix.base.models.tax import ask_for_vat_id
|
||||
from pretix.base.services.tax import (
|
||||
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id,
|
||||
@@ -650,22 +647,49 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
FakeQuestion = namedtuple(
|
||||
'FakeQuestion', 'id question position required help_text container_type', defaults=('', Question.ContainerType.ORDERPOSITION)
|
||||
)
|
||||
|
||||
|
||||
def get_fake_attendee_questions(settings):
|
||||
fq = []
|
||||
sqo = settings.system_question_order
|
||||
|
||||
if settings.attendee_names_asked:
|
||||
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), settings.attendee_names_required))
|
||||
|
||||
if settings.attendee_emails_asked:
|
||||
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), settings.attendee_emails_required))
|
||||
|
||||
if settings.attendee_company_asked:
|
||||
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), settings.attendee_company_required))
|
||||
|
||||
if settings.attendee_addresses_asked:
|
||||
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), settings.attendee_addresses_required))
|
||||
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), settings.attendee_addresses_required))
|
||||
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), settings.attendee_addresses_required))
|
||||
fq.append(FakeQuestion('state', _('State'), sqo.get('country', 0), settings.attendee_addresses_required))
|
||||
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), settings.attendee_addresses_required))
|
||||
return fq
|
||||
|
||||
|
||||
class BaseQuestionsForm(forms.Form):
|
||||
"""
|
||||
This is the base form class responsible for asking order- or ticket-related questions.
|
||||
"""
|
||||
address_validation = False
|
||||
|
||||
def build_user_question_field(self, request, event, answerlist, qc, datafield):
|
||||
def build_user_question_field(self, request, event, answerlist, q):
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
answers = [a for a in answerlist if a.question_id == datafield.id]
|
||||
answers = [a for a in answerlist if a.question_id == q.id]
|
||||
if answers:
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
required = qc.required and not self.all_optional
|
||||
if datafield.type == Question.TYPE_BOOLEAN:
|
||||
required = q.required and not self.all_optional
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
# itself.
|
||||
@@ -679,105 +703,107 @@ class BaseQuestionsForm(forms.Form):
|
||||
initialbool = False
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=self.make_label(qc), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif datafield.type == Question.TYPE_NUMBER:
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
label=self.make_label(qc), required=required,
|
||||
min_value=datafield.valid_number_min or Decimal('0.00'),
|
||||
max_value=datafield.valid_number_max,
|
||||
help_text=rich_text(qc.help_text),
|
||||
label=escape(q.question), required=required,
|
||||
min_value=q.valid_number_min or Decimal('0.00'),
|
||||
max_value=q.valid_number_max,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif datafield.type == Question.TYPE_STRING:
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=self.make_label(qc), required=required,
|
||||
min_length=datafield.valid_string_length_min,
|
||||
max_length=datafield.valid_string_length_max,
|
||||
help_text=rich_text(qc.help_text),
|
||||
label=escape(q.question), required=required,
|
||||
min_length=q.valid_string_length_min,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif datafield.type == Question.TYPE_TEXT:
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=self.make_label(qc), required=required,
|
||||
min_length=datafield.valid_string_length_min,
|
||||
max_length=datafield.valid_string_length_max,
|
||||
help_text=rich_text(qc.help_text),
|
||||
label=escape(q.question), required=required,
|
||||
min_length=q.valid_string_length_min,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=rich_text(q.help_text),
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif datafield.type == Question.TYPE_COUNTRYCODE:
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField(
|
||||
countries=CachedCountries,
|
||||
blank=True, null=True, blank_label=' ',
|
||||
).formfield(
|
||||
label=self.make_label(qc), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
widget=forms.Select,
|
||||
empty_label=' ',
|
||||
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
|
||||
initial=initial.answer if initial else (
|
||||
guess_country_from_request(request, event) if required else None),
|
||||
)
|
||||
elif datafield.type == Question.TYPE_CHOICE:
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=datafield.options,
|
||||
label=self.make_label(qc), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
queryset=q.options,
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
widget=forms.Select,
|
||||
to_field_name='identifier',
|
||||
empty_label='',
|
||||
initial=initial.options.first() if initial else None,
|
||||
)
|
||||
elif datafield.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=datafield.options,
|
||||
label=self.make_label(qc), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
queryset=q.options,
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
to_field_name='identifier',
|
||||
widget=QuestionCheckboxSelectMultiple,
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif datafield.type == Question.TYPE_FILE:
|
||||
if datafield.valid_file_portrait:
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
if q.valid_file_portrait:
|
||||
field = PortraitImageField(
|
||||
label=self.make_label(qc), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=initial.file if initial else None,
|
||||
widget=PortraitImageWidget(answer=initial, request=request, attrs={'data-portrait-photo': 'true'}),
|
||||
widget=PortraitImageWidget(answer=initial, request=request,
|
||||
attrs={'data-portrait-photo': 'true'}),
|
||||
)
|
||||
else:
|
||||
field = ExtFileField(
|
||||
label=self.make_label(qc), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(answer=initial, request=request),
|
||||
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||
)
|
||||
elif datafield.type == Question.TYPE_DATE:
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
attrs = {}
|
||||
if datafield.valid_date_min:
|
||||
attrs['data-min'] = datafield.valid_date_min.isoformat()
|
||||
if datafield.valid_date_max:
|
||||
attrs['data-max'] = datafield.valid_date_max.isoformat()
|
||||
help_text = qc.help_text
|
||||
if q.valid_date_min:
|
||||
attrs['data-min'] = q.valid_date_min.isoformat()
|
||||
if q.valid_date_max:
|
||||
attrs['data-max'] = q.valid_date_max.isoformat()
|
||||
help_text = q.help_text
|
||||
if not help_text:
|
||||
if datafield.valid_date_min and datafield.valid_date_max:
|
||||
if q.valid_date_min and q.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date between {min} and {max}.'),
|
||||
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif datafield.valid_date_min:
|
||||
elif q.valid_date_min:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date no earlier than {min}.'),
|
||||
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif datafield.valid_date_max:
|
||||
elif q.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date no later than {max}.'),
|
||||
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
@@ -787,16 +813,16 @@ class BaseQuestionsForm(forms.Form):
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.DateField(
|
||||
label=self.make_label(qc), required=required,
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(help_text),
|
||||
initial=_initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
)
|
||||
if datafield.valid_date_min:
|
||||
field.validators.append(MinDateValidator(datafield.valid_date_min))
|
||||
if datafield.valid_date_max:
|
||||
field.validators.append(MaxDateValidator(datafield.valid_date_max))
|
||||
elif datafield.type == Question.TYPE_TIME:
|
||||
if q.valid_date_min:
|
||||
field.validators.append(MinDateValidator(q.valid_date_min))
|
||||
if q.valid_date_max:
|
||||
field.validators.append(MaxDateValidator(q.valid_date_max))
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).time()
|
||||
@@ -805,29 +831,29 @@ class BaseQuestionsForm(forms.Form):
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.TimeField(
|
||||
label=self.make_label(qc), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=_initial,
|
||||
widget=TimePickerWidget(without_seconds=True),
|
||||
)
|
||||
elif datafield.type == Question.TYPE_DATETIME:
|
||||
help_text = qc.help_text
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
help_text = q.help_text
|
||||
if not help_text:
|
||||
if datafield.valid_datetime_min and datafield.valid_datetime_max:
|
||||
if q.valid_datetime_min and q.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time between {min} and {max}.'),
|
||||
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif datafield.valid_datetime_min:
|
||||
elif q.valid_datetime_min:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time no earlier than {min}.'),
|
||||
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif datafield.valid_datetime_max:
|
||||
elif q.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time no later than {max}.'),
|
||||
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
|
||||
if initial and initial.answer:
|
||||
@@ -839,20 +865,20 @@ class BaseQuestionsForm(forms.Form):
|
||||
_initial = None
|
||||
|
||||
field = SplitDateTimeField(
|
||||
label=self.make_label(qc), required=required,
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(help_text),
|
||||
initial=_initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=datafield.valid_datetime_min,
|
||||
max_date=datafield.valid_datetime_max
|
||||
min_date=q.valid_datetime_min,
|
||||
max_date=q.valid_datetime_max
|
||||
),
|
||||
)
|
||||
if datafield.valid_datetime_min:
|
||||
field.validators.append(MinDateTimeValidator(datafield.valid_datetime_min))
|
||||
if datafield.valid_datetime_max:
|
||||
field.validators.append(MaxDateTimeValidator(datafield.valid_datetime_max))
|
||||
elif datafield.type == Question.TYPE_PHONENUMBER:
|
||||
if q.valid_datetime_min:
|
||||
field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
|
||||
if q.valid_datetime_max:
|
||||
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||
elif q.type == Question.TYPE_PHONENUMBER:
|
||||
if initial:
|
||||
try:
|
||||
initial = PhoneNumber().from_string(initial.answer)
|
||||
@@ -865,96 +891,72 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
|
||||
field = PhoneNumberField(
|
||||
label=self.make_label(qc), required=required,
|
||||
help_text=rich_text(qc.help_text),
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
||||
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
||||
# the future.
|
||||
initial=initial,
|
||||
widget=WrappedPhoneNumberPrefixWidget()
|
||||
)
|
||||
else:
|
||||
raise ProgrammingError('Invalid question type')
|
||||
field.datafield = datafield
|
||||
field.questionnaire_row = qc
|
||||
field.question = q
|
||||
if answers:
|
||||
# Cache the answer object for later use
|
||||
field.answer = answers[0]
|
||||
|
||||
if qc.dependency_question_id:
|
||||
field.widget.attrs['data-question-dependency'] = qc.dependency_question.form_field_name
|
||||
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(qc.dependency_values))
|
||||
if datafield.type != 'M':
|
||||
field.widget.attrs['required'] = qc.required and not self.all_optional
|
||||
field._required = qc.required and not self.all_optional
|
||||
if q.dependency_question_id:
|
||||
field.widget.attrs['data-question-dependency'] = q.dependency_question_id
|
||||
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
|
||||
if q.type != 'M':
|
||||
field.widget.attrs['required'] = q.required and not self.all_optional
|
||||
field._required = q.required and not self.all_optional
|
||||
field.required = False
|
||||
|
||||
return field
|
||||
|
||||
def build_text_block(self, request, event, qc):
|
||||
return forms.CharField( # TODO(questionnaires): use a less hacky way to format this field
|
||||
required=False,
|
||||
label="",
|
||||
help_text=rich_text((f"#### {qc.label}\n" if qc.label else "") + str(qc.help_text)),
|
||||
disabled=True,
|
||||
widget=QuestionCheckboxSelectMultiple,
|
||||
)
|
||||
def check_user_questions(self, d):
|
||||
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
|
||||
|
||||
def make_label(self, qc):
|
||||
label = escape(qc.label)
|
||||
if qc.questionnaire.ask_during_checkin:
|
||||
label += format_html(' <span class="fa fa-qrcode text-primary" data-toggle="tooltip" title="{}"></span>', _("This question will be asked during check-in."))
|
||||
if qc.questionnaire.hidden:
|
||||
label += format_html(' <span class="fa fa-eye-slash text-primary" data-toggle="tooltip" title="{}"></span>', _("Hidden question"))
|
||||
return label
|
||||
|
||||
def check_user_questions(self, formdata):
|
||||
row_cache = {f.questionnaire_row.pk: f.questionnaire_row for f in self.fields.values() if getattr(f, 'questionnaire_row', None)}
|
||||
user_fields = [f for f in self.fields.values() if getattr(f, 'datafield', None)]
|
||||
|
||||
def question_is_visible(parentid, expectedvals):
|
||||
if parentid is None:
|
||||
return True
|
||||
if parentid not in row_cache:
|
||||
def question_is_visible(parentid, qvals):
|
||||
if parentid not in question_cache:
|
||||
return False
|
||||
parentq = row_cache[parentid]
|
||||
if not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
|
||||
parentq = question_cache[parentid]
|
||||
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
|
||||
return False
|
||||
if parentq.form_field_name not in formdata:
|
||||
if 'question_%d' % parentid not in d:
|
||||
return False
|
||||
formval = formdata.get(parentq.form_field_name)
|
||||
dval = d.get('question_%d' % parentid)
|
||||
return (
|
||||
('True' in expectedvals and formval)
|
||||
or ('False' in expectedvals and not formval)
|
||||
or (isinstance(formval, QuestionOption) and formval.identifier in expectedvals)
|
||||
or (isinstance(formval, (list, QuerySet)) and any(qval in [o.identifier for o in formval] for qval in expectedvals))
|
||||
('True' in qvals and dval)
|
||||
or ('False' in qvals and not dval)
|
||||
or (isinstance(dval, QuestionOption) and dval.identifier in qvals)
|
||||
or (isinstance(dval, (list, QuerySet)) and any(qval in [o.identifier for o in dval] for qval in qvals))
|
||||
)
|
||||
|
||||
def question_is_required(qr):
|
||||
return qr.required and question_is_visible(qr.dependency_question_id, qr.dependency_values)
|
||||
def question_is_required(q):
|
||||
return (
|
||||
q.required and
|
||||
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values))
|
||||
)
|
||||
|
||||
if not self.all_optional:
|
||||
for field in user_fields:
|
||||
qr = field.questionnaire_row
|
||||
answer = formdata.get(qr.form_field_name)
|
||||
field = self[qr.form_field_name]
|
||||
if question_is_required(qr) and not answer and answer != 0 and not field.errors:
|
||||
raise ValidationError({qr.form_field_name: [_('This field is required.')]})
|
||||
for q in question_cache.values():
|
||||
answer = d.get('question_%d' % q.pk)
|
||||
field = self['question_%d' % q.pk]
|
||||
if question_is_required(q) and not answer and answer != 0 and not field.errors:
|
||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
|
||||
|
||||
# Strip invisible question from cleaned_data so they don't end up in the database
|
||||
for field in user_fields:
|
||||
qr = field.questionnaire_row
|
||||
answer = formdata.get(qr.form_field_name)
|
||||
if not question_is_visible(qr.dependency_question_id, qr.dependency_values) and answer is not None:
|
||||
formdata[qr.form_field_name] = None
|
||||
for q in question_cache.values():
|
||||
answer = d.get('question_%d' % q.pk)
|
||||
if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
|
||||
d['question_%d' % q.pk] = None
|
||||
|
||||
# Strip False answers to required yes/no questions even if all_optional is set, as our data model assumes that
|
||||
# required yes/no questions can only be answered with yes
|
||||
for field in user_fields:
|
||||
qr = field.questionnaire_row
|
||||
if qr.required and field.datafield.type == Question.TYPE_BOOLEAN:
|
||||
if qr.form_field_name in formdata and formdata[qr.form_field_name] is False:
|
||||
formdata[qr.form_field_name] = None
|
||||
for q in question_cache.values():
|
||||
if q.required and q.type == Question.TYPE_BOOLEAN:
|
||||
if 'question_%d' % q.pk in d and d['question_%d' % q.pk] is False:
|
||||
d['question_%d' % q.pk] = None
|
||||
|
||||
|
||||
class OrderLevelQuestionsForm(BaseQuestionsForm):
|
||||
@@ -962,7 +964,8 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
|
||||
"""
|
||||
Takes two additional keyword arguments:
|
||||
|
||||
:param container: The checkout session or order the form should be for
|
||||
:param checkoutsession: The checkout session the form should be for
|
||||
:param order: The order the form should be for
|
||||
:param event: The event this belongs to
|
||||
"""
|
||||
request = kwargs.pop('request', None)
|
||||
@@ -971,35 +974,14 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
questionnaires = Questionnaire.objects.filter(
|
||||
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=container.sales_channel.identifier),
|
||||
event=event, type=Questionnaire.QuestionnaireType.ORDER_PURCHASE,
|
||||
).order_by('position').prefetch_related(
|
||||
Prefetch('rows', QuestionnaireRow.objects.prefetch_related(
|
||||
Prefetch('user_datafield', Question.objects.prefetch_related(
|
||||
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
|
||||
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
|
||||
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
|
||||
# a prefetch lookup on this query...
|
||||
'question',
|
||||
Question.objects.none(),
|
||||
to_attr='dummy'
|
||||
)))
|
||||
))
|
||||
),
|
||||
to_attr='questionnairerowlist')
|
||||
)
|
||||
questions = Question.objects.filter(
|
||||
event=event, container_type=Question.ContainerType.ORDER,
|
||||
ask_during_checkin=False, hidden=False,
|
||||
).order_by('position')
|
||||
answerlist = container.answers.prefetch_related('options')
|
||||
|
||||
idx = 0
|
||||
for questionnaire in questionnaires:
|
||||
for qrow in getattr(questionnaire, 'questionnairerowlist', questionnaire.rows.all()):
|
||||
if qrow.user_datafield:
|
||||
df = qrow.user_datafield
|
||||
self.fields[qrow.form_field_name] = self.build_user_question_field(request, event, answerlist, qrow, df)
|
||||
else:
|
||||
self.fields[qrow.form_field_name] = self.build_text_block(request, event, qrow)
|
||||
idx += 1
|
||||
for q in questions:
|
||||
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, answerlist, q)
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
@@ -1026,7 +1008,6 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
orderpos = self.orderpos = kwargs.pop('orderpos', None)
|
||||
pos = cartpos or orderpos
|
||||
item = pos.item
|
||||
questionnaires = pos.item.relevant_questionnaires
|
||||
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
|
||||
@@ -1036,15 +1017,18 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
|
||||
self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item)
|
||||
|
||||
for questionnaire in questionnaires:
|
||||
for qrow in getattr(questionnaire, 'questionnairerowlist', questionnaire.rows.all()):
|
||||
if qrow.user_datafield:
|
||||
df = qrow.user_datafield
|
||||
self.fields[qrow.form_field_name] = self.build_user_question_field(request, event, pos.answerlist, qrow, df)
|
||||
elif qrow.system_datafield:
|
||||
self.fields[qrow.form_field_name] = self.build_system_question_field(request, event, pos, qrow)
|
||||
else:
|
||||
self.fields[qrow.form_field_name] = self.build_text_block(request, event, qrow)
|
||||
questions = []
|
||||
if item.ask_attendee_data:
|
||||
questions += get_fake_attendee_questions(event.settings)
|
||||
questions += pos.item.questions_to_ask
|
||||
|
||||
questions.sort(key=lambda q: q.position)
|
||||
|
||||
for q in questions:
|
||||
if isinstance(q, FakeQuestion):
|
||||
self.fields[q.id] = self.build_system_question_field(request, event, pos, q)
|
||||
else:
|
||||
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos.answerlist, q)
|
||||
|
||||
responses = question_form_fields.send(sender=event, position=pos)
|
||||
data = pos.meta_info_data
|
||||
@@ -1109,20 +1093,21 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
)
|
||||
|
||||
def build_system_question_field(self, request, event, pos, qc):
|
||||
if qc.system_datafield == QuestionnaireRow.SystemDatafield.ATTENDEE_NAME_PARTS:
|
||||
field_name = qc.id
|
||||
if field_name == 'attendee_name_parts':
|
||||
return NamePartsFormField(
|
||||
max_length=255,
|
||||
required=qc.required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
titles=event.settings.name_scheme_titles,
|
||||
label=self.make_label(qc),
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=pos.attendee_name_parts,
|
||||
)
|
||||
if qc.system_datafield == QuestionnaireRow.SystemDatafield.ATTENDEE_EMAIL:
|
||||
if field_name == 'attendee_email':
|
||||
return forms.EmailField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=self.make_label(qc),
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=pos.attendee_email,
|
||||
widget=forms.EmailInput(
|
||||
@@ -1131,19 +1116,19 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
}
|
||||
)
|
||||
)
|
||||
if qc.system_datafield == QuestionnaireRow.SystemDatafield.COMPANY:
|
||||
if field_name == 'company':
|
||||
return forms.CharField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=self.make_label(qc),
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
max_length=255,
|
||||
initial=pos.company,
|
||||
)
|
||||
|
||||
if qc.system_datafield == QuestionnaireRow.SystemDatafield.STREET:
|
||||
if field_name == 'street':
|
||||
return forms.CharField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=self.make_label(qc),
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
@@ -1152,21 +1137,21 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
}),
|
||||
initial=pos.street,
|
||||
)
|
||||
if qc.system_datafield == QuestionnaireRow.SystemDatafield.ZIPCODE:
|
||||
if field_name == 'zipcode':
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
max_length=30,
|
||||
label=self.make_label(qc),
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=pos.zipcode,
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
)
|
||||
if qc.system_datafield == QuestionnaireRow.SystemDatafield.CITY:
|
||||
if field_name == 'city':
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
label=self.make_label(qc),
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
max_length=255,
|
||||
initial=pos.city,
|
||||
@@ -1174,13 +1159,13 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
if qc.system_datafield == QuestionnaireRow.SystemDatafield.COUNTRY:
|
||||
if field_name == 'country':
|
||||
country = pos.country or guess_country_from_request(request, event)
|
||||
return CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=self.make_label(qc),
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
@@ -1188,7 +1173,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
'data-trigger-address-info': 'on',
|
||||
}),
|
||||
)
|
||||
if qc.system_datafield == QuestionnaireRow.SystemDatafield.STATE:
|
||||
if field_name == 'state':
|
||||
country = pos.country or guess_country_from_request(request, event)
|
||||
c = [('', '---')]
|
||||
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
|
||||
@@ -1208,7 +1193,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
field = forms.ChoiceField(
|
||||
label=self.make_label(qc),
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
required=False,
|
||||
choices=c,
|
||||
|
||||
@@ -71,8 +71,8 @@ class EmailTransmissionType(TransmissionType):
|
||||
|
||||
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
||||
return {
|
||||
"transmission_email_other": bool(transmission_info.get("transmission_email_address")),
|
||||
"transmission_email_address": transmission_info.get("transmission_email_address"),
|
||||
"transmission_email_other": bool((transmission_info or {}).get("transmission_email_address")),
|
||||
"transmission_email_address": (transmission_info or {}).get("transmission_email_address"),
|
||||
}
|
||||
|
||||
def form_data_to_transmission_info(self, form_data: dict) -> dict:
|
||||
|
||||
@@ -131,6 +131,27 @@ class PeppolIdValidator:
|
||||
def __init__(self, validate_online=False):
|
||||
self.validate_online = validate_online
|
||||
|
||||
def _validate_peppol_id_online(self, value):
|
||||
base_hostnames = ['participant.sml.prod.tech.peppol.org', 'participant.sml.test.tech.peppol.org']
|
||||
smp_id = base64.b32encode(hashlib.sha256(value.lower().encode()).digest()).decode().rstrip("=")
|
||||
for base_hostname in base_hostnames:
|
||||
smp_domain = f'{smp_id}.iso6523-actorid-upis.{base_hostname}'
|
||||
resolver = dns.resolver.Resolver()
|
||||
try:
|
||||
answers = resolver.resolve(smp_domain, 'NAPTR', lifetime=1.0)
|
||||
if answers:
|
||||
return value
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
# ID not registered, do not set found=True
|
||||
pass
|
||||
except Exception: # noqa
|
||||
# Error likely on our end or infrastructure is down, allow user to proceed
|
||||
return value
|
||||
|
||||
raise ValidationError(
|
||||
_("The Peppol participant ID is not registered on the Peppol network."),
|
||||
)
|
||||
|
||||
def __call__(self, value):
|
||||
if ":" not in value:
|
||||
raise ValidationError(_("A Peppol participant ID always starts with a prefix, followed by a colon (:)."))
|
||||
@@ -146,25 +167,7 @@ class PeppolIdValidator:
|
||||
params={"number": prefix})
|
||||
|
||||
if self.validate_online:
|
||||
base_hostnames = ['edelivery.tech.ec.europa.eu', 'acc.edelivery.tech.ec.europa.eu']
|
||||
smp_id = base64.b32encode(hashlib.sha256(value.lower().encode()).digest()).decode().rstrip("=")
|
||||
for base_hostname in base_hostnames:
|
||||
smp_domain = f'{smp_id}.iso6523-actorid-upis.{base_hostname}'
|
||||
resolver = dns.resolver.Resolver()
|
||||
try:
|
||||
answers = resolver.resolve(smp_domain, 'NAPTR', lifetime=1.0)
|
||||
if answers:
|
||||
return value
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
# ID not registered, do not set found=True
|
||||
pass
|
||||
except Exception: # noqa
|
||||
# Error likely on our end or infrastructure is down, allow user to proceed
|
||||
return value
|
||||
|
||||
raise ValidationError(
|
||||
_("The Peppol participant ID is not registered on the Peppol network."),
|
||||
)
|
||||
value = self._validate_peppol_id_online(value)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@@ -39,14 +39,8 @@ class Command(BaseCommand):
|
||||
parser.add_argument(
|
||||
'--print-sql',
|
||||
action='store_true',
|
||||
help='Print all SQL queries (needs package `django-extensions`).',
|
||||
help='Print all SQL queries.',
|
||||
)
|
||||
parser.description = "django-scopes aware shell"
|
||||
parser.epilog = """
|
||||
To wrap shell in scopes_disabled (completely ignore django-scopes), add --override.
|
||||
|
||||
For more specific scopes, add --model__lookup=value flags, e.g. --organizer__slug=demo
|
||||
"""
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-19 14:24
|
||||
import json
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
from itertools import chain, groupby
|
||||
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F
|
||||
from django.utils.translation import pgettext_lazy
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.fields
|
||||
from pretix.base.i18n import language
|
||||
from pretix.helpers.iter import sort_groupby, sort_groupby_exp
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FakeQuestion = namedtuple(
|
||||
'FakeQuestion', 'id question position required help_text'
|
||||
)
|
||||
|
||||
|
||||
def get_fake_questions(settings):
|
||||
def b(s):
|
||||
return s == 'True'
|
||||
fq = []
|
||||
sqo = json.loads(settings.get('system_question_order', '{}'))
|
||||
_ = LazyI18nString.from_gettext
|
||||
|
||||
if LazyI18nString(settings.get('attendee_data_explanation_text', '')):
|
||||
fq.append(FakeQuestion(None, '', -1, False, settings.get('attendee_data_explanation_text', '')))
|
||||
|
||||
if b(settings.get('attendee_names_asked', 'True')):
|
||||
fq.append(FakeQuestion('@attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required')), None))
|
||||
|
||||
if b(settings.get('attendee_emails_asked')):
|
||||
fq.append(FakeQuestion('@attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required')), None))
|
||||
|
||||
if b(settings.get('attendee_company_asked')):
|
||||
fq.append(FakeQuestion('@company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required')), None))
|
||||
|
||||
if b(settings.get('attendee_addresses_asked')):
|
||||
fq.append(FakeQuestion('@street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required')), None))
|
||||
fq.append(FakeQuestion('@zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required')), None))
|
||||
fq.append(FakeQuestion('@city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required')), None))
|
||||
fq.append(FakeQuestion('@state', _(pgettext_lazy("address", "State")), sqo.get('country', 0), b(settings.get('attendee_addresses_required')), None))
|
||||
fq.append(FakeQuestion('@country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required')), None))
|
||||
return fq
|
||||
|
||||
|
||||
def migrate_questions_forward(apps, schema_editor):
|
||||
from django.utils.translation import (
|
||||
get_supported_language_variant, gettext as _,
|
||||
)
|
||||
|
||||
Event = apps.get_model("pretixbase", "Event")
|
||||
Item = apps.get_model("pretixbase", "Item")
|
||||
Question = apps.get_model("pretixbase", "Question")
|
||||
Questionnaire = apps.get_model("pretixbase", "Questionnaire")
|
||||
QuestionnaireRow = apps.get_model("pretixbase", "QuestionnaireRow")
|
||||
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
|
||||
|
||||
qc_candidate = namedtuple('qc_candidate', ['item_info', 'question_id', 'question'])
|
||||
|
||||
def create_grouped_item_questionnaires(event, qc_candidates_iter, label_prefix, questionnaire_type, personalized_items):
|
||||
# group by item, creating a unique questionnaire per item -> iter[tuple[list[Question], item_info]]
|
||||
item_questionnaires = (
|
||||
([c.question for c in group], item_info)
|
||||
for item_info, group in sort_groupby(
|
||||
qc_candidates_iter,
|
||||
group_key=lambda c: c.item_info,
|
||||
inner_sort_key=lambda c: c.question.position,
|
||||
)
|
||||
)
|
||||
|
||||
# group again, combining all questionnaires containing an identical set of questions (by means of grouping by a list of item-ids)
|
||||
merged_questionnaires = sort_groupby_exp(item_questionnaires, group_key=lambda questions, item_info: [str(q.id) for q in questions])
|
||||
for _unused, grouped_data in merged_questionnaires:
|
||||
questions = grouped_data[0][0]
|
||||
item_infos = [item_info for _c, item_info in grouped_data if item_info is not None]
|
||||
|
||||
internal_name = label_prefix + ', '.join(str(ii.internal_name or ii.name) for ii in item_infos)
|
||||
compat_personalization = False
|
||||
if all(ii.personalized for ii in item_infos) and len(item_infos) == len(personalized_items):
|
||||
if questionnaire_type == "position_purchase":
|
||||
# if we have a questionnaire for exactly all personalized items, we just call it "Attendee data"
|
||||
# (same as the default questionnaire for new events)
|
||||
internal_name = _('Attendee data')
|
||||
compat_personalization = True
|
||||
else:
|
||||
internal_name = label_prefix + _('personalized products')
|
||||
if len(internal_name) > 255:
|
||||
internal_name = internal_name[:250] + ' ...'
|
||||
|
||||
# create questionnaires and rows
|
||||
questionnaire = Questionnaire.objects.create(
|
||||
event=event, type=questionnaire_type, position=0, all_sales_channels=True,
|
||||
internal_name=internal_name, compat_personalization=compat_personalization
|
||||
)
|
||||
questionnaire.items.set([ii.id for ii in item_infos])
|
||||
valid_deps = {question.id for question in questions}
|
||||
new_rows = {}
|
||||
deps_to_add = []
|
||||
for position, question in enumerate(questions):
|
||||
if isinstance(question, FakeQuestion):
|
||||
QuestionnaireRow.objects.create(
|
||||
questionnaire=questionnaire,
|
||||
position=position + 1,
|
||||
system_datafield=question.id,
|
||||
required=question.required,
|
||||
label=question.question,
|
||||
help_text=question.help_text,
|
||||
)
|
||||
else:
|
||||
if question.dependency_question_id and question.dependency_question_id not in valid_deps:
|
||||
logger.warning('Question with invalid dependency ignored %s/%s/%d', event.organizer.slug, event.slug, question.id)
|
||||
continue
|
||||
new_rows[question.id] = QuestionnaireRow.objects.create(
|
||||
questionnaire=questionnaire,
|
||||
position=position + 1,
|
||||
user_datafield=question,
|
||||
required=question.required,
|
||||
label=question.question,
|
||||
help_text=question.help_text,
|
||||
dependency_values=question.dependency_values,
|
||||
)
|
||||
if question.dependency_question:
|
||||
deps_to_add.append((question.id, question.dependency_question.id))
|
||||
for q, dep in deps_to_add:
|
||||
new_rows[q].dependency_question = new_rows[dep]
|
||||
new_rows[q].save()
|
||||
|
||||
for event in Event.objects.iterator():
|
||||
# get relevant settings
|
||||
settings = {
|
||||
setting.key: setting.value for setting in EventSettingsStore.objects.filter(object_id=event.id, key__in=(
|
||||
'system_question_order', 'attendee_names_asked', 'attendee_names_required', 'attendee_emails_asked', 'attendee_emails_required',
|
||||
'attendee_company_asked', 'attendee_company_required', 'attendee_addresses_asked', 'attendee_addresses_required', 'locale',
|
||||
'attendee_data_explanation_text',
|
||||
))
|
||||
}
|
||||
with language(get_supported_language_variant(settings.get('locale', 'en'))):
|
||||
# [P] get all ticket-level questions (user-defined and system provided), along with the products for which they're asked
|
||||
questions = event.questions.filter(container_type='P')
|
||||
|
||||
personalized_items = event.items.filter(personalized=True).values_list('id', 'internal_name', 'name', 'personalized', named=True)
|
||||
|
||||
# [position_checkout] presale
|
||||
rows = chain((
|
||||
qc_candidate(item, 0, q)
|
||||
for q in get_fake_questions(settings)
|
||||
for item in personalized_items
|
||||
), (
|
||||
qc_candidate(item, q.id, q)
|
||||
for q in questions.filter(hidden=False, ask_during_checkin=False)
|
||||
for item in q.items.values_list('id', 'internal_name', 'name', 'personalized', named=True)
|
||||
))
|
||||
|
||||
# Questionnaire.QuestionnaireType.POSITION_PURCHASE == "position_purchase"
|
||||
create_grouped_item_questionnaires(event, rows, '', "position_purchase", personalized_items)
|
||||
|
||||
# [position_checkin] checkin
|
||||
rows = chain((
|
||||
qc_candidate(item, q.id, q)
|
||||
for q in questions.filter(ask_during_checkin=True)
|
||||
for item in q.items.values_list('id', 'internal_name', 'name', 'personalized', named=True)
|
||||
))
|
||||
|
||||
# Questionnaire.QuestionnaireType.POSITION_CHECKIN == "position_checkin"
|
||||
create_grouped_item_questionnaires(event, rows, _('Check-in questions for '), "position_checkin", personalized_items)
|
||||
|
||||
# [position_hidden] hidden
|
||||
rows = chain((
|
||||
qc_candidate(item, q.id, q)
|
||||
for q in questions.filter(hidden=True, ask_during_checkin=False)
|
||||
for item in q.items.values_list('id', 'internal_name', 'name', 'personalized', named=True)
|
||||
))
|
||||
|
||||
# Questionnaire.QuestionnaireType.POSITION_HIDDEN == "position_hidden"
|
||||
create_grouped_item_questionnaires(event, rows, _('Hidden questions for '), "position_hidden", personalized_items)
|
||||
|
||||
if len(personalized_items) == 0:
|
||||
rows = chain((
|
||||
qc_candidate(None, 0, q)
|
||||
for q in get_fake_questions(settings)
|
||||
))
|
||||
create_grouped_item_questionnaires(event, rows, '', "position_purchase", personalized_items)
|
||||
|
||||
# [O] get all order-level questions
|
||||
questions = list(event.questions.filter(container_type='O', hidden=False).order_by('position'))
|
||||
if questions:
|
||||
# create questionnaires and rows
|
||||
questionnaire = Questionnaire.objects.create(
|
||||
event=event, type="order_purchase", position=0, all_sales_channels=True,
|
||||
internal_name=_('Per-order questions'),
|
||||
)
|
||||
valid_deps = {question.id for question in questions}
|
||||
new_rows = {}
|
||||
deps_to_add = []
|
||||
for position, question in enumerate(questions):
|
||||
if question.dependency_question_id and question.dependency_question_id not in valid_deps:
|
||||
logger.warning('Question with invalid dependency ignored %s/%s/%d', event.organizer.slug, event.slug, question.id)
|
||||
continue
|
||||
new_rows[question.id] = QuestionnaireRow.objects.create(
|
||||
questionnaire=questionnaire,
|
||||
position=position + 1,
|
||||
user_datafield=question,
|
||||
required=question.required,
|
||||
label=question.question,
|
||||
help_text=question.help_text,
|
||||
dependency_values=question.dependency_values,
|
||||
)
|
||||
if question.dependency_question:
|
||||
deps_to_add.append((question.id, question.dependency_question.id))
|
||||
for q, dep in deps_to_add:
|
||||
new_rows[q].dependency_question = new_rows[dep]
|
||||
new_rows[q].save()
|
||||
|
||||
|
||||
for question in event.questions.all():
|
||||
question.internal_name = str(question.question)
|
||||
if question.print_on_invoice:
|
||||
question.print_on_invoice_label = question.question
|
||||
question.save()
|
||||
|
||||
|
||||
def migrate_questions_backward(apps, schema_editor):
|
||||
Question = apps.get_model("pretixbase", "Question")
|
||||
Question.objects.filter(question__isnull=True).update(question=F('internal_name'))
|
||||
|
||||
|
||||
def no_op(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0311_fix_unshredded_invoices'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Questionnaire',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('internal_name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(max_length=25)),
|
||||
('position', models.PositiveIntegerField(default=0)),
|
||||
('all_sales_channels', models.BooleanField(default=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questionnaires', to='pretixbase.event')),
|
||||
('items', models.ManyToManyField(related_name='questionnaires', to='pretixbase.item')),
|
||||
('limit_sales_channels', models.ManyToManyField(to='pretixbase.saleschannel')),
|
||||
('compat_personalization', models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QuestionnaireRow',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('position', models.PositiveIntegerField(default=0)),
|
||||
('system_datafield', models.CharField(max_length=25, null=True)),
|
||||
('required', models.BooleanField(default=False)),
|
||||
('label', i18nfield.fields.I18nTextField()),
|
||||
('help_text', i18nfield.fields.I18nTextField(null=True)),
|
||||
('dependency_values', pretix.base.models.fields.MultiStringField(default=[])),
|
||||
('dependency_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.questionnairerow')),
|
||||
('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rows', to='pretixbase.questionnaire')),
|
||||
('user_datafield', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='referenced_by', to='pretixbase.question')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='internal_name',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='print_on_invoice_label',
|
||||
field=i18nfield.fields.I18nTextField(null=True),
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_questions_forward,
|
||||
no_op,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='question',
|
||||
name='question',
|
||||
field=i18nfield.fields.I18nTextField(null=True, db_column='question'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
no_op,
|
||||
migrate_questions_backward,
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='question',
|
||||
old_name='question',
|
||||
new_name='tbd_question',
|
||||
),
|
||||
# add tbd_hidden, tbd_ask_during_checkin, ...
|
||||
|
||||
# TODO(questionnaires) remove old columns from Question model
|
||||
]
|
||||
@@ -688,7 +688,7 @@ class QuestionColumn(ImportColumn):
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Question') + ': ' + str(self.q.internal_name)
|
||||
return _('Question') + ': ' + str(self.q.question)
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
@@ -810,7 +810,7 @@ def get_order_import_columns(event):
|
||||
Expires(event),
|
||||
Comment(event),
|
||||
]
|
||||
for q in event.positiondatafields.prefetch_related('options').exclude(type=Question.TYPE_FILE):
|
||||
for q in event.questions.prefetch_related('options').exclude(type=Question.TYPE_FILE):
|
||||
default.append(QuestionColumn(event, q))
|
||||
|
||||
for recv, resp in order_import_columns.send(sender=event):
|
||||
|
||||
@@ -37,8 +37,8 @@ from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
|
||||
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
|
||||
Questionnaire, QuestionnaireRow, QuestionOption, Quota, SubEventItem,
|
||||
SubEventItemVariation, itempicture_upload_to,
|
||||
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
|
||||
itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .mail import OutgoingMail
|
||||
|
||||
@@ -151,12 +151,29 @@ class LoggingMixin:
|
||||
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event,
|
||||
organizer_id=organizer_id, **kwargs)
|
||||
if isinstance(data, dict):
|
||||
sensitivekeys = ['password', 'secret', 'api_key']
|
||||
# sensitivekeys are matched whether contained, not exactly
|
||||
sensitivekeys = [
|
||||
'auth',
|
||||
'key',
|
||||
'token',
|
||||
'password',
|
||||
'Password',
|
||||
'_pass',
|
||||
'salt',
|
||||
'secret',
|
||||
]
|
||||
|
||||
for sensitivekey in sensitivekeys:
|
||||
for k, v in data.items():
|
||||
if (sensitivekey in k) and v:
|
||||
def clean_recursive(d):
|
||||
data = d
|
||||
data_iterator = d.items() if isinstance(d, dict) else enumerate(d)
|
||||
for k, v in data_iterator:
|
||||
if v and any([sensitivekey in str(k) for sensitivekey in sensitivekeys]):
|
||||
data[k] = "********"
|
||||
elif isinstance(v, (list, dict)):
|
||||
data[k] = clean_recursive(v)
|
||||
return data
|
||||
|
||||
data = clean_recursive(data)
|
||||
|
||||
logentry.data = json.dumps(data, cls=CustomJSONEncoder, sort_keys=True)
|
||||
elif data:
|
||||
|
||||
@@ -83,6 +83,8 @@ from .organizer import Organizer, Team
|
||||
if TYPE_CHECKING:
|
||||
from hierarkey.proxy import HierarkeyProxy
|
||||
|
||||
from .organizer import SalesChannel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -336,12 +338,18 @@ class EventMixin:
|
||||
return safe_string(json.dumps(eventdict))
|
||||
|
||||
@classmethod
|
||||
def annotated(cls, qs, channel, voucher=None):
|
||||
def annotated(cls, qs, channel: "str | SalesChannel | list[SalesChannel]", voucher=None):
|
||||
# Channel can currently be a SalesChannel or a str, since we need that compatibility, but a SalesChannel
|
||||
# makes the query SIGNIFICANTLY faster
|
||||
from pretix.base.models import Item, ItemVariation, Quota, SalesChannel
|
||||
|
||||
assert isinstance(channel, (SalesChannel, str))
|
||||
assert isinstance(channel, (SalesChannel, str, list))
|
||||
|
||||
if isinstance(channel, SalesChannel):
|
||||
channel = [channel]
|
||||
|
||||
if isinstance(channel, list):
|
||||
assert all(isinstance(c, SalesChannel) for c in channel)
|
||||
|
||||
sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).filter(
|
||||
Q(variations__isnull=True)
|
||||
@@ -364,8 +372,8 @@ class EventMixin:
|
||||
q_variation &= Q(Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=channel))
|
||||
q_variation &= Q(Q(item__all_sales_channels=True) | Q(item__limit_sales_channels__identifier=channel))
|
||||
else:
|
||||
q_variation &= Q(Q(all_sales_channels=True) | Q(limit_sales_channels=channel))
|
||||
q_variation &= Q(Q(item__all_sales_channels=True) | Q(item__limit_sales_channels=channel))
|
||||
q_variation &= Q(Q(all_sales_channels=True) | Q(limit_sales_channels__in=channel))
|
||||
q_variation &= Q(Q(item__all_sales_channels=True) | Q(item__limit_sales_channels__in=channel))
|
||||
|
||||
if voucher:
|
||||
if voucher.variation_id:
|
||||
@@ -737,9 +745,6 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.mail_send_order_approved_attendee = True
|
||||
self.settings.mail_send_order_approved_free_attendee = True
|
||||
self.settings.mail_send_download_reminder_attendee = True
|
||||
from . import Questionnaire, QuestionnaireRow
|
||||
q = self.questionnaires.create(internal_name=str(_('Attendee data')), type=Questionnaire.QuestionnaireType.POSITION_PURCHASE, position=0, compat_personalization=True)
|
||||
q.rows.create(label=str(_('Attendee name')), system_datafield=QuestionnaireRow.SystemDatafield.ATTENDEE_NAME_PARTS, position=0)
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
@@ -1429,10 +1434,6 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
return issues
|
||||
|
||||
@property
|
||||
def sales_channels(self):
|
||||
return self.organizer.sales_channels if self.all_sales_channels else self.limit_sales_channels
|
||||
|
||||
def get_users_with_any_permission(self):
|
||||
"""
|
||||
Returns a queryset of users who have any permission to this event.
|
||||
@@ -1554,22 +1555,6 @@ class Event(EventMixin, LoggedModel):
|
||||
if presale_start > presale_end:
|
||||
raise ValidationError(_('The event\'s presale cannot end before it starts.'))
|
||||
|
||||
@property
|
||||
def orderdatafields(self):
|
||||
from . import Question
|
||||
qs = self.questions.filter(container_type=Question.ContainerType.ORDER)
|
||||
if 'questions' in getattr(self, '_prefetched_objects_cache', {}):
|
||||
qs._result_cache = [df for df in self.questions.all() if df.container_type == Question.ContainerType.ORDER]
|
||||
return qs
|
||||
|
||||
@property
|
||||
def positiondatafields(self):
|
||||
from . import Question
|
||||
qs = self.questions.filter(container_type=Question.ContainerType.ORDERPOSITION)
|
||||
if 'questions' in getattr(self, '_prefetched_objects_cache', {}):
|
||||
qs._result_cache = [df for df in self.questions.all() if df.container_type == Question.ContainerType.ORDERPOSITION]
|
||||
return qs
|
||||
|
||||
|
||||
class SubEvent(EventMixin, LoggedModel):
|
||||
"""
|
||||
@@ -1684,11 +1669,17 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
return qs_annotated
|
||||
|
||||
@classmethod
|
||||
def annotated(cls, qs, channel, voucher=None):
|
||||
def annotated(cls, qs, channel: "str | SalesChannel | list[SalesChannel]", voucher=None):
|
||||
from .items import SubEventItem, SubEventItemVariation
|
||||
from .organizer import SalesChannel
|
||||
|
||||
assert isinstance(channel, (str, SalesChannel))
|
||||
assert isinstance(channel, (SalesChannel, str, list))
|
||||
|
||||
if isinstance(channel, SalesChannel):
|
||||
channel = [channel]
|
||||
|
||||
if isinstance(channel, list):
|
||||
assert all(isinstance(c, SalesChannel) for c in channel)
|
||||
|
||||
qs = super().annotated(qs, channel, voucher=voucher)
|
||||
qs = qs.annotate(
|
||||
|
||||
+70
-319
@@ -41,7 +41,6 @@ import warnings
|
||||
from collections import Counter, OrderedDict
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from decimal import Decimal, DecimalException
|
||||
from itertools import groupby
|
||||
from typing import Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -314,7 +313,14 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_
|
||||
# makes the query SIGNIFICANTLY faster
|
||||
from .organizer import SalesChannel
|
||||
|
||||
assert isinstance(channel, (SalesChannel, str))
|
||||
assert isinstance(channel, (SalesChannel, str, list))
|
||||
|
||||
if isinstance(channel, SalesChannel):
|
||||
channel = [channel]
|
||||
|
||||
if isinstance(channel, list):
|
||||
assert all(isinstance(c, SalesChannel) for c in channel)
|
||||
|
||||
q = (
|
||||
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||
# in models/event.py: EventMixin.annotated()
|
||||
@@ -326,7 +332,7 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_
|
||||
if isinstance(channel, str):
|
||||
q &= Q(Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=channel))
|
||||
else:
|
||||
q &= Q(Q(all_sales_channels=True) | Q(limit_sales_channels=channel))
|
||||
q &= Q(Q(all_sales_channels=True) | Q(limit_sales_channels__in=channel))
|
||||
|
||||
if not allow_addons:
|
||||
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
||||
@@ -379,6 +385,8 @@ class Item(LoggedModel):
|
||||
:type tax_rate: decimal.Decimal
|
||||
:param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise)
|
||||
:type admission: bool
|
||||
:param personalized: ``True``, if attendee information should be collected for this ticket
|
||||
:type personalized: bool
|
||||
:param picture: A product picture to be shown next to the product description
|
||||
:type picture: File
|
||||
:param available_from: The date this product goes on sale
|
||||
@@ -531,13 +539,12 @@ class Item(LoggedModel):
|
||||
),
|
||||
default=False
|
||||
)
|
||||
tbd_personalized = models.BooleanField( # TODO(questionnaires): field to be deleted
|
||||
personalized = models.BooleanField(
|
||||
verbose_name=_("Is a personalized ticket"),
|
||||
help_text=_(
|
||||
'Whether or not buying this product allows to enter attendee information'
|
||||
),
|
||||
default=False,
|
||||
db_column='personalized',
|
||||
default=False
|
||||
)
|
||||
generate_tickets = models.BooleanField(
|
||||
verbose_name=_("Generate tickets"),
|
||||
@@ -822,16 +829,9 @@ class Item(LoggedModel):
|
||||
return self.event.settings.show_quota_left
|
||||
return self.show_quota_left
|
||||
|
||||
def get_asked_questions(self, questionnaire_type=None, sales_channel=None):
|
||||
qs = QuestionnaireRow.objects.filter(
|
||||
Q(system_datafield__isnull=False) | Q(user_datafield_id__isnull=False),
|
||||
questionnaire__items=self,
|
||||
) # .prefetch_related('user_datafield')
|
||||
if questionnaire_type is not None:
|
||||
qs = qs.filter(questionnaire__type=questionnaire_type)
|
||||
if sales_channel is not None:
|
||||
qs = qs.filter(Q(questionnaire__all_sales_channels=True) | Q(questionnaire__limit_sales_channels=sales_channel))
|
||||
return qs
|
||||
@property
|
||||
def ask_attendee_data(self):
|
||||
return self.admission and self.personalized
|
||||
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None,
|
||||
include_bundled=False, force_fixed_gross_price=False):
|
||||
@@ -1572,14 +1572,12 @@ class ItemBundle(models.Model):
|
||||
raise ValidationError(_('The count needs to be equal to or greater than zero.'))
|
||||
|
||||
|
||||
class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
|
||||
class Question(LoggedModel):
|
||||
"""
|
||||
A question is a data field that can be used to extend an order or a ticket by custom
|
||||
information, e.g. "Attendee age". To be actually useful, questions need to be added to
|
||||
one or multiple Questionnaires. The answers may be found in QuestionAnswers, attached
|
||||
to Orders, OrderPositions or CartPositions.
|
||||
|
||||
A question can allow one of several input types, currently:
|
||||
A question is an input field that can be used to extend a ticket by custom information,
|
||||
e.g. "Attendee age". The answers are found next to the position. The answers may be found
|
||||
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of
|
||||
several input types, currently:
|
||||
|
||||
* a number (``TYPE_NUMBER``)
|
||||
* a one-line string (``TYPE_STRING``)
|
||||
@@ -1599,7 +1597,7 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
|
||||
:param required: Whether answering this question is required for submitting an order including
|
||||
items associated with this question.
|
||||
:type required: bool
|
||||
:param items: TO BE REMOVED
|
||||
:param items: A set of ``Items`` objects that this question should be applied to
|
||||
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
|
||||
:type ask_during_checkin: bool
|
||||
:param show_during_checkin: Whether to show the answer to this question during check-in.
|
||||
@@ -1616,22 +1614,6 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
|
||||
class ContainerType(models.TextChoices):
|
||||
ORDER = "O", _("Order")
|
||||
ORDERPOSITION = "P", _("Order position")
|
||||
|
||||
class FieldType(models.TextChoices):
|
||||
NUMBER = "N", _("Number")
|
||||
STRING = "S", _("Text (one line)")
|
||||
TEXT = "T", _("Multiline text")
|
||||
BOOLEAN = "B", _("Yes/No")
|
||||
CHOICE = "C", _("Choose one from a list")
|
||||
CHOICE_MULTIPLE = "M", _("Choose multiple from a list")
|
||||
FILE = "F", _("File upload")
|
||||
DATE = "D", _("Date")
|
||||
TIME = "H", _("Time")
|
||||
DATETIME = "W", _("Date and time")
|
||||
COUNTRYCODE = "CC", _("Country code (ISO 3166-1 alpha-2)")
|
||||
PHONENUMBER = "TEL", _("Phone number")
|
||||
|
||||
# compat
|
||||
TYPE_NUMBER = "N"
|
||||
TYPE_STRING = "S"
|
||||
TYPE_TEXT = "T"
|
||||
@@ -1644,12 +1626,23 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
|
||||
TYPE_DATETIME = "W"
|
||||
TYPE_COUNTRYCODE = "CC"
|
||||
TYPE_PHONENUMBER = "TEL"
|
||||
TYPE_CHOICES = FieldType.choices
|
||||
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_NUMBER, _("Number")),
|
||||
(TYPE_STRING, _("Text (one line)")),
|
||||
(TYPE_TEXT, _("Multiline text")),
|
||||
(TYPE_BOOLEAN, _("Yes/No")),
|
||||
(TYPE_CHOICE, _("Choose one from a list")),
|
||||
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list")),
|
||||
(TYPE_FILE, _("File upload")),
|
||||
(TYPE_DATE, _("Date")),
|
||||
(TYPE_TIME, _("Time")),
|
||||
(TYPE_DATETIME, _("Date and time")),
|
||||
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
|
||||
(TYPE_PHONENUMBER, _("Phone number")),
|
||||
)
|
||||
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
|
||||
ASK_DURING_CHECKIN_UNSUPPORTED = []
|
||||
SHOW_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE]
|
||||
TYPES_ALLOWED_AS_DEPENDENCIES = {TYPE_BOOLEAN, TYPE_CHOICE, TYPE_CHOICE_MULTIPLE}
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
@@ -1662,13 +1655,8 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
|
||||
verbose_name=_("Asked on"),
|
||||
default=ContainerType.ORDERPOSITION,
|
||||
)
|
||||
internal_name = models.CharField(
|
||||
verbose_name=_("Internal name"),
|
||||
max_length=255,
|
||||
)
|
||||
tbd_question = I18nTextField( # TODO(questionnaires) : to be removed
|
||||
verbose_name=_("Internal name"),
|
||||
null=True, blank=True, db_column="question",
|
||||
question = I18nTextField(
|
||||
verbose_name=_("Question")
|
||||
)
|
||||
identifier = models.CharField(
|
||||
max_length=190,
|
||||
@@ -1682,63 +1670,54 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
|
||||
),
|
||||
],
|
||||
)
|
||||
#help_text = I18nTextField(
|
||||
# # TODO(questionnaires) : to be removed
|
||||
# verbose_name=_("Help text"),
|
||||
# help_text=_("If the question needs to be explained or clarified, do it here!"),
|
||||
# null=True, blank=True,
|
||||
#)
|
||||
help_text = I18nTextField(
|
||||
verbose_name=_("Help text"),
|
||||
help_text=_("If the question needs to be explained or clarified, do it here!"),
|
||||
null=True, blank=True,
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=5,
|
||||
choices=FieldType.choices,
|
||||
choices=TYPE_CHOICES,
|
||||
verbose_name=_("Question type")
|
||||
)
|
||||
tbd_required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireRow
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Required question"),
|
||||
db_column="required",
|
||||
verbose_name=_("Required question")
|
||||
)
|
||||
# items = models.ManyToManyField( # TODO(questionnaires) : to be removed, -> Questionnaire
|
||||
# Item,
|
||||
# related_name='questions',
|
||||
# verbose_name=_("Products"),
|
||||
# blank=True,
|
||||
# help_text=_('This question will be asked to buyers of the selected products')
|
||||
# )
|
||||
tbd_position = models.PositiveIntegerField( # TODO(questionnaires) : to be removed, -> Questionnaire + QuestionnaireRow
|
||||
items = models.ManyToManyField(
|
||||
Item,
|
||||
related_name='questions',
|
||||
verbose_name=_("Products"),
|
||||
blank=True,
|
||||
help_text=_('This question will be asked to buyers of the selected products')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position"),
|
||||
db_column="position",
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
tbd_ask_during_checkin = models.BooleanField( # TODO(questionnaires) : to be removed
|
||||
ask_during_checkin = models.BooleanField(
|
||||
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
||||
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||
default=False,
|
||||
db_column="ask_during_checkin",
|
||||
default=False
|
||||
)
|
||||
show_during_checkin = models.BooleanField(
|
||||
verbose_name=_('Show answer during check-in'),
|
||||
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||
default=False
|
||||
)
|
||||
tbd_hidden = models.BooleanField( # to be removed
|
||||
hidden = models.BooleanField(
|
||||
verbose_name=_('Hidden question'),
|
||||
help_text=_('This question will only show up in the backend.'),
|
||||
default=False,
|
||||
db_column="hidden",
|
||||
default=False
|
||||
)
|
||||
print_on_invoice = models.BooleanField(
|
||||
verbose_name=_('Print answer on invoices'),
|
||||
default=False
|
||||
)
|
||||
print_on_invoice_label = I18nTextField(
|
||||
verbose_name=_('Label on invoice'),
|
||||
null=True, blank=True,
|
||||
)
|
||||
dependency_question = models.ForeignKey( # TODO(questionnaires) : to be removed, -> QuestionnaireRow
|
||||
dependency_question = models.ForeignKey(
|
||||
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
dependency_values = MultiStringField(default=[]) # TODO(questionnaires) : to be removed, -> QuestionnaireRow
|
||||
dependency_values = MultiStringField(default=[])
|
||||
valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
|
||||
verbose_name=_('Minimum value'),
|
||||
help_text=_('Currently not supported in our apps and during check-in'))
|
||||
@@ -1777,13 +1756,13 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Data field")
|
||||
verbose_name_plural = _("Data fields")
|
||||
ordering = ('internal_name', 'id')
|
||||
verbose_name = _("Question")
|
||||
verbose_name_plural = _("Questions")
|
||||
ordering = ('position', 'id')
|
||||
unique_together = (('event', 'identifier'),)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.internal_name)
|
||||
return str(self.question)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
@@ -1817,12 +1796,15 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
|
||||
|
||||
@property
|
||||
def sortkey(self):
|
||||
return self.id
|
||||
return self.position, self.id
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
return self.sortkey < other.sortkey
|
||||
|
||||
def clean_answer(self, answer):
|
||||
if self.required:
|
||||
if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)):
|
||||
raise ValidationError(_('An answer to this question is required to proceed.'))
|
||||
if not answer:
|
||||
if self.type == Question.TYPE_BOOLEAN:
|
||||
return False
|
||||
@@ -1927,7 +1909,7 @@ class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
|
||||
return answer
|
||||
|
||||
@staticmethod
|
||||
def clean_items(event, items): # TODO(questionnaires) : remove method / move to qc
|
||||
def clean_items(event, items):
|
||||
for item in items:
|
||||
if event != item.event:
|
||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||
@@ -2008,237 +1990,6 @@ class QuestionOption(models.Model):
|
||||
ordering = ('position', 'id')
|
||||
|
||||
|
||||
class Questionnaire(LoggedModel):
|
||||
class QuestionnaireType(models.TextChoices):
|
||||
ORDER_PURCHASE = "order_purchase", _("Order-wide, before purchase")
|
||||
POSITION_PURCHASE = "position_purchase", _("Per product, before purchase")
|
||||
POSITION_ATTENDEE_ONLY = "position_attendee", _("Per product, via attendee link")
|
||||
POSITION_CHECKIN = "position_checkin", _("Per product, at check-in")
|
||||
POSITION_HIDDEN = "position_hidden", _("Per product, hidden")
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
related_name="questionnaires",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
internal_name = models.CharField(
|
||||
verbose_name=_("Internal name"),
|
||||
max_length=255,
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=25,
|
||||
choices=QuestionnaireType.choices,
|
||||
verbose_name=_("Questionnaire type")
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
Item,
|
||||
related_name='questionnaires',
|
||||
verbose_name=_("Products"),
|
||||
blank=True,
|
||||
help_text=_('This questionnaire will be asked to buyers of the selected products')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
all_sales_channels = models.BooleanField(
|
||||
verbose_name=_("Sell on all sales channels the product is sold on"),
|
||||
default=True,
|
||||
)
|
||||
limit_sales_channels = models.ManyToManyField(
|
||||
"SalesChannel",
|
||||
verbose_name=_("Restrict to specific sales channels"),
|
||||
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
|
||||
'selected here but not on product level, the variation will not be available.'),
|
||||
blank=True,
|
||||
)
|
||||
compat_personalization = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ('position', 'id')
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.internal_name
|
||||
|
||||
@property
|
||||
def sortkey(self):
|
||||
return self.position, self.id
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
return self.sortkey < other.sortkey
|
||||
|
||||
@property
|
||||
def ask_before_sale(self):
|
||||
return self.type == Questionnaire.QuestionnaireType.POSITION_PURCHASE
|
||||
|
||||
@property
|
||||
def ask_during_checkin(self):
|
||||
return self.type == Questionnaire.QuestionnaireType.POSITION_CHECKIN
|
||||
|
||||
@property
|
||||
def hidden(self):
|
||||
return self.type == Questionnaire.QuestionnaireType.POSITION_HIDDEN
|
||||
|
||||
@staticmethod
|
||||
def check_constraints(event):
|
||||
errors = set()
|
||||
all_questionnaires = Questionnaire.objects.filter(event=event).prefetch_related('rows', 'items')
|
||||
all_sales_channels = event.sales_channels.all()
|
||||
for (sales_channel, type, item_id, sdf, udf), fields in groupby(sorted(
|
||||
(sales_channel, q.type, item.id, c.system_datafield or '', c.user_datafield_id or 0, q, item)
|
||||
for q in all_questionnaires
|
||||
for sales_channel in (q.limit_sales_channels if not q.all_sales_channels else all_sales_channels).values_list('pk', flat=True)
|
||||
for item in q.items.all()
|
||||
for c in q.rows.all()
|
||||
), key=lambda d: (d[0], d[1], d[2], d[3], d[4])):
|
||||
fields = list(fields)
|
||||
if len(fields) > 1:
|
||||
sales_channel, type, item_id, sdf, udf, q, item = fields[0]
|
||||
questionnaires = set(q for sc, type, item, sdf, udf, q, item in fields)
|
||||
df_label = _('System data field') + f' "{sdf}"' if sdf else _('User-defined data field') + f' "{str(udf)}"'
|
||||
if len(questionnaires) == 1:
|
||||
errors.add(_('{datafield} added twice to questionnaire "{questionnaire}"').format(
|
||||
datafield=df_label, questionnaire=next(str(q) for q in questionnaires)
|
||||
))
|
||||
else:
|
||||
errors.add(_('{datafield} added twice to the same item "{item}" via questionnaires "{questionnaires}"').format(
|
||||
datafield=df_label, item=str(item), questionnaires='" and "'.join(str(q) for q in questionnaires)
|
||||
))
|
||||
if errors:
|
||||
raise ValidationError(list(errors))
|
||||
|
||||
|
||||
class QuestionnaireRow(LoggedModel):
|
||||
class SystemDatafield(models.TextChoices):
|
||||
ATTENDEE_NAME_PARTS = '@attendee_name_parts', _('Attendee name')
|
||||
ATTENDEE_EMAIL = '@attendee_email', _('Attendee email')
|
||||
COMPANY = '@company', _('Company')
|
||||
STREET = '@street', _('Street')
|
||||
ZIPCODE = '@zipcode', _('ZIP code')
|
||||
CITY = '@city', _('City')
|
||||
STATE = '@state', pgettext_lazy('address', 'State')
|
||||
COUNTRY = '@country', _('Country')
|
||||
|
||||
# _name_schemes_all_fields = sorted(list(set(k for scheme in PERSON_NAME_SCHEMES.values() for k, v, w, *__ in scheme['fields'])))
|
||||
_name_schemes_all_fields = ['calling_name', 'degree', 'family_name', 'full_name', 'given_name',
|
||||
'latin_transcription', 'middle_name', 'salutation', 'title']
|
||||
SYSTEM_DATAFIELD_NUMBERS = [
|
||||
# The index in this list is used to create fake IDs for legacy APIs, so do not reorder anything!
|
||||
SystemDatafield.ATTENDEE_NAME_PARTS.value,
|
||||
SystemDatafield.ATTENDEE_EMAIL.value,
|
||||
SystemDatafield.COMPANY.value,
|
||||
SystemDatafield.STREET.value,
|
||||
SystemDatafield.ZIPCODE.value,
|
||||
SystemDatafield.CITY.value,
|
||||
SystemDatafield.STATE.value,
|
||||
SystemDatafield.COUNTRY.value,
|
||||
] + [
|
||||
'@attendee_name_parts:' + field
|
||||
for field in _name_schemes_all_fields
|
||||
]
|
||||
SYSTEM_DATAFIELD_TYPES = {
|
||||
SystemDatafield.ATTENDEE_NAME_PARTS.value: Question.TYPE_STRING,
|
||||
SystemDatafield.ATTENDEE_EMAIL.value: Question.TYPE_STRING,
|
||||
SystemDatafield.COMPANY.value: Question.TYPE_STRING,
|
||||
SystemDatafield.STREET.value: Question.TYPE_STRING,
|
||||
SystemDatafield.ZIPCODE.value: Question.TYPE_STRING,
|
||||
SystemDatafield.CITY.value: Question.TYPE_STRING,
|
||||
SystemDatafield.STATE.value: Question.TYPE_STRING,
|
||||
SystemDatafield.COUNTRY.value: Question.TYPE_COUNTRYCODE,
|
||||
}
|
||||
|
||||
questionnaire = models.ForeignKey(
|
||||
Questionnaire,
|
||||
related_name="rows",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
user_datafield = models.ForeignKey(
|
||||
Question,
|
||||
related_name="referenced_by",
|
||||
on_delete=models.CASCADE,
|
||||
null=True, blank=True,
|
||||
)
|
||||
system_datafield = models.CharField(
|
||||
max_length=25,
|
||||
choices=SystemDatafield.choices,
|
||||
null=True, blank=True,
|
||||
)
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Required question")
|
||||
)
|
||||
label = I18nTextField(
|
||||
verbose_name=_("Question")
|
||||
)
|
||||
help_text = I18nTextField(
|
||||
verbose_name=_("Help text"),
|
||||
help_text=_("If the question needs to be explained or clarified, do it here!"),
|
||||
null=True, blank=True,
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
'QuestionnaireRow', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
dependency_values = MultiStringField(default=[])
|
||||
|
||||
class Meta:
|
||||
ordering = ('position', 'id')
|
||||
|
||||
@property
|
||||
def datafield_type(self):
|
||||
if self.system_datafield:
|
||||
return QuestionnaireRow.SYSTEM_DATAFIELD_TYPES[self.system_datafield]
|
||||
elif self.user_datafield:
|
||||
return self.user_datafield.type
|
||||
|
||||
@property
|
||||
def form_field_name(self):
|
||||
if self.user_datafield_id:
|
||||
return 'question_%s' % self.user_datafield_id
|
||||
elif self.system_datafield:
|
||||
return self.system_datafield[1:]
|
||||
else:
|
||||
return 'text_%s' % self.position
|
||||
|
||||
@property
|
||||
def cooked_id(self):
|
||||
if self.user_datafield_id or self.system_datafield:
|
||||
return QuestionnaireRow.cook_id_from_parts(self.questionnaire_id, self.user_datafield_id, self.system_datafield)
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def cook_id_from_parts(qid, dfid, sys_df):
|
||||
df_id = dfid + 100 if dfid else QuestionnaireRow.SYSTEM_DATAFIELD_NUMBERS.index(sys_df)
|
||||
assert qid < 1000000000
|
||||
return qid * 1000000000 + df_id
|
||||
|
||||
@staticmethod
|
||||
def uncook_id(id):
|
||||
id = int(id)
|
||||
if id > 1000000000:
|
||||
qid, dfid = divmod(id, 1000000000)
|
||||
if dfid >= 100:
|
||||
return qid, dfid - 100, None
|
||||
else:
|
||||
return qid, None, QuestionnaireRow.SYSTEM_DATAFIELD_NUMBERS[dfid]
|
||||
else:
|
||||
return None, id, None
|
||||
|
||||
def clean_answer(self, answer):
|
||||
if not self.user_datafield:
|
||||
raise ValueError('QuestionnaireRow.clean_answer is only implemented for rows referencing a user datafield')
|
||||
|
||||
if self.required:
|
||||
if not answer or (self.user_datafield.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)):
|
||||
raise ValidationError(_('An answer to this question is required to proceed.'))
|
||||
|
||||
return self.user_datafield.clean_answer(answer)
|
||||
|
||||
|
||||
class Quota(LoggedModel):
|
||||
"""
|
||||
A quota is a "pool of tickets". It is there to limit the number of items
|
||||
|
||||
@@ -41,7 +41,7 @@ import logging
|
||||
import operator
|
||||
import string
|
||||
import warnings
|
||||
from collections import Counter, namedtuple
|
||||
from collections import Counter
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from functools import reduce
|
||||
@@ -79,7 +79,7 @@ from pretix.base.banlist import banned
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Customer, Questionnaire, User
|
||||
from pretix.base.models import Customer, User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, ROUNDING_MODES
|
||||
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
|
||||
@@ -95,9 +95,7 @@ from ._transactions import (
|
||||
)
|
||||
from .base import LockModel, LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import (
|
||||
Item, ItemVariation, Question, QuestionnaireRow, QuestionOption, Quota,
|
||||
)
|
||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -110,114 +108,6 @@ def generate_position_secret():
|
||||
raise TypeError("Function no longer exists, use secret generators")
|
||||
|
||||
|
||||
FakeQuestionnaireRow = namedtuple('FakeQuestionnaireRow', ('user_datafield', 'label', 'answer'))
|
||||
|
||||
|
||||
class QuestionnaireRenderer:
|
||||
def __init__(self, qrows, system_answers, answerlist):
|
||||
self.qrows = qrows
|
||||
self.row_cache = {
|
||||
q.pk: q for q in self.qrows
|
||||
}
|
||||
self.system_answers = system_answers
|
||||
self.answerlist = answerlist
|
||||
|
||||
def _row_is_visible(self, parentid, qvals):
|
||||
# TODO(questionnaires): country-dependent address logic
|
||||
if parentid is None:
|
||||
return True
|
||||
if parentid not in self.row_cache:
|
||||
return False
|
||||
parentrow = self.row_cache[parentid]
|
||||
if not self._row_is_visible(parentrow.dependency_question_id, parentrow.dependency_values):
|
||||
return False
|
||||
answer_values = self.get_dependency_answer_values(parentrow)
|
||||
return answer_values and any(qval in answer_values for qval in qvals)
|
||||
|
||||
def row_is_required(self, qrow):
|
||||
# TODO(questionnaires): country-dependent address logic
|
||||
return qrow.required and self._row_is_visible(qrow.dependency_question_id, qrow.dependency_values)
|
||||
|
||||
@cached_property
|
||||
def answer_cache(self):
|
||||
return {
|
||||
**self.system_answers,
|
||||
**{aw.question_id: aw for aw in self.answerlist},
|
||||
}
|
||||
|
||||
def get_system_answer(self, field_name):
|
||||
try:
|
||||
return self.system_answers[field_name]
|
||||
except KeyError:
|
||||
raise KeyError('Unknown system datafield name: ' + field_name)
|
||||
|
||||
def get_dependency_answer_values(self, qc):
|
||||
if qc.user_datafield_id:
|
||||
if qc.user_datafield_id not in self.answer_cache:
|
||||
return None
|
||||
answer = self.answer_cache[qc.user_datafield_id]
|
||||
return answer.to_dependency_values()
|
||||
elif qc.system_datafield:
|
||||
return [self.get_system_answer(qc.system_datafield)]
|
||||
else:
|
||||
raise ValueError('Questionnaire child without datafield has no answer')
|
||||
|
||||
@cached_property
|
||||
def question_data(self) -> List[QuestionnaireRow]:
|
||||
"""
|
||||
Returns a list of QuestionnaireRows extended with `answer` attributes, which contains either a QuestionAnswer
|
||||
object or a string (in case of system data fields).
|
||||
"""
|
||||
question_data = []
|
||||
for qrow in self.qrows:
|
||||
if not (qrow.user_datafield_id or qrow.system_datafield):
|
||||
continue
|
||||
try:
|
||||
qrow.answer = self.answer_cache[qrow.user_datafield_id or qrow.system_datafield]
|
||||
except KeyError:
|
||||
pass
|
||||
if self._row_is_visible(qrow.dependency_question_id, qrow.dependency_values):
|
||||
question_data.append(qrow)
|
||||
return question_data
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.question_data)
|
||||
|
||||
def __bool__(self):
|
||||
return len(self.question_data) > 0
|
||||
|
||||
@cached_property
|
||||
def asked_questions(self):
|
||||
return {qrow.user_datafield_id or qrow.system_datafield for qrow in self.question_data}
|
||||
|
||||
@property
|
||||
def leftovers(self) -> List[FakeQuestionnaireRow]:
|
||||
"""
|
||||
Returns a list of questionnaire-row-like objects for all answers that exist, but are not asked in the
|
||||
questionnaire. These might either exist due to questionnaire changes after orders were already created,
|
||||
or due to programmatically adding answers via the API.
|
||||
"""
|
||||
return [FakeQuestionnaireRow(answer.question, answer.question.internal_name, answer) for answer in self.answerlist if answer.question.id not in self.asked_questions]
|
||||
|
||||
@property
|
||||
def full_address_asked(self):
|
||||
Field = QuestionnaireRow.SystemDatafield
|
||||
return {Field.STREET, Field.ZIPCODE, Field.CITY, Field.STATE, Field.COUNTRY}.issubset(self.asked_questions)
|
||||
|
||||
|
||||
class OrderLevelAnswerableMixin:
|
||||
|
||||
@cached_property
|
||||
def rendered_questions(self):
|
||||
qrows = QuestionnaireRow.objects.filter(
|
||||
Q(system_datafield__isnull=False) | Q(user_datafield_id__isnull=False),
|
||||
Q(questionnaire__all_sales_channels=True) | Q(questionnaire__limit_sales_channels=self.sales_channel),
|
||||
questionnaire__event=self.event,
|
||||
questionnaire__type=Questionnaire.QuestionnaireType.ORDER_PURCHASE,
|
||||
)
|
||||
return QuestionnaireRenderer(qrows=qrows, system_answers={}, answerlist=self.answers.all())
|
||||
|
||||
|
||||
class OrderQuerySet(models.QuerySet):
|
||||
def get_with_secret_check(self, code, received_secret, tag, secret_length=64):
|
||||
dummy = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"[:secret_length]
|
||||
@@ -246,7 +136,7 @@ class OrderQuerySet(models.QuerySet):
|
||||
return order
|
||||
|
||||
|
||||
class Order(OrderLevelAnswerableMixin, LockModel, LoggedModel):
|
||||
class Order(LockModel, LoggedModel):
|
||||
"""
|
||||
An order is created when a user clicks 'buy' on his cart. It holds
|
||||
several OrderPositions and is connected to a user. It has an
|
||||
@@ -1028,7 +918,7 @@ class Order(OrderLevelAnswerableMixin, LockModel, LoggedModel):
|
||||
positions = list(
|
||||
self.positions.all().annotate(
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))
|
||||
).select_related('item').prefetch_related('item__questionnaires')
|
||||
).select_related('item').prefetch_related('item__questions')
|
||||
)
|
||||
if not self.event.settings.allow_modifications_after_checkin:
|
||||
for cp in positions:
|
||||
@@ -1037,8 +927,9 @@ class Order(OrderLevelAnswerableMixin, LockModel, LoggedModel):
|
||||
|
||||
if self.event.settings.get('invoice_address_asked', as_type=bool) or self.event.settings.get('invoice_name_required', as_type=bool):
|
||||
return True
|
||||
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
|
||||
for cp in positions:
|
||||
if cp.item.get_asked_questions(questionnaire_type=Questionnaire.QuestionnaireType.POSITION_PURCHASE):
|
||||
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
|
||||
return True
|
||||
|
||||
return False # nothing there to modify
|
||||
@@ -1503,13 +1394,6 @@ class QuestionAnswer(models.Model):
|
||||
elif self.checkoutsession:
|
||||
return self.checkoutsession.event
|
||||
|
||||
@property
|
||||
def container_type(self):
|
||||
if self.orderposition_id or self.cartposition_id:
|
||||
return Question.ContainerType.ORDERPOSITION
|
||||
elif self.order_id or self.checkoutsession_id:
|
||||
return Question.ContainerType.ORDER
|
||||
|
||||
def __str__(self):
|
||||
return self.to_string(use_cached=True)
|
||||
|
||||
@@ -1565,12 +1449,6 @@ class QuestionAnswer(models.Model):
|
||||
else:
|
||||
return self.answer
|
||||
|
||||
def to_dependency_values(self):
|
||||
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
return [o.identifier for o in self.options.all()]
|
||||
elif self.question.type in (Question.TYPE_BOOLEAN, Question.TYPE_COUNTRYCODE):
|
||||
return self.answer
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.orderposition and self.cartposition:
|
||||
raise ValueError('QuestionAnswer cannot be linked to an order and a cart position at the same time.')
|
||||
@@ -1713,33 +1591,56 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
|
||||
def meta_info_data(self, d):
|
||||
self.meta_info = json.dumps(d)
|
||||
|
||||
def get_questionnaire_renderer(self, questionnaire_type, sales_channel):
|
||||
def cache_answers(self, all=True):
|
||||
"""
|
||||
Creates a new property on the object:
|
||||
question_data: a list of QuestionnaireRow objects, extended by an 'answer' property
|
||||
Creates two properties on the object.
|
||||
(1) answ: a dictionary of question.id → answer string
|
||||
(2) questions: a list of Question objects, extended by an 'answer' property
|
||||
"""
|
||||
self.answ = {}
|
||||
for a in getattr(self, 'answerlist', self.answers.all()): # use prefetch_related cache from get_cart
|
||||
self.answ[a.question_id] = a
|
||||
|
||||
# We need to clone our question objects, otherwise we will override the cached
|
||||
# answers of other items in the same cart if the question objects have been
|
||||
# selected via prefetch_related
|
||||
if hasattr(self.item, 'relevant_questionnaires'):
|
||||
qrows = list(copy.copy(qc) for qq in self.item.relevant_questionnaires for qc in qq.questionnairerowlist)
|
||||
if not all:
|
||||
if hasattr(self.item, 'questions_to_ask'):
|
||||
questions = list(copy.copy(q) for q in self.item.questions_to_ask)
|
||||
else:
|
||||
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
|
||||
hidden=False))
|
||||
else:
|
||||
qrows = list(self.item.get_asked_questions(questionnaire_type=questionnaire_type, sales_channel=sales_channel))
|
||||
return self._cache_question_data(qrows)
|
||||
questions = list(copy.copy(q) for q in self.item.questions.all())
|
||||
|
||||
@cached_property
|
||||
def system_answers(self):
|
||||
return {
|
||||
"@attendee_name_parts": self.attendee_name,
|
||||
"@attendee_email": self.attendee_email,
|
||||
"@company": self.company,
|
||||
"@street": self.street,
|
||||
"@zipcode": self.zipcode,
|
||||
"@city": self.city,
|
||||
"@state": self.state,
|
||||
"@country": str(self.country),
|
||||
question_cache = {
|
||||
q.pk: q for q in questions
|
||||
}
|
||||
|
||||
def question_is_visible(parentid, qvals):
|
||||
if parentid not in question_cache:
|
||||
return False
|
||||
parentq = question_cache[parentid]
|
||||
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
|
||||
return False
|
||||
if parentid not in self.answ:
|
||||
return False
|
||||
return (
|
||||
('True' in qvals and self.answ[parentid].answer == 'True')
|
||||
or ('False' in qvals and self.answ[parentid].answer == 'False')
|
||||
or (any(qval in [o.identifier for o in self.answ[parentid].options.all()] for qval in qvals))
|
||||
)
|
||||
|
||||
self.questions = []
|
||||
for q in questions:
|
||||
if q.id in self.answ:
|
||||
q.answer = self.answ[q.id]
|
||||
q.answer.question = q # cache object
|
||||
else:
|
||||
q.answer = ""
|
||||
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values):
|
||||
self.questions.append(q)
|
||||
|
||||
@property
|
||||
def net_price(self):
|
||||
return self.price - self.tax_value
|
||||
@@ -1751,21 +1652,20 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
|
||||
else self.variation.quotas.filter(subevent=self.subevent))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
def ensure_updated(fieldname):
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {fieldname}.union(kwargs['update_fields'])
|
||||
|
||||
if 'attendee_name_parts' in kwargs.get('update_fields', set()):
|
||||
ensure_updated('attendee_name_cached')
|
||||
update_fields = kwargs.get('update_fields', set())
|
||||
if 'attendee_name_parts' in update_fields:
|
||||
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
|
||||
|
||||
name = self.attendee_name
|
||||
if name != self.attendee_name_cached:
|
||||
self.attendee_name_cached = name
|
||||
ensure_updated('attendee_name_cached')
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
|
||||
|
||||
if self.attendee_name_parts is None:
|
||||
self.attendee_name_parts = {}
|
||||
ensure_updated('attendee_name_parts')
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'attendee_name_parts'}.union(kwargs['update_fields'])
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
@@ -2841,7 +2741,7 @@ class OrderPosition(AbstractPosition):
|
||||
positions = list(
|
||||
self.order.positions.all().annotate(
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))
|
||||
).select_related('item').prefetch_related('item__questionnaires')
|
||||
).select_related('item').prefetch_related('item__questions')
|
||||
)
|
||||
if not self.event.settings.allow_modifications_after_checkin:
|
||||
for cp in positions:
|
||||
@@ -2851,7 +2751,7 @@ class OrderPosition(AbstractPosition):
|
||||
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
|
||||
for cp in positions:
|
||||
if cp.pk == self.pk or cp.addon_to_id == self.pk:
|
||||
if cp.item.questionnaires.all(): # TODO(questionnaires) : filter to only QuestionnaireType.POSITION_PURCHASE
|
||||
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
|
||||
return True
|
||||
|
||||
return False # nothing there to modify
|
||||
@@ -3279,7 +3179,7 @@ class Transaction(models.Model):
|
||||
return self.tax_value_includes_rounding_correction * self.count
|
||||
|
||||
|
||||
class CheckoutSession(OrderLevelAnswerableMixin, models.Model):
|
||||
class CheckoutSession(models.Model):
|
||||
"""
|
||||
A checkout session optionally bundles cart positions with additional information. This is historically
|
||||
not required in pretix and currently only used in the Storefront API.
|
||||
|
||||
@@ -42,7 +42,7 @@ from django.conf import settings
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import MinLengthValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
@@ -312,6 +312,25 @@ class Organizer(LoggedModel):
|
||||
)
|
||||
i += 1
|
||||
|
||||
def get_users_with_permission(self, permission):
|
||||
"""
|
||||
Returns a queryset of users who have a specific permission to this organizer.
|
||||
|
||||
:return: Iterable of User
|
||||
"""
|
||||
from .auth import User
|
||||
|
||||
if permission:
|
||||
qs = Team.objects.with_organizer_permission(permission)
|
||||
else:
|
||||
qs = Team.objects.all()
|
||||
|
||||
team_with_perm = qs.filter(
|
||||
members__pk=OuterRef('pk'),
|
||||
organizer=self,
|
||||
)
|
||||
return User.objects.filter(Exists(team_with_perm))
|
||||
|
||||
|
||||
def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
|
||||
@@ -35,12 +35,13 @@
|
||||
import logging
|
||||
from collections import OrderedDict, namedtuple
|
||||
from itertools import groupby
|
||||
from typing import Optional, Union
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.models import Event, LogEntry
|
||||
from pretix.base.models import Event, LogEntry, Organizer
|
||||
from pretix.base.signals import register_notification_types
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.helpers.urls import mainreverse_absolute
|
||||
@@ -57,7 +58,8 @@ class Notification:
|
||||
"""
|
||||
Represents a notification that is sent/shown to a user. A notification consists of:
|
||||
|
||||
* one ``event`` reference
|
||||
* one ``event`` reference (can be ``Ǹone``)
|
||||
* one ``organizer`` reference
|
||||
* one ``title`` text that is shown e.g. in the email subject or in a headline
|
||||
* optionally one ``detail`` text that may or may not be shown depending on the notification method
|
||||
* optionally one ``url`` that should be absolute and point to the context of an notification (e.g. an order)
|
||||
@@ -67,8 +69,12 @@ class Notification:
|
||||
each consisting of a button label and an absolute URL to point to.
|
||||
"""
|
||||
|
||||
def __init__(self, event: Event, title: str, detail: str=None, url: str=None):
|
||||
def __init__(self, event: Optional[Event], title: str, detail: Optional[str]=None, url: Optional[str]=None, organizer: Optional[Organizer]=None):
|
||||
assert event or organizer, "You need to pass either event or organizer"
|
||||
if event and organizer:
|
||||
assert event.organizer_id == organizer.id, "Organizer and event mismatch"
|
||||
self.title = title
|
||||
self.organizer = organizer or event.organizer
|
||||
self.event = event
|
||||
self.detail = detail
|
||||
self.url = url
|
||||
@@ -91,8 +97,16 @@ class Notification:
|
||||
|
||||
|
||||
class NotificationType:
|
||||
def __init__(self, event: Event = None):
|
||||
self.event = event
|
||||
def __init__(self, event_or_organizer: Union[Event, Organizer] = None):
|
||||
if isinstance(event_or_organizer, Event):
|
||||
self.event = event_or_organizer
|
||||
self.organizer = event_or_organizer.organizer
|
||||
elif isinstance(event_or_organizer, Organizer):
|
||||
self.event = None
|
||||
self.organizer = event_or_organizer
|
||||
else:
|
||||
self.event = None
|
||||
self.organizer = None
|
||||
|
||||
def __repr__(self):
|
||||
return '<NotificationType: {}>'.format(self.action_type)
|
||||
@@ -121,31 +135,39 @@ class NotificationType:
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def is_event_level(self) -> bool:
|
||||
"""
|
||||
Return `True` if this notification type can be configured per event (the default).
|
||||
"""
|
||||
return True
|
||||
|
||||
def build_notification(self, logentry: LogEntry) -> Notification:
|
||||
"""
|
||||
This is the main function that you should override. It is supposed to turn a log entry
|
||||
object into a notification object that can then be rendered e.g. into an email.
|
||||
"""
|
||||
return Notification(
|
||||
logentry.event,
|
||||
logentry.display()
|
||||
event=logentry.event,
|
||||
title=logentry.display(),
|
||||
organizer=logentry.organizer,
|
||||
)
|
||||
|
||||
|
||||
def get_all_notification_types(event=None):
|
||||
def get_all_notification_types(event_or_organizer=None):
|
||||
global _ALL_TYPES
|
||||
|
||||
if event is None and _ALL_TYPES:
|
||||
if event_or_organizer is None and _ALL_TYPES:
|
||||
return _ALL_TYPES
|
||||
|
||||
types = OrderedDict()
|
||||
for recv, ret in register_notification_types.send(event):
|
||||
for recv, ret in register_notification_types.send(event_or_organizer):
|
||||
if isinstance(ret, (list, tuple)):
|
||||
for r in ret:
|
||||
types[r.action_type] = r
|
||||
else:
|
||||
types[ret.action_type] = ret
|
||||
if event is None:
|
||||
if event_or_organizer is None:
|
||||
_ALL_TYPES = types
|
||||
return types
|
||||
|
||||
@@ -181,6 +203,7 @@ class ParametrizedOrderNotificationType(NotificationType):
|
||||
|
||||
n = Notification(
|
||||
event=logentry.event,
|
||||
organizer=logentry.organizer,
|
||||
title=self._title.format(order=order, event=logentry.event),
|
||||
url=order_url
|
||||
)
|
||||
|
||||
+38
-32
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.presale.views import get_cart_positions
|
||||
from pretix.presale.views import get_cart
|
||||
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1165,7 +1165,7 @@ class FreeOrderProvider(BasePaymentProvider):
|
||||
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
|
||||
from .services.cart import get_fees
|
||||
|
||||
cart = get_cart_positions(request)
|
||||
cart = get_cart(request)
|
||||
|
||||
try:
|
||||
fees = get_fees(event=request.event, request=request,
|
||||
@@ -1433,7 +1433,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
for p in cs.get('payments', [])
|
||||
if p.get('info_data', {}).get('gift_card')
|
||||
]
|
||||
positions = get_cart_positions(request)
|
||||
positions = get_cart(request)
|
||||
testmode = self.event.testmode
|
||||
else:
|
||||
used_cards = []
|
||||
@@ -1527,42 +1527,37 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
})
|
||||
|
||||
def refund_control_render(self, request, refund) -> str:
|
||||
from .models import GiftCard
|
||||
|
||||
if 'gift_card' in refund.info_data:
|
||||
gc = GiftCard.objects.get(pk=refund.info_data.get('gift_card'))
|
||||
template = get_template('pretixcontrol/giftcards/payment.html')
|
||||
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
'gc': gc,
|
||||
}
|
||||
try:
|
||||
gc = self.event.organizer.accepted_gift_cards.get(pk=refund.info_data.get('gift_card'))
|
||||
ctx['gc'] = gc
|
||||
except GiftCard.DoesNotExist:
|
||||
pass
|
||||
return template.render(ctx)
|
||||
|
||||
def payment_control_render(self, request, payment) -> str:
|
||||
from .models import GiftCard
|
||||
template = get_template('pretixcontrol/giftcards/payment.html')
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
**({'error': payment.info_data[
|
||||
'error']} if 'error' in payment.info_data else {}),
|
||||
**({'gift_card_secret': payment.info_data[
|
||||
'gift_card_secret']} if 'gift_card_secret' in payment.info_data else {})
|
||||
}
|
||||
|
||||
if any(key in payment.info_data for key in ('gift_card', 'error')):
|
||||
template = get_template('pretixcontrol/giftcards/payment.html')
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
**({'error': payment.info_data[
|
||||
'error']} if 'error' in payment.info_data else {}),
|
||||
**({'gift_card_secret': payment.info_data[
|
||||
'gift_card_secret']} if 'gift_card_secret' in payment.info_data else {})
|
||||
}
|
||||
try:
|
||||
ctx['gc'] = self.event.organizer.accepted_gift_cards.get(pk=payment.info_data.get('gift_card'))
|
||||
except GiftCard.DoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
ctx = {
|
||||
'gc': gc,
|
||||
}
|
||||
except GiftCard.DoesNotExist:
|
||||
pass
|
||||
finally:
|
||||
return template.render(ctx)
|
||||
if any(key in ctx for key in ('gc', 'gift_card_secret', 'error')):
|
||||
return template.render(ctx)
|
||||
|
||||
def payment_control_render_short(self, payment: OrderPayment) -> str:
|
||||
d = payment.info_data
|
||||
@@ -1572,10 +1567,9 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
d = refund.info_data
|
||||
return d.get('gift_card_secret', d.get('gift_card_code', self.public_name))
|
||||
|
||||
def api_payment_details(self, payment: OrderPayment):
|
||||
from .models import GiftCard
|
||||
def api_payment_details(self, payment: OrderPayment | OrderRefund):
|
||||
try:
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
gc = self.event.organizer.accepted_gift_cards.get(pk=payment.info_data.get('gift_card'))
|
||||
except GiftCard.DoesNotExist:
|
||||
return {
|
||||
**({'error': payment.info_data[
|
||||
@@ -1698,7 +1692,19 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
@transaction.atomic()
|
||||
def execute_refund(self, refund: OrderRefund):
|
||||
from .models import GiftCard
|
||||
gc = GiftCard.objects.get(pk=refund.info_data.get('gift_card') or refund.payment.info_data.get('gift_card'))
|
||||
try:
|
||||
gc = self.event.organizer.accepted_gift_cards.get(
|
||||
pk=refund.info_data.get('gift_card') or refund.payment.info_data.get('gift_card'),
|
||||
currency=self.event.currency
|
||||
)
|
||||
except GiftCard.DoesNotExist:
|
||||
raise PaymentException(_("Gift card not found."))
|
||||
|
||||
if gc.testmode != refund.order.testmode:
|
||||
raise PaymentException(_("Only test gift card can be used in test mode and vice versa."))
|
||||
if gc.expires and gc.expires < time_machine_now():
|
||||
raise PaymentException(_("This gift card is no longer valid."))
|
||||
|
||||
trans = gc.transactions.create(
|
||||
value=refund.amount,
|
||||
order=refund.order,
|
||||
|
||||
@@ -581,11 +581,11 @@ def images_from_questions(sender, *args, **kwargs):
|
||||
return a.file
|
||||
|
||||
d = {}
|
||||
for q in sender.positiondatafields:
|
||||
for q in sender.questions.all():
|
||||
if q.type != Question.TYPE_FILE:
|
||||
continue
|
||||
d['question_{}'.format(q.identifier)] = {
|
||||
'label': _('Data field: {question}').format(question=q.internal_name),
|
||||
'label': _('Question: {question}').format(question=q.question),
|
||||
'evaluate': partial(get_answer, question_id=q.pk, etag=False),
|
||||
'etag': partial(get_answer, question_id=q.pk, etag=True),
|
||||
}
|
||||
@@ -619,18 +619,18 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
return a.to_string_i18n()
|
||||
|
||||
d = {}
|
||||
for q in sender.positiondatafields:
|
||||
for q in sender.questions.all():
|
||||
if q.type == Question.TYPE_FILE:
|
||||
continue
|
||||
d['question_{}'.format(q.identifier)] = {
|
||||
'label': _('Data field: {question}').format(question=q.internal_name),
|
||||
'editor_sample': _('<Answer: {question}>').format(question=q.internal_name),
|
||||
'label': _('Question: {question}').format(question=q.question),
|
||||
'editor_sample': _('<Answer: {question}>').format(question=q.question),
|
||||
'evaluate': partial(get_answer, question_id=q.pk),
|
||||
'migrate_from': 'question_{}'.format(q.pk)
|
||||
}
|
||||
d['question_{}'.format(q.pk)] = {
|
||||
'label': _('Data field: {question}').format(question=q.internal_name),
|
||||
'editor_sample': _('<Answer: {question}>').format(question=q.internal_name),
|
||||
'label': _('Question: {question}').format(question=q.question),
|
||||
'editor_sample': _('<Answer: {question}>').format(question=q.question),
|
||||
'evaluate': partial(get_answer, question_id=q.pk),
|
||||
'hidden': True,
|
||||
}
|
||||
|
||||
@@ -330,5 +330,6 @@ def register_default_organizer_permissions(sender, **kwargs):
|
||||
PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "No access")),
|
||||
PermissionOption(actions=("read",), label=pgettext_lazy("permission_level", "View")),
|
||||
],
|
||||
help_text=_("View access includes the option to e.g. abort pending emails or retry failed emails."),
|
||||
),
|
||||
]
|
||||
@@ -22,9 +22,9 @@
|
||||
import os
|
||||
import sys
|
||||
from enum import Enum
|
||||
from importlib import metadata
|
||||
from typing import List
|
||||
|
||||
import importlib_metadata as metadata
|
||||
from django.apps import AppConfig, apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
@@ -1410,11 +1410,10 @@ class CartManager:
|
||||
for k, v in self._widget_data.items():
|
||||
if not k.startswith('question-'):
|
||||
continue
|
||||
# TODO(questionnaires) - switch to QuestionnaireRow
|
||||
q = cp.item.questions.filter(ask_during_checkin=False, identifier__iexact=k[9:]).first()
|
||||
if q:
|
||||
try:
|
||||
cp._answers[q] = q.clean_answer(v) # TODO(questionnaires) - switch to row.clean_answer
|
||||
cp._answers[q] = q.clean_answer(v)
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import partial, reduce
|
||||
from typing import List
|
||||
|
||||
import dateutil
|
||||
import dateutil.parser
|
||||
@@ -56,9 +55,8 @@ from django_scopes import scope, scopes_disabled
|
||||
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Device, Event, Gate, Item, ItemVariation, Order,
|
||||
OrderPosition, Questionnaire, QuestionnaireRow, QuestionOption,
|
||||
OrderPosition, QuestionOption,
|
||||
)
|
||||
from pretix.base.models.orders import QuestionnaireRenderer
|
||||
from pretix.base.signals import checkin_created, periodic_task
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.database import conditional_atomic
|
||||
@@ -863,10 +861,10 @@ class CheckInError(Exception):
|
||||
|
||||
|
||||
class RequiredQuestionsError(Exception):
|
||||
def __init__(self, msg, code, questionnaire_rows: List[QuestionnaireRow]):
|
||||
def __init__(self, msg, code, questions):
|
||||
self.msg = msg
|
||||
self.code = code
|
||||
self.questionnaire_rows = questionnaire_rows
|
||||
self.questions = questions
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
@@ -899,14 +897,14 @@ def _save_answers(op, answers, given_answers):
|
||||
written = False
|
||||
for q, a in given_answers.items():
|
||||
if not a:
|
||||
if q.id in answers:
|
||||
if q in answers:
|
||||
written = True
|
||||
answers[q.id].delete()
|
||||
answers[q].delete()
|
||||
else:
|
||||
continue
|
||||
if isinstance(a, QuestionOption):
|
||||
if q.id in answers:
|
||||
qa = answers[q.id]
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
qa.answer = str(a.answer)
|
||||
qa.save()
|
||||
written = True
|
||||
@@ -915,8 +913,8 @@ def _save_answers(op, answers, given_answers):
|
||||
qa = _create_answer(question=q, answer=str(a.answer))
|
||||
qa.options.add(a)
|
||||
elif isinstance(a, list):
|
||||
if q.id in answers:
|
||||
qa = answers[q.id]
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
qa.answer = ", ".join([str(o) for o in a])
|
||||
qa.save()
|
||||
written = True
|
||||
@@ -925,8 +923,8 @@ def _save_answers(op, answers, given_answers):
|
||||
qa = _create_answer(question=q, answer=", ".join([str(o) for o in a]))
|
||||
qa.options.add(*a)
|
||||
elif isinstance(a, File):
|
||||
if q.id in answers:
|
||||
qa = answers[q.id]
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
else:
|
||||
qa = _create_answer(question=q, answer=str(a))
|
||||
qa.file.save(os.path.basename(a.name), a, save=False)
|
||||
@@ -934,8 +932,8 @@ def _save_answers(op, answers, given_answers):
|
||||
qa.save()
|
||||
written = True
|
||||
else:
|
||||
if q.id in answers:
|
||||
qa = answers[q.id]
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
qa.answer = str(a)
|
||||
qa.save()
|
||||
else:
|
||||
@@ -1035,20 +1033,17 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
|
||||
# Do this outside of transaction so it is saved even if the checkin fails for some other reason
|
||||
checkin_questions = list(
|
||||
QuestionnaireRow.objects.filter(
|
||||
questionnaire__event=clist.event, questionnaire__type=Questionnaire.QuestionnaireType.POSITION_CHECKIN,
|
||||
questionnaire__items__in=[op.item_id],
|
||||
).select_related('user_datafield', 'questionnaire')
|
||||
clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id])
|
||||
)
|
||||
require_answers = []
|
||||
if type != Checkin.TYPE_EXIT and checkin_questions:
|
||||
qrenderer = QuestionnaireRenderer(qrows=[], system_answers=op.system_answers, answerlist=op.answers.all())
|
||||
answers = {a.question: a for a in op.answers.all()}
|
||||
for q in checkin_questions:
|
||||
if q.user_datafield not in given_answers and q.user_datafield_id not in qrenderer.answer_cache:
|
||||
if q not in given_answers and q not in answers:
|
||||
require_answers.append(q)
|
||||
|
||||
if not simulate:
|
||||
_save_answers(op, qrenderer.answer_cache, given_answers)
|
||||
_save_answers(op, answers, given_answers)
|
||||
|
||||
with conditional_atomic(not simulate):
|
||||
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
|
||||
@@ -1115,7 +1110,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
raise RequiredQuestionsError(
|
||||
_('You need to answer questions to complete this check-in.'),
|
||||
'incomplete',
|
||||
questionnaire_rows=require_answers,
|
||||
require_answers
|
||||
)
|
||||
|
||||
required_media_policy = op.item.media_policy
|
||||
|
||||
@@ -279,13 +279,13 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
).select_related(
|
||||
'question'
|
||||
).order_by(
|
||||
'question__internal_name',
|
||||
'question__position',
|
||||
'question__id'
|
||||
)
|
||||
for answ in answers_qs:
|
||||
desc += "<br />{}{} {}".format(
|
||||
answ.question.print_on_invoice_label,
|
||||
"" if str(answ.question.print_on_invoice_label).endswith("?") else ":",
|
||||
answ.question.question,
|
||||
"" if str(answ.question.question).endswith("?") else ":",
|
||||
answ.to_string_i18n()
|
||||
)
|
||||
|
||||
|
||||
@@ -316,6 +316,7 @@ def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString],
|
||||
body_html = None
|
||||
|
||||
m = OutgoingMail.objects.create(
|
||||
guid=guid,
|
||||
organizer=organizer,
|
||||
event=event,
|
||||
order=order,
|
||||
|
||||
@@ -34,7 +34,9 @@ from pretix.base.models import (
|
||||
from pretix.base.notifications import Notification, get_all_notification_types
|
||||
from pretix.base.services.mail import mail_send_task
|
||||
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
||||
from pretix.base.signals import notification
|
||||
from pretix.base.signals import (
|
||||
event_notification_sent, organizer_notification_sent,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.celery import get_task_priority
|
||||
from pretix.helpers.urls import mainreverse_absolute
|
||||
@@ -47,40 +49,47 @@ def notify(logentry_ids: list):
|
||||
logentry_ids = [logentry_ids]
|
||||
|
||||
qs = LogEntry.all.select_related(
|
||||
'event', 'event__organizer'
|
||||
'event', 'event__organizer', 'organizer'
|
||||
).order_by(
|
||||
'action_type', 'event_id',
|
||||
'action_type', 'event_id', 'organizer_id',
|
||||
).filter(id__in=logentry_ids)
|
||||
|
||||
_event, _at, notify_specific, notify_global = None, None, None, None
|
||||
_event, _organizer, _at, notify_specific, notify_global = None, None, None, None, None
|
||||
for logentry in qs:
|
||||
if not logentry.event:
|
||||
break # Ignore, we only have event-related notifications right now
|
||||
if not logentry.event and not logentry.organizer:
|
||||
break # Ignore, we only have event- or organizer-related notifications right now
|
||||
|
||||
notification_type = logentry.notification_type
|
||||
|
||||
if not notification_type:
|
||||
break # No suitable plugin
|
||||
|
||||
if _event != logentry.event or _at != logentry.action_type or notify_global is None:
|
||||
if _event != logentry.event or _organizer != logentry.organizer or _at != logentry.action_type or notify_global is None:
|
||||
_event = logentry.event
|
||||
_organizer = logentry.organizer
|
||||
_at = logentry.action_type
|
||||
|
||||
event_or_organizer = logentry.event or logentry.organizer
|
||||
# All users that have the permission to get the notification
|
||||
users = logentry.event.get_users_with_permission(
|
||||
users = event_or_organizer.get_users_with_permission(
|
||||
notification_type.required_permission
|
||||
).filter(notifications_send=True, is_active=True)
|
||||
|
||||
if logentry.user:
|
||||
users = users.exclude(pk=logentry.user.pk)
|
||||
|
||||
# Get all notification settings, both specific to this event as well as global
|
||||
notify_specific = {
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
event=logentry.event,
|
||||
action_type=notification_type.action_type,
|
||||
user__pk__in=users.values_list('pk', flat=True)
|
||||
)
|
||||
}
|
||||
if logentry.event:
|
||||
notify_specific = {
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
event=logentry.event,
|
||||
action_type=notification_type.action_type,
|
||||
user__pk__in=users.values_list('pk', flat=True)
|
||||
)
|
||||
}
|
||||
else:
|
||||
notify_specific = {}
|
||||
notify_global = {
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
@@ -106,7 +115,10 @@ def notify(logentry_ids: list):
|
||||
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||
)
|
||||
|
||||
notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
|
||||
if logentry.event:
|
||||
event_notification_sent.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
|
||||
elif logentry.organizer:
|
||||
organizer_notification_sent.send(logentry.organizer, logentry_id=logentry.id, notification_type=notification_type.action_type)
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, acks_late=True, max_retries=9, default_retry_delay=900)
|
||||
@@ -158,13 +170,20 @@ def send_notification_mail(notification: Notification, user: User):
|
||||
body_plain = tpl_plain.render(ctx)
|
||||
|
||||
guid = uuid.uuid4()
|
||||
settings_holder = notification.event or notification.organizer
|
||||
prefix = settings_holder.settings.mail_prefix
|
||||
if not prefix:
|
||||
if notification.event:
|
||||
prefix = notification.event.slug.upper()
|
||||
elif notification.organizer:
|
||||
prefix = notification.organizer.name
|
||||
m = OutgoingMail.objects.create(
|
||||
guid=guid,
|
||||
user=user,
|
||||
to=[user.email],
|
||||
subject='[{}] {}: {}'.format(
|
||||
settings.PRETIX_INSTANCE_NAME,
|
||||
notification.event.settings.mail_prefix or notification.event.slug.upper(),
|
||||
prefix,
|
||||
notification.title
|
||||
),
|
||||
body_plain=body_plain,
|
||||
|
||||
@@ -359,7 +359,8 @@ def mark_order_refunded(order, user=None, auth=None, api_token=None):
|
||||
|
||||
def mark_order_expired(order, user=None, auth=None):
|
||||
"""
|
||||
Mark this order as expired. This sets the payment status and returns the order object.
|
||||
Mark this order as expired. This sets the order status, generates an invoice cancellation
|
||||
and returns the order object.
|
||||
:param order: The order to change
|
||||
:param user: The user that performed the change
|
||||
"""
|
||||
@@ -969,7 +970,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
|
||||
gift_cards = [] # for backwards compatibility
|
||||
for p in payment_requests:
|
||||
if p['provider'] == 'giftcard':
|
||||
gift_cards.append(GiftCard.objects.get(pk=p['info_data']['gift_card']))
|
||||
gift_cards.extend(event.organizer.accepted_gift_cards.filter(pk=p['info_data']['gift_card']))
|
||||
|
||||
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total, payment_requests=payment_requests,
|
||||
meta_info=meta_info, positions=positions, gift_cards=gift_cards):
|
||||
@@ -2175,7 +2176,8 @@ class OrderChangeManager:
|
||||
subevent=a.subevent,
|
||||
voucher=None,
|
||||
base_qs=Item.objects.filter(pk=a.item.pk),
|
||||
allow_addons=True
|
||||
allow_addons=True,
|
||||
ignore_hide_sold_out_for_item_ids={a.item.pk}
|
||||
)
|
||||
if (not items) or items[0].current_unavailability_reason:
|
||||
return False
|
||||
@@ -2474,7 +2476,11 @@ class OrderChangeManager:
|
||||
self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={
|
||||
'fee': fee.pk,
|
||||
'old_price': fee.value,
|
||||
'new_price': op.value.gross
|
||||
'new_price': op.value.gross,
|
||||
'old_tax_rate': fee.tax_rate,
|
||||
'new_tax_rate': op.value.rate,
|
||||
'old_tax_value': fee.tax_value,
|
||||
'new_tax_value': op.value.tax,
|
||||
})
|
||||
fee.value = op.value.gross
|
||||
fee._calculate_tax()
|
||||
@@ -2484,9 +2490,13 @@ class OrderChangeManager:
|
||||
self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={
|
||||
'position': position.pk,
|
||||
'positionid': position.positionid,
|
||||
'old_price': position.price,
|
||||
'addon_to': position.addon_to_id,
|
||||
'new_price': op.price.gross
|
||||
'old_price': position.price,
|
||||
'new_price': op.price.gross,
|
||||
'old_tax_rate': position.tax_rate,
|
||||
'new_tax_rate': op.price.rate,
|
||||
'old_tax_value': position.tax_value,
|
||||
'new_tax_value': op.price.tax,
|
||||
})
|
||||
position.price = op.price.gross
|
||||
position.price_includes_rounding_correction = Decimal("0.00")
|
||||
@@ -2501,25 +2511,43 @@ class OrderChangeManager:
|
||||
elif isinstance(op, self.TaxRuleOperation):
|
||||
if isinstance(op.position, OrderPosition):
|
||||
position = position_cache.setdefault(op.position.pk, op.position)
|
||||
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={
|
||||
log = {
|
||||
'position': position.pk,
|
||||
'positionid': position.positionid,
|
||||
'addon_to': position.addon_to_id,
|
||||
'old_taxrule': position.tax_rule.pk if position.tax_rule else None,
|
||||
'new_taxrule': op.tax_rule.pk
|
||||
})
|
||||
'new_taxrule': op.tax_rule.pk,
|
||||
'old_price': position.price,
|
||||
'old_tax_rate': position.tax_rate,
|
||||
'old_tax_value': position.tax_value,
|
||||
}
|
||||
position._calculate_tax(op.tax_rule)
|
||||
position.save()
|
||||
log.update({
|
||||
'new_price': position.price,
|
||||
'new_tax_rate': position.tax_rate,
|
||||
'new_tax_value': position.tax_value,
|
||||
})
|
||||
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data=log)
|
||||
elif isinstance(op.position, OrderFee):
|
||||
fee = fee_cache.setdefault(op.position.pk, op.position)
|
||||
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={
|
||||
log = {
|
||||
'fee': fee.pk,
|
||||
'fee_type': fee.fee_type,
|
||||
'old_taxrule': fee.tax_rule.pk if fee.tax_rule else None,
|
||||
'new_taxrule': op.tax_rule.pk
|
||||
})
|
||||
'new_taxrule': op.tax_rule.pk,
|
||||
'old_price': fee.value,
|
||||
'old_tax_rate': fee.tax_rate,
|
||||
'old_tax_value': fee.tax_value,
|
||||
}
|
||||
fee._calculate_tax(op.tax_rule)
|
||||
fee.save()
|
||||
log.update({
|
||||
'new_price': fee.value,
|
||||
'new_tax_rate': fee.tax_rate,
|
||||
'new_tax_value': fee.tax_value,
|
||||
})
|
||||
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data=log)
|
||||
elif isinstance(op, self.CancelFeeOperation):
|
||||
fee = fee_cache.setdefault(op.fee.pk, op.fee)
|
||||
self.order.log_action('pretix.event.order.changed.cancelfee', user=self.user, auth=self.auth, data={
|
||||
|
||||
@@ -752,6 +752,11 @@ def base_placeholders(sender, **kwargs):
|
||||
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
|
||||
lambda event: concatenation_for_salutation(name_scheme['sample']),
|
||||
))
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
"attendee_name_for_salutation", ["position"],
|
||||
lambda position: concatenation_for_salutation(position.attendee_name_parts),
|
||||
lambda event: concatenation_for_salutation(name_scheme['sample']),
|
||||
))
|
||||
|
||||
for f, l, w in name_scheme['fields']:
|
||||
if f == 'full_name':
|
||||
|
||||
@@ -126,10 +126,10 @@ def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
|
||||
for s in current_seats.values():
|
||||
if s.has_op:
|
||||
raise SeatProtected(_('You can not change the plan since seat "%s" is not present in the new plan and is '
|
||||
'already sold.', s.name))
|
||||
'already sold.'), s.name)
|
||||
if s.has_v:
|
||||
raise SeatProtected(_('You can not change the plan since seat "%s" is not present in the new plan and is '
|
||||
'already used in a voucher.', s.name))
|
||||
'already used in a voucher.'), s.name)
|
||||
|
||||
Seat.objects.bulk_create(create_seats)
|
||||
CartPosition.objects.filter(addon_to__seat__in=[s.pk for s in current_seats.values()]).delete()
|
||||
|
||||
+114
-7
@@ -379,12 +379,99 @@ DEFAULTS = {
|
||||
|
||||
)
|
||||
},
|
||||
# 'system_question_order': { # TODO(questionnaires) - remove this
|
||||
# 'default': {},
|
||||
# 'type': dict,
|
||||
# 'serializer_class': serializers.DictField,
|
||||
# 'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True),
|
||||
# },
|
||||
'system_question_order': {
|
||||
'default': {},
|
||||
'type': dict,
|
||||
'serializer_class': serializers.DictField,
|
||||
'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True),
|
||||
},
|
||||
'attendee_names_asked': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for attendee names"),
|
||||
help_text=_("Ask for a name for all personalized tickets."),
|
||||
)
|
||||
},
|
||||
'attendee_names_required': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require attendee names"),
|
||||
help_text=_("Require customers to fill in the names of all attendees."),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}),
|
||||
)
|
||||
},
|
||||
'attendee_emails_asked': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for email addresses per ticket"),
|
||||
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
|
||||
"only to that email address. If you enable this option, the system will additionally ask for "
|
||||
"individual email addresses for every personalized ticket. This might be useful if you want to "
|
||||
"obtain individual addresses for every attendee even in case of group orders. However, "
|
||||
"pretix will send the order confirmation by default only to the one primary email address, not to "
|
||||
"the per-attendee addresses. You can however enable this in the email settings."),
|
||||
)
|
||||
},
|
||||
'attendee_emails_required': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require email addresses per ticket"),
|
||||
help_text=_("Require customers to fill in individual email addresses for all personalized tickets. See the "
|
||||
"above option for more details. One email address for the order confirmation will always be "
|
||||
"required regardless of this setting."),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
|
||||
)
|
||||
},
|
||||
'attendee_company_asked': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for company per ticket"),
|
||||
)
|
||||
},
|
||||
'attendee_company_required': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require company per ticket"),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_company_asked'}),
|
||||
)
|
||||
},
|
||||
'attendee_addresses_asked': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for postal addresses per ticket"),
|
||||
)
|
||||
},
|
||||
'attendee_addresses_required': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require postal addresses per ticket"),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_addresses_asked'}),
|
||||
)
|
||||
},
|
||||
'order_email_asked_twice': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
@@ -3351,6 +3438,19 @@ Your {organizer} team""")) # noqa: W291
|
||||
"how to obtain a voucher code.")
|
||||
)
|
||||
},
|
||||
'attendee_data_explanation_text': {
|
||||
'default': '',
|
||||
'type': LazyI18nString,
|
||||
'serializer_class': I18nField,
|
||||
'form_class': I18nFormField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Attendee data explanation"),
|
||||
widget=I18nMarkdownTextarea,
|
||||
widget_kwargs={'attrs': {'rows': '2'}},
|
||||
help_text=_("This text will be shown above the questions asked for every personalized product. You can use it e.g. to explain "
|
||||
"why you need information from them.")
|
||||
)
|
||||
},
|
||||
'checkout_success_text': {
|
||||
'default': '',
|
||||
'type': LazyI18nString,
|
||||
@@ -3956,7 +4056,6 @@ PERSON_NAME_SCHEMES = OrderedDict([
|
||||
'_scheme': 'salutation_title_given_family_degree',
|
||||
},
|
||||
}),
|
||||
# If new schemes add new fields, update QuestionnaireRow._name_schemes_all_fields
|
||||
])
|
||||
|
||||
DEFAULTS['name_scheme']['serializer_kwargs']['choices'] = ((k, k) for k in PERSON_NAME_SCHEMES)
|
||||
@@ -4073,6 +4172,14 @@ def validate_event_settings(event, settings_dict):
|
||||
raise ValidationError({
|
||||
'locale': _('Your default locale must also be enabled for your event (see box above).')
|
||||
})
|
||||
if settings_dict.get('attendee_names_required') and not settings_dict.get('attendee_names_asked'):
|
||||
raise ValidationError({
|
||||
'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.')
|
||||
})
|
||||
if settings_dict.get('attendee_emails_required') and not settings_dict.get('attendee_emails_asked'):
|
||||
raise ValidationError({
|
||||
'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.')
|
||||
})
|
||||
if settings_dict.get('invoice_address_required') and not settings_dict.get('invoice_address_asked'):
|
||||
raise ValidationError({
|
||||
'invoice_address_required': _('You have to ask for invoice addresses if you want to make them required.')
|
||||
|
||||
@@ -605,15 +605,14 @@ subclass of pretix.base.ticketoutput.BaseTicketOutput
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
register_notification_types = EventPluginSignal()
|
||||
register_notification_types = GlobalSignal()
|
||||
"""
|
||||
This signal is sent out to get all known notification types. Receivers should return an
|
||||
instance of a subclass of pretix.base.notifications.NotificationType or a list of such
|
||||
instances.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event,
|
||||
however for this signal, the ``sender`` **may also be None** to allow creating the general
|
||||
notification settings!
|
||||
When called for actually sending notifications, ``sender`` will be the event or organizer,
|
||||
depending on context.
|
||||
"""
|
||||
|
||||
register_event_permission_groups = GlobalSignal()
|
||||
@@ -628,7 +627,7 @@ This signal is sent out to get all known permissions. Receivers should return an
|
||||
instance of pretix.base.permissions.PermissionGroup or a list of such instances.
|
||||
"""
|
||||
|
||||
notification = EventPluginSignal()
|
||||
event_notification_sent = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``logentry_id``, ``notification_type``
|
||||
|
||||
@@ -637,6 +636,15 @@ This signal is sent out when a notification is sent.
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
organizer_notification_sent = OrganizerPluginSignal()
|
||||
"""
|
||||
Arguments: ``logentry_id``, ``notification_type``
|
||||
|
||||
This signal is sent out when a notification is sent.
|
||||
|
||||
As with all organizer-plugin signals, the ``sender`` keyword argument will contain the organizer.
|
||||
"""
|
||||
|
||||
register_sales_channel_types = GlobalSignal()
|
||||
"""
|
||||
This signal is sent out to get all known sales channels types. Receivers should return an
|
||||
@@ -1191,11 +1199,14 @@ The ``sender`` keyword argument will contain the organizer.
|
||||
|
||||
customer_signed_in = OrganizerPluginSignal(allow_legacy_plugins=True)
|
||||
"""
|
||||
Arguments: ``customer``
|
||||
Arguments: ``customer``, ``profile``
|
||||
|
||||
This signal is sent out every time a customer signs in. The ``customer`` object
|
||||
is given as the first argument.
|
||||
|
||||
In case of SSO login, the full claims will be passed in the ``profile`` argument,
|
||||
e.g. as ``oidc_claims`` for OIDC. Otherwise, ``profile`` will be ``None``.
|
||||
|
||||
The ``sender`` keyword argument will contain the organizer.
|
||||
"""
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ import json
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
from importlib import metadata
|
||||
from urllib.parse import urljoin
|
||||
from urllib.request import urlopen
|
||||
|
||||
import importlib_metadata as metadata
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
@@ -27,7 +27,7 @@ from decimal import Decimal
|
||||
from django import forms
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Prefetch, Q, QuerySet
|
||||
from django.db.models import Prefetch, QuerySet
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware
|
||||
|
||||
@@ -37,12 +37,11 @@ from pretix.base.forms.questions import (
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
|
||||
Questionnaire, QuestionnaireRow, QuestionOption,
|
||||
QuestionOption,
|
||||
)
|
||||
from pretix.base.models.customers import AttendeeProfile
|
||||
from pretix.base.models.orders import CheckoutSession, Order
|
||||
from pretix.presale.signals import contact_form_fields_overrides
|
||||
from pretix.presale.views import prefetch_relevant_questionnaires
|
||||
|
||||
|
||||
class BaseQuestionsViewMixin:
|
||||
@@ -108,16 +107,20 @@ class BaseQuestionsViewMixin:
|
||||
)
|
||||
form.pos = cartpos or orderpos
|
||||
form.show_copy_answers_to_addon_button = form.pos.addon_to and (
|
||||
# TODO(questionnaires) - prefetch this
|
||||
set(qc.form_field_name for qc in form.pos.addon_to.item.get_asked_questions(questionnaire_type=Questionnaire.QuestionnaireType.POSITION_PURCHASE))
|
||||
& set(qc.form_field_name for qc in form.pos.item.get_asked_questions(questionnaire_type=Questionnaire.QuestionnaireType.POSITION_PURCHASE))
|
||||
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
|
||||
(form.pos.addon_to.item.ask_attendee_data and form.pos.item.ask_attendee_data and (
|
||||
self.request.event.settings.attendee_names_asked or
|
||||
self.request.event.settings.attendee_emails_asked or
|
||||
self.request.event.settings.attendee_company_asked or
|
||||
self.request.event.settings.attendee_addresses_asked
|
||||
))
|
||||
)
|
||||
|
||||
override_sets = self.get_question_override_sets(cr, idx)
|
||||
for overrides in override_sets:
|
||||
for question_name, question_field in form.fields.items():
|
||||
if hasattr(question_field, 'datafield'):
|
||||
src = overrides.get(question_field.datafield.identifier)
|
||||
if hasattr(question_field, 'question'):
|
||||
src = overrides.get(question_field.question.identifier)
|
||||
else:
|
||||
src = overrides.get(question_name)
|
||||
if not src:
|
||||
@@ -181,7 +184,7 @@ class BaseQuestionsViewMixin:
|
||||
field, v,
|
||||
checkoutsession=checkoutsession,
|
||||
order=order,
|
||||
question=field.datafield,
|
||||
question=field.question,
|
||||
)
|
||||
|
||||
for form in self.forms:
|
||||
@@ -243,7 +246,7 @@ class BaseQuestionsViewMixin:
|
||||
field, v,
|
||||
cartposition=cartposition,
|
||||
orderposition=orderposition,
|
||||
question=field.datafield,
|
||||
question=field.question,
|
||||
)
|
||||
|
||||
answer_dict = self._build_answer_dict(field, answer, k)
|
||||
@@ -328,8 +331,8 @@ class BaseQuestionsViewMixin:
|
||||
'field_name': k,
|
||||
'field_label': str(field.label),
|
||||
'value': answer_value,
|
||||
'question_type': field.datafield.type,
|
||||
'question_identifier': field.datafield.identifier,
|
||||
'question_type': field.question.type,
|
||||
'question_identifier': field.question.identifier,
|
||||
}
|
||||
|
||||
|
||||
@@ -345,17 +348,27 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
|
||||
@cached_property
|
||||
def positions(self):
|
||||
qqs = self.request.event.questions.all()
|
||||
if self.only_user_visible:
|
||||
type = Questionnaire.QuestionnaireType.POSITION_PURCHASE
|
||||
else:
|
||||
type = None
|
||||
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
|
||||
return list(self.order.positions.select_related(
|
||||
'item', 'variation'
|
||||
).prefetch_related(
|
||||
Prefetch('answers',
|
||||
QuestionAnswer.objects.prefetch_related('options'),
|
||||
to_attr='answerlist'),
|
||||
prefetch_relevant_questionnaires(self.request.event, self.order.sales_channel.identifier, type),
|
||||
Prefetch('item__questions',
|
||||
qqs.prefetch_related(
|
||||
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
|
||||
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
|
||||
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
|
||||
# a prefetch lookup on this query...
|
||||
'question',
|
||||
Question.objects.none(),
|
||||
to_attr='dummy'
|
||||
)))
|
||||
).select_related('dependency_question'),
|
||||
to_attr='questions_to_ask')
|
||||
))
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -26,7 +26,6 @@ from django.core import signing
|
||||
from django.http import HttpResponseBadRequest, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,7 +60,6 @@ def redir_view(request):
|
||||
u = urllib.parse.urlparse(url)
|
||||
return render(request, 'pretixbase/redirect.html', {
|
||||
'hostname': u.hostname,
|
||||
'bold_hostname': format_html("<strong>{}</strong>", u.hostname),
|
||||
'url': url,
|
||||
})
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ class CachedFileInput(forms.ClearableFileInput):
|
||||
from ...base.models import CachedFile
|
||||
v = super().value_from_datadict(data, files, name)
|
||||
if v is None and data.get(name + '-cachedfile'): # An explicit "[x] clear" would be False, not None
|
||||
v = CachedFile.objects.filter(id=data[name + '-cachedfile']).first()
|
||||
v = CachedFile.objects.filter(id=data[name + '-cachedfile'], web_download=True).first()
|
||||
if not v.allowed_for_session(self.request):
|
||||
v = None
|
||||
return v
|
||||
|
||||
@@ -639,6 +639,15 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
|
||||
'event_calendar_future_only',
|
||||
'frontpage_text',
|
||||
'event_info_text',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
'attendee_emails_asked',
|
||||
'attendee_emails_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'attendee_addresses_asked',
|
||||
'attendee_addresses_required',
|
||||
'attendee_data_explanation_text',
|
||||
'order_phone_asked',
|
||||
'order_phone_required',
|
||||
'checkout_phone_helptext',
|
||||
|
||||
@@ -728,7 +728,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
self.fields['quota'].widget.choices = self.fields['quota'].choices
|
||||
for q in self.event.questions.all():
|
||||
kwargs = {
|
||||
"label": q.internal_name,
|
||||
"label": q.question,
|
||||
"required": False,
|
||||
}
|
||||
fname = 'question_{}'.format(q.pk)
|
||||
@@ -887,30 +887,29 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
).distinct()
|
||||
for q in self.event.questions.all():
|
||||
if fdata.get(f'question_{q.pk}'):
|
||||
ref = {'orderposition__order_id': OuterRef('pk')} if q.container_type == Question.ContainerType.ORDERPOSITION else {'order_id': OuterRef('pk')}
|
||||
if q.type in (Question.TYPE_BOOLEAN, Question.TYPE_NUMBER):
|
||||
answers = QuestionAnswer.objects.filter(
|
||||
question_id=q.pk,
|
||||
answer__exact=fdata.get(f'question_{q.pk}'),
|
||||
**ref,
|
||||
orderposition__order_id=OuterRef('pk'),
|
||||
answer__exact=fdata.get(f'question_{q.pk}')
|
||||
)
|
||||
elif q.type in (Question.TYPE_DATE, Question.TYPE_TIME, Question.TYPE_DATETIME):
|
||||
answers = QuestionAnswer.objects.filter(
|
||||
question_id=q.pk,
|
||||
answer__exact=str(fdata.get(f'question_{q.pk}')),
|
||||
**ref,
|
||||
orderposition__order_id=OuterRef('pk'),
|
||||
answer__exact=str(fdata.get(f'question_{q.pk}'))
|
||||
)
|
||||
elif q.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
answers = QuestionAnswer.objects.filter(
|
||||
question_id=q.pk,
|
||||
options=fdata.get(f'question_{q.pk}'),
|
||||
**ref,
|
||||
orderposition__order_id=OuterRef('pk'),
|
||||
options=fdata.get(f'question_{q.pk}')
|
||||
)
|
||||
else:
|
||||
answers = QuestionAnswer.objects.filter(
|
||||
question_id=q.pk,
|
||||
answer__iexact=fdata.get(f'question_{q.pk}'),
|
||||
**ref,
|
||||
orderposition__order_id=OuterRef('pk'),
|
||||
answer__iexact=fdata.get(f'question_{q.pk}')
|
||||
)
|
||||
qs = qs.annotate(**{f'q_{q.pk}': Exists(answers)}).filter(**{f'q_{q.pk}': True})
|
||||
|
||||
|
||||
@@ -60,13 +60,12 @@ from pretix.base.models import (
|
||||
Item, ItemCategory, ItemProgramTime, ItemVariation, Question,
|
||||
QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.models.items import (
|
||||
ItemAddOn, ItemBundle, ItemMetaValue, Questionnaire,
|
||||
)
|
||||
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
|
||||
from pretix.base.signals import item_copy_data
|
||||
from pretix.control.forms import (
|
||||
ButtonGroupRadioSelect, ExtFileField, SalesChannelCheckboxSelectMultiple,
|
||||
SplitDateTimeField, SplitDateTimePickerWidget,
|
||||
ButtonGroupRadioSelect, ExtFileField, ItemMultipleChoiceField,
|
||||
SalesChannelCheckboxSelectMultiple, SplitDateTimeField,
|
||||
SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2, Select2ItemVarMulti
|
||||
from pretix.helpers.models import modelcopy
|
||||
@@ -146,16 +145,61 @@ class CategoryForm(I18nModelForm):
|
||||
|
||||
|
||||
class QuestionForm(I18nModelForm):
|
||||
question = I18nFormField(
|
||||
label=_("Question"),
|
||||
widget_kwargs={'attrs': {'rows': 2}},
|
||||
widget=I18nTextarea
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.container_type != Question.ContainerType.ORDERPOSITION:
|
||||
if self.instance.container_type == Question.ContainerType.ORDERPOSITION:
|
||||
self.fields['items'].queryset = self.instance.event.items.all()
|
||||
self.fields['items'].required = True
|
||||
else:
|
||||
del self.fields['items']
|
||||
del self.fields['ask_during_checkin']
|
||||
del self.fields['show_during_checkin']
|
||||
del self.fields['print_on_invoice']
|
||||
del self.fields['print_on_invoice_label']
|
||||
else:
|
||||
self.fields['print_on_invoice_label'].widget.attrs['data-display-dependency'] = '#id_print_on_invoice'
|
||||
self.fields['print_on_invoice_label'].widget.attrs['rows'] = '2'
|
||||
self.fields['dependency_question'].widget.attrs['data-container-type'] = self.instance.container_type
|
||||
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
|
||||
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
|
||||
ask_during_checkin=False,
|
||||
container_type=self.instance.container_type,
|
||||
)
|
||||
if self.instance.pk:
|
||||
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
|
||||
pk=self.instance.pk
|
||||
)
|
||||
self.fields['identifier'].required = False
|
||||
self.fields['dependency_values'].required = False
|
||||
self.fields['help_text'].widget.attrs['rows'] = 3
|
||||
|
||||
def clean_dependency_values(self):
|
||||
val = self.data.getlist('dependency_values')
|
||||
return val
|
||||
|
||||
def clean_dependency_question(self):
|
||||
dep = val = self.cleaned_data.get('dependency_question')
|
||||
if dep:
|
||||
if dep.ask_during_checkin:
|
||||
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
|
||||
|
||||
seen_ids = {self.instance.pk} if self.instance else set()
|
||||
while dep:
|
||||
if dep.pk in seen_ids:
|
||||
raise ValidationError(_('Circular dependency between questions detected.'))
|
||||
seen_ids.add(dep.pk)
|
||||
dep = dep.dependency_question
|
||||
return val
|
||||
|
||||
def clean_ask_during_checkin(self):
|
||||
val = self.cleaned_data.get('ask_during_checkin')
|
||||
|
||||
if val and self.cleaned_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||
raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||
|
||||
return val
|
||||
|
||||
def clean_show_during_checkin(self):
|
||||
val = self.cleaned_data.get('show_during_checkin')
|
||||
@@ -188,12 +232,18 @@ class QuestionForm(I18nModelForm):
|
||||
model = Question
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'internal_name',
|
||||
'question',
|
||||
'help_text',
|
||||
'type',
|
||||
'required',
|
||||
'ask_during_checkin',
|
||||
'show_during_checkin',
|
||||
'hidden',
|
||||
'identifier',
|
||||
'items',
|
||||
'dependency_question',
|
||||
'dependency_values',
|
||||
'print_on_invoice',
|
||||
'print_on_invoice_label',
|
||||
'valid_number_min',
|
||||
'valid_number_max',
|
||||
'valid_datetime_min',
|
||||
@@ -209,11 +259,17 @@ class QuestionForm(I18nModelForm):
|
||||
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'valid_date_min': DatePickerWidget(),
|
||||
'valid_date_max': DatePickerWidget(),
|
||||
'items': forms.CheckboxSelectMultiple(
|
||||
attrs={'class': 'scrolling-multiple-choice'}
|
||||
),
|
||||
'dependency_values': forms.SelectMultiple,
|
||||
'help_text': I18nMarkdownTextarea,
|
||||
}
|
||||
field_classes = {
|
||||
'valid_datetime_min': SplitDateTimeField,
|
||||
'valid_datetime_max': SplitDateTimeField,
|
||||
'items': ItemMultipleChoiceField,
|
||||
'dependency_question': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
@@ -391,20 +447,13 @@ class ItemCreateForm(I18nModelForm):
|
||||
help_text=_('Select this option e.g. for t-shirts that come in multiple sizes. '
|
||||
'You can select the variations in the next step.'),
|
||||
required=False)
|
||||
questionnaires = SafeModelMultipleChoiceField(
|
||||
queryset=Questionnaire.objects.none(),
|
||||
required=False,
|
||||
label=_('Questionnaires'),
|
||||
widget=forms.CheckboxSelectMultiple(attrs={
|
||||
'class': 'scrolling-multiple-choice'
|
||||
}),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
self.user = kwargs.pop('user')
|
||||
kwargs.setdefault('initial', {})
|
||||
kwargs['initial'].setdefault('admission', True)
|
||||
kwargs['initial'].setdefault('personalized', True)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['category'].queryset = self.instance.event.categories.all()
|
||||
@@ -420,8 +469,6 @@ class ItemCreateForm(I18nModelForm):
|
||||
)
|
||||
self.fields['category'].widget.choices = self.fields['category'].choices
|
||||
|
||||
self.fields['questionnaires'].queryset = self.instance.event.questionnaires.filter(type__startswith='position_')
|
||||
|
||||
self.fields['tax_rule'].queryset = self.instance.event.tax_rules.all()
|
||||
change_decimal_field(self.fields['default_price'], self.instance.event.currency)
|
||||
self.fields['copy_from'] = forms.ModelChoiceField(
|
||||
@@ -528,15 +575,10 @@ class ItemCreateForm(I18nModelForm):
|
||||
self.instance.picture.save(os.path.basename(src.picture.name), src.picture)
|
||||
|
||||
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
|
||||
if not self.instance.admission:
|
||||
self.instance.personalized = False
|
||||
instance = super().save(*args, **kwargs)
|
||||
|
||||
instance.questionnaires.clear()
|
||||
instance.questionnaires.add(*self.cleaned_data['questionnaires'])
|
||||
try:
|
||||
Questionnaire.check_constraints(self.event)
|
||||
except ValidationError as exc:
|
||||
raise ValidationError({'questionnaires': exc})
|
||||
|
||||
if not self.event.has_subevents and not self.cleaned_data.get('has_variations'):
|
||||
if self.cleaned_data.get('quota_option') == self.EXISTING and self.cleaned_data.get('quota_add_existing') is not None:
|
||||
quota = self.cleaned_data.get('quota_add_existing')
|
||||
@@ -627,6 +669,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
'internal_name',
|
||||
'category',
|
||||
'admission',
|
||||
'personalized',
|
||||
'default_price',
|
||||
'tax_rule',
|
||||
]
|
||||
@@ -659,14 +702,6 @@ class ItemUpdateForm(I18nModelForm):
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
|
||||
required=False,
|
||||
)
|
||||
questionnaires = SafeModelMultipleChoiceField(
|
||||
queryset=Questionnaire.objects.none(),
|
||||
required=False,
|
||||
label=_('Questionnaires'),
|
||||
widget=forms.CheckboxSelectMultiple(attrs={
|
||||
'class': 'scrolling-multiple-choice'
|
||||
}),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -762,9 +797,6 @@ class ItemUpdateForm(I18nModelForm):
|
||||
)
|
||||
self.fields['category'].widget.choices = self.fields['category'].choices
|
||||
|
||||
self.fields['questionnaires'].queryset = self.instance.event.questionnaires.filter(type__startswith='position_')
|
||||
self.initial['questionnaires'] = self.instance.questionnaires.values_list('pk', flat=True)
|
||||
|
||||
self.fields['free_price_suggestion'].widget.attrs['data-display-dependency'] = '#id_free_price'
|
||||
|
||||
self.fields['validity_dynamic_start_choice'] = forms.TypedChoiceField(
|
||||
@@ -828,8 +860,11 @@ class ItemUpdateForm(I18nModelForm):
|
||||
)
|
||||
)
|
||||
|
||||
if not d.get('admission'):
|
||||
d['personalized'] = False
|
||||
|
||||
if d.get('grant_membership_type'):
|
||||
if not d['grant_membership_type'].transferable and not d['personalized']: # TODO(questionnaires)
|
||||
if not d['grant_membership_type'].transferable and not d['personalized']:
|
||||
self.add_error(
|
||||
'personalized' if d.get('admission') else 'admission',
|
||||
_("Your product grants a non-transferable membership and should therefore be a personalized "
|
||||
@@ -856,16 +891,6 @@ class ItemUpdateForm(I18nModelForm):
|
||||
|
||||
return d
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
instance = super().save(*args, **kwargs)
|
||||
instance.questionnaires.clear()
|
||||
instance.questionnaires.add(*self.cleaned_data['questionnaires'])
|
||||
try:
|
||||
Questionnaire.check_constraints(self.event)
|
||||
except ValidationError as exc:
|
||||
raise ValidationError({'questionnaires': exc})
|
||||
return instance
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
localized_fields = '__all__'
|
||||
@@ -877,6 +902,7 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'all_sales_channels',
|
||||
'limit_sales_channels',
|
||||
'admission',
|
||||
'personalized',
|
||||
'description',
|
||||
'picture',
|
||||
'default_price',
|
||||
@@ -1171,10 +1197,6 @@ class ItemAddOnForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['addon_category'].queryset = self.event.categories.all()
|
||||
self.fields['addon_category'].help_text = format_html('<a href="{}" data-iframe-dialog="true" data-iframe-dialog-target="<[name$=addon_category]" target="_blank">{}</a>', reverse('control:event.items.categories.add', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}), _("Create a new category"))
|
||||
self.fields['addon_category'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
|
||||
@@ -66,7 +66,7 @@ class ProcessForm(forms.Form):
|
||||
widget=forms.Select(
|
||||
attrs={'data-static': 'true'}
|
||||
),
|
||||
help_text=conditional_escape(c.help_text),
|
||||
help_text=conditional_escape(c.help_text or ""),
|
||||
)
|
||||
|
||||
def get_columns(self):
|
||||
|
||||
@@ -38,7 +38,6 @@ from urllib.parse import urlparse
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.forms import formset_factory, inlineformset_factory
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.urls import reverse
|
||||
@@ -70,9 +69,9 @@ from pretix.base.forms.widgets import (
|
||||
SplitDateTimePickerWidget, format_placeholders_help_text,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Customer, Device, Event, EventMetaProperty, Gate, GiftCard,
|
||||
GiftCardAcceptance, Membership, MembershipType, OrderPosition, Organizer,
|
||||
ReusableMedium, SalesChannel, Team,
|
||||
Customer, Device, Event, EventMetaProperty, Gate, GiftCard, Membership,
|
||||
MembershipType, OrderPosition, Organizer, ReusableMedium, SalesChannel,
|
||||
Team,
|
||||
)
|
||||
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
|
||||
from pretix.base.models.organizer import OrganizerFooterLink, TeamQuerySet
|
||||
@@ -364,7 +363,7 @@ class TeamForm(forms.ModelForm):
|
||||
for opt in pg.options
|
||||
],
|
||||
label=pg.label,
|
||||
help_text=conditional_escape(pg.help_text),
|
||||
help_text=conditional_escape(pg.help_text or ""),
|
||||
initial=initial,
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
@@ -389,7 +388,7 @@ class TeamForm(forms.ModelForm):
|
||||
for opt in pg.options
|
||||
],
|
||||
label=pg.label,
|
||||
help_text=conditional_escape(pg.help_text),
|
||||
help_text=conditional_escape(pg.help_text or ""),
|
||||
initial=initial,
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
@@ -894,15 +893,7 @@ class GiftCardCreateForm(forms.ModelForm):
|
||||
|
||||
def clean_secret(self):
|
||||
s = self.cleaned_data['secret']
|
||||
if GiftCard.objects.filter(
|
||||
secret__iexact=s
|
||||
).filter(
|
||||
Q(issuer=self.organizer) |
|
||||
Q(issuer__in=GiftCardAcceptance.objects.filter(
|
||||
acceptor=self.organizer,
|
||||
active=True,
|
||||
).values_list('issuer', flat=True))
|
||||
).exists():
|
||||
if self.organizer.accepted_gift_cards.filter(secret__iexact=s).exists():
|
||||
raise ValidationError(
|
||||
_('A gift card with the same secret already exists in your or an affiliated organizer account.')
|
||||
)
|
||||
@@ -1198,7 +1189,7 @@ OrganizerFooterLinkFormset = inlineformset_factory(
|
||||
class SSOProviderForm(I18nModelForm):
|
||||
|
||||
config_oidc_base_url = forms.URLField(
|
||||
label=pgettext_lazy('sso_oidc', 'Base URL'),
|
||||
label=pgettext_lazy('sso_oidc', 'Base URL / issuer'),
|
||||
required=False,
|
||||
)
|
||||
config_oidc_client_id = forms.CharField(
|
||||
@@ -1295,6 +1286,14 @@ class SSOProviderForm(I18nModelForm):
|
||||
|
||||
|
||||
class SSOClientForm(I18nModelForm):
|
||||
disp_base_url = forms.CharField(
|
||||
label=_('Base URL / issuer'),
|
||||
disabled=True,
|
||||
)
|
||||
disp_client_id = forms.CharField(
|
||||
label=_('Client ID'),
|
||||
disabled=True,
|
||||
)
|
||||
regenerate_client_secret = forms.BooleanField(
|
||||
label=_('Invalidate old client secret and generate a new one'),
|
||||
required=False,
|
||||
@@ -1302,7 +1301,8 @@ class SSOClientForm(I18nModelForm):
|
||||
|
||||
class Meta:
|
||||
model = CustomerSSOClient
|
||||
fields = ['is_active', 'name', 'client_id', 'client_type', 'authorization_grant_type', 'redirect_uris',
|
||||
fields = ['is_active', 'name', 'disp_base_url', 'disp_client_id', 'regenerate_client_secret',
|
||||
'client_type', 'authorization_grant_type', 'redirect_uris',
|
||||
'allowed_scopes', 'require_pkce']
|
||||
widgets = {
|
||||
'authorization_grant_type': forms.RadioSelect,
|
||||
@@ -1317,13 +1317,15 @@ class SSOClientForm(I18nModelForm):
|
||||
help_text=self.fields['allowed_scopes'].help_text,
|
||||
required=self.fields['allowed_scopes'].required,
|
||||
initial=self.fields['allowed_scopes'].initial,
|
||||
choices=CustomerSSOClient.SCOPE_CHOICES,
|
||||
choices=[(val, format_html('<code>{}</code> – {}', val, label)) for val, label in CustomerSSOClient.SCOPE_CHOICES],
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
organizer = kwargs['event']
|
||||
self.initial['disp_base_url'] = eventreverse_absolute(organizer, 'presale:organizer.index', {}).strip('/')
|
||||
if self.instance and self.instance.pk:
|
||||
self.fields['client_id'].disabled = True
|
||||
self.initial['disp_client_id'] = self.instance.client_id
|
||||
else:
|
||||
del self.fields['client_id']
|
||||
self.initial['disp_client_id'] = _('(will be generated)')
|
||||
del self.fields['regenerate_client_secret']
|
||||
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ class SubEventBulkEditForm(I18nModelForm):
|
||||
self.fields[k].widget.is_required = False
|
||||
self.fields[k].required = False
|
||||
|
||||
for k in ('date_from', 'date_to', 'date_admission', 'presale_start', 'presale_end'):
|
||||
for k in ('date_from', 'date_to'):
|
||||
self.fields[k + '_day'] = forms.DateField(
|
||||
label=self._meta.model._meta.get_field(k).verbose_name,
|
||||
help_text=self._meta.model._meta.get_field(k).help_text,
|
||||
@@ -167,6 +167,14 @@ class SubEventBulkEditForm(I18nModelForm):
|
||||
required=False,
|
||||
)
|
||||
|
||||
for k in ('date_admission', 'presale_start', 'presale_end'):
|
||||
self.fields[k + '_rel'] = RelativeDateTimeField(
|
||||
label=self._meta.model._meta.get_field(k).verbose_name,
|
||||
help_text=self._meta.model._meta.get_field(k).help_text,
|
||||
required=False,
|
||||
limit_choices=('event__date_from',),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
localized_fields = '__all__'
|
||||
@@ -247,6 +255,18 @@ class SubEventBulkEditForm(I18nModelForm):
|
||||
)
|
||||
setattr(obj, k.replace('_time', ''), newval)
|
||||
fields.add(k.replace('_time', ''))
|
||||
elif k.endswith('_rel'):
|
||||
target_key = k[:-4]
|
||||
for obj in objs:
|
||||
cval = self.cleaned_data[k]
|
||||
if cval is None:
|
||||
newval = None
|
||||
if not self._meta.model._meta.get_field(target_key).null:
|
||||
continue
|
||||
else:
|
||||
newval = cval.datetime(obj)
|
||||
setattr(obj, target_key, newval)
|
||||
fields.add(target_key)
|
||||
else:
|
||||
fields.add(k)
|
||||
for obj in objs:
|
||||
|
||||
@@ -866,9 +866,6 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
|
||||
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
|
||||
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
|
||||
'pretix.event.question.option.changed': _('An answer option has been changed.'),
|
||||
'pretix.event.questionnaire.added': _('A questionnaire has been created.'),
|
||||
'pretix.event.questionnaire.deleted': _('A questionnaire has been deleted.'),
|
||||
'pretix.event.questionnaire.changed': _('A questionnaire has been changed.'),
|
||||
'pretix.event.permissions.added': _('A user has been added to the event team.'),
|
||||
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
|
||||
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
|
||||
|
||||
@@ -182,12 +182,12 @@ def get_event_navigation(request: HttpRequest):
|
||||
'active': 'event.items.categories' in url.url_name,
|
||||
},
|
||||
{
|
||||
'label': _('Questionnaires'),
|
||||
'url': reverse('control:event.items.questionnaires', kwargs={
|
||||
'label': _('Questions'),
|
||||
'url': reverse('control:event.items.questions', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': 'event.items.questionnaires' in url.url_name or 'event.items.questions' in url.url_name,
|
||||
'active': 'event.items.questions' in url.url_name,
|
||||
},
|
||||
{
|
||||
'label': _('Discounts'),
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixcontrol/scss/main.scss" %}" />
|
||||
<link rel="stylesheet" type="text/x-scss" href="{% static "lightbox/css/lightbox.scss" %}" />
|
||||
{% endcompress %}
|
||||
{% vite_importmap %}
|
||||
{% if DEBUG %}
|
||||
<script type="text/javascript" src="{% url 'javascript-catalog' lang=request.LANGUAGE_CODE %}"></script>
|
||||
{% else %}
|
||||
@@ -87,7 +88,6 @@
|
||||
<meta name="theme-color" content="#3b1c4a">
|
||||
<meta name="referrer" content="origin">
|
||||
|
||||
{% vite_importmap %}
|
||||
{% block custom_header %}{% endblock %}
|
||||
</head>
|
||||
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
{% for q in result.questions %}
|
||||
<li>
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
|
||||
{{ q.internal_name }}
|
||||
{{ q.question }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -115,7 +115,7 @@
|
||||
{% if a.question.show_during_checkin %}
|
||||
<p>
|
||||
<span class="fa fa-question-circle text-muted fa-fw"></span>
|
||||
<strong>{{ a.question.internal_name }}</strong>
|
||||
<strong>{{ a.question.question }}</strong>
|
||||
{{ a }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
{% load static %}
|
||||
{% load hierarkey_form %}
|
||||
{% load formset_tags %}
|
||||
{% load icon %}
|
||||
{% block title %}{% trans "General settings" %}{% endblock %}
|
||||
{% block custom_header %}
|
||||
{{ block.super }}
|
||||
@@ -95,6 +94,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
|
||||
|
||||
{% bootstrap_field sform.attendee_names_asked_required layout="control" %}
|
||||
{% bootstrap_field sform.attendee_emails_asked_required layout="control" %}
|
||||
{% bootstrap_field sform.attendee_company_asked_required layout="control" %}
|
||||
{% bootstrap_field sform.attendee_addresses_asked_required layout="control" %}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-3">
|
||||
{% trans "Custom fields" %}
|
||||
@@ -103,30 +109,12 @@
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
target="_blank">
|
||||
{% trans "Manage questionnaires" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-3">
|
||||
</label>
|
||||
<div class="col-md-9 static-form-row">
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questionnaires" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
target="_blank" class="btn btn-default">
|
||||
{% icon "wrench" %} {% trans "Manage questionnaires" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
target="_blank" class="btn btn-default">
|
||||
{% icon "wrench" %} {% trans "Manage data fields" %}
|
||||
{% trans "Manage questions" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% bootstrap_field sform.attendee_data_explanation_text layout="control" %}
|
||||
|
||||
<h4>{% trans "Form settings" %}</h4>
|
||||
{% bootstrap_field sform.name_scheme layout="control" %}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
{% load i18n %}
|
||||
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Gift card code" %}</dt>
|
||||
<dd>
|
||||
{% if gc %}
|
||||
<a href="{% url "control:organizer.giftcard" organizer=gc.issuer.slug giftcard=gc.pk %}">
|
||||
{{ gc.secret }}
|
||||
</a>
|
||||
{% if gc.issuer.slug != request.organizer %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<span class="fa fa-group"></span> {{ gc.issuer }}
|
||||
</span>
|
||||
{% if gc or gift_card_secret %}
|
||||
<dt>{% trans "Gift card code" %}</dt>
|
||||
<dd>
|
||||
{% if gc %}
|
||||
<a href="{% url "control:organizer.giftcard" organizer=gc.issuer.slug giftcard=gc.pk %}">
|
||||
{{ gc.secret }}
|
||||
</a>
|
||||
{% if gc.issuer.slug != request.organizer %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<span class="fa fa-group"></span> {{ gc.issuer }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif gift_card_secret %}
|
||||
{{ gift_card_secret }}
|
||||
{% endif %}
|
||||
{% elif gift_card_secret %}
|
||||
{{ gift_card_secret }}
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if gc %}
|
||||
<dt>{% trans "Issuer" %}</dt>
|
||||
<dd>{{ gc.issuer }}</dd>
|
||||
@@ -25,4 +27,4 @@
|
||||
<dt>{% trans "Error" %}</dt>
|
||||
<dd>{{ error }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</dl>
|
||||
@@ -64,7 +64,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.questionnaires layout="control" %}
|
||||
<div class="form-group" data-display-dependency="#admission_on">
|
||||
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
|
||||
<div class="col-md-9">
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-id-card-o"></span>
|
||||
<strong>{% trans "Personalized ticket" %}</strong><br>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
When this ticket is purchased, the system will ask for a name or other details according
|
||||
to your event settings.
|
||||
{% endblocktrans %}
|
||||
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
|
||||
<br>
|
||||
<span class="text-warning">
|
||||
<span class="fa fa-warning" aria-hidden="true"></span>
|
||||
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
|
||||
</span>
|
||||
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
|
||||
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-circle-o"></span>
|
||||
<strong>{% trans "Non-personalized ticket" %}</strong>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
The system will not ask for a name or other attendee details. This only affects
|
||||
system-provided fields, you can still add your own questions.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.category layout="control" %}
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -72,7 +72,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.questionnaires layout="control" %}
|
||||
<div class="form-group" data-display-dependency="#admission_on">
|
||||
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
|
||||
<div class="col-md-9">
|
||||
{% for e in form.errors.personalized %}
|
||||
<div class="alert alert-danger has-error">
|
||||
{{ e }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-id-card-o"></span>
|
||||
<strong>{% trans "Personalized ticket" %}</strong><br>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
When this ticket is purchased, the system will ask for a name or other details according
|
||||
to your event settings.
|
||||
{% endblocktrans %}
|
||||
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
|
||||
<br>
|
||||
<span class="text-warning">
|
||||
<span class="fa fa-warning" aria-hidden="true"></span>
|
||||
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
|
||||
</span>
|
||||
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
|
||||
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-file-text-o"></span>
|
||||
<strong>{% trans "Non-personalized ticket" %}</strong>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
The system will not ask for a name or other attendee details. This only affects
|
||||
system-provided fields, you can still add your own questions.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.description layout="control" %}
|
||||
{% bootstrap_field form.picture layout="control" %}
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="text-right flip">{% trans "Default price" %}</th>
|
||||
<th class="action-col-2"><span class="sr-only">Edit</span></th>
|
||||
</tr>
|
||||
@@ -109,25 +108,15 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if i.admission %}
|
||||
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket" %}"></span>
|
||||
{% if i.personalized %}
|
||||
<span class="fa fa-id-card-o fa-fw text-muted" data-toggle="tooltip" title="{% trans "Personalized admission ticket" %}"></span>
|
||||
{% else %}
|
||||
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket without personalization" %}"></span>
|
||||
{% endif %}
|
||||
{% elif i.issue_giftcard %}
|
||||
<span class="fa fa-gift fa-fw text-muted" data-toggle="tooltip" title="{% trans "Gift card" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if i.attendee_data_required %}
|
||||
<i class="fa-stack fa-stack-small fa-fw " data-toggle="tooltip_html" title="<div class='text-left'>{% trans "Personal data of attendees required" %}. {% if i.custom_questions_asked %}{% trans "Custom questions asked" %}. {% endif %}{% trans "Questionnaires" %}:<br>{{ i.questionnaire_names_html }}</div>">
|
||||
<i class="fa text-muted fa-id-card-o fa-stack-1x"></i>
|
||||
<i class="fa fa-stack-shifted fa-asterisk fa-stack-1x fa-stack-shift-up fa-stack-sm"></i>
|
||||
</i>
|
||||
{% elif i.attendee_data_asked %}
|
||||
<i class="fa-stack fa-stack-small fa-fw" data-toggle="tooltip_html" title="<div class='text-left'>{% trans "Personal data of attendees asked" %}. {% if i.custom_questions_asked %}{% trans "Custom questions asked" %}. {% endif %}{% trans "Questionnaires" %}:<br>{{ i.questionnaire_names_html }}</div>">
|
||||
<span class="fa fa-id-card-o fa-fw text-muted" ></span>
|
||||
</i>
|
||||
{% elif i.custom_questions_asked %}
|
||||
<span class="fa fa-question-circle-o fa-fw text-muted" data-toggle="tooltip_html" title="<div class='text-left'>{% trans "Custom questions asked" %}. {% trans "Questionnaires" %}:<br>{{ i.questionnaire_names_html }}</div>"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if i.var_count %}
|
||||
<span class="fa fa-bars fa-fw text-muted" data-toggle="tooltip" title="{% trans "Product with variations" %}"></span>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
{% load bootstrap3 %}
|
||||
{% load escapejson %}
|
||||
{% load formset_tags %}
|
||||
{% block title %}{% blocktrans with name=question.internal_name %}Data field: {{ name }}{% endblocktrans %}{% endblock %}
|
||||
{% block title %}{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>
|
||||
{% blocktrans with name=question.internal_name %}Data field: {{ name }}{% endblocktrans %}
|
||||
{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<a href="{% url "control:event.items.questions.edit" event=request.event.slug organizer=request.event.organizer.slug question=question.pk %}"
|
||||
class="btn btn-default">
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Delete data field" %}{% endblock %}
|
||||
{% block title %}{% trans "Delete question" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Delete data field" %}</h1>
|
||||
<h1>{% trans "Delete question" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the data field <strong>{{ question }}</strong>?{% endblocktrans %}</p>
|
||||
<p>{% blocktrans %}Are you sure you want to delete the question <strong>{{ question }}</strong>?{% endblocktrans %}</p>
|
||||
{% if dependent|length > 0 %}
|
||||
<div class="alert alert-warning">
|
||||
<p>{% blocktrans %}All answers to the question given by the buyers of the following products will be <strong>lost</strong>.{% endblocktrans %}
|
||||
{% blocktrans with url=edit_url|add:"#tab-0-1-open" %}If you want to keep the answers, <a href="{{url}}">edit the data field</a> and set it to hidden.{% endblocktrans %}
|
||||
{% blocktrans with url=edit_url|add:"#tab-0-1-open" %}If you want to keep the answers, <a href="{{url}}">edit the question</a> and set it to hidden.{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
<ul>
|
||||
@@ -24,7 +24,7 @@
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete data field and all answers" %}
|
||||
{% trans "Delete question and all answers" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,143 +5,158 @@
|
||||
{% load escapejson %}
|
||||
{% block title %}
|
||||
{% if question %}
|
||||
{% blocktrans with name=question.internal_name %}Data field: {{ name }}{% endblocktrans %}
|
||||
{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans "Create new data field" %}
|
||||
{% trans "Question" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
{% if question %}
|
||||
<h1>{% blocktrans with name=question.internal_name %}Data field: {{ name }}{% endblocktrans %}</h1>
|
||||
<h1>{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}</h1>
|
||||
{% else %}
|
||||
<h1>{% trans "Create new data field" %}</h1>
|
||||
<h1>{% trans "Question" %}</h1>
|
||||
{% endif %}
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.internal_name layout="control" %}
|
||||
{% bootstrap_field form.type layout="control" %}
|
||||
<div id="valid-number" class="panel panel-default">
|
||||
<div class="panel-heading"><h4 class="panel-title">{% trans "Validation" %}</h4></div>
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.valid_number_min layout="control" %}
|
||||
{% bootstrap_field form.valid_number_max layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="valid-date" class="panel panel-default">
|
||||
<div class="panel-heading"><h4 class="panel-title">{% trans "Validation" %}</h4></div>
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.valid_date_min layout="control" %}
|
||||
{% bootstrap_field form.valid_date_max layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="valid-datetime" class="panel panel-default">
|
||||
<div class="panel-heading"><h4 class="panel-title">{% trans "Validation" %}</h4></div>
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.valid_datetime_min layout="control" %}
|
||||
{% bootstrap_field form.valid_datetime_max layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="valid-string" class="panel panel-default">
|
||||
<div class="panel-heading"><h4 class="panel-title">{% trans "Validation" %}</h4></div>
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.valid_string_length_min layout="control" %}
|
||||
{% bootstrap_field form.valid_string_length_max layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="valid-file" class="panel panel-default">
|
||||
<div class="panel-heading"><h4 class="panel-title">{% trans "Validation" %}</h4></div>
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.valid_file_portrait layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="answer-options" class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">{% trans "Answer options" %}</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<noscript>
|
||||
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
|
||||
</noscript>
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
|
||||
{{ formset.management_form }}
|
||||
{% bootstrap_formset_errors formset %}
|
||||
<div data-formset-body>
|
||||
{% for form in formset %}
|
||||
<div data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ form.id }}
|
||||
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="row question-option-row">
|
||||
<div class="col-xs-10">
|
||||
<span class="text-muted">
|
||||
{% blocktrans trimmed with id=form.instance.identifier %}
|
||||
Answer option {{ id }}
|
||||
{% endblocktrans %}
|
||||
</span>
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-xs-2 text-right flip">
|
||||
<span> </span><br>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script type="form-template" data-formset-empty-form>
|
||||
{% escapescript %}
|
||||
<div data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ formset.empty_form.id }}
|
||||
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="row question-option-row">
|
||||
<div class="col-xs-10">
|
||||
<span class="text-muted">
|
||||
{% trans "New answer option" %}
|
||||
</span>
|
||||
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-xs-2 text-right flip">
|
||||
<span> </span><br>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<p>
|
||||
<button type="button" class="btn btn-default" data-formset-add>
|
||||
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
|
||||
</p>
|
||||
<div class="tabbed-form">
|
||||
<fieldset>
|
||||
<legend>{% trans "General" %}</legend>
|
||||
{% bootstrap_field form.question layout="control" %}
|
||||
{% bootstrap_field form.type layout="control" %}
|
||||
{% if form.items %}
|
||||
{% bootstrap_field form.items layout="control" %}
|
||||
{% endif %}
|
||||
{% bootstrap_field form.required layout="control" %}
|
||||
<div class="alert alert-info alert-required-boolean">
|
||||
{% blocktrans trimmed %}
|
||||
If you mark a Yes/No question as required, it means that the user has to select Yes and No is not
|
||||
accepted. If you want to allow both options, do not make this field required.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="valid-number">
|
||||
{% bootstrap_field form.valid_number_min layout="control" %}
|
||||
{% bootstrap_field form.valid_number_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-date">
|
||||
{% bootstrap_field form.valid_date_min layout="control" %}
|
||||
{% bootstrap_field form.valid_date_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-datetime">
|
||||
{% bootstrap_field form.valid_datetime_min layout="control" %}
|
||||
{% bootstrap_field form.valid_datetime_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-string">
|
||||
{% bootstrap_field form.valid_string_length_min layout="control" %}
|
||||
{% bootstrap_field form.valid_string_length_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-file">
|
||||
{% bootstrap_field form.valid_file_portrait layout="control" %}
|
||||
</div>
|
||||
<div id="answer-options">
|
||||
<h3>{% trans "Answer options" %}</h3>
|
||||
<noscript>
|
||||
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
|
||||
</noscript>
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
|
||||
{{ formset.management_form }}
|
||||
{% bootstrap_formset_errors formset %}
|
||||
<div data-formset-body>
|
||||
{% for form in formset %}
|
||||
<div data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ form.id }}
|
||||
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="row question-option-row">
|
||||
<div class="col-xs-10">
|
||||
<span class="text-muted">
|
||||
{% blocktrans trimmed with id=form.instance.identifier %}
|
||||
Answer option {{ id }}
|
||||
{% endblocktrans %}
|
||||
</span>
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-xs-2 text-right flip">
|
||||
<span> </span><br>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script type="form-template" data-formset-empty-form>
|
||||
{% escapescript %}
|
||||
<div data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ formset.empty_form.id }}
|
||||
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="row question-option-row">
|
||||
<div class="col-xs-10">
|
||||
<span class="text-muted">
|
||||
{% trans "New answer option" %}
|
||||
</span>
|
||||
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-xs-2 text-right flip">
|
||||
<span> </span><br>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<p>
|
||||
<button type="button" class="btn btn-default" data-formset-add>
|
||||
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Advanced" %}</legend>
|
||||
{% bootstrap_field form.help_text layout="control" %}
|
||||
{% bootstrap_field form.identifier layout="control" %}
|
||||
{% if form.ask_during_checkin %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% endif %}
|
||||
{% if form.show_during_checkin %}
|
||||
{% bootstrap_field form.show_during_checkin layout="control" %}
|
||||
{% endif %}
|
||||
{% bootstrap_field form.hidden layout="control" %}
|
||||
{% if form.print_on_invoice %}
|
||||
{% bootstrap_field form.print_on_invoice layout="control" %}
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_dependency_question">
|
||||
{% trans "Question dependency" %}
|
||||
<br><span class="optional">{% trans "Optional" context "form" %}</span>
|
||||
</label>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field form.dependency_question layout="inline" form_group_class="inner" %}
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<script type="text/plain" id="dependency_value_val">{{ form.instance.dependency_values|escapejson_dumps }}</script>
|
||||
{% bootstrap_field form.dependency_values layout="inline" form_group_class="inner" %}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% bootstrap_field form.identifier layout="control" %}
|
||||
{% if form.show_during_checkin %}
|
||||
{% bootstrap_field form.show_during_checkin layout="control" %}
|
||||
{% endif %}
|
||||
{% if form.print_on_invoice %}
|
||||
{% bootstrap_field form.print_on_invoice layout="control" %}
|
||||
{% bootstrap_field form.print_on_invoice_label layout="control" %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load static %}
|
||||
{% load icon %}
|
||||
{% load compress %}
|
||||
{% load vite %}
|
||||
|
||||
{% block title %}
|
||||
{% trans "Questionnaires" %}
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>
|
||||
{% trans "Questionnaires" %}
|
||||
<a href="{% url "control:event.items.questions" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default pull-right">
|
||||
{% icon "wrench" %} {% trans "Manage data fields" %}
|
||||
</a>
|
||||
</h1>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Questionaires allow your attendees to fill in additional data about their ticket. If you provide food, one
|
||||
example might be to ask your users about preferences or dietary requirements.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Each questionnaire can be assigned to one or more products for which its questions are asked. It can appear
|
||||
either during the checkout process, in the ticket details for each attendee individually, or at the check-in
|
||||
at the venue.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
The answer to each question is stored either in a system-provided attendee data field (such as name, email,
|
||||
or postal address), or in a user-defined data field attached to the order or the attendee.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
User-defined data fields can be created from the questionnaire editor below, and managed using the button to the right.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<br>
|
||||
|
||||
{{ request.event.settings.locales|json_script:"event_locales" }}
|
||||
{{ questionnaire_type_choices|json_script:"questionnaire_type_choices" }}
|
||||
{{ question_type_choices|json_script:"question_type_choices" }}
|
||||
{{ system_question_choices|json_script:"system_question_choices" }}
|
||||
|
||||
{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_edit_url %}
|
||||
{{ datafield_edit_url|json_script:"datafield_edit_url" }}
|
||||
|
||||
{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_view_url %}
|
||||
{{ datafield_view_url|json_script:"datafield_view_url" }}
|
||||
|
||||
{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug as datafield_create_url %}
|
||||
{{ datafield_create_url|json_script:"datafield_create_url" }}
|
||||
|
||||
<div id="questionnaires-editor">
|
||||
<!-- Vue app mount point -->
|
||||
<center><div class="big-load-indicator"><i class="fa fa-cog big-rotating-icon"></i></div></center>
|
||||
</div>
|
||||
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/questionnaires/index.ts" %}
|
||||
{% endblock %}
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Data fields" %}{% endblock %}
|
||||
{% block title %}{% trans "Questions" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Data fields" %}</h1>
|
||||
<h1>{% trans "Questions" %}</h1>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Questions allow your attendees to fill in additional data about their ticket. If you provide food, one
|
||||
@@ -11,52 +11,99 @@
|
||||
</p>
|
||||
{% csrf_token %}
|
||||
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level data field" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket data field" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if request.event.settings.feature_flag_order_level_questions %}
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket question" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level question" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>{% trans "Per-order data fields" %}</h2>
|
||||
<p>{% trans "These data fields can be used on orders." %}</p>
|
||||
<h2>{% trans "Per-ticket questions" %}</h2>
|
||||
<p>{% trans "These questions are asked for every ticket, so possibly multiple times in the same order." %}</p>
|
||||
{% else %}
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Internal name" %}</th>
|
||||
<th>{% trans "Question" %}</th>
|
||||
<th>{% trans "Type" %}</th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th>{% trans "Products" %}</th>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<th class="action-col-2"></th>
|
||||
{% endif %}
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for q in questions %}{% if q.container_type == "O" %}
|
||||
<tr>
|
||||
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P">
|
||||
{% for q in questions %}{% if q.container_type == "P" %}
|
||||
<tr data-dnd-id="{{ q.id }}">
|
||||
<td>
|
||||
<strong>
|
||||
{{ q.internal_name }}
|
||||
{% if q.pk %}
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
|
||||
{% endif %}
|
||||
{{ q.question }}
|
||||
{% if q.pk %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</strong><br>
|
||||
<small class="text-muted">{{ q.identifier }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{{ q.get_type_display }}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.show_during_checkin %}
|
||||
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Show during check-in" %}"></span>
|
||||
{% if q.pk %}
|
||||
{{ q.get_type_display }}
|
||||
{% else %}
|
||||
{% trans "System question" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.print_on_invoice %}
|
||||
<span class="fa fa-file-o text-muted" data-toggle="tooltip" title="{% trans "Print answer on invoices" %}"></span>
|
||||
{% if q.required %}
|
||||
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.ask_during_checkin %}
|
||||
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.hidden %}
|
||||
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk %}
|
||||
<ul>
|
||||
{% for item in q.items.all %}
|
||||
<li>
|
||||
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<small>{% trans "All personalized products" %}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<td class="dnd-container">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-right flip">
|
||||
{% if q.pk %}
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
@@ -74,55 +121,85 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>{% trans "Per-ticket data fields" %}</h2>
|
||||
<p>{% trans "These data field can be used on individual tickets." %}</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Internal name" %}</th>
|
||||
<th>{% trans "Type" %}</th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for q in questions %}{% if q.container_type == "P" %}
|
||||
{% if request.event.settings.feature_flag_order_level_questions %}
|
||||
<h2>
|
||||
{% trans "Per-order questions" %}
|
||||
<small><span class="label label-info" title="
|
||||
{% trans "This functionality is in active development and expected to change significantly over the coming months." %}
|
||||
{% trans "Per-order questions are currently not supported and will not be displayed in pretixPOS." %}
|
||||
" data-toggle="tooltip">
|
||||
<span class="fa fa-flask" aria-hidden="true"></span>
|
||||
{% trans "Experimental feature" %}
|
||||
</span></small>
|
||||
</h2>
|
||||
<p>{% trans "These questions are asked once per order." %}</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
|
||||
{{ q.internal_name }}
|
||||
</a>
|
||||
</strong><br>
|
||||
<small class="text-muted">{{ q.identifier }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{{ q.get_type_display }}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.show_during_checkin %}
|
||||
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Show answer during check-in" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.print_on_invoice %}
|
||||
<span class="fa fa-file-o text-muted" data-toggle="tooltip" title="{% trans "Print answer on invoices" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<th>{% trans "Question" %}</th>
|
||||
<th>{% trans "Type" %}</th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<th class="action-col-2"></th>
|
||||
{% endif %}
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
{% endif %}{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</thead>
|
||||
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O">
|
||||
{% for q in questions %}{% if q.container_type == "O" %}
|
||||
<tr data-dnd-id="{{ q.id }}">
|
||||
<td>
|
||||
<strong>
|
||||
{{ q.question }}
|
||||
</strong><br>
|
||||
<small class="text-muted">{{ q.identifier }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk %}
|
||||
{{ q.get_type_display }}
|
||||
{% else %}
|
||||
{% trans "System question" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.required %}
|
||||
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.ask_during_checkin %}
|
||||
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.hidden %}
|
||||
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<td class="dnd-container">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-right flip">
|
||||
{% if q.pk %}
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if 'event.settings.general:write' in request.eventpermset %}
|
||||
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
|
||||
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load urlreplace %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "Please wait..." %}</h1>
|
||||
{{ notify_info|json_script:"notify_info" }}
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/notify_parent.js" %}"></script>
|
||||
{% endblock %}
|
||||
@@ -20,10 +20,8 @@
|
||||
{% endif %}
|
||||
{% elif question.type == "M" %}
|
||||
{{ answer.to_string_i18n|rich_text_snippet }}
|
||||
{% elif question.type %}
|
||||
{{ answer.to_string_i18n|rich_text_snippet }}
|
||||
{% else %}
|
||||
{{ answer|linebreaksbr }}
|
||||
{{ answer.to_string_i18n|linebreaksbr }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<em>{% trans "not answered" %}</em>
|
||||
|
||||
@@ -428,23 +428,12 @@
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if order.rendered_questions or order.rendered_questions.leftovers %}
|
||||
{% if order.answers.all %}
|
||||
<hr>
|
||||
{% endif %}
|
||||
{% for q in order.rendered_questions %}
|
||||
<dt>{{ q.label }}</dt>
|
||||
<dd>{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=q answer=q.answer %}</dd>
|
||||
{% endfor %}
|
||||
{% for q in order.rendered_questions.leftovers %}
|
||||
<dt class="text-muted">
|
||||
{{ q.label }}
|
||||
<span class="fa fa-question-circle text-muted"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This data field is not asked for this product, but an answer exists anyway." %}"
|
||||
></span>
|
||||
</dt>
|
||||
<dd class="text-muted">{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=ans.question answer=ans %}</dd>
|
||||
<!-- add a edit or delete button? -->
|
||||
{% for ans in order.answers.all %}
|
||||
<dt>{{ ans.question.internal_name|default:ans.question.question }}</dt>
|
||||
<dd>{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=ans.question answer=ans %}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
@@ -621,44 +610,17 @@
|
||||
{% endif %}
|
||||
{% if line.has_questions %}
|
||||
<dl>
|
||||
{% for q in line.rendered_questions %}
|
||||
{% if q.system_datafield == "@street" and line.rendered_questions.full_address_asked %}
|
||||
<dt>
|
||||
{% trans "Attendee address" %}
|
||||
</dt>
|
||||
<dd>
|
||||
{% if line.street or line.zipcode or line.city or line.country %}
|
||||
{{ line.street|default_if_none:""|linebreaksbr }}<br>
|
||||
{{ line.zipcode|default_if_none:"" }} {{ line.city|default_if_none:"" }}<br>
|
||||
{% if line.state %}{{ line.state_for_address }}<br>{% endif %}
|
||||
{{ line.country.name|default_if_none:"" }}
|
||||
{% else %}
|
||||
<em>{% trans "not answered" %}</em>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% elif q.system_datafield == "@zipcode" and line.rendered_questions.full_address_asked %}
|
||||
{% elif q.system_datafield == "@city" and line.rendered_questions.full_address_asked %}
|
||||
{% elif q.system_datafield == "@state" and line.rendered_questions.full_address_asked %}
|
||||
{% elif q.system_datafield == "@country" and line.rendered_questions.full_address_asked %}
|
||||
{% else %}
|
||||
<dt>
|
||||
{{ q.label }}
|
||||
{% if q.questionnaire.ask_during_checkin %}
|
||||
<span class="fa fa-qrcode text-muted"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This question will be asked during check-in." %}"
|
||||
></span>
|
||||
{% endif %}
|
||||
{% if q.questionnaire.hidden %}
|
||||
<span class="fa fa-eye-slash text-muted"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "Hidden question" %}"
|
||||
></span>
|
||||
{% endif %}
|
||||
</dt>
|
||||
<dd>
|
||||
{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=q answer=q.answer %}
|
||||
{% if q.system_datafield == "@attendee_email" and not line.addon_to %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_names_asked %}
|
||||
<dt>{% trans "Attendee name" %}</dt>
|
||||
<dd>{% if line.attendee_name %}{{ line.attendee_name_all_components }}{% else %}
|
||||
<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_emails_asked %}
|
||||
<dt>{% trans "Attendee email" %}</dt>
|
||||
<dd>
|
||||
{% if line.attendee_email %}
|
||||
{{ line.attendee_email }}
|
||||
{% if not line.addon_to %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
|
||||
{% csrf_token %}
|
||||
@@ -671,22 +633,50 @@
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for q in line.rendered_questions.leftovers %}
|
||||
<dt class="text-muted">
|
||||
{{ q.label }}
|
||||
<span class="fa fa-question-circle text-muted"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This data field is not asked for this product, but an answer exists anyway." %}"
|
||||
></span>
|
||||
{% else %}
|
||||
<em>{% trans "not answered" %}</em>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
|
||||
<dt>
|
||||
{% trans "Attendee company" %}
|
||||
</dt>
|
||||
<dd class="text-muted">{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=q answer=q.answer %}</dd>
|
||||
<!-- add a edit or delete button? -->
|
||||
<dd>
|
||||
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
|
||||
<dt>
|
||||
{% trans "Attendee address" %}
|
||||
</dt>
|
||||
<dd>
|
||||
{% if line.street or line.zipcode or line.city or line.country %}
|
||||
{{ line.street|default_if_none:""|linebreaksbr }}<br>
|
||||
{{ line.zipcode|default_if_none:"" }} {{ line.city|default_if_none:"" }}<br>
|
||||
{% if line.state %}{{ line.state_for_address }}<br>{% endif %}
|
||||
{{ line.country.name|default_if_none:"" }}
|
||||
{% else %}
|
||||
<em>{% trans "not answered" %}</em>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% for q in line.questions %}
|
||||
<dt>
|
||||
{{ q.question }}
|
||||
{% if q.ask_during_checkin %}
|
||||
<span class="fa fa-qrcode text-muted"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This question will be asked during check-in." %}"
|
||||
></span>
|
||||
{% endif %}
|
||||
</dt>
|
||||
<dd>
|
||||
{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=q answer=q.answer %}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
{% for q in line.additional_fields %}
|
||||
<dt>{{ q.internal_name }}</dt>
|
||||
<dt>{{ q.question }}</dt>
|
||||
<dd>{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %}
|
||||
<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endfor %}
|
||||
|
||||
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user