Reusable media (#3131)

Co-authored-by: Martin Gross <gross@rami.io>
This commit is contained in:
Raphael Michel
2023-04-03 10:45:22 +02:00
committed by GitHub
co-authored by Martin Gross
parent 377117548d
commit d0b449ea89
67 changed files with 2876 additions and 133 deletions
-1
View File
@@ -225,4 +225,3 @@ You can get three response codes:
"subevent": 23, "subevent": 23,
"checkinlist": 5 "checkinlist": 5
} }
+8 -2
View File
@@ -13,6 +13,10 @@ failed scans.
The endpoints listed on this page have been added. The endpoints listed on this page have been added.
.. versionchanged:: 4.18
The ``source_type`` parameter has been added.
.. _`rest-checkin-redeem`: .. _`rest-checkin-redeem`:
Checking a ticket in Checking a ticket in
@@ -28,6 +32,7 @@ Checking a ticket in
passed needs to be from a distinct event. passed needs to be from a distinct event.
:<json string secret: Scanned QR code corresponding to the ``secret`` attribute of a ticket. :<json string secret: Scanned QR code corresponding to the ``secret`` attribute of a ticket.
:<json string source_type: Type of source the ``secret`` was obtained form. Defaults to ``"barcode"``.
:<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event. :<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
:<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry. :<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used. :<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
@@ -72,6 +77,7 @@ Checking a ticket in
{ {
"secret": "M5BO19XmFwAjLd4nDYUAL9ISjhti0e9q", "secret": "M5BO19XmFwAjLd4nDYUAL9ISjhti0e9q",
"source_type": "barcode",
"lists": [1], "lists": [1],
"force": false, "force": false,
"ignore_unpaid": false, "ignore_unpaid": false,
@@ -213,8 +219,8 @@ Checking a ticket in
* ``revoked`` - Ticket code has been revoked. * ``revoked`` - Ticket code has been revoked.
* ``error`` - Internal error. * ``error`` - Internal error.
In case of reason ``rules``, there might be an additional response field ``reason_explanation`` with a human-readable In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation``
description of the violated rules. However, that field can also be missing or be ``null``. with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:statuscode 201: no error :statuscode 201: no error
+2 -2
View File
@@ -753,8 +753,8 @@ Order position endpoints
* ``ambiguous`` - Multiple tickets match scan, rejected. * ``ambiguous`` - Multiple tickets match scan, rejected.
* ``revoked`` - Ticket code has been revoked. * ``revoked`` - Ticket code has been revoked.
In case of reason ``rules``, there might be an additional response field ``reason_explanation`` with a human-readable In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation``
description of the violated rules. However, that field can also be missing or be ``null``. with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
+8
View File
@@ -547,6 +547,9 @@ Therefore, we're also not including a list of the options here, but instead reco
to see available options. The ``explain=true`` flag enables a verbose mode that provides you with human-readable to see available options. The ``explain=true`` flag enables a verbose mode that provides you with human-readable
information about the properties. information about the properties.
Note that some settings are read-only, e.g. because they can be read on event level but currently only be changed on
organizer level.
.. note:: Please note that this is not a complete representation of all event settings. You will find more settings .. note:: Please note that this is not a complete representation of all event settings. You will find more settings
in the web interface. in the web interface.
@@ -593,6 +596,7 @@ information about the properties.
{ {
"value": "https://pretix.eu", "value": "https://pretix.eu",
"label": "Imprint URL", "label": "Imprint URL",
"readonly": false,
"help_text": "This should point e.g. to a part of your website that has your contact details and legal information." "help_text": "This should point e.g. to a part of your website that has your contact details and legal information."
} }
}, },
@@ -606,6 +610,10 @@ information about the properties.
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. versionchanged:: 4.18
The ``readonly`` flag has been added.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/settings/ .. http:patch:: /api/v1/organizers/(organizer)/events/(event)/settings/
Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``. Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``.
+1
View File
@@ -32,6 +32,7 @@ at :ref:`plugin-docs`.
membershiptypes membershiptypes
memberships memberships
giftcards giftcards
reusablemedia
carts carts
teams teams
devices devices
+17
View File
@@ -108,6 +108,9 @@ generate_tickets boolean If ``false``,
allow_waitinglist boolean If ``false``, no waiting list will be shown for this allow_waitinglist boolean If ``false``, no waiting list will be shown for this
product when it is sold out. product when it is sold out.
issue_giftcard boolean If ``true``, buying this product will yield a gift card. issue_giftcard boolean If ``true``, buying this product will yield a gift card.
media_policy string Policy on how to handle reusable media (experimental feature).
Possible values are ``null``, ``"new"``, ``"reuse"``, and ``"reuse_or_new"``.
media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices.
show_quota_left boolean Publicly show how many tickets are still available. show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used. If this is ``null``, the event default is used.
has_variations boolean Shows whether or not this item has variations. has_variations boolean Shows whether or not this item has variations.
@@ -189,6 +192,10 @@ meta_data object Values set fo
The ``validity_*`` attributes have been added. The ``validity_*`` attributes have been added.
.. versionchanged:: 4.18
The ``media_policy`` and ``media_type`` attributes have been added.
Notes Notes
----- -----
@@ -244,6 +251,8 @@ Endpoints
"admission": false, "admission": false,
"personalized": false, "personalized": false,
"issue_giftcard": false, "issue_giftcard": false,
"media_policy": null,
"media_type": null,
"meta_data": {}, "meta_data": {},
"position": 0, "position": 0,
"picture": null, "picture": null,
@@ -373,6 +382,8 @@ Endpoints
"admission": false, "admission": false,
"personalized": false, "personalized": false,
"issue_giftcard": false, "issue_giftcard": false,
"media_policy": null,
"media_type": null,
"meta_data": {}, "meta_data": {},
"position": 0, "position": 0,
"picture": null, "picture": null,
@@ -483,6 +494,8 @@ Endpoints
"admission": false, "admission": false,
"personalized": false, "personalized": false,
"issue_giftcard": false, "issue_giftcard": false,
"media_policy": null,
"media_type": null,
"meta_data": {}, "meta_data": {},
"position": 0, "position": 0,
"picture": null, "picture": null,
@@ -580,6 +593,8 @@ Endpoints
"admission": false, "admission": false,
"personalized": false, "personalized": false,
"issue_giftcard": false, "issue_giftcard": false,
"media_policy": null,
"media_type": null,
"meta_data": {}, "meta_data": {},
"position": 0, "position": 0,
"picture": null, "picture": null,
@@ -709,6 +724,8 @@ Endpoints
"admission": false, "admission": false,
"personalized": false, "personalized": false,
"issue_giftcard": false, "issue_giftcard": false,
"media_policy": null,
"media_type": null,
"meta_data": {}, "meta_data": {},
"position": 0, "position": 0,
"picture": null, "picture": null,
+1
View File
@@ -910,6 +910,7 @@ Creating orders
* ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected) * ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
* ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID)
* ``answers`` * ``answers``
* ``question`` * ``question``
+1
View File
@@ -157,6 +157,7 @@ information about the properties.
{ {
"value": "calendar", "value": "calendar",
"label": "Default overview style", "label": "Default overview style",
"readonly": false,
"help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used." "help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used."
} }
}, },
+317
View File
@@ -0,0 +1,317 @@
.. _`rest-reusablemedia`:
Reusable media
==============
Reusable media represent things, typically physical tokens like plastic cards or NFC wristbands, which can represent
other entities inside the system. For example, a medium can link to an order position or to a gift card and can be used
in their place. Later, the medium might be reused for a different ticket.
Resource description
--------------------
The reusable medium resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the medium
type string Type of medium, e.g. ``"barcode"`` or ``"nfc_uid"``.
identifier string Unique identifier of the medium. The format depends on the ``type``.
active boolean Whether this medium may be used.
created datetime Date of creation
updated datetime Date of last modification
expires datetime Expiry date (or ``null``)
customer string Identifier of a customer account this medium belongs to.
linked_orderposition integer Internal ID of a ticket this medium is linked to.
linked_giftcard integer Internal ID of a gift card this medium is linked to.
info object Additional data, content depends on the ``type``. Consider
this internal to the system and don't use it for your own data.
notes string Internal notes and comments (or ``null``)
===================================== ========================== =======================================================
Existing media types are:
- ``barcode``
- ``nfc_uid``
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/reusablemedia/
Returns a list of all media issued by a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/reusablemedia/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1.
:query string identifier: Only show media with the given identifier. Note that you should use the lookup endpoint described below for most use cases.
:query string type: Only show media with the given type.
:query boolean active: Only show media that are (not) active.
:query string customer: Only show media linked to the given customer.
:query string created_since: Only show media created since a given date.
:query string updated_since: Only show media updated since a given date.
:query integer linked_orderposition: Only show media linked to the given ticket.
:query integer linked_giftcard: Only show media linked to the given gift card.
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/reusablemedia/(id)/
Returns information on one medium, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/reusablemedia/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
}
:param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the medium to fetch
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/reusablemedia/lookup/
Look up a new reusable medium by its identifier. In some cases, this might lead to the automatic creation of a new
medium behind the scenes.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/reusablemedia/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"identifier": "ABCDEFGH",
"type": "barcode",
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
}
:param organizer: The ``slug`` field of the organizer to look up a medium for
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
:statuscode 201: no error
:statuscode 400: The medium could not be looked up due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:post:: /api/v1/organizers/(organizer)/reusablemedia/
Creates a new reusable medium.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/reusablemedia/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"identifier": "ABCDEFGH",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
}
:param organizer: The ``slug`` field of the organizer to create a medium for
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
:statuscode 201: no error
:statuscode 400: The medium could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/reusablemedia/(id)/
Update a reusable medium. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``id``, ``identifier`` and ``type`` fields.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/reusablemedia/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"linked_orderposition": 13
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": 13,
"linked_giftcard": None,
"notes": None,
"info": {}
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the medium to modify
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
:statuscode 200: no error
:statuscode 400: The medium could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
+5
View File
@@ -26,6 +26,7 @@ can_create_events boolean
can_change_teams boolean can_change_teams boolean
can_change_organizer_settings boolean can_change_organizer_settings boolean
can_manage_customers boolean can_manage_customers boolean
can_manage_reusable_media boolean
can_manage_gift_cards boolean can_manage_gift_cards boolean
can_change_event_settings boolean can_change_event_settings boolean
can_change_items boolean can_change_items boolean
@@ -36,6 +37,10 @@ can_change_vouchers boolean
can_checkin_orders boolean can_checkin_orders boolean
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 4.18
The ``can_manage_reusable_media`` permission has been added.
Team member resource Team member resource
-------------------- --------------------
+2
View File
@@ -81,6 +81,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'),
) )
@@ -220,6 +221,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:upload'), ('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:checkinrpc.search'),
('POST', 'api-v1:reusablemedium-lookup'),
) )
+2
View File
@@ -26,6 +26,7 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, CheckinList from pretix.base.models import Checkin, CheckinList
@@ -84,6 +85,7 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
lists = serializers.PrimaryKeyRelatedField(required=True, many=True, queryset=CheckinList.objects.none()) lists = serializers.PrimaryKeyRelatedField(required=True, many=True, queryset=CheckinList.objects.none())
secret = serializers.CharField(required=True, allow_null=False) secret = serializers.CharField(required=True, allow_null=False)
force = serializers.BooleanField(default=False, required=False) force = serializers.BooleanField(default=False, required=False)
source_type = serializers.ChoiceField(choices=[(k, v) for k, v in MEDIA_TYPES.items()], default='barcode')
type = serializers.ChoiceField(choices=Checkin.CHECKIN_TYPES, default=Checkin.TYPE_ENTRY) type = serializers.ChoiceField(choices=Checkin.CHECKIN_TYPES, default=Checkin.TYPE_ENTRY)
ignore_unpaid = serializers.BooleanField(default=False, required=False) ignore_unpaid = serializers.BooleanField(default=False, required=False)
questions_supported = serializers.BooleanField(default=True, required=False) questions_supported = serializers.BooleanField(default=True, required=False)
+17
View File
@@ -797,6 +797,21 @@ class EventSettingsSerializer(SettingsSerializer):
'logo_show_title', 'logo_show_title',
'og_image', 'og_image',
'name_scheme', 'name_scheme',
'reusable_media_active',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_uid_autocreate_giftcard',
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
]
readonly_fields = [
# These are read-only since they are currently only settable on organizers, not events
'reusable_media_active',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_uid_autocreate_giftcard',
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -863,6 +878,8 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'invoice_address_from_tax_id', 'invoice_address_from_tax_id',
'invoice_address_from_vat_id', 'invoice_address_from_vat_id',
'name_scheme', 'name_scheme',
'reusable_media_type_barcode',
'reusable_media_type_nfc_uid',
'system_question_order', 'system_question_order',
] ]
+3 -1
View File
@@ -244,7 +244,8 @@ class ItemSerializer(I18nAwareModelSerializer):
'grant_membership_duration_like_event', 'grant_membership_duration_days', 'grant_membership_duration_like_event', 'grant_membership_duration_days',
'grant_membership_duration_months', 'validity_mode', 'validity_fixed_from', 'validity_fixed_until', 'grant_membership_duration_months', 'validity_mode', 'validity_fixed_from', 'validity_fixed_until',
'validity_dynamic_duration_minutes', 'validity_dynamic_duration_hours', 'validity_dynamic_duration_days', 'validity_dynamic_duration_minutes', 'validity_dynamic_duration_hours', 'validity_dynamic_duration_days',
'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit') 'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit',
'media_policy', 'media_type')
read_only_fields = ('has_variations',) read_only_fields = ('has_variations',)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -263,6 +264,7 @@ class ItemSerializer(I18nAwareModelSerializer):
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order')) Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
Item.clean_available(data.get('available_from'), data.get('available_until')) Item.clean_available(data.get('available_from'), data.get('available_until'))
Item.clean_media_settings(self.context['event'], data.get('media_policy'), data.get('media_type'), data.get('issue_giftcard'))
if data.get('personalized') and not data.get('admission'): if data.get('personalized') and not data.get('admission'):
raise ValidationError(_('Only admission products can currently be personalized.')) raise ValidationError(_('Only admission products can currently be personalized.'))
+128
View File
@@ -0,0 +1,128 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.serializers.organizer import (
CustomerSerializer, GiftCardSerializer,
)
from pretix.base.models import Order, OrderPosition, ReusableMedium
logger = logging.getLogger(__name__)
class NestedOrderMiniSerializer(I18nAwareModelSerializer):
event = serializers.SlugRelatedField(slug_field='slug', read_only=True)
class Meta:
model = Order
fields = ['code', 'event']
class NestedOrderPositionSerializer(OrderPositionSerializer):
order = NestedOrderMiniSerializer()
class NestedGiftCardSerializer(GiftCardSerializer):
def to_representation(self, instance):
d = super().to_representation(instance)
if hasattr(instance, 'cached_value'):
d['value'] = str(Decimal(instance.cached_value).quantize(Decimal("0.01")))
else:
d['value'] = str(Decimal(instance.value).quantize(Decimal("0.01")))
return d
class ReusableMediaSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True)
else:
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
required=False,
allow_null=True,
queryset=self.context['organizer'].issued_gift_cards.all()
)
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
else:
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
required=False,
allow_null=True,
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
)
if 'customer' in self.context['request'].query_params.getlist('expand'):
self.fields['customer'] = CustomerSerializer(read_only=True)
else:
self.fields['customer'] = serializers.SlugRelatedField(
required=False,
allow_null=True,
slug_field='identifier',
queryset=self.context['organizer'].customers.all()
)
def validate(self, data):
data = super().validate(data)
if 'type' in data and 'identifier' in data:
qs = self.context['organizer'].reusable_media.filter(
identifier=data['identifier'], type=data['type']
)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise ValidationError(
{'identifier': _('A medium with the same identifier and type already exists in your organizer account.')}
)
return data
class Meta:
model = ReusableMedium
fields = (
'id',
'created',
'updated',
'type',
'identifier',
'active',
'expires',
'customer',
'linked_orderposition',
'linked_giftcard',
'info',
'notes',
)
class MediaLookupInputSerializer(serializers.Serializer):
type = serializers.CharField(required=True)
identifier = serializers.CharField(required=True)
+32 -4
View File
@@ -33,6 +33,7 @@ from django.utils.encoding import force_str
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy from django.utils.translation import gettext_lazy
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import scopes_disabled
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
@@ -48,8 +49,8 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item, CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat, ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
SubEvent, TaxRule, Voucher, ReusableMedium, Seat, SubEvent, TaxRule, Voucher,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -356,6 +357,9 @@ class PdfDataSerializer(serializers.Field):
def to_representation(self, instance: OrderPosition): def to_representation(self, instance: OrderPosition):
res = {} res = {}
if 'event' not in self.context:
return {}
ev = instance.subevent or instance.order.event ev = instance.subevent or instance.order.event
with language(instance.order.locale, instance.order.event.settings.region): with language(instance.order.locale, instance.order.event.settings.region):
# This needs to have some extra performance improvements to avoid creating hundreds of queries when # This needs to have some extra performance improvements to avoid creating hundreds of queries when
@@ -784,13 +788,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
required=False, allow_null=True) required=False, allow_null=True)
country = CompatibleCountryField(source='*') country = CompatibleCountryField(source='*')
requested_valid_from = serializers.DateTimeField(required=False, allow_null=True) requested_valid_from = serializers.DateTimeField(required=False, allow_null=True)
use_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
required=False, allow_null=True)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled', 'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until', 'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until',
'requested_valid_from') 'requested_valid_from', 'use_reusable_medium')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -799,6 +805,9 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
v.required = False v.required = False
v.allow_blank = True v.allow_blank = True
v.allow_null = True v.allow_null = True
with scopes_disabled():
if 'use_reusable_medium' in self.fields:
self.fields['use_reusable_medium'].queryset = ReusableMedium.objects.all()
def validate_secret(self, secret): def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -807,6 +816,13 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
) )
return secret return secret
def validate_use_reusable_medium(self, m):
if m.organizer_id != self.context['event'].organizer_id:
raise ValidationError(
'The specified medium does not belong to this organizer.'
)
return m
def validate_item(self, item): def validate_item(self, item):
if item.event != self.context['event']: if item.event != self.context['event']:
raise ValidationError( raise ValidationError(
@@ -1264,7 +1280,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos_data['attendee_name_parts'] = { pos_data['attendee_name_parts'] = {
'_legacy': attendee_name '_legacy': attendee_name
} }
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas'}) pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
if simulate: if simulate:
pos.order = order._wrapped pos.order = order._wrapped
else: else:
@@ -1332,6 +1348,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
# Save instances # Save instances
for pos_data in positions_data: for pos_data in positions_data:
answers_data = pos_data.pop('answers', []) answers_data = pos_data.pop('answers', [])
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
pos = pos_data['__instance'] pos = pos_data['__instance']
pos._calculate_tax() pos._calculate_tax()
@@ -1370,6 +1387,17 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answ = pos.answers.create(**answ_data) answ = pos.answers.create(**answ_data)
answ.options.add(*options) answ.options.add(*options)
if use_reusable_medium:
use_reusable_medium.linked_orderposition = pos
use_reusable_medium.save(update_fields=['linked_orderposition'])
use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.changed',
data={
'by_order': order.code,
'linked_orderposition': pos.pk,
}
)
if not simulate: if not simulate:
for cp in delete_cps: for cp in delete_cps:
if cp.addon_to_id: if cp.addon_to_id:
+7 -1
View File
@@ -183,7 +183,7 @@ class TeamSerializer(serializers.ModelSerializer):
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams', 'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings', 'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers', 'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers' 'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
) )
def validate(self, data): def validate(self, data):
@@ -333,6 +333,12 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'cookie_consent_dialog_text_secondary', 'cookie_consent_dialog_text_secondary',
'cookie_consent_dialog_button_yes', 'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no', 'cookie_consent_dialog_button_no',
'reusable_media_active',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_uid_autocreate_giftcard',
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
+6
View File
@@ -36,6 +36,7 @@ logger = logging.getLogger(__name__)
class SettingsSerializer(serializers.Serializer): class SettingsSerializer(serializers.Serializer):
default_fields = [] default_fields = []
readonly_fields = []
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.changed_data = [] self.changed_data = []
@@ -59,8 +60,13 @@ class SettingsSerializer(serializers.Serializer):
f.parent = self f.parent = self
self.fields[fname] = f self.fields[fname] = f
def validate(self, attrs):
return {k: v for k, v in attrs.items() if k not in self.readonly_fields}
def update(self, instance: HierarkeyProxy, validated_data): def update(self, instance: HierarkeyProxy, validated_data):
for attr, value in validated_data.items(): for attr, value in validated_data.items():
if attr in self.readonly_fields:
continue
if isinstance(value, FieldFile): if isinstance(value, FieldFile):
# Delete old file # Delete old file
fname = instance.get(attr, as_type=File) fname = instance.get(attr, as_type=File)
+4 -3
View File
@@ -42,9 +42,9 @@ from rest_framework import routers
from pretix.api.views import cart from pretix.api.views import cart
from .views import ( from .views import (
checkin, device, discount, event, exporters, idempotency, item, oauth, checkin, device, discount, event, exporters, idempotency, item, media,
order, organizer, shredders, upload, user, version, voucher, waitinglist, oauth, order, organizer, shredders, upload, user, version, voucher,
webhooks, waitinglist, webhooks,
) )
router = routers.DefaultRouter() router = routers.DefaultRouter()
@@ -59,6 +59,7 @@ orga_router.register(r'giftcards', organizer.GiftCardViewSet)
orga_router.register(r'customers', organizer.CustomerViewSet) orga_router.register(r'customers', organizer.CustomerViewSet)
orga_router.register(r'memberships', organizer.MembershipViewSet) orga_router.register(r'memberships', organizer.MembershipViewSet)
orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet) orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet)
orga_router.register(r'reusablemedia', media.ReusableMediaViewSet)
orga_router.register(r'teams', organizer.TeamViewSet) orga_router.register(r'teams', organizer.TeamViewSet)
orga_router.register(r'devices', organizer.DeviceViewSet) orga_router.register(r'devices', organizer.DeviceViewSet)
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
+105 -89
View File
@@ -59,7 +59,7 @@ from pretix.api.views.order import OrderPositionFilter
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition, CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
Question, RevokedTicketSecret, TeamAPIToken, Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
) )
from pretix.base.services.checkin import ( from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
@@ -396,7 +396,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce, def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported, untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
legacy_url_support=False): source_type='barcode', legacy_url_support=False):
if not checkinlists: if not checkinlists:
raise ValidationError('No check-in list passed.') raise ValidationError('No check-in list passed.')
@@ -422,6 +422,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
common_checkin_args = dict( common_checkin_args = dict(
raw_barcode=raw_barcode, raw_barcode=raw_barcode,
raw_source_type=source_type,
type=checkin_type, type=checkin_type,
list=checkinlists[0], list=checkinlists[0],
datetime=datetime, datetime=datetime,
@@ -433,7 +434,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
raw_barcode_for_checkin = None raw_barcode_for_checkin = None
from_revoked_secret = False from_revoked_secret = False
# 1. Gather a list of positions that could be the one we looking fore, either from their ID, secret or # 1. Gather a list of positions that could be the one we looking for, either from their ID, secret or
# parent secret # parent secret
queryset = _checkin_list_position_queryset(checkinlists, pdf_data=pdf_data, ignore_status=True, ignore_products=True).order_by( queryset = _checkin_list_position_queryset(checkinlists, pdf_data=pdf_data, ignore_status=True, ignore_products=True).order_by(
F('addon_to').asc(nulls_first=True) F('addon_to').asc(nulls_first=True)
@@ -457,98 +458,111 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# 2. Handle the "nothing found" case: Either it's really a bogus secret that we don't know (-> error), or it # 2. Handle the "nothing found" case: Either it's really a bogus secret that we don't know (-> error), or it
# might be a revoked one that we actually know (-> error, but with better error message and logging and # might be a revoked one that we actually know (-> error, but with better error message and logging and
# with respecting the force option). # with respecting the force option), or it's a reusable medium (-> proceed with that)
if not op_candidates: if not op_candidates:
revoked_matches = list(RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode)) try:
if len(revoked_matches) == 0: media = ReusableMedium.objects.select_related('linked_orderposition').active().get(
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={ organizer_id=checkinlists[0].event.organizer_id,
'datetime': datetime, type=source_type,
'type': checkin_type, identifier=raw_barcode,
'list': checkinlists[0].pk, linked_orderposition__isnull=False,
'barcode': raw_barcode, )
'searched_lists': [cl.pk for cl in checkinlists] raw_barcode_for_checkin = raw_barcode
}, user=user, auth=auth) except ReusableMedium.DoesNotExist:
revoked_matches = list(
RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode))
if len(revoked_matches) == 0:
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
'datetime': datetime,
'type': checkin_type,
'list': checkinlists[0].pk,
'barcode': raw_barcode,
'searched_lists': [cl.pk for cl in checkinlists]
}, user=user, auth=auth)
for cl in checkinlists: for cl in checkinlists:
for k, s in cl.event.ticket_secret_generators.items(): for k, s in cl.event.ticket_secret_generators.items():
try:
parsed = s.parse_secret(raw_barcode)
common_checkin_args.update({
'raw_item': parsed.item,
'raw_variation': parsed.variation,
'raw_subevent': parsed.subevent,
})
except:
pass
Checkin.objects.create(
position=None,
successful=False,
error_reason=Checkin.REASON_INVALID,
**common_checkin_args,
)
if force and legacy_url_support and isinstance(auth, Device):
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
# valid at the time but no longer exists at time of upload, the device would retry to
# upload the same scan over and over again. Since we can't update all devices quickly,
# here's a dirty workaround to make it stop.
try: try:
parsed = s.parse_secret(raw_barcode) brand = auth.software_brand
common_checkin_args.update({ ver = parse(auth.software_version)
'raw_item': parsed.item, legacy_mode = (
'raw_variation': parsed.variation, (brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
'raw_subevent': parsed.subevent, (brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
}) (brand == 'pretixSCAN' and ver < parse('1.11.2'))
except: )
if legacy_mode:
return Response({
'status': 'error',
'reason': Checkin.REASON_ALREADY_REDEEMED,
'reason_explanation': None,
'require_attention': False,
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
}, status=400)
except: # we don't care e.g. about invalid version numbers
pass pass
Checkin.objects.create( return Response({
position=None, 'detail': 'Not found.', # for backwards compatibility
successful=False, 'status': 'error',
error_reason=Checkin.REASON_INVALID, 'reason': Checkin.REASON_INVALID,
**common_checkin_args, 'reason_explanation': None,
) 'require_attention': False,
'list': MiniCheckinListSerializer(checkinlists[0]).data,
if force and legacy_url_support and isinstance(auth, Device): }, status=404)
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was elif revoked_matches and force:
# valid at the time but no longer exists at time of upload, the device would retry to op_candidates = [revoked_matches[0].position]
# upload the same scan over and over again. Since we can't update all devices quickly, if list_by_event[revoked_matches[0].event_id].addon_match:
# here's a dirty workaround to make it stop. op_candidates += list(revoked_matches[0].position.addons.all())
try: raw_barcode_for_checkin = raw_barcode_for_checkin or raw_barcode
brand = auth.software_brand from_revoked_secret = True
ver = parse(auth.software_version) else:
legacy_mode = ( op = revoked_matches[0].position
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or op.order.log_action('pretix.event.checkin.revoked', data={
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or 'datetime': datetime,
(brand == 'pretixSCAN' and ver < parse('1.11.2')) 'type': checkin_type,
) 'list': list_by_event[revoked_matches[0].event_id].pk,
if legacy_mode: 'barcode': raw_barcode
return Response({ }, user=user, auth=auth)
'status': 'error', common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
'reason': Checkin.REASON_ALREADY_REDEEMED, Checkin.objects.create(
'reason_explanation': None, position=op,
'require_attention': False, successful=False,
'__warning': 'Compatibility hack active due to detected old pretixSCAN version', error_reason=Checkin.REASON_REVOKED,
}, status=400) **common_checkin_args
except: # we don't care e.g. about invalid version numbers )
pass return Response({
'status': 'error',
return Response({ 'reason': Checkin.REASON_REVOKED,
'detail': 'Not found.', # for backwards compatibility 'reason_explanation': None,
'status': 'error', 'require_attention': False,
'reason': Checkin.REASON_INVALID, 'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, revoked_matches[
'reason_explanation': None, 0].event)).data,
'require_attention': False, 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
'list': MiniCheckinListSerializer(checkinlists[0]).data, }, status=400)
}, status=404)
elif revoked_matches and force:
op_candidates = [revoked_matches[0].position]
if list_by_event[revoked_matches[0].event_id].addon_match:
op_candidates += list(revoked_matches[0].position.addons.all())
raw_barcode_for_checkin = raw_barcode
from_revoked_secret = True
else: else:
op = revoked_matches[0].position op_candidates = [media.linked_orderposition] + list(media.linked_orderposition.addons.all())
op.order.log_action('pretix.event.checkin.revoked', data={
'datetime': datetime,
'type': checkin_type,
'list': list_by_event[revoked_matches[0].event_id].pk,
'barcode': raw_barcode
}, user=user, auth=auth)
common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
Checkin.objects.create(
position=op,
successful=False,
error_reason=Checkin.REASON_REVOKED,
**common_checkin_args
)
return Response({
'status': 'error',
'reason': Checkin.REASON_REVOKED,
'reason_explanation': None,
'require_attention': False,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, revoked_matches[0].event)).data,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400)
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary # 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
# key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out # key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
@@ -634,6 +648,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
auth=auth, auth=auth,
type=checkin_type, type=checkin_type,
raw_barcode=raw_barcode_for_checkin, raw_barcode=raw_barcode_for_checkin,
raw_source_type=source_type,
from_revoked_secret=from_revoked_secret, from_revoked_secret=from_revoked_secret,
) )
except RequiredQuestionsError as e: except RequiredQuestionsError as e:
@@ -812,6 +827,7 @@ class CheckinRPCRedeemView(views.APIView):
return _redeem_process( return _redeem_process(
checkinlists=s.validated_data['lists'], checkinlists=s.validated_data['lists'],
raw_barcode=s.validated_data['secret'], raw_barcode=s.validated_data['secret'],
source_type=s.validated_data['source_type'],
answers_data=s.validated_data.get('answers'), answers_data=s.validated_data.get('answers'),
datetime=s.validated_data.get('datetime') or now(), datetime=s.validated_data.get('datetime') or now(),
force=s.validated_data['force'], force=s.validated_data['force'],
+2 -1
View File
@@ -542,7 +542,8 @@ class EventSettingsView(views.APIView):
fname: { fname: {
'value': s.data[fname], 'value': s.data[fname],
'label': getattr(field, '_label', fname), 'label': getattr(field, '_label', fname),
'help_text': getattr(field, '_help_text', None) 'help_text': getattr(field, '_help_text', None),
'readonly': fname in s.readonly_fields,
} for fname, field in s.fields.items() } for fname, field in s.fields.items()
}) })
return Response(s.data) return Response(s.data)
+160
View File
@@ -0,0 +1,160 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from decimal import Decimal
import django_filters
from django.db import transaction
from django.db.models import OuterRef, Prefetch, Subquery, Sum
from django.db.models.functions import Coalesce
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets, serializers
from rest_framework.decorators import action
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.filters import OrderingFilter
from rest_framework.response import Response
from pretix.api.serializers.media import (
MediaLookupInputSerializer, ReusableMediaSerializer,
)
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
Checkin, GiftCard, GiftCardTransaction, OrderPosition, ReusableMedium,
)
from pretix.helpers import OF_SELF
from pretix.helpers.dicts import merge_dicts
with scopes_disabled():
class ReusableMediumFilter(FilterSet):
identifier = django_filters.CharFilter(field_name='identifier')
type = django_filters.CharFilter(field_name='type')
customer = django_filters.CharFilter(field_name='customer__identifier')
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
class Meta:
model = ReusableMedium
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard']
class ReusableMediaViewSet(viewsets.ModelViewSet):
serializer_class = ReusableMediaSerializer
queryset = ReusableMedium.objects.none()
permission = 'can_manage_reusable_media'
write_permission = 'can_manage_reusable_media'
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('-updated', '-id')
ordering_fields = ('created', 'updated', 'identifier', 'type', 'id')
filterset_class = ReusableMediumFilter
def get_queryset(self):
s = GiftCardTransaction.objects.filter(
card=OuterRef('pk')
).order_by().values('card').annotate(s=Sum('value')).values('s')
return self.request.organizer.reusable_media.prefetch_related(
Prefetch(
'linked_orderposition',
queryset=OrderPosition.objects.select_related(
'order', 'order__event', 'order__event__organizer', 'seat',
).prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.all()),
'answers', 'answers__options', 'answers__question',
)
),
Prefetch(
'linked_giftcard',
queryset=GiftCard.objects.annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00'))
)
)
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
inst.log_action(
'pretix.reusable_medium.created',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
inst.log_action(
'pretix.reusable_medium.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
def perform_destroy(self, instance):
raise MethodNotAllowed("Media cannot be deleted.")
@action(methods=["POST"], detail=False)
def lookup(self, request, *args, **kwargs):
s = MediaLookupInputSerializer(
data=request.data,
)
s.is_valid(raise_exception=True)
try:
m = ReusableMedium.objects.get(
type=s.validated_data["type"],
identifier=s.validated_data["identifier"],
organizer=request.organizer,
)
s = self.get_serializer(m)
return Response({"result": s.data})
except ReusableMedium.DoesNotExist:
mt = MEDIA_TYPES.get(s.validated_data["type"])
if mt:
m = mt.handle_unknown(request.organizer, s.validated_data["identifier"], request.user, request.auth)
if m:
s = self.get_serializer(m)
return Response({"result": s.data})
return Response({"result": None})
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
def list(self, request, **kwargs):
date = serializers.DateTimeField().to_representation(now())
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
resp = self.get_paginated_response(serializer.data)
resp['X-Page-Generated'] = date
return resp
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, headers={'X-Page-Generated': date})
+9 -5
View File
@@ -244,7 +244,8 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related( Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related(
Prefetch('meta_values', to_attr='meta_values_cached', queryset=SubEventMetaValue.objects.select_related('property')) Prefetch('meta_values', to_attr='meta_values_cached', queryset=SubEventMetaValue.objects.select_related('property'))
)), )),
Prefetch('addons', opq.select_related('item', 'variation', 'seat')) Prefetch('addons', opq.select_related('item', 'variation', 'seat')),
'linked_media',
).select_related('seat', 'addon_to', 'addon_to__seat') ).select_related('seat', 'addon_to', 'addon_to__seat')
) )
else: else:
@@ -639,13 +640,11 @@ class OrderViewSet(viewsets.ModelViewSet):
raise ValidationError(_('One of the selected products is not available in the selected country.')) raise ValidationError(_('One of the selected products is not available in the selected country.'))
send_mail = serializer._send_mail send_mail = serializer._send_mail
order = serializer.instance order = serializer.instance
if not order.pk: if not order.pk:
# Simulation # Simulation -- exit here
serializer = SimulatedOrderSerializer(order, context=serializer.context) serializer = SimulatedOrderSerializer(order, context=serializer.context)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
else:
prefetch_related_objects([order], self._positions_prefetch(request))
serializer = OrderSerializer(order, context=serializer.context)
order.log_action( order.log_action(
'pretix.event.order.placed', 'pretix.event.order.placed',
@@ -679,6 +678,10 @@ class OrderViewSet(viewsets.ModelViewSet):
if gen_invoice: if gen_invoice:
invoice = generate_invoice(order, trigger_pdf=True) invoice = generate_invoice(order, trigger_pdf=True)
# Refresh serializer only after running signals
prefetch_related_objects([order], self._positions_prefetch(request))
serializer = OrderSerializer(order, context=serializer.context)
if send_mail: if send_mail:
free_flow = ( free_flow = (
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
@@ -1005,6 +1008,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
Prefetch('meta_values', to_attr='meta_values_cached', Prefetch('meta_values', to_attr='meta_values_cached',
queryset=SubEventMetaValue.objects.select_related('property')) queryset=SubEventMetaValue.objects.select_related('property'))
)), )),
'linked_media',
Prefetch('order', self.request.event.orders.select_related('invoice_address').prefetch_related( Prefetch('order', self.request.event.orders.select_related('invoice_address').prefetch_related(
Prefetch( Prefetch(
'positions', 'positions',
+2 -1
View File
@@ -457,7 +457,8 @@ class OrganizerSettingsView(views.APIView):
fname: { fname: {
'value': s.data[fname], 'value': s.data[fname],
'label': getattr(field, '_label', fname), 'label': getattr(field, '_label', fname),
'help_text': getattr(field, '_help_text', None) 'help_text': getattr(field, '_help_text', None),
'readonly': fname in s.readonly_fields,
} for fname, field in s.fields.items() } for fname, field in s.fields.items()
}) })
return Response(s.data) return Response(s.data)
+116
View File
@@ -0,0 +1,116 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.db import transaction
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
class BaseMediaType:
medium_created_by_server = False
supports_orderposition = False
supports_giftcard = False
@property
def identifier(self):
raise NotImplementedError()
@property
def verbose_name(self):
raise NotImplementedError()
def generate_identifier(self, organizer):
if self.medium_created_by_server:
raise NotImplementedError()
else:
raise ValueError("Media type does not allow to generate identifier")
def is_active(self, organizer):
return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False)
def handle_unknown(self, organizer, identifier, user, auth):
pass
def __str__(self):
return str(self.verbose_name)
class BarcodePlainMediaType(BaseMediaType):
identifier = 'barcode'
verbose_name = _('Barcode / QR-Code')
medium_created_by_server = True
supports_giftcard = False
supports_orderposition = True
def generate_identifier(self, organizer):
return get_random_string(
length=organizer.settings.reusable_media_type_barcode_identifier_length,
# Exclude o,0,1,i to avoid confusion with bad fonts/printers
# We use upper case to make collisions with ticket secrets less likely
allowed_chars='ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
)
class NfcUidMediaType(BaseMediaType):
identifier = 'nfc_uid'
verbose_name = _('NFC UID-based')
medium_created_by_server = False
supports_giftcard = True
supports_orderposition = False
def handle_unknown(self, organizer, identifier, user, auth):
from pretix.base.models import GiftCard, ReusableMedium
if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool):
if identifier.startswith("08"):
# Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random
# UIDs on every read, so they won't be useful.
return
with transaction.atomic():
gc = GiftCard.objects.create(
issuer=organizer,
expires=organizer.default_gift_card_expiry,
currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'),
)
m = ReusableMedium.objects.create(
type=self.identifier,
identifier=identifier,
organizer=organizer,
active=True,
linked_giftcard=gc
)
m.log_action(
'pretix.reusable_medium.created.auto',
user=user, auth=auth,
)
gc.log_action(
'pretix.giftcards.created',
user=user, auth=auth,
)
return m
MEDIA_TYPES = {
m.identifier: m for m in [
BarcodePlainMediaType(),
NfcUidMediaType(),
]
}
@@ -0,0 +1,77 @@
# Generated by Django 3.2.18 on 2023-02-20 12:46
import django.core.serializers.json
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
def set_can_manage_reusable_media(apps, schema_editor):
Team = apps.get_model('pretixbase', 'Team')
Team.objects.filter(can_change_organizer_settings=True).update(can_manage_reusable_media=True)
Team.objects.filter(can_change_orders=True, all_events=True).update(can_manage_reusable_media=True)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0235_auto_20230316_2023'),
]
operations = [
migrations.AddField(
model_name='item',
name='media_policy',
field=models.CharField(max_length=16, null=True),
),
migrations.AddField(
model_name='item',
name='media_type',
field=models.CharField(max_length=100, null=True),
),
migrations.AddField(
model_name='team',
name='can_manage_reusable_media',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='ReusableMedium',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('type', models.CharField(max_length=100)),
('identifier', models.CharField(max_length=200)),
('active', models.BooleanField(default=True)),
('expires', models.DateTimeField(blank=True, null=True)),
('info', models.JSONField(default=dict)),
('notes', models.TextField(null=True, blank=True)),
('customer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='reusable_media', to='pretixbase.customer')),
('linked_giftcard',
models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='linked_media',
to='pretixbase.giftcard')),
('linked_orderposition',
models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='linked_media',
to='pretixbase.orderposition')),
('organizer',
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reusable_media',
to='pretixbase.organizer')),
],
options={
'ordering': ('identifier', 'type', 'organizer'),
'unique_together': {('identifier', 'type', 'organizer')},
'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')},
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.AddField(
model_name='checkin',
name='raw_source_type',
field=models.CharField(max_length=100, null=True),
),
migrations.RunPython(
set_can_manage_reusable_media,
migrations.RunPython.noop,
),
]
+1
View File
@@ -40,6 +40,7 @@ from .items import (
SubEventItem, SubEventItemVariation, itempicture_upload_to, SubEventItem, SubEventItemVariation, itempicture_upload_to,
) )
from .log import LogEntry from .log import LogEntry
from .media import ReusableMedium
from .memberships import Membership, MembershipType from .memberships import Membership, MembershipType
from .notifications import NotificationSetting from .notifications import NotificationSetting
from .orders import ( from .orders import (
+6
View File
@@ -44,6 +44,7 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
from pretix.base.models.fields import MultiStringField from pretix.base.models.fields import MultiStringField
from pretix.helpers import PostgresWindowFrame from pretix.helpers import PostgresWindowFrame
@@ -377,6 +378,11 @@ class Checkin(models.Model):
# For "raw" scans where we do not know which position they belong to (e.g. scan of signed # For "raw" scans where we do not know which position they belong to (e.g. scan of signed
# barcode that is not in database). # barcode that is not in database).
raw_barcode = models.TextField(null=True, blank=True) raw_barcode = models.TextField(null=True, blank=True)
raw_source_type = models.CharField(
max_length=100,
null=True, blank=True,
choices=[(k, v) for k, v in MEDIA_TYPES.items()],
)
raw_item = models.ForeignKey( raw_item = models.ForeignKey(
'pretixbase.Item', 'pretixbase.Item',
related_name='checkins', related_name='checkins',
+1
View File
@@ -142,6 +142,7 @@ class Customer(LoggedModel):
self.save() self.save()
self.all_logentries().update(data={}, shredded=True) self.all_logentries().update(data={}, shredded=True)
self.orders.all().update(customer=None) self.orders.all().update(customer=None)
self.reusable_media.all().update(customer=None)
self.memberships.all().update(attendee_name_parts=None) self.memberships.all().update(attendee_name_parts=None)
self.attendee_profiles.all().delete() self.attendee_profiles.all().delete()
self.invoice_addresses.all().delete() self.invoice_addresses.all().delete()
+2 -1
View File
@@ -180,7 +180,8 @@ class Device(LoggedModel):
'can_view_orders', 'can_view_orders',
'can_change_orders', 'can_change_orders',
'can_view_vouchers', 'can_view_vouchers',
'can_manage_gift_cards' 'can_manage_gift_cards',
'can_manage_reusable_media',
} }
def get_event_permission_set(self, organizer, event) -> set: def get_event_permission_set(self, organizer, event) -> set:
+52
View File
@@ -64,6 +64,7 @@ from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice from pretix.base.models.tax import TaxedPrice
from ...helpers.images import ImageSizeValidator from ...helpers.images import ImageSizeValidator
from ..media import MEDIA_TYPES
from .event import Event, SubEvent from .event import Event, SubEvent
@@ -368,6 +369,16 @@ class Item(LoggedModel):
(VALIDITY_MODE_DYNAMIC, _('Dynamic validity')), (VALIDITY_MODE_DYNAMIC, _('Dynamic validity')),
) )
MEDIA_POLICY_REUSE = 'reuse'
MEDIA_POLICY_NEW = 'new'
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
MEDIA_POLICIES = (
(None, _("Don't use re-usable media, use regular one-off tickets")),
(MEDIA_POLICY_REUSE, _('Require an existing medium to be re-used')),
(MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')),
(MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')),
)
objects = ItemQuerySetManager() objects = ItemQuerySetManager()
event = models.ForeignKey( event = models.ForeignKey(
@@ -630,6 +641,29 @@ class Item(LoggedModel):
help_text=_('The selected start date may only be this many days in the future.') help_text=_('The selected start date may only be this many days in the future.')
) )
media_policy = models.CharField(
choices=MEDIA_POLICIES,
null=True, blank=True, max_length=16,
verbose_name=_('Reusable media policy'),
help_text=_(
'If this product should be stored on a re-usable physical medium, you can attach a physical media policy. '
'This is not required for regular tickets, which just use a one-time barcode, but only for products like '
'renewable season tickets or re-chargable gift card wristbands. '
'This is an advanced feature that also requires specific configuration of ticketing and printing settings.'
)
)
media_type = models.CharField(
max_length=100,
null=True, blank=True,
choices=[(None, _("Don't use re-usable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()],
verbose_name=_('Reusable media type'),
help_text=_(
'Select the type of physical medium that should be used for this product. Note that not all media types '
'support all types of products, and not all media types are supported across all sales channels or '
'check-in processes.'
)
)
# !!! Attention: If you add new fields here, also add them to the copying code in # !!! Attention: If you add new fields here, also add them to the copying code in
# pretix/control/forms/item.py if applicable. # pretix/control/forms/item.py if applicable.
@@ -801,6 +835,24 @@ class Item(LoggedModel):
def has_variations(self): def has_variations(self):
return self.variations.exists() return self.variations.exists()
@staticmethod
def clean_media_settings(event, media_policy, media_type, issue_giftcard):
if media_policy:
if not media_type:
raise ValidationError(_('If you select a reusable media policy, you also need to select a reusable '
'media type.'))
mt = MEDIA_TYPES[media_type]
if not mt.is_active(event.organizer):
raise ValidationError(_('The selected media type is not enabled in your organizer settings.'))
if not mt.supports_orderposition and not issue_giftcard:
raise ValidationError(_('The selected media type does not support usage for tickets currently.'))
if not mt.supports_giftcard and issue_giftcard:
raise ValidationError(_('The selected media type does not support usage for gift cards currently.'))
if issue_giftcard:
raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, '
'gift cards for some reusable media types can be created or re-charged directly '
'at the POS.'))
@staticmethod @staticmethod
def clean_per_order(min_per_order, max_per_order): def clean_per_order(min_per_order, max_per_order):
if min_per_order is not None and max_per_order is not None: if min_per_order is not None and max_per_order is not None:
+125
View File
@@ -0,0 +1,125 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import LoggedModel
from pretix.base.models.customers import Customer
from pretix.base.models.giftcards import GiftCard
from pretix.base.models.orders import OrderPosition
from pretix.base.models.organizer import Organizer
class ReusableMediumQuerySet(models.QuerySet):
def active(self):
return self.filter(
Q(expires__isnull=True) | Q(expires__gte=now()),
active=True,
)
class ReusableMediumQuerySetManager(ScopedManager(organizer='organizer').__class__):
def __init__(self):
super().__init__()
self._queryset_class = ReusableMediumQuerySet
def active(self):
return self.get_queryset().active()
class ReusableMedium(LoggedModel):
id = models.BigAutoField(primary_key=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
organizer = models.ForeignKey(
Organizer,
related_name='reusable_media',
on_delete=models.PROTECT
)
type = models.CharField(
verbose_name=pgettext_lazy('reusable_medium', 'Media type'),
choices=((k, v) for k, v in MEDIA_TYPES.items()),
max_length=100,
)
identifier = models.CharField(
max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Identifier'),
)
active = models.BooleanField(
verbose_name=_('Active'),
default=True
)
expires = models.DateTimeField(
verbose_name=_('Expiration date'),
null=True, blank=True
)
customer = models.ForeignKey(
Customer,
null=True, blank=True,
related_name='reusable_media',
on_delete=models.SET_NULL,
verbose_name=_('Customer account'),
)
linked_orderposition = models.ForeignKey(
OrderPosition,
null=True, blank=True,
related_name='linked_media',
on_delete=models.SET_NULL,
verbose_name=_('Linked ticket'),
)
linked_giftcard = models.ForeignKey(
GiftCard,
null=True, blank=True,
related_name='linked_media',
on_delete=models.SET_NULL,
verbose_name=_('Linked gift card'),
)
info = models.JSONField(
default=dict
)
notes = models.TextField(verbose_name=_('Notes'), null=True, blank=True)
objects = ReusableMediumQuerySetManager()
@cached_property
def media_type(self):
return MEDIA_TYPES[self.type]
@property
def is_expired(self):
return self.expires and self.expires > now()
class Meta:
unique_together = (("identifier", "type", "organizer"),)
index_together = (("identifier", "type", "organizer"), ("updated", "id"))
ordering = "identifier", "type", "organizer"
+6
View File
@@ -236,6 +236,8 @@ class Team(LoggedModel):
:type can_change_teams: bool :type can_change_teams: bool
:param can_manage_customers: If ``True``, the members can view and change organizer-level customer accounts. :param can_manage_customers: If ``True``, the members can view and change organizer-level customer accounts.
:type can_manage_customers: bool :type can_manage_customers: bool
:param can_manage_reusable_media: If ``True``, the members can view and change organizer-level reusable media.
:type can_manage_reusable_media: bool
:param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account. :param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account.
:type can_change_organizer_settings: bool :type can_change_organizer_settings: bool
:param can_change_event_settings: If ``True``, the members can change the settings of the associated events. :param can_change_event_settings: If ``True``, the members can change the settings of the associated events.
@@ -277,6 +279,10 @@ class Team(LoggedModel):
default=False, default=False,
verbose_name=_("Can manage customer accounts") verbose_name=_("Can manage customer accounts")
) )
can_manage_reusable_media = models.BooleanField(
default=False,
verbose_name=_("Can manage reusable media")
)
can_manage_gift_cards = models.BooleanField( can_manage_gift_cards = models.BooleanField(
default=False, default=False,
verbose_name=_("Can manage gift cards") verbose_name=_("Can manage gift cards")
+8
View File
@@ -455,6 +455,11 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT" "TIME_FORMAT"
) if op.valid_until else "" ) if op.valid_until else ""
}), }),
("medium_identifier", {
"label": _("Reusable Medium ID"),
"editor_sample": _("ABC1234DEF4567"),
"evaluate": lambda op, order, ev: op.linked_media.all()[0].identifier if op.linked_media.all() else "",
}),
("seat", { ("seat", {
"label": _("Seat: Full name"), "label": _("Seat: Full name"),
"editor_sample": _("Ground floor, Row 3, Seat 4"), "editor_sample": _("Ground floor, Row 3, Seat 4"),
@@ -761,6 +766,9 @@ class Renderer:
else: else:
content = self._get_text_content(op, order, o) content = self._get_text_content(op, order, o)
if len(content) == 0:
return
level = 'H' level = 'H'
if len(content) > 32: if len(content) > 32:
level = 'M' level = 'M'
+10
View File
@@ -52,6 +52,7 @@ from django_scopes import scopes_disabled
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Seat, CartPosition, Event, InvoiceAddress, Item, ItemVariation, Seat,
SeatCategoryMapping, Voucher, SeatCategoryMapping, Voucher,
@@ -199,6 +200,8 @@ error_messages = {
'seat_multiple': gettext_lazy('You can not select the same seat multiple times.'), 'seat_multiple': gettext_lazy('You can not select the same seat multiple times.'),
'gift_card': gettext_lazy("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."), 'gift_card': gettext_lazy("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'), 'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
'media_usage_not_implemented': gettext_lazy('The configuration of this product requires mapping to a physical '
'medium, which is currently not available online.'),
} }
@@ -394,6 +397,13 @@ class CartManager:
if not op.item.is_available() or (op.variation and not op.variation.is_available()): if not op.item.is_available() or (op.variation and not op.variation.is_available()):
raise CartError(error_messages['unavailable']) raise CartError(error_messages['unavailable'])
if op.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
mt = MEDIA_TYPES[op.item.media_type]
if not mt.medium_created_by_server:
raise CartError(error_messages['media_usage_not_implemented'])
elif op.item.media_policy == Item.MEDIA_POLICY_REUSE:
raise CartError(error_messages['media_usage_not_implemented'])
if self._sales_channel not in op.item.sales_channels or (op.variation and self._sales_channel not in op.variation.sales_channels): if self._sales_channel not in op.item.sales_channels or (op.variation and self._sales_channel not in op.variation.sales_channels):
raise CartError(error_messages['unavailable']) raise CartError(error_messages['unavailable'])
+2 -1
View File
@@ -693,7 +693,7 @@ def _save_answers(op, answers, given_answers):
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False, def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
raw_barcode=None, from_revoked_secret=False): raw_barcode=None, raw_source_type=None, from_revoked_secret=False):
""" """
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time. not valid at this time.
@@ -870,6 +870,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
forced=force and (not entry_allowed or from_revoked_secret or force_used), forced=force and (not entry_allowed or from_revoked_secret or force_used),
force_sent=force, force_sent=force,
raw_barcode=raw_barcode, raw_barcode=raw_barcode,
raw_source_type=raw_source_type,
) )
op.order.log_action('pretix.event.checkin', data={ op.order.log_action('pretix.event.checkin', data={
'position': op.id, 'position': op.id,
+30
View File
@@ -62,6 +62,7 @@ from pretix.api.models import OAuthApplication
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
from pretix.base.email import get_email_context from pretix.base.email import get_email_context
from pretix.base.i18n import get_language_without_region, language from pretix.base.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership, CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
@@ -2911,3 +2912,32 @@ def signal_listener_issue_memberships(sender: Event, order: Order, **kwargs):
for p in order.positions.all(): for p in order.positions.all():
if p.item.grant_membership_type_id: if p.item.grant_membership_type_id:
create_membership(order.customer, p) create_membership(order.customer, p)
@receiver(order_placed, dispatch_uid="pretixbase_order_placed_media")
@receiver(order_changed, dispatch_uid="pretixbase_order_changed_media")
@transaction.atomic()
def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
from pretix.base.models import ReusableMedium
for p in order.positions.all():
if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
mt = MEDIA_TYPES[p.item.media_type]
if mt.medium_created_by_server and not p.linked_media.exists():
rm = ReusableMedium.objects.create(
organizer=sender.organizer,
type=p.item.media_type,
identifier=mt.generate_identifier(sender.organizer),
active=True,
customer=order.customer,
linked_orderposition=p,
)
rm.log_action(
'pretix.reusable_medium.created',
data={
'by_order': order.code,
'linked_orderposition': p.pk,
'active': True,
'customer': order.customer_id,
}
)
+84 -1
View File
@@ -169,6 +169,84 @@ DEFAULTS = {
"was not logged in during the purchase.") "was not logged in during the purchase.")
) )
}, },
'reusable_media_active': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Activate re-usable media"),
help_text=_("The re-usable media feature allows you to connect tickets and gift cards with physical media "
"such as wristbands or chip cards that may be re-used for different tickets or gift cards "
"later.")
)
},
'reusable_media_type_barcode': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Active"),
)
},
'reusable_media_type_barcode_identifier_length': {
'default': 24,
'type': int,
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict(
validators=[
MinValueValidator(12),
MaxValueValidator(64),
]
),
'form_kwargs': dict(
label=_('Length of barcodes'),
validators=[
MinValueValidator(12),
MaxValueValidator(64),
],
required=True,
widget=forms.NumberInput(
attrs={
'min': '12',
'max': '64',
},
),
)
},
'reusable_media_type_nfc_uid': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Active"),
)
},
'reusable_media_type_nfc_uid_autocreate_giftcard': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Automatically create a new gift card if a previously unknown chip is seen"),
)
},
'reusable_media_type_nfc_uid_autocreate_giftcard_currency': {
'default': 'EUR',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': dict(
choices=[(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES],
),
'form_kwargs': dict(
choices=[(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES],
label=_("Gift card currency"),
)
},
'max_items_per_order': { 'max_items_per_order': {
'default': '10', 'default': '10',
'type': int, 'type': int,
@@ -3416,7 +3494,12 @@ def validate_organizer_settings(organizer, settings_dict):
# organizer-settings either. # organizer-settings either.
# #
# N.B.: When actually fleshing out this stub, adding it to the OrganizerUpdateForm should be considered. # N.B.: When actually fleshing out this stub, adding it to the OrganizerUpdateForm should be considered.
pass """
if settings_dict.get('reusable_media_type_ntag_pretix1') and settings_dict.get('reusable_media_type_nfc_uid'):
raise ValidationError({
'reusable_media_type_nfc_uid': _('This needs to be disabled if other NFC-based types are active.')
})
"""
def global_settings_object(holder): def global_settings_object(holder):
+56
View File
@@ -1419,6 +1419,62 @@ class CustomerFilterForm(FilterForm):
return qs.distinct() return qs.distinct()
class ReusableMediaFilterForm(FilterForm):
orders = {
'type': 'type',
'identifier': 'identifier',
}
query = forms.CharField(
label=_('Search query'),
widget=forms.TextInput(attrs={
'placeholder': _('Search query'),
'autofocus': 'autofocus'
}),
required=False
)
status = forms.ChoiceField(
label=_('Status'),
required=False,
choices=(
('', _('All')),
('active', _('active')),
('disabled', _('disabled')),
('expired', _('expired')),
)
)
def __init__(self, *args, **kwargs):
kwargs.pop('request')
super().__init__(*args, **kwargs)
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(
Q(identifier__icontains=query)
| Q(customer__identifier__icontains=query)
| Q(customer__external_identifier__istartswith=query)
| Q(linked_orderposition__order__code__icontains=query)
| Q(linked_giftcard__secret__icontains=query)
)
if fdata.get('status') == 'active':
qs = qs.filter(Q(expires__gt=now()) | Q(expires__isnull=False), active=True)
elif fdata.get('status') == 'disabled':
qs = qs.filter(active=False)
elif fdata.get('status') == 'expired':
qs = qs.filter(expires__lte=now())
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
else:
qs = qs.order_by("identifier", "type", "organizer")
return qs.distinct()
class TeamFilterForm(FilterForm): class TeamFilterForm(FilterForm):
orders = { orders = {
'name': 'name', 'name': 'name',
+10
View File
@@ -401,6 +401,8 @@ class ItemCreateForm(I18nModelForm):
'validity_dynamic_duration_months', 'validity_dynamic_duration_months',
'validity_dynamic_start_choice', 'validity_dynamic_start_choice',
'validity_dynamic_start_choice_day_limit', 'validity_dynamic_start_choice_day_limit',
'media_type',
'media_policy',
) )
for f in fields: for f in fields:
setattr(self.instance, f, getattr(src, f)) setattr(self.instance, f, getattr(src, f))
@@ -592,6 +594,10 @@ class ItemUpdateForm(I18nModelForm):
del self.fields['grant_membership_duration_days'] del self.fields['grant_membership_duration_days']
del self.fields['grant_membership_duration_months'] del self.fields['grant_membership_duration_months']
if not self.event.settings.reusable_media_active:
del self.fields['media_type']
del self.fields['media_policy']
def clean(self): def clean(self):
d = super().clean() d = super().clean()
if d['issue_giftcard']: if d['issue_giftcard']:
@@ -635,6 +641,8 @@ class ItemUpdateForm(I18nModelForm):
_("The start of validity must be before the end of validity.") _("The start of validity must be before the end of validity.")
) )
Item.clean_media_settings(self.event, d.get('media_policy'), d.get('media_type'), d.get('issue_giftcard'))
return d return d
def clean_picture(self): def clean_picture(self):
@@ -693,6 +701,8 @@ class ItemUpdateForm(I18nModelForm):
'validity_dynamic_duration_months', 'validity_dynamic_duration_months',
'validity_dynamic_start_choice', 'validity_dynamic_start_choice',
'validity_dynamic_start_choice_day_limit', 'validity_dynamic_start_choice_day_limit',
'media_policy',
'media_type',
] ]
field_classes = { field_classes = {
'available_from': SplitDateTimeField, 'available_from': SplitDateTimeField,
+147 -3
View File
@@ -41,10 +41,14 @@ from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from django.forms.utils import ErrorDict from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelMultipleChoiceField from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
)
from i18nfield.forms import ( from i18nfield.forms import (
I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
) )
@@ -62,15 +66,18 @@ from pretix.base.forms.questions import (
from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import ( from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership, Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
MembershipType, Organizer, Team, MembershipType, OrderPosition, Organizer, ReusableMedium, Team,
) )
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.organizer import OrganizerFooterLink from pretix.base.models.organizer import OrganizerFooterLink
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_organizer_settings,
)
from pretix.control.forms import ExtFileField, SplitDateTimeField from pretix.control.forms import ExtFileField, SplitDateTimeField
from pretix.control.forms.event import ( from pretix.control.forms.event import (
SafeEventMultipleChoiceField, multimail_validate, SafeEventMultipleChoiceField, multimail_validate,
) )
from pretix.control.forms.widgets import Select2
from pretix.multidomain.models import KnownDomain from pretix.multidomain.models import KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
@@ -208,6 +215,7 @@ class TeamForm(forms.ModelForm):
fields = ['name', 'all_events', 'limit_events', 'can_create_events', fields = ['name', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings', 'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards', 'can_manage_customers', 'can_manage_gift_cards', 'can_manage_customers',
'can_manage_reusable_media',
'can_change_event_settings', 'can_change_items', 'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders', 'can_checkin_orders', 'can_view_orders', 'can_change_orders', 'can_checkin_orders',
'can_view_vouchers', 'can_change_vouchers'] 'can_view_vouchers', 'can_change_vouchers']
@@ -389,6 +397,12 @@ class OrganizerSettingsForm(SettingsForm):
'cookie_consent_dialog_text_secondary', 'cookie_consent_dialog_text_secondary',
'cookie_consent_dialog_button_yes', 'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no', 'cookie_consent_dialog_button_no',
'reusable_media_active',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_uid_autocreate_giftcard',
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
] ]
organizer_logo_image = ExtFileField( organizer_logo_image = ExtFileField(
@@ -431,6 +445,26 @@ class OrganizerSettingsForm(SettingsForm):
)) ))
for k, v in PERSON_NAME_TITLE_GROUPS.items() for k, v in PERSON_NAME_TITLE_GROUPS.items()
] ]
self.fields['reusable_media_active'].label = mark_safe(
conditional_escape(self.fields['reusable_media_active'].label) +
' ' +
'<span class="label label-info">{}</span>'.format(_('experimental'))
)
self.fields['reusable_media_active'].help_text = mark_safe(
conditional_escape(self.fields['reusable_media_active'].help_text) +
' ' +
'<br/><span class="fa fa-flask"></span> ' +
_('This feature is currently in an experimental stage. It only supports very limited use cases and might '
'change at any point.')
)
def clean(self):
data = super().clean()
settings_dict = self.obj.settings.freeze()
settings_dict.update(data)
validate_organizer_settings(self.obj, data)
return data
class MailSettingsForm(SettingsForm): class MailSettingsForm(SettingsForm):
@@ -626,6 +660,116 @@ class GiftCardUpdateForm(forms.ModelForm):
} }
class SafeOrderPositionChoiceField(forms.ModelChoiceField):
def __init__(self, queryset, **kwargs):
queryset = queryset.model.all.none()
super().__init__(queryset, **kwargs)
def label_from_instance(self, op):
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
class ReusableMediumUpdateForm(forms.ModelForm):
error_messages = {
'duplicate': _("An medium with this type and identifier is already registered."),
}
class Meta:
model = ReusableMedium
fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderposition', 'notes']
field_classes = {
'expires': SplitDateTimeField,
'customer': SafeModelChoiceField,
'linked_giftcard': SafeModelChoiceField,
'linked_orderposition': SafeOrderPositionChoiceField,
}
widgets = {
'expires': SplitDateTimePickerWidget,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
organizer = self.instance.organizer
self.fields['linked_orderposition'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
self.fields['linked_orderposition'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
'organizer': organizer.slug,
}),
'data-placeholder': _('Ticket')
}
)
self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices
self.fields['linked_orderposition'].required = False
self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all()
self.fields['linked_giftcard'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.giftcards.select2', kwargs={
'organizer': organizer.slug,
}),
'data-placeholder': _('Gift card')
}
)
self.fields['linked_giftcard'].widget.choices = self.fields['linked_giftcard'].choices
self.fields['linked_giftcard'].required = False
if organizer.settings.customer_accounts:
self.fields['customer'].queryset = organizer.customers.all()
self.fields['customer'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
'organizer': organizer.slug,
}),
'data-placeholder': _('Customer')
}
)
self.fields['customer'].widget.choices = self.fields['customer'].choices
self.fields['customer'].required = False
else:
del self.fields['customer']
def clean(self):
identifier = self.cleaned_data.get('identifier')
type = self.cleaned_data.get('type')
if identifier is not None and type is not None:
try:
self.instance.organizer.reusable_media.exclude(pk=self.instance.pk).get(
identifier=identifier,
type=type,
)
except ReusableMedium.DoesNotExist:
pass
else:
raise forms.ValidationError(
self.error_messages['duplicate'],
code='duplicate',
)
return self.cleaned_data
class ReusableMediumCreateForm(ReusableMediumUpdateForm):
class Meta:
model = ReusableMedium
fields = ['active', 'type', 'identifier', 'expires', 'linked_orderposition', 'linked_giftcard', 'customer', 'notes']
field_classes = {
'expires': SplitDateTimeField,
'customer': SafeModelChoiceField,
'linked_giftcard': SafeModelChoiceField,
'linked_orderposition': SafeOrderPositionChoiceField,
}
widgets = {
'expires': SplitDateTimePickerWidget,
}
class CustomerUpdateForm(forms.ModelForm): class CustomerUpdateForm(forms.ModelForm):
error_messages = { error_messages = {
'duplicate': _("An account with this email address is already registered."), 'duplicate': _("An account with this email address is already registered."),
+4
View File
@@ -362,6 +362,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.customer.anonymized': _('The account has been disabled and anonymized.'), 'pretix.customer.anonymized': _('The account has been disabled and anonymized.'),
'pretix.customer.password.resetrequested': _('A new password has been requested.'), 'pretix.customer.password.resetrequested': _('A new password has been requested.'),
'pretix.customer.password.set': _('A new password has been set.'), 'pretix.customer.password.set': _('A new password has been set.'),
'pretix.reusable_medium.created': _('The reusable medium has been created.'),
'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'),
'pretix.reusable_medium.changed': _('The reusable medium has been changed.'),
'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'),
'pretix.email.error': _('Sending of an email has failed.'), 'pretix.email.error': _('Sending of an email has failed.'),
'pretix.event.comment': _('The event\'s internal comment has been updated.'), 'pretix.event.comment': _('The event\'s internal comment has been updated.'),
'pretix.event.canceled': _('The event has been canceled.'), 'pretix.event.canceled': _('The event has been canceled.'),
+10
View File
@@ -578,6 +578,16 @@ def get_organizer_navigation(request):
'children': children, 'children': children,
}) })
if request.organizer.settings.reusable_media_active:
nav.append({
'label': _('Reusable media'),
'url': reverse('control:organizer.reusable_media', kwargs={
'organizer': request.organizer.slug
}),
'icon': 'key',
'active': 'organizer.reusable_medi' in url.url_name,
})
if 'can_change_organizer_settings' in request.orgapermset: if 'can_change_organizer_settings' in request.orgapermset:
nav.append({ nav.append({
'label': _('Devices'), 'label': _('Devices'),
@@ -4,6 +4,7 @@
{% load formset_tags %} {% load formset_tags %}
{% block inside %} {% block inside %}
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"> <form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% bootstrap_form_errors form layout="control" %}
{% csrf_token %} {% csrf_token %}
<div class="row"> <div class="row">
<div class="col-xs-12 col-lg-10"> <div class="col-xs-12 col-lg-10">
@@ -178,6 +179,12 @@
<fieldset> <fieldset>
<legend>{% trans "Tickets & Badges" %}</legend> <legend>{% trans "Tickets & Badges" %}</legend>
{% bootstrap_field form.generate_tickets layout="control" %} {% bootstrap_field form.generate_tickets layout="control" %}
{% if form.media_policy %}
{% bootstrap_field form.media_policy layout="control" %}
{% endif %}
{% if form.media_type %}
{% bootstrap_field form.media_type layout="control" %}
{% endif %}
{% for f in plugin_forms %} {% for f in plugin_forms %}
{% if f.is_layouts %} {% if f.is_layouts %}
{% bootstrap_form f layout="control" %} {% bootstrap_form f layout="control" %}
@@ -462,6 +462,14 @@
</dd> </dd>
</div> </div>
{% endif %} {% endif %}
{% for m in line.linked_media.all %}
<div class="cart-icon-details">
<dd>
<span class="fa fa-key fa-fw" aria-hidden="true"></span>
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}">{{ m.identifier }}</a> <span class="text-muted">({{ m.get_type_display }})</span>
</dd>
</div>
{% endfor %}
{% if not line.canceled %} {% if not line.canceled %}
<div class="position-buttons"> <div class="position-buttons">
{% if line.generate_ticket %} {% if line.generate_ticket %}
@@ -200,6 +200,70 @@
{% bootstrap_field sform.cookie_consent_dialog_button_yes layout="control" %} {% bootstrap_field sform.cookie_consent_dialog_button_yes layout="control" %}
{% bootstrap_field sform.cookie_consent_dialog_button_no layout="control" %} {% bootstrap_field sform.cookie_consent_dialog_button_no layout="control" %}
</fieldset> </fieldset>
<fieldset>
<legend>{% trans "Reusable media" %}</legend>
{% bootstrap_field sform.reusable_media_active layout="control" %}
<div data-display-dependency="#{{ sform.reusable_media_active.id_for_label }}">
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">{% trans "Barcode media" %}</h4>
</div>
<div class="panel-body">
<p class="help-block">
{% blocktrans trimmed %}
A "barcode medium" can be any printed or digital representation of a barcode.
The medium will initially be created through the sale of a product that has a
media policy requiring such a medium as well as a ticket or badge layout that
includes the "Reusable Medium ID" as a QR code. Later, the same barcode may
be re-used during the sale of a different product.
{% endblocktrans %}
{% blocktrans trimmed %}
Barcode media can currently only be connected to tickets.
{% endblocktrans %}
{% blocktrans trimmed %}
This subsequent reuse of the barcode is currently only supported during POS sales.
{% endblocktrans %}
</p>
{% bootstrap_field sform.reusable_media_type_barcode layout="control" %}
<div data-display-dependency="#{{ sform.reusable_media_type_barcode.id_for_label }}">
{% bootstrap_field sform.reusable_media_type_barcode_identifier_length layout="control" %}
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">{% trans "NFC UID-based" %}</h4>
</div>
<div class="panel-body">
<p class="help-block">
{% blocktrans trimmed %}
This medium type can work with almost any type of NFC chip. With this
option, only the UID of the NFC chip is used for identification.
{% endblocktrans %}
{% blocktrans trimmed %}
NFC media can currently only be connected to gift cards.
{% endblocktrans %}
</p>
<p class="help-block">
<span class="fa fa-warning text-warning"></span>
{% blocktrans trimmed %}
This method does not provide a high level of protection against abuse since it
is possible for malicious users to clone someone's chip with the same UID.
{% endblocktrans %}
</p>
{% bootstrap_field sform.reusable_media_type_nfc_uid layout="control" %}
<div data-display-dependency="#{{ sform.reusable_media_type_nfc_uid.id_for_label }}">
{% bootstrap_field sform.reusable_media_type_nfc_uid_autocreate_giftcard layout="control" %}
<div data-display-dependency="#{{ sform.reusable_media_type_nfc_uid_autocreate_giftcard.id_for_label }}">
{% bootstrap_field sform.reusable_media_type_nfc_uid_autocreate_giftcard_currency layout="control" %}
</div>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset> <fieldset>
<legend>{% trans "Invoices" %}</legend> <legend>{% trans "Invoices" %}</legend>
{% bootstrap_field sform.invoice_regenerate_allowed layout="control" %} {% bootstrap_field sform.invoice_regenerate_allowed layout="control" %}
@@ -0,0 +1,113 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load urlreplace %}
{% load bootstrap3 %}
{% load money %}
{% block title %}{% trans "Reusable media" %}{% endblock %}
{% block inner %}
<h1>
{% trans "Reusable media" %}
</h1>
{% if media|length == 0 and not filter_form.filtered %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
No media have been created yet.
{% endblocktrans %}
</p>
<a href="{% url "control:organizer.reusable_medium.create" organizer=request.organizer.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new medium" %}</a>
</div>
{% else %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Filter" %}</h3>
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="col-md-6 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.query layout='inline' %}
</div>
<div class="col-md-6 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.status layout='inline' %}
</div>
</div>
<div class="text-right">
<button class="btn btn-primary btn-lg" type="submit">
<span class="fa fa-filter"></span>
{% trans "Filter" %}
</button>
</div>
</form>
</div>
<p>
<a href="{% url "control:organizer.reusable_medium.create" organizer=request.organizer.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new medium" %}</a>
</p>
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Identifier" context "reusable_media" %}
<a href="?{% url_replace request 'ordering' '-identifier' %}"><i
class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'identifier' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Media type" context "reusable_media" %}
<a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Connections" context "reusable_media" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for m in media %}
<tr>
<td>
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}">
{% if not m.active %}<strike>{% endif %}
<strong>{{ m.identifier }}</strong>
{% if not m.active %}</strike>{% endif %}
</a>
</td>
<td>
{{ m.get_type_display }}
</td>
<td>
{% if m.customer %}
<span class="helper-display-block">
<span class="fa fa-user fa-fw"></span>
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=m.customer.identifier %}">
{{ m.customer }}
</a>
</span>
{% endif %}
{% if m.linked_orderposition %}
<span class="helper-display-block">
<span class="fa fa-ticket fa-fw"></span>
<a href="{% url "control:event.order" event=m.linked_orderposition.order.event.slug organizer=request.organizer.slug code=m.linked_orderposition.order.code %}">
{{ m.linked_orderposition.order.code }}</a>-{{ m.linked_orderposition.positionid }}
</span>
{% endif %}
{% if m.linked_giftcard %}
<span class="helper-display-block">
<span class="fa fa-credit-card fa-fw"></span>
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=m.linked_giftcard.id %}">
{{ m.linked_giftcard.secret }}</a>
</span>
{% endif %}
</td>
<td class="text-right">
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}"
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Details" %}">
<i class="fa fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}
@@ -0,0 +1,93 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% block title %}
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
Medium {{ id }}
{% endblocktrans %}
{% endblock %}
{% block inner %}
<h1>
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
Medium {{ id }}
{% endblocktrans %}
</h1>
<div class="row">
<div class="col-md-10 col-xs-12">
<div class="panel panel-primary items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Details" %}
</h3>
</div>
<div class="panel-body">
<form action="" method="post">
{% csrf_token %}
<dl class="dl-horizontal">
<dt>{% trans "Media type" context "reusable_media" %}</dt>
<dd>{{ medium.get_type_display }}</dd>
<dt>{% trans "Identifier" context "reusable_media" %}</dt>
<dd><code>{{ medium.identifier }}</code></dd>
<dt>{% trans "Status" %}</dt>
<dd>
{% if not medium.active %}
{% trans "disabled" %}
{% elif medium.is_expired %}
{% trans "expired" %}
{% else %}
{% trans "active" %}
{% endif %}
</dd>
<dt>{% trans "Connections" context "reusable_media" %}</dt>
<dd>
{% if medium.customer %}
<span class="helper-display-block">
<span class="fa fa-user fa-fw"></span>
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=medium.customer.identifier %}">
{{ medium.customer }}
</a>
</span>
{% endif %}
{% if medium.linked_orderposition %}
<span class="helper-display-block">
<span class="fa fa-ticket fa-fw"></span>
<a href="{% url "control:event.order" event=medium.linked_orderposition.order.event.slug organizer=request.organizer.slug code=medium.linked_orderposition.order.code %}">
{{ medium.linked_orderposition.order.code }}</a>-{{ medium.linked_orderposition.positionid }}
</span>
{% endif %}
{% if medium.linked_giftcard %}
<span class="helper-display-block">
<span class="fa fa-credit-card fa-fw"></span>
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=medium.linked_giftcard.id %}">
{{ medium.linked_giftcard.secret }}</a>
</span>
{% endif %}
</dd>
{% if medium.notes %}
<dt>{% trans "Notes" %}</dt>
<dd>{{ medium.notes }}</dd>
{% endif %}
</dl>
</form>
<div class="text-right">
<a href="{% url "control:organizer.reusable_medium.edit" organizer=request.organizer.slug pk=medium.pk %}"
class="btn btn-default">
<i class="fa fa-edit"></i> {% trans "Edit" %}
</a>
</div>
</div>
</div>
</div>
<div class="col-md-2 col-xs-12">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Medium history" context "reusable_media" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=medium %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% if not medium.pk %}
{% trans "New medium" context "reusable_media" %}
{% else %}
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
Medium {{ id }}
{% endblocktrans %}
{% endif %}
{% endblock %}
{% block inner %}
<h1>
{% if not medium.pk %}
{% trans "New medium" context "reusable_media" %}
{% else %}
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
Medium {{ id }}
{% endblocktrans %}
{% endif %}
</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -24,6 +24,7 @@
{% bootstrap_field form.can_create_events layout="control" %} {% bootstrap_field form.can_create_events layout="control" %}
{% bootstrap_field form.can_manage_gift_cards layout="control" %} {% bootstrap_field form.can_manage_gift_cards layout="control" %}
{% bootstrap_field form.can_manage_customers layout="control" %} {% bootstrap_field form.can_manage_customers layout="control" %}
{% bootstrap_field form.can_manage_reusable_media layout="control" %}
{% bootstrap_field form.can_change_teams layout="control" %} {% bootstrap_field form.can_change_teams layout="control" %}
{% bootstrap_field form.can_change_organizer_settings layout="control" %} {% bootstrap_field form.can_change_organizer_settings layout="control" %}
</fieldset> </fieldset>
+9
View File
@@ -164,7 +164,15 @@ urlpatterns = [
organizer.MembershipDeleteView.as_view(), name='organizer.customer.membership.delete'), organizer.MembershipDeleteView.as_view(), name='organizer.customer.membership.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/anonymize$', re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/anonymize$',
organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'), organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'),
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media$', organizer.ReusableMediaListView.as_view(), name='organizer.reusable_media'),
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media/add$',
organizer.ReusableMediumCreateView.as_view(), name='organizer.reusable_medium.create'),
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media/(?P<pk>[^/]+)/$',
organizer.ReusableMediumDetailView.as_view(), name='organizer.reusable_medium'),
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media/(?P<pk>[^/]+)/edit$',
organizer.ReusableMediumUpdateView.as_view(), name='organizer.reusable_medium.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'), re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards/select2$', typeahead.giftcard_select2, name='organizer.giftcards.select2'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'), re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'), re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/edit$', organizer.GiftCardUpdateView.as_view(), re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/edit$', organizer.GiftCardUpdateView.as_view(),
@@ -213,6 +221,7 @@ urlpatterns = [
name='organizer.export.scheduled.run'), name='organizer.export.scheduled.run'),
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(), re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(),
name='organizer.export.scheduled.delete'), name='organizer.export.scheduled.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/ticket_select2$', typeahead.ticket_select2, name='organizer.ticket_select2'),
re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'), re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'),
re_path(r'^events/$', main.EventList.as_view(), name='events'), re_path(r'^events/$', main.EventList.as_view(), name='events'),
re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'), re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'),
+1 -1
View File
@@ -367,7 +367,7 @@ class OrderDetail(OrderView):
'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type', 'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type',
'discount', 'discount',
).prefetch_related( ).prefetch_related(
'item__questions', 'issued_gift_cards', 'item__questions', 'issued_gift_cards', 'linked_media',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')), Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
Prefetch('all_checkins', queryset=Checkin.all.select_related('list').order_by('datetime')), Prefetch('all_checkins', queryset=Checkin.all.select_related('list').order_by('datetime')),
).order_by('positionid') ).order_by('positionid')
+104 -5
View File
@@ -73,7 +73,7 @@ from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry, CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry,
Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer, Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer,
ScheduledOrganizerExport, Team, TeamInvite, User, ReusableMedium, ScheduledOrganizerExport, Team, TeamInvite, User,
) )
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue
@@ -92,7 +92,7 @@ from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.exports import ScheduledOrganizerExportForm from pretix.control.forms.exports import ScheduledOrganizerExportForm
from pretix.control.forms.filter import ( from pretix.control.forms.filter import (
CustomerFilterForm, DeviceFilterForm, EventFilterForm, GiftCardFilterForm, CustomerFilterForm, DeviceFilterForm, EventFilterForm, GiftCardFilterForm,
OrganizerFilterForm, TeamFilterForm, OrganizerFilterForm, ReusableMediaFilterForm, TeamFilterForm,
) )
from pretix.control.forms.orders import ExporterForm from pretix.control.forms.orders import ExporterForm
from pretix.control.forms.organizer import ( from pretix.control.forms.organizer import (
@@ -100,8 +100,9 @@ from pretix.control.forms.organizer import (
EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm, EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm,
MailSettingsForm, MembershipTypeForm, MembershipUpdateForm, MailSettingsForm, MembershipTypeForm, MembershipUpdateForm,
OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm, OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm,
OrganizerSettingsForm, OrganizerUpdateForm, SSOClientForm, SSOProviderForm, OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm,
TeamForm, WebHookForm, ReusableMediumUpdateForm, SSOClientForm, SSOProviderForm, TeamForm,
WebHookForm,
) )
from pretix.control.forms.rrule import RRuleForm from pretix.control.forms.rrule import RRuleForm
from pretix.control.logdisplay import OVERVIEW_BANLIST from pretix.control.logdisplay import OVERVIEW_BANLIST
@@ -533,7 +534,7 @@ class OrganizerCreate(CreateView):
organizer=form.instance, name=_('Administrators'), organizer=form.instance, name=_('Administrators'),
all_events=True, can_create_events=True, can_change_teams=True, can_manage_gift_cards=True, all_events=True, can_create_events=True, can_change_teams=True, can_manage_gift_cards=True,
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True, can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
can_manage_customers=True, can_manage_customers=True, can_manage_reusable_media=True,
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
) )
t.members.add(self.request.user) t.members.add(self.request.user)
@@ -2738,3 +2739,101 @@ class CustomerAnonymizeView(OrganizerDetailViewMixin, OrganizerPermissionRequire
'organizer': self.request.organizer.slug, 'organizer': self.request.organizer.slug,
'customer': self.object.identifier, 'customer': self.object.identifier,
}) })
class ReusableMediaListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
model = ReusableMedium
template_name = 'pretixcontrol/organizers/reusable_media.html'
permission = 'can_manage_reusable_media'
context_object_name = 'media'
def get_queryset(self):
qs = self.request.organizer.reusable_media.select_related(
'customer', 'linked_orderposition', 'linked_orderposition__order', 'linked_orderposition__order__event',
'linked_giftcard'
)
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
return ctx
@cached_property
def filter_form(self):
return ReusableMediaFilterForm(data=self.request.GET, request=self.request)
class ReusableMediumDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView):
template_name = 'pretixcontrol/organizers/reusable_medium.html'
permission = 'can_manage_reusable_media'
@cached_property
def medium(self):
return get_object_or_404(
self.request.organizer.reusable_media,
pk=self.kwargs.get('pk')
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['medium'] = self.medium
return ctx
class ReusableMediumCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
template_name = 'pretixcontrol/organizers/reusable_medium_edit.html'
permission = 'can_manage_reusable_media'
context_object_name = 'medium'
form_class = ReusableMediumCreateForm
def get_form_kwargs(self):
ctx = super().get_form_kwargs()
c = ReusableMedium(organizer=self.request.organizer)
ctx['instance'] = c
return ctx
def form_valid(self, form):
r = super().form_valid(form)
form.instance.log_action('pretix.reusable_medium.created', user=self.request.user, data={
k: getattr(form.instance, k)
for k in form.changed_data
})
messages.success(self.request, _('Your changes have been saved.'))
return r
def get_success_url(self):
return reverse('control:organizer.reusable_medium', kwargs={
'organizer': self.request.organizer.slug,
'pk': self.object.pk,
})
class ReusableMediumUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
template_name = 'pretixcontrol/organizers/reusable_medium_edit.html'
permission = 'can_manage_reusable_media'
context_object_name = 'medium'
form_class = ReusableMediumUpdateForm
def get_object(self, queryset=None):
return get_object_or_404(
self.request.organizer.reusable_media,
pk=self.kwargs.get('pk')
)
def form_valid(self, form):
if form.has_changed():
self.object.log_action('pretix.reusable_medium.changed', user=self.request.user, data={
k: getattr(self.object, k)
for k in form.changed_data
})
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def get_success_url(self):
return reverse('control:organizer.reusable_medium', kwargs={
'organizer': self.request.organizer.slug,
'pk': self.object.pk,
})
+100 -2
View File
@@ -48,8 +48,8 @@ from django.utils.translation import gettext as _, pgettext
from pretix.base.models import ( from pretix.base.models import (
EventMetaProperty, EventMetaValue, ItemMetaProperty, ItemMetaValue, EventMetaProperty, EventMetaValue, ItemMetaProperty, ItemMetaValue,
ItemVariation, ItemVariationMetaValue, Order, Organizer, SubEventMetaValue, ItemVariation, ItemVariationMetaValue, Order, OrderPosition, Organizer,
User, Voucher, SubEventMetaValue, User, Voucher,
) )
from pretix.control.forms.event import EventWizardCopyForm from pretix.control.forms.event import EventWizardCopyForm
from pretix.control.permissions import ( from pretix.control.permissions import (
@@ -172,6 +172,104 @@ def event_list(request):
return JsonResponse(doc) return JsonResponse(doc)
@organizer_permission_required(("can_manage_gift_cards", "can_manage_reusable_media"))
def giftcard_select2(request, **kwargs):
query = request.GET.get('query', '')
try:
page = int(request.GET.get('page', '1'))
except ValueError:
page = 1
if request.user.has_organizer_permission(request.organizer, 'can_manage_gift_cards', request):
qs = request.organizer.issued_gift_cards.filter(
Q(secret__icontains=query)
).order_by('secret')
else:
qs = request.organizer.issued_gift_cards.filter(
Q(secret__iexact=query)
).order_by('secret')
if not query:
qs = qs.none()
total = qs.count()
pagesize = 20
offset = (page - 1) * pagesize
doc = {
'results': [
{
'id': e.pk,
'text': str(e),
}
for e in qs[offset:offset + pagesize]
],
'pagination': {
"more": total >= (offset + pagesize)
}
}
return JsonResponse(doc)
@organizer_permission_required(("can_manage_reusable_media"))
def ticket_select2(request, **kwargs):
query = request.GET.get('query', '')
try:
page = int(request.GET.get('page', '1'))
except ValueError:
page = 1
qs_orders = OrderPosition.all.select_related('order', 'order__event', 'item', 'variation').filter(
order__event__organizer=request.organizer,
).order_by()
exact_match = Q(secret__iexact=query)
soft_match = Q(secret__icontains=query)
qsplit = query.split("-")
if len(qsplit) >= 3 and qsplit[2].isdigit():
soft_match |= Q(order__event__slug__iexact=qsplit[0], order__code__iexact=qsplit[1], positionid=qsplit[2])
elif len(qsplit) >= 2 and qsplit[1].isdigit():
soft_match |= Q(order__code__istartswith=qsplit[0], positionid=qsplit[1])
elif len(qsplit) >= 2:
soft_match |= Q(order__event__slug__iexact=qsplit[0], order__code__istartswith=qsplit[1])
else:
soft_match |= Q(order__code__istartswith=qsplit[0])
if not request.user.has_active_staff_session(request.session.session_key):
qs_orders = qs_orders.filter(
exact_match | (
soft_match & (
Q(order__event__organizer_id__in=request.user.teams.filter(all_events=True, can_view_orders=True).values_list('organizer', flat=True))
| Q(order__event_id__in=request.user.teams.filter(can_view_orders=True).values_list('limit_events__id', flat=True))
)
)
)
else:
qs_orders = qs_orders.filter(exact_match | soft_match)
if not query:
qs_orders = qs_orders.none()
total = qs_orders.count()
pagesize = 20
offset = (page - 1) * pagesize
doc = {
'results': [
{
'id': op.pk,
'text': f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})',
'event': str(op.order.event)
}
for op in qs_orders[offset:offset + pagesize]
],
'pagination': {
"more": total >= (offset + pagesize)
}
}
return JsonResponse(doc)
@organizer_permission_required("can_manage_customers") @organizer_permission_required("can_manage_customers")
def customer_select2(request, **kwargs): def customer_select2(request, **kwargs):
query = request.GET.get('query', '') query = request.GET.get('query', '')
+1
View File
@@ -116,6 +116,7 @@ def team(organizer):
can_view_vouchers=True, can_view_vouchers=True,
can_change_orders=True, can_change_orders=True,
can_manage_customers=True, can_manage_customers=True,
can_manage_reusable_media=True,
can_change_organizer_settings=True can_change_organizer_settings=True
) )
+69 -1
View File
@@ -33,7 +33,9 @@ from pytz import UTC
from tests.const import SAMPLE_PNG from tests.const import SAMPLE_PNG
from pretix.api.serializers.item import QuestionSerializer from pretix.api.serializers.item import QuestionSerializer
from pretix.base.models import Checkin, InvoiceAddress, Order, OrderPosition from pretix.base.models import (
Checkin, InvoiceAddress, Order, OrderPosition, ReusableMedium,
)
# Lots of this code is overlapping with test_checkin.py, and some of it is arguably redundant since it's triggering # Lots of this code is overlapping with test_checkin.py, and some of it is arguably redundant since it's triggering
# the same backend code paths (for now). However, this is SUCH a critical part of pretix that we don't want to take # the same backend code paths (for now). However, this is SUCH a critical part of pretix that we don't want to take
@@ -275,6 +277,72 @@ def test_by_secret_special_chars(token_client, organizer, clist, event, order):
assert resp.data['status'] == 'ok' assert resp.data['status'] == 'ok'
@pytest.mark.django_db
def test_by_medium(token_client, organizer, clist, event, order):
with scopes_disabled():
ReusableMedium.objects.create(
type="barcode",
identifier="abcdef",
organizer=organizer,
linked_orderposition=order.positions.first(),
)
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
with scopes_disabled():
ci = clist.checkins.get(position=order.positions.first())
assert ci.raw_barcode == "abcdef"
assert ci.raw_source_type == "barcode"
@pytest.mark.django_db
def test_by_medium_not_connected(token_client, organizer, clist, event, order):
with scopes_disabled():
ReusableMedium.objects.create(
type="barcode",
identifier="abcdef",
organizer=organizer,
)
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 404
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'invalid'
@pytest.mark.django_db
def test_by_medium_wrong_type(token_client, organizer, clist, event, order):
with scopes_disabled():
ReusableMedium.objects.create(
type="nfc_uid",
identifier="abcdef",
organizer=organizer,
linked_orderposition=order.positions.first(),
)
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 404
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'invalid'
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "nfc_uid"})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
@pytest.mark.django_db
def test_by_medium_inactive(token_client, organizer, clist, event, order):
with scopes_disabled():
ReusableMedium.objects.create(
type="barcode",
identifier="abcdef",
organizer=organizer,
active=False,
linked_orderposition=order.positions.first(),
)
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 404
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'invalid'
@pytest.mark.django_db @pytest.mark.django_db
def test_only_once(token_client, organizer, clist, event, order): def test_only_once(token_client, organizer, clist, event, order):
with scopes_disabled(): with scopes_disabled():
+6 -2
View File
@@ -1212,7 +1212,8 @@ def test_get_event_settings(token_client, organizer, event):
"value": "https://example.org", "value": "https://example.org",
"label": "Imprint URL", "label": "Imprint URL",
"help_text": "This should point e.g. to a part of your website that has your contact details and legal " "help_text": "This should point e.g. to a part of your website that has your contact details and legal "
"information." "information.",
"readonly": False,
} }
@@ -1229,14 +1230,17 @@ def test_patch_event_settings(token_client, organizer, event):
{ {
'de': 'Ich bin mit den AGB einverstanden.' 'de': 'Ich bin mit den AGB einverstanden.'
} }
] ],
'reusable_media_active': True, # readonly, ignored
}, },
format='json' format='json'
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.data['imprint_url'] == "https://example.com" assert resp.data['imprint_url'] == "https://example.com"
assert not resp.data['reusable_media_active']
event.settings.flush() event.settings.flush()
assert event.settings.imprint_url == 'https://example.com' assert event.settings.imprint_url == 'https://example.com'
assert not event.settings.reusable_media_active
mocked.assert_not_called() mocked.assert_not_called()
resp = token_client.patch( resp = token_client.patch(
+2
View File
@@ -296,6 +296,8 @@ TEST_ITEM_RES = {
"grant_membership_duration_like_event": True, "grant_membership_duration_like_event": True,
"grant_membership_duration_days": 0, "grant_membership_duration_days": 0,
"grant_membership_duration_months": 0, "grant_membership_duration_months": 0,
"media_policy": None,
"media_type": None,
"validity_mode": None, "validity_mode": None,
"validity_fixed_from": None, "validity_fixed_from": None,
"validity_fixed_until": None, "validity_fixed_until": None,
+92 -1
View File
@@ -35,7 +35,8 @@ from pytz import UTC
from tests.const import SAMPLE_PNG from tests.const import SAMPLE_PNG
from pretix.base.models import ( from pretix.base.models import (
InvoiceAddress, Order, OrderPosition, Question, SeatingPlan, InvoiceAddress, Item, Order, OrderPosition, Organizer, Question,
SeatingPlan,
) )
from pretix.base.models.orders import CartPosition, OrderFee, QuestionAnswer from pretix.base.models.orders import CartPosition, OrderFee, QuestionAnswer
@@ -55,6 +56,27 @@ def taxrule(event):
return event.tax_rules.create(rate=Decimal('19.00')) return event.tax_rules.create(rate=Decimal('19.00'))
@pytest.fixture
def medium(organizer):
return organizer.reusable_media.create(
type="barcode",
identifier="ABCDE"
)
@pytest.fixture
def organizer2():
return Organizer.objects.create(name='Partner', slug='partner')
@pytest.fixture
def medium2(organizer2):
return organizer2.reusable_media.create(
type="barcode",
identifier="ABCDE"
)
@pytest.fixture @pytest.fixture
def question(event, item): def question(event, item):
q = event.questions.create(question="T-Shirt size", type="S", identifier="ABC") q = event.questions.create(question="T-Shirt size", type="S", identifier="ABC")
@@ -2810,3 +2832,72 @@ def test_create_cart_and_consume_cart_with_addons(token_client, organizer, event
), format='json', data=res ), format='json', data=res
) )
assert resp.status_code == 201 assert resp.status_code == 201
@pytest.mark.django_db
def test_order_create_use_medium(token_client, organizer, event, item, quota, question, medium):
item.media_type = medium.type
item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW
item.save()
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['use_reusable_medium'] = medium.pk
res['positions'][0]['answers'][0]['question'] = question.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
medium.refresh_from_db()
assert o.positions.first() == medium.linked_orderposition
assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier
@pytest.mark.django_db
def test_order_create_use_medium_other_organizer(token_client, organizer, event, item, quota, question, medium2):
item.media_type = medium2.type
item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW
item.save()
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['use_reusable_medium'] = medium2.pk
res['positions'][0]['answers'][0]['question'] = question.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.data == {
"positions": [
{
"use_reusable_medium": ["The specified medium does not belong to this organizer."]
}
]
}
assert resp.status_code == 400
@pytest.mark.django_db
def test_order_create_create_medium(token_client, organizer, event, item, quota, question):
item.media_type = 'barcode'
item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW
item.save()
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
i = resp.data['positions'][0]['pdf_data']['medium_identifier']
assert i
m = organizer.reusable_media.get(identifier=i)
assert m.linked_orderposition == o.positions.first()
assert m.type == "barcode"
+2 -2
View File
@@ -1802,7 +1802,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q
assert not resp.data['positions'][0].get('pdf_data') assert not resp.data['positions'][0].get('pdf_data')
# order list # order list
with django_assert_max_num_queries(29): with django_assert_max_num_queries(30):
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug organizer.slug, event.slug
)) ))
@@ -1815,7 +1815,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q
assert not resp.data['results'][0]['positions'][0].get('pdf_data') assert not resp.data['results'][0]['positions'][0].get('pdf_data')
# position list # position list
with django_assert_max_num_queries(32): with django_assert_max_num_queries(33):
resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/?pdf_data=true'.format( resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/?pdf_data=true'.format(
organizer.slug, event.slug organizer.slug, event.slug
)) ))
+2 -1
View File
@@ -62,7 +62,8 @@ def test_get_settings(token_client, organizer):
assert resp.data['event_list_type'] == { assert resp.data['event_list_type'] == {
"value": "week", "value": "week",
"label": "Default overview style", "label": "Default overview style",
"help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used." "help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used.",
"readonly": False,
} }
+352
View File
@@ -0,0 +1,352 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from datetime import timedelta
from decimal import Decimal
import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import Order, Organizer, ReusableMedium
@pytest.fixture
def giftcard(organizer):
gc = organizer.issued_gift_cards.create(secret="ABCDEF", currency="EUR")
gc.transactions.create(value=Decimal('23.00'))
return gc
@pytest.fixture
def medium(organizer):
m = organizer.reusable_media.create(identifier="ABCDEFGH", type="barcode", active=True)
return m
@pytest.fixture
def organizer2():
return Organizer.objects.create(name='Partner', slug='partner')
@pytest.fixture
def giftcard2(organizer2):
gc = organizer2.issued_gift_cards.create(secret="ABCDEF", currency="EUR")
gc.transactions.create(value=Decimal('23.00'))
return gc
@pytest.fixture
def customer(organizer, event):
return organizer.customers.create(
identifier="8WSAJCJ",
email="foo@example.org",
name_parts={"_legacy": "Foo"},
name_cached="Foo",
is_verified=False,
)
TEST_MEDIUM_RES = {
"id": 1,
"identifier": "ABCDEFGH",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {},
}
@pytest.mark.django_db
def test_medium_list(token_client, organizer, event, medium):
res = dict(TEST_MEDIUM_RES)
res["id"] = medium.pk
res["created"] = medium.created.isoformat().replace('+00:00', 'Z')
res["updated"] = medium.updated.isoformat().replace('+00:00', 'Z')
resp = token_client.get('/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/reusablemedia/?identifier=XYZABC'.format(organizer.slug))
assert resp.status_code == 200
assert [] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/reusablemedia/?identifier=ABCDEFGH'.format(organizer.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
@pytest.mark.django_db
def test_medium_detail(token_client, organizer, event, medium, giftcard, customer):
res = dict(TEST_MEDIUM_RES)
res["id"] = medium.pk
res["created"] = medium.created.isoformat().replace('+00:00', 'Z')
res["updated"] = medium.updated.isoformat().replace('+00:00', 'Z')
resp = token_client.get('/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk))
assert resp.status_code == 200
assert res == resp.data
with scopes_disabled():
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
total=14, locale='en'
)
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14"))
medium.linked_orderposition = op
medium.linked_giftcard = giftcard
medium.customer = customer
medium.save()
resp = token_client.get(
'/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard&expand=linked_orderposition&expand=customer'.format(
organizer.slug, medium.pk))
assert resp.status_code == 200
assert resp.data["customer"] == {
"identifier": customer.identifier,
"external_identifier": None,
"email": "foo@example.org",
"name": "Foo",
"name_parts": {"_legacy": "Foo"},
"is_active": True,
"is_verified": False,
"last_login": None,
"date_joined": customer.date_joined.isoformat().replace("+00:00", "Z"),
"locale": "en",
"last_modified": customer.last_modified.isoformat().replace("+00:00", "Z"),
"notes": None
}
assert resp.data["linked_orderposition"] == {
"id": op.pk,
"order": {"code": "FOO", "event": "dummy"},
"positionid": op.positionid,
"item": ticket.pk,
"variation": None,
"price": "14.00",
"attendee_name": None,
"attendee_name_parts": {},
"company": None,
"street": None,
"zipcode": None,
"city": None,
"country": None,
"state": None,
"discount": None,
"attendee_email": None,
"voucher": None,
"tax_rate": "0.00",
"tax_value": "0.00",
"secret": op.secret,
"addon_to": None,
"subevent": None,
"checkins": [],
"downloads": [],
"answers": [],
"tax_rule": None,
"pseudonymization_id": op.pseudonymization_id,
"pdf_data": {},
"seat": None,
"canceled": False,
"valid_from": None,
"valid_until": None,
"blocked": None
}
assert resp.data["linked_giftcard"] == {
"id": giftcard.pk,
"secret": "ABCDEF",
"issuance": giftcard.issuance.isoformat().replace("+00:00", "Z"),
"value": "23.00",
"currency": "EUR",
"testmode": False,
"expires": None,
"conditions": None
}
TEST_MEDIUM_CREATE_PAYLOAD = {
"type": "barcode",
"identifier": "FOOBAR",
"active": True,
"info": {"foo": "bar"}
}
@pytest.mark.django_db
def test_medium_create(token_client, organizer, giftcard):
payload = dict(TEST_MEDIUM_CREATE_PAYLOAD)
payload['linked_giftcard'] = giftcard.pk
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
payload,
format='json'
)
assert resp.status_code == 201
with scopes_disabled():
m = ReusableMedium.objects.get(pk=resp.data['id'])
assert m.organizer == organizer
assert m.type == "barcode"
assert m.identifier == "FOOBAR"
assert m.active
assert m.linked_giftcard == giftcard
assert m.info == {"foo": "bar"}
assert m.created > now() - timedelta(minutes=10)
assert m.updated > now() - timedelta(minutes=10)
@pytest.mark.django_db
def test_medium_foreignkeyval(token_client, organizer, giftcard2):
payload = dict(TEST_MEDIUM_CREATE_PAYLOAD)
payload['linked_giftcard'] = giftcard2.pk
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
payload,
format='json'
)
assert resp.status_code == 400
assert resp.data == {'linked_giftcard': [f'Invalid pk "{giftcard2.pk}" - object does not exist.']}
@pytest.mark.django_db
def test_medium_create_duplicate(token_client, organizer, event, medium):
payload = dict(TEST_MEDIUM_CREATE_PAYLOAD)
payload['identifier'] = medium.identifier
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
payload,
format='json'
)
assert resp.status_code == 400
assert resp.data == {
'identifier': ['A medium with the same identifier and type already exists in your organizer account.']}
@pytest.mark.django_db
def test_medium_patch(token_client, organizer, event, medium, giftcard, customer):
resp = token_client.patch(
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk),
{
'linked_giftcard': giftcard.pk,
'customer': customer.identifier,
'info': {'test': 2},
'identifier': 'WILLBEIGNORED',
},
format='json'
)
assert resp.status_code == 200
medium.refresh_from_db()
assert medium.linked_giftcard == giftcard
assert medium.customer == customer
assert medium.info == {'test': 2}
assert medium.identifier == "ABCDEFGH"
@pytest.mark.django_db
def test_medium_no_deletion(token_client, organizer, event, medium):
resp = token_client.delete(
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk),
)
assert resp.status_code == 405
@pytest.mark.django_db
def test_medium_lookup_ok(token_client, organizer, event, medium):
res = dict(TEST_MEDIUM_RES)
res["id"] = medium.pk
res["created"] = medium.created.isoformat().replace('+00:00', 'Z')
res["updated"] = medium.updated.isoformat().replace('+00:00', 'Z')
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug),
{
"type": medium.type,
"identifier": medium.identifier,
},
format='json'
)
assert resp.status_code == 200
assert res == resp.data["result"]
@pytest.mark.django_db
def test_medium_lookup_not_found(token_client, organizer, organizer2, medium):
medium.organizer = organizer2
medium.save()
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug),
{
"type": medium.type,
"identifier": medium.identifier,
},
format='json'
)
assert resp.status_code == 200
assert resp.data["result"] is None
@pytest.mark.django_db
def test_medium_autocreate(token_client, organizer):
# Disabled
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug),
{
"type": "nfc_uid",
"identifier": "AABBCCDD",
},
format='json'
)
assert resp.status_code == 200
assert resp.data["result"] is None
# Enabled
organizer.settings.reusable_media_type_nfc_uid_autocreate_giftcard = True
organizer.settings.reusable_media_type_nfc_uid_autocreate_giftcard_currency = 'EUR'
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/lookup/?expand=linked_giftcard'.format(organizer.slug),
{
"type": "nfc_uid",
"identifier": "AABBCCDD",
},
format='json'
)
assert resp.status_code == 200
res = resp.data["result"]
with scopes_disabled():
m = ReusableMedium.objects.get(pk=res["id"])
assert res["identifier"] == "AABBCCDD" == m.identifier
assert res["type"] == "nfc_uid" == m.type
assert res["linked_giftcard"]["value"] == "0.00"
# Ignore NFC random UID
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/lookup/?expand=linked_giftcard'.format(organizer.slug),
{
"type": "nfc_uid",
"identifier": "08080808",
},
format='json'
)
assert resp.status_code == 200
assert resp.data["result"] is None
+2 -2
View File
@@ -39,7 +39,7 @@ def second_team(organizer, event):
TEST_TEAM_RES = { TEST_TEAM_RES = {
'id': 1, 'name': 'Test-Team', 'all_events': True, 'limit_events': [], 'can_create_events': True, 'id': 1, 'name': 'Test-Team', 'all_events': True, 'limit_events': [], 'can_create_events': True,
'can_change_teams': True, 'can_change_organizer_settings': True, 'can_manage_gift_cards': True, 'can_change_teams': True, 'can_change_organizer_settings': True, 'can_manage_gift_cards': True,
'can_manage_customers': True, 'can_manage_customers': True, 'can_manage_reusable_media': True,
'can_change_event_settings': True, 'can_change_items': True, 'can_view_orders': True, 'can_change_orders': True, 'can_change_event_settings': True, 'can_change_items': True, 'can_view_orders': True, 'can_change_orders': True,
'can_view_vouchers': True, 'can_change_vouchers': True, 'can_checkin_orders': False 'can_view_vouchers': True, 'can_change_vouchers': True, 'can_checkin_orders': False
} }
@@ -47,7 +47,7 @@ TEST_TEAM_RES = {
SECOND_TEAM_RES = { SECOND_TEAM_RES = {
'id': 1, 'name': 'User team', 'all_events': False, 'limit_events': ['dummy'], 'id': 1, 'name': 'User team', 'all_events': False, 'limit_events': ['dummy'],
'can_create_events': False, 'can_create_events': False,
'can_manage_customers': False, 'can_manage_customers': False, 'can_manage_reusable_media': False,
'can_change_teams': False, 'can_change_organizer_settings': False, 'can_manage_gift_cards': False, 'can_change_teams': False, 'can_change_organizer_settings': False, 'can_manage_gift_cards': False,
'can_change_event_settings': False, 'can_change_items': False, 'can_view_orders': False, 'can_change_orders': False, 'can_change_event_settings': False, 'can_change_items': False, 'can_view_orders': False, 'can_change_orders': False,
'can_view_vouchers': False, 'can_change_vouchers': False, 'can_checkin_orders': False 'can_view_vouchers': False, 'can_change_vouchers': False, 'can_checkin_orders': False
+31
View File
@@ -3632,3 +3632,34 @@ class OrderReactivateTest(TestCase):
reactivate_order(self.order) reactivate_order(self.order)
m.refresh_from_db() m.refresh_from_db()
assert not m.canceled assert not m.canceled
@pytest.mark.django_db
def test_autocreate_medium(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket', issue_giftcard=True,
default_price=Decimal('23.00'), admission=True, media_type='barcode',
media_policy=Item.MEDIA_POLICY_REUSE_OR_NEW)
cp1 = CartPosition.objects.create(
item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123"
)
q = event.quotas.create(size=None, name="foo")
q.items.add(ticket)
order = _create_order(
event, email='dummy@example.org', positions=[cp1],
now_dt=now(),
payment_requests=[
{
"id": "test1",
"provider": "banktransfer",
"max_value": None,
"min_value": None,
"multi_use_supported": False,
"info_data": {},
"pprov": BankTransfer(event),
},
],
locale='de'
)[0]
op = order.positions.first()
m = op.linked_media.get()
assert m.type == "barcode"
+25
View File
@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import json
from datetime import timedelta from datetime import timedelta
import pytest import pytest
@@ -191,3 +192,27 @@ def test_manage_acceptance_permission_required(organizer, organizer2, admin_user
'add': organizer2.slug 'add': organizer2.slug
}) })
assert not organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists() assert not organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists()
@pytest.mark.django_db
def test_typeahead(organizer, admin_user, client, gift_card):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
team = organizer.teams.get()
# Privileged user can search
r = client.get('/control/organizer/dummy/giftcards/select2?query=' + gift_card.secret[0:3])
d = json.loads(r.content)
assert d == {"results": [{"id": gift_card.pk, "text": gift_card.secret}], "pagination": {"more": False}}
# Unprivileged user can only do exact match
team.can_manage_gift_cards = False
team.can_manage_reusable_media = True
team.save()
r = client.get('/control/organizer/dummy/giftcards/select2?query=' + gift_card.secret[0:3])
d = json.loads(r.content)
assert d == {"results": [], "pagination": {"more": False}}
r = client.get('/control/organizer/dummy/giftcards/select2?query=' + gift_card.secret)
d = json.loads(r.content)
assert d == {"results": [{"id": gift_card.pk, "text": gift_card.secret}], "pagination": {"more": False}}
+7
View File
@@ -211,6 +211,10 @@ organizer_urls = [
'organizer/abc/customers', 'organizer/abc/customers',
'organizer/abc/customer/add', 'organizer/abc/customer/add',
'organizer/abc/customer/1/', 'organizer/abc/customer/1/',
'organizer/abc/reusable_media',
'organizer/abc/reusable_media/add',
'organizer/abc/reusable_media/1/',
'organizer/abc/reusable_media/1/edit',
'organizer/abc/giftcards', 'organizer/abc/giftcards',
'organizer/abc/giftcard/add', 'organizer/abc/giftcard/add',
'organizer/abc/giftcard/1/', 'organizer/abc/giftcard/1/',
@@ -541,6 +545,9 @@ organizer_permission_urls = [
("can_manage_customers", "organizer/dummy/customer/ABC/membership/add", 404), ("can_manage_customers", "organizer/dummy/customer/ABC/membership/add", 404),
("can_manage_customers", "organizer/dummy/customer/ABC/membership/1/edit", 404), ("can_manage_customers", "organizer/dummy/customer/ABC/membership/1/edit", 404),
("can_manage_customers", "organizer/dummy/customer/ABC/", 404), ("can_manage_customers", "organizer/dummy/customer/ABC/", 404),
("can_manage_reusable_media", "organizer/dummy/reusable_media", 200),
("can_manage_reusable_media", "organizer/dummy/reusable_media/1/edit", 404),
("can_manage_reusable_media", "organizer/dummy/reusable_media/1/", 404),
("can_manage_gift_cards", "organizer/dummy/giftcards", 200), ("can_manage_gift_cards", "organizer/dummy/giftcards", 200),
("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200),
("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404), ("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404),
+167
View File
@@ -0,0 +1,167 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
from datetime import timedelta
from decimal import Decimal
import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import Order, Organizer, Team, User
@pytest.fixture
def organizer():
return Organizer.objects.create(name='Dummy', slug='dummy')
@pytest.fixture
def medium(organizer):
m = organizer.reusable_media.create(identifier="ABCDEFGH", type="barcode")
return m
@pytest.fixture
def gift_card(organizer):
gc = organizer.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=42)
return gc
@pytest.fixture
def admin_user(organizer):
u = User.objects.create_user('dummy@dummy.dummy', 'dummy')
admin_team = Team.objects.create(organizer=organizer, can_manage_reusable_media=True, name='Admin team')
admin_team.members.add(u)
return u
@pytest.mark.django_db
def test_list_of_media(organizer, admin_user, client, medium):
client.login(email='dummy@dummy.dummy', password='dummy')
resp = client.get('/control/organizer/dummy/reusable_media')
assert medium.identifier in resp.content.decode()
resp = client.get('/control/organizer/dummy/reusable_media?query=' + medium.identifier[:3])
assert medium.identifier in resp.content.decode()
resp = client.get('/control/organizer/dummy/reusable_media?query=1234_FOO')
assert medium.identifier not in resp.content.decode()
@pytest.mark.django_db
def test_medium_detail_view(organizer, admin_user, medium, client):
client.login(email='dummy@dummy.dummy', password='dummy')
resp = client.get('/control/organizer/dummy/reusable_media/{}/'.format(medium.pk))
assert medium.identifier in resp.content.decode()
@pytest.mark.django_db
def test_medium_add(organizer, admin_user, client, gift_card):
client.login(email='dummy@dummy.dummy', password='dummy')
resp = client.post('/control/organizer/dummy/reusable_media/add', {
'type': 'barcode',
'identifier': 'FOOBAR',
'linked_giftcard': gift_card.pk,
}, follow=True)
assert 'FOOBAR' in resp.content.decode()
assert gift_card.secret in resp.content.decode()
with scopes_disabled():
m = organizer.reusable_media.get()
assert m.linked_giftcard == gift_card
assert m.type == 'barcode'
assert m.identifier == 'FOOBAR'
@pytest.mark.django_db
def test_medium_update(organizer, admin_user, client, medium, gift_card):
client.login(email='dummy@dummy.dummy', password='dummy')
client.post(f'/control/organizer/dummy/reusable_media/{medium.pk}/edit', {
'active': 'on',
'linked_giftcard': gift_card.pk,
}, follow=True)
medium.refresh_from_db()
assert medium.linked_giftcard == gift_card
@pytest.mark.django_db
def test_typeahead(organizer, admin_user, client, gift_card):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
event = organizer.events.create(
name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy'
)
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
total=14, locale='en'
)
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14"))
team = organizer.teams.get()
# Privileged user can search
team.all_events = True
team.can_view_orders = True
team.save()
r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret[0:3])
d = json.loads(r.content)
assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}}
r = client.get('/control/organizer/dummy/ticket_select2?query=DUMMY-FOO-1')
d = json.loads(r.content)
assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}}
r = client.get('/control/organizer/dummy/ticket_select2?query=DUMMY-FOO')
d = json.loads(r.content)
assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}}
r = client.get('/control/organizer/dummy/ticket_select2?query=FOO-1')
d = json.loads(r.content)
assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}}
# Unprivileged user can only do exact match
team.all_events = True
team.can_view_orders = False
team.save()
r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret[0:3])
d = json.loads(r.content)
assert d == {"results": [], "pagination": {"more": False}}
r = client.get('/control/organizer/dummy/ticket_select2?query=FOO-1')
d = json.loads(r.content)
assert d == {"results": [], "pagination": {"more": False}}
r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret)
d = json.loads(r.content)
assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}}
team.all_events = False
team.can_view_orders = True
team.save()
r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret[0:3])
d = json.loads(r.content)
assert d == {"results": [], "pagination": {"more": False}}
r = client.get('/control/organizer/dummy/ticket_select2?query=FOO-1')
d = json.loads(r.content)
assert d == {"results": [], "pagination": {"more": False}}
r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret)
d = json.loads(r.content)
assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}}