mirror of
https://github.com/pretix/pretix.git
synced 2026-08-18 12:06:26 +00:00
New check-in features (#3022)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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._]+)$')])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""))
|
||||
},
|
||||
|
||||
@@ -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``
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,9 @@ TEST_ORDERPOSITION1_RES = {
|
||||
"country": None,
|
||||
"state": None,
|
||||
"subevent": None,
|
||||
"valid_from": None,
|
||||
"valid_until": None,
|
||||
"blocked": None,
|
||||
"pseudonymization_id": "ABCDEFGHKL",
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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","","","","","","","","","","","","","",""
|
||||
""")
|
||||
|
||||
Reference in New Issue
Block a user