New check-in features (#3022)

This commit is contained in:
Raphael Michel
2023-02-09 09:46:46 +01:00
committed by GitHub
parent 7b0d07065f
commit 6902725f3c
69 changed files with 1606 additions and 183 deletions
+2
View File
@@ -203,6 +203,8 @@ Checking a ticket in
* ``invalid`` - Ticket is not known.
* ``unpaid`` - Ticket is not paid for.
* ``blocked`` - Ticket has been blocked.
* ``invalid_time`` - Ticket is not valid at this time.
* ``canceled`` Ticket is canceled or expired.
* ``already_redeemed`` - Ticket already has been redeemed.
* ``product`` - Tickets with this product may not be scanned at this device.
+3 -1
View File
@@ -364,7 +364,7 @@ Endpoints
Stores a failed check-in. Only necessary for statistical purposes if you perform scan validation offline.
:<json boolean error_reason: One of ``canceled``, ``invalid``, ``unpaid``, ``product``, ``rules``, ``revoked``,
``incomplete``, ``already_redeemed``, or ``error``. Required.
``incomplete``, ``already_redeemed``, ``blocked``, ``invalid_time``, or ``error``. Required.
:<json raw_barcode: The raw barcode you scanned. Required.
:<json datetime: Date and time of the scan. Optional.
:<json type: Type of scan, defaults to ``"entry"``.
@@ -743,6 +743,8 @@ Order position endpoints
* ``invalid`` - Ticket code not known.
* ``unpaid`` - Ticket is not paid for.
* ``blocked`` - Ticket has been blocked.
* ``invalid_time`` - Ticket is not valid at this time.
* ``canceled`` Ticket is canceled or expired. This reason is only sent when your request sets.
``canceled_supported`` to ``true``, otherwise these orders return ``unpaid``.
* ``already_redeemed`` - Ticket already has been redeemed.
+10 -1
View File
@@ -24,6 +24,9 @@ active boolean If ``false``, t
description multi-lingual string A public description of the variation. May contain
Markdown syntax or can be ``null``.
position integer An integer, used for sorting
checkin_attention boolean If ``true``, the check-in app should show a warning
that this ticket requires special attention if such
a variation is being scanned.
require_approval boolean If ``true``, orders with this variation will need to be
approved by the event organizer before they can be
paid.
@@ -48,7 +51,7 @@ meta_data object Values set for
.. versionchanged:: 4.16
The ``meta_data`` attribute has been added.
The ``meta_data`` and ``checkin_attention`` attributes have been added.
Endpoints
---------
@@ -84,6 +87,7 @@ Endpoints
"en": "S"
},
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
@@ -107,6 +111,7 @@ Endpoints
"en": "L"
},
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
@@ -159,6 +164,7 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
@@ -197,6 +203,7 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
@@ -225,6 +232,7 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
@@ -284,6 +292,7 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": false,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
+28 -1
View File
@@ -112,6 +112,12 @@ variations list of objects A list with one
for price calculations (or ``null``).
├ active boolean If ``false``, this variation will not be sold or shown.
├ description multi-lingual string A public description of the variation. May contain
├ checkin_attention boolean If ``true``, the check-in app should show a warning
that this ticket requires special attention if such
a variation is being scanned.
├ require_approval boolean If ``true``, orders with this variation will need to be
approved by the event organizer before they can be
paid.
├ require_membership boolean If ``true``, booking this variation requires an active membership.
├ require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
be hidden from users without a valid membership.
@@ -164,7 +170,8 @@ meta_data object Values set for
.. versionchanged:: 4.16
The ``variations[x].meta_data`` attribute has been added. The ``personalized`` attribute has been added.
The ``variations[x].meta_data`` and ``variations[x].checkin_attention`` attributes has been added.
The ``personalized`` attribute has been added.
Notes
-----
@@ -252,6 +259,8 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
@@ -268,6 +277,8 @@ Endpoints
"price": "23.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
@@ -369,6 +380,8 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"description": null,
@@ -385,6 +398,8 @@ Endpoints
"price": "23.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
@@ -466,6 +481,8 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
@@ -482,6 +499,8 @@ Endpoints
"price": "23.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
@@ -552,6 +571,8 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
@@ -568,6 +589,8 @@ Endpoints
"price": "23.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
@@ -669,6 +692,8 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
@@ -685,6 +710,8 @@ Endpoints
"price": "23.00",
"original_price": null,
"active": true,
"checkin_attention": false,
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"sales_channels": ["web"],
+177 -1
View File
@@ -91,6 +91,10 @@ require_approval boolean If ``true`` and
needs approval by an organizer before it can
continue. If ``true`` and the order is canceled,
this order has been denied by the event organizer.
valid_if_pending boolean If ``true`` and the order is pending, this order
is still treated like a paid order for most purposes,
such as check-in. This may be used e.g. for trusted
customers who only need to pay after the event.
url string The full URL to the order confirmation page
payments list of objects List of payment processes (see below)
refunds list of objects List of refund processes (see below)
@@ -122,6 +126,10 @@ last_modified datetime Last modificati
The ``include`` query parameter has been added.
.. versionchanged:: 4.16
The ``valid_if_pending`` attribute has been added.
.. _order-position-resource:
@@ -159,6 +167,9 @@ secret string Secret code pri
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
discount integer ID of a discount that has been used during the creation of this position in some way (or ``null``).
blocked list of strings A list of strings, or ``null``. Whenever not ``null``, the ticket may not be used (e.g. for check-in).
valid_from datetime The ticket will not be valid before this time. Can be ``null``.
valid_until datetime The ticket will not be valid after this time. Can be ``null``.
pseudonymization_id string A random ID, e.g. for use in lead scanning apps
checkins list of objects List of **successful** check-ins with this ticket
├ id integer Internal ID of the check-in event
@@ -186,6 +197,10 @@ pdf_data object Data object req
``pdf_data=true`` query parameter to your request.
===================================== ========================== =======================================================
.. versionchanged:: 4.16
The attributes ``blocked``, ``valid_from`` and ``valid_until`` have been added.
.. _order-payment-resource:
Order payment resource
@@ -294,6 +309,7 @@ List of all orders
"custom_followup_at": null,
"checkin_attention": false,
"require_approval": false,
"valid_if_pending": false,
"invoice_address": {
"last_modified": "2017-12-01T10:00:00Z",
"is_business": true,
@@ -336,6 +352,9 @@ List of all orders
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"valid_from": null,
"valid_until": null,
"blocked": null,
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
@@ -467,6 +486,7 @@ Fetching individual orders
"custom_followup_at": null,
"checkin_attention": false,
"require_approval": false,
"valid_if_pending": false,
"invoice_address": {
"last_modified": "2017-12-01T10:00:00Z",
"company": "Sample company",
@@ -509,6 +529,9 @@ Fetching individual orders
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"valid_from": null,
"valid_until": null,
"blocked": null,
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
@@ -640,6 +663,8 @@ Updating order fields
* ``invoice_address`` (you always need to supply the full object, or ``null`` to delete the current address)
* ``valid_if_pending``
**Example request**:
.. sourcecode:: http
@@ -845,6 +870,7 @@ Creating orders
* ``custom_followup_at`` (optional)
* ``checkin_attention`` (optional)
* ``require_approval`` (optional)
* ``valid_if_pending`` (optional)
* ``invoice_address`` (optional)
* ``company``
@@ -880,6 +906,8 @@ Creating orders
* ``secret`` (optional)
* ``addon_to`` (optional, see below)
* ``subevent`` (optional)
* ``valid_from`` (optional)
* ``valid_until`` (optional)
* ``answers``
* ``question``
@@ -1448,6 +1476,9 @@ List of all order positions
"seat": null,
"addon_to": null,
"subevent": null,
"valid_from": null,
"valid_until": null,
"blocked": null,
"checkins": [
{
"list": 44,
@@ -1554,6 +1585,9 @@ Fetching individual positions
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
"valid_from": null,
"valid_until": null,
"blocked": null,
"discount": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
@@ -1659,6 +1693,10 @@ Manipulating individual positions
The ``PATCH`` method now supports changing items, variations, subevents, seats, prices, and tax rules.
The ``POST`` endpoint to add individual positions has been added.
.. versionadded:: 4.16
The endpoints to manage blocks have been added.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Updates specific fields on an order position. Currently, only the following fields are supported:
@@ -1697,6 +1735,10 @@ Manipulating individual positions
* ``tax_rule``
* ``valid_from``
* ``valid_until``
Changing parameters such as ``item`` or ``price`` will **not** automatically trigger creation of a new invoice,
you need to take care of that yourself.
@@ -1771,6 +1813,10 @@ Manipulating individual positions
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.
* ``valid_from``
* ``valid_until``
This will **not** automatically trigger creation of a new invoice, you need to take care of that yourself.
**Example request**:
@@ -1834,6 +1880,82 @@ Manipulating individual positions
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/add_block/
Blocks an order position from being used. The block name either needs to be ``"admin"`` or start with ``"api:"``. It
may only contain letters, numbers, dots and underscores. ``"admin"`` represents the regular block that can be set
in the backend user interface.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23/add_block/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "api:block1"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``id`` field of the order position to update
:statuscode 200: no error
:statuscode 400: The order position could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/remove_block/
Unblocks an order position from being used. The block name either needs to be ``"admin"`` or start with ``"api:"``. It
may only contain letters, numbers, dots and underscores. ``"admin"`` represents the regular block that can be set
in the backend user interface. Blocks set by plugins cannot be lifted through this API.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23/remove_block/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "api:block1"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``id`` field of the order position to update
:statuscode 200: no error
:statuscode 400: The order position could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position.
Changing order contents
-----------------------
@@ -1852,7 +1974,7 @@ otherwise, such as splitting an order or changing fees.
* ``patch_positions``: A list of objects with the two keys ``position`` specifying an order position ID and
``body`` specifying the desired changed values of the position (``item``, ``variation``, ``subevent``, ``seat``,
``price``, ``tax_rule``).
``price``, ``tax_rule``, ``valid_from``, ``valid_until``).
* ``cancel_positions``: A list of objects with the single key ``position`` specifying an order position ID.
@@ -2541,3 +2663,57 @@ With some non-default ticket secret generation methods, a list of revoked ticket
: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.
Blocked ticket secrets
----------------------
With some non-default ticket secret generation methods, a list of blocked ticket secrets is required for proper validation.
This endpoint returns all secrets that are currently blocked **or have been blocked before and are now unblocked**, so
be sure to check the ``blocked`` attribute for its actual value. The list is currently always ordered with the most
recently updated ones first.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/blockedsecrets/
Returns a list of all blocked or historically blocked secrets within a given event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/blockedsecrets/ 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
X-Page-Generated: 2017-12-01T10:00:00Z
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1234,
"secret": "k24fiuwvu8kxz3y1",
"blocked": true,
"updated": "2017-12-01T10:00:00Z",
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:query datetime updated_since: Only return records that have been updated since the given date.
:query boolean blocked: Only return blocked / non-blocked records.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
differences, this is the value you want to use as ``updated_since`` in your next call.
: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.
+1 -1
View File
@@ -61,7 +61,7 @@ Backend
item_formsets, order_search_filter_q, order_search_forms
.. automodule:: pretix.base.signals
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events, orderposition_blocked_display
Vouchers
""""""""
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 278 KiB

+48 -18
View File
@@ -23,30 +23,50 @@ partition "data-based check" {
"Is the order in status PAID or PENDING\nand is the position not canceled?" --> if "" then
-right->[no] "Return error CANCELED"
else
-down->[yes] "Is the product part of the check-in list?"
-down->[yes] "Is one or more block set on the ticket?"
--> if "" then
-right->[no] "Return error PRODUCT"
-right->[no] "Return error BLOCKED"
else
-down->[yes] "Is the subevent part of the check-in list?"
-down->[yes] "If this is not an exit, is the valid_from/valid_until\nconstraint on the ticket fulfilled?"
--> if "" then
-right->[no] "Return error INVALID"
note bottom: TODO\ninconsistent\nwith online\ncheck
-right->[no] "Return error INVALID_TIME"
else
-down->[yes] "Is the order in status PAID?"
-down->[yes] "Is the product part of the check-in list?"
--> if "" then
-right->[no] "Does the check-in list include pending orders?"
-right->[no] "Return error PRODUCT"
else
-down->[yes] "Is the subevent part of the check-in list?"
--> if "" then
-right->[no] "Return error UNPAID "
-right->[no] "Return error INVALID"
note bottom: TODO\ninconsistent\nwith online\ncheck
else
-down->[yes] "Is ignore_unpaid set?\n(Has the operator confirmed\nthe checkin?)"
-down->[yes] "Is the order in status PAID?"
--> if "" then
-right->[no] "Return error UNPAID "
-right->[no] "Is Order.require_approval set?"
--> if "" then
-->[yes] "Return error UNPAID "
else
-right->[no] "Is Order.valid_if_pending set?"
--> if "" then
-->[yes] "Is this an entry or exit?"
else
-->[no] "Does the check-in list include pending orders?"
--> if "" then
-->[no] "Return error UNPAID "
else
-->[yes] "Is ignore_unpaid set on the request?\n(Has the operator confirmed\nthe checkin?)"
--> if "" then
-->[no] "Return error UNPAID "
else
-->[yes] "Is this an entry or exit?"
endif
endif
endif
endif
else
-down->[yes] "Is this an entry or exit?"
endif
endif
else
-down->[yes] "Is this an entry or exit?"
endif
endif
endif
@@ -98,16 +118,26 @@ partition "dataless check" {
--> if "" then
-right->[yes] "Return error REVOKED"
else
-down->[no] "Is the product part of the check-in list? "
-down->[yes] "Is the ticket secret on the block list?"
--> if "" then
-right->[no] "Return error PRODUCT "
-right->[yes] "Return error BLOCKED "
else
-down->[yes] "Is the subevent part of the check-in list? "
-down->[yes] "If this is not an exit, is the valid_from/valid_until\nconstraint on the ticket fulfilled? "
--> if "" then
-right->[no] "Return error INVALID "
note bottom: TODO\ninconsistent\nwith online\ncheck
-right->[no] "Return error INVALID_TIME "
else
--> "Is this an entry or exit? "
-down->[no] "Is the product part of the check-in list? "
--> if "" then
-right->[no] "Return error PRODUCT "
else
-down->[yes] "Is the subevent part of the check-in list? "
--> if "" then
-right->[no] "Return error INVALID "
note bottom: TODO\ninconsistent\nwith online\ncheck
else
--> "Is this an entry or exit? "
endif
endif
endif
endif
endif
Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 177 KiB

+31 -11
View File
@@ -40,29 +40,49 @@ endif
"Is the order in status PAID or PENDING\nand is the position not canceled?" --> if "" then
-right->[no] "Return error CANCELED"
else
-down->[yes] "Is the product part of the check-in list?"
-down->[yes] "Is one or more block set on the ticket?"
--> if "" then
-right->[no] "Return error PRODUCT"
-right->[no] "Return error BLOCKED"
else
-down->[yes] "Is the subevent part of the check-in list?"
-down->[yes] "If this is not an exit, is the valid_from/valid_until\nconstraint on the ticket fulfilled?"
--> if "" then
-right->[no] "Return error PRODUCT "
-right->[no] "Return error INVALID_TIME"
else
-down->[yes] "Is the order in status PAID\nor is this a forced upload?"
-down->[yes] "Is the product part of the check-in list?"
--> if "" then
-right->[no] "Does the check-in list include pending orders?"
-right->[no] "Return error PRODUCT"
else
-down->[yes] "Is the subevent part of the check-in list?"
--> if "" then
-right->[no] "Return error UNPAID "
-right->[no] "Return error PRODUCT "
else
-down->[yes] "Is ignore_unpaid set?\n(Has the operator confirmed\nthe checkin?)"
-down->[yes] "Is the order in status PAID\nor is this a forced upload?"
--> if "" then
-right->[no] "Return error UNPAID "
-right->[no] "Is Order.require_approval set?"
--> if "" then
-->[no] "Is Order.valid_if_pending set?"
--> if "" then
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
else
-right->[no] "Does the check-in list include pending orders?"
--> if "" then
-right->[no] "Return error UNPAID "
else
-down->[yes] "Is ignore_unpaid set on the request?\n(Has the operator confirmed\nthe checkin?)"
--> if "" then
-right->[no] "Return error UNPAID "
else
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
endif
endif
endif
else
-->[yes] "Return error UNPAID "
endif
else
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
endif
endif
else
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
endif
endif
endif
+4
View File
@@ -74,6 +74,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:checkinlistpos-list'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:order-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'),
@@ -108,6 +109,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:checkinlist-failed_checkins'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
@@ -142,6 +144,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:checkinlistpos-list'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:orderposition-pdf_image'),
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
@@ -208,6 +211,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'plugins:pretix_posbackend:stripeterminal.paymentintent'),
('PUT', 'plugins:pretix_posbackend:file.upload'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:blockedsecrets-list'),
('GET', 'api-v1:event.settings'),
('GET', 'plugins:pretix_seating:event.event'),
('GET', 'plugins:pretix_seating:event.event.subevent'),
+4 -4
View File
@@ -60,8 +60,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
@@ -84,8 +84,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
+26 -11
View File
@@ -52,7 +52,8 @@ from pretix.base.models import (
SubEvent, TaxRule, Voucher,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
RevokedTicketSecret,
)
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
@@ -300,7 +301,9 @@ class FailedCheckinSerializer(I18nAwareModelSerializer):
class OrderDownloadsField(serializers.Field):
def to_representation(self, instance: Order):
if instance.status != Order.STATUS_PAID:
if instance.status != Order.STATUS_PENDING or instance.require_approval or not instance.event.settings.ticket_download_pending:
if instance.status != Order.STATUS_PENDING or instance.require_approval or (
not instance.valid_if_pending and not instance.event.settings.ticket_download_pending
):
return []
request = self.context['request']
@@ -324,7 +327,9 @@ class OrderDownloadsField(serializers.Field):
class PositionDownloadsField(serializers.Field):
def to_representation(self, instance: OrderPosition):
if instance.order.status != Order.STATUS_PAID:
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or (
not instance.order.valid_if_pending and not instance.order.event.settings.ticket_download_pending
):
return []
if not instance.generate_ticket:
return []
@@ -437,11 +442,12 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled',
'valid_from', 'valid_until', 'blocked')
read_only_fields = (
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
'seat', 'canceled', 'discount',
'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked'
)
def __init__(self, *args, **kwargs):
@@ -463,7 +469,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
class RequireAttentionField(serializers.Field):
def to_representation(self, instance: OrderPosition):
return instance.order.checkin_attention or instance.item.checkin_attention
return instance.require_checkin_attention
class AttendeeNameField(serializers.Field):
@@ -508,7 +514,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
'company', 'street', 'zipcode', 'city', 'country', 'state',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
'order__status')
'order__status', 'valid_from', 'valid_until', 'blocked')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -622,7 +628,7 @@ class OrderSerializer(I18nAwareModelSerializer):
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url', 'customer'
'url', 'customer', 'valid_if_pending'
)
read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
@@ -677,7 +683,8 @@ class OrderSerializer(I18nAwareModelSerializer):
def update(self, instance, validated_data):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = ['comment', 'custom_followup_at', 'checkin_attention', 'email', 'locale', 'phone']
update_fields = ['comment', 'custom_followup_at', 'checkin_attention', 'email', 'locale', 'phone',
'valid_if_pending']
if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address')
@@ -783,7 +790,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -941,7 +948,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
model = Order
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'require_approval')
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'require_approval',
'valid_if_pending')
def validate_payment_provider(self, pp):
if pp is None:
@@ -1532,3 +1540,10 @@ class RevokedTicketSecretSerializer(I18nAwareModelSerializer):
class Meta:
model = RevokedTicketSecret
fields = ('id', 'secret', 'created')
class BlockedTicketSecretSerializer(I18nAwareModelSerializer):
class Meta:
model = BlockedTicketSecret
fields = ('id', 'secret', 'updated', 'blocked')
+17 -2
View File
@@ -24,6 +24,7 @@ import os
import pycountry
from django.core.files import File
from django.core.validators import RegexValidator
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -53,7 +54,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
model = OrderPosition
fields = ('order', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'secret', 'addon_to', 'subevent', 'answers', 'seat')
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'valid_from', 'valid_until')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -89,6 +90,8 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
addon_to=validated_data.get('addon_to'),
subevent=validated_data.get('subevent'),
seat=validated_data.get('seat'),
valid_from=validated_data.get('valid_from'),
valid_until=validated_data.get('valid_until'),
)
if self.context.get('commit', True):
ocm.commit()
@@ -198,7 +201,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
class Meta:
model = OrderPosition
fields = (
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule',
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule', 'valid_from', 'valid_until'
)
def __init__(self, *args, **kwargs):
@@ -264,6 +267,8 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
price = validated_data.get('price', instance.price)
seat = validated_data.get('seat', current_seat)
tax_rule = validated_data.get('tax_rule', instance.tax_rule)
valid_from = validated_data.get('valid_from', instance.valid_from)
valid_until = validated_data.get('valid_until', instance.valid_until)
change_item = None
if item != instance.item or variation != instance.variation:
@@ -290,6 +295,12 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
if tax_rule != instance.tax_rule:
ocm.change_tax_rule(instance, tax_rule)
if valid_from != instance.valid_from:
ocm.change_valid_from(instance, valid_from)
if valid_until != instance.valid_until:
ocm.change_valid_until(instance, valid_until)
if self.context.get('commit', True):
ocm.commit()
instance.refresh_from_db()
@@ -423,3 +434,7 @@ class OrderChangeOperationSerializer(serializers.Serializer):
seen_positions.add(d['fee'])
return data
class BlockNameSerializer(serializers.Serializer):
name = serializers.CharField(validators=[RegexValidator('^(admin|api:[a-zA-Z0-9._]+)$')])
+1
View File
@@ -81,6 +81,7 @@ event_router.register(r'orders', order.OrderViewSet)
event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
event_router.register(r'blockedsecrets', order.BlockedSecretViewSet, basename='blockedsecrets')
event_router.register(r'taxrules', event.TaxRuleViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
+18 -6
View File
@@ -273,7 +273,13 @@ with scopes_disabled():
def check_rules_qs(self, queryset, name, value):
if not self.checkinlist.rules:
return queryset
return queryset.filter(SQLLogic(self.checkinlist).apply(self.checkinlist.rules))
return queryset.filter(
SQLLogic(self.checkinlist).apply(self.checkinlist.rules)
).filter(
Q(valid_from__isnull=True) | Q(valid_from__lte=now()),
Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
blocked__isnull=True,
)
def _handle_file_upload(data, user, auth):
@@ -325,7 +331,13 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
if checkinlist.subevent:
list_q &= Q(subevent=checkinlist.subevent)
if not ignore_status:
list_q &= Q(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if checkinlist.include_pending else [Order.STATUS_PAID])
if checkinlist.include_pending:
list_q &= Q(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING])
else:
list_q &= Q(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
if not checkinlist.all_products and not ignore_products:
list_q &= Q(item__in=checkinlist.limit_products.values_list('id', flat=True))
lists_qs.append(list_q)
@@ -582,7 +594,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'status': 'error',
'reason': Checkin.REASON_AMBIGUOUS,
'reason_explanation': None,
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'require_attention': op.require_checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400)
@@ -627,7 +639,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
except RequiredQuestionsError as e:
return Response({
'status': 'incomplete',
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'require_attention': op.require_checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'questions': [
QuestionSerializer(q).data for q in e.questions
@@ -656,14 +668,14 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'status': 'error',
'reason': e.code,
'reason_explanation': e.reason,
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'require_attention': op.require_checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400)
else:
return Response({
'status': 'ok',
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'require_attention': op.require_checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=201)
+92 -10
View File
@@ -49,15 +49,15 @@ from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.order import (
InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer,
OrderPaymentSerializer, OrderPositionSerializer,
OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer,
PriceCalcSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer,
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
OrderPaymentCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
RevokedTicketSecretSerializer, SimulatedOrderSerializer,
)
from pretix.api.serializers.orderchange import (
OrderChangeOperationSerializer, OrderFeeChangeSerializer,
OrderPositionChangeSerializer,
BlockNameSerializer, OrderChangeOperationSerializer,
OrderFeeChangeSerializer, OrderPositionChangeSerializer,
OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer,
)
@@ -70,7 +70,9 @@ from pretix.base.models import (
OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule, TeamAPIToken,
generate_secret,
)
from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
from pretix.base.models.orders import (
BlockedTicketSecret, QuestionAnswer, RevokedTicketSecret,
)
from pretix.base.payment import PaymentException
from pretix.base.pdf import get_images
from pretix.base.secrets import assign_ticket_secret
@@ -287,7 +289,7 @@ class OrderViewSet(viewsets.ModelViewSet):
if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
raise PermissionDenied("Downloads are not available for canceled or expired orders.")
if order.status == Order.STATUS_PENDING and not request.event.settings.ticket_download_pending:
if order.status == Order.STATUS_PENDING and not (order.valid_if_pending or request.event.settings.ticket_download_pending):
raise PermissionDenied("Downloads are not available for pending orders.")
ct = CachedCombinedTicket.objects.filter(
@@ -763,6 +765,16 @@ class OrderViewSet(viewsets.ModelViewSet):
}
)
if 'valid_if_pending' in self.request.data and serializer.instance.valid_if_pending != self.request.data.get('valid_if_pending'):
serializer.instance.log_action(
'pretix.event.order.valid_if_pending',
user=self.request.user,
auth=self.request.auth,
data={
'new_value': self.request.data.get('valid_if_pending')
}
)
if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'):
serializer.instance.email_known_to_work = False
serializer.instance.log_action(
@@ -1183,7 +1195,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
if pos.order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
raise PermissionDenied("Downloads are not available for canceled or expired orders.")
if pos.order.status == Order.STATUS_PENDING and not request.event.settings.ticket_download_pending:
if pos.order.status == Order.STATUS_PENDING and not (pos.order.valid_if_pending or request.event.settings.ticket_download_pending):
raise PermissionDenied("Downloads are not available for pending orders.")
if not pos.generate_ticket:
raise PermissionDenied("Downloads are not enabled for this product.")
@@ -1225,6 +1237,54 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
raise ValidationError(str(e))
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
def add_block(self, request, **kwargs):
serializer = BlockNameSerializer(
data=request.data,
context=self.get_serializer_context(),
)
serializer.is_valid(raise_exception=True)
instance = self.get_object()
try:
ocm = OrderChangeManager(
instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
notify=False,
reissue_invoice=False,
)
ocm.add_block(instance, serializer.validated_data['name'])
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
def remove_block(self, request, **kwargs):
serializer = BlockNameSerializer(
data=request.data,
context=self.get_serializer_context(),
)
serializer.is_valid(raise_exception=True)
instance = self.get_object()
try:
ocm = OrderChangeManager(
instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
notify=False,
reissue_invoice=False,
)
ocm.remove_block(instance, serializer.validated_data['name'])
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
return self.retrieve(request, [], **kwargs)
def perform_destroy(self, instance):
try:
ocm = OrderChangeManager(
@@ -1785,3 +1845,25 @@ class RevokedSecretViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return RevokedTicketSecret.objects.filter(event=self.request.event)
with scopes_disabled():
class BlockedSecretFilter(FilterSet):
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
class Meta:
model = BlockedTicketSecret
fields = ['blocked']
class BlockedSecretViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = BlockedTicketSecretSerializer
queryset = BlockedTicketSecret.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('-updated', '-pk')
filterset_class = BlockedSecretFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_queryset(self):
return BlockedTicketSecret.objects.filter(event=self.request.event)
+5
View File
@@ -109,6 +109,8 @@ class JSONExporter(BaseExporter):
'name': str(variation),
'description': str(variation.description),
'position': variation.position,
'checkin_attention': variation.checkin_attention,
'require_approval': variation.require_approval,
'require_membership': variation.require_membership,
'sales_channels': variation.sales_channels,
'available_from': variation.available_from,
@@ -193,6 +195,9 @@ class JSONExporter(BaseExporter):
'state': position.state,
'secret': position.secret,
'addon_to': position.addon_to_id,
'valid_from': position.valid_from,
'valid_until': position.valid_until,
'blocked': position.blocked,
'answers': [
{
'question': answer.question_id,
+9
View File
@@ -43,6 +43,7 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import (
@@ -569,6 +570,9 @@ class OrderListExporter(MultiSheetListExporter):
_('Seat zone'),
_('Seat row'),
_('Seat number'),
_('Blocked'),
_('Valid from'),
_('Valid until'),
_('Order comment'),
_('Follow-up date'),
]
@@ -682,6 +686,11 @@ class OrderListExporter(MultiSheetListExporter):
else:
row += ['', '', '', '', '']
row += [
_('Yes') if op.blocked else '',
date_format(op.valid_from, 'SHORT_DATETIME_FORMAT') if op.valid_from else '',
date_format(op.valid_until, 'SHORT_DATETIME_FORMAT') if op.valid_until else '',
]
row.append(order.comment)
row.append(order.custom_followup_at.strftime("%Y-%m-%d") if order.custom_followup_at else "")
acache = {}
@@ -0,0 +1,55 @@
# Generated by Django 3.2.17 on 2023-02-08 09:39
import django.core.serializers.json
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0229_invoice_payment_provider_stamp'),
]
operations = [
migrations.AddField(
model_name='itemvariation',
name='checkin_attention',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='order',
name='valid_if_pending',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='orderposition',
name='blocked',
field=models.JSONField(null=True),
),
migrations.AddField(
model_name='orderposition',
name='valid_from',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='orderposition',
name='valid_until',
field=models.DateTimeField(blank=True, null=True),
),
migrations.CreateModel(
name='BlockedTicketSecret',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('secret', models.TextField(db_index=True)),
('blocked', models.BooleanField()),
('updated', models.DateTimeField(auto_now=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocked_secrets', to='pretixbase.event')),
('position', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blocked_secrets', to='pretixbase.orderposition')),
],
options={
'unique_together': {('event', 'secret')},
} if 'mysql' not in settings.DATABASES['default']['ENGINE'] else {}
),
]
+12 -4
View File
@@ -106,10 +106,14 @@ class CheckinList(LoggedModel):
order__event=self.event,
)
if not ignore_status:
qs = qs.filter(
canceled=False,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [Order.STATUS_PAID],
)
if self.include_pending:
qs = qs.filter(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING], canceled=False)
else:
qs = qs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True),
canceled=False
)
if self.subevent_id:
qs = qs.filter(subevent_id=self.subevent_id)
@@ -327,6 +331,8 @@ class Checkin(models.Model):
REASON_ALREADY_REDEEMED = 'already_redeemed'
REASON_AMBIGUOUS = 'ambiguous'
REASON_ERROR = 'error'
REASON_BLOCKED = 'blocked'
REASON_INVALID_TIME = 'invalid_time'
REASONS = (
(REASON_CANCELED, _('Order canceled')),
(REASON_INVALID, _('Unknown ticket')),
@@ -338,6 +344,8 @@ class Checkin(models.Model):
(REASON_PRODUCT, _('Ticket type not allowed here')),
(REASON_AMBIGUOUS, _('Ticket code is ambiguous on list')),
(REASON_ERROR, _('Server error')),
(REASON_BLOCKED, _('Ticket blocked')),
(REASON_INVALID_TIME, _('Ticket not valid at this time')),
)
successful = models.BooleanField(
+7
View File
@@ -870,6 +870,13 @@ class ItemVariation(models.Model):
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
'that unlocks this variation.')
)
checkin_attention = models.BooleanField(
verbose_name=_('Requires special attention'),
default=False,
help_text=_('If you set this, the check-in app will show a visible warning that this ticket requires special '
'attention. You can use this for example for student tickets to indicate to the person at '
'check-in that the student ID card still needs to be checked.')
)
objects = ScopedManager(organizer='item__event__organizer')
+86 -2
View File
@@ -122,6 +122,9 @@ class Order(LockModel, LoggedModel):
* ``STATUS_EXPIRED``
* ``STATUS_CANCELED``
:param valid_if_pending: Treat this order like a paid order for most purposes (such as check-in), even if it is
still unpaid.
:type valid_if_pending: bool
:param event: The event this order belongs to
:type event: Event
:param customer: The customer this order belongs to
@@ -177,6 +180,9 @@ class Order(LockModel, LoggedModel):
verbose_name=_("Status"),
db_index=True
)
valid_if_pending = models.BooleanField(
default=False,
)
testmode = models.BooleanField(default=False)
event = models.ForeignKey(
Event,
@@ -645,7 +651,7 @@ class Order(LockModel, LoggedModel):
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
).select_related('item').prefetch_related('issued_gift_cards')
)
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
cancelable = all([op.item.allow_cancel and not op.has_checkin and not op.blocked for op in positions])
if not cancelable or not positions:
return False
for op in positions:
@@ -848,7 +854,7 @@ class Order(LockModel, LoggedModel):
) and (
self.status == Order.STATUS_PAID
or (
(self.event.settings.ticket_download_pending or self.total == Decimal("0.00")) and
(self.valid_if_pending or self.event.settings.ticket_download_pending) and
self.status == Order.STATUS_PENDING and
not self.require_approval
)
@@ -2201,17 +2207,31 @@ class OrderPosition(AbstractPosition):
:type canceled: bool
:param pseudonymization_id: The QR code content for lead scanning
:type pseudonymization_id: str
:param blocked: A list of reasons why this order position is blocked. Blocked positions can't be used for check-in and
other purposes. Each entry should be a short string that can be translated into a human-readable
description by a plugin. If the position is not blocked, the value must be ``None``, not an empty
list.
:type blocked: list
:param valid_from: The ticket will not be considered valid before this date. If the value is ``None``, no check on
ticket level is made.
:type valid_from: datetime
:param valid_until: The ticket will not be considered valid after this date. If the value is ``None``, no check on
ticket level is made.
:type valid_until: datetime
"""
positionid = models.PositiveIntegerField(default=1)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
related_name='all_positions',
on_delete=models.PROTECT
)
voucher_budget_use = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True,
)
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
@@ -2225,6 +2245,7 @@ class OrderPosition(AbstractPosition):
max_digits=10, decimal_places=2,
verbose_name=_('Tax value')
)
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
pseudonymization_id = models.CharField(
@@ -2232,8 +2253,21 @@ class OrderPosition(AbstractPosition):
unique=True,
db_index=True
)
canceled = models.BooleanField(default=False)
blocked = models.JSONField(null=True, blank=True)
valid_from = models.DateTimeField(
verbose_name=_("Valid from"),
null=True,
blank=True,
)
valid_until = models.DateTimeField(
verbose_name=_("Valid until"),
null=True,
blank=True,
)
all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager()
@@ -2264,6 +2298,12 @@ class OrderPosition(AbstractPosition):
def sort_key(self):
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0, self.positionid
@cached_property
def require_checkin_attention(self):
if self.order.checkin_attention or self.item.checkin_attention or (self.variation_id and self.variation.checkin_attention):
return True
return False
@property
def checkins(self):
"""
@@ -2276,11 +2316,30 @@ class OrderPosition(AbstractPosition):
def generate_ticket(self):
if self.item.generate_tickets is not None:
return self.item.generate_tickets
if self.blocked:
return False
return (
(self.order.event.settings.ticket_download_addons or not self.addon_to_id) and
(self.event.settings.ticket_download_nonadm or self.item.admission)
)
@property
def blocked_reasons(self):
from ..signals import orderposition_blocked_display
if not self.blocked:
return []
reasons = {}
for b in self.blocked:
for recv, response in orderposition_blocked_display.send(self.event, orderposition=self, block_name=b):
if response:
reasons[b] = response
break
else:
reasons[b] = b
return reasons
@classmethod
def transform_cart_positions(cls, cp: List, order) -> list:
from . import Voucher
@@ -2363,6 +2422,11 @@ class OrderPosition(AbstractPosition):
event=self.order.event, position=self, force_invalidate=True, save=False
)
if not self.blocked:
self.blocked = None
elif not isinstance(self.blocked, list) or any(not isinstance(b, str) for b in self.blocked):
raise TypeError("blocked needs to be a list of strings")
if not self.pseudonymization_id:
self.assign_pseudonymization_id()
@@ -2941,6 +3005,26 @@ class RevokedTicketSecret(models.Model):
created = models.DateTimeField(auto_now_add=True)
class BlockedTicketSecret(models.Model):
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='blocked_secrets')
position = models.ForeignKey(
OrderPosition,
on_delete=models.SET_NULL,
related_name='blocked_secrets',
null=True,
)
secret = models.TextField(db_index=True)
blocked = models.BooleanField()
updated = models.DateTimeField(auto_now=True)
class Meta:
if 'mysql' not in settings.DATABASES['default']['ENGINE']:
# MySQL does not support indexes on TextField(). Django knows this and just ignores db_index, but it will
# not silently ignore the UNIQUE index, causing this table to fail. I'm so glad we're deprecating MySQL
# in a few months, so we'll just live without an unique index until then.
unique_together = (('event', 'secret'),)
@receiver(post_delete, sender=CachedTicket)
def cachedticket_delete(sender, instance, **kwargs):
if instance.file:
+49 -1
View File
@@ -646,6 +646,52 @@ class Locale(ImportColumn):
order.locale = value
class ValidFrom(ImportColumn):
identifier = 'valid_from'
verbose_name = gettext_lazy('Valid from')
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = self.event.timezone.localize(d)
return d
except (ValueError, TypeError):
pass
else:
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
def assign(self, value, order, position, invoice_address, **kwargs):
position.valid_from = value
class ValidUntil(ImportColumn):
identifier = 'valid_until'
verbose_name = gettext_lazy('Valid until')
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = self.event.timezone.localize(d)
return d
except (ValueError, TypeError):
pass
else:
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
def assign(self, value, order, position, invoice_address, **kwargs):
position.valid_until = value
class Saleschannel(ImportColumn):
identifier = 'sales_channel'
verbose_name = gettext_lazy('Sales channel')
@@ -816,7 +862,9 @@ def get_all_columns(event):
Locale(event),
Saleschannel(event),
SeatColumn(event),
Comment(event)
Comment(event),
ValidFrom(event),
ValidUntil(event),
]
for q in event.questions.prefetch_related('options').exclude(type=Question.TYPE_FILE):
default.append(QuestionColumn(event, q))
@@ -8,4 +8,6 @@ message Ticket {
int64 item = 2;
int64 variation = 3;
int64 subevent = 4;
optional int64 validFromUnixTime = 5;
optional int64 validUntilUnixTime = 6;
}
@@ -37,7 +37,7 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11pretix_sig1.proto\"I\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x42\x33\n#eu.pretix.libpretixsync.crypto.sig1B\x0cTicketProtosb\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11pretix_sig1.proto\"\xb7\x01\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x12\x1e\n\x11validFromUnixTime\x18\x05 \x01(\x03H\x00\x88\x01\x01\x12\x1f\n\x12validUntilUnixTime\x18\x06 \x01(\x03H\x01\x88\x01\x01\x42\x14\n\x12_validFromUnixTimeB\x15\n\x13_validUntilUnixTimeB3\n#eu.pretix.libpretixsync.crypto.sig1B\x0cTicketProtosb\x06proto3')
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'pretix_sig1_pb2', globals())
@@ -45,6 +45,6 @@ if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n#eu.pretix.libpretixsync.crypto.sig1B\014TicketProtos'
_TICKET._serialized_start=21
_TICKET._serialized_end=94
_TICKET._serialized_start=22
_TICKET._serialized_end=205
# @@protoc_insertion_point(module_scope)
+25 -6
View File
@@ -23,6 +23,7 @@ import base64
import inspect
import struct
from collections import namedtuple
from datetime import datetime
from typing import Optional
from cryptography.hazmat.backends.openssl.backend import Backend
@@ -85,10 +86,12 @@ class BaseTicketSecretGenerator:
return None
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
attendee_name: str = None, current_secret: str = None, force_invalidate=False) -> str:
attendee_name: str = None, valid_from: datetime = None, valid_until: datetime = None,
current_secret: str = None, force_invalidate=False) -> str:
"""
Generate a new secret for a ticket with product ``item``, variation ``variation``, subevent ``subevent``,
attendee name ``attendee_name`` (can be ``None``) and the current secret ``current_secret`` (if any).
attendee name ``attendee_name`` (can be ``None``), earliest validity ``valid_from``, lastest validity
``valid_until``, and the current secret ``current_secret`` (if any).
The result must be a string that should only contain the characters ``A-Za-z0-9+/=``.
@@ -118,7 +121,8 @@ class RandomTicketSecretGenerator(BaseTicketSecretGenerator):
use_revocation_list = False
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
attendee_name: str = None, current_secret: str = None, force_invalidate=False):
attendee_name: str = None, valid_from: datetime = None, valid_until: datetime = None,
current_secret: str = None, force_invalidate=False) -> str:
if current_secret and not force_invalidate:
return current_secret
return get_random_string(
@@ -202,15 +206,23 @@ class Sig1TicketSecretGenerator(BaseTicketSecretGenerator):
opaque_id = ticket.seed
return self.ParsedSecret(item=item, subevent=subevent, variation=variation, opaque_id=opaque_id, attendee_name=None)
def _encode_time(self, t):
if t is None:
return 0
return int(t.timestamp())
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
current_secret: str = None, force_invalidate=False):
attendee_name: str = None, valid_from: datetime = None, valid_until: datetime = None,
current_secret: str = None, force_invalidate=False) -> str:
if current_secret and not force_invalidate:
ticket = self._parse(current_secret)
if ticket:
unchanged = (
ticket.item == item.pk and
ticket.variation == (variation.pk if variation else 0) and
ticket.subevent == (subevent.pk if subevent else 0)
ticket.subevent == (subevent.pk if subevent else 0) and
ticket.validFromUnixTime == self._encode_time(valid_from) and
ticket.validUntilUnixTime == self._encode_time(valid_until)
)
if unchanged:
return current_secret
@@ -220,6 +232,8 @@ class Sig1TicketSecretGenerator(BaseTicketSecretGenerator):
t.item = item.pk
t.variation = variation.pk if variation else 0
t.subevent = subevent.pk if subevent else 0
t.validFromUnixTime = self._encode_time(valid_from)
t.validUntilUnixTime = self._encode_time(valid_until)
payload = t.SerializeToString()
result = base64.b64encode(self._sign_payload(payload)).decode()[::-1]
return result
@@ -236,8 +250,13 @@ def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_us
force_invalidate = True
kwargs = {}
if 'attendee_name' in inspect.signature(gen.generate_secret).parameters:
params = inspect.signature(gen.generate_secret).parameters
if 'attendee_name' in params:
kwargs['attendee_name'] = position.attendee_name
if 'valid_from' in params:
kwargs['valid_from'] = position.valid_from
if 'valid_until' in params:
kwargs['valid_until'] = position.valid_until
secret = gen.generate_secret(
item=position.item,
variation=position.variation,
+13 -7
View File
@@ -23,7 +23,7 @@ import logging
from decimal import Decimal
from django.db import transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Subquery
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
from django.utils.translation import gettext
from i18nfield.strings import LazyI18nString
@@ -122,9 +122,13 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
s = OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(k=Count('id')).values('k')
orders_to_cancel = event.orders.annotate(pcnt=Subquery(s, output_field=IntegerField())).filter(
has_blocked = OrderPosition.objects.filter(order_id=OuterRef('pk'), blocked__isnull=False)
orders_to_cancel = event.orders.annotate(
pcnt=Subquery(s, output_field=IntegerField()),
has_blocked=Exists(has_blocked),
).filter(
status__in=[Order.STATUS_PAID, Order.STATUS_PENDING, Order.STATUS_EXPIRED],
pcnt__gt=0
pcnt__gt=0,
).all()
if subevent or subevents_from:
@@ -146,13 +150,14 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
has_subevent=Exists(has_subevent),
has_other_subevent=Exists(has_other_subevent),
).filter(
has_subevent=True, has_other_subevent=True
Q(has_subevent=True, has_other_subevent=True) |
Q(has_subevent=True, has_blocked=True)
)
orders_to_cancel = orders_to_cancel.annotate(
has_subevent=Exists(has_subevent),
has_other_subevent=Exists(has_other_subevent),
).filter(
has_subevent=True, has_other_subevent=False
has_subevent=True, has_other_subevent=False, has_blocked=False
)
for se in subevents:
@@ -167,7 +172,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
else:
subevents = None
subevent_ids = set()
orders_to_change = event.orders.none()
orders_to_change = orders_to_cancel.filter(has_blocked=True)
orders_to_cancel = orders_to_cancel.filter(has_blocked=False)
event.log_action(
'pretix.event.canceled', user=user,
)
@@ -247,7 +253,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
ocm = OrderChangeManager(o, user=user, notify=False)
for p in o.positions.all():
if p.subevent_id in subevent_ids:
if (not event.has_subevents or p.subevent_id in subevent_ids) and not p.blocked:
total += p.price
ocm.cancel(p)
positions.append(p)
+35 -2
View File
@@ -721,6 +721,34 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'canceled' if canceled_supported else 'unpaid'
)
if op.blocked:
raise CheckInError(
_('This ticket has been blocked.'), # todo provide reason
'blocked'
)
if type != Checkin.TYPE_EXIT and op.valid_from and op.valid_from > now():
raise CheckInError(
_('This ticket is only valid after {datetime}.').format(
datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT')
),
'invalid_time',
_('This ticket is only valid after {datetime}.').format(
datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT')
),
)
if type != Checkin.TYPE_EXIT and op.valid_until and op.valid_until < now():
raise CheckInError(
_('This ticket was only valid before {datetime}.').format(
datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT')
),
'invalid_time',
_('This ticket was only valid before {datetime}.').format(
datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT')
),
)
# Do this outside of transaction so it is saved even if the checkin fails for some other reason
checkin_questions = list(
clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id])
@@ -751,8 +779,13 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
_('This order position has an invalid date for this check-in list.'),
'product'
)
elif op.order.status != Order.STATUS_PAID and not force and not (
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
elif op.order.status != Order.STATUS_PAID and not force and op.order.require_approval:
raise CheckInError(
_('This order is not yet approved.'),
'unpaid'
)
elif op.order.status != Order.STATUS_PAID and not force and not op.order.valid_if_pending and not (
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
):
raise CheckInError(
_('This order is not marked as paid.'),
+140 -30
View File
@@ -68,7 +68,8 @@ from pretix.base.models import (
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
InvoiceAddress, OrderFee, OrderRefund, generate_secret,
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
generate_secret,
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
@@ -250,7 +251,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
generate_invoice(order)
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None):
def extend_order(order: Order, new_date: datetime, force: bool=False, valid_if_pending: bool=None, user: User=None, auth=None):
"""
Extends the deadline of an order. If the order is already expired, the quota will be checked to
see if this is actually still possible. If ``force`` is set to ``True``, the result of this check
@@ -261,19 +262,35 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
@transaction.atomic
def change(was_expired=True):
old_date = order.expires
order.expires = new_date
if was_expired:
order.status = Order.STATUS_PENDING
order.save(update_fields=['expires'] + (['status'] if was_expired else []))
order.log_action(
'pretix.event.order.expirychanged',
user=user,
auth=auth,
data={
'expires': order.expires,
'state_change': was_expired
}
)
if valid_if_pending is not None and valid_if_pending != order.valid_if_pending:
order.valid_if_pending = valid_if_pending
if valid_if_pending:
order.log_action(
'pretix.event.order.valid_if_pending.set',
user=user,
auth=auth,
)
else:
order.log_action(
'pretix.event.order.valid_if_pending.unset',
user=user,
auth=auth,
)
order.save(update_fields=['valid_if_pending', 'expires'] + (['status'] if was_expired else []))
if old_date != new_date:
order.log_action(
'pretix.event.order.expirychanged',
user=user,
auth=auth,
data={
'expires': order.expires,
'state_change': was_expired
}
)
if was_expired:
num_invoices = order.invoices.filter(is_cancellation=False).count()
@@ -1203,6 +1220,7 @@ def expire_orders(sender, **kwargs):
qs = Order.objects.filter(
expires__lt=now(),
status=Order.STATUS_PENDING,
valid_if_pending=False,
require_approval=False
).exclude(
Exists(
@@ -1247,11 +1265,17 @@ def send_expiry_warnings(sender, **kwargs):
with language(o.locale, settings.region):
o.expiry_reminder_sent = True
o.save(update_fields=['expiry_reminder_sent'])
email_template = settings.mail_text_order_expire_warning
email_context = get_email_context(event=o.event, order=o)
if settings.payment_term_expire_automatically:
can_autoexpire = (
settings.payment_term_expire_automatically and
not o.valid_if_pending and
not o.fees.filter(fee_type=OrderFee.FEE_TYPE_CANCELLATION).exists()
)
if can_autoexpire:
email_template = settings.mail_text_order_expire_warning
email_subject = settings.mail_subject_order_expire_warning
else:
email_template = settings.mail_text_order_pending_warning
email_subject = settings.mail_subject_order_pending_warning
try:
@@ -1310,8 +1334,7 @@ def send_download_reminders(sender, **kwargs):
positions = o.positions.select_related('item')
if o.status != Order.STATUS_PAID:
if o.status != Order.STATUS_PENDING or o.require_approval or not \
o.event.settings.ticket_download_pending:
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
continue
send = False
for p in positions:
@@ -1405,12 +1428,17 @@ class OrderChangeManager:
TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule'))
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until'))
SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee', 'price_diff'))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
ChangeValidFromOperation = namedtuple('ChangeValidFromOperation', ('position', 'valid_from'))
ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until'))
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name'))
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name'))
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
self.order = order
@@ -1514,6 +1542,18 @@ class OrderChangeManager:
def regenerate_secret(self, position: OrderPosition):
self._operations.append(self.RegenerateSecretOperation(position))
def change_valid_from(self, position: OrderPosition, new_value: datetime):
self._operations.append(self.ChangeValidFromOperation(position, new_value))
def change_valid_until(self, position: OrderPosition, new_value: datetime):
self._operations.append(self.ChangeValidUntilOperation(position, new_value))
def add_block(self, position: OrderPosition, block_name: str):
self._operations.append(self.AddBlockOperation(position, block_name))
def remove_block(self, position: OrderPosition, block_name: str):
self._operations.append(self.RemoveBlockOperation(position, block_name))
def change_price(self, position: OrderPosition, price: Decimal):
tax_rule = self._current_tax_rules().get(position.pk, position.tax_rule) or TaxRule.zero()
price = tax_rule.tax(price, base_price_is='gross')
@@ -1595,7 +1635,8 @@ class OrderChangeManager:
self._invoice_dirty = True
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None):
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None):
if isinstance(seat, str):
if not seat:
seat = None
@@ -1649,7 +1690,8 @@ class OrderChangeManager:
self._quotadiff.update(new_quotas)
if seat:
self._seatdiff.update([seat])
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership))
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until))
def split(self, position: OrderPosition):
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
@@ -1961,6 +2003,7 @@ class OrderChangeManager:
def _perform_operations(self):
nextposid = self.order.all_positions.aggregate(m=Max('positionid'))['m'] + 1
split_positions = []
secret_dirty = set()
for op in self._operations:
if isinstance(op, self.ItemOperation):
@@ -1986,9 +2029,7 @@ class OrderChangeManager:
else:
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
secret_dirty.add(op.position)
op.position.save()
elif isinstance(op, self.MembershipOperation):
self.order.log_action('pretix.event.order.changed.membership', user=self.user, auth=self.auth, data={
@@ -2009,9 +2050,7 @@ class OrderChangeManager:
'new_seat_id': op.seat.pk if op.seat else None,
})
op.position.seat = op.seat
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
secret_dirty.add(op.position)
op.position.save()
elif isinstance(op, self.SubeventOperation):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
@@ -2023,9 +2062,7 @@ class OrderChangeManager:
'new_price': op.position.price
})
op.position.subevent = op.subevent
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
secret_dirty.add(op.position)
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
@@ -2131,8 +2168,10 @@ class OrderChangeManager:
opa.canceled = True
if opa.voucher:
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
if opa in secret_dirty:
secret_dirty.remove(opa)
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
event=self.event, position=opa, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
)
opa.save(update_fields=['canceled', 'secret'])
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
@@ -2149,6 +2188,8 @@ class OrderChangeManager:
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
)
if op.position in secret_dirty:
secret_dirty.remove(op.position)
op.position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create(
@@ -2156,7 +2197,7 @@ class OrderChangeManager:
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
)
nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
@@ -2169,6 +2210,8 @@ class OrderChangeManager:
'membership': pos.used_membership_id,
'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
})
elif isinstance(op, self.SplitOperation):
split_positions.append(op.position)
@@ -2176,12 +2219,79 @@ class OrderChangeManager:
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=True, save=True
)
if op.position in secret_dirty:
secret_dirty.remove(op.position)
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
'order': self.order.pk})
self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
})
elif isinstance(op, self.ChangeValidFromOperation):
self.order.log_action('pretix.event.order.changed.valid_from', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'new_value': op.valid_from.isoformat() if op.valid_from else None,
'old_value': op.position.valid_from.isoformat() if op.position.valid_from else None,
})
op.position.valid_from = op.valid_from
op.position.save(update_fields=['valid_from'])
secret_dirty.add(op.position)
elif isinstance(op, self.ChangeValidUntilOperation):
self.order.log_action('pretix.event.order.changed.valid_until', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'new_value': op.valid_until.isoformat() if op.valid_until else None,
'old_value': op.position.valid_until.isoformat() if op.position.valid_until else None,
})
op.position.valid_until = op.valid_until
op.position.save(update_fields=['valid_until'])
secret_dirty.add(op.position)
elif isinstance(op, self.AddBlockOperation):
self.order.log_action('pretix.event.order.changed.add_block', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'block_name': op.block_name,
})
if op.position.blocked:
if op.block_name not in op.position.blocked:
op.position.blocked = op.position.blocked + [op.block_name]
else:
op.position.blocked = [op.block_name]
op.position.save(update_fields=['blocked'])
if op.position.blocked:
op.position.blocked_secrets.update_or_create(
event=self.event,
secret=op.position.secret,
defaults={
'blocked': True,
'updated': now(),
}
)
elif isinstance(op, self.RemoveBlockOperation):
self.order.log_action('pretix.event.order.changed.remove_block', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'block_name': op.block_name,
})
if op.position.blocked and op.block_name in op.position.blocked:
op.position.blocked = [b for b in op.position.blocked if b != op.block_name]
if not op.position.blocked:
op.position.blocked = None
op.position.save(update_fields=['blocked'])
if not op.position.blocked:
try:
bs = op.position.blocked_secrets.get(secret=op.position.secret)
bs.blocked = False
bs.save()
except BlockedTicketSecret.DoesNotExist:
pass
# todo: revoke list handling
for p in secret_dirty:
assign_ticket_secret(
event=self.event, position=p, force_invalidate=False, save=True
)
if split_positions:
self.split_order = self._create_split_order(split_positions)
+13
View File
@@ -2021,6 +2021,19 @@ your payment before {expire_date}.
You can view the payment information and the status of your order at
{url}
Best regards,
Your {event} team"""))
},
'mail_text_order_pending_warning': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
we did not yet receive a full payment for your order for {event}.
Please keep in mind that you are required to pay before {expire_date}.
You can view the payment information and the status of your order at
{url}
Best regards,
Your {event} team"""))
},
+13
View File
@@ -538,6 +538,19 @@ keyword argument will contain the event to **copy from**. The keyword arguments
in the new event of the respective types.
"""
orderposition_blocked_display = EventPluginSignal()
"""
Arguments: ``orderposition``, ``block_name``
To display the reason for a blocked ticket to a backend user,
``pretix.base.signals.orderposition_block_display`` will be sent out.
The first received response that is not ``None`` will be used to display the block
to the user. The receivers are expected to return plain text.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
item_copy_data = EventPluginSignal()
"""
Arguments: ``source``, ``target``
+6 -1
View File
@@ -1052,7 +1052,7 @@ class MailSettingsForm(SettingsForm):
"value is 0, the mail will never be sent.")
)
mail_text_order_expire_warning = I18nFormField(
label=_("Text"),
label=_("Text (if order will expire automatically)"),
required=False,
widget=I18nTextarea,
)
@@ -1061,6 +1061,11 @@ class MailSettingsForm(SettingsForm):
required=False,
widget=I18nTextInput,
)
mail_text_order_pending_warning = I18nFormField(
label=_("Text (if order will not expire automatically)"),
required=False,
widget=I18nTextarea,
)
mail_subject_order_pending_warning = I18nFormField(
label=_("Subject (if order will not expire automatically)"),
required=False,
+3
View File
@@ -210,6 +210,7 @@ class OrderFilterForm(FilterForm):
('', _('All orders')),
(_('Valid orders'), (
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
(Order.STATUS_PAID + 'v', _('Paid or confirmed')),
(Order.STATUS_PENDING, _('Pending')),
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
)),
@@ -296,6 +297,8 @@ class OrderFilterForm(FilterForm):
qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'ne':
qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
elif s == 'pv':
qs = qs.filter(Q(status=Order.STATUS_PAID) | Q(status=Order.STATUS_PENDING, valid_if_pending=True))
elif s in ('p', 'n', 'e', 'c', 'r'):
qs = qs.filter(status=s)
elif s == 'overpaid':
+1 -1
View File
@@ -387,7 +387,6 @@ class ItemCreateForm(I18nModelForm):
'show_quota_left',
'hidden_if_available',
'require_bundling',
'checkin_attention',
'require_membership',
'grant_membership_type',
'grant_membership_duration_like_event',
@@ -800,6 +799,7 @@ class ItemVariationForm(I18nModelForm):
'require_membership',
'require_membership_hidden',
'require_membership_types',
'checkin_attention',
'available_from',
'available_until',
'sales_channels',
+41 -10
View File
@@ -68,12 +68,6 @@ from pretix.helpers.money import change_decimal_field
class ExtendForm(I18nModelForm):
quota_ignore = forms.BooleanField(
label=_('Overbook quota'),
help_text=_('If you check this box, this operation will be performed even if it leads to an overbooked quota '
'and you having sold more tickets than you planned!'),
required=False
)
expires = forms.DateField(
label=_("Expiration date"),
widget=forms.DateInput(attrs={
@@ -81,16 +75,35 @@ class ExtendForm(I18nModelForm):
'data-is-payment-date': 'true'
}),
)
valid_if_pending = forms.BooleanField(
label=_('Confirm order regardless of payment'),
help_text=_('If you check this box, this order will behave like a paid order for most purposes, even though it '
'is not yet paid. This means that the customer can already download and use tickets regardless '
'of your event settings, and the order might be treated as paid by some plugins. If you check '
'this, this order will not be marked as "expired" automatically if the payment deadline arrives, '
'since we expect that you want to collect the amount somehow and not auto-cancel the order.'),
required=False
)
quota_ignore = forms.BooleanField(
label=_('Overbook quota'),
help_text=_('If you check this box, this operation will be performed even if it leads to an overbooked quota '
'and you having sold more tickets than you planned!'),
required=False
)
class Meta:
model = Order
fields = []
def __init__(self, *args, **kwargs):
kwargs.setdefault('initial', {})
kwargs['initial'].setdefault('valid_if_pending', kwargs['instance'].valid_if_pending)
kwargs['initial'].setdefault('expires', kwargs['instance'].expires)
super().__init__(*args, **kwargs)
if self.instance.status == Order.STATUS_PENDING or self.instance._is_still_available(now(),
count_waitinglist=False)\
is True:
if (
self.instance.status == Order.STATUS_PENDING or
self.instance._is_still_available(now(), count_waitinglist=False) is True
):
del self.fields['quota_ignore']
def clean(self):
@@ -435,6 +448,20 @@ class OrderPositionChangeForm(forms.Form):
localize=True,
label=_('New price (gross)')
)
blocked = forms.BooleanField(
required=False,
label=_('Ticket is blocked')
)
valid_from = SplitDateTimeField(
required=False,
widget=SplitDateTimePickerWidget,
label=_('Validity start')
)
valid_until = SplitDateTimeField(
required=False,
widget=SplitDateTimePickerWidget,
label=_('Validity end')
)
used_membership = forms.ChoiceField(
required=False,
)
@@ -466,6 +493,9 @@ class OrderPositionChangeForm(forms.Form):
initial = kwargs.get('initial', {})
initial['price'] = instance.price
initial['blocked'] = instance.blocked and "admin" in instance.blocked
initial['valid_from'] = instance.valid_from
initial['valid_until'] = instance.valid_until
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
@@ -756,7 +786,8 @@ class EventCancelForm(forms.Form):
label=_('Automatically refund money if possible'),
initial=True,
required=False,
help_text=_('Only available for payment method that support automatic refunds.')
help_text=_('Only available for payment method that support automatic refunds. Tickets that have been blocked '
'(manually or by a plugin) are not auto-canceled and you will need to deal with them manually.')
)
manual_refund = forms.BooleanField(
label=_('Create refund in the manual refund to-do list'),
+32 -1
View File
@@ -52,7 +52,7 @@ from pretix.base.models import (
Checkin, CheckinList, Event, ItemVariation, LogEntry, OrderPosition,
TaxRule,
)
from pretix.base.signals import logentry_display
from pretix.base.signals import logentry_display, orderposition_blocked_display
from pretix.base.templatetags.money import money_filter
OVERVIEW_BANLIST = [
@@ -162,6 +162,25 @@ def _display_order_changed(event: Event, logentry: LogEntry):
return text + ' ' + _('A new secret has been generated for position #{posid}.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.valid_from':
return text + ' ' + _('The validity start date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
'new_value') else ''
)
elif logentry.action_type == 'pretix.event.order.changed.valid_until':
return text + ' ' + _('The validity end date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else ''
)
elif logentry.action_type == 'pretix.event.order.changed.add_block':
return text + ' ' + _('A block has been added for position #{posid}.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.remove_block':
return text + ' ' + _('A block has been removed for position #{posid}.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.split':
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
@@ -351,6 +370,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'),
'pretix.event.order.expirychanged': _('The order\'s expiry date has been changed.'),
'pretix.event.order.valid_if_pending.set': _('The order has been set to be usable before it is paid.'),
'pretix.event.order.valid_if_pending.unset': _('The order has been set to require payment before use.'),
'pretix.event.order.expired': _('The order has been marked as expired.'),
'pretix.event.order.paid': _('The order has been marked as paid.'),
'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'),
@@ -376,6 +397,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.custom_followup_at': _('The order\'s follow-up date has been updated.'),
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
'toggled.'),
'pretix.event.order.pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if '
'unpaid has been toggled.'),
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
'pretix.event.order.email.error': _('Sending of an email has failed.'),
@@ -647,3 +670,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
if logentry.action_type == 'pretix.control.auth.user.impersonate_stopped':
return str(_('You stopped impersonating {}.')).format(data['other_email'])
@receiver(signal=orderposition_blocked_display, dispatch_uid="pretixcontrol_orderposition_blocked_display")
def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, block_name, **kwargs):
if block_name == 'admin':
return _('Blocked manually')
elif block_name.startswith('api:'):
return _('Blocked because of an API integration')
@@ -103,7 +103,7 @@
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_subject_order_changed,mail_text_order_changed" %}
{% blocktrans asvar title_payment_reminder %}Payment reminder{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_subject_order_expire_warning,mail_subject_order_pending_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_subject_order_expire_warning,mail_text_order_expire_warning,mail_subject_order_pending_warning,mail_text_order_pending_warning" exclude="mail_days_order_expire_warning" %}
{% blocktrans asvar title_waiting_list_notification %}Waiting list notification{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_subject_waiting_list,mail_text_waiting_list" %}
@@ -107,6 +107,7 @@
{% bootstrap_field form.require_membership_hidden layout="control" %}
</div>
{% endif %}
{% bootstrap_field form.checkin_attention layout="control" %}
</div>
</details>
{% endfor %}
@@ -204,6 +205,7 @@
{% bootstrap_field formset.empty_form.require_membership_hidden layout="control" %}
</div>
{% endif %}
{% bootstrap_field formset.empty_form.checkin_attention layout="control" %}
</div>
</details>
{% endescapescript %}
@@ -18,6 +18,7 @@
<select name="status" class="form-control">
<option value="" {% if request.GET.status == "" %}selected="selected"{% endif %}>{% trans "All orders" %}</option>
<option value="p" {% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "Paid" %}</option>
<option value="pv" {% if request.GET.status == "pv" %}selected="selected"{% endif %}>{% trans "Paid or confirmed" %}</option>
<option value="n" {% if request.GET.status == "n" %}selected="selected"{% endif %}>{% trans "Pending" %}</option>
<option value="np" {% if request.GET.status == "np" or "status" not in request.GET %}selected="selected"{% endif %}>{% trans "Pending or paid" %}</option>
<option value="o" {% if request.GET.status == "o" %}selected="selected"{% endif %}>{% trans "Pending (overdue)" %}</option>
@@ -189,6 +189,55 @@
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Ticket block" %}</strong>
</div>
<div class="col-sm-5">
{% if "admin" in position.blocked %}
{% trans "Blocked" %}
{% elif position.blocked %}
{% trans "Blocked due to external constraints" %}
{% else %}
{% trans "Not blocked" %}
{% endif %}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.blocked layout='inline' %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Validity time" %}</strong>
</div>
<div class="col-sm-5">
{% if position.valid_from %}
{% blocktrans trimmed with datetime=position.valid_from|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime }}
{% endblocktrans %}
{% endif %}
{% if position.valid_until %}
{% if position.valid_from %}
<br />
{% endif %}
{% blocktrans trimmed with datetime=position.valid_from|date:"SHORT_DATETIME_FORMAT" %}
Valid until {{ datetime }}
{% endblocktrans %}
{% endif %}
{% if not position.valid_from and not position.valid_until %}
{% trans "Unconstrained" %}
{% endif %}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.valid_from layout='inline' %}
<div class="text-center">
{% trans "" %}
</div>
{% bootstrap_field position.form.valid_until layout='inline' %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Ticket secret" %}</strong>
@@ -194,7 +194,10 @@
<dt>{% trans "Expiry date" %}</dt>
<dd>
{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}
{% if has_cancellation_fee and request.event.settings.payment_term_expire_automatically %}
{% if order.valid_if_pending %}
<span class="fa fa-warning text-danger" data-toggle="tooltip"
title="{% trans "This order will not expire automatically since it is already confirmed and can be used." %}"></span>
{% elif has_cancellation_fee and request.event.settings.payment_term_expire_automatically %}
<span class="fa fa-warning text-danger" data-toggle="tooltip"
title="{% trans "This order will not expire automatically as it has an open cancellation fee." %}"></span>
{% endif %}
@@ -390,6 +393,15 @@
{% endif %}
{% endfor %}
{% endif %}
{% if line.blocked %}
<br />
<strong class="text-danger">
<span class="fa fa-ban fa-fw text-danger"></span>
{% for k, block_reason in line.blocked_reasons.items %}
{{ block_reason }}
{% endfor %}
</strong>
{% endif %}
{% if line.seat %}
<br />
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="14" viewBox="0 0 4.7624999 3.7041668" class="svg-icon">
@@ -429,6 +441,26 @@
</a>
</span>
{% endif %}
{% if line.valid_from or line.valid_until %}
<div class="cart-icon-details">
<dd>
<span class="fa fa-clock-o fa-fw" aria-hidden="true"></span>
{% if line.valid_from and line.valid_until %}
{% blocktrans trimmed with datetime_from=line.valid_from|date:"SHORT_DATETIME_FORMAT" datetime_until=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime_from }} until {{ datetime_until }}
{% endblocktrans %}
{% elif line.valid_from %}
{% blocktrans trimmed with datetime=line.valid_from|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime }}
{% endblocktrans %}
{% elif line.valid_until %}
{% blocktrans trimmed with datetime=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid until {{ datetime }}
{% endblocktrans %}
{% endif %}
</dd>
</div>
{% endif %}
{% if not line.canceled %}
<div class="position-buttons">
{% if line.generate_ticket %}
@@ -6,6 +6,11 @@
<span class="fa fa-question-circle"></span>
{% trans "Approval pending" %}
</span>
{% elif order.valid_if_pending %}
<span data-toggle="tooltip" class="label label-info {{ class }}">
<span class="fa fa-money"></span>
{% trans "Pending (confirmed)" context "order state" %}
</span>
{% else %}
<span data-toggle="tooltip" title="{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}"
class="label label-warning {{ class }}">
+15 -4
View File
@@ -36,7 +36,7 @@ import dateutil.parser
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Exists, Max, OuterRef, Prefetch, Subquery
from django.db.models import Exists, Max, OuterRef, Prefetch, Q, Subquery
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
@@ -85,9 +85,16 @@ class CheckInListQueryMixin:
m=Max('datetime')
).values('m')
if self.list.include_pending:
status_q = Q(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING])
else:
status_q = Q(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
qs = OrderPosition.objects.filter(
status_q,
order__event=self.request.event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.list.include_pending else [Order.STATUS_PAID],
).annotate(
last_entry=Subquery(cqs),
last_exit=Subquery(cqs_exit),
@@ -199,7 +206,9 @@ class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMi
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request):
raise PermissionDenied()
for op in positions:
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
if op.order.status == Order.STATUS_PAID or (
(self.list.include_pending or op.order.valid_if_pending) and op.order.status == Order.STATUS_PENDING
):
Checkin.objects.filter(position=op, list=self.list).delete()
op.order.log_action('pretix.event.checkin.reverted', data={
'position': op.id,
@@ -213,7 +222,9 @@ class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMi
else:
t = Checkin.TYPE_EXIT if request.POST.get('checkout') == 'true' else Checkin.TYPE_ENTRY
for op in positions:
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
if op.order.status == Order.STATUS_PAID or (
(self.list.include_pending or op.order.valid_if_pending) and op.order.status == Order.STATUS_PENDING
):
lci = op.checkins.filter(list=self.list).first()
if self.list.allow_multiple_entries or t != Checkin.TYPE_ENTRY or (lci and lci.type != Checkin.TYPE_ENTRY):
ci = Checkin.objects.create(position=op, list=self.list, datetime=now(), type=t)
+5
View File
@@ -629,6 +629,11 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
orderposition__order__expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == 'np':
qs = qs.filter(orderposition__order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'pv':
qs = qs.filter(
Q(orderposition__order__status=Order.STATUS_PAID) |
Q(orderposition__order__status=Order.STATUS_PENDING, orderposition__order__valid_if_pending=True)
)
elif s == 'ne':
qs = qs.filter(orderposition__order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:
+12
View File
@@ -1520,6 +1520,7 @@ class OrderExtend(OrderView):
self.order,
new_date=self.form.cleaned_data.get('expires'),
force=self.form.cleaned_data.get('quota_ignore', False),
valid_if_pending=self.form.cleaned_data.get('valid_if_pending', False),
user=self.request.user
)
messages.success(self.request, _('The payment term has been changed.'))
@@ -1773,6 +1774,17 @@ class OrderChange(OrderView):
if p.form.cleaned_data['tax_rule'] and p.form.cleaned_data['tax_rule'] != p.tax_rule:
ocm.change_tax_rule(p, p.form.cleaned_data['tax_rule'])
if p.form.cleaned_data["blocked"] and "admin" not in (p.blocked or []):
ocm.add_block(p, "admin")
elif not p.form.cleaned_data["blocked"] and "admin" in (p.blocked or []):
ocm.remove_block(p, "admin")
if p.form.cleaned_data['valid_from'] != p.valid_from:
ocm.change_valid_from(p, p.form.cleaned_data['valid_from'])
if p.form.cleaned_data['valid_until'] != p.valid_until:
ocm.change_valid_until(p, p.form.cleaned_data['valid_until'])
if p.form.cleaned_data.get('operation_split'):
ocm.split(p)
+1 -1
View File
@@ -340,7 +340,7 @@ class BadgeExporter(BaseExporter):
if form_data.get('include_pending'):
qs = qs.filter(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING])
else:
qs = qs.filter(order__status__in=[Order.STATUS_PAID])
qs = qs.filter(Q(order__status=Order.STATUS_PAID) | Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True))
if form_data.get('date_from'):
dt = make_aware(datetime.combine(
+28 -7
View File
@@ -216,10 +216,13 @@ class CheckInListMixin(BaseExporter):
)
if form_data.get('attention_only'):
qs = qs.filter(Q(item__checkin_attention=True) | Q(order__checkin_attention=True))
qs = qs.filter(Q(item__checkin_attention=True) | Q(order__checkin_attention=True) | Q(variation__checkin_attention=True))
if not cl.include_pending:
qs = qs.filter(order__status=Order.STATUS_PAID)
qs = qs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
else:
qs = qs.filter(order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING))
@@ -363,12 +366,15 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
)
if op.seat:
item += '<br/>' + str(op.seat)
name = bleach.clean(str(name), tags=['br']).strip().replace('<br>', '<br/>')
if op.blocked:
name = '<font face="OpenSansBd">[' + _('Blocked') + ']</font> ' + name
row = [
'!!' if op.item.checkin_attention or op.order.checkin_attention else '',
CBFlowable(bool(op.last_checked_in)),
'!!' if op.require_checkin_attention else '',
CBFlowable(bool(op.last_checked_in)) if not op.blocked else '',
'' if op.order.status != Order.STATUS_PAID else '',
op.order.code,
Paragraph(bleach.clean(str(name), tags=['br']).strip().replace('<br>', '<br/>'), self.get_style()),
Paragraph(name, self.get_style()),
Paragraph(bleach.clean(str(item), tags=['br']).strip().replace('<br>', '<br/>'), self.get_style()),
]
acache = {}
@@ -401,6 +407,12 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
('TEXTCOLOR', (2, len(tdata)), (2, len(tdata)), '#ffffff'),
('ALIGN', (2, len(tdata)), (2, len(tdata)), 'CENTER'),
]
if op.blocked:
tstyledata += [
('BACKGROUND', (1, len(tdata)), (1, len(tdata)), '#990000'),
('TEXTCOLOR', (1, len(tdata)), (1, len(tdata)), '#ffffff'),
('ALIGN', (1, len(tdata)), (1, len(tdata)), 'CENTER'),
]
tdata.append(row)
table = Table(tdata, colWidths=colwidths, repeatRows=1)
@@ -440,7 +452,10 @@ class CSVCheckinList(CheckInListMixin, ListExporter):
_('Product'), _('Price'), _('Checked in'), _('Checked out'), _('Automatically checked in')
]
if not cl.include_pending:
qs = qs.filter(order__status=Order.STATUS_PAID)
qs = qs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
else:
qs = qs.filter(order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING))
headers.append(_('Paid'))
@@ -470,6 +485,9 @@ class CSVCheckinList(CheckInListMixin, ListExporter):
headers.append(_('Seat zone'))
headers.append(_('Seat row'))
headers.append(_('Seat number'))
headers.append(_('Blocked'))
headers.append(_('Valid from'))
headers.append(_('Valid until'))
headers += [
_('Address'),
_('ZIP code'),
@@ -565,7 +583,7 @@ class CSVCheckinList(CheckInListMixin, ListExporter):
row.append(op.voucher.code if op.voucher else "")
row.append(op.order.datetime.astimezone(self.event.timezone).strftime('%Y-%m-%d'))
row.append(op.order.datetime.astimezone(self.event.timezone).strftime('%H:%M:%S'))
row.append(_('Yes') if op.order.checkin_attention or op.item.checkin_attention else _('No'))
row.append(_('Yes') if op.require_checkin_attention else _('No'))
row.append(op.order.comment or "")
if op.seat:
@@ -580,6 +598,9 @@ class CSVCheckinList(CheckInListMixin, ListExporter):
row += ['', '', '', '', '']
row += [
_('Yes') if op.blocked else '',
date_format(op.valid_from, 'SHORT_DATETIME_FORMAT') if op.valid_from else '',
date_format(op.valid_until, 'SHORT_DATETIME_FORMAT') if op.valid_until else '',
op.street or '',
op.zipcode or '',
op.city or '',
+4 -2
View File
@@ -243,7 +243,8 @@ class OrderMailForm(BaseMailForm):
self.fields['recipients'].choices = recp_choices
choices = [(e, l) for e, l in Order.STATUS_CHOICE if e != 'n']
choices.insert(0, ('na', _('payment pending (except unapproved)')))
choices.insert(0, ('valid_if_pending', _('payment pending but already confirmed')))
choices.insert(0, ('na', _('payment pending (except unapproved or already confirmed)')))
choices.insert(0, ('pa', _('approval pending')))
if not event.settings.get('payment_term_expire_automatically', as_type=bool):
choices.append(
@@ -257,10 +258,11 @@ class OrderMailForm(BaseMailForm):
choices=choices
)
if not self.initial.get('sendto'):
self.initial['sendto'] = ['p', 'na']
self.initial['sendto'] = ['p', 'na', 'valid_if_pending']
elif 'n' in self.initial['sendto']:
self.initial['sendto'].append('pa')
self.initial['sendto'].append('na')
self.initial['sendto'].append('valid_if_pending')
self.fields['items'].queryset = event.items.all()
if not self.initial.get('items'):
+9 -3
View File
@@ -22,7 +22,7 @@
from datetime import datetime, time, timedelta
from django.db import models
from django.db.models import Exists, OuterRef
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from django.utils.formats import date_format
from django.utils.timezone import make_aware
@@ -118,7 +118,13 @@ class ScheduledMail(models.Model):
Exists(OrderPosition.objects.filter(order=OuterRef('pk'), item_id__in=limit_products))
)
status = [Order.STATUS_PENDING, Order.STATUS_PAID] if self.rule.include_pending else [Order.STATUS_PAID]
if self.rule.include_pending:
status_q = Q(status__in=[Order.STATUS_PAID, Order.STATUS_PENDING])
else:
status_q = Q(
Q(status=Order.STATUS_PAID) |
Q(status=Order.STATUS_PENDING, valid_if_pending=True)
)
if self.last_successful_order_id:
orders = orders.filter(
@@ -126,7 +132,7 @@ class ScheduledMail(models.Model):
)
orders = orders.filter(
status__in=status,
status_q,
require_approval=False,
).order_by('pk').select_related('invoice_address').prefetch_related('positions')
+5 -2
View File
@@ -264,7 +264,8 @@ class OrderSendView(BaseSenderView):
if 'status' not in _cache_store:
status = dict(Order.STATUS_CHOICE)
status['overdue'] = _('pending with payment overdue')
status['na'] = _('payment pending (except unapproved)')
status['valid_if_pending'] = _('payment pending but already confirmed')
status['na'] = _('payment pending (except unapproved or already confirmed)')
status['pa'] = _('approval pending')
status['r'] = status['c']
_cache_store['status'] = status
@@ -346,7 +347,9 @@ class OrderSendView(BaseSenderView):
if 'pa' in form.cleaned_data['sendto']:
statusq |= Q(status=Order.STATUS_PENDING, require_approval=True)
if 'na' in form.cleaned_data['sendto']:
statusq |= Q(status=Order.STATUS_PENDING, require_approval=False)
statusq |= Q(status=Order.STATUS_PENDING, require_approval=False, valid_if_pending=False)
if 'valid_if_pending' in form.cleaned_data['sendto']:
statusq |= Q(status=Order.STATUS_PENDING, require_approval=False, valid_if_pending=True)
orders = qs.filter(statusq)
opq = OrderPosition.objects.filter(
@@ -127,7 +127,7 @@ class AllTicketsPDF(BaseExporter):
if form_data.get('include_pending'):
qs = qs.filter(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING])
else:
qs = qs.filter(order__status__in=[Order.STATUS_PAID])
qs = qs.filter(Q(order__status=Order.STATUS_PAID) | Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True))
if form_data.get('date_range'):
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
@@ -58,6 +58,8 @@ window.vapp = new Vue({
'result.unpaid': gettext('Ticket not paid'),
'result.rules': gettext('Entry not allowed'),
'result.revoked': gettext('Ticket code revoked/changed'),
'result.blocked': gettext('Ticket blocked'),
'result.invalid_time': gettext('Ticket not valid at this time'),
'result.canceled': gettext('Order canceled'),
'result.ambiguous': gettext('Ticket code is ambiguous on list'),
'status.checkin': gettext('Checked-in Tickets'),
@@ -29,7 +29,7 @@
{{ line.variation }}
{% endif %}
</p>
{% if line.seat or line.voucher or line.subevent or line.used_membership%}
{% if line.seat or line.voucher or line.subevent or line.used_membership or line.blocked or line.valid_from or line.valid_until %}
<dl class="dl-inline">
{% elif event.settings.show_checkin_number_user and line.checkin_count %}
<dl class="dl-inline">
@@ -96,6 +96,36 @@
</dd>
</div>
{% endif %}
{% if line.valid_from or line.valid_until %}
<div class="cart-icon-details">
<dd>
<span class="fa fa-clock-o fa-fw" aria-hidden="true"></span>
{% if line.valid_from and line.valid_until %}
{% blocktrans trimmed with datetime_from=line.valid_from|date:"SHORT_DATETIME_FORMAT" datetime_until=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime_from }} until {{ datetime_until }}
{% endblocktrans %}
{% elif line.valid_from %}
{% blocktrans trimmed with datetime=line.valid_from|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime }}
{% endblocktrans %}
{% elif line.valid_until %}
{% blocktrans trimmed with datetime=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid until {{ datetime }}
{% endblocktrans %}
{% endif %}
</dd>
</div>
{% endif %}
{% if line.blocked %}
<div class="cart-icon-details">
<dd class="text-danger">
<span class="fa fa-ban fa-fw text-danger" aria-hidden="true"></span>
<strong>
{% trans "This ticket is blocked." %}
</strong>
</dd>
</div>
{% endif %}
{% if event.settings.show_checkin_number_user and line.checkin_count %}
<div class="cart-icon-details">
<dt class="sr-only">{% trans "Usage:" context "ticket_checkins" %}</dt>
@@ -111,7 +141,7 @@
</dd>
</div>
{% endif %}
{% if line.seat or line.voucher or line.subevent or line.used_membership%}
{% if line.seat or line.voucher or line.subevent or line.used_membership or line.blocked or line.valid_from or line.valid_until %}
</dl>
{% elif event.settings.show_checkin_number_user and line.checkin_count %}
</dl>
@@ -7,11 +7,13 @@
{% trans "Confirmation pending" context "order state" %}
{% elif event.settings.payment_pending_hidden %}
{# intentionally left blank #}
{% elif order.valid_if_pending %}
{% trans "Confirmed" context "order state" %}
{% else %}
{% trans "Payment pending" %}
{% endif %}
{% if not event.settings.payment_pending_hidden %}
<i class="status-dot fa fa-circle text-warning" aria-hidden="true"></i>
<i class="status-dot fa fa-circle {% if order.valid_if_pending %}text-info{% else %}text-warning{% endif %}" aria-hidden="true"></i>
{% endif %}
{% elif order.status == "p" %}
{% if order.count_positions == 0 %}
+9
View File
@@ -131,6 +131,9 @@ TEST_ORDERPOSITION1_RES = {
"country": None,
"state": None,
"subevent": None,
"valid_from": None,
"valid_until": None,
"blocked": None,
"pseudonymization_id": "ABCDEFGHKL",
}
@@ -163,6 +166,9 @@ TEST_ORDERPOSITION2_RES = {
"country": None,
"state": None,
"subevent": None,
"valid_from": None,
"valid_until": None,
"blocked": None,
"pseudonymization_id": "BACDEFGHKL",
}
@@ -195,6 +201,9 @@ TEST_ORDERPOSITION3_RES = {
"country": None,
"state": None,
"subevent": None,
"valid_from": None,
"valid_until": None,
"blocked": None,
"pseudonymization_id": "FOOBAR12345",
}
+3
View File
@@ -168,6 +168,9 @@ TEST_ORDERPOSITION1_RES = {
"country": None,
"state": None,
"subevent": None,
"valid_from": None,
"valid_until": None,
"blocked": None,
"pseudonymization_id": "ABCDEFGHKL",
}
+4
View File
@@ -378,6 +378,7 @@ def test_item_detail_variations(token_client, organizer, event, team, item):
"active": True,
"description": None,
"position": 0,
"checkin_attention": False,
"require_approval": False,
"require_membership": False,
"require_membership_hidden": False,
@@ -551,6 +552,7 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego
},
"active": True,
"require_approval": True,
"checkin_attention": False,
"require_membership": False,
"require_membership_hidden": False,
"require_membership_types": [],
@@ -1291,6 +1293,7 @@ TEST_VARIATIONS_RES = {
"position": 0,
"default_price": None,
"price": "23.00",
"checkin_attention": False,
"require_approval": False,
"require_membership": False,
"require_membership_hidden": False,
@@ -1311,6 +1314,7 @@ TEST_VARIATIONS_UPDATE = {
"description": None,
"position": 1,
"default_price": "20.0",
"checkin_attention": False,
"require_approval": False,
"require_membership": False,
"require_membership_hidden": False,
+83
View File
@@ -255,6 +255,7 @@ def test_order_update_allowed_fields(token_client, organizer, event, order):
organizer.slug, event.slug, order.code
), format='json', data={
'comment': 'Here is a comment',
'valid_if_pending': True,
'custom_followup_at': '2021-06-12',
'checkin_attention': True,
'email': 'foo@bar.com',
@@ -283,6 +284,7 @@ def test_order_update_allowed_fields(token_client, organizer, event, order):
assert order.email == 'foo@bar.com'
assert order.phone == '+4962219999'
assert order.locale == 'de'
assert order.valid_if_pending
assert order.invoice_address.company == "This is my company name"
assert order.invoice_address.name_cached == "John Doe"
assert order.invoice_address.name_parts == {'_legacy': 'John Doe'}
@@ -540,6 +542,64 @@ def test_position_regenerate_secrets(token_client, organizer, event, order):
assert ps != p.secret
@pytest.mark.django_db
def test_position_manage_blocks(token_client, organizer, event, order):
with scopes_disabled():
p = order.positions.first()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/add_block/'.format(
organizer.slug, event.slug, p.pk,
), format='json', data={
'name': 'invalid'
}
)
assert resp.status_code == 400
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/add_block/'.format(
organizer.slug, event.slug, p.pk,
), format='json', data={
'name': 'admin'
}
)
assert resp.status_code == 200
p.refresh_from_db()
assert p.blocked == ['admin']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/add_block/'.format(
organizer.slug, event.slug, p.pk,
), format='json', data={
'name': 'api:custom'
}
)
assert resp.status_code == 200
p.refresh_from_db()
assert p.blocked == ['admin', 'api:custom']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/remove_block/'.format(
organizer.slug, event.slug, p.pk,
), format='json', data={
'name': 'api:custom'
}
)
assert resp.status_code == 200
p.refresh_from_db()
assert p.blocked == ['admin']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/remove_block/'.format(
organizer.slug, event.slug, p.pk,
), format='json', data={
'name': 'admin'
}
)
assert resp.status_code == 200
p.refresh_from_db()
assert p.blocked is None
@pytest.mark.django_db
def test_order_resend_link(token_client, organizer, event, order):
djmail.outbox = []
@@ -1614,6 +1674,8 @@ def test_position_add_and_set_info(token_client, organizer, event, order, questi
'order': order.code,
'item': item.pk,
'attendee_name': 'John Doe',
'valid_from': '2022-12-12T12:12:12+00:00',
'valid_until': '2022-12-12T13:12:12+00:00',
'answers': [
{
'question': question.pk,
@@ -1635,6 +1697,27 @@ def test_position_add_and_set_info(token_client, organizer, event, order, questi
assert op.positionid == 3
assert op.attendee_name == 'John Doe'
assert op.answers.count() == 1
assert op.valid_from.isoformat() == '2022-12-12T12:12:12+00:00'
assert op.valid_until.isoformat() == '2022-12-12T13:12:12+00:00'
@pytest.mark.django_db
def test_position_update_validity(token_client, organizer, event, order, quota, item, subevent):
with scopes_disabled():
op = order.positions.get()
payload = {
'valid_from': '2022-12-12T12:12:12+00:00',
'valid_until': '2022-12-12T13:12:12+00:00',
}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format(
organizer.slug, event.slug, op.pk
), format='json', data=payload
)
assert resp.status_code == 200
op.refresh_from_db()
assert op.valid_from.isoformat() == '2022-12-12T12:12:12+00:00'
assert op.valid_until.isoformat() == '2022-12-12T13:12:12+00:00'
@pytest.mark.django_db
+8 -2
View File
@@ -153,6 +153,7 @@ ORDER_CREATE_PAYLOAD = {
"phone": "+49622112345",
"locale": "en",
"sales_channel": "web",
"valid_if_pending": True,
"fees": [
{
"fee_type": "payment",
@@ -223,6 +224,7 @@ def test_order_create(token_client, organizer, event, item, quota, question):
assert o.total == Decimal('23.25')
assert o.status == Order.STATUS_PENDING
assert o.sales_channel == "web"
assert o.valid_if_pending
assert not o.testmode
with scopes_disabled():
@@ -295,6 +297,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'datetime': None,
'payment_date': None,
'payment_provider': None,
'valid_if_pending': True,
'fees': [
{
'id': 0,
@@ -345,6 +348,9 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'discount': None,
'checkins': [],
'downloads': [],
"valid_from": None,
"valid_until": None,
"blocked": None,
'answers': [
{'question': question.pk, 'answer': 'L', 'question_identifier': 'ABC',
'options': [opt.pk],
@@ -413,13 +419,13 @@ def test_order_create_positionids_addons_simulated(token_client, organizer, even
'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None,
'voucher': None, 'tax_rate': '0.00', 'tax_value': '0.00', 'discount': None,
'addon_to': None, 'subevent': None, 'checkins': [], 'downloads': [], 'answers': [], 'tax_rule': None,
'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False},
'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None},
{'id': 0, 'order': '', 'positionid': 2, 'item': item.pk, 'variation': None, 'price': '23.00',
'attendee_name': 'Peter', 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, 'company': None,
'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None,
'voucher': None, 'tax_rate': '0.00', 'tax_value': '0.00', 'discount': None,
'addon_to': 1, 'subevent': None, 'checkins': [], 'downloads': [], 'answers': [], 'tax_rule': None,
'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False}
'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None}
]
+20
View File
@@ -173,6 +173,9 @@ TEST_ORDERPOSITION_RES = {
"city": None,
"country": None,
"state": None,
"valid_from": None,
"valid_until": None,
"blocked": None,
"answers": [
{
"question": 1,
@@ -271,6 +274,7 @@ TEST_ORDER_RES = {
"vat_id_validated": True
},
"require_approval": False,
"valid_if_pending": False,
"positions": [TEST_ORDERPOSITION_RES],
"downloads": [],
"payments": TEST_PAYMENTS_RES,
@@ -1752,6 +1756,22 @@ def test_revoked_secret_list(token_client, organizer, event):
assert [res] == resp.data['results']
@pytest.mark.django_db
def test_blocked_secret_list(token_client, organizer, event):
r = event.blocked_secrets.create(secret="abcd", blocked=True)
res = {
"id": r.id,
"secret": "abcd",
"blocked": True,
"updated": r.updated.isoformat().replace("+00:00", "Z")
}
resp = token_client.get('/api/v1/organizers/{}/events/{}/blockedsecrets/'.format(
organizer.slug, event.slug,
))
assert resp.status_code == 200
assert [res] == resp.data['results']
@pytest.mark.django_db
def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_queries):
# order detail
+2
View File
@@ -61,6 +61,8 @@ event_permission_sub_urls = [
('patch', 'can_change_event_settings', 'settings/', 200),
('get', 'can_view_orders', 'revokedsecrets/', 200),
('get', 'can_view_orders', 'revokedsecrets/1/', 404),
('get', 'can_view_orders', 'blockedsecrets/', 200),
('get', 'can_view_orders', 'blockedsecrets/1/', 404),
('get', 'can_view_orders', 'orders/', 200),
('get', 'can_view_orders', 'orderpositions/', 200),
('delete', 'can_change_orders', 'orderpositions/1/', 404),
+54
View File
@@ -98,6 +98,42 @@ class EventCancelTests(TestCase):
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@classscope(attr='o')
def test_cancel_auto_refund_skip_blocked(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
p1 = self.order.payments.create(
amount=Decimal('46.00'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='giftcard',
info='{"gift_card": %d}' % gc.pk
)
self.order.status = Order.STATUS_PAID
self.order.save()
self.op1.blocked = ["admin"]
self.op1.save()
cancel_event(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
self.op1.refresh_from_db()
assert not self.op1.canceled
self.op2.refresh_from_db()
assert self.op2.canceled
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
assert r.amount == Decimal('23.00')
assert r.source == OrderRefund.REFUND_SOURCE_ADMIN
assert r.payment == p1
assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists()
assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()
assert gc.value == Decimal('23.00')
@classscope(attr='o')
def test_cancel_auto_refund(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
@@ -524,6 +560,24 @@ class SubEventCancelTests(TestCase):
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@classscope(attr='o')
def test_cancel_skip_blocked(self):
self.op2.subevent = self.se1
self.op2.blocked = ["admin"]
self.op2.save()
cancel_event(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
self.op1.refresh_from_db()
assert self.op1.canceled
self.op2.refresh_from_db()
assert not self.op2.canceled
@classscope(attr='o')
def test_cancel_all_subevents(self):
cancel_event(
+69 -1
View File
@@ -115,6 +115,50 @@ def test_checkin_canceled_position(position, clist):
assert position.checkins.count() == 0
@pytest.mark.django_db
def test_checkin_blocked_position(position, clist):
position.blocked = ["admin"]
position.save()
with pytest.raises(CheckInError) as excinfo:
perform_checkin(position, clist, {})
assert excinfo.value.code == 'blocked'
assert position.checkins.count() == 0
with pytest.raises(CheckInError) as excinfo:
perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT)
assert excinfo.value.code == 'blocked'
assert position.checkins.count() == 0
@pytest.mark.django_db
def test_checkin_valid_from(event, position, clist):
position.valid_from = event.timezone.localize(datetime(2020, 1, 1, 12, 0, 0))
position.save()
with freeze_time("2020-01-01 10:45:00"):
with pytest.raises(CheckInError) as excinfo:
perform_checkin(position, clist, {})
assert excinfo.value.code == 'invalid_time'
assert excinfo.value.reason == 'This ticket is only valid after 2020-01-01 12:00.'
assert position.checkins.count() == 0
perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT)
assert position.checkins.count() == 1
@pytest.mark.django_db
def test_checkin_valid_until(event, position, clist):
position.valid_until = event.timezone.localize(datetime(2020, 1, 1, 9, 0, 0))
position.save()
with freeze_time("2020-01-01 10:45:00"):
with pytest.raises(CheckInError) as excinfo:
perform_checkin(position, clist, {})
assert excinfo.value.code == 'invalid_time'
assert excinfo.value.reason == 'This ticket was only valid before 2020-01-01 09:00.'
assert position.checkins.count() == 0
perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT)
assert position.checkins.count() == 1
@pytest.mark.django_db
def test_checkin_invalid_product(position, clist):
clist.all_products = False
@@ -162,6 +206,30 @@ def test_unpaid(position, clist):
assert excinfo.value.code == 'unpaid'
@pytest.mark.django_db
def test_unpaid_but_valid(position, clist):
o = position.order
o.status = Order.STATUS_PENDING
o.valid_if_pending = True
o.save()
clist.include_pending = False
clist.save()
perform_checkin(position, clist, {})
@pytest.mark.django_db
def test_require_approval(position, clist):
o = position.order
o.status = Order.STATUS_PENDING
o.require_approval = True
o.save()
clist.include_pending = False
clist.save()
with pytest.raises(CheckInError) as excinfo:
perform_checkin(position, clist, {}, ignore_unpaid=True)
assert excinfo.value.code == 'unpaid'
@pytest.mark.django_db
def test_unpaid_include_pending_ignore(position, clist):
o = position.order
@@ -173,7 +241,7 @@ def test_unpaid_include_pending_ignore(position, clist):
@pytest.mark.django_db
def test_unpaid_ignore_without_include_pendung(position, clist):
def test_unpaid_ignore_without_include_pending(position, clist):
o = position.order
o.status = Order.STATUS_PENDING
o.save()
+12 -1
View File
@@ -1310,7 +1310,7 @@ class OrderTestCase(BaseQuotaTestCase):
assert self.order.user_cancel_allowed
@classscope(attr='o')
def test_can_cancel_checked_in(self):
def test_can_not_cancel_checked_in(self):
self.order.status = Order.STATUS_PAID
self.order.save()
self.event.settings.cancel_allow_user = False
@@ -1322,6 +1322,17 @@ class OrderTestCase(BaseQuotaTestCase):
)
assert not self.order.user_cancel_allowed
@classscope(attr='o')
def test_can_not_cancel_blocked(self):
self.order.status = Order.STATUS_PAID
self.order.save()
self.event.settings.cancel_allow_user = False
self.event.settings.cancel_allow_user_paid = True
assert self.order.user_cancel_allowed
self.op1.blocked = ["admin"]
self.op1.save()
assert not self.order.user_cancel_allowed
@classscope(attr='o')
def test_can_cancel_order_multiple(self):
item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23,
+13
View File
@@ -679,6 +679,19 @@ def test_import_seat(user, event, item):
assert not s3.is_available()
@pytest.mark.django_db
@scopes_disabled()
def test_import_validity(user, event, item):
settings = dict(DEFAULT_SETTINGS)
settings['item'] = 'static:{}'.format(item.pk)
settings['valid_until'] = 'csv:J'
import_orders.apply(
args=(event.pk, inputfile_factory().id, settings, 'en', user.pk)
).get()
assert OrderPosition.objects.first().valid_until.isoformat() == '2021-06-28T11:00:00+00:00'
@pytest.mark.django_db
@scopes_disabled()
def test_import_subevent_invalid(user, event, item):
+68
View File
@@ -44,6 +44,7 @@ from pretix.base.payment import (
FreeOrderProvider, GiftCardPayment, PaymentException,
)
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services.invoices import generate_invoice
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _create_order, approve_order, cancel_order,
@@ -567,6 +568,17 @@ class PaymentReminderTests(TestCase):
self.event.settings.mail_days_order_expire_warning = 10
send_expiry_warnings(sender=self.event)
assert len(djmail.outbox) == 1
assert "only guarantee your order" in djmail.outbox[0].body
@classscope(attr='o')
def test_sent_no_expiry(self):
self.order.valid_if_pending = True
self.order.save()
self.event.settings.mail_days_order_expire_warning = 10
send_expiry_warnings(sender=self.event)
assert len(djmail.outbox) == 1
assert "only guarantee your order" not in djmail.outbox[0].body
assert "required to pay" in djmail.outbox[0].body
@classscope(attr='o')
def test_sent_not_immediately_after_purchase(self):
@@ -2904,6 +2916,62 @@ class OrderChangeManagerTests(TestCase):
assert self.op1.used_membership is None
assert self.op1.item == self.ticket
@classscope(attr='o')
def test_add_block(self):
self.ocm.add_block(self.op1, "admin")
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.blocked == ["admin"]
bs = self.op1.blocked_secrets.get()
assert bs.secret == self.op1.secret
assert bs.blocked
@classscope(attr='o')
def test_remove_block(self):
self.op1.blocked = ["admin"]
self.op1.save()
bs = self.op1.blocked_secrets.create(event=self.event, secret=self.op1.secret, blocked=True)
self.ocm.remove_block(self.op1, "admin")
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.blocked is None
bs.refresh_from_db()
assert not bs.blocked
@classscope(attr='o')
def test_set_valid_from(self):
old_secret = self.op1.secret
dt = make_aware(datetime(2016, 9, 20, 15, 0, 0, 0))
self.ocm.change_valid_from(self.op1, dt)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.valid_from == dt
assert self.op1.secret == old_secret
@classscope(attr='o')
def test_set_valid_until(self):
self.event.settings.ticket_secret_generator = "pretix_sig1"
assign_ticket_secret(self.event, self.op1, force_invalidate=True, save=True)
old_secret = self.op1.secret
dt = make_aware(datetime(2022, 9, 20, 15, 0, 0, 0))
self.ocm.change_valid_until(self.op1, dt)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.secret != old_secret
@classscope(attr='o')
def test_unset_valid_from_until(self):
self.op1.valid_from = make_aware(datetime(2016, 9, 20, 15, 0, 0, 0))
self.op1.valid_until = make_aware(datetime(2016, 9, 20, 15, 0, 0, 0))
self.op1.save()
self.ocm.change_valid_from(self.op1, None)
self.ocm.change_valid_until(self.op1, None)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.valid_from is None
assert self.op1.valid_until is None
@pytest.mark.django_db
def test_autocheckin(clist_autocheckin, event):
+16
View File
@@ -302,6 +302,22 @@ def test_sendmail_rule_send_order_pending_excluded(event, order):
assert len(djmail.outbox) == 0
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_send_order_valid_if_pending(event, order):
order.valid_if_pending = True
order.status = Order.STATUS_PENDING
order.save()
djmail.outbox = []
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), include_pending=False,
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
@pytest.mark.django_db
@pytest.mark.parametrize('status', [
Order.STATUS_EXPIRED,
+25 -19
View File
@@ -90,12 +90,13 @@ def test_csv_simple(event):
})
assert clean(content.decode()) == clean(""""Order code","Attendee name","Attendee name: Title","Attendee name:
First name","Attendee name: Middle name","Attendee name: Family name","Product","Price","Checked in","Checked out","Automatically
checked in","Secret","E-mail","Phone number","Company","Voucher code","Order date","Order time","Requires special attention","Comment","Seat ID","Seat name",
"Seat zone","Seat row","Seat number","Address","ZIP code","City","Country","State"
checked in","Secret","E-mail","Phone number","Company","Voucher code","Order date","Order time","Requires special attention",
"Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Blocked","Valid from","Valid until","Address","ZIP code",
"City","Country","State"
"FOO","Mr Peter A Jones","Mr","Peter","A","Jones","Ticket","23.00","","","No","hutjztuxhkbtwnesv2suqv26k6ttytxx",
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
"FOO","Mrs Andrea J Zulu","Mrs","Andrea","J","Zulu","Ticket","13.00","","","No","ggsngqtnmhx74jswjngw3fk8pfwz2a7k",
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
""")
@@ -112,11 +113,12 @@ def test_csv_order_by_name_parts(event): # noqa
assert clean(content.decode()) == clean(""""Order code","Attendee name","Attendee name: Title",
"Attendee name: First name","Attendee name: Middle name","Attendee name: Family name","Product","Price",
"Checked in","Checked out","Automatically checked in","Secret","E-mail","Phone number","Company","Voucher code","Order date","Order time","Requires special
attention","Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Address","ZIP code","City","Country","State"
attention","Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Blocked","Valid from","Valid until",
"Address","ZIP code","City","Country","State"
"FOO","Mrs Andrea J Zulu","Mrs","Andrea","J","Zulu","Ticket","13.00","","","No","ggsngqtnmhx74jswjngw3fk8pfwz2a7k",
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
"FOO","Mr Peter A Jones","Mr","Peter","A","Jones","Ticket","23.00","","","No","hutjztuxhkbtwnesv2suqv26k6ttytxx",
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
""")
c = CSVCheckinList(event, organizer=event.organizer)
_, _, content = c.render({
@@ -129,11 +131,12 @@ def test_csv_order_by_name_parts(event): # noqa
assert clean(content.decode()) == clean(""""Order code","Attendee name","Attendee name: Title",
"Attendee name: First name","Attendee name: Middle name","Attendee name: Family name","Product","Price",
"Checked in","Checked out","Automatically checked in","Secret","E-mail","Phone number","Company","Voucher code","Order date","Order time","Requires special
attention","Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Address","ZIP code","City","Country","State"
attention","Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Blocked","Valid from","Valid until",
"Address","ZIP code","City","Country","State"
"FOO","Mr Peter A Jones","Mr","Peter","A","Jones","Ticket","23.00","","","No","hutjztuxhkbtwnesv2suqv26k6ttytxx",
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
"FOO","Mrs Andrea J Zulu","Mrs","Andrea","J","Zulu","Ticket","13.00","","","No","ggsngqtnmhx74jswjngw3fk8pfwz2a7k",
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
""")
@@ -180,11 +183,12 @@ def test_csv_order_by_inherited_name_parts(event): # noqa
assert clean(content.decode()) == clean(""""Order code","Attendee name","Attendee name: Title",
"Attendee name: First name","Attendee name: Middle name","Attendee name: Family name","Product","Price",
"Checked in","Checked out","Automatically checked in","Secret","E-mail","Phone number","Company","Voucher code","Order date","Order time","Requires special
attention","Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Address","ZIP code","City","Country","State"
attention","Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Blocked","Valid from","Valid until",
"Address","ZIP code","City","Country","State"
"BAR","Mr Albert J Zulu","Mr","Albert","J","Zulu","Ticket","23.00","","","No","hutjztuxhkbtwnesv2suqv26k6ttytyy",
"dummy@dummy.test","'+498912345678","BARCORP","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","BARCORP","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
"FOO","Mr Paul A Jones","Mr","Paul","A","Jones","Ticket","23.00","","","No","hutjztuxhkbtwnesv2suqv26k6ttytxx",
"dummy@dummy.test","'+498912345678","FOOCORP","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","FOOCORP","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
""")
c = CSVCheckinList(event, organizer=event.organizer)
_, _, content = c.render({
@@ -197,11 +201,12 @@ def test_csv_order_by_inherited_name_parts(event): # noqa
assert clean(content.decode()) == clean(""""Order code","Attendee name","Attendee name: Title",
"Attendee name: First name","Attendee name: Middle name","Attendee name: Family name","Product","Price",
"Checked in","Checked out","Automatically checked in","Secret","E-mail","Phone number","Company","Voucher code","Order date","Order time","Requires special
attention","Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Address","ZIP code","City","Country","State"
attention","Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Blocked","Valid from","Valid until",
"Address","ZIP code","City","Country","State"
"BAR","Mr Albert J Zulu","Mr","Albert","J","Zulu","Ticket","23.00","","","No","hutjztuxhkbtwnesv2suqv26k6ttytyy",
"dummy@dummy.test","'+498912345678","BARCORP","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","BARCORP","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
"FOO","Mr Paul A Jones","Mr","Paul","A","Jones","Ticket","23.00","","","No","hutjztuxhkbtwnesv2suqv26k6ttytxx",
"dummy@dummy.test","'+498912345678","FOOCORP","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","FOOCORP","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
""")
c = CSVCheckinList(event, organizer=event.organizer)
_, _, content = c.render({
@@ -214,9 +219,10 @@ def test_csv_order_by_inherited_name_parts(event): # noqa
assert clean(content.decode()) == clean(""""Order code","Attendee name","Attendee name: Title",
"Attendee name: First name","Attendee name: Middle name","Attendee name: Family name","Product","Price",
"Checked in","Checked out","Automatically checked in","Secret","E-mail","Phone number","Company","Voucher code","Order date","Order time","Requires special
attention","Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Address","ZIP code","City","Country","State"
attention","Comment","Seat ID","Seat name","Seat zone","Seat row","Seat number","Blocked","Valid from","Valid until",
"Address","ZIP code","City","Country","State"
"FOO","Mr Paul A Jones","Mr","Paul","A","Jones","Ticket","23.00","","","No","hutjztuxhkbtwnesv2suqv26k6ttytxx",
"dummy@dummy.test","'+498912345678","FOOCORP","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","FOOCORP","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
"BAR","Mr Albert J Zulu","Mr","Albert","J","Zulu","Ticket","23.00","","","No","hutjztuxhkbtwnesv2suqv26k6ttytyy",
"dummy@dummy.test","'+498912345678","BARCORP","","2019-02-22","14:00:00","No","","","","","","","","","","",""
"dummy@dummy.test","'+498912345678","BARCORP","","2019-02-22","14:00:00","No","","","","","","","","","","","","","",""
""")