Compare commits

..
Author SHA1 Message Date
Kara Engelhardt 6f88fd5878 deduplicate global i18nstring_localize 2026-11-11 10:41:03 +01:00
Kara Engelhardt 4fa1b01e3a cleanups for moving parts into core; fix preview for apple; bugfixes 2026-11-10 21:54:40 +01:00
Kara Engelhardt e1cf77365a Bind pass updates to order secret 2026-11-09 18:29:07 +01:00
Kara Engelhardt 382843dfdb core cleanups 2026-11-09 15:31:48 +01:00
Kara Engelhardt 942251abe5 add dependencies 2026-11-09 12:57:56 +01:00
Kara Engelhardt a55ac639cf Create default wallet style on install 2026-11-09 12:57:56 +01:00
Kara Engelhardt 723c184dd6 remove is_cacheable 2026-11-09 12:57:56 +01:00
Kara Engelhardt 88d46f265c bugfix 2026-11-09 12:57:56 +01:00
Kara Engelhardt 973c6631ae WalletPlatform.{is_enabled,is_available,is_available_for_position}; cleanups; refactoring 2026-11-09 12:57:56 +01:00
Kara Engelhardt f54b41bc24 cleanup; ticket cache invalidation 2026-11-09 12:57:55 +01:00
Kara Engelhardt 664ecacc4b WIP refactoring 2026-11-09 12:57:55 +01:00
Kara Engelhardt 574eff5b82 Fix fields shredder missed 2026-11-09 12:57:55 +01:00
Kara Engelhardt a7f62d6201 fix apple update mechanism; add method to send push event to devices 2026-11-09 12:57:55 +01:00
Kara Engelhardt fb582173e6 bugfixing 2026-11-09 12:57:55 +01:00
Kara Engelhardt 9816588c6a Store generated apple wallet passes; implement callback urls; refactoring; bugfixes 2026-11-09 12:57:55 +01:00
Kara Engelhardt f537731073 default_overflow, ui fixes, cleanups 2026-11-09 12:57:55 +01:00
Kara Engelhardt 1046a4179d calculate overflow options serverside 2026-11-09 12:57:55 +01:00
Kara Engelhardt 0d824049ce Fix theoretical placeholder overflow inconsistency
how would a overflow from a placeholderfieldgroup with context_args={'order_position'} to one with context_args={'event'} even work
2026-11-09 12:57:55 +01:00
Kara Engelhardt 9ae3ec9a87 fix and add tests; fix (and hopefully don't add) bugs; implement generate_order 2026-11-09 12:57:55 +01:00
Kara Engelhardt 93155c6d92 continue cleanup, start fixing tests 2026-11-09 12:57:55 +01:00
Kara Engelhardt b3e40e9536 switch to get_cleaned_setting everywhere 2026-11-09 12:57:55 +01:00
Kara Engelhardt cd4da853da apple: show fg color as white when background is set 2026-11-09 12:57:55 +01:00
Kara Engelhardt aa03e47009 cleanup google integration 2026-11-09 12:57:55 +01:00
Kara Engelhardt fae07bfc47 cleanup 2026-11-09 12:57:55 +01:00
Kara Engelhardt d2b6affe8e cleanup 2026-11-09 12:57:55 +01:00
Kara Engelhardt f95b97d570 Style file remove button and support dragndrop 2026-11-09 12:57:55 +01:00
Kara Engelhardt c9368672f1 WIP: simplify preview rendering 2026-11-09 12:57:55 +01:00
Kara Engelhardt c9b040b15b Add booleansettingsfield; use for shimmer animation 2026-11-09 12:57:55 +01:00
Kara Engelhardt 076fbc10be Add details field to generic google event ticket 2026-11-09 12:57:55 +01:00
Kara Engelhardt f4ecd9885f no details limit 2026-11-09 12:57:55 +01:00
Kara Engelhardt 491a9ad22b Start with custom google pass layout 2026-11-09 12:57:55 +01:00
Kara Engelhardt f018d32804 File settings handling; nicer file upload button 2026-11-09 12:57:55 +01:00
Kara Engelhardt 0f1f492397 Bugfixes 2026-11-09 12:57:55 +01:00
Kara Engelhardt 21d44d15e3 cleanup 2026-11-09 12:57:55 +01:00
Kara Engelhardt f834ea8b86 google: ticket type in preview 2026-11-09 12:57:55 +01:00
Kara Engelhardt 1b3e954368 google: show website 2026-11-09 12:57:55 +01:00
Kara Engelhardt 3e8c1dba85 bugfixing; update types 2026-11-09 12:57:55 +01:00
Kara Engelhardt 2c1c792aa3 cleanup 2026-11-09 12:57:55 +01:00
Kara Engelhardt 6874727dbc localization; bugfixes; google event ticket 2026-11-09 12:57:55 +01:00
Kara Engelhardt 651c0daada continue google event ticket 2026-11-09 12:57:55 +01:00
Kara Engelhardt 23531419e4 Background preview 2026-11-09 12:57:55 +01:00
Kara Engelhardt 46a05cab74 show default as first layout 2026-11-09 12:57:55 +01:00
Kara Engelhardt 43de592766 formatting 2026-11-09 12:57:55 +01:00
Kara Engelhardt d73f688bc4 Show default placeholder label 2026-11-09 12:57:55 +01:00
Kara Engelhardt dd6bde0d93 Add default for ImageSettingsField 2026-11-09 12:57:55 +01:00
Kara Engelhardt 8c947bd515 Continue apple pass; Add float and color settings 2026-11-09 12:57:55 +01:00
Kara Engelhardt 18f2260712 add more fields for apple default pass layout 2026-11-09 12:57:55 +01:00
Kara Engelhardt b7b70e8f88 Add ticketoutput confirmation to control 2026-11-09 12:57:55 +01:00
Kara Engelhardt 9a6d45f625 add wallet layout chooser to item settings 2026-11-09 12:57:55 +01:00
Kara Engelhardt f2e7e6a8af fix layout settings styling, image setting preview, clear image 2026-11-09 12:57:55 +01:00
Kara Engelhardt ba1f2c3deb cleanup wallet placeholder signal & image placeholder handling 2026-11-09 12:57:55 +01:00
Kara Engelhardt e4585aceb5 add missing import 2026-11-09 12:57:55 +01:00
Kara Engelhardt 7f97992650 fix walletsettings panel header spacing 2026-11-09 12:57:55 +01:00
Kara Engelhardt 87c9b2337b show max_entries in overflow help text 2026-11-09 12:57:55 +01:00
Kara Engelhardt 439b6fe205 missing file 2026-11-09 12:57:55 +01:00
Kara Engelhardt 59bb4102f4 button styling 2026-11-09 12:57:55 +01:00
Kara Engelhardt 7819612bc2 ticketoutput confirmation_text; is_cacheable 2026-11-09 12:57:55 +01:00
Kara Engelhardt 995357c3c5 bugfixes, start moving wallet to core 2026-11-09 12:57:55 +01:00
Kara Engelhardt b435ed6369 filesettings; placeholder context transformations; bugfixes 2026-11-09 12:57:55 +01:00
Kara Engelhardt 6aae1ace8a WIP
proper fileupload handling, better store
2026-11-09 12:57:55 +01:00
Kara Engelhardt 29c9620812 WIP
mvp (file)settings
2026-11-09 12:57:55 +01:00
Kara Engelhardt 935f8d195c WIP
prettify & cleanup preview

support multiline fields in preview

add venue

store active state for predefined fieldgroups
2026-11-09 12:57:55 +01:00
Kara Engelhardt d222434d50 WIP 2026-11-09 12:57:55 +01:00
Kara Engelhardt 66024bd1a6 WIP 2026-11-09 12:57:55 +01:00
Kara Engelhardt 81323a0250 WIP: predefined preview, google api, custom placeholder class 2026-11-09 12:57:55 +01:00
Kara Engelhardt 24dc3fc217 WIP 2026-11-09 12:57:55 +01:00
Kara Engelhardt a35cad3546 WIP 2026-11-09 12:57:55 +01:00
Kara Engelhardt 623b524bc3 WIP 2026-11-09 12:57:54 +01:00
Kara Engelhardt 916418bef3 preview from server, qrcode in apple event pass 2026-11-09 12:57:54 +01:00
Kara Engelhardt c98be87cbe WIP preview 2026-11-09 12:57:54 +01:00
Kara Engelhardt b3afdd866a WIP preview 2026-11-09 12:57:54 +01:00
Kara Engelhardt c96c6d8184 start preview 2026-11-09 12:57:54 +01:00
Kara Engelhardt b08ca1a410 wallet wip 2026-11-09 12:57:54 +01:00
Kara Engelhardt d01018a108 Fix typo 2026-11-09 12:57:54 +01:00
Kara Engelhardt c14673e8d4 vite dev server: do not clear screen 2026-11-09 12:57:54 +01:00
Kara Engelhardt f80a3a61bc WIP 2026-11-09 12:57:54 +01:00
Kara Engelhardt 557b520e7c Remove static layout name from editor page 2026-11-09 12:57:54 +01:00
Kara Engelhardt da146097d5 use placeholder name as label default 2026-11-09 12:57:54 +01:00
Kara Engelhardt 046590cdf9 WIP: first successful pass creation 2026-11-09 12:57:54 +01:00
Kara Engelhardt 6b33fc2e31 WalletLayout now contain layouts for all platforms 2026-11-09 12:57:54 +01:00
Kara Engelhardt 2ab9298a09 WIP 2026-11-09 12:57:54 +01:00
Kara Engelhardt 81a6929627 WIP: i18n editor, start apple wallet generation 2026-11-09 12:57:54 +01:00
Kara Engelhardt 53e09bc3c0 WIP: i18nfields, refactoring, jsonschema-validatoin 2026-11-09 12:57:54 +01:00
Kara Engelhardt dda8ffaabf WIP: use api 2026-11-09 12:57:54 +01:00
Kara Engelhardt 015d5a42a7 WIP 2026-11-09 12:57:54 +01:00
Kara Engelhardt 7d939e0f96 WIP 2026-11-09 12:57:54 +01:00
Kara Engelhardt ce12943e50 WIP 2026-11-09 12:57:54 +01:00
Kara Engelhardt ec1afe4fbf WIP 2026-11-09 12:57:54 +01:00
Kara Engelhardt a66edc7853 WIP 2026-11-09 12:57:54 +01:00
Kara Engelhardt bb6d3629ca Add wallet plugins stub 2026-11-09 12:57:54 +01:00
124 changed files with 8997 additions and 1539 deletions

No files matched your search

+2
View File
@@ -7,6 +7,7 @@ on:
- 'src/pretix/static/pretixpresale/widget/**'
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
- 'src/pretix/plugins/webcheckin/**'
- 'src/pretix/plugins/wallet/**'
- 'eslint.config.mjs'
- 'package.json'
- 'package-lock.json'
@@ -16,6 +17,7 @@ on:
- 'src/pretix/static/pretixpresale/widget/**'
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
- 'src/pretix/plugins/webcheckin/**'
- 'src/pretix/plugins/wallet/**'
- 'eslint.config.mjs'
- 'package.json'
- 'package-lock.json'
+1 -1
View File
@@ -56,7 +56,7 @@ calculated automatically.
},
"answers": [
{
"datafield": "A1B2C3D4",
"question": 1,
"answer": "23",
"options": []
}
+8 -18
View File
@@ -3,14 +3,6 @@
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.
@@ -39,13 +31,12 @@ 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 Values of data fields (answers to user-defined questions)
├ datafield string **NEW** The data field's ``identifier`` (internal reference)
answers list of objects Answers to user-defined questions
├ question integer Internal ID of the answered question
├ answer string Text representation of the answer
├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
├ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
├ question integer **Deprecated.** Internal ID of the data field
└ question_identifier string **Read-only, deprecated.** The data field's ``identifier`` (internal reference)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat (or ``null``)
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
@@ -208,8 +199,7 @@ Cart position endpoints
* ``bundled`` (optional, expect a list of nested objects of cart positions)
* ``answers``
* ``datafield`` **NEW**
* ``question`` **DEPRECATED**
* ``question``
* ``answer``
* ``options``
@@ -233,7 +223,7 @@ Cart position endpoints
"attendee_email": null,
"answers": [
{
"datafield": "A1B2C3D4",
"question": 1,
"answer": "23",
"options": []
}
@@ -295,7 +285,7 @@ Cart position endpoints
"attendee_email": null,
"answers": [
{
"datafield": "A1B2C3D4",
"question": 1,
"answer": "23",
"options": []
}
@@ -313,7 +303,7 @@ Cart position endpoints
"attendee_email": null,
"answers": [
{
"datafield": "A1B2C3D4",
"question": 1,
"answer": "23",
"options": []
}
+26 -37
View File
@@ -5,14 +5,6 @@
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.
@@ -32,43 +24,41 @@ 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 ``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
:<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
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
@@ -76,13 +66,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**:
@@ -102,7 +92,7 @@ Checking a ticket in
"datetime": null,
"questions_supported": true,
"answers": {
"A1B2C3D4": "XS"
"4": "XS"
}
}
@@ -328,7 +318,6 @@ Performing a ticket search
],
"answers": [
{
"datafield": "A1B2C3D4",
"question": 12,
"answer": "Foo",
"options": []
+10 -21
View File
@@ -5,14 +5,6 @@
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
--------------------
@@ -577,7 +569,6 @@ Order position endpoints
],
"answers": [
{
"datafield": "A1B2C3D4",
"question": 12,
"answer": "Foo",
"options": []
@@ -618,29 +609,27 @@ 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 ``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
:<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
answers, the string should contain the (comma-separated) IDs of the selected options.
**Example request**:
@@ -659,7 +648,7 @@ Order position endpoints
"questions_supported": true,
"canceled_supported": true,
"answers": {
"A1B2C3D4": "XS"
"4": "XS"
}
}
-250
View File
@@ -1,250 +0,0 @@
.. _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.
-428
View File
@@ -1,428 +0,0 @@
.. _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.
-3
View File
@@ -21,9 +21,6 @@ at :ref:`plugin-docs`.
item_add-ons
item_meta_properties
item_program_times
datafields
datafield_options
questionnaires
questions
question_options
quotas
+5 -13
View File
@@ -37,9 +37,12 @@ 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 **DEPRECATED.** ``true`` for items that require personalization according
personalized boolean ``true`` for items that require personalization according
to event settings. Only affects system-level fields, not
custom questions. Defaults to ``false``.
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``.
position integer An integer, used for sorting
picture file A product picture to be displayed in the shop
(can be ``null``).
@@ -230,17 +233,6 @@ 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
-----
+12 -63
View File
@@ -9,14 +9,6 @@
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
--------------
@@ -127,11 +119,6 @@ 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
===================================== ========================== =======================================================
@@ -175,10 +162,6 @@ answers list of objects **NEW** Values
The ``use_gift_cards`` attribute has been added.
.. versionchanged:: 2027.1
The ``answers`` attribute has been added.
.. _order-position-resource:
Order position resource
@@ -248,13 +231,12 @@ 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 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
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
├ 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
├ question integer **Deprecated.** Internal ID of the data field
└ question_identifier string **Read-only, deprecated.** The data field's ``identifier`` (internal reference)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat. Can be ``null``.
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
@@ -470,11 +452,10 @@ List of all orders
],
"answers": [
{
"datafield": "WY3TP9SL",
"question": 12,
"question_identifier": "WY3TP9SL",
"answer": "Foo",
"option_identifiers": [],
"option_idenfiters": [],
"options": []
}
],
@@ -507,15 +488,7 @@ List of all orders
],
"refunds": [],
"cancellation_date": null,
"plugin_data": {},
"answers": [
{
"datafield": "XYZ12345",
"answer": "42",
"option_identifiers": [],
"options": []
}
]
"plugin_data": {}
}
]
}
@@ -726,7 +699,6 @@ Fetching individual orders
],
"answers": [
{
"datafield": "WY3TP9SL",
"question": 12,
"question_identifier": "WY3TP9SL",
"answer": "Foo",
@@ -763,15 +735,7 @@ Fetching individual orders
],
"refunds": [],
"cancellation_date": null,
"plugin_data": {},
"answers": [
{
"datafield": "XYZ12345",
"answer": "42",
"option_identifiers": [],
"options": []
}
]
"plugin_data": {}
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -1115,8 +1079,7 @@ 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``
* ``datafield`` (**NEW**)
* ``question`` (**DEPRECATED**)
* ``question``
* ``answer``
* ``options``
@@ -1151,12 +1114,6 @@ 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
@@ -1204,12 +1161,6 @@ Creating orders
"internal_reference": "",
"vat_id": ""
},
"answers": [
{
"datafield": "XYZ12345",
"answer": "42"
}
],
"positions": [
{
"positionid": 1,
@@ -1223,7 +1174,7 @@ Creating orders
"addon_to": null,
"answers": [
{
"datafield": "WY3TP9SL",
"question": 1,
"answer": "23",
"options": []
}
@@ -1733,7 +1684,6 @@ List of all order positions
],
"answers": [
{
"datafield": "WY3TP9SL",
"question": 12,
"question_identifier": "WY3TP9SL",
"answer": "Foo",
@@ -1913,7 +1863,6 @@ Fetching individual positions
],
"answers": [
{
"datafield": "WY3TP9SL",
"question": 12,
"question_identifier": "WY3TP9SL",
"answer": "Foo",
@@ -2022,7 +1971,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 ``datafield``, ``answer``, and possibly ``options``. ``question`` is deprecated. ``question_identifier``
expected to provide ``question``, ``answer``, and possibly ``options``. ``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.
@@ -2118,7 +2067,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 ``datafield``, ``answer``, and possibly ``options``. ``question`` is deprecated. ``question_identifier``
expected to provide ``question``, ``answer``, and possibly ``options``. ``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.
+224 -2
View File
@@ -1,7 +1,229 @@
Question options
================
.. warning:: This resource is **deprecated**.
Resource description
--------------------
New applications must use the :ref:`rest-datafieldoptions` resource.
Questions of type "choice" or "multiple choice" can have different options attached.
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)/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.
-379
View File
@@ -1,379 +0,0 @@
.. _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.
+1 -40
View File
@@ -8,23 +8,10 @@
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 related to an order position that need to be filled out by customers during checkout.
Questions define additional fields that need to be filled out by customers during checkout.
The question resource contains the following public fields:
.. rst-class:: rest-resource-table
@@ -97,10 +84,6 @@ 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
---------
@@ -266,13 +249,6 @@ 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**:
@@ -375,13 +351,6 @@ 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.
@@ -468,14 +437,6 @@ 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**:
+1 -1
View File
@@ -40,7 +40,7 @@ we generally use a custom ``form_valid`` method on our ``FormView`` that looks l
def form_valid(self, form):
if form.has_changed():
self.request.event.log_action('pretix.event.changed', user=self.request.user, data={
k: getattr(self.request.event, k) for k in form.changed_data
k: form.cleaned_data.get(k) for k in form.changed_data
})
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
-1
View File
@@ -44,7 +44,6 @@ const ignores = globalIgnores([
'src/pretix/static/pretixbase/js/asynctask.js',
'src/pretix/static/pretixbase/js/details.js',
'src/pretix/static/pretixbase/js/gettextstub.js',
'src/pretix/static/pretixbase/js/i18nstring.js',
'src/pretix/static/pretixcontrol/js/menu.js',
'src/pretix/static/pretixcontrol/js/ui/editor.js',
'src/pretix/static/pretixcontrol/js/ui/geo.js',
+2 -2
View File
@@ -18,12 +18,12 @@
"doc": "doc"
},
"scripts": {
"dev:control": "vite",
"dev:control": "vite --clearScreen=false",
"dev:widget": "vite src/pretix/static/pretixpresale/widget",
"build": "npm run build:control -s && npm run build:widget -s",
"build:control": "vite build",
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin src/pretix/plugins/wallet",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
+2
View File
@@ -60,6 +60,8 @@ dependencies = [
"djangorestframework>=3.18.3",
"drf_ujson2==1.7.*",
"geoip2==5.*",
"google-auth=2.62.*",
"httpx==0.28.*",
"isoweek",
"jsonschema",
"kombu==5.6.*",
+1
View File
@@ -31,6 +31,7 @@ ignore =
src/tests/plugins/stripe/*
src/tests/plugins/sendmail/*
src/tests/plugins/ticketoutputpdf/*
src/tests/plugins/wallet/*
.*
CODE_OF_CONDUCT.md
CONTRIBUTING.md
+1
View File
@@ -66,6 +66,7 @@ INSTALLED_APPS = [
'pretix.plugins.returnurl',
'pretix.plugins.autocheckin',
'pretix.plugins.webcheckin',
'pretix.plugins.wallet',
'django_countries',
'oauth2_provider',
'phonenumber_field',
+33
View File
@@ -0,0 +1,33 @@
from pretix.base.models import (
CachedFile
)
from rest_framework.exceptions import ValidationError
from django.core.exceptions import ValidationError as DjangoValidationError
from django.conf import settings
from pretix.api.auth.utils import get_session_key_for_api_auth
def handle_file_upload(data, user, auth, allowed_types):
try:
cf = CachedFile.objects.get(
file__isnull=False,
pk=data[len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, 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_auth(user, auth):
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except (ValidationError, DjangoValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except CachedFile.DoesNotExist:
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
if cf.type not in allowed_types:
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
return cf.file
-1
View File
@@ -117,7 +117,6 @@ 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):
+8 -31
View File
@@ -64,7 +64,7 @@ from pretix.api.views import RichOrderingFilter
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,
Checkin, CheckinList, Device, Event, Order, OrderPosition,
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
)
from pretix.base.models.orders import PrintLog
@@ -76,6 +76,7 @@ from pretix.base.services.checkin import (
from pretix.base.services.media import perform_media_exchange
from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF
from pretix.api.helpers import handle_file_upload
with scopes_disabled():
class CheckinListFilter(FilterSet):
@@ -329,35 +330,6 @@ with scopes_disabled():
)
def _handle_file_upload(data, user, auth):
try:
cf = CachedFile.objects.get(
file__isnull=False,
pk=data[len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, 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_auth(user, auth):
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except (ValidationError, BaseValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except CachedFile.DoesNotExist:
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
allowed_types = (
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
)
if cf.type not in allowed_types:
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
return cf.file
def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_products=False, pdf_data=False, expand=None):
list_by_event = {cl.event_id: cl for cl in checkinlists}
if not checkinlists:
@@ -802,7 +774,12 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
try:
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)
given_answers[q] = handle_file_upload(
answers_data[str(q.pk)],
user,
auth,
allowed_types=('image/png', 'image/jpeg', 'image/gif', 'application/pdf')
)
else:
given_answers[q] = None
else:
-2
View File
@@ -653,8 +653,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
request.event, op, force_invalidate=True, save=True
)
order.save(update_fields=['secret'])
CachedTicket.objects.filter(order_position__order=order).delete()
CachedCombinedTicket.objects.filter(order=order).delete()
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk,
'order': order.pk})
order.log_action(
+3 -3
View File
@@ -29,7 +29,6 @@ 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 _
@@ -102,8 +101,9 @@ class OutboundSyncProvider:
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
@classproperty
def display_name(cls): # noqa: N805
@classmethod
@property
def display_name(cls):
return str(cls.identifier)
@classmethod
+1 -1
View File
@@ -109,7 +109,7 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
def get_babel_locale():
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such as de_Informal
# Also, this returns best-effort region information for number formatting etc
current_language = translation.get_language()
current_region = getattr(_active_region, "value", None)
+2 -2
View File
@@ -25,7 +25,7 @@ from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country
from pretix.base.models import Invoice
from pretix.base.signals import EventPluginRegistry, Registry
from pretix.base.signals import PluginAwareRegistry, Registry
class TransmissionType:
@@ -220,7 +220,7 @@ class TransmissionProvider:
return None
class TransmissionProviderRegistry(EventPluginRegistry):
class TransmissionProviderRegistry(PluginAwareRegistry):
def __init__(self):
super().__init__({
'identifier': lambda o: getattr(o, 'identifier'),
+98 -25
View File
@@ -21,21 +21,29 @@
#
import logging
import os
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
OrderPosition,
)
from pretix.base.services.tasks import EventTask, ProfiledTask
from pretix.base.services.tasks import (
ProfiledTask, TransactionAwareProfiledEventTask,
)
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import register_ticket_outputs
from pretix.base.signals import (
invalidate_ticket_cache, register_ticket_outputs,
)
from pretix.celery_app import app
from pretix.helpers.database import rolledback_transaction
@@ -52,6 +60,7 @@ def generate_orderposition(order_position: int, provider: str):
if prov.identifier == provider:
filename, ttype, data = prov.generate(order_position)
path, ext = os.path.splitext(filename)
for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider):
ct.delete()
ct = CachedTicket.objects.create(order_position=order_position, provider=provider,
@@ -70,6 +79,7 @@ def generate_order(order: int, provider: str):
if prov.identifier == provider:
filename, ttype, data = prov.generate_order(order)
path, ext = os.path.splitext(filename)
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
ct.delete()
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext,
@@ -91,35 +101,55 @@ class DummyRollbackException(Exception):
pass
def preview(event: int, provider: str):
def get_preview_position(event):
connection = transaction.get_connection()
if not connection.in_atomic_block:
raise RuntimeError("get_preview_position needs to be called in a rolledback_transaction")
item = event.items.create(name=LazyI18nString.from_gettext("Sample product"), default_price=Decimal('42.23'),
description=LazyI18nString.from_gettext("Sample product description"))
item2 = event.items.create(name=LazyI18nString.from_gettext("Sample workshop"), default_price=Decimal('23.40'))
item.program_times.create(item=item, start=datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc),
end=datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc))
from pretix.base.models import Order, SeatingPlan
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
email='sample@pretix.eu',
locale=event.settings.locale,
sales_channel=event.organizer.sales_channels.get(identifier="web"),
expires=now(), code="PREVIEW1234", total=119)
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
sample = {k: str(v) for k, v in scheme['sample'].items()}
SeatingPlan.objects.create(
name="Plan", organizer=event.organizer, layout="{}"
)
event.seat_category_mappings.create(
layout_category='Stalls', product=item
)
seat = event.seats.create(seat_number="1", row_label="C", product=item, seat_guid=uuid.uuid4())
position = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price, seat=seat)
s = event.subevents.first()
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=position, subevent=s)
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=position, subevent=s)
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
return position
def preview(event: int, provider: str, provider_arguments: dict = {}):
event = Event.objects.get(id=event)
with rolledback_transaction(), language(event.settings.locale, event.settings.region):
item = event.items.create(name=_("Sample product"), default_price=Decimal('42.23'),
description=_("Sample product description"))
item2 = event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40'))
from pretix.base.models import Order
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
email='sample@pretix.eu',
locale=event.settings.locale,
sales_channel=event.organizer.sales_channels.get(identifier="web"),
expires=now(), code="PREVIEW1234", total=119)
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
sample = {k: str(v) for k, v in scheme['sample'].items()}
p = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
s = event.subevents.first()
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p, subevent=s)
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p, subevent=s)
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
p = get_preview_position(event)
responses = register_ticket_outputs.send(event)
for receiver, response in responses:
prov = response(event)
if prov.identifier == provider:
return prov.generate(p)
return prov.generate(p, **provider_arguments)
def get_tickets_for_order(order, base_position=None):
@@ -206,8 +236,8 @@ def get_tickets_for_order(order, base_position=None):
return tickets
@app.task(base=EventTask, acks_late=True)
def invalidate_cache(event: Event, item: int=None, provider: str=None, order: int=None, **kwargs):
@app.task(base=TransactionAwareProfiledEventTask, acks_late=True)
def invalidate_cache(event: Event, item: int | None=None, provider: str | None=None, order: int | None=None, **kwargs):
qs = CachedTicket.objects.filter(order_position__order__event=event)
qsc = CachedCombinedTicket.objects.filter(order__event=event)
@@ -226,3 +256,46 @@ def invalidate_cache(event: Event, item: int=None, provider: str=None, order: in
ct.delete()
for ct in qsc:
ct.delete()
invalidate_ticket_cache.send(event, item=item, provider=provider, order=order)
def get_download_buttons(event, show_disabled):
buttons = []
responses = register_ticket_outputs.send(event)
for receiver, response in responses:
provider = response(event)
if provider.is_meta:
continue
if not provider.is_enabled and not show_disabled:
continue
buttons.append({
'text': provider.download_button_text or 'Download',
'multi_text': provider.multi_download_button_text or 'Download',
'long_text': provider.long_download_button_text or 'Download',
'icon': provider.download_button_icon or 'fa-download',
'identifier': provider.identifier,
'multi': provider.multi_download_enabled,
'javascript_required': provider.javascript_required,
})
return buttons
def get_wallet_buttons(event: Event, positions: list[OrderPosition], show_disabled: bool):
from pretix.plugins.wallet.platforms import get_available_platforms
platforms = list(get_available_platforms(event, show_disabled))
buttons: dict[str | OrderPosition, list] = {"combined": []}
for platform in platforms:
if any(platform.is_available_for_position(pos) for pos in positions):
attr, cont = platform.render_download_button()
buttons["combined"].append({"identifier": platform.identifier, "attributes": attr, "content": cont})
for position in positions:
buttons[position] = []
for platform in platforms:
if platform.is_available_for_position(position):
attr, cont = platform.render_download_button()
buttons[position].append({"identifier": platform.identifier, "attributes": attr, "content": cont})
return buttons
+39 -16
View File
@@ -34,7 +34,7 @@
import logging
import warnings
from typing import Any, Callable, Generic, List, Tuple, TypeVar
from typing import Any, Callable, Generic, Iterable, List, Tuple, TypeVar
import django.dispatch
from django.apps import apps
@@ -440,7 +440,8 @@ class Registry:
meta = {k: accessor(obj) for k, accessor in self.keys.items()}
tup = (obj, meta)
for key, value in meta.items():
self.by_key[key][value] = tup
self.by_key[key].setdefault(value, [])
self.by_key[key][value].append(tup)
self.registered_entries[obj] = meta
if len(objs) == 1:
@@ -465,16 +466,33 @@ class Registry:
return clz
return reg
def get(self, **kwargs):
(key, value), = kwargs.items()
return self.by_key.get(key).get(value, (None, None))
def get(self, **kwargs) -> tuple[Any, dict[str, Any]] | tuple[None, None]:
result = list(self.filter(**kwargs))
def filter(self, **kwargs):
return (
(entry, meta)
for entry, meta in self.registered_entries.items()
if all(value == meta[key] for key, value in kwargs.items())
)
if len(result) == 1:
return result[0]
elif len(result) >= 1:
raise RuntimeError(f"get() returned more than one -- it returned {len(result)}!")
return (None, None)
def filter(self, **kwargs) -> Iterable[tuple[Any, dict[str, Any]]]:
for key in kwargs.keys():
if key not in self.keys:
raise ValueError(f"Unknown key: {key!r}")
if len(kwargs) == 1:
# fast route: lookup using `self.by_key`
(key, value), = kwargs.items()
return self.by_key.get(key, {}).get(value, [])
else:
return (
(entry, meta)
for entry, meta in self.registered_entries.items()
if all(value == meta[key] for key, value in kwargs.items())
)
def all(self):
return self.registered_entries.keys()
class PluginAwareRegistry(Registry):
@@ -522,11 +540,7 @@ class PluginAwareRegistry(Registry):
return result
def get(self, active_in=None, **kwargs):
item, meta = super().get(**kwargs)
if meta and active_in is not None:
if not is_app_active(active_in, meta['plugin']):
return None, None
return item, meta
return super().get(active_in=active_in, **kwargs)
EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
@@ -1218,3 +1232,12 @@ This signal is sent out each time the information for a Device is modified.
Both the original and updated versions of the Device are included to allow
receivers to see what has been updated.
"""
invalidate_ticket_cache = EventPluginSignal()
"""
Arguments: ``item: int | None``, ``provider: str | None``, ``order: int | None``
This signal is sent out when the ticket cache is invalidated.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -0,0 +1 @@
{% for key, value in attributes.items %}{% if key != "class" %}{% if value is not False %} {{ key }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endif %}{% endfor %}
+2 -2
View File
@@ -36,10 +36,10 @@ register = template.Library()
@register.filter("money")
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False):
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False) -> str:
if isinstance(value, (float, int, str)):
if value == '':
return value
return value # pyright: ignore[reportReturnType]
value = Decimal(value)
if value is None:
value = Decimal('0.00')
+13 -2
View File
@@ -99,7 +99,7 @@ class BaseTicketOutput:
def get_tickets_to_print(self, order):
return order.positions_with_tickets
def generate_order(self, order: Order) -> Tuple[str, str, str]:
def generate_order(self, order: Order) -> Tuple[str, str, str | bytes]:
"""
This method is the same as order() but should not generate one file per order position
but instead one file for the full order.
@@ -114,7 +114,7 @@ class BaseTicketOutput:
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
If you want, you can just iterate over ``order.positions_with_tickets`` which applies the
If you want, you can just iterate over ``self.get_tickets_to_print`` which applies the
appropriate filters for you.
"""
with tempfile.TemporaryDirectory() as d:
@@ -192,6 +192,17 @@ class BaseTicketOutput:
"""
pass
@property
def is_meta(self) -> bool:
"""
Returns whether or whether not this output is a "meta" output that only works as a settings holder
and should never be used directly. This is a trick to implement outputs with multiple formats but
unified settings.
.. note:: You should set is_enabled to False for meta outputs.
"""
return False
@property
def download_button_text(self) -> str:
"""
+2 -2
View File
@@ -57,8 +57,8 @@ RE_ASYNC_ID = re.compile(r"^[a-zA-Z0-9\-]+$")
class AsyncMixin:
success_url = None
error_url = None
success_url: str | None = None
error_url: str | None = None
known_errortypes = []
def get_success_url(self, value):
+1 -1
View File
@@ -1005,7 +1005,7 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
class ProviderForm(SettingsForm):
"""
This is a SettingsForm, but if fields are set to required=True, validation
errors are only raised if the payment method is enabled.
errors are only raised if the provider is enabled.
"""
def __init__(self, *args, **kwargs):
@@ -481,5 +481,15 @@
<div class="steps">
</div>
{% enddialog %}
{% trans "Confirmation required" as confirmation_title %}
{% dialog "confirmationmodal" confirmation_title "" icon="shield" %}
<div class="modal-card-confirm modal-card-confirm-spread">
<button type="button" class="btn btn-default btn-lg" commandfor="confirmationmodal" command="close">{% trans "Cancel" %}</button>
<button type="button" id="confirmationmodal-confirm"
class="btn btn-primary btn-lg">
{% trans "Confirm" %}
</button>
</div>
{% enddialog %}
</body>
</html>
@@ -29,11 +29,13 @@
{% for provider in providers %}
<div class="panel panel-default ticketoutput-panel">
<div class="panel-heading">
<a href="{% url "control:event.settings.tickets.preview" event=request.event.slug organizer=request.organizer.slug output=provider.identifier %}"
class="btn btn-default btn-sm pull-right flip {% if not provider.evaluated_preview_allowed %}disabled{% endif %}"
target="_blank">
{% trans "Preview" %}
</a>
{% if not provider.is_meta %}
<a href="{% url "control:event.settings.tickets.preview" event=request.event.slug organizer=request.organizer.slug output=provider.identifier %}"
class="btn btn-default btn-sm pull-right flip {% if not provider.evaluated_preview_allowed %}disabled{% endif %}"
target="_blank">
{% trans "Preview" %}
</a>
{% endif %}
<h3 class="panel-title">
{% with 'ticketoutput_'|add:provider.identifier|add:'__enabled' as field_name %}
{% bootstrap_field provider.form|getitem:field_name layout='inline' label=provider.verbose_name %}
@@ -10,6 +10,7 @@
{% load l10n %}
{% load phone_format %}
{% load getitem %}
{% load dialog %}
{% block title %}
{% blocktrans trimmed with code=order.code %}
Order details: {{ code }}
@@ -573,6 +574,19 @@
</button>
</form>
{% endfor %}
{% for b in wallet_buttons|getitem:line %}
{% comment %} TODO(wallet): move view to core {% endcomment %}
<form action="{% url "plugins:wallet:event.order.wallet.get" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk platform=b.identifier %}"
method="post" data-asynctask data-asynctask-download
class="form-inline helper-display-inline">
{% csrf_token %}
<button type="submit"
class="btn btn-xs btn-default {{ b.attributes.class }}"
{% include "pretixbase/fragments/attributes.html" with attributes=b.attributes %}>
{{ b.content }}
</button>
</form>
{% endfor %}
{% endif %}
<button type="submit" data-toggle="qrcode" data-qrcode="{{ line.secret }}"
class="btn btn-xs btn-default">
+9 -4
View File
@@ -982,7 +982,7 @@ class TicketSettingsPreview(EventPermissionRequiredMixin, View):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if provider.identifier == self.kwargs.get('output'):
if provider.identifier == self.kwargs.get('output') and not provider.is_meta:
return provider
def get(self, request, *args, **kwargs):
@@ -1085,6 +1085,11 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
provider_settings_fields = provider.settings_form_fields
provider_settings_content = provider.settings_content_render(self.request)
if not provider_settings_fields and not provider_settings_content:
continue
provider.form = ProviderForm(
obj=self.request.event,
settingspref='ticketoutput_%s_' % provider.identifier,
@@ -1094,17 +1099,17 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
provider.form.fields = OrderedDict(
[
('ticketoutput_%s_%s' % (provider.identifier, k), v)
for k, v in provider.settings_form_fields.items()
for k, v in provider_settings_fields.items()
]
)
provider.settings_content = provider.settings_content_render(self.request)
provider.settings_content = provider_settings_content
provider.form.prepare_fields()
provider.evaluated_preview_allowed = True
if not provider.preview_allowed:
provider.evaluated_preview_allowed = False
else:
for k, v in provider.settings_form_fields.items():
for k, v in provider_settings_fields.items():
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
provider.evaluated_preview_allowed = False
break
+4 -3
View File
@@ -1540,10 +1540,11 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
}
for f in self.plugin_forms:
change_data.update({
k: (f.cleaned_data.get(k).name
f"{f.prefix}-{k}": (
f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.changed_data
else f.cleaned_data.get(k)
) for k in f.changed_data
})
meta_changed = {}
+8 -22
View File
@@ -110,7 +110,7 @@ from pretix.base.services.stats import order_overview
from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
)
from pretix.base.services.tickets import generate
from pretix.base.services.tickets import generate, get_download_buttons, get_wallet_buttons
from pretix.base.signals import order_modified, register_ticket_outputs
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import markdown_compile_email
@@ -544,7 +544,8 @@ class OrderDetail(OrderView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['items'] = self.get_items()
items = self.get_items()
ctx['items'] = items
ctx['has_cancellation_fee'] = any(f.fee_type == OrderFee.FEE_TYPE_CANCELLATION for f in ctx['items']['fees'])
ctx['event'] = self.request.event
ctx['payments'] = self.order.payments.order_by('-created')
@@ -566,7 +567,8 @@ class OrderDetail(OrderView):
pending_sum = self.order.pending_sum
ctx['overpaid'] = pending_sum * -1
ctx['download_buttons'] = self.download_buttons
ctx['download_buttons'] = get_download_buttons(self.request.event, show_disabled=True)
ctx['wallet_buttons'] = get_wallet_buttons(self.request.event, items['positions'], show_disabled=True)
ctx['payment_refund_sum'] = self.order.payment_refund_sum
ctx['pending_sum'] = pending_sum
ctx['uncancelled_invoice'] = self.order.invoices.exclude(
@@ -575,22 +577,6 @@ class OrderDetail(OrderView):
return ctx
@cached_property
def download_buttons(self):
buttons = []
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
buttons.append({
'text': provider.download_button_text or 'Ticket',
'icon': provider.download_button_icon or 'fa-download',
'identifier': provider.identifier,
'multi': provider.multi_download_enabled,
'javascript_required': provider.javascript_required
})
return buttons
def get_items(self):
queryset = self.object.all_positions
@@ -706,9 +692,9 @@ class OrderDownload(AsyncAction, OrderView):
if 'position' in kwargs and not self.order_position.generate_ticket:
return self.error(_('Ticket download is not enabled for this product.'))
ct = self.get_last_ct()
if ct:
if ct := self.get_last_ct():
return self.success(ct)
return self.do('orderposition' if 'position' in kwargs else 'order',
self.order_position.pk if 'position' in kwargs else self.order.pk,
self.output.identifier)
@@ -738,7 +724,7 @@ class OrderDownload(AsyncAction, OrderView):
content_type=value.type
)
else:
return redirect(self.get_self_url())
return redirect(self.get_success_url(value))
def get_last_ct(self):
ct = CachedTicket.objects.filter(
+9
View File
@@ -21,11 +21,13 @@
#
import copy
from decimal import Decimal
import typing
from django.core.files import File
from django.db import models
from django.db.models.fields import DecimalField
T = typing.TypeVar('T', bound=models.Model)
class Thumbnail(models.Model):
source = models.CharField(max_length=255)
@@ -47,6 +49,13 @@ def modelcopy(obj: models.Model, **kwargs):
setattr(n, f.name, copy.deepcopy(val))
return n
def modelclone(obj: T, **kwargs) -> T:
new = copy.copy(obj)
new.pk = None
new._state.adding = True
for k,v in kwargs.items():
setattr(new, k, v)
return new
# django 5 contains this in django.utils.choices.flatten_choices
def flatten_choices(choices):
-8
View File
@@ -196,14 +196,6 @@ OPTIONS = OrderedDict([
'offsets': [46 * mm, 46 * mm],
'pagesize': pagesizes.A4,
}),
('herma_75x40', {
'name': 'HERMA 75 x 40 mm (9010)',
'cols': 2,
'rows': 6,
'margins': [28.5 * mm, 25 * mm, 28.5 * mm, 25 * mm],
'offsets': [85 * mm, 40 * mm],
'pagesize': pagesizes.A4,
}),
('herma_88.9x33.87', {
'name': 'HERMA 88,9 x 33,87 mm (4515)',
'cols': 2,
-5
View File
@@ -219,11 +219,6 @@ TEMPLATES = {
"pagesize": (75 * mm, 52 * mm),
"layout": _simple_template(75 * mm, 52 * mm),
},
"75x40": {
"label": format_lazy(_("{width} x {height} mm label"), width=75, height=40),
"pagesize": (75 * mm, 40 * mm),
"layout": _simple_template(75 * mm, 40 * mm),
},
"70x36": {
"label": format_lazy(_("{width} x {height} mm label"), width=70, height=36),
"pagesize": (70 * mm, 36 * mm),
@@ -123,7 +123,6 @@ class TicketLayoutViewSet(viewsets.ModelViewSet):
serializer_class = TicketLayoutSerializer
queryset = TicketLayout.objects.none()
lookup_field = 'id'
write_permission = 'event.settings.general:write'
def get_queryset(self):
return self.request.event.ticket_layouts.all()
@@ -22,8 +22,6 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from pretix.base.services import tickets
from .models import TicketLayout, TicketLayoutItem
@@ -65,5 +63,3 @@ class TicketLayoutItemForm(forms.ModelForm):
return
else:
return super().save(commit=commit)
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk, 'provider': 'pdf',
'item': self.instance.item_id})
+21
View File
@@ -0,0 +1,21 @@
#
# 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/>.
#
+131
View File
@@ -0,0 +1,131 @@
import os
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers, viewsets
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.services import tickets
from .models import WalletLayout, WalletPlatformLayout
from .platforms import wallet_platform_registry, pass_style_registry
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
platform = serializers.ChoiceField(choices=[])
style = serializers.CharField(allow_null=True, required=False)
file_settings = serializers.JSONField(default=dict, required=False)
class Meta:
model = WalletPlatformLayout
fields = ("platform", "style", "layout", "file_settings")
def get_fields(self):
fields = super().get_fields()
fields["platform"].choices = [
p.identifier for p, _ in wallet_platform_registry.filter(active_in=self.context['event'])
]
return fields
def validate_layout(self, value):
if not isinstance(value, dict):
raise ValidationError(_("Layout must be a dict"))
return value
def validate(self, data):
platform = data.get("platform")
style = data.get("style")
layout = data.get("layout")
if platform and style and layout:
style, _meta = pass_style_registry.get(active_in=self.context['event'], identifier=(platform, style))
if not style:
raise ValidationError({"style": _("Invalid style")})
obj = style(event=self.context["event"], layout=data["layout"])
obj.validate()
data["file_settings"] = obj.extract_file_settings(
self.context["request"], data.get("file_settings", {})
)
return data
def to_representation(self, instance):
ret = super().to_representation(instance)
ret["file_settings"] = {}
for file_setting in instance.file_settings.all():
try:
url = file_setting.file.url
except AttributeError:
continue
request = self.context["request"]
ret["file_settings"][file_setting.key] = {
"url": request.build_absolute_uri(url),
"name": os.path.basename(file_setting.file.name).split(".", 1)[-1],
}
return ret
class WalletLayoutSerializer(I18nAwareModelSerializer):
platform_layouts = WalletPlatformLayoutSerializer(many=True)
class Meta:
model = WalletLayout
fields = ("id", "name", "platform_layouts")
read_only_fields = ("id",)
def save(self, *args, **kwargs):
super().save(*args, **kwargs, event=self.context["event"])
def update(self, instance, validated_data):
platform_layouts = validated_data.pop("platform_layouts")
for layout in platform_layouts:
if layout["style"]:
file_settings = layout.pop("file_settings", {})
obj, _ = instance.platform_layouts.update_or_create(
platform=layout["platform"], defaults=layout
)
for key, file in file_settings.items():
if not file:
obj.file_settings.filter(key=key).delete()
elif file != "keep":
file_setting, _ = obj.file_settings.get_or_create(key=key)
file_setting.file.save(os.path.basename(file.name), file)
instance.platform_layouts.exclude(
platform__in={
layout["platform"]
for layout in platform_layouts
if layout["style"] is not None
}
).delete()
return super().update(instance, validated_data)
class WalletLayoutViewSet(viewsets.ModelViewSet):
model = WalletLayout
queryset = WalletLayout.objects.none()
serializer_class = WalletLayoutSerializer
permission = "event.settings.general:write"
def get_queryset(self):
return self.request.event.wallet_layouts.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx["event"] = self.request.event
return ctx
@transaction.atomic()
def perform_update(self, serializer):
super().perform_update(serializer)
serializer.instance.log_action(
action="pretix.plugins.wallet.layout.changed",
user=self.request.user,
auth=self.request.auth,
data=serializer.to_representation(serializer.instance),
)
tickets.invalidate_cache.apply_async(
kwargs={"event": self.request.event.pk, "provider": "wallet"}
)
+63
View File
@@ -0,0 +1,63 @@
#
# 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 django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
from pretix import __version__ as version
class WalletApp(AppConfig):
name = "pretix.plugins.wallet"
verbose_name = _("wallet")
class PretixPluginMeta:
name = _("wallet")
author = _("the pretix team")
version = version
category = "FORMAT"
description = _(
"Issue wallet passes for tickets (e.g. apple wallet, google wallet)"
)
def ready(self):
from . import receivers # NOQA
from .platforms import apple # NOQA
from .platforms import google # NOQA
def installed(self, event):
from pretix.plugins.wallet.platforms import wallet_platform_registry, pass_style_registry
wallet_layout, created = event.wallet_layouts.get_or_create(
default=True,
defaults={
'name': _('Default layout'),
}
)
if created:
for platform, _meta in wallet_platform_registry.filter(active_in=event):
if not platform.default_style:
continue
style, _meta = pass_style_registry.get(active_in=event, platform=platform.identifier, identifier=platform.default_style)
if not style:
continue
layout = style(event).default_layout
wallet_layout.platform_layouts.get_or_create(platform=platform.identifier, defaults={"style": style.identifier, "layout": layout})
+119
View File
@@ -0,0 +1,119 @@
import logging
import re
import subprocess
from django import forms
from django.core.exceptions import ValidationError
from django.core.files import File
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.utils.translation import gettext_lazy as _
from pretix.control.forms import ClearableBasenameFileInput
from .models import WalletLayout, WalletLayoutItem
logger = logging.getLogger(__name__)
def validate_rsa_privkey(value: File):
value = value.read().strip()
if isinstance(value, bytes):
value = value.decode()
if not value:
return
if not re.match(
r"^-----BEGIN( (RSA |ENCRYPTED )?PRIVATE KEY-----).*-----END\1$",
value,
re.DOTALL,
):
raise ValidationError(
_(
"This does not look like an RSA private key in PEM format (it misses the correct begin or end signifiers)"
),
)
class CertificateFileField(forms.FileField):
widget = ClearableBasenameFileInput
def clean(self, value, *args, **kwargs):
value = super().clean(value, *args, **kwargs)
if isinstance(value, UploadedFile):
value.open("rb")
value.seek(0)
content = value.read()
if (
content.startswith(b"-----BEGIN CERTIFICATE-----")
and b"-----BEGIN CERTIFICATE-----" in content
):
return SimpleUploadedFile("cert.pem", content, "text/plain")
openssl_cmd = [
"openssl",
"x509",
"-inform",
"DER",
"-outform",
"PEM",
]
process = subprocess.Popen(
openssl_cmd,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
)
process.stdin.write(content)
pem, error = process.communicate()
if process.returncode != 0:
logger.info("Trying to convert a DER to PEM failed: {}".format(error))
raise ValidationError(
_(
"This does not look like a X509 certificate in either PEM or DER format"
),
)
return SimpleUploadedFile("cert.pem", pem, "text/plain")
return value
class WalletLayoutCreateForm(forms.ModelForm):
class Meta:
model = WalletLayout
fields = ("name",)
def __init__(self, *args, event, **kwargs):
super().__init__(*args, **kwargs)
self.event = event
def save(self, *args, **kwargs):
self.instance.event = self.event
return super().save(*args, **kwargs)
class WalletLayoutItemForm(forms.ModelForm):
is_layouts = True
layout = forms.ModelChoiceField(
queryset=WalletLayout.objects.none(),
label=_("Wallet Layout"),
empty_label=_("(Event default)"),
required=False,
)
class Meta:
model = WalletLayoutItem
fields = ("layout",)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop("event")
super().__init__(*args, **kwargs)
self.fields["layout"].queryset = self.event.wallet_layouts.all()
def save(self, commit=True):
if self.has_changed():
if self.cleaned_data["layout"] is None:
if self.instance.pk:
self.instance.delete()
else:
self.instance.layout = self.cleaned_data["layout"]
super().save(commit=commit)
return self.instance
+77
View File
@@ -0,0 +1,77 @@
from typing import Callable, Iterable, Protocol, runtime_checkable
from django.utils import translation
from django.utils.formats import date_format
from django.utils.functional import Promise
type MaybeTranslatedString = str | Localizable
@runtime_checkable
class Localizable(Protocol):
def localize(self, lng: str) -> str: ... # noqa: E704
# marked Promise so DjangoJSONEncoder automatically transforms it into a string
class LocalizableBase(Promise):
"""
Base for classes conforming to `Localizable` by implementing a language-aware __str__.
"""
def localize(self, lng):
with translation.override(lng):
return str(self)
class FormattingLocalizableString(LocalizableBase):
def __init__(
self,
base_str: MaybeTranslatedString,
*format_args: MaybeTranslatedString,
**format_kwargs: MaybeTranslatedString,
):
self.base_str = base_str
self.format_args = format_args
self.format_kwargs = format_kwargs
def __str__(self):
return str(self.base_str).format(*self.format_args, **self.format_kwargs)
class GettextLocalizableString(LocalizableBase):
def __init__(self, base):
self.base = base
@classmethod
def gettext(cls, base):
"""Fake function so makemessages picks up these strings."""
return cls(base)
def __str__(self):
return translation.gettext(self.base)
class LocalizableDatetime(LocalizableBase):
def __init__(self, datetime, format):
self.datetime = datetime
self.format = format
def __str__(self):
return date_format(self.datetime, self.format)
class LazyLocalizable(LocalizableBase):
def __init__(self, func: Callable[..., str]):
self.func = func
def __str__(self):
return self.func()
def localizable_to_dict(string: MaybeTranslatedString, locales: Iterable[str]):
translated = {}
for locale in locales:
with translation.override(locale):
translated[locale] = str(string)
return translated
@@ -0,0 +1,235 @@
# Generated by Django 5.2.17 on 2026-11-09 16:47
import django.db.models.deletion
import pretix.base.models.base
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
(
"pretixbase",
"0311_fix_unshredded_invoices",
),
]
operations = [
migrations.CreateModel(
name="AppleWalletDevice",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("device_library_id", models.CharField()),
("push_token", models.CharField()),
("last_push", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name="CombinedAppleWalletPass",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("file", models.FileField(max_length=255, null=True, upload_to="")),
("created", models.DateTimeField(auto_now_add=True)),
(
"order",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.order",
),
),
],
),
migrations.CreateModel(
name="WalletLayout",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("name", models.CharField(max_length=190)),
("default", models.BooleanField(default=False)),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="wallet_layouts",
to="pretixbase.event",
),
),
],
options={
"ordering": ("-default", "name"),
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name="WalletLayoutItem",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
(
"item",
models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="walletlayout",
to="pretixbase.item",
),
),
(
"layout",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="item_assignments",
to="wallet.walletlayout",
),
),
],
),
migrations.CreateModel(
name="WalletPlatformLayout",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("platform", models.CharField(max_length=10)),
("style", models.CharField(max_length=255)),
("layout", models.JSONField(default=dict)),
(
"parent",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="platform_layouts",
to="wallet.walletlayout",
),
),
],
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name="WalletLayoutFileSetting",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("key", models.CharField()),
("file", models.FileField(upload_to="")),
(
"layout",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="file_settings",
to="wallet.walletplatformlayout",
),
),
],
),
migrations.CreateModel(
name="AppleWalletPass",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("serial_number", models.CharField()),
("pass_type", models.CharField()),
("manifest", models.CharField()),
("content", models.FileField(upload_to="")),
("updated_at", models.DateTimeField(auto_now=True)),
(
"order_position",
models.OneToOneField(
on_delete=django.db.models.deletion.PROTECT,
to="pretixbase.orderposition",
),
),
(
"registered_devices",
models.ManyToManyField(
related_name="passes", to="wallet.applewalletdevice"
),
),
],
options={
"unique_together": {("pass_type", "serial_number")},
},
),
migrations.CreateModel(
name="GoogleWalletInstance",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("resource", models.CharField()),
("identifier", models.CharField()),
("data", models.JSONField()),
("usages", models.IntegerField(default=0)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.event",
),
),
(
"order_position",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.orderposition",
),
),
],
options={
"unique_together": {("resource", "identifier")},
},
),
migrations.AddConstraint(
model_name="walletlayout",
constraint=models.UniqueConstraint(
models.F("event"),
condition=models.Q(("default", True)),
name="one_default_wallet_per_event",
),
),
migrations.AlterUniqueTogether(
name="walletplatformlayout",
unique_together={("parent", "platform")},
),
migrations.AlterField(
model_name="applewalletdevice",
name="device_library_id",
field=models.CharField(unique=True),
),
]
Whitespace-only changes.
+208
View File
@@ -0,0 +1,208 @@
#
# 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 django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q, constraints
from django.utils.crypto import salted_hmac, get_random_string, constant_time_compare
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.models import LoggedModel, OrderPosition
from pretix.base.models.orders import Order
class WalletLayout(LoggedModel):
event = models.ForeignKey(
"pretixbase.Event", on_delete=models.CASCADE, related_name="wallet_layouts"
)
name = models.CharField(max_length=190, verbose_name=_("Name"))
default = models.BooleanField(
verbose_name=_("Default"),
default=False,
)
objects = ScopedManager(organizer="event__organizer")
class Meta:
constraints = [
constraints.UniqueConstraint(
"event", condition=Q(default=True), name="one_default_wallet_per_event"
)
]
ordering = (
"-default",
"name",
)
def __str__(self):
return self.name
def allow_delete(self):
return not self.default
class WalletPlatformLayout(LoggedModel):
parent = models.ForeignKey(
WalletLayout, on_delete=models.CASCADE, related_name="platform_layouts"
)
platform = models.CharField(max_length=10)
style = models.CharField(max_length=255)
layout = models.JSONField(default=dict)
objects = ScopedManager(organizer="parent__event__organizer")
class Meta:
unique_together = (("parent", "platform"),)
@property
def pass_layout(self):
from .platforms import pass_style_registry
style, _meta = pass_style_registry.get(active_in=self.parent.event, identifier=(self.platform, self.style))
if style:
file_settings = {fs.key: fs.file for fs in self.file_settings.all()}
return style(
event=self.parent.event, layout=self.layout, file_settings=file_settings
)
else:
raise ValueError(f"Style {self.platform}.{self.style} not found")
class WalletLayoutItem(models.Model):
item = models.OneToOneField(
"pretixbase.Item",
null=True,
blank=True,
related_name="walletlayout",
on_delete=models.CASCADE,
)
layout = models.ForeignKey(
WalletLayout,
null=True,
on_delete=models.CASCADE,
related_name="item_assignments",
)
def clean(self):
if self.layout and self.item.event != self.layout.event:
raise ValidationError("cannot bind layout to item of different event")
class WalletLayoutFileSetting(models.Model):
layout = models.ForeignKey(
WalletPlatformLayout, on_delete=models.CASCADE, related_name="file_settings"
)
key = models.CharField()
file = models.FileField()
class AppleWalletDevice(models.Model):
device_library_id = models.CharField(unique=True)
push_token = models.CharField()
last_push = models.DateTimeField(auto_now_add=True)
class AppleWalletPassQuerySet(models.QuerySet):
def get_by_auth_token(self, received_message):
dummy = get_random_string(16)
pk, received_token = received_message.split(":")
try:
pass_obj = self.get(order_position__pk=pk)
except AppleWalletPass.DoesNotExist:
# Do a hash comparison as well to harden against timing attacks
dummy_hash = salted_hmac(
key_salt="pretix.plugins.wallet.AppleWalletPass.auth_token",
algorithm="sha256",
value=str(12345),
secret=dummy,
).hexdigest()
constant_time_compare(
dummy_hash,
received_token,
)
raise AppleWalletPass.DoesNotExist
if constant_time_compare(
pass_obj.auth_token,
received_message,
):
return pass_obj
else:
raise AppleWalletPass.DoesNotExist
class AppleWalletPass(models.Model):
order_position = models.OneToOneField(OrderPosition, on_delete=models.PROTECT)
serial_number = models.CharField()
pass_type = models.CharField()
manifest = models.CharField()
content = models.FileField()
updated_at = models.DateTimeField(auto_now=True)
registered_devices = models.ManyToManyField(
AppleWalletDevice, related_name="passes"
)
objects = AppleWalletPassQuerySet.as_manager()
class Meta:
unique_together = [
("pass_type", "serial_number"),
]
@property
def auth_token(self):
return (
str(self.order_position.pk)
+ ":"
+ salted_hmac(
key_salt="pretix.plugins.wallet.AppleWalletPass.auth_token",
algorithm="sha256",
secret=self.order_position.web_secret,
value=str(self.order_position.pk),
).hexdigest()
)
class CombinedAppleWalletPass(models.Model):
order = models.OneToOneField(Order, on_delete=models.CASCADE)
file = models.FileField(null=True, blank=True, max_length=255)
created = models.DateTimeField(auto_now_add=True)
class GoogleWalletInstance(models.Model):
event = models.ForeignKey("pretixbase.Event", on_delete=models.CASCADE)
order_position = models.ForeignKey(
OrderPosition, on_delete=models.CASCADE, null=True
)
resource = models.CharField()
identifier = models.CharField()
data = models.JSONField()
usages = models.IntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = (("resource", "identifier"),)
@@ -0,0 +1,14 @@
from . import base, placeholders
from .base import WalletPlaceholderRenderer, get_available_context
from .placeholders import (
get_wallet_placeholder_renderer, get_wallet_placeholders,
)
__all__ = [
"base",
"placeholders",
"get_available_context",
"get_wallet_placeholder_renderer",
"get_wallet_placeholders",
"WalletPlaceholderRenderer",
]
@@ -0,0 +1,302 @@
from collections.abc import Callable
from functools import cached_property
from typing import Any
from django.core.files import File
from ..i18n import MaybeTranslatedString
class BaseWalletPlaceholder:
"""
This is the base class for all wallet placeholders.
"""
@property
def required_context(self) -> set[str]:
"""
A a set of all attribute names that need to be contained in the base context so that this placeholder is available.
"""
return set()
@property
def identifier(self) -> str:
"""The unique identifier of this placeholder"""
raise NotImplementedError()
@property
def content_type(self) -> str:
"""The content type this placeholder generates (text / image)"""
raise NotImplementedError()
@property
def label(self) -> MaybeTranslatedString:
"""The human readable name of this placeholder"""
raise NotImplementedError()
@property
def control_label(self) -> MaybeTranslatedString:
"""
The human readable name of this placeholder shown in the backend.
Defaults to `label`
"""
return self.label
def render(self, **context):
raise NotImplementedError()
def render_sample(self, **context):
raise NotImplementedError()
class BaseWalletTextPlaceholder(BaseWalletPlaceholder):
@property
def content_type(self) -> str:
return "text"
def render(self, **context) -> MaybeTranslatedString | None:
"""
This method is called to generate the text that is being shown on the pass.
You will be passed the keyword arguments specified in ``required_context``.
You are expected to return a plain-text string.
"""
raise NotImplementedError()
def render_sample(self, **context) -> MaybeTranslatedString:
"""
This method is called to generate a text to be used in previews.
You will be passed sample instances of the arguments specified in ``required_context``.
If those instances contain all data needed, you do not need to implement this.
"""
sample = self.render(**context)
if not sample:
raise RuntimeError("`render` returned falsy value when rendering a sample")
return sample
class BaseWalletImagePlaceholder(BaseWalletPlaceholder):
@property
def content_type(self) -> str:
return "image"
def render(self, **context) -> File | None:
"""
This method is called to generate the image that is being shown on the pass.
You will be passed the keyword arguments specified in ``required_context``.
You are expected to return a `File` object.
"""
raise NotImplementedError()
def render_sample(self, **context) -> str | None:
"""
This method is called to generate a text to be used in previews.
You will be passed sample instances of the arguments specified in ``required_context``.
You are expected to return a URL to a sample image or `None` if no sample can be shown.
"""
return None
class FunctionalWalletTextPlaceholder(BaseWalletTextPlaceholder):
def __init__(
self,
identifier: str,
label: MaybeTranslatedString,
args: set[str],
func: Callable[..., MaybeTranslatedString | None],
sample: (
None | MaybeTranslatedString | Callable[..., MaybeTranslatedString]
) = None,
):
self._identifier = identifier
self._label = label
self._args = args
self.render = func
self._sample = sample
def __repr__(self):
return f"FunctionalWalletTextPlaceholder(identifier={self.identifier!r}, label={self.label!r})"
@property
def identifier(self):
return self._identifier
@property
def label(self):
return self._label
@property
def required_context(self) -> set[str]:
return self._args
def render_sample(self, **context) -> MaybeTranslatedString:
if isinstance(self._sample, Callable):
return self._sample(**context)
elif self._sample:
return self._sample
else:
return super().render_sample(**context)
class FunctionalWalletImagePlaceholder(BaseWalletImagePlaceholder):
def __init__(
self,
identifier: str,
label: MaybeTranslatedString,
args: set[str],
func: Callable[..., File | None],
sample: None | str | Callable[..., str] = None,
):
self._identifier = identifier
self._label = label
self._args = args
self.render = func
self._sample = sample
@property
def required_context(self) -> set[str]:
return self._args
@property
def identifier(self):
return self._identifier
@property
def label(self):
return self._label
def render_sample(self, **context) -> str | None:
if isinstance(self._sample, Callable):
return self._sample(**context)
return self._sample
class MissingContextException(Exception):
pass
class PlaceholderContextTransformation:
@property
def required_context(self) -> set[str]:
"""
A a set of all attribute names that need to be contained in the base context for this transformation to function.
"""
return set()
@property
def generated_context_name(self) -> str:
"""The name of the context arg this transformation produces"""
raise NotImplementedError()
def transform(self, context):
raise NotImplementedError("`transform` not implemented")
class FunctionalContextTransformation(PlaceholderContextTransformation):
def __init__(self, context_name: str, context_args: set[str], func: Callable):
self.context_args = context_args
self.context_name = context_name
self.transform = func
@property
def required_context(self) -> set[str]:
return self.context_args
@property
def generated_context_name(self) -> str:
return self.context_name
def get_available_context(
initial_context: set[str], transformations: list[PlaceholderContextTransformation]
):
used_trans = set()
available_context = set(initial_context)
changed = True
while changed:
changed = False
for trans in transformations:
if trans in used_trans:
continue
if trans.required_context <= available_context:
used_trans.add(trans)
if trans.generated_context_name not in available_context:
available_context.add(trans.generated_context_name)
changed = True
return available_context
def get_transformed_context(
context_args: dict[str, Any],
transformations: list[PlaceholderContextTransformation],
):
used_trans = set()
transformed_context_args = dict(context_args)
changed = True
while changed:
changed = False
for trans in transformations:
if trans in used_trans:
continue
if trans.required_context <= transformed_context_args.keys():
used_trans.add(trans)
if trans.generated_context_name not in transformed_context_args.keys():
transformation_context = {
k: v
for k, v in transformed_context_args.items()
if k in trans.required_context
}
transformed_context_args[trans.generated_context_name] = (
trans.transform(**transformation_context)
)
changed = True
return transformed_context_args
class WalletPlaceholderRenderer:
def __init__(
self,
*,
transformations: list[PlaceholderContextTransformation] | None = None,
**kwargs,
):
self.context = kwargs
self.cache = {}
self.transformations = list(transformations) if transformations else []
@cached_property
def _get_transformed_context(self):
return get_transformed_context(self.context, self.transformations)
def _get_placeholder_context(self, placeholder: BaseWalletPlaceholder):
context = self._get_transformed_context
missing_context = placeholder.required_context - context.keys()
if missing_context:
raise MissingContextException(
f"Missing context args for '{placeholder.identifier}': {', '.join(missing_context)}"
)
return {k: v for k, v in context.items() if k in placeholder.required_context}
def is_available(self, placeholder: BaseWalletPlaceholder, context_args: set[str]):
available_context = get_available_context(context_args, self.transformations)
missing_context = placeholder.required_context - available_context
return not missing_context
def render_placeholder(self, placeholder: BaseWalletPlaceholder):
if placeholder.identifier in self.cache:
return self.cache[placeholder.identifier]
value = self.cache[placeholder.identifier] = placeholder.render(
**self._get_placeholder_context(placeholder)
)
return value
def render_sample(self, placeholder: BaseWalletPlaceholder):
return placeholder.render_sample(**self._get_placeholder_context(placeholder))
@@ -0,0 +1,314 @@
from django.contrib.staticfiles import finders
from django.core.files import File
from django.dispatch import receiver
from django.templatetags.static import static
from pretix.base.models.event import Event
from pretix.base.templatetags.money import money_filter
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.wallet.i18n import (
FormattingLocalizableString, GettextLocalizableString, LocalizableDatetime,
)
from ..signals import register_wallet_placeholders
from .base import (
BaseWalletPlaceholder, FunctionalContextTransformation,
FunctionalWalletImagePlaceholder, FunctionalWalletTextPlaceholder,
WalletPlaceholderRenderer,
)
def get_wallet_placeholder_renderer(**kwargs):
return WalletPlaceholderRenderer(
**kwargs,
transformations=[
# TODO: make this extendable via signal?
FunctionalContextTransformation(
"item", {"order_position"}, lambda order_position: order_position.item
),
FunctionalContextTransformation(
"order", {"order_position"}, lambda order_position: order_position.order
),
FunctionalContextTransformation(
"event", {"order"}, lambda order: order.event
),
FunctionalContextTransformation(
"organizer", {"event"}, lambda event: event.organizer
),
FunctionalContextTransformation(
"subevent_or_event",
{"order_position"},
lambda order_position: order_position.subevent
or order_position.order.event,
),
FunctionalContextTransformation(
"seat",
{"order_position"},
lambda order_position: (
order_position.seat if order_position.seat_id else None
),
),
],
)
def get_wallet_placeholders(event) -> dict[str, dict[str, BaseWalletPlaceholder]]:
placeholders = {"text": {}, "image": {}}
for r, ps in register_wallet_placeholders.send(sender=event):
for placeholder in ps:
placeholders[placeholder.content_type][placeholder.identifier] = placeholder
return placeholders
def get_static_file(name) -> File | None:
path: str | None = finders.find(name) # type: ignore
if not path:
return
return File(open(path, "rb"))
def admission(subevent_or_event):
return (
LocalizableDatetime(
subevent_or_event.date_admission.astimezone(subevent_or_event.timezone),
"SHORT_DATETIME_FORMAT",
)
if subevent_or_event.date_admission
else None
)
def date_from(subevent_or_event):
return LocalizableDatetime(
subevent_or_event.date_from.astimezone(subevent_or_event.timezone),
"SHORT_DATETIME_FORMAT",
)
def admission_or_date_from(subevent_or_event):
return admission(subevent_or_event) or date_from(subevent_or_event)
def website(order_position, order):
if order_position.subevent:
return eventreverse_absolute(
order.event,
"presale:event.index",
{"subevent": order_position.subevent.pk},
)
else:
return eventreverse_absolute(order.event, "presale:event.index")
@receiver(
register_wallet_placeholders,
dispatch_uid="plugin_wallet_register_wallet_placeholders",
)
def base_text_placeholders(sender: Event, **kwargs):
return [
FunctionalWalletTextPlaceholder(
"event",
GettextLocalizableString.gettext("Event"),
{"event"},
lambda event: event.name,
),
FunctionalWalletTextPlaceholder(
"event_slug",
GettextLocalizableString.gettext("Event Slug"),
{"event"},
lambda event: event.slug,
),
FunctionalWalletTextPlaceholder(
"admission",
GettextLocalizableString.gettext("Admission"),
{"subevent_or_event"},
admission,
date_from,
),
FunctionalWalletTextPlaceholder(
"date_from",
GettextLocalizableString.gettext("Begin"),
{"subevent_or_event"},
date_from,
),
FunctionalWalletTextPlaceholder(
"admission_or_date_from",
GettextLocalizableString.gettext("Admission or Date From"),
{"subevent_or_event"},
admission_or_date_from,
),
FunctionalWalletTextPlaceholder(
"order",
GettextLocalizableString.gettext("Order Code"),
{"order"},
lambda order: order.code,
),
FunctionalWalletTextPlaceholder(
"total",
GettextLocalizableString.gettext("Order Total"),
{"event", "order"},
lambda event, order: money_filter(order.total, event.currency),
),
FunctionalWalletTextPlaceholder(
"order_email",
GettextLocalizableString.gettext("Order Email"),
{"order"},
lambda order: order.email,
),
FunctionalWalletTextPlaceholder(
"price",
GettextLocalizableString.gettext("Item Price"),
{"event", "order_position"},
lambda event, order_position: money_filter(
order_position.price, event.currency
),
),
FunctionalWalletTextPlaceholder(
"secret",
GettextLocalizableString.gettext("Order Secret (QR-Code-Content)"),
{"order_position"},
lambda order_position: order_position.secret,
),
FunctionalWalletTextPlaceholder(
"item",
GettextLocalizableString.gettext("Product"),
{"item"},
lambda item: item.name,
),
FunctionalWalletTextPlaceholder(
"item_description",
GettextLocalizableString.gettext("Product description"),
{"item"},
lambda item: item.description,
),
FunctionalWalletTextPlaceholder(
"item_with_variation",
GettextLocalizableString.gettext("Product and variation"),
{"order_position"},
lambda order_position: (
FormattingLocalizableString(
"{name} - {variation}",
name=order_position.item.name,
variation=order_position.variation.value,
)
if order_position.variation
else order_position.item.name
),
),
FunctionalWalletTextPlaceholder(
"seat",
GettextLocalizableString.gettext("Seat: Full name"),
{"seat"},
lambda seat: (
FormattingLocalizableString("{seat}", seat=seat) if seat else None
),
GettextLocalizableString.gettext("Ground floor, Row 3, Seat 4"),
),
FunctionalWalletTextPlaceholder(
"seat_zone",
GettextLocalizableString.gettext("Seat: Zone"),
{"seat"},
lambda seat: seat.zone_name,
GettextLocalizableString.gettext("Ground floor"),
),
FunctionalWalletTextPlaceholder(
"seat_row",
GettextLocalizableString.gettext("Seat: Row"),
{"seat"},
lambda seat: seat.row_label or seat.row_name,
"C",
),
FunctionalWalletTextPlaceholder(
"seat_number",
GettextLocalizableString.gettext("Seat: Seat Number"),
{"seat"},
lambda seat: seat.seat_label or seat.seat_number,
"5",
),
FunctionalWalletTextPlaceholder(
"attendee_name",
GettextLocalizableString.gettext("Attendee name"),
{"order_position"},
lambda order_position: order_position.attendee_name,
),
FunctionalWalletTextPlaceholder(
"program_start",
GettextLocalizableString.gettext("Programm times: Start"),
{"item", "subevent_or_event"},
lambda item, subevent_or_event: (
LocalizableDatetime(
min(pt.start for pt in item.program_times.all()).astimezone(
subevent_or_event.timezone
),
"SHORT_DATETIME_FORMAT",
)
if item.program_times.all()
else None
),
),
FunctionalWalletTextPlaceholder(
"program_end",
GettextLocalizableString.gettext("Programm times: End"),
{"item", "subevent_or_event"},
lambda item, subevent_or_event: (
LocalizableDatetime(
max(pt.end for pt in item.program_times.all()).astimezone(
subevent_or_event.timezone
),
"SHORT_DATETIME_FORMAT",
)
if item.program_times.all()
else None
),
),
FunctionalWalletTextPlaceholder(
"organizer",
GettextLocalizableString.gettext("Organizer"),
{"organizer"},
lambda organizer: organizer.name,
),
FunctionalWalletTextPlaceholder(
"organizer_contact",
GettextLocalizableString.gettext("Organizer Contact"),
{"event"},
lambda event: event.settings.contact_mail,
"contact@example.com",
),
FunctionalWalletTextPlaceholder(
"order_code",
GettextLocalizableString.gettext("Order Code"),
{"order"},
lambda order: order.code,
),
FunctionalWalletTextPlaceholder(
"purchase_date",
GettextLocalizableString.gettext("Purchase Date"),
{"order", "subevent_or_event"},
lambda order, subevent_or_event: LocalizableDatetime(
order.datetime.astimezone(subevent_or_event.timezone),
"SHORT_DATETIME_FORMAT",
),
),
FunctionalWalletTextPlaceholder(
"website",
GettextLocalizableString.gettext("Website"),
{"order_position", "order"},
website,
),
# IMAGES
FunctionalWalletImagePlaceholder(
"poweredby_logo",
GettextLocalizableString.gettext("Logo"),
set(),
lambda: get_static_file("pretixplugins/wallet/logo.png"),
static("pretixplugins/wallet/logo.png"),
),
FunctionalWalletImagePlaceholder(
"poweredby_icon",
GettextLocalizableString.gettext("Icon"),
set(),
lambda: get_static_file("pretixplugins/wallet/icon.png"),
static("pretixplugins/wallet/icon.png"),
),
# TODO: Image upload
]
@@ -0,0 +1,34 @@
from typing import Iterable
from pretix.base.models import Event
from .base import (
PassStyle,
WalletPlatform,
wallet_platform_registry,
pass_style_registry,
)
def get_available_platforms(
event: Event, show_disabled: bool = False
) -> Iterable[WalletPlatform]:
for p, _ in wallet_platform_registry.filter(active_in=event):
platform = p(event)
if platform.is_available() and (show_disabled or platform.is_enabled()):
yield platform
def get_platform(event: Event, platform: str, show_disabled: bool) -> WalletPlatform | None:
for p in get_available_platforms(event, show_disabled):
if p.identifier == platform:
return p
__all__ = [
"get_available_platforms",
"get_platform",
"pass_style_registry",
"PassStyle",
"wallet_platform_registry",
"WalletPlatform",
]
@@ -0,0 +1,9 @@
from .platform import ApplePlatform, SignedZipFile, StringResource
from .styles import AppleWalletEventTicket
__all__ = [
"ApplePlatform",
"AppleWalletEventTicket",
"SignedZipFile",
"StringResource",
]
@@ -0,0 +1,276 @@
from collections import OrderedDict
import hashlib
import io
import json
import logging
import tempfile
import zipfile
import cryptography
import cryptography.hazmat.primitives.hashes
import cryptography.hazmat.primitives.serialization.pkcs7
import cryptography.x509
from django.utils.encoding import force_bytes
from django.utils.translation import gettext as _
from pretix.base.models.orders import Order
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.wallet.i18n import (
MaybeTranslatedString,
)
from ..base import (
WalletPlatform, wallet_platform_registry
)
from ...models import CombinedAppleWalletPass
from django.core.files.base import ContentFile
from django import forms
from django.utils.functional import classproperty
from pretix.base.forms import SecretKeySettingsField
from pretix.control.forms import ClearableBasenameFileInput
from ...forms import CertificateFileField, validate_rsa_privkey
logger = logging.getLogger("pretix.plugins.wallet.apple")
@wallet_platform_registry.register
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple Wallet")
@classmethod
def get_global_settings(cls, **kwargs):
return OrderedDict(
[
(
# TODO: extract from pass certificate
"wallet_apple_team_id",
forms.CharField(
label=_("Apple Wallet Pass team ID"),
required=False,
),
),
(
# TODO: extract from pass certificate
"wallet_apple_pass_type_id",
forms.CharField(
label=_("Apple Wallet Pass type"),
required=False,
),
),
(
"wallet_apple_certificate",
CertificateFileField(
label=_("Apple Wallet Pass certificate file"),
required=False,
),
),
(
# TODO: download this server-side
"wallet_apple_ca_certificate",
CertificateFileField(
label=_("Apple Wallet Pass CA Certificate"),
help_text=_(
"You can download the current CA certificate from apple at "
"https://www.apple.com/certificateauthority/AppleWWDRCAG4.cer"
),
required=False,
),
),
(
"wallet_apple_key",
forms.FileField(
label=_("Apple Wallet Pass secret key"),
required=False,
validators=[validate_rsa_privkey],
widget=ClearableBasenameFileInput,
),
),
(
"wallet_apple_key_password",
SecretKeySettingsField(
label=_("Apple Wallet Pass key password"),
required=False,
help_text=_(
"Optional, only necessary if the key entered above requires a password to use."
),
),
),
]
)
def generate(self, position, layout=None) -> str:
if not layout:
op_layout = self.get_layout(position)
if not op_layout:
raise ValueError(
_(
"WalletPlatform.generate called without layout and without position layout"
)
)
layout = op_layout.pass_layout
layout.generate(position)
return eventreverse_absolute(
self.event,
"plugins:wallet:apple_download",
kwargs={
"order": position.order.code,
"secret": position.order.secret,
"position": position.pk,
},
)
def generate_preview(self, position, layout):
pass_obj = layout.generate(position)
data = pass_obj.content.file.read()
pass_obj.content.delete()
return "pkpass", "application/vnd.apple.pkpass", data
def generate_order(self, order: Order):
with tempfile.TemporaryFile() as d:
with zipfile.ZipFile(d, "w") as zipf:
for pos in order.positions_with_tickets:
if self.is_available_for_position(pos):
layout = self.get_layout(pos)
if not layout:
raise ValueError(
"Impossible: is_available_for_position==True but no layout"
)
pass_obj = layout.pass_layout.generate(pos, False)
zipf.writestr(
"{}-{}.pkpass".format(order.code, pos.positionid),
pass_obj.content.read(),
)
d.seek(0)
data = d.read()
if hasattr(order, "combinedapplewalletpass"):
order.combinedapplewalletpass.delete()
ct = CombinedAppleWalletPass.objects.create(
order=order,
file=None,
)
ct.file.save(
"{}-{}.pkpasses".format(order.code, self.identifier), ContentFile(data)
)
return eventreverse_absolute(
self.event,
"plugins:wallet:apple_download_combined",
kwargs={"order": order.code, "secret": order.secret},
)
def is_available(self):
for field in [
"wallet_apple_team_id",
"wallet_apple_pass_type_id",
"wallet_apple_certificate",
"wallet_apple_ca_certificate",
"wallet_apple_key",
]:
if not self.event.settings.get(field):
return False
return super().is_available()
@classproperty
def default_style(cls): # noqa: N805
return "event_1"
class StringResource:
entries: dict[str, MaybeTranslatedString]
locales: set[str]
def __init__(self, locales):
self.entries = {}
self.locales = set(locales)
def add_entry(self, key: str, value: MaybeTranslatedString):
if key in self.entries:
raise ValueError(f"{key} already exists in this StringResource")
self.entries[key] = value
def escape(self, string):
return string.translate(
str.maketrans({'"': '\\"', "\r": "\\r", "\n": "\\n", "\\": "\\\\"})
)
def generate_resource(self, language):
output = ""
for key, entry in self.entries.items():
if isinstance(entry, str):
output += f'"{self.escape(key)}" = "{self.escape(entry)}";\n'
else:
output += f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n'
return output.strip()
def generate(self):
return {language: self.generate_resource(language) for language in self.locales}
class SignedZipFile:
"""Generates a zip-file with manifest and signature as apple expects a pkpass file to be"""
def __init__(
self,
ca_certificate: str | bytes,
certificate: str | bytes,
key: str | bytes,
password,
):
self.ca_certificate = cryptography.x509.load_pem_x509_certificate(
force_bytes(ca_certificate)
)
self.certificate = cryptography.x509.load_pem_x509_certificate(
force_bytes(certificate)
)
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
force_bytes(key), force_bytes(password) if password else None
)
self.password = password
self.file = io.BytesIO()
self.zip_file = zipfile.ZipFile(self.file, "w")
self.manifest = {}
def sign(self, data: bytes):
return (
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7SignatureBuilder()
.set_data(data)
.add_signer(
self.certificate,
self.key,
cryptography.hazmat.primitives.hashes.SHA256(),
)
.add_certificate(self.ca_certificate)
.sign(
cryptography.hazmat.primitives.serialization.Encoding.DER,
[
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.Binary,
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.DetachedSignature,
],
)
)
def finish(self):
manifest = json.dumps(self.manifest).encode()
signature = self.sign(manifest)
self.add_file("manifest.json", manifest)
self.add_file("signature", signature)
self.zip_file.close()
return self.file.getvalue(), manifest.decode()
def add_file(self, filename: str, content: str | bytes):
if isinstance(content, str):
content = content.encode()
with self.zip_file.open(filename, "w") as f:
f.write(content)
self.manifest[filename] = hashlib.sha1(content).hexdigest()
@@ -0,0 +1,433 @@
import json
import logging
from typing import Any
from django.core.files.base import ContentFile
from django.utils.translation import gettext as _
from django.db import transaction
from pretix.base.models import OrderPosition
from pretix.helpers import OF_SELF
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.wallet.i18n import (
FormattingLocalizableString, GettextLocalizableString,
)
from ...models import AppleWalletPass
from ..base import (
FieldGroup, FieldGroupDisplay, PassStyle, PlaceholderFieldEntry,
TextFieldGroup, pass_style_registry
)
from ..settings import ImageSettingsField
from ..settings import ColorSettingsField, FloatSettingsField, convert_to_png
from .platform import ApplePlatform, SignedZipFile, StringResource
logger = logging.getLogger("pretix.plugins.wallet.apple")
class AppleWalletStyle(PassStyle):
platform = ApplePlatform
@property
def settings(self):
return [
ImageSettingsField(
identifier="logo",
label=_("Logo"),
help_text=_("Will be displayed on the top left corner of the pass"),
default="pretixplugins/wallet/logo.png",
),
ImageSettingsField(
identifier="icon",
label=_("Icon"),
help_text=_("Will be displayed as the file icon"),
default="pretixplugins/wallet/icon.png",
),
ImageSettingsField(identifier="background", label=_("Background Image")),
ColorSettingsField(
identifier="bg_color",
label=_("Background Color"),
),
ColorSettingsField(
identifier="fg_color",
label=_("Foreground Color"),
help_text=_(
"This color is ignored if a background image is set. Instead, the text will be displayed in white."
),
default=self.event.settings.primary_color,
),
ColorSettingsField(
identifier="label_color", label=_("Label Color"), default="#000000"
),
FloatSettingsField(
identifier="lat",
label=_("Latitude"),
min=-90,
max=90,
default=self.event.geo_lat,
),
FloatSettingsField(
identifier="long",
label=_("Longitude"),
min=-180,
max=180,
default=self.event.geo_lon,
),
]
def pass_content(self, fields, strings):
raise NotImplementedError()
def generate_pass_json(
self, fields, op, strings, pass_type, team_id, serial_number, auth_token
):
event = op.subevent or op.order.event
tz = event.timezone
ticket = (
FormattingLocalizableString(
"{name} - {variation}", name=op.item.name, variation=op.variation.value
)
if op.variation
else op.item.name
)
description = FormattingLocalizableString(
GettextLocalizableString.gettext("Ticket for {event} ({product})"),
event=self.event.name,
product=ticket,
)
strings.add_entry("description", description)
pass_json = {
"formatVersion": 1,
"description": "description",
"organizationName": self.event.organizer.name,
"passTypeIdentifier": pass_type,
"teamIdentifier": team_id,
"serialNumber": serial_number,
"webServiceURL": eventreverse_absolute(self.event, "presale:event.index")
+ "_wallet/apple/",
"authenticationToken": auth_token,
**self.pass_content(fields, strings),
}
# TODO: Apples specs says this should be "specified as a CSS-style RGB triple, such as rgb(100, 10, 110).", but hex codes work as well
if bg_color := self.get_cleaned_setting("bg_color"):
pass_json["backgroundColor"] = bg_color
if fg_color := self.get_cleaned_setting("fg_color"):
pass_json["foregroundColor"] = fg_color
if label_color := self.get_cleaned_setting("label_color"):
pass_json["labelColor"] = label_color
if self.get_cleaned_setting("background"):
# iOS ignores the set foregroundColor if a background is set and uses white instead.
# Explicitly set white in this case so the pass looks the same on all platforms
# https://developer.apple.com/forums/thread/809417
pass_json["foregroundColor"] = "#ffffff"
# macOS always shows transparency as black, iOS as backgroundColor.
# Explicitly set black in this case so the pass looks the same on all platforms
pass_json["backgroundColor"] = "#000000"
if "expirationDate" not in pass_json:
if op.valid_until:
pass_json["expirationDate"] = op.valid_until.astimezone(tz).isoformat()
elif event.settings.show_date_to and event.date_to:
pass_json["expirationDate"] = event.date_to.astimezone(tz).isoformat()
if "relevantDates" not in pass_json:
if op.valid_from and op.valid_to:
pass_json["relevantDates"] = [
{
"startDate": op.valid_from.astimezone(tz).isoformat(),
"endDate": op.valid_to.astimezone(tz).isoformat(),
}
]
elif op.valid_from:
pass_json["relevantDates"] = [
{"date": op.valid_from.astimezone(tz).isoformat()}
]
elif event.settings.show_date_to and event.date_to:
pass_json["relevantDates"] = [
{
"startDate": event.date_from.astimezone(tz).isoformat(),
"endDate": event.date_to.astimezone(tz).isoformat(),
}
]
else:
pass_json["relevantDates"] = [
{"date": event.date_from.astimezone(tz).isoformat()}
]
# TODO: also fill relevantDates from program times
if "locations" not in pass_json:
if (lat := self.get_cleaned_setting("lat")) and (
long := self.get_cleaned_setting("long")
):
pass_json["locations"] = {
"latitude": lat,
"longitude": long,
}
return pass_json
@transaction.atomic
def generate(self, op: OrderPosition, preview: bool = False):
pass_type = self.event.settings.wallet_apple_pass_type_id
team_id = self.event.settings.wallet_apple_team_id
serial_number = "%s-%s-%s-%d" % (
self.event.organizer.slug,
self.event.slug,
op.order.code,
op.pk,
)
if preview:
pass_obj = AppleWalletPass(order_position=op, pass_type=pass_type, serial_number=serial_number)
else:
pass_obj, _ = AppleWalletPass.objects.select_for_update(
of=OF_SELF
).get_or_create(
order_position=op,
pass_type=pass_type,
defaults={
"serial_number": serial_number,
},
)
order = op.order
filename = "{}-{}.pkpass".format(order.event.slug, order.code)
fields = self.get_pass_fields(op)
pkpass = SignedZipFile(
self.event.settings.wallet_apple_ca_certificate.read(),
self.event.settings.wallet_apple_certificate.read(),
self.event.settings.wallet_apple_key.read(),
self.event.settings.wallet_apple_key_password,
)
strings = StringResource(locales=self.event.settings.locales)
pass_json = self.generate_pass_json(
fields, op, strings, pass_type, team_id, pass_obj.serial_number, pass_obj.auth_token
)
if file := self.get_cleaned_setting("logo"):
logo = convert_to_png(
file, (480, 150)
) # TODO: check max_size against apple HIG
if file := self.get_cleaned_setting("icon"):
icon = convert_to_png(
file, max_size=(87, 87)
) # TODO: check max_size against apple HIG
pkpass.add_file("icon.png", icon.read())
pkpass.add_file("logo.png", logo.read())
if file := self.get_cleaned_setting("background"):
background = convert_to_png(
file, max_size=(540, 660)
) # TODO: check max_size against apple HIG
pkpass.add_file("background.png", background.read())
for lang, content in strings.generate().items():
pkpass.add_file(f"{lang}.lproj/pass.strings", content)
pkpass.add_file("pass.json", json.dumps(pass_json))
result, manifest = pkpass.finish()
if preview:
pass_obj.content.file = ContentFile(result, name=filename)
else:
if pass_obj.content and pass_obj.manifest != manifest:
pass_obj.content.delete()
if pass_obj.manifest != manifest:
pass_obj.content.save(filename, ContentFile(result, name=filename))
pass_obj.manifest = manifest
pass_obj.save()
return pass_obj
@pass_style_registry.register
class AppleWalletEventTicket(AppleWalletStyle):
identifier = "event_1"
name = _("Event Ticket Layout")
@property
def fieldgroups(self) -> list[FieldGroup]:
return [
TextFieldGroup(
identifier="logo_text",
name=_("Logo text"),
max_entries=1,
display=FieldGroupDisplay.PLAIN,
context_args={"order_position"},
can_overflow=False,
),
TextFieldGroup(
identifier="header",
name=_("Header"),
max_entries=3,
context_args={"order_position"},
default_entries=[
PlaceholderFieldEntry(
content="admission_or_date_from",
label=GettextLocalizableString.gettext("Admission"),
),
],
default_overflow="back",
),
TextFieldGroup(
identifier="primary",
name=_("Primary"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderFieldEntry(
content="event",
)
],
description=_("These fields appear prominently featured on the pass."),
required=True,
context_args={"order_position"},
default_overflow="back",
),
TextFieldGroup(
identifier="secondary",
name=_("Secondary"),
max_entries=4,
context_args={"order_position"},
default_entries=[
PlaceholderFieldEntry(
content="item_with_variation",
label=GettextLocalizableString.gettext("Product"),
)
],
default_overflow="back",
),
# TODO: validation of max field count if combined "Coupons, store cards, and generic passes
# with a square barcode can have a total of up to four secondary and auxiliary fields, combined."
TextFieldGroup(
identifier="auxiliary",
name=_("Auxiliary"),
max_entries=4,
context_args={"order_position"},
default_entries=[
PlaceholderFieldEntry(
content="seat",
label=GettextLocalizableString.gettext("Seat"),
),
PlaceholderFieldEntry(
content="attendee_name",
),
PlaceholderFieldEntry(
content="program_start",
label=GettextLocalizableString.gettext("From"),
),
PlaceholderFieldEntry(
content="program_end",
label=GettextLocalizableString.gettext("To"),
),
],
default_overflow="back",
),
TextFieldGroup(
identifier="code",
name=_("QR-Code"),
max_entries=1,
display=FieldGroupDisplay.PLAIN,
default_entries=[
PlaceholderFieldEntry(
content="secret",
)
],
context_args={"order_position"},
),
TextFieldGroup(
identifier="back",
name=_("Back"),
context_args={"order_position"},
default_entries=[
PlaceholderFieldEntry(
content="admission",
),
PlaceholderFieldEntry(
content="attendee_name",
),
PlaceholderFieldEntry(
content="order_email",
label=GettextLocalizableString.gettext("Ordered by"),
),
PlaceholderFieldEntry(
content="organizer",
),
PlaceholderFieldEntry(
content="organizer_contact",
),
PlaceholderFieldEntry(
content="order_code",
),
PlaceholderFieldEntry(
content="purchase_date",
),
PlaceholderFieldEntry(
content="website",
),
],
),
]
@property
def preview_renderer(self) -> str | None:
return "apple_eventticket"
def convert_fields(self, strings, fields, prefix):
# TODO: detect if a string-value/string-label is the same as a generated field name
# eg if a placeholder returns "primary-0-label", this would not be shown to the user but instead replaces with the label
converted = []
for i, f in enumerate(fields):
converted_field = {**f, "key": f"{prefix}-{i}"}
if "label" in converted_field:
strings.add_entry(f"{prefix}-{i}-label", converted_field["label"])
converted_field["label"] = f"{prefix}-{i}-label"
strings.add_entry(f"{prefix}-{i}-value", converted_field["value"])
converted_field["value"] = f"{prefix}-{i}-value"
converted.append(converted_field)
return converted
def pass_content(self, fields, strings):
content: dict[str, Any] = {
"eventTicket": {
"primaryFields": self.convert_fields(
strings, fields["primary"], "primary"
),
"secondaryFields": self.convert_fields(
strings, fields["secondary"], "secondary"
),
"auxiliaryFields": self.convert_fields(
strings, fields["auxiliary"], "auxiliary"
),
"backFields": self.convert_fields(strings, fields["back"], "back"),
"headerFields": self.convert_fields(
strings, fields["header"], "header"
),
},
}
if fields["logo_text"]:
content["logoText"] = self.convert_fields(
strings, fields["logo_text"], "logo_text"
)[0]["value"]
if fields["code"]:
content["barcodes"] = [
{
"format": "PKBarcodeFormatQR",
"message": str(fields["code"][0]["value"]),
"messageEncoding": "utf-8",
"altText": str(fields["code"][0]["value"]),
}
]
return content
+731
View File
@@ -0,0 +1,731 @@
import enum
from functools import cached_property
from typing import Any, Iterable, TypedDict
import jsonschema
from django.core.exceptions import ValidationError
from django.core.files import File
from i18nfield.strings import LazyI18nString
from pretix.api.helpers import handle_file_upload
from pretix.base.models import OrderPosition
from pretix.base.models.orders import Order
from pretix.base.settings import SettingsSandbox
from pretix.base.signals import PluginAwareRegistry, is_app_active
from pretix.plugins.wallet.models import WalletLayout, WalletPlatformLayout
from ..i18n import MaybeTranslatedString, localizable_to_dict
from ..placeholders import (
WalletPlaceholderRenderer,
get_available_context,
get_wallet_placeholder_renderer,
get_wallet_placeholders,
)
from .settings import SettingsField, SettingsFile
from django.utils.translation import gettext as _
from django.utils.html import format_html
from django.utils.functional import classproperty
class WalletPlatform:
identifier: str
name: str
# TODO: register urls here?
def __init__(self, event):
self.event = event
self.wallet_settings = SettingsSandbox("ticketoutput", "wallet", event)
def is_enabled(self):
if not self.wallet_settings.get("_enabled", as_type=bool):
return False
return True
def is_available(self) -> bool:
return True
def is_available_for_position(self, op: OrderPosition):
return op.generate_ticket and bool(self.get_layout(op))
@classmethod
def get_global_settings(cls, **kwargs):
"""
Return a list of fields for the global settings form, as a dict of (setting name, form field).
"""
return {}
@classproperty
def default_style(cls) -> None | str: # noqa: N805
return None
def render_download_button(self) -> tuple[dict[str, str] | None, str]:
"""
Render the download button that should be shown.
returns: A tuple of (attr, content).
attr: the html attributes that should be added to the button element as a dict[str, str] or None
content: the content of the button
"""
return None, format_html(
'<span class="fa fa-download"></span> {}',
_("Add to {wallet}").format(wallet=self.name),
)
def get_layout(self, op) -> WalletPlatformLayout | None:
if hasattr(op.item, "walletlayout"):
wallet_layout = op.item.walletlayout.layout
else:
try:
wallet_layout = op.event.wallet_layouts.get(default=True)
except WalletLayout.DoesNotExist:
return None
return wallet_layout.platform_layouts.filter(platform=self.identifier).first()
def generate(self, position) -> str:
"""
This method should generate the wallet pass and return a url the user should be sent
to to retrieve that pass (e.g. via download or by redirecting to the wallet provider)
.. note:: You should cache the generated files where possible and only regenerate
if the pass content has changed.
"""
op_layout = self.get_layout(position)
if not op_layout:
raise ValueError(_("No layout defined for order position"))
layout = op_layout.pass_layout
return layout.generate(position)
def generate_order(self, order: Order) -> str:
"""
This method should generate the wallet passes for all positions in `order` and return
a url the user should be sent to to retrieve them (e.g. via download or by redirecting
to the wallet provider)
.. note:: You should cache the generated files where possible and only regenerate
if the pass contents have changed.
"""
raise NotImplementedError()
def generate_preview(
self, position: OrderPosition, layout: "PassStyle"
) -> str | tuple[str, str, bytes]:
"""
This method should generate the wallet pass for `position` and either return a string containing
a url the user should be sent to or a a tuple of an extension (str), a mimetype (str) and the data
(bytes) for a file that should be downloaded to the user.
"""
raise NotImplementedError()
class LayoutContext(TypedDict):
placeholders: dict[str, dict]
placeholder_renderer: WalletPlaceholderRenderer
class FieldGroupType(enum.Enum):
PLACEHOLDER = "placeholder"
PREDEFINED = "predefined"
class FieldGroupDisplay(enum.Enum):
PLAIN = "plain"
WITH_LABEL = "with_label"
class FieldGroup:
type: FieldGroupType
identifier: str
name: str
description: str
required: bool = False
def __init__(self, identifier: str, name: str, description=None, required=False):
self.identifier = identifier
self.name = name
self.required = required
self.description = description or ""
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: LayoutContext,
) -> dict:
raise NotImplementedError()
def asdict(
self,
locales: list[str],
context: LayoutContext,
remaining_fields: list["FieldGroup"],
):
return {
"type": self.type.value,
"identifier": self.identifier,
"name": self.name,
"description": self.description,
"required": self.required,
}
def default(self, locales: list[str]) -> dict[str, Any]:
raise NotImplementedError()
class FieldContentType(enum.Enum):
IMAGE = "image"
TEXT = "text"
class FieldEntryType(enum.Enum):
CUSTOM = "custom"
PLACEHOLDER = "placeholder"
class FieldEntry[T]:
type: FieldEntryType
label: MaybeTranslatedString | None
content: T
def __init__(
self,
type: FieldEntryType,
content: T,
label: MaybeTranslatedString | None = None,
):
self.type = type
self.label = label
self.content = content
def asdict(self, locales: list[str]) -> dict:
return {
"type": self.type.value,
"content": self.content,
"label": (
localizable_to_dict(self.label, locales)
if self.label is not None
else None
),
}
class PlaceholderFieldEntry(FieldEntry[str]):
type = FieldEntryType.PLACEHOLDER
content: str
def __init__(self, content: str, label: MaybeTranslatedString | None = None):
self.label = label
self.content = content
class CustomFieldEntry(FieldEntry[MaybeTranslatedString]):
type: FieldEntryType
content: MaybeTranslatedString
def asdict(self, locales: list[str]) -> dict:
return {
"type": self.type.value,
"content": localizable_to_dict(self.content, locales),
"label": localizable_to_dict(self.label, locales) if self.label else None,
}
class PredefinedFieldGroup(FieldGroup):
type = FieldGroupType.PREDEFINED
default_active: bool
def __init__(
self,
identifier: str,
name: str,
description=None,
required=False,
default_active=True,
):
self.identifier = identifier
self.name = name
self.required = required
self.description = description or ""
self.default_active = default_active
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: LayoutContext,
):
return {"type": "object", "properties": {"active": {"type": "boolean"}}}
def asdict(self, *args, **kwargs):
return {
**super().asdict(*args, **kwargs),
"default_active": self.default_active,
}
def default(self, locales):
return {"active": self.default_active}
class PlaceholderFieldGroup(FieldGroup):
type = FieldGroupType.PLACEHOLDER
content_type: FieldContentType
default_entries: list[FieldEntry]
display: FieldGroupDisplay
min_entries: int | None
max_entries: int | None
context_args: set[
str
] # what context arguments are available when rendering this fieldgroup
can_overflow: bool
default_overflow: str | None
def __init__(
self,
identifier: str,
name: str,
content_type: FieldContentType,
description: str = "",
required=False,
default_entries=None,
min_entries=None,
max_entries=None,
display=FieldGroupDisplay.WITH_LABEL,
context_args: set[str] | None = None,
can_overflow: bool = True,
default_overflow: str | None = None,
):
super().__init__(identifier, name, description, required)
self.content_type = content_type
self.default_entries = default_entries or []
self.min_entries = min_entries
self.max_entries = max_entries
self.display = display
self.context_args = context_args or set()
self.can_overflow = can_overflow
self.default_overflow = default_overflow
if self.required and (self.min_entries is None or self.min_entries < 1):
self.min_entries = 1
def asdict(
self,
locales: list[str],
context: LayoutContext,
remaining_fields: list[FieldGroup],
):
return {
**super().asdict(locales, context, remaining_fields),
"content_type": self.content_type.value,
"default_entries": [x.asdict(locales) for x in self.default_entries],
"display": self.display.value,
"min_entries": self.min_entries,
"max_entries": self.max_entries,
"context_args": list(
get_available_context(
self.context_args, context["placeholder_renderer"].transformations
)
),
"overflows": self.overflows(
remaining_fields, context, self.available_placeholders(context)
),
"default_overflow": self.default_overflow,
}
def default(self, locales):
return {"entries": [x.asdict(locales) for x in self.default_entries]}
def available_placeholders(self, context):
content_type_placeholders = (
context["placeholders"].get(self.content_type.value, {}).values()
)
renderer = context["placeholder_renderer"]
available_placeholders = [
x.identifier
for x in content_type_placeholders
if renderer.is_available(x, self.context_args)
]
return available_placeholders
def overflows(self, remaining_fields, context, available_placeholders):
if self.can_overflow:
return [
f.identifier
for f in remaining_fields
if isinstance(f, PlaceholderFieldGroup)
and f.content_type == self.content_type
and f.display == self.display
and set(available_placeholders)
<= set(f.available_placeholders(context))
]
else:
return []
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: LayoutContext,
):
available_placeholders = self.available_placeholders(context)
return {
"type": "object",
"properties": {
"entries": self.entries_schema(placeholders=available_placeholders),
"overflow": {
"anyOf": [
{"type": "null"},
{
"type": "string",
"enum": self.overflows(
remaining_fields, context, available_placeholders
),
},
]
},
},
"required": ["entries"],
}
def entries_schema(self, placeholders: list[str]):
baseprops = {}
if self.display == FieldGroupDisplay.WITH_LABEL:
baseprops["label"] = {"$ref": "#/$defs/I18nString"}
schema = {
"type": "array",
"items": {
"type": "object",
"anyOf": [
{
"properties": {
**baseprops,
"type": {"const": "placeholder"},
"content": {"enum": placeholders},
}
},
{
"properties": {
**baseprops,
"type": {"const": "custom"},
"content": {"$ref": "#/$defs/I18nString"},
}
},
],
"required": ["type", "content"],
},
}
if self.display == FieldGroupDisplay.WITH_LABEL:
schema["items"]["required"].append("label")
if self.min_entries is not None:
schema["minItems"] = self.min_entries
# max_entries is not enforced here, as the layout can have more fields than that (null-fields are removed, rest is overspilled)
return schema
class TextFieldGroup(PlaceholderFieldGroup):
content_type = FieldContentType.TEXT
def __init__(self, **kwargs):
super().__init__(content_type=self.content_type, **kwargs)
class ImageFieldGroup(PlaceholderFieldGroup):
content_type = FieldContentType.IMAGE
display = FieldGroupDisplay.PLAIN
def __init__(self, **kwargs):
super().__init__(content_type=self.content_type, display=self.display, **kwargs)
class PassStyle:
platform: type[WalletPlatform]
identifier: str # unique within platform
name: str
def __init__(
self, event, layout=None, file_settings: dict[str, File] | None = None
):
self.event = event
self.layout = layout
self.file_settings = file_settings or {}
self.placeholders = get_wallet_placeholders(self.event)
@property
def fieldgroups(self) -> list[FieldGroup]:
# order here limits in what order users can configure field "overspilling" (if too many fields are defined, where should the rest go)
# -> can only go down in the list
# we evaluate the fields in this order, so they overspill in this order as well
return []
@property
def settings(self) -> list[SettingsField]:
return []
@cached_property
def _settings_dict(self):
return {x.identifier: x for x in self.settings}
@property
def preview_renderer(self) -> str | None:
return None
@property
def preview_data(self) -> dict:
return {}
@property
def default_layout(self) -> dict:
return {
"fieldgroups": {
fieldgroup.identifier: fieldgroup.default(self.event.settings.locales)
for fieldgroup in self.fieldgroups
},
}
def get_cleaned_setting(
self, setting_identifier: str
) -> SettingsFile | str | float | bool | None:
if not self.layout:
raise ValueError("`layout` needs to be set")
setting = self._settings_dict.get(setting_identifier)
if not setting:
return None
if setting.type == "image":
val = self.file_settings.get(setting.identifier)
else:
val = self.layout.get("settings", {}).get(
setting.identifier, setting.default
)
return setting.clean(val)
def asdict(self):
context = LayoutContext(
placeholders=self.placeholders,
placeholder_renderer=get_wallet_placeholder_renderer(),
)
return {
"identifier": self.identifier,
"name": self.name,
"fieldgroups": [
x.asdict(
self.event.settings.locales,
context,
remaining_fields=self.fieldgroups[i:],
)
for i, x in enumerate(self.fieldgroups, 1)
],
"preview_renderer": self.preview_renderer,
"preview_data": self.preview_data,
"settings": [x.asdict(self.event.settings.locales) for x in self.settings],
}
def layout_schema(self):
context = LayoutContext(
placeholders=self.placeholders,
placeholder_renderer=get_wallet_placeholder_renderer(),
)
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
# TODO: $id
"title": self.name,
"type": "object",
"properties": {
"fieldgroups": {
"description": "Layout Field Groups",
"type": "object",
"properties": {
group.identifier: group.layout_schema(
context=context, remaining_fields=self.fieldgroups[i:]
)
for (i, group) in enumerate(self.fieldgroups, 1)
},
"required": [
group.identifier for group in self.fieldgroups if group.required
],
},
"settings": {
"type": "object",
"properties": {
setting.identifier: setting.layout_schema()
for setting in self.settings
if setting.layout_schema()
},
"required": [
setting.identifier
for setting in self.settings
if setting.required and setting.layout_schema()
],
},
},
"$defs": {
"I18nString": {
"oneOf": [
{"type": "string"},
{"type": "object", "additionalProperties": {"type": "string"}},
]
}
},
}
if any(group.required for group in self.fieldgroups):
schema.setdefault("required", [])
schema["required"].append("fieldgroups")
if any(
setting.required and setting.type == "text" for setting in self.settings
):
schema.setdefault("required", [])
schema["required"].append("settings")
return schema
def render_placeholder(self, context, content_type, content):
placeholder = self.placeholders.get(content_type, {}).get(content)
if placeholder:
placeholder_value = context.render_placeholder(placeholder)
if placeholder_value:
return placeholder.label, placeholder_value
return None, None
def validate(self):
schema = self.layout_schema()
try:
jsonschema.validate(self.layout, schema)
except jsonschema.ValidationError as e:
raise ValidationError("Invalid layout: {}".format(str(e)))
def extract_file_settings(self, request, file_settings):
res = {}
for setting in self.settings:
if setting.type == "image":
if file_settings.get(setting.identifier) == "file:keep":
res[setting.identifier] = "keep"
elif data := file_settings.get(setting.identifier):
res[setting.identifier] = handle_file_upload(
data,
request.user,
getattr(request, "auth", None),
{"image/png", "image/jpeg"},
)
elif setting.identifier in file_settings:
res[setting.identifier] = None
return res
def _render_placeholder_fieldgroup_entries(self, group, context):
group_fields = []
if group.identifier not in self.layout["fieldgroups"]:
return group_fields
for field in self.layout["fieldgroups"][group.identifier]["entries"]:
field_entry: dict[str, MaybeTranslatedString | None] = {}
if group.display == FieldGroupDisplay.WITH_LABEL:
field_entry["label"] = LazyI18nString(field["label"])
if field["type"] == FieldEntryType.PLACEHOLDER.value:
label, field_entry["value"] = self.render_placeholder(
context, group.content_type.value, field["content"]
)
if (
group.display == FieldGroupDisplay.WITH_LABEL
and not str(field_entry["label"])
and label
):
field_entry["label"] = label
elif field["type"] == FieldEntryType.CUSTOM.value:
field_entry["value"] = LazyI18nString(field["content"])
if "value" in field_entry and field_entry["value"]:
group_fields.append(field_entry)
return group_fields
def get_pass_fields(self, op: OrderPosition):
if not self.layout:
raise ValueError("`layout` needs to be set")
context = get_wallet_placeholder_renderer(event=self.event, order_position=op)
fields = {}
for group in self.fieldgroups:
if not isinstance(group, PlaceholderFieldGroup):
continue
fields.setdefault(group.identifier, [])
fields[group.identifier] += self._render_placeholder_fieldgroup_entries(
group, context
)
if group.min_entries and len(fields[group.identifier]) < group.min_entries:
raise ValueError(
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(fields[group.identifier])} were provided"
)
if (
overflow_group := self.layout["fieldgroups"]
.get(group.identifier, {})
.get("overflow")
):
fields.setdefault(overflow_group, [])
fields[overflow_group] += fields[group.identifier][group.max_entries :]
fields[group.identifier] = fields[group.identifier][: group.max_entries]
return fields
def group_is_active(self, identifier: str):
if not self.layout:
raise ValueError("`layout` needs to be set")
return self.layout["fieldgroups"].get(identifier, {}).get("active", False)
def generate(self, op: OrderPosition):
raise NotImplementedError()
class PluginAwareClassRegistry[T](PluginAwareRegistry):
base_class: type[T]
def register(self, *objs):
for obj in objs:
if not issubclass(obj, self.base_class):
raise TypeError(f"Entries must be derived from {self.base_class}")
return super().register(*objs)
def filter(
self, active_in=None, **kwargs
) -> Iterable[tuple[type[T], dict[str, Any]]]:
result = super().filter(**kwargs)
if active_in is not None:
result = (
(entry, meta)
for entry, meta in result
if is_app_active(active_in, meta["plugin"])
)
return result
def get(
self, active_in=None, **kwargs
) -> tuple[type[T], dict[str, Any]] | tuple[None, None]:
return super().get(active_in=active_in, **kwargs)
class WalletPlatformRegistry(PluginAwareClassRegistry[WalletPlatform]):
base_class = WalletPlatform
def __init__(self):
super().__init__({"identifier": lambda o: o.identifier})
class PassStyleRegistry(PluginAwareClassRegistry[PassStyle]):
base_class = PassStyle
def __init__(self):
super().__init__(
{
"identifier": lambda o: (o.platform.identifier, o.identifier),
"platform": lambda o: o.platform.identifier,
}
)
wallet_platform_registry = WalletPlatformRegistry()
pass_style_registry = PassStyleRegistry()
@@ -0,0 +1,8 @@
from .platform import GooglePlatform
from .styles import GoogleWalletCustomEventTicket, GoogleWalletDefaultEventTicket
__all__ = [
"GooglePlatform",
"GoogleWalletCustomEventTicket",
"GoogleWalletDefaultEventTicket",
]
@@ -0,0 +1,525 @@
import base64
import datetime
import enum
import json
import struct
import time
from typing import Iterable
import requests
from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS
from google.auth import crypt, jwt
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
from pretix.base.models.orders import OrderPosition
from pretix.plugins.wallet.i18n import (
MaybeTranslatedString, localizable_to_dict,
)
class ReviewStatus(enum.StrEnum):
APPROVED = "APPROVED"
DRAFT = "DRAFT"
REJECTED = "REJECTED"
UNDER_REVIEW = "UNDER_REVIEW"
REVIEW_STATUS_UNSPECIFIED = "REVIEW_STATUS_UNSPECIFIED"
class ConfirmationCodeLabel(enum.StrEnum):
CONFIRMATION_CODE = "CONFIRMATION_CODE"
CONFIRMATION_NUMBER = "CONFIRMATION_NUMBER"
ORDER_NUMBER = "ORDER_NUMBER"
RESERVATION_NUMBER = "RESERVATION_NUMBER"
class AnimationType(enum.StrEnum):
FOIL_SHIMMER = "FOIL_SHIMMER"
class DoorsOpenLabel(enum.StrEnum):
DOORS_OPEN = "DOORS_OPEN"
GATES_OPEN = "GATES_OPEN"
class State(enum.StrEnum):
ACTIVE = "ACTIVE"
COMPLETED = "COMPLETED"
EXPIRED = "EXPIRED"
INACTIVE = "INACTIVE"
class BarcodeType(enum.StrEnum):
AZTEC = "AZTEC"
CODE_39 = "CODE_39"
CODE_128 = "CODE_128"
CODABAR = "CODABAR"
DATA_MATRIX = "DATA_MATRIX"
EAN_8 = "EAN_8"
EAN_13 = "EAN_13"
EAN13 = "EAN13"
ITF_14 = "ITF_14"
PDF_417 = "PDF_417"
PDF417 = "PDF417"
QR_CODE = "QR_CODE"
UPC_A = "UPC_A"
TEXT_ONLY = "TEXT_ONLY"
class Resource(enum.StrEnum):
EVENT_TICKET_CLASS = "eventTicketClass"
EVENT_TICKET_OBJECT = "eventTicketObject"
def item(field):
return {"firstValue": {"fields": [{"fieldPath": field}]}}
def card_row_template_info(fields: Iterable[str]):
items = [item(field) for field in fields]
if len(items) == 3:
return {
"threeItems": {
"startItem": items[0],
"middleItem": items[1],
"endItem": items[2],
}
}
elif len(items) == 2:
return {
"twoItems": {
"startItem": items[0],
"endItem": items[1],
}
}
else:
return {
"oneItem": {
"startItem": items[0],
}
}
def localized_string(
string: MaybeTranslatedString, locales: set[str], default_locale: str
):
if isinstance(string, str):
return {
"defaultValue": {
"language": default_locale,
"value": string,
}
}
else:
string_dict = localizable_to_dict(string, locales)
default_string = string_dict.pop(default_locale)
return {
"defaultValue": {
"language": default_locale,
"value": default_string,
},
"translatedValues": [
{"language": key, "value": value} for key, value in string_dict.items()
],
}
def localized_uri(
uri: str, description: MaybeTranslatedString, locales: set[str], default_locale: str
):
return {
"uri": uri,
"localizedDescription": localized_string(description, locales, default_locale),
}
class Image(dict):
def __init__(
self,
uri: str,
content_description: MaybeTranslatedString,
locales: set[str],
default_locale: str,
):
self["uri"] = uri
self["contentDescription"] = localized_string(
content_description, locales, default_locale
)
class BaseGoogleWalletObject:
resource: Resource
id: str
data: dict
locales: set[str]
default_locale: str
order_position = None
def _localized_string(self, string: MaybeTranslatedString):
return localized_string(string, self.locales, self.default_locale)
def _localized_uri(
self,
uri: str,
description: MaybeTranslatedString,
):
return localized_uri(uri, description, self.locales, self.default_locale)
def _image(self, uri: str, content_description: MaybeTranslatedString):
return {
"sourceUri": {"uri": uri},
"contentDescription": self._localized_string(content_description),
}
def serialize(self, mini=True):
if mini:
return {"id": self.id}
else:
return self.data
class EventTicketClass(BaseGoogleWalletObject):
resource = Resource.EVENT_TICKET_CLASS
def __init__(
self,
issuer_name: str,
class_id: str,
event_name: MaybeTranslatedString,
locales: set[str],
default_locale: str,
):
self.id = class_id
self.default_locale = default_locale
self.locales = locales
self.data = {
"id": class_id,
"issuerName": issuer_name,
"eventName": localized_string(
event_name, self.locales, self.default_locale
),
"reviewStatus": ReviewStatus.UNDER_REVIEW,
}
def homepage_uri(
self,
uri: str,
description: MaybeTranslatedString,
):
self.data["homepageUri"] = self._localized_uri(uri, description)
def country_code(self, code: str):
self.data["countryCode"] = code
def hero_image(self, uri: str, content_description: MaybeTranslatedString):
self.data["heroImage"] = self._image(uri, content_description)
def logo(self, uri: str, content_description: MaybeTranslatedString):
self.data["logo"] = self._image(uri, content_description)
def wide_logo(self, uri: str, content_description: MaybeTranslatedString):
self.data["wideLogo"] = self._image(uri, content_description)
def hex_background_color(self, hex_background_color: str):
self.data["hexBackgroundColor"] = hex_background_color
def confirmation_code_label(self, label: ConfirmationCodeLabel):
self.data["confirmationCodeLabel"] = label
def venue(self, name: MaybeTranslatedString, address: MaybeTranslatedString):
self.data["venue"] = {
"name": self._localized_string(name),
"address": self._localized_string(address),
}
def security_animation(self, security_animation: AnimationType):
self.data["securityAnimation"] = {"animationType": security_animation}
def doors_open(
self,
doors_open: str,
doors_open_label: None | DoorsOpenLabel | MaybeTranslatedString = None,
):
self.data.setdefault("dateTime", {})
self.data["dateTime"]["doorsOpen"] = doors_open
if doors_open_label and isinstance(doors_open_label, DoorsOpenLabel):
self.data["dateTime"]["doorsOpenLabel"] = doors_open_label
elif doors_open_label:
self.data["dateTime"]["customDoorsOpenLabel"] = self._localized_string(
doors_open_label
)
def start(self, start):
self.data.setdefault("dateTime", {})
self.data["dateTime"]["start"] = start
def end(self, end):
self.data.setdefault("dateTime", {})
self.data["dateTime"]["end"] = end
def callback_url(self, callback_url: str):
self.data["callbackOptions"] = {"url": callback_url}
def card_template_override(self, overrides: list[list[str]]):
self.data.setdefault("classTemplateInfo", {})
self.data["classTemplateInfo"].setdefault("cardTemplateOverride", {})
self.data["classTemplateInfo"]["cardTemplateOverride"][
"cardRowTemplateInfos"
] = [card_row_template_info(x) for x in overrides]
def details_item_infos(self, fields: list[str]):
self.data.setdefault("classTemplateInfo", {})
self.data["classTemplateInfo"].setdefault("detailsTemplateOverride", {})
self.data["classTemplateInfo"]["detailsTemplateOverride"][
"detailsItemInfos"
] = [{"item": item(field)} for field in fields]
def add_text_module(
self, id: str, header: MaybeTranslatedString, body: MaybeTranslatedString
):
self.data.setdefault("textModulesData", [])
self.data["textModulesData"].append(
{
"id": id,
"localizedHeader": self._localized_string(header),
"localizedBody": self._localized_string(body),
}
)
class EventTicketObject(BaseGoogleWalletObject):
resource = Resource.EVENT_TICKET_OBJECT
def __init__(
self,
object_id: str,
class_id: str,
state: State,
locales: set[str],
default_locale: str,
order_position: OrderPosition
):
self.id = object_id
self.locales = locales
self.default_locale = default_locale
self.order_position = order_position
self.data = {
"id": object_id,
"classId": class_id,
"state": state,
}
def barcode(
self,
value: str,
alternate_text: str | None = None,
type: BarcodeType = BarcodeType.QR_CODE,
):
self.data["barcode"] = {
"type": type,
"value": value,
}
if alternate_text:
self.data["barcode"]["alternateText"] = alternate_text
def reservation_info(self, confirmation_code: str):
self.data["reservationInfo"] = {
"confirmationCode": confirmation_code,
}
def ticket_number(self, ticket_number: str):
self.data["ticket_number"] = ticket_number
def valid_time_interval(self, start: datetime.datetime | None, end: datetime.datetime | None):
self.data.setdefault("validTimeInterval", {})
if start:
self.data["validTimeInterval"]["start"] = {"date": start.isoformat()}
if end:
self.data["validTimeInterval"]["end"] = {"date": end.isoformat()}
def ticket_type(self, ticket_type: MaybeTranslatedString):
self.data["ticketType"] = self._localized_string(ticket_type)
def ticket_holder_name(self, name):
self.data["ticketHolderName"] = name
def seat(self, seat):
self.data.setdefault("seatInfo", {})
self.data["seatInfo"]["seat"] = self._localized_string(seat)
def row(self, row):
self.data.setdefault("seatInfo", {})
self.data["seatInfo"]["row"] = self._localized_string(row)
def section(self, section):
self.data.setdefault("seatInfo", {})
self.data["seatInfo"]["section"] = self._localized_string(section)
def add_text_module(
self, id: str, header: MaybeTranslatedString, body: MaybeTranslatedString
):
self.data.setdefault("textModulesData", [])
self.data["textModulesData"].append(
{
"id": id,
"localizedHeader": self._localized_string(header),
"localizedBody": self._localized_string(body),
}
)
class ButtonJWT(dict):
def __init__(
self,
issuer,
origins,
payload: dict[str, list[BaseGoogleWalletObject]],
precreated=True,
):
serialized_payload = {}
for k, v in payload.items():
serialized_payload[k] = [x.serialize(mini=precreated) for x in v]
self.payload = serialized_payload
self.issuer = issuer
self.origins = origins
def serialize(self):
return {
"iss": self.issuer,
"aud": "google",
"typ": "savetowallet",
"iat": int(time.time()),
"payload": self.payload,
"origins": self.origins,
}
class Comms:
def __init__(self, service_account_info: dict):
credentials = service_account.Credentials.from_service_account_info(
service_account_info,
scopes=["https://www.googleapis.com/auth/wallet_object.issuer"],
)
self._session = AuthorizedSession(credentials)
self._signer = crypt.RSASigner.from_service_account_info(service_account_info)
self.client_email = service_account_info.get("client_email")
def get_item(self, resource: Resource, resource_id: str):
result = self._session.get(
f"https://walletobjects.googleapis.com/walletobjects/v1/{resource}/{resource_id}"
)
if result.status_code == 200:
return result.json()
elif result.status_code == 404:
return False
def put_item(self, resource: Resource, resource_id: str, payload: dict):
item = self.get_item(resource, resource_id)
if item is None:
raise Exception("API Error")
elif not item:
result = self._session.post(
f"https://walletobjects.googleapis.com/walletobjects/v1/{resource}?strict=true",
json=payload,
)
else:
result = self._session.put(
f"https://walletobjects.googleapis.com/walletobjects/v1/{resource}/{resource_id}?strict=true",
json=payload,
)
result.raise_for_status()
return result.json()
def patch_item(self, resource: Resource, resource_id: str, payload: dict):
result = self._session.patch(
f"https://walletobjects.googleapis.com/walletobjects/v1/{resource}/{resource_id}?strict=true",
json=payload,
)
result.raise_for_status()
return result.json()
def sign_jwt(self, payload):
return jwt.encode(self._signer, payload).decode()
def pack(string):
ret = struct.pack("<Q", len(string))[:4] + bytes(string, "utf-8")
return ret
class GooglePaySignatureManager:
ROOT_KEYS_URL = "https://pay.google.com/gp/m/issuer/keys"
SENDER_ID = "GooglePayPasses"
PROTOCOL = "ECv2SigningOnly"
def __init__(self, recipient_id: str):
self.recipient_id = recipient_id
def root_keys(self):
r = requests.get(self.ROOT_KEYS_URL)
r.raise_for_status()
return r.json()["keys"]
def verify_signature(self, keys, salt, message, signatures):
salt = b"".join(pack(x) for x in salt)
for key in keys:
if datetime.datetime.fromtimestamp(
int(key["keyExpiration"]) / 1000, tz=datetime.UTC
) > datetime.datetime.now(datetime.UTC):
pk = ECC.import_key(base64.b64decode(key["keyValue"]))
verifier = DSS.new(pk, "fips-186-3", "der")
for signature in signatures:
try:
verifier.verify(
SHA256.new(salt + pack(self.PROTOCOL) + pack(message)),
base64.b64decode(signature),
)
return message
except ValueError:
continue
raise ValueError("No valid signature found")
def unseal(self, data):
for k in (
"signature",
"intermediateSigningKey",
"protocolVersion",
"signedMessage",
):
if k not in data:
raise ValueError(f"Missing key: {k}")
intermediate = self.verify_signature(
self.root_keys(),
[self.SENDER_ID],
data["intermediateSigningKey"]["signedKey"],
data["intermediateSigningKey"]["signatures"],
)
intermediateSigningKey = json.loads(intermediate)
message = self.verify_signature(
[intermediateSigningKey],
[self.SENDER_ID, self.recipient_id],
data["signedMessage"],
[data["signature"]],
)
message = json.loads(message)
if datetime.datetime.fromtimestamp(
int(message["expTimeMillis"]) / 1000, tz=datetime.UTC
) <= datetime.datetime.now(datetime.UTC):
raise ValueError("stale event")
return message
@@ -0,0 +1,245 @@
import json
import uuid
from functools import cached_property
from typing import OrderedDict
from django.conf import settings
from django.db import transaction
from django.utils import translation
from django.utils.translation import gettext as _
from django import forms
from django.utils.crypto import salted_hmac
from pretix.base.models import Event, OrderPosition
from pretix.base.models.orders import Order
from pretix.base.settings import GlobalSettingsObject
from pretix.plugins.wallet.models import GoogleWalletInstance
from ..base import (
PassStyle,
WalletPlatform,
wallet_platform_registry
)
from .google_wallet import (
BaseGoogleWalletObject,
ButtonJWT,
Comms,
)
from django.utils.html import format_html
from django.utils.functional import classproperty
def _get_instance_uuid():
gs = GlobalSettingsObject()
if not gs.settings.wallet_google_instance_uuid:
gs.settings.wallet_google_instance_uuid = str(uuid.uuid4())
return gs.settings.wallet_google_instance_uuid
def get_class_id(event: Event, op: OrderPosition):
instance_uuid = _get_instance_uuid()
issuer_id = event.settings.wallet_google_issuer_id
return "%s.pretix-%s-%s-%s-%s-%s" % (
issuer_id,
instance_uuid,
event.organizer.slug,
event.slug,
op.item_id,
op.variation_id or 0,
)
def get_object_id(op: OrderPosition):
instance_uuid = _get_instance_uuid()
issuer_id = op.order.event.settings.wallet_google_issuer_id
op_secret = salted_hmac(
key_salt="pretix.plugins.wallet.google.object_id",
algorithm="sha256",
secret=op.web_secret,
value=str(op.pk),
).hexdigest()
return f"{issuer_id}.pretix-{instance_uuid}-{op_secret}"
def get_translated_dict(string, locales):
translated = {}
for locale in locales:
translation.activate(locale)
translated[locale] = _(string)
translation.deactivate()
return translated
def get_translated_string(string, locale):
translation.activate(locale)
translated = _(string)
translation.deactivate()
return translated
def dedup(list):
new = []
for i in list:
if not any(x.data == i.data for x in new):
new.append(i)
return new
@wallet_platform_registry.register
class GooglePlatform(WalletPlatform):
identifier = "google"
name = _("Google Wallet")
def render_download_button(self) -> tuple[dict[str, str] | None, str]:
confirmation_text = _(
"Please be aware, that contrary to other virtual wallets/passes (like Apple Wallet), Google Wallet Passes are not "
"handled offline. Every pass that is created, has to be transmitted to Google Inc.\n"
"\n"
'By clicking "Confirm" below, we will transfer some of your personal information, which is '
"necessary to provide you with your Google Wallet Pass, to Google Inc.\n"
"\n"
"Please be aware, that there is no way to delete the data, once it has been transmitted.\n"
)
return (
{"data-confirmation-text": confirmation_text},
format_html(
'<span class="fa fa-download"></span> {}',
_("Add to {wallet}").format(wallet=self.name),
),
)
@classmethod
def get_global_settings(cls, **kwargs):
return OrderedDict(
[
(
"wallet_google_issuer_id",
forms.CharField(
label=_("Google Wallet Issuer/Merchant ID"),
help_text=_(
# TODO: update text
"After getting accepted by Google into the Google Pay API for Passes program, "
"your Issuer ID can be found in the Merchant center at "
"https://wallet.google.com/merchant/walletobjects/"
),
required=False,
),
),
(
"wallet_google_credentials",
forms.FileField(
label=_("Google Wallet Service Account Credentials"),
help_text=_(
"Please paste the contents of the JSON credentials file "
"of the service account you tied to your Google Pay API "
"for Passes Issuer ID"
),
required=False,
# TODO: add validator
# validators=[validate_json_credentials]
),
),
(
"wallet_google_instance_uuid",
forms.CharField(
label=_("Google Wallet Pass Instance UUID"),
help_text=_(
"Instance-specific part to be added to the wallet ids"
),
required=False,
),
),
]
)
@classproperty
def default_style(self):
return "event"
@cached_property
def comms(self):
return Comms(
json.loads(self.event.settings.get("wallet_google_credentials").read())
)
def put_item_cached(self, object: BaseGoogleWalletObject):
with transaction.atomic():
instance, created = GoogleWalletInstance.objects.get_or_create(
event=self.event,
resource=object.resource,
identifier=object.id,
defaults={"data": object.data, "order_position": object.order_position},
)
if created or instance.data != object.data:
result = self.comms.put_item(object.resource, object.id, object.data)
instance.data = object.data
instance.save(update_fields=["data"])
return result
def _get_jwt(self, ticket_objects):
for to in dedup(ticket_objects):
self.put_item_cached(to)
generated_jwt = self.comms.sign_jwt(
ButtonJWT(
origins=[settings.SITE_URL],
issuer=self.comms.client_email,
payload={"eventTicketObjects": ticket_objects},
precreated=True,
).serialize()
)
return f"https://pay.google.com/gp/v/save/{generated_jwt}"
def generate(self, position):
# TODO?: early-exit caching (if object for position exists and layout is None)
op_layout = self.get_layout(position)
if not op_layout:
raise ValueError(
_(
"WalletPlatform.generate called without layout and without position layout"
)
)
layout = op_layout.pass_layout
class_object, ticket_object = layout.generate(position)
self.put_item_cached(class_object)
return self._get_jwt([ticket_object])
def generate_preview(self, position: OrderPosition, layout: PassStyle) -> str:
class_object, ticket_object = layout.generate(position)
self.put_item_cached(class_object)
return self._get_jwt([ticket_object])
def generate_order(self, order: Order):
class_objects = []
ticket_objects = []
for position in order.positions_with_tickets:
if self.is_available_for_position(position):
layout = self.get_layout(position)
if not layout:
raise ValueError(
"Impossible: is_available_for_position==True but no layout"
)
class_object, ticket_object = layout.pass_layout.generate(position, False)
class_objects.append(class_object)
ticket_objects.append(ticket_object)
for co in dedup(class_objects):
self.put_item_cached(co)
return self._get_jwt(ticket_objects)
def is_available(self):
for field in ["wallet_google_issuer_id", "wallet_google_credentials"]:
if not self.event.settings.get(field):
return False
return super().is_available()
@@ -0,0 +1,345 @@
from urllib.parse import urljoin
from django.utils.translation import gettext as _
from pretix.base.models import OrderPosition
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.wallet.i18n import (
FormattingLocalizableString, GettextLocalizableString, LazyLocalizable,
)
from ..base import (
FieldGroup, FieldGroupDisplay, PassStyle, PlaceholderFieldEntry,
PredefinedFieldGroup, TextFieldGroup, pass_style_registry
)
from .google_wallet import (
AnimationType, ConfirmationCodeLabel, DoorsOpenLabel, EventTicketClass, EventTicketObject,
State,
)
from ..settings import (
BooleanSettingsField, ColorSettingsField, FloatSettingsField,
ImageSettingsField,
)
from .platform import GooglePlatform, get_class_id, get_object_id
class GoogleWalletEventTicketStyle(PassStyle):
platform = GooglePlatform
def generate(self, op):
self.op = op
class_object = self._generate_class()
ticket_object = self._generate_object(op, class_id=class_object.id)
return class_object, ticket_object
@property
def settings(self):
return [
ImageSettingsField(
identifier="logo",
label=_("Logo"),
default=self.event.settings.logo_image
or "pretixplugins/wallet/logo.png",
),
# TODO: wide logo
ImageSettingsField(
identifier="hero",
label=_("Hero Image"),
),
ColorSettingsField(
identifier="bg_color",
label=_("Background Color"),
default=self.event.settings.primary_color,
),
FloatSettingsField(
identifier="lat",
label=_("Latitude"),
min=-90,
max=90,
default=self.event.geo_lat,
),
FloatSettingsField(
identifier="long",
label=_("Longitude"),
min=-180,
max=180,
default=self.event.geo_lon,
),
BooleanSettingsField(
identifier="shimmer", label=_("Add shimmer animation around barcode")
),
]
@property
def fieldgroups(self) -> list[FieldGroup]:
return [
PredefinedFieldGroup(identifier="venue", name=_("Venue")),
PredefinedFieldGroup(identifier="date", name=_("Date")),
TextFieldGroup(
identifier="code",
name=_("QR-Code"),
max_entries=1,
display=FieldGroupDisplay.PLAIN,
default_entries=[
PlaceholderFieldEntry(
content="secret",
)
],
context_args={"order_position"},
),
]
@property
def preview_data(self):
return {
"event": str(self.event),
"organizer": str(self.event.organizer),
"date_from": self.event.date_from.astimezone(
self.event.timezone
).isoformat(),
"venue": self.venue(),
"event_page": eventreverse_absolute(self.event, "presale:event.index"),
}
def venue(self):
if not str(self.event.location).strip():
return
def _get_name():
lines = str(self.event.location).strip().splitlines()
return lines[0]
def _get_address():
lines = str(self.event.location).splitlines()
# We must provide at least one address line each for the name and address - no way around it.
if len(lines) > 1:
return "\n".join(lines[1:])
else:
return lines[0]
return LazyLocalizable(_get_name), LazyLocalizable(_get_address)
def _generate_class(self):
output_class = EventTicketClass(
self.event.organizer.name,
get_class_id(self.event, self.op),
self.event.name,
self.event.settings.locales,
self.event.settings.locale,
)
output_class.homepage_uri(
eventreverse_absolute(self.event, "presale:event.index"),
GettextLocalizableString.gettext("Website"),
)
# TODO: merchantLocations, locations is deprecated
# if (lat := self.get_cleaned_setting("lat")) and (
# long := self.get_cleaned_setting("long")
# ):
# output_class.locations(lat, long)
output_class.country_code(self.event.settings.locale)
if hero := self.get_cleaned_setting("hero"):
output_class.hero_image(
urljoin(
eventreverse_absolute(self.event, "presale:event.index"), hero.url
),
self.event.name,
)
if logo := self.get_cleaned_setting("logo"):
output_class.logo(
urljoin(
eventreverse_absolute(self.event, "presale:event.index"), logo.url
),
self.event.name,
)
output_class.hex_background_color(self.get_cleaned_setting("bg_color"))
output_class.confirmation_code_label(ConfirmationCodeLabel.ORDER_NUMBER)
if self.group_is_active("venue") and (venue := self.venue()):
output_class.venue(*venue)
if self.get_cleaned_setting("shimmer"):
output_class.security_animation(AnimationType.FOIL_SHIMMER)
if self.group_is_active("date"):
output_class.start(
self.event.date_from.astimezone(self.event.timezone).isoformat()
)
if self.event.settings.show_date_to and self.event.date_to:
output_class.end(
self.event.date_to.astimezone(self.event.timezone).isoformat()
)
if self.event.date_admission:
output_class.doors_open(
self.event.date_admission.astimezone(
self.event.timezone
).isoformat(),
DoorsOpenLabel.DOORS_OPEN,
)
output_class.callback_url(
eventreverse_absolute(
self.event,
"plugins:wallet:google_webhook",
)
)
return output_class
def _generate_object(self, op: OrderPosition, class_id: str):
output_object = EventTicketObject(
get_object_id(op),
class_id,
State.ACTIVE,
self.event.settings.locales,
self.event.settings.locale,
order_position=op
)
fields = self.get_pass_fields(op)
if fields["code"]:
output_object.barcode(fields["code"][0]["value"])
output_object.reservation_info(op.order.code)
output_object.ticket_number(op.code)
output_object.valid_time_interval(op.valid_from, op.valid_until)
output_object.ticket_type(
FormattingLocalizableString(
"{name} - {variation}", name=op.item.name, variation=op.variation.value
)
if op.variation
else op.item.name
)
return output_object
@pass_style_registry.register
class GoogleWalletDefaultEventTicket(GoogleWalletEventTicketStyle):
identifier = "event"
name = "Default Event Ticket"
@property
def preview_renderer(self):
return "google_eventticket_default"
@property
def fieldgroups(self):
return [
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
PredefinedFieldGroup(identifier="ticket_holder", name=_("Ticket Holder")),
*super().fieldgroups,
]
def ticket_holder_name(self, op):
return op.attendee_name or (op.addon_to.attendee_name if op.addon_to else None)
def _generate_object(self, op: OrderPosition, class_id: str):
output_object = super()._generate_object(op, class_id)
if self.group_is_active("ticket_holder") and self.ticket_holder_name(op):
output_object.ticket_holder_name(self.ticket_holder_name(op))
if self.group_is_active("seating") and (seat := self.op.seat):
if seat.zone_name:
output_object.section(seat.zone_name)
if seat.row_label:
output_object.row(seat.row_label)
elif seat.row_name:
output_object.row(seat.row_name)
if seat.seat_label:
output_object.seat(seat.seat_label)
elif seat.seat_number:
output_object.seat(seat.seat_number)
return output_object
@pass_style_registry.register
class GoogleWalletCustomEventTicket(GoogleWalletEventTicketStyle):
identifier = "event_custom"
name = "Custom Event Ticket"
@property
def preview_renderer(self):
return "google_eventticket_custom"
@property
def fieldgroups(self):
return [
*super().fieldgroups,
TextFieldGroup(
identifier="second_row",
name=_("Second Row"),
max_entries=3,
context_args={"order_position"},
),
TextFieldGroup(
identifier="third_row",
name=_("Third Row"),
max_entries=3,
context_args={"order_position"},
),
TextFieldGroup(
identifier="details",
name=_("Details"),
context_args={"order_position"},
),
]
def _generate_class(self):
output_class = super()._generate_class()
output_class.card_template_override(
[
[
"object.textModulesData['second_row-0']"
"object.textModulesData['second_row-1']"
"object.textModulesData['second_row-2']"
],
[
"object.textModulesData['third_row-0']",
"object.textModulesData['third_row-1']",
"object.textModulesData['third_row-2']",
],
]
)
output_class.details_item_infos(
[
f"object.textModulesData['details-{i}']"
for i in range(
len(
self.layout["fieldgroups"].get("details", {}).get("entries", [])
)
)
]
)
return output_class
def _generate_object(self, op: OrderPosition, class_id: str):
output_object = super()._generate_object(op, class_id)
fields = self.get_pass_fields(op)
for row in ["second_row", "third_row", "details"]:
for i, field in enumerate(fields.get(row, [])):
output_object.add_text_module(
f"{row}-{i}",
field["label"],
field["value"],
)
return output_object
@@ -0,0 +1,242 @@
import tempfile
from typing import Any, Callable, Literal, Protocol, runtime_checkable
from django.conf import settings
from django.contrib.staticfiles import finders
from django.core.files import File
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models.fields.files import FieldFile
from django.forms import ValidationError
from django.templatetags.static import static
from django.utils.translation import gettext as _
SettingType = Literal["image", "text", "float", "color", "bool"]
JsonSerializable = int | bool | float | str | None
class SettingsField[T, I = T]:
identifier: str
label: str
type: SettingType
help_text: str | None
required: bool
default: Any | None
def asdict(self, locales: list[str]):
return {
"identifier": self.identifier,
"label": self.label,
"type": self.type,
"help_text": self.help_text,
"required": self.required,
"default": self.get_default(),
}
def layout_schema(self) -> dict[str, Any] | None:
raise NotImplementedError()
def get_default(self) -> JsonSerializable:
if isinstance(self.default, JsonSerializable):
return self.default
elif self.default:
raise NotImplementedError()
def clean(self, value: I | None) -> T | None:
if value:
return value
if self.default:
return self.default
class TextSettingsField(SettingsField[str]):
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
required: bool = False,
default: str | None = None,
):
self.identifier = identifier
self.label = label
self.type = "text"
self.help_text = help_text
self.required = required
self.default = default
def layout_schema(self) -> dict[str, Any] | None:
schema = {"type": "string"}
if not self.required:
schema = {"oneOf": [schema, {"type": "null"}]}
return schema
class ColorSettingsField(SettingsField[str]):
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
required: bool = False,
default: str | None = None,
):
self.identifier = identifier
self.label = label
self.type = "color"
self.help_text = help_text
self.required = required
self.default = default
def layout_schema(self) -> dict[str, Any] | None:
# TODO: validate that '#' + 6 hex chars
schema = {"type": "string"}
if not self.required:
schema = {"oneOf": [schema, {"type": "null"}]}
return schema
class FloatSettingsField(SettingsField[float]):
min: float | None
max: float | None
def __init__(
self,
identifier: str,
label: str,
help_text=None,
required: bool = False,
min: float | None = None,
max: float | None = None,
default: float | None = None,
):
self.type = "float"
self.identifier = identifier
self.label = label
self.help_text = help_text
self.required = required
self.min = min
self.max = max
self.default = default
def layout_schema(self):
schema: dict[str, Any] = {"type": "number"}
if self.min is not None:
schema["minimum"] = self.min
if self.max is not None:
schema["maximum"] = self.max
if not self.required:
schema = {"oneOf": [schema, {"type": "null"}]}
return schema
def asdict(self, locales: list[str]):
return super().asdict(locales) | {"min": self.min, "max": self.max}
class BooleanSettingsField(SettingsField[bool]):
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
default: bool = False,
):
self.type = "bool"
self.identifier = identifier
self.label = label
self.help_text = help_text
self.required = False
self.default = default
def layout_schema(self):
return {"type": "boolean"}
@runtime_checkable
class UrlAndReadFile(Protocol):
url: str
open: Callable
seek: Callable
read: Callable
class SettingsFile:
url: str
file: UrlAndReadFile
def __init__(self, url, file):
self.url = url
self.file = file
class ImageSettingsField(SettingsField[SettingsFile, FieldFile]):
default: str | UrlAndReadFile | None = None
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
default: str | UrlAndReadFile | None = None,
):
self.identifier = identifier
self.label = label
self.type = "image"
self.help_text = help_text
self.required = False
if isinstance(default, UrlAndReadFile):
self.default = default
elif isinstance(default, str):
if not finders.find(default):
raise ValueError(f"Unknown file: {default}")
self.default = default
elif default:
raise ValueError("Unsupported `default`")
def get_default(self):
if isinstance(self.default, str):
return static(self.default)
elif isinstance(self.default, File):
return self.default.url
def layout_schema(self):
return
def clean(self, value):
if value:
return SettingsFile(value.url, value)
if isinstance(self.default, UrlAndReadFile):
return SettingsFile(self.default.url, self.default)
if isinstance(self.default, str):
return SettingsFile(
static(self.default), File(None, finders.find(self.default))
)
def convert_to_png(file: SettingsFile, max_size=None):
# TODO: move validation to upload
try:
from PIL import Image, ImageOps
except ImportError:
return file
file.file.open("rb")
file.file.seek(0)
try:
with (
Image.open(file.file, formats=settings.PILLOW_FORMATS_IMAGE) as im,
tempfile.NamedTemporaryFile("rb", suffix=".png") as tmpfile,
):
im = ImageOps.exif_transpose(im)
if max_size:
im.thumbnail(max_size, resample=Image.Resampling.BICUBIC)
im.save(tmpfile.name)
tmpfile.seek(0)
return SimpleUploadedFile("picture.png", tmpfile.read(), "image/png")
except IOError:
raise ValidationError(
_("The file you uploaded could not be converted to PNG format.")
)
+171
View File
@@ -0,0 +1,171 @@
#
# 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 django.dispatch import receiver
from pretix.base.models.event import Event
from pretix.base.signals import (
register_global_settings,
register_ticket_outputs,
invalidate_ticket_cache,
periodic_task,
)
from pretix.control.signals import item_forms
from pretix.plugins.wallet.platforms.google.platform import get_object_id
from .forms import WalletLayoutItemForm
from .models import WalletLayout, WalletLayoutItem
from .ticketoutput import WalletSettingsHolder
from .platforms import wallet_platform_registry
from .tasks import disable_wallet_instance, send_update_apns
from .models import AppleWalletPass, CombinedAppleWalletPass
from .tasks import get_pass
from .models import GoogleWalletInstance
from django.utils.translation import gettext as _
from pretix.base.logentrytypes import log_entry_types, EventLogEntryType
@receiver(register_ticket_outputs, dispatch_uid="output_wallet")
def register_output(sender, **kwargs):
return WalletSettingsHolder
for platform in wallet_platform_registry.all():
if hasattr(platform, "get_global_settings"):
register_global_settings.connect(
platform.get_global_settings,
dispatch_uid=f"wallet_global_settings_{platform.identifier}",
)
@receiver(item_forms, dispatch_uid="pretix_wallet_item_forms")
def control_item_forms(sender, request, item, **kwargs):
forms = []
try:
inst = WalletLayoutItem.objects.get(item=item)
except WalletLayoutItem.DoesNotExist:
inst = WalletLayoutItem(item=item)
forms.append(
WalletLayoutItemForm(
instance=inst,
event=sender,
data=(request.POST if request.method == "POST" else None),
prefix="wallet",
)
)
return forms
@receiver(periodic_task, dispatch_uid="wallet_periodic")
def periodic_tasks(sender, **kwargs):
# TODO: move to platform?
send_update_apns.apply_async()
# TODO: move to platform?
@receiver(invalidate_ticket_cache, dispatch_uid="invalidate_ticket_cache_wallet_apple")
def invalidate_apple(
sender: Event, item: int | None, provider: str | None, order: int | None, **kwargs
):
if provider and provider != "wallet":
return
pass_qs = AppleWalletPass.objects.filter(order_position__order__event=sender)
passes_qs = CombinedAppleWalletPass.objects.filter(order__event=sender)
if item:
pass_qs = pass_qs.filter(order_position__item_id=item)
passes_qs = passes_qs.filter(order__all_positions__item_id=item)
if order:
pass_qs = pass_qs.filter(order_position__order_id=order)
passes_qs = passes_qs.filter(order_id=order)
for pass_obj in pass_qs:
get_pass.apply_async(
kwargs={
"model": "orderposition",
"pk": pass_obj.order_position_id,
"platform_identifier": "apple",
"show_disabled": False,
}
)
for passes_obj in passes_qs:
passes_obj.delete()
@receiver(invalidate_ticket_cache, dispatch_uid="invalidate_ticket_cache_wallet_google")
def invalidate_google(
sender: Event, item: int | None, provider: str | None, order: int | None, **kwargs
):
if provider and provider != "wallet":
return
inst_qs = GoogleWalletInstance.objects.filter(event=sender).exclude(
order_position__isnull=True
)
if item:
inst_qs = inst_qs.filter(order_position__item_id=item)
if order:
inst_qs = inst_qs.filter(order_position__order_id=order)
for inst in inst_qs:
if get_object_id(inst.order_position) != inst.identifier:
# the OPs web_secret was changed
disable_wallet_instance.apply_async(kwargs={"pk": inst.pk})
get_pass.apply_async(
kwargs={
"model": "orderposition",
"pk": inst.order_position_id,
"platform_identifier": "google",
"show_disabled": False,
}
)
@log_entry_types.new_from_dict(
{
"pretix.plugins.wallet.layout.created": _(
"The wallet layout has been created."
),
"pretix.plugins.wallet.layout.changed": _(
"The wallet layout has been changed."
),
"pretix.plugins.wallet.layout.deleted": _(
"The wallet layout has been deleted."
),
}
)
class WalletLogEntryType(EventLogEntryType):
object_link_wrapper = _("Wallet Layout {val}")
object_link_viewname = "plugins:wallet:edit"
content_type = WalletLayout
def object_link_args(self, content_object):
return {"layout": content_object.pk}
def object_link_display_name(self, content_object):
return content_object.name
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
#
# 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 pretix.base.signals import EventPluginSignal
register_wallet_placeholders = EventPluginSignal()
"""
This signal is sent out to get all known wallet placeholders. Receivers should return
an list of subclasses of pretix.plugins.wallet.placeholders.BaseWalletPlaceholder.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { computed, inject, ref, watchEffect } from "vue";
import StyleSettings from "./style-settings.vue";
import Select from "./input/select.vue";
import Input from "./input/input.vue";
import { StoreKey } from "../walletStore";
import GoogleEventDefault from "./platform-preview/google-event-default.vue";
import GoogleEventCustom from "./platform-preview/google-event-custom.vue";
import Apple from "./platform-preview/apple.vue";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
function openForm(url: string, data: Record<string, string | object>) {
let form = document.createElement("form");
form.target = "_blank";
form.method = "POST";
form.action = url;
form.style.display = "none";
for (var key in data) {
var input = document.createElement("input");
input.type = "hidden";
input.name = key;
let value = data[key];
if (value instanceof Object) {
value = JSON.stringify(data[key]);
}
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
async function openPreview(e: Event) {
e.preventDefault();
openForm(store.previewUrl, {
csrfmiddlewaretoken: store.csrfToken,
id: store.layoutId,
...(await store.serializeCurrentPlatformLayout()),
});
}
const platformChoices = computed((): [string, string][] => {
return [
[null, "Do not generate pass"],
...Object.values(store.platform.styles).map((x): [string, string] => [
x.identifier,
x.name,
]),
];
});
const previewRenderers = {
google_eventticket_default: GoogleEventDefault,
google_eventticket_custom: GoogleEventCustom,
apple_eventticket: Apple,
};
const preview_data = computed(() => {
return {
data: store.style?.preview_data,
fieldgroups: store.fieldgroups,
fieldgroup_contents: store.renderedFieldGroups,
settings: store.settingsValues,
};
});
</script>
<template lang="pug">
// TODO: add :key for all `v-for`s
// TODO: proper spinner
template(v-if="!store.loaded") {{ gettext("Loading...") }}
form(v-else @submit.prevent="store.save")
.form-group
Input(label="Name" v-model="store.name" required="true")
nav
ul.nav.nav-tabs
li(v-for="platform in store.platforms" :class="{'active': store.platform.identifier === platform.identifier}")
a(role="tab" @click="store.setPlatform(platform.identifier)") {{ platform.name }}
.tabbed-form.tab-content
.tab-pane.active.row
.col-md-6.col-lg-8
Select.form-group(label="Style" :modelValue="store.style?.identifier || null" @update:modelValue="store.setStyle" :choices="platformChoices")
button(type="button" @click="store.setStyle(store.style.identifier)") Reset style
StyleSettings(v-if="!!store.style")
.col-md-6.col-lg-4(style="position: sticky; top: 10px;")
.panel.panel-default
.panel-heading Preview
.panel-body
div(v-if="!!store.style?.preview_renderer && store.style?.preview_renderer in previewRenderers")
p.text-muted {{ gettext("The preview below is only a rough representation of what the pass might look like. Please check the generated pass.") }}
component(:is="previewRenderers[store.style?.preview_renderer]" v-bind="preview_data")
div(v-else) Preview not supported
.form-group.submit-group
button.btn.btn-lg.btn-default(type="button" @click="openPreview") Preview
button.btn.btn-primary.btn-save(type="submit") Submit
</template>
<style lang="css">
.walletsettings-panel .panel-heading {
.checkbox {
padding: 0;
margin: 0;
display: inline-block;
input[type="checkbox"] {
margin-top: 0;
margin-right: 5px;
margin-left: 0px;
position: relative;
top: 1px;
}
label {
padding-left: 0;
}
}
}
.walletsettings-panel .panel-body {
> :is(h1, h2, h3, h4, h5, h6):first-child {
margin-top: 0;
}
}
.pass-container {
min-width: 0;
width: 100%;
min-height: 0;
border: solid 1px #949494;
padding: 1em;
overflow: hidden;
position: relative;
background-image: var(--background-image);
background-color: var(--background-color);
color: var(--bg-color);
background-size: cover;
background-position: center;
}
.blur-bg::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(10px);
pointer-events: none;
}
.f-row {
display: flex;
flex-direction: row;
gap: inherit;
> div {
min-width: 0;
}
}
.f-column {
display: flex;
flex-direction: column;
gap: inherit;
}
.pass-inner {
position: relative;
gap: 1em;
display: flex;
flex-direction: column;
}
.qrcode-inner {
font-weight: bold;
background-color: lightgray;
color: black;
width: 50%;
aspect-ratio: 1;
align-self: center;
/* center content */
display: flex;
align-items: center;
text-align: center;
justify-content: center;
padding: 1em;
overflow-wrap: anywhere;
}
</style>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useId } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string
}>()
const modelValue = defineModel<boolean|null>();
const id = useId()
</script>
<template lang="pug">
.checkbox
label
input(:id="id" v-model="modelValue" v-bind="$attrs" type="checkbox")
| {{ props.label }}
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref, useId, watchEffect } from "vue";
const gettext = (window as any).gettext;
const selectedFile = defineModel<{"name": string; "url": string;}>()
const props = defineProps<{
label?: LocalizableString;
errors?: string[];
help_text?: string;
default?: string;
}>();
const id = useId();
function onChange(e) {
selectedFile.value = (e.target as HTMLInputElement).files[0] || null
}
const inputRef = ref<HTMLInputElement>();
function clearFile() {
selectedFile.value = null;
}
function onDrop(e) {
selectedFile.value = e.dataTransfer.files[0] || null
}
</script>
<template lang="pug">
.form-group.row
label.control-label.col-md-3(:for="id", v-if="!!label") {{ label }}
br(v-if="!$attrs.required")
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
div.col-md-9(@drop.stop.prevent="onDrop" @dragover.stop.prevent @dragenter.stop.prevent)
div(v-if="!!selectedFile")
| {{ gettext("Currently") + ': ' }}
a(:href="selectedFile.url" data-lightbox="input") {{ selectedFile.name }}
.btn-group
button.btn.btn-default(type="button" @click="inputRef.click()") Select file
button.btn.btn-danger(v-if="!!selectedFile" type="button" @click="clearFile")
i.fa.fa-trash
span.sr-only {{ gettext("Clear") }}
input.hidden(:id="id" @change="onChange" v-bind="$attrs" :required="!!$attrs.required" type="file" ref="inputRef")
.help-block(v-if="!!help_text || !!props.default")
template(v-if="!!help_text") {{ help_text }}
br(v-if="!!help_text && !!props.default")
template(v-if="!!props.default")
a(:href="props.default" data-lightbox="input") {{ gettext("Show default") }}
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
<style lang="css" scoped>
.thumb-img {
max-height: 100px;
max-width: 200px;
object-fit: contain;
}
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { inject, watchEffect } from 'vue'
import { StoreKey } from "../../walletStore";
const store = inject(StoreKey)!
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
errors?: string[],
placeholders: Record<string, string>
}>();
const modelValue = defineModel<Record<string, string> | string>();
watchEffect(() => {
if (typeof modelValue.value === "string") {
const oldVal = modelValue.value;
modelValue.value = Object.fromEntries(Object.keys(store.locales).map((x): [string, string] => [x, oldVal]))
}
if (modelValue.value === null) {
modelValue.value = {};
}
})
</script>
<template lang="pug">
input.form-control(v-if="!!modelValue" v-for="(human_readable, locale) in store.locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="placeholders?.[locale] || human_readable")
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { useId, computed, onMounted, useTemplateRef, onUnmounted, onUpdated, onBeforeUpdate } from "vue";
const gettext = (window as any).gettext;
defineOptions({
inheritAttrs: false,
});
const {
label,
errors,
type = "text",
min=null,
max=null,
defaultVal=null
} = defineProps<{
label?: string;
errors?: string[];
type?: string;
help_text?: string;
inline?: boolean;
min?: number;
max?: number;
defaultVal?: string;
}>();
const modelValue = defineModel<string | null>();
const id = useId();
const inputRef = useTemplateRef("input");
const attrs = computed(() => {
if (type === "float") {
return { class: "appear-text", type: "number", step: "any", min, max };
} else {
return { type: "text" };
}
});
onMounted(() => {
if (type == "color") {
const jq_elem = $(inputRef.value)
jq_elem.colorpicker({
format: "hex",
align: "left",
customClass: "colorpicker-2x",
sliders: {
saturation: {
maxLeft: 200,
maxTop: 200,
},
hue: {
maxTop: 200,
},
alpha: {
maxTop: 200,
},
},
})
jq_elem.on('changeColor', (e) => e.value && (modelValue.value = e.value))
jq_elem.on('showPicker', () => {jq_elem.colorpicker('setValue', modelValue.value || defaultVal)});
}
});
onUnmounted(() => {
if (type == "color") {
$(inputRef.value).colorpicker("destroy");
}
});
</script>
<template lang="pug">
.form-group(:class="{'row':inline}")
label.control-label(:for="id" v-if="label" :class="{'col-md-3': inline}") {{ label }}
br(v-if="!$attrs.required")
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
div(:class="{'col-md-9': inline}")
input.form-control(:id="id" ref="input" v-model="modelValue" v-bind="{...$attrs, ...attrs}" :placeholder="defaultVal")
.help-block(v-if="!!help_text") {{ help_text }}
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
<style lang="css" scoped>
.appear-text {
appearance: textfield;
}
</style>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useId, watchEffect } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string
choices: Array<[string, string]>
errors?: string[],
class?: string,
help_text?: string
}>()
const modelValue = defineModel<string|null>();
const id = useId()
watchEffect(() => {
if (props.choices.length === 1) {
modelValue.value = props.choices[0][0]
} else if (props.choices.length < 1) {
modelValue.value = null
}
})
</script>
<template lang="pug">
template(v-if="choices.length >= 1" :class="props.class")
label.control-label(v-if="props.label" :for="id") {{ props.label }}
select.form-control(:id="id" v-model="modelValue" v-bind="$attrs" required)
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
.help-block(v-if="props.help_text") {{ help_text }}
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed, inject, watchEffect } from "vue";
import Select from "./input/select.vue";
import I18nInput from "./input/i18ninput.vue";
import TextContent from "./text-content.vue";
import { StoreKey } from "../walletStore";
import { i18nstring_localize } from "/src/pretix/static/pretixbase/js/i18nstring.ts";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
const ngettext = (window as any).ngettext;
const interpolate = (window as any).interpolate;
const props = defineProps<{
fieldgroup: PlaceholderFieldGroupDefinition;
}>();
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({
required: true,
});
const overflowOptions = computed((): Array<[string | null, string]> => {
if (props.fieldgroup.overflows.length > 0) {
return [
...props.fieldgroup.overflows.map((x): [string, string] => [x, store.fieldgroups[x]?.name]),
[null, "Do not overflow"],
];
} else {
return [];
}
});
function addEntry() {
fieldConfig.value.entries.push({ type: "placeholder", label: "", content: null });
}
watchEffect(() => {
if (!fieldConfig.value) {
fieldConfig.value = {
overflow: props.fieldgroup.default_overflow,
entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries)),
};
}
if (fieldConfig.value && !fieldConfig.value.entries) {
fieldConfig.value.entries = JSON.parse(
JSON.stringify(props.fieldgroup.default_entries),
);
}
});
const placeholderChoices = computed(() => {
const availableContext = new Set(props.fieldgroup.context_args);
const choices = Object.entries(store.placeholders.text)
.filter(([_, { required_context }]) =>
new Set(required_context).isSubsetOf(availableContext),
)
.map(([k, v]): [string, string] => [k, i18nstring_localize(v.label)]);
choices.push(["other", gettext("Other…")]);
return choices;
});
const overflowText = computed(() => {
if (!props.fieldgroup.max_entries) {
return ""
}
const formats = ngettext(
'Maximum one field in this fieldgroup can be shown on the pass. The remaining fields will be shown in the selected fieldgroup.',
'Maximum %s fields in this fieldgroup can be shown on the pass. The remaining fields will be shown in the selected fieldgroup.',
props.fieldgroup.max_entries
);
return interpolate(formats, [props.fieldgroup.max_entries])
})
const placeholders = computed(() => {
return fieldConfig.value.entries.map((entry) => {
const placeholder =
entry.type === "placeholder"
? store.placeholders[props.fieldgroup.content_type][entry.content]
: null;
const placeholders = {};
let base;
if (i18nstring_localize(entry.label)) {
base = entry.label
} else if (placeholder) {
base = placeholder.label
} else {
return null
}
for (const locale of Object.keys(store.locales)) {
placeholders[locale] = i18nstring_localize(base, locale)
}
return placeholders
})
});
</script>
<template lang="pug">
.panel.panel-default.walletsettings-panel
.panel-heading
h3.panel-title {{ fieldgroup.name }}
.panel-body(v-if="fieldConfig")
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
h4 {{ gettext("Content") }}
.form-group
table.table.table-hover
thead
tr
th.col-5(v-if="fieldgroup.display == 'with_label'") {{ gettext('Label') }}
th(:class="'col-' + (fieldgroup.display == 'with_label' ? '6' : '11')") {{ gettext('Content') }}
th.col-xs-1
tbody
tr(v-for="n, i in fieldConfig.entries.length" :key="i")
td(v-if="fieldgroup.display == 'with_label'")
.i18n-form-group
I18nInput(v-model="fieldConfig.entries[n - 1].label" :placeholders="placeholders[n - 1]")
td
TextContent(v-if='fieldgroup.content_type == "text"'
v-model="fieldConfig.entries[n - 1]"
:placeholderChoices="placeholderChoices")
Select(v-else-if='fieldgroup.content_type == "image"'
v-model="fieldConfig.entries[n - 1].content"
:choices="Object.entries(store.placeholders.image).map(([k, v]) => [k, i18nstring_localize(v.label)])"
)
td.text-right
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n - 1, 1)" v-if="fieldConfig.entries.length > (fieldgroup.min_entries||0)")
i.fa.fa-trash
span.sr-only {{ gettext('Delete') }}
.text-left.text-muted(v-else) {{ gettext("At least one field in this group is required") }}
button.btn.btn-default(type="button" @click="addEntry")
i.fa.fa-plus
| {{ gettext("Add field") }}
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow" :help_text="overflowText")
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
const store = inject(StoreKey)!;
const props = defineProps<{
fieldgroup: string;
}>();
function focusGroup() {
// TODO: highlight group? or change ui concept completely
const elem = document.getElementById('fieldgroup-' + props.fieldgroup)
elem && elem.scrollIntoView()
}
</script>
<template lang="pug">
div(@click="focusGroup")
div(v-for="entry in store.renderedFieldGroups[fieldgroup]")
div.pass-label {{ entry.label }}
div.pass-content {{ entry.content }}
</template>
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { StoreKey } from "../../walletStore";
import FieldGroupEntries from "./FieldGroupEntries.vue";
const store = inject(StoreKey)!;
const props = defineProps<{ data: any }>();
const theme = computed(() => {
const theme = {
bg_color: store.getSettingValue("bg_color"),
fg_color: store.getSettingValue("fg_color"),
label_color: store.getSettingValue("label_color"),
bg_image: null,
};
const bg_image = store.getSettingValue("background");
if (bg_image?.url) {
theme.bg_image = `url(${encodeURI(store.getSettingValue("background").url)})`;
theme.bg_color = "#000";
theme.fg_color = '#fff'
}
return theme;
});
</script>
<template lang="pug">
div.apple-pass-preview
.pass-container.blur-bg.apple-pass-front
.pass-inner.f-column
.f-row.head-row
img.logo(:src="store.getSettingValue('logo')?.url")
div.logo-text {{ store.renderedFieldGroups['logo_text']?.[0]?.content }}
FieldGroupEntries.header.f-row(fieldgroup="header")
.f-column
FieldGroupEntries.primary(fieldgroup="primary")
FieldGroupEntries.f-row(fieldgroup="secondary")
FieldGroupEntries.f-row(fieldgroup="auxiliary")
.qrcode-inner(v-for="qrcode in store.renderedFieldGroups['code']") {{ qrcode.label }}
.pass-container.apple-pass-back
FieldGroupEntries.pass-inner.f-column(fieldgroup="back")
</template>
<style lang="css">
.apple-pass-preview {
display: grid;
gap: 1em;
grid-template-columns: repeat(auto-fit, minmax(auto, 360px));
align-items: start;
.apple-pass-front {
background-image: v-bind("theme.bg_image");
background-color: v-bind("theme.bg_color");
color: v-bind("theme.fg_color");
background-size: cover;
background-position: center;
padding-top: 0.25em;
}
.pass-content {
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
}
.head-row {
align-items: center;
gap: 0.5rem;
}
.logo {
max-height: 3lh;
object-fit: contain;
flex-grow: 0;
}
.logo-text {
font-size: 1.5em;
font-weight: bold;
min-height: 2lh;
flex: 1 1;
align-content: center;
text-wrap: nowrap;
}
.header {
flex: 1 0 fit-content;
text-align: end;
line-height: 1.25;
gap: 0.25rem;
justify-content: end;
.pass-content {
font-size: 1.25em;
}
}
.primary {
.pass-content {
font-size: 1.5em;
}
}
.hero {
align-self: center;
max-width: 100%;
}
.apple-pass-front .pass-label {
font-weight: bold;
font-size: 0.8em;
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
color: v-bind("theme.label_color");
}
.apple-pass-back .pass-label {
font-weight: bold;
}
}
</style>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import Google from "./google.vue";
import FieldGroupEntries from "./FieldGroupEntries.vue";
const props = defineProps();
</script>
<template lang="pug">
Google(v-bind="props")
template(#card-rows)
FieldGroupEntries.card-row(fieldgroup="second_row")
FieldGroupEntries.card-row(fieldgroup="third_row")
template(#details)
FieldGroupEntries.f-column(fieldgroup="details")
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { inject } from "vue";
import { StoreKey } from "../../walletStore";
import { dateTimeFormat } from "../../helpers";
import Google from "./google.vue";
import { i18nstring_localize } from "/src/pretix/static/pretixbase/js/i18nstring.ts";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
const props = defineProps<{ data: any }>();
function isActive(fieldgroup: string) {
return store.layout.fieldgroups[fieldgroup]?.active;
}
</script>
<template lang="pug">
Google(:data="data")
template(#card-rows)
div.card-row(v-if="isActive('date')")
div
div.pass-label {{ gettext("Date") }}
div.pass-content {{ dateTimeFormat(data.date_from, {"dateStyle": "medium"}) }}
div
div.pass-label {{ gettext("Time") }}
div.pass-content {{ dateTimeFormat(data.date_from, {"timeStyle": "short"}) }}
div.card-row(v-if="isActive('seating')")
div
div.pass-label {{ gettext("Section") }}
div.pass-content {{ gettext("Ground floor") }}
div
div.pass-label {{ gettext("Row") }} / {{ gettext("Seat") }}
div.pass-content 5 / 2
template(#details)
div.pass-details-row(v-if="isActive('ticket_holder')")
div.pass-label {{ gettext("Ticket Holder") }}
div.pass-content {{ gettext("John Doe") }}
div.pass-details-row(v-if="isActive('venue')")
div.pass-label {{ i18nstring_localize(data.venue?.[0]) }}
div.pass-content {{ i18nstring_localize(data.venue?.[1]) }}
div.pass-details-row(v-if="isActive('date')")
div.pass-label {{ gettext("Event Start Time") }}
div.pass-content {{ i18nstring_localize(data.venue?.[1]) }}
div.pass-details-row
div.pass-label {{ gettext("Ticket Type") }}
div.pass-content {{ gettext("Sample product") }}
div.pass-details-row
div.pass-label {{ gettext("Ticket Number") }}
div.pass-content ABCDE-1
div.pass-details-row
div.pass-label {{ gettext("Order Number") }}
div.pass-content ABCDE
a.pass-details-link.f-row(:href="data.event_page")
div
i.fa.fa-globe
div.pass-label
| {{ " " + gettext("Website") }}
</template>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed } from "vue";
import { dateTimeFormat } from "../../helpers";
import { i18nstring_localize } from "/src/pretix/static/pretixbase/js/i18nstring.ts";
const props = defineProps<{
data: any;
fieldgroups: Record<string, FieldGroupConfig>;
fieldgroup_contents: Record<
string,
{ label?: LocalizableString; content: LocalizableString }
>;
settings: Record<string, SettingValue>;
}>();
function isActive(fieldgroup: string) {
return props.fieldgroups[fieldgroup]?.active;
}
const qrcode = computed(() => props.fieldgroup_contents["code"]?.[0]);
</script>
<template lang="pug">
div.pass-preview-google(style="")
div.f-column
.pass-container.f-row
img.logo(:src="props.settings['logo']?.url")
div
.bold {{ data.event }}
div(v-if="isActive('date')") {{ dateTimeFormat(data.date_from, {"dateStyle": "medium"}) }}
div(v-else) {{ data.organizer }}
.pass-container.google-main(:data-background-color="props.settings['bg_color']")
.pass-inner
.f-row
img.logo(:src="props.settings['logo']?.url")
div.logo-text {{ data.organizer }}
.top-row
div(v-if="isActive('venue')") {{ i18nstring_localize(data.venue?.[0]) }}
.event-name {{ data.event }}
.f-column
slot(name="card-rows")
.qrcode-inner {{ qrcode.label }}
.f-column
img.hero(:src="props.settings['hero']?.url")
.pass-container.google-pass-details
.pass-inner
slot(name="details")
</template>
<style lang="css">
.pass-preview-google {
display: grid;
gap: 1em;
grid-template-columns: repeat(auto-fit, minmax(auto, 360px));
align-items: start;
.pass-container {
border-radius: 1em;
}
.google-main {
--background-color: attr(data-background-color type(<color>));
color: contrast-color(var(--background-color));
}
.logo {
height: 2lh;
aspect-ratio: 1;
border-radius: 50%;
background-color: white;
object-fit: fill;
}
.logo-text {
align-self: center;
font-size: 1.5em;
}
.top-row > .event-name {
font-size: 1.75em;
}
.card-row {
display: flex;
flex-direction: row;
gap: inherit;
> div {
flex: 0 1 100%;
}
&:has(> :first-child:nth-last-child(2)) {
/* twoItems */
> :nth-child(2) {
text-align: end;
}
}
&:has(> :first-child:nth-last-child(3)) {
/* threeItems */
> :nth-child(2) {
text-align: center;
}
> :nth-child(3) {
text-align: end;
}
}
}
.card-row {
.pass-label {
font-size: 0.9em;
}
}
.hero {
align-self: center;
max-width: 100%;
}
.google-pass-details {
.pass-label {
font-weight: bold;
}
.pass-content {
font-size: 0.9em;
white-space: pre-wrap;
}
}
.pass-details-link {
color: black;
}
}
</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { watchEffect } from 'vue';
import Checkbox from './input/checkbox.vue';
const gettext = (window as any).gettext;
const props = defineProps<{
fieldgroup: FieldGroupDefinition;
}>();
const fieldConfig = defineModel<PredefinedFieldGroupConfig>({ required: true });
watchEffect(() => {
if (!fieldConfig.value) {
fieldConfig.value = {active: props.fieldgroup.required};
}
});
</script>
<template lang="pug">
.panel.panel-default.walletsettings-panel
.panel-heading
h3.panel-title.form-inline
.form-group
Checkbox(v-if="!!fieldConfig" :label="fieldgroup.name" v-model="fieldConfig.active")
.panel-body(v-if="!!fieldgroup.description")
.form-group
span.text-muted {{ fieldgroup.description }}
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import Input from "./input/input.vue";
import FileInput from "./input/file-input.vue";
import { inject } from "vue";
import { StoreKey } from "../walletStore";
import Checkbox from "./input/checkbox.vue";
const props = defineProps<{
field?: Setting;
}>();
const store = inject(StoreKey)!;
</script>
<template lang="pug">
div.form-group
Input(v-if='field.type == "text" || field.type == "float" || field.type == "color"'
:required="field.required" @update:modelValue="(v) => store.setSetting(field.identifier, v)"
:modelValue="store.settings[field.identifier]" :inline="true"
:label="field.label" :type="field.type" :help_text="field.help_text"
:min="field.min" :max="field.max" :defaultVal="field.default")
FileInput(v-if='field.type == "image"' :label="field.label" :required="field.required"
:help_text="field.help_text" @update:modelValue="(v) => store.setSetting(field.identifier, v)"
:modelValue="store.settings[field.identifier]" :default="field.default")
.form-group.row(v-if='field.type == "bool"')
div.col-md-3
div.col-md-9
Checkbox(:required="field.required" @update:modelValue="(v) => store.setSetting(field.identifier, v)"
:modelValue="store.settings[field.identifier]" :label="field.label" :help_text="field.help_text")
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { inject } from "vue";
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
import PredefinedFieldSettings from "./predefined-field-settings.vue";
import SettingsField from "./settings-field.vue";
import { StoreKey } from "../walletStore.js";
const gettext = (window as any).gettext;
const store = inject(StoreKey)!;
</script>
<template lang="pug">
h2.h3 {{ gettext("Settings") }}
SettingsField(v-for="field of store.style.settings" :field="field" :key="field.identifier")
h2.h3 {{ gettext("Field Groups") }}
div(v-for="(fieldgroup, fieldgroupId) in store.style.fieldgroups" :id="'fieldgroup-' + fieldgroup.identifier")
PlaceholderFieldSettings(
v-if="fieldgroup.type == 'placeholder'"
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup"
)
PredefinedFieldSettings(v-else-if="fieldgroup.type == 'predefined'"
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup")
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, inject, reactive } from 'vue'
import Select from './input/select.vue'
import I18nInput from './input/i18ninput.vue'
import { StoreKey } from "../walletStore";
const store = inject(StoreKey)!
const gettext = (window as any).gettext
const props = defineProps<{
placeholderChoices: [string|null, string][];
}>();
const entry = defineModel<FieldEntry>({ required: true })
const selection = computed({
get() {
if (entry.value.type === 'placeholder') {
return entry.value.content
} else if (entry.value.type === 'custom') {
return "other"
}
},
set(newValue) {
if (newValue == "other") {
entry.value.type = "custom"
entry.value.content = {};
} else {
entry.value.type = "placeholder"
entry.value.content = newValue
}
}
})
const textContent = computed({
get() {
if (entry.value.type === 'placeholder') {
return ""
} else if (entry.value.type === 'custom') {
return entry.value.content
}
},
set(newValue) {
entry.value.content = newValue
}
})
</script>
<template lang="pug">
.i18n-form-group
Select(
v-model="selection"
:choices="placeholderChoices"
)
I18nInput(v-model="textContent" v-if="selection === 'other'" :locales="store.locales")
</template>
@@ -0,0 +1,4 @@
export function dateTimeFormat(d: string, options?: Record<string, string>): string {
const locale = document.body.attributes['data-pretixlocale'].value
return new Intl.DateTimeFormat(locale, options).format(new Date(d))
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,111 @@
type Platform = {
identifier: string;
name: string;
styles: Styles;
};
type Style = {
identifier: string;
name: string;
fieldgroups: FieldGroupDefinition[];
settings: Setting[];
preview_renderer: string;
preview_data: Record<string, string>;
};
type Placeholder = {
label: string;
sample: string;
required_context: string[];
};
type Styles = Record<string, Style>;
type Placeholders = Record<string, Placeholder>;
type PlaceholderConfig = Record<string, Placeholders>;
type Platforms = Platform[];
type BaseFieldGroupDefinition = {
type: FieldGroupType;
identifier: string;
name: string;
description: string;
required: boolean;
};
type FieldGroupDefinition =
| PlaceholderFieldGroupDefinition
| PredefinedFieldGroupDefinition;
type FieldGroupDisplay = "plain" | "with_label";
type FieldGroupType = "placeholder" | "predefined";
type FieldContentType = "text" | "image";
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
type: "placeholder";
content_type: FieldContentType;
default_entries: FieldEntry[];
min_entries: number | null;
max_entries: number | null;
context_args: string[];
overflows: string[];
default_overflow: string | null;
};
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
type: "predefined";
default_active: boolean;
};
type LocalizableString = null | string | Record<string, string>;
type PlaceholderFieldEntry = {
type: "placeholder";
label: LocalizableString;
content?: string;
};
type CustomFieldEntry = {
type: "custom";
label?: LocalizableString;
content?: LocalizableString;
};
type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry;
type Setting = {
identifier: string;
label: string;
type: "text" | "image" | "color" | "float" | "bool";
required: boolean;
help_text: string;
default: string;
};
type SettingValue = string | number | boolean | WalletFile | { url: string };
type PlaceholderFieldGroupConfig = {
entries: Array<FieldEntry>;
overflow: string | null;
};
type PredefinedFieldGroupConfig = {
active: boolean;
};
type FieldGroupConfig =
| PlaceholderFieldGroupConfig
| PredefinedFieldGroupConfig;
type LayoutData = {
fieldgroups?: Record<string, FieldGroupConfig>;
settings?: Record<string, any>;
};
type PlatformLayout = {
style: string;
fieldgroups: Record<string, FieldGroupConfig>;
settings: {};
file_settings: Record<string, WalletFile>;
};
type ServerSideFile = { url: string; name: string };
type ClientSideFile = { file: File | null; identifier?: string; url: string };
type WalletFile = ServerSideFile | ClientSideFile;
@@ -0,0 +1 @@
../../../../../../../pretix/static/pretixpresale/widget/src/lib/store.ts
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,30 @@
import { createApp } from "vue";
import App from "./components/app.vue";
import { createWalletStore, StoreKey } from "./walletStore";
const mountEl = document.querySelector<HTMLElement>("#editor")!;
const store = createWalletStore({
platforms: JSON.parse(
document.querySelector("#platforms")?.textContent ?? "{}",
),
placeholders: JSON.parse(
document.querySelector("#placeholders")?.textContent ?? "{}",
),
locales: JSON.parse(document.querySelector("#locales")?.textContent ?? "{}"),
csrfToken: document.querySelector<HTMLInputElement>(
"input[name=csrfmiddlewaretoken]",
)?.value!!,
layoutId: mountEl.dataset.layoutId!,
baseUrl: mountEl.dataset.baseUrl!,
previewUrl: mountEl.dataset.previewUrl!,
});
store.load();
const app = createApp(App);
app.provide(StoreKey, store);
app.mount(mountEl);
app.config.errorHandler = (error, _vm, info) => {
// vue fatals on errors by default, which is a weird choice
// https://github.com/vuejs/core/issues/3525
// https://github.com/vuejs/router/discussions/2435
console.error("[VUE]", info, error);
};
@@ -0,0 +1,413 @@
import { i18nstring_localize } from "/src/pretix/static/pretixbase/js/i18nstring.ts";
import { createStore } from "./lib/store";
import { toRaw, type InjectionKey } from "vue";
export type WidgetStore = ReturnType<typeof createWalletStore>;
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
function getDefaultFieldgroupState(
fieldgroup: FieldGroupDefinition,
): FieldGroupConfig {
if (fieldgroup.type == "predefined") {
return { active: fieldgroup.required || fieldgroup.default_active };
} else if (fieldgroup.type == "placeholder") {
return {
overflow: fieldgroup.default_overflow,
entries: JSON.parse(JSON.stringify(fieldgroup.default_entries)),
};
}
}
function parseExistingFieldgroupState(
fieldgroup: FieldGroupDefinition,
existing?: FieldGroupConfig,
): FieldGroupConfig {
if (!existing) {
return getDefaultFieldgroupState(fieldgroup);
}
if (fieldgroup.type == "predefined") {
return {
active: "active" in existing ? existing.active : fieldgroup.required,
};
} else if (fieldgroup.type == "placeholder") {
return {
overflow: "overflow" in existing ? existing.overflow : null,
// TODO: check that placeholders are possible
entries: structuredClone(
toRaw(
"entries" in existing ? existing.entries : fieldgroup.default_entries,
),
),
};
}
}
export function createWalletStore(config: {
platforms: Platforms;
placeholders: PlaceholderConfig;
locales: Record<string, string>;
csrfToken: string;
layoutId: string;
baseUrl: string;
previewUrl: string;
}) {
return createStore({
state: () => ({
...config,
loaded: false,
activePlatform: config.platforms[0].identifier,
name: null as string | null,
platformLayouts: {} as Record<string, PlatformLayout>,
}),
getters: {
platform(): Platform {
return this.getPlatform(this.activePlatform);
},
layout() {
if (!this.loaded) {
return;
}
return this.platformLayouts[this.activePlatform] || null;
},
style(): Style {
if (this.layout) {
return this.platform.styles[this.layout.style];
} else {
return null;
}
},
styles() {
return this.platform.styles;
},
settings() {
const settings: Record<string, SettingValue> = {};
for (const setting of this.style.settings) {
if (
setting.type == "text" ||
setting.type == "color" ||
setting.type == "float" ||
setting.type == "bool"
) {
settings[setting.identifier] =
this.layout.settings[setting.identifier];
} else if (setting.type == "image") {
const val = this.layout.file_settings[setting.identifier];
if (val) {
settings[setting.identifier] = val;
} else {
settings[setting.identifier] = null;
}
}
}
return settings;
},
settingsValues(): Record<string, SettingValue> {
const settings: Record<string, SettingValue> = {};
for (const setting of this.style.settings) {
if (
setting.type == "text" ||
setting.type == "color" ||
setting.type == "float" ||
setting.type == "bool"
) {
settings[setting.identifier] =
this.layout.settings[setting.identifier] || setting.default;
} else if (setting.type == "image") {
const val = this.layout.file_settings[setting.identifier];
if (val && "url" in val && val.url) {
settings[setting.identifier] = val;
} else if (setting.default) {
settings[setting.identifier] = { url: setting.default };
}
}
}
return settings;
},
renderedFieldGroups() {
const content = {};
const placeholderFieldGroups = this.style.fieldgroups.filter(
(x) => x.type === "placeholder",
);
for (const fieldgroup of placeholderFieldGroups) {
content[fieldgroup.identifier] = [];
const layout_group = this.layout.fieldgroups[
fieldgroup.identifier
] as any as PlaceholderFieldGroupConfig;
for (const entry of layout_group.entries) {
let label = i18nstring_localize(entry.label);
let value = null;
if (entry.type === "placeholder") {
const placeholder =
this.placeholders[fieldgroup.content_type][entry.content];
if (!placeholder) {
continue;
}
if (!label) {
label = i18nstring_localize(placeholder.label);
}
value = placeholder?.sample;
} else if (entry.type == "custom") {
value = i18nstring_localize(entry.content);
}
content[fieldgroup.identifier].push({
label,
content: value,
});
}
}
for (const fieldgroup of placeholderFieldGroups) {
const layout_group: PlaceholderFieldGroupConfig =
this.layout.fieldgroups[fieldgroup.identifier];
if (
fieldgroup.max_entries &&
content[fieldgroup.identifier].length > fieldgroup.max_entries
) {
const overflow = content[fieldgroup.identifier].splice(
fieldgroup.max_entries,
);
if (layout_group.overflow) {
content[layout_group.overflow].unshift(...overflow);
}
}
}
return content;
},
fieldgroups(): Record<string, FieldGroupDefinition> {
return Object.fromEntries(
this.style.fieldgroups.map((x) => [x.identifier, x]),
);
},
},
actions: {
getPlatform(identifier: string): Platform {
for (const platform of this.platforms) {
if (platform.identifier === identifier) {
return platform;
}
}
},
load() {
// TODO: error handling / proper api client
fetch(`${this.baseUrl}${this.layoutId}/`)
.then((x) => x.json())
.then((x) => {
this.parseServerLayout(x);
this.loaded = true;
})
.catch(alert);
},
parseServerLayout(serverLayout) {
this.name = serverLayout.name;
for (const layout of serverLayout.platform_layouts) {
const clientLayout = {
style: layout.style,
fieldgroups: {},
settings: layout.layout.settings || {},
file_settings: layout.file_settings,
};
const styleDefinition = this.getPlatform(layout.platform).styles[
layout.style
];
for (const fieldgroup of styleDefinition.fieldgroups) {
clientLayout.fieldgroups[fieldgroup.identifier] =
parseExistingFieldgroupState(
fieldgroup,
layout.layout.fieldgroups[fieldgroup.identifier],
);
}
this.platformLayouts[layout.platform] = clientLayout;
}
},
setPlatform(platform: string) {
this.activePlatform = platform;
},
setStyle(style: string | null) {
if (style === null) {
delete this.platformLayouts[this.activePlatform];
} else if (Object.keys(this.platform.styles).includes(style)) {
const newLayout = {
style,
fieldgroups: {},
settings: {},
file_settings: {},
};
for (const fieldgroup of this.platform.styles[style].fieldgroups) {
newLayout.fieldgroups[fieldgroup.identifier] =
getDefaultFieldgroupState(fieldgroup);
}
this.platformLayouts[this.activePlatform] = newLayout;
// TODO: keep old fieldgroups & settings if matching
}
},
getSetting(identifier: string): Setting {
for (const setting of this.style.settings) {
if (setting.identifier === identifier) {
return setting;
}
}
},
getSettingValue(identifier: string): SettingValue {
const setting = this.getSetting(identifier);
if (!setting) return;
if (
setting.type == "text" ||
setting.type == "color" ||
setting.type == "float" ||
setting.type == "bool"
) {
return this.layout.settings[setting.identifier] || setting.default;
} else if (setting.type == "image") {
const val = this.layout.file_settings[setting.identifier];
if (val && "url" in val && val.url) {
return val;
} else if (setting.default) {
return { url: setting.default };
}
}
},
setSetting(identifier: string, value: string | File) {
const setting = this.getSetting(identifier);
if (!setting) return;
if (setting.type === "image") {
if (this.layout.file_settings[setting.identifier]?.url) {
URL.revokeObjectURL(
this.layout.file_settings[setting.identifier].url,
);
}
if (value instanceof File) {
this.layout.file_settings[setting.identifier] = {
file: value,
url: value === null ? null : URL.createObjectURL(value),
name: value.name,
};
} else if (value === null) {
this.layout.file_settings[setting.identifier] = {
file: null,
};
}
} else if (
(setting.type == "text" || setting.type == "color") &&
typeof value == "string"
) {
this.layout.settings[setting.identifier] = value;
} else if (setting.type == "float" && typeof value == "string") {
this.layout.settings[setting.identifier] = parseFloat(value);
} else if (setting.type == "float" && typeof value == "number") {
this.layout.settings[setting.identifier] = value;
} else if (setting.type == "bool" && typeof value == "boolean") {
this.layout.settings[setting.identifier] = value;
}
},
async uploadFile(file: ClientSideFile) {
return await fetch("/api/v1/upload", {
method: "POST",
body: file.file,
headers: {
"content-disposition": `attachment; filename="${encodeURI(file.file.name)}"`,
"content-type": file.file.type || "application/octet-stream",
"X-CSRFToken": this.csrfToken,
},
})
.then((x) => x.json())
.then((x) => {
file.identifier = x.id;
return x.id;
})
.catch(alert);
},
serializeFieldgroups(platform, layout: PlatformLayout) {
const styleDefinition = this.getPlatform(platform).styles[layout.style];
return Object.fromEntries(
Object.entries(layout.fieldgroups)
.map(([k, v]) => {
const fieldgroupDefinition = styleDefinition.fieldgroups.find(
(x) => x.identifier == k,
);
if (!fieldgroupDefinition) {
return;
}
if (fieldgroupDefinition.type == "placeholder") {
v = v as PlaceholderFieldGroupConfig;
v = {
...v,
entries: v.entries.filter(
(x) => x.type != "placeholder" || !!x.content,
),
};
}
return [k, v];
})
.filter((x) => !!x),
);
},
async serializePlatformLayout(platform, layout: PlatformLayout) {
const uploadedFiles = Object.fromEntries(
(
await Promise.all(
Object.entries(layout.file_settings).map(async ([k, v]) => {
if ("identifier" in v) {
return [k, v.identifier];
} else if ("file" in v && v.file instanceof File) {
return [k, await this.uploadFile(v)];
} else if ("file" in v && v.file == null) {
return [k, null];
}
}),
)
).filter((x) => !!x),
);
return {
platform,
style: layout.style,
file_settings: uploadedFiles,
layout: {
fieldgroups: this.serializeFieldgroups(platform, layout),
settings: layout.settings,
},
};
},
async serializeCurrentPlatformLayout() {
return this.serializePlatformLayout(this.activePlatform, this.layout);
},
async serializeLayout() {
const layoutPromises = Object.entries(this.platformLayouts).map(
([platform, layout]) =>
this.serializePlatformLayout(platform, layout),
);
return {
name: this.name,
platform_layouts: await Promise.all(layoutPromises),
};
},
async save() {
const serializedLayout = await this.serializeLayout();
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
{
method: "PUT",
headers: {
"content-type": "application/json",
"X-CSRFToken": this.csrfToken,
},
body: JSON.stringify(serializedLayout),
},
)
.then((x) => {
if (!x.ok) {
throw new Error(`Response status: ${x.status}`);
}
return x.json();
})
.then((x) => {
this.parseServerLayout(x);
})
.catch((x) => alert(x));
},
},
});
}
+104
View File
@@ -0,0 +1,104 @@
from pretix.base.models import Event, Order, OrderPosition
from pretix.base.services.tasks import ProfiledTask
from pretix.celery_app import app
import ssl
import httpx
import tempfile
from django.utils.timezone import now
from django.db.models import OuterRef, Exists
from pretix.plugins.wallet.platforms import get_platform
from pretix.plugins.wallet.platforms.google.google_wallet import Resource, State
from pretix.plugins.wallet.platforms.google.platform import GooglePlatform
from .models import AppleWalletPass, AppleWalletDevice, GoogleWalletInstance
from django_scopes import scopes_disabled
@app.task
@scopes_disabled()
def send_update_apns():
events_to_push = Event.objects.filter(
plugins__icontains="pretix.plugins.wallet"
).filter(
Exists(
AppleWalletDevice.objects.filter(
Exists(
AppleWalletPass.objects.filter(
registered_devices=OuterRef("pk"),
updated_at__gte=OuterRef("last_push"),
order_position__order__event=OuterRef(OuterRef("pk")),
)
)
)
)
)
for event in events_to_push:
devices_to_push = AppleWalletDevice.objects.filter(
Exists(
AppleWalletPass.objects.filter(
registered_devices=OuterRef("pk"),
updated_at__gte=OuterRef("last_push"),
order_position__order__event=event,
)
)
)
if not devices_to_push.exists():
return
ca_cert = event.settings.wallet_apple_certificate.read()
cert = event.settings.wallet_apple_certificate.read()
key = event.settings.wallet_apple_key.read()
ctx = ssl.create_default_context()
with tempfile.NamedTemporaryFile("w") as certfile:
certfile.write(key)
certfile.write("\n")
certfile.write(cert)
certfile.write("\n")
certfile.write(ca_cert)
certfile.flush()
ctx.load_cert_chain(
certfile=certfile.name,
password=event.settings.wallet_apple_key_password,
)
client = httpx.Client(verify=ctx, http1=False, http2=True)
for device in devices_to_push:
device.last_push = now()
client.post(
f"https://api.push.apple.com/3/device/{device.push_token}", json={}
)
device.save()
@app.task(base=ProfiledTask)
def get_pass(model: str, pk: int, platform_identifier: str, show_disabled: bool):
if model == "order":
order = Order.objects.select_related("event").get(id=pk)
platform = get_platform(order.event, platform_identifier, show_disabled)
if not platform:
return
return platform.generate_order(order)
elif model == "orderposition":
position = OrderPosition.objects.select_related("order", "order__event").get(
id=pk
)
platform = get_platform(position.order.event, platform_identifier, show_disabled)
if not platform:
return
return platform.generate(position)
@app.task()
def disable_wallet_instance(pk: int):
try:
obj = GoogleWalletInstance.objects.get(pk=pk)
except GoogleWalletInstance.DoesNotExist:
return
platform = GooglePlatform(obj.order_position.event)
platform.comms.patch_item(Resource(obj.resource), obj.identifier, {"state": State.INACTIVE})
obj.delete()
@@ -0,0 +1,35 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load bootstrap3 %}
{% load vite %}
{% load static %}
{% load compress %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<h1>{% trans "New layout" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Ticket design" %}
</label>
<div class="col-md-9 form-control-static">
<p>
{% blocktrans trimmed %}
You can modify the design after you saved this page.
{% endblocktrans %}
</p>
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Wallet layout" %}{% endblock %}
{% block content %}
<h1>{% trans "Wallet layout" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if object.allow_delete %}
<p>{% blocktrans with name=object.name %}Are you sure you want to delete <strong>"{{ name }}"</strong>?{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans %}You cannot delete the default layout.{% endblocktrans %}</p>
{% endif %}
<div class="form-group submit-group">
<a href="{% url "plugins:wallet:index" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
{% if object.allow_delete %}
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
{% endif %}
</div>
</form>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load bootstrap3 %}
{% load vite %}
{% load static %}
{% load compress %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<h1>{% trans "Edit layout" %}</h1>
{{ platforms|json_script:"platforms" }}
{{ placeholders|json_script:"placeholders" }}
{{ locales|json_script:"locales" }}
<div id="editor" data-layout-id="{{ object.pk }}"
data-base-url="{% url 'api-v1:walletlayout-list' organizer=request.organizer.slug event=request.event.slug %}"
data-preview-url="{% url 'plugins:wallet:preview' organizer=request.organizer.slug event=request.event.slug %}"></div>
{% vite_hmr %}
{% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %}
{% csrf_token %}
{% endblock %}
@@ -0,0 +1,78 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load wallet %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<h1>{% trans "Wallet layouts" %}</h1>
{% if object_list|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any layouts yet.
{% endblocktrans %}
</p>
{% if "event.settings.general:write" in request.eventpermset %}
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new layout" %}
</a>
{% endif %}
</div>
{% else %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Default" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for l in object_list %}
<tr>
<td>
{% if "can_change_event_settings" in request.eventpermset %}
<strong><a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
{{ l.name }}
</a></strong>
{% else %}
<strong>{{ l.name }}</strong>
{% endif %}
</td>
<td>
{% if l.default %}
<span class="text-success">
<span class="fa fa-check"></span>
{% trans "Default" %}
</span>
{% elif "can_change_event_settings" in request.eventpermset %}
<form class="form-inline" method="post"
action="{% url "plugins:wallet:default" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
{% csrf_token %}
<button class="btn btn-default btn-sm">
{% trans "Make default" %}
</button>
</form>
{% endif %}
</td>
<td class="text-right flip">
{% if "can_change_event_settings" in request.eventpermset %}
<a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ l.id }}"
class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a>
{% if l.allow_delete %}
<a href="{% url "plugins:wallet:delete" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% else %}
<button class="btn btn-danger btn-sm" disabled><i class="fa fa-trash"></i></button>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,8 @@
{% load i18n %}
<p>
<a class="btn btn-primary btn-lg" target="_blank"
href="{% url "plugins:wallet:index" organizer=request.organizer.slug event=request.event.slug %}">
<span class="fa fa-paint-brush"></span>
{% trans "Edit layouts" %}
</a>
</p>
@@ -0,0 +1,10 @@
from django import template
from ..models import WalletLayout
register = template.Library()
@register.filter
def platform_layouts(platform, event):
return WalletLayout.objects.filter(event=event, platform=platform.identifier)
+43
View File
@@ -0,0 +1,43 @@
#
# 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 django.template.loader import render_to_string
from django.utils.translation import gettext as _
from pretix.base.ticketoutput import BaseTicketOutput
class WalletSettingsHolder(BaseTicketOutput):
"""
Mock ticket output provider for control settings
TODO: replace with custom view?
"""
identifier = "wallet"
verbose_name = _("Wallet")
is_meta = True
is_enabled = False
preview_allowed = False
def settings_content_render(self, request) -> str:
return render_to_string(
"pretixplugins/wallet/settings_content.html", {"request": request}
)
+139
View File
@@ -0,0 +1,139 @@
#
# 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 django.urls import re_path
from pretix.api.urls import event_router
from pretix.plugins.wallet.views.google import CallbackView
from .api import WalletLayoutViewSet
from .views.apple import (
DeviceGetUpdatablePassesView,
DeviceRegistrationView,
GetPassView,
LogView,
DownloadPassView,
DownloadCombinedPassView
)
from .views.control import (
LayoutCreateView,
LayoutDelete,
LayoutEditorView,
LayoutListView,
LayoutPreviewView,
LayoutSetDefault,
WalletGetView
)
from .views.presale import PresaleWalletGetPass, PresaleWalletGetPassPosition
urlpatterns = [
re_path(
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/$",
LayoutListView.as_view(),
name="index",
),
re_path(
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/add/$",
LayoutCreateView.as_view(),
name="add",
),
re_path(
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$",
LayoutEditorView.as_view(),
name="edit",
),
re_path(
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/preview/$",
LayoutPreviewView.as_view(),
name="preview",
),
re_path(
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/default/(?P<layout>[^/]+)/$",
LayoutSetDefault.as_view(),
name="default",
),
re_path(
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$",
LayoutDelete.as_view(),
name="delete",
),
re_path(
# TODO(wallet): actually provide this view
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/orders/(?P<code>[0-9A-Z]+)/wallet/(?P<position>\d+)/(?P<platform>[^/]+)/$",
WalletGetView.as_view(),
name="event.order.wallet.get",
),
]
event_patterns = [
re_path(
r"_wallet/apple/v1/devices/(?P<device_library_identifier>[^/]+)/registrations/(?P<pass_type_identifier>[^/]+)/(?P<serial_number>[^/]+)$",
DeviceRegistrationView.as_view(),
name="apple_register",
),
re_path(
r"_wallet/apple/v1/devices/(?P<device_library_identifier>[^/]+)/registrations/(?P<pass_type_identifier>[^/]+)$",
DeviceGetUpdatablePassesView.as_view(),
name="apple_get-updatable",
),
re_path(
r"_wallet/apple/v1/passes/(?P<pass_type_identifier>[^/]+)/(?P<serial_number>[^/]+)$",
GetPassView.as_view(),
name="apple_get-pass",
),
re_path(
r"_wallet/apple/v1/log$",
LogView.as_view(),
name="apple_log",
),
re_path(
r"_wallet/google/callback$",
CallbackView.as_view(),
name="google_webhook",
),
re_path(
r"^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/wallet/download/(?P<position>[0-9]+)/(?P<platform>[^/]+)$",
PresaleWalletGetPass.as_view(),
name="presale_event.order.wallet.get",
),
re_path(
r"^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/wallet/apple/download/(?P<position>[0-9]+)/$",
DownloadPassView.as_view(),
name="apple_download",
),
re_path(
r"^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/wallet/apple/download/$",
DownloadCombinedPassView.as_view(),
name="apple_download_combined",
),
re_path(
r"^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/wallet/download/(?P<platform>[^/]+)$",
PresaleWalletGetPass.as_view(),
name="presale_event.order.wallet.get.combined",
),
re_path(
r"^ticket/(?P<order>[^/]+)/(?P<position>\d+)/(?P<secret>[A-Za-z0-9]+)/wallet/download/(?P<pid>[0-9]+)/(?P<platform>[^/]+)$",
PresaleWalletGetPassPosition.as_view(),
name="presale_event.order.position.wallet.get",
),
]
event_router.register("walletlayouts", WalletLayoutViewSet)
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more