diff --git a/invenio_app_ils/circulation/api.py b/invenio_app_ils/circulation/api.py
index ef14a3aa7..ebd3ca8ac 100644
--- a/invenio_app_ils/circulation/api.py
+++ b/invenio_app_ils/circulation/api.py
@@ -30,10 +30,15 @@
get_all_expiring_or_overdue_loans_by_patron_pid,
)
from invenio_app_ils.errors import (
+ DocumentOverbookedError,
IlsException,
InvalidLoanExtendError,
InvalidParameterError,
+ ItemCannotCirculateError,
+ ItemHasActiveLoanError,
+ ItemNotFoundError,
MissingRequiredParameterError,
+ MultipleItemsBarcodeFoundError,
PatronHasLoanOnDocumentError,
PatronHasLoanOnItemError,
PatronHasRequestOnDocumentError,
@@ -119,7 +124,7 @@ def request_loan(
patron_pid,
transaction_location_pid,
transaction_user_pid=None,
- **kwargs
+ **kwargs,
):
"""Create a new loan and trigger the first transition to PENDING."""
loan_cls = current_circulation.loan_record_cls
@@ -170,13 +175,51 @@ def patron_has_active_loan_on_item(patron_pid, item_pid):
return search_result.hits.total.value > 0
+def _checkout_loan(
+ item_pid,
+ patron_pid,
+ transaction_location_pid,
+ transaction_user_pid=None,
+ trigger="checkout",
+ **kwargs,
+):
+ """Checkout a loan."""
+ transaction_user_pid = transaction_user_pid or str(current_user.id)
+ loan_cls = current_circulation.loan_record_cls
+ # create a new loan
+ record_uuid = uuid.uuid4()
+ new_loan = dict(
+ patron_pid=patron_pid,
+ transaction_location_pid=transaction_location_pid,
+ transaction_user_pid=transaction_user_pid,
+ )
+
+ # check if there is an existing request
+ loan = patron_has_request_on_document(patron_pid, kwargs.get("document_pid"))
+ if loan:
+ loan = loan_cls.get_record_by_pid(loan.pid)
+ pid = IlsCirculationLoanIdProvider.get(loan["pid"]).pid
+ loan.update(new_loan)
+ else:
+ pid = ils_circulation_loan_pid_minter(record_uuid, data=new_loan)
+ loan = loan_cls.create(data=new_loan, id_=record_uuid)
+
+ params = deepcopy(loan)
+ params.update(item_pid=item_pid, **kwargs)
+
+ loan = current_circulation.circulation.trigger(
+ loan, **dict(params, trigger=trigger)
+ )
+ return pid, loan
+
+
def checkout_loan(
item_pid,
patron_pid,
transaction_location_pid,
transaction_user_pid=None,
force=False,
- **kwargs
+ **kwargs,
):
"""Create a new loan and trigger the first transition to ITEM_ON_LOAN.
@@ -191,7 +234,7 @@ def checkout_loan(
the checkout. If False, the checkout will fail when the item cannot
circulate.
"""
- loan_cls = current_circulation.loan_record_cls
+
if patron_has_active_loan_on_item(patron_pid=patron_pid, item_pid=item_pid):
raise PatronHasLoanOnItemError(patron_pid, item_pid)
optional_delivery = kwargs.get("delivery")
@@ -201,35 +244,86 @@ def checkout_loan(
if force:
_set_item_to_can_circulate(item_pid)
- transaction_user_pid = transaction_user_pid or str(current_user.id)
-
- # create a new loan
- record_uuid = uuid.uuid4()
- new_loan = dict(
- patron_pid=patron_pid,
- transaction_location_pid=transaction_location_pid,
+ return _checkout_loan(
+ item_pid,
+ patron_pid,
+ transaction_location_pid,
transaction_user_pid=transaction_user_pid,
+ **kwargs,
)
- # check if there is an existing request
- loan = patron_has_request_on_document(patron_pid, kwargs.get("document_pid"))
- if loan:
- loan = loan_cls.get_record_by_pid(loan.pid)
- pid = IlsCirculationLoanIdProvider.get(loan["pid"]).pid
- loan.update(new_loan)
- else:
- pid = ils_circulation_loan_pid_minter(record_uuid, data=new_loan)
- loan = loan_cls.create(data=new_loan, id_=record_uuid)
- params = deepcopy(loan)
- params.update(item_pid=item_pid, **kwargs)
+def _ensure_item_loanable_via_self_checkout(item_pid):
+ """Self-checkout: return loanable item or raise when not loanable.
- # trigger the transition to request
- loan = current_circulation.circulation.trigger(
- loan, **dict(params, trigger="checkout")
+ Implements the self-checkout rules to loan an item.
+ """
+ item = current_app_ils.item_record_cls.get_record_by_pid(item_pid)
+ item_dict = item.replace_refs()
+
+ if item_dict["status"] != "CAN_CIRCULATE":
+ raise ItemCannotCirculateError()
+
+ circulation_state = item_dict["circulation"].get("state")
+ has_active_loan = (
+ circulation_state and circulation_state in CIRCULATION_STATES_LOAN_ACTIVE
)
+ if has_active_loan:
+ raise ItemHasActiveLoanError(loan_pid=item_dict["circulation"]["loan_pid"])
- return pid, loan
+ document = current_app_ils.document_record_cls.get_record_by_pid(
+ item_dict["document_pid"]
+ )
+ document_dict = document.replace_refs()
+ if document_dict["circulation"].get("overbooked", False):
+ raise DocumentOverbookedError(
+ f"Cannot self-checkout the overbooked document {item_dict['document_pid']}"
+ )
+
+ return item
+
+
+def self_checkout_get_item_by_barcode(barcode):
+ """Search for an item by barcode.
+
+ :param barcode: the barcode of the item to search for
+ :return item: the item that was found, or raise in case of errors
+ """
+ item_search = current_app_ils.item_search_cls()
+ items = item_search.search_by_barcode(barcode).execute()
+ if items.hits.total.value == 0:
+ raise ItemNotFoundError(barcode=barcode)
+ if items.hits.total.value > 1:
+ raise MultipleItemsBarcodeFoundError(barcode)
+
+ item_pid = items.hits[0].pid
+ item = _ensure_item_loanable_via_self_checkout(item_pid)
+ return item_pid, item
+
+
+def self_checkout(
+ item_pid, patron_pid, transaction_location_pid, transaction_user_pid=None, **kwargs
+):
+ """Perform self-checkout.
+
+ :param item_pid: a dict containing `value` and `type` fields to
+ uniquely identify the item.
+ :param patron_pid: the PID value of the patron
+ :param transaction_location_pid: the PID value of the location where the
+ checkout is performed
+ :param transaction_user_pid: the PID value of the user that performed the
+ checkout
+ """
+ _ensure_item_loanable_via_self_checkout(item_pid["value"])
+ return _checkout_loan(
+ item_pid,
+ patron_pid,
+ transaction_location_pid,
+ transaction_user_pid=transaction_user_pid,
+ trigger="self_checkout",
+ delivery=dict(method="SELF-CHECKOUT"),
+ **kwargs,
+ )
def bulk_extend_loans(patron_pid, **kwargs):
@@ -253,7 +347,7 @@ def bulk_extend_loans(patron_pid, **kwargs):
params,
trigger="extend",
transition_kwargs=dict(send_notification=False),
- )
+ ),
)
extended_loans.append(extended_loan)
except (CirculationException, InvalidLoanExtendError):
diff --git a/invenio_app_ils/circulation/config.py b/invenio_app_ils/circulation/config.py
index b49addcf0..b53696698 100644
--- a/invenio_app_ils/circulation/config.py
+++ b/invenio_app_ils/circulation/config.py
@@ -44,7 +44,6 @@
PatronOwnerPermission,
authenticated_user_permission,
backoffice_permission,
- loan_checkout_permission,
loan_extend_circulation_permission,
patron_owner_permission,
superuser_permission,
@@ -84,6 +83,7 @@
ILS_CIRCULATION_DELIVERY_METHODS = {
"PICKUP": "Pick it up at the library desk",
"DELIVERY": "Have it delivered to my office",
+ "SELF-CHECKOUT": "Self-checkout",
}
# Notification message creator for loan notifications
@@ -162,7 +162,13 @@
dest="ITEM_ON_LOAN",
trigger="checkout",
transition=ILSToItemOnLoan,
- permission_factory=loan_checkout_permission,
+ permission_factory=backoffice_permission,
+ ),
+ dict(
+ dest="ITEM_ON_LOAN",
+ trigger="self_checkout",
+ transition=ILSToItemOnLoan,
+ permission_factory=authenticated_user_permission,
),
],
"PENDING": [
@@ -172,6 +178,12 @@
transition=ILSToItemOnLoan,
permission_factory=backoffice_permission,
),
+ dict(
+ dest="ITEM_ON_LOAN",
+ trigger="self_checkout",
+ transition=ILSToItemOnLoan,
+ permission_factory=authenticated_user_permission,
+ ),
dict(
dest="CANCELLED",
trigger="cancel",
diff --git a/invenio_app_ils/circulation/loaders/__init__.py b/invenio_app_ils/circulation/loaders/__init__.py
index 8c18152a6..8ed3367bb 100644
--- a/invenio_app_ils/circulation/loaders/__init__.py
+++ b/invenio_app_ils/circulation/loaders/__init__.py
@@ -12,9 +12,11 @@
from .schemas.json.bulk_extend import BulkExtendLoansSchemaV1
from .schemas.json.loan_checkout import LoanCheckoutSchemaV1
from .schemas.json.loan_request import LoanRequestSchemaV1
+from .schemas.json.loan_self_checkout import LoanSelfCheckoutSchemaV1
from .schemas.json.loan_update_dates import LoanUpdateDatesSchemaV1
loan_request_loader = ils_marshmallow_loader(LoanRequestSchemaV1)
loan_checkout_loader = ils_marshmallow_loader(LoanCheckoutSchemaV1)
+loan_self_checkout_loader = ils_marshmallow_loader(LoanSelfCheckoutSchemaV1)
loan_update_dates_loader = ils_marshmallow_loader(LoanUpdateDatesSchemaV1)
loans_bulk_update_loader = ils_marshmallow_loader(BulkExtendLoansSchemaV1)
diff --git a/invenio_app_ils/circulation/loaders/schemas/json/loan_self_checkout.py b/invenio_app_ils/circulation/loaders/schemas/json/loan_self_checkout.py
new file mode 100644
index 000000000..029f612bd
--- /dev/null
+++ b/invenio_app_ils/circulation/loaders/schemas/json/loan_self_checkout.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 CERN.
+#
+# invenio-app-ils is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+
+"""Invenio App ILS circulation Loan Checkout loader JSON schema."""
+
+from invenio_circulation.records.loaders.schemas.json import LoanItemPIDSchemaV1
+from marshmallow import fields
+
+from .base import LoanBaseSchemaV1
+
+
+class LoanSelfCheckoutSchemaV1(LoanBaseSchemaV1):
+ """Loan self-checkout schema."""
+
+ item_pid = fields.Nested(LoanItemPIDSchemaV1, required=True)
diff --git a/invenio_app_ils/circulation/notifications/messages.py b/invenio_app_ils/circulation/notifications/messages.py
index 3682789d8..2d52f1290 100644
--- a/invenio_app_ils/circulation/notifications/messages.py
+++ b/invenio_app_ils/circulation/notifications/messages.py
@@ -25,6 +25,7 @@ class NotificationLoanMsg(NotificationMsg):
request="request.html",
request_no_items="request_no_items.html",
checkout="checkout.html",
+ self_checkout="self_checkout.html",
checkin="checkin.html",
extend="extend.html",
cancel="cancel.html",
diff --git a/invenio_app_ils/circulation/serializers/__init__.py b/invenio_app_ils/circulation/serializers/__init__.py
index 36bcb9e78..d8811bc67 100644
--- a/invenio_app_ils/circulation/serializers/__init__.py
+++ b/invenio_app_ils/circulation/serializers/__init__.py
@@ -6,6 +6,7 @@
# under the terms of the MIT License; see LICENSE file for more details.
"""Loan serializers."""
+
from invenio_records_rest.serializers.response import search_responsify
from invenio_app_ils.records.schemas.json import ILSRecordSchemaJSONV1
diff --git a/invenio_app_ils/circulation/serializers/response.py b/invenio_app_ils/circulation/serializers/response.py
index 52780559b..bcc9a2903 100644
--- a/invenio_app_ils/circulation/serializers/response.py
+++ b/invenio_app_ils/circulation/serializers/response.py
@@ -6,6 +6,7 @@
# under the terms of the MIT License; see LICENSE file for more details.
"""Response serializers for circulation module."""
+
import json
from flask import current_app
diff --git a/invenio_app_ils/circulation/templates/invenio_app_ils_circulation/notifications/self_checkout.html b/invenio_app_ils/circulation/templates/invenio_app_ils_circulation/notifications/self_checkout.html
new file mode 100644
index 000000000..5903b9e63
--- /dev/null
+++ b/invenio_app_ils/circulation/templates/invenio_app_ils_circulation/notifications/self_checkout.html
@@ -0,0 +1,19 @@
+{% block title %}
+InvenioILS: loan started for "{{ document.title|safe }}"
+{% endblock %}
+
+{% block body_plain %}
+Dear {{ patron.name }},
+
+your loan for "{{ document.full_title }}" <{{ spa_routes.HOST }}{{ spa_routes.PATHS['literature']|format(pid=document.pid) }}> has started.
+
+The due date is {{ loan.end_date }}.
+{% endblock %}
+
+{% block body_html %}
+Dear {{ patron.name }},
+
+your loan for "{{ document.full_title }}" has started.
+
+The due date is {{ loan.end_date }}.
+{% endblock %}
diff --git a/invenio_app_ils/circulation/views.py b/invenio_app_ils/circulation/views.py
index ccef71bf3..67c08b760 100644
--- a/invenio_app_ils/circulation/views.py
+++ b/invenio_app_ils/circulation/views.py
@@ -7,7 +7,7 @@
"""Invenio App ILS Circulation views."""
-from flask import Blueprint, abort
+from flask import Blueprint, abort, request
from flask_login import current_user
from invenio_circulation.links import loan_links_factory
from invenio_circulation.pidstore.pids import CIRCULATION_LOAN_PID_TYPE
@@ -18,15 +18,33 @@
from invenio_app_ils.circulation.loaders import (
loan_checkout_loader,
loan_request_loader,
+ loan_self_checkout_loader,
loan_update_dates_loader,
loans_bulk_update_loader,
)
from invenio_app_ils.circulation.utils import circulation_overdue_loan_days
-from invenio_app_ils.errors import OverdueLoansNotificationError
+from invenio_app_ils.errors import (
+ DocumentOverbookedError,
+ ItemCannotCirculateError,
+ ItemHasActiveLoanError,
+ LoanSelfCheckoutDocumentOverbooked,
+ LoanSelfCheckoutItemActiveLoan,
+ LoanSelfCheckoutItemInvalidStatus,
+ MissingRequiredParameterError,
+ OverdueLoansNotificationError,
+)
+from invenio_app_ils.items.api import ITEM_PID_TYPE
from invenio_app_ils.permissions import need_permissions
from ..patrons.api import patron_exists
-from .api import bulk_extend_loans, checkout_loan, request_loan, update_dates_loan
+from .api import (
+ bulk_extend_loans,
+ checkout_loan,
+ request_loan,
+ self_checkout,
+ self_checkout_get_item_by_barcode,
+ update_dates_loan,
+)
from .notifications.api import (
send_bulk_extend_notification,
send_loan_overdue_reminder_notification,
@@ -43,73 +61,97 @@ def create_circulation_blueprint(app):
url_prefix="",
)
- endpoints = app.config.get("RECORDS_REST_ENDPOINTS", [])
- options = endpoints.get(CIRCULATION_LOAN_PID_TYPE, {})
+ options = app.config["RECORDS_REST_ENDPOINTS"][CIRCULATION_LOAN_PID_TYPE]
default_media_type = options.get("default_media_type", "")
rec_serializers = options.get("record_serializers", {})
serializers = {
mime: obj_or_import_string(func) for mime, func in rec_serializers.items()
}
- bulk_loan_extension_serializers = {"application/json": bulk_extend_v1_response}
-
+ # /request
loan_request = LoanRequestResource.as_view(
LoanRequestResource.view_name,
serializers=serializers,
default_media_type=default_media_type,
ctx=dict(links_factory=loan_links_factory, loader=loan_request_loader),
)
-
blueprint.add_url_rule(
"/circulation/loans/request", view_func=loan_request, methods=["POST"]
)
+ # /checkout
loan_checkout = LoanCheckoutResource.as_view(
LoanCheckoutResource.view_name,
serializers=serializers,
default_media_type=default_media_type,
ctx=dict(links_factory=loan_links_factory, loader=loan_checkout_loader),
)
-
blueprint.add_url_rule(
"/circulation/loans/checkout",
view_func=loan_checkout,
methods=["POST"],
)
+ # /self-checkout
+ item_rec_serializers = app.config["RECORDS_REST_ENDPOINTS"][ITEM_PID_TYPE].get(
+ "record_serializers", {}
+ )
+ item_serializers = {
+ mime: obj_or_import_string(func) for mime, func in item_rec_serializers.items()
+ }
+ loan_self_checkout_item = LoanSelfCheckoutResource.as_view(
+ LoanSelfCheckoutResource.view_name,
+ serializers={}, # required, even if `method_serializers` will override it
+ method_serializers={
+ "GET": item_serializers,
+ "POST": serializers, # default loan serializers
+ },
+ default_media_type=default_media_type,
+ ctx=dict(
+ loan_links_factory=loan_links_factory, loader=loan_self_checkout_loader
+ ),
+ )
+ blueprint.add_url_rule(
+ "/circulation/loans/self-checkout",
+ view_func=loan_self_checkout_item,
+ methods=["GET", "POST"],
+ )
+
+ # /notification-overdue
loan_notification_overdue = LoanNotificationResource.as_view(
LoanNotificationResource.view_name.format(CIRCULATION_LOAN_PID_TYPE),
serializers=serializers,
default_media_type=default_media_type,
ctx=dict(links_factory=loan_links_factory),
)
-
blueprint.add_url_rule(
"{0}/notification-overdue".format(options["item_route"]),
view_func=loan_notification_overdue,
methods=["POST"],
)
+ # /bulk-extend
+ bulk_loan_extension_serializers = {"application/json": bulk_extend_v1_response}
+
bulk_loan_extension = BulkLoanExtensionResource.as_view(
BulkLoanExtensionResource.view_name,
serializers=bulk_loan_extension_serializers,
default_media_type=default_media_type,
ctx=dict(loader=loans_bulk_update_loader),
)
-
blueprint.add_url_rule(
"/circulation/bulk-extend",
view_func=bulk_loan_extension,
methods=["POST"],
)
+ # /update-dates
loan_update = LoanUpdateDatesResource.as_view(
LoanUpdateDatesResource.view_name.format(CIRCULATION_LOAN_PID_TYPE),
serializers=serializers,
default_media_type=default_media_type,
ctx=dict(links_factory=loan_links_factory, loader=loan_update_dates_loader),
)
-
blueprint.add_url_rule(
"{0}/update-dates".format(options["item_route"]),
view_func=loan_update,
@@ -157,6 +199,47 @@ def post(self, **kwargs):
return self.make_response(pid, loan, 202, links_factory=self.links_factory)
+class LoanSelfCheckoutResource(IlsCirculationResource):
+ """Loan self-checkout action resource."""
+
+ view_name = "loan_self_checkout"
+
+ @need_permissions("circulation-loan-self-checkout")
+ def get(self, **kwargs):
+ """Loan self-checkout GET method to retrieve items."""
+ try:
+ barcode = request.args["barcode"]
+ except KeyError:
+ msg = "Parameter `barcode` is missing"
+ raise MissingRequiredParameterError(description=msg)
+
+ try:
+ pid, item = self_checkout_get_item_by_barcode(barcode)
+ except ItemCannotCirculateError:
+ raise LoanSelfCheckoutItemInvalidStatus()
+ except ItemHasActiveLoanError:
+ raise LoanSelfCheckoutItemActiveLoan()
+ except DocumentOverbookedError:
+ raise LoanSelfCheckoutDocumentOverbooked()
+
+ return self.make_response(pid, item, 200)
+
+ @need_permissions("circulation-loan-self-checkout")
+ def post(self, **kwargs):
+ """Loan self-checkout POST method to perform the checkout."""
+ data = self.loader()
+ try:
+ pid, loan = self_checkout(**data)
+ except ItemCannotCirculateError:
+ raise LoanSelfCheckoutItemInvalidStatus()
+ except ItemHasActiveLoanError:
+ raise LoanSelfCheckoutItemActiveLoan()
+ except DocumentOverbookedError:
+ raise LoanSelfCheckoutDocumentOverbooked()
+
+ return self.make_response(pid, loan, 202, links_factory=self.loan_links_factory)
+
+
class BulkLoanExtensionResource(IlsCirculationResource):
"""Bulk loan extension resource."""
diff --git a/invenio_app_ils/errors.py b/invenio_app_ils/errors.py
index 63bece127..7be5ce158 100644
--- a/invenio_app_ils/errors.py
+++ b/invenio_app_ils/errors.py
@@ -98,11 +98,17 @@ def __init__(self, record_type, record_id, ref_type, ref_ids, **kwargs):
self.record_id = record_id
+class ItemCannotCirculateError(IlsException):
+ """The item cannot circulate."""
+
+ description = "This item cannot circulate."
+
+
class ItemHasActiveLoanError(IlsException):
"""The item which we are trying to update has an active loan."""
description = (
- "Could not update item because it has an active loan with " "pid: {loan_pid}."
+ "Could not update item because it has an active loan with pid: {loan_pid}."
)
def __init__(self, loan_pid, **kwargs):
@@ -126,6 +132,7 @@ def __init__(self, patron_pid, **kwargs):
class PatronHasLoanOnItemError(IlsException):
"""A patron already has an active loan or a loan request on an item."""
+ code = 400
description = "Patron '{0}' has already an active loan on item '{1}:{2}'"
def __init__(self, patron_pid, item_pid, **kwargs):
@@ -135,6 +142,8 @@ def __init__(self, patron_pid, item_pid, **kwargs):
:param prop: Missing property from loan request.
"""
super().__init__(**kwargs)
+ self.patron_pid = patron_pid
+ self.item_pid = item_pid
self.description = self.description.format(
patron_pid, item_pid["type"], item_pid["value"]
)
@@ -143,6 +152,7 @@ def __init__(self, patron_pid, item_pid, **kwargs):
class PatronHasRequestOnDocumentError(IlsException):
"""A patron already has a loan request on a document."""
+ code = 400
description = (
"Patron '{patron_pid}' has already a loan "
"request on document '{document_pid}'"
@@ -163,6 +173,7 @@ def __init__(self, patron_pid, document_pid, **kwargs):
class PatronHasLoanOnDocumentError(IlsException):
"""A patron already has an active loan on a document."""
+ code = 400
description = (
"Patron '{patron_pid}' has already an active loan "
"on document '{document_pid}'"
@@ -194,9 +205,47 @@ def __init__(self, patron_pid, current_user_pid, **kwargs):
)
+class LoanSelfCheckoutItemUnavailable(IlsException):
+ """A patron cannot self-checkout an item."""
+
+ code = 400
+ description = "This literature is not available for self-checkout. Please contact the library for more information."
+
+ def get_body(self, environ=None, scope=None):
+ """Get the request body."""
+ body = dict(
+ status=self.code,
+ message=self.get_description(environ),
+ )
+
+ if self.supportCode:
+ body["supportCode"] = self.supportCode
+
+ return json.dumps(body)
+
+
+class LoanSelfCheckoutItemInvalidStatus(LoanSelfCheckoutItemUnavailable):
+ """A patron cannot self-checkout an item that cannot circulate."""
+
+ supportCode = "SELF-CHECKOUT-001"
+
+
+class LoanSelfCheckoutDocumentOverbooked(LoanSelfCheckoutItemUnavailable):
+ """A patron cannot self-checkout an item for an overbooked document."""
+
+ supportCode = "SELF-CHECKOUT-002"
+
+
+class LoanSelfCheckoutItemActiveLoan(LoanSelfCheckoutItemUnavailable):
+ """A patron cannot self-checkout an item that cannot circulate."""
+
+ supportCode = "SELF-CHECKOUT-003"
+
+
class NotImplementedConfigurationError(IlsException):
"""Exception raised when function is not implemented."""
+ code = 500
description = (
"Function is not implemented. Implement this function in your module "
"and pass it to the config variable"
@@ -211,15 +260,20 @@ def __init__(self, config_variable=None, **kwargs):
class MissingRequiredParameterError(IlsException):
"""Exception raised when required parameter is missing."""
+ code = 400
+
class InvalidParameterError(IlsException):
"""Exception raised when an invalid parameter is has been given."""
+ code = 400
+
class DocumentNotFoundError(IlsException):
"""Raised when a document could not be found."""
- description = "Document PID '{}' was not found"
+ code = 404
+ description = "Document PID '{}' was not found."
def __init__(self, document_pid, **kwargs):
"""Initialize exception."""
@@ -227,10 +281,38 @@ def __init__(self, document_pid, **kwargs):
self.description = self.description.format(document_pid)
+class ItemNotFoundError(IlsException):
+ """Raised when an item could not be found."""
+
+ code = 404
+ description = "Item not found."
+
+ def __init__(self, pid=None, barcode=None, **kwargs):
+ """Initialize exception."""
+ super().__init__(**kwargs)
+ if pid:
+ self.description += " PID: {}".format(pid)
+ if barcode:
+ self.description += " Barcode: {}".format(barcode)
+
+
+class MultipleItemsBarcodeFoundError(IlsException):
+ """Raised when multiple items with the same barcode has been found."""
+
+ code = 500
+ description = "Multiple items with barcode {} found."
+
+ def __init__(self, barcode, **kwargs):
+ """Initialize exception."""
+ super().__init__(**kwargs)
+ self.description = self.description.format(barcode)
+
+
class LocationNotFoundError(IlsException):
"""Raised when a location could not be found."""
- description = "Location PID '{}' was not found"
+ code = 404
+ description = "Location PID '{}' was not found."
def __init__(self, location_pid, **kwargs):
"""Initialize exception."""
@@ -241,7 +323,8 @@ def __init__(self, location_pid, **kwargs):
class InternalLocationNotFoundError(IlsException):
"""Raised when an internal location could not be found."""
- description = "Internal Location PID '{}' was not found"
+ code = 404
+ description = "Internal Location PID '{}' was not found."
def __init__(self, internal_location_pid, **kwargs):
"""Initialize exception."""
@@ -252,6 +335,7 @@ def __init__(self, internal_location_pid, **kwargs):
class UnknownItemPidTypeError(IlsException):
"""Raised when the given item PID type is unknown."""
+ code = 400
description = "Unknown Item PID type '{}'"
def __init__(self, pid_type, **kwargs):
@@ -293,6 +377,14 @@ def __init__(self, description):
super().__init__(description=description)
+class DocumentOverbookedError(IlsException):
+ """Raised when a document is overbooked."""
+
+ def __init__(self, description):
+ """Initialize exception."""
+ super().__init__(description=description)
+
+
class VocabularyError(IlsException):
"""Generic vocabulary exception."""
diff --git a/invenio_app_ils/items/search.py b/invenio_app_ils/items/search.py
index b5bd80efa..5591d2aef 100644
--- a/invenio_app_ils/items/search.py
+++ b/invenio_app_ils/items/search.py
@@ -41,6 +41,19 @@ def search_by_document_pid(
return search
+ def search_by_barcode(self, barcode, filter_states=None, exclude_states=None):
+ """Retrieve items matching the given barcode."""
+ search = self
+
+ search = search.filter("term", barcode=barcode)
+
+ if filter_states:
+ search = search.filter("terms", status=filter_states)
+ elif exclude_states:
+ search = search.exclude("terms", status=exclude_states)
+
+ return search
+
def search_by_internal_location_pid(
self,
internal_location_pid=None,
diff --git a/invenio_app_ils/permissions.py b/invenio_app_ils/permissions.py
index b121ebc08..1b90817b1 100644
--- a/invenio_app_ils/permissions.py
+++ b/invenio_app_ils/permissions.py
@@ -132,7 +132,10 @@ def patron_owner_permission(record):
def loan_checkout_permission(*args, **kwargs):
- """Return permission to allow admins and librarians to checkout and patrons to self-checkout if enabled."""
+ """Loan checkout permissions checks.
+
+ Allow admins and librarians to checkout, patrons to self-checkout when enabled.
+ """
if not has_request_context():
# CLI or Celery task
return backoffice_permission()
@@ -199,8 +202,10 @@ def views_permissions_factory(action):
elif action in _is_patron_owner_permission:
return PatronOwnerPermission
elif action == "circulation-loan-checkout":
- if current_app.config["ILS_SELF_CHECKOUT_ENABLED"]:
- return authenticated_user_permission()
- else:
- return backoffice_permission()
+ return backoffice_permission()
+ elif (
+ action == "circulation-loan-self-checkout"
+ and current_app.config["ILS_SELF_CHECKOUT_ENABLED"]
+ ):
+ return authenticated_user_permission()
return deny_all()
diff --git a/tests/api/circulation/test_loan_checkout.py b/tests/api/circulation/test_loan_checkout.py
index e08f07912..54642a300 100644
--- a/tests/api/circulation/test_loan_checkout.py
+++ b/tests/api/circulation/test_loan_checkout.py
@@ -15,13 +15,16 @@
from flask import url_for
from flask_principal import UserNeed
from invenio_access.permissions import Permission
+from invenio_search import current_search
-from invenio_app_ils.items.api import Item
-from invenio_app_ils.permissions import (
- authenticated_user_permission,
- loan_checkout_permission,
- views_permissions_factory,
+from invenio_app_ils.errors import (
+ LoanSelfCheckoutDocumentOverbooked,
+ LoanSelfCheckoutItemActiveLoan,
+ LoanSelfCheckoutItemInvalidStatus,
)
+from invenio_app_ils.items.api import Item
+from invenio_app_ils.items.serializers import item
+from invenio_app_ils.proxies import current_app_ils
from tests.helpers import user_login, user_logout
NEW_LOAN = {
@@ -30,7 +33,15 @@
"patron_pid": "3",
"transaction_location_pid": "locid-1",
"pickup_location_pid": "locid-1",
- "delivery": {"method": "PICK_UP"},
+ "delivery": {"method": "PICKUP"},
+}
+
+NEW_LOAN_REQUEST = {
+ "document_pid": "CHANGE ME IN EACH TEST",
+ "patron_pid": "3",
+ "transaction_location_pid": "locid-1",
+ "pickup_location_pid": "locid-1",
+ "delivery": {"method": "PICKUP"},
}
@@ -215,77 +226,171 @@ def test_checkout_loader_start_end_dates(app, client, json_headers, users, testd
assert res.status_code == 400
-def _views_permissions_factory(action):
- """Override ILS views permissions factory."""
- if action == "circulation-loan-checkout":
- return authenticated_user_permission()
- return views_permissions_factory(action)
+def test_self_checkout_search(app, client, json_headers, users, testdata):
+ """Test self-checkout search."""
+ app.config["ILS_SELF_CHECKOUT_ENABLED"] = True
+
+ # test that anonymous user cannot search for barcodes
+ url = url_for("invenio_app_ils_circulation.loan_self_checkout")
+ res = client.get(f"{url}?barcode=123456", headers=json_headers)
+ assert res.status_code == 401
+ user_login(client, "patron2", users)
-def test_self_checkout(app, client, json_headers, users, testdata):
- """Tests for self checkout feature."""
- app.config["ILS_SELF_CHECKOUT_ENABLED"] = True
- app.config["ILS_VIEWS_PERMISSIONS_FACTORY"] = _views_permissions_factory
- app.config["RECORDS_REST_ENDPOINTS"]["pitmid"][
- "list_permission_factory_imp"
- ] = authenticated_user_permission
- app.config["ILS_CIRCULATION_RECORDS_REST_ENDPOINTS"]["loanid"][
- "update_permission_factory_imp"
- ] = loan_checkout_permission
-
- # Self checkout by librarian should pass
- librarian = users["librarian"]
+ # test the missing parameter error
+ url = url_for("invenio_app_ils_circulation.loan_self_checkout")
+ res = client.get(url, headers=json_headers)
+ assert res.status_code == 400
+
+ # test that authenticated user can search for barcodes, but it will return
+ # 404 when not found
+ unexisting_barcode = "123456"
+ res = client.get(f"{url}?barcode={unexisting_barcode}", headers=json_headers)
+ assert res.status_code == 404
+
+ # test that an error is returned when the item cannot circulate
+ missing_item_barcode = "123456789-1"
+ url = url_for("invenio_app_ils_circulation.loan_self_checkout")
+ res = client.get(f"{url}?barcode={missing_item_barcode}", headers=json_headers)
+ assert res.status_code == 400
+ # assert that the payload will contain the key error with a msg
+ response = res.get_json()
+ assert LoanSelfCheckoutItemInvalidStatus.description in response["message"]
+ assert LoanSelfCheckoutItemInvalidStatus.supportCode in response["supportCode"]
+
+ # create a loan on the same patron, and another one on another patron
user_login(client, "librarian", users)
- params = deepcopy(NEW_LOAN)
- params["item_pid"] = dict(type="pitmid", value="itemid-60")
- params["transaction_user_pid"] = str(librarian.id)
- params["patron_pid"] = str(librarian.id)
url = url_for("invenio_app_ils_circulation.loan_checkout")
- res = client.post(url, headers=json_headers, data=json.dumps(params))
- assert res.status_code == 202
- loan = res.get_json()["metadata"]
- assert loan["state"] == "ITEM_ON_LOAN"
- assert loan["item_pid"] == params["item_pid"]
- assert loan["patron_pid"] == str(librarian.id)
- user_logout(client)
+ for item_pid, patron_pid in [
+ ("itemid-60", "2"), # barcode 123456789-60
+ ("itemid-61", "1"), # barcode 123456789-61
+ ]:
+ params = deepcopy(NEW_LOAN)
+ params["transaction_user_pid"] = str(users["librarian"].id)
+ params["item_pid"] = dict(type="pitmid", value=item_pid)
+ params["patron_pid"] = patron_pid
+ res = client.post(url, headers=json_headers, data=json.dumps(params))
+ assert res.status_code == 202
+
+ # ensure new loans and related items are fully indexed
+ current_search.flush_and_refresh(index="*")
+
+ user_login(client, "patron2", users)
+
+ # test that an error is returned when the item is already on loan by the same user
+ on_loan_same_patron_barcode = "123456789-60"
+ url = url_for("invenio_app_ils_circulation.loan_self_checkout")
+ res = client.get(
+ f"{url}?barcode={on_loan_same_patron_barcode}", headers=json_headers
+ )
+ assert res.status_code == 400
+ # assert that the payload will contain the key error with a msg
+ response = res.get_json()
+ assert LoanSelfCheckoutItemActiveLoan.description in response["message"]
+ assert LoanSelfCheckoutItemActiveLoan.supportCode in response["supportCode"]
+
+ # test that an error is returned when the item is already on loan by another user
+ on_loan_other_patron_barcode = "123456789-61"
+ url = url_for("invenio_app_ils_circulation.loan_self_checkout")
+ res = client.get(
+ f"{url}?barcode={on_loan_other_patron_barcode}", headers=json_headers
+ )
+ assert res.status_code == 400
+ # assert that the payload will contain the key error with a msg
+ response = res.get_json()
+ assert LoanSelfCheckoutItemActiveLoan.description in response["message"]
+ assert LoanSelfCheckoutItemActiveLoan.supportCode in response["supportCode"]
- # Self checkout by patron should pass if patron_pid matches
- patron3 = users["patron3"]
- user_login(client, "patron3", users)
- params = deepcopy(NEW_LOAN)
- params["item_pid"] = dict(type="pitmid", value="itemid-61")
- params["transaction_user_pid"] = str(patron3.id)
- params["patron_pid"] = str(patron3.id)
- url = url_for("invenio_app_ils_circulation.loan_checkout")
- res = client.post(url, headers=json_headers, data=json.dumps(params))
+ # test happy path
+ available_barcode = "123456789-10"
+ url = url_for("invenio_app_ils_circulation.loan_self_checkout")
+ res = client.get(f"{url}?barcode={available_barcode}", headers=json_headers)
+ assert res.status_code == 200
+ response = res.get_json()
+ assert response["metadata"]["pid"] == "itemid-10"
- assert res.status_code == 202
- loan = res.get_json()["metadata"]
- assert loan["state"] == "ITEM_ON_LOAN"
- assert loan["item_pid"] == params["item_pid"]
- assert loan["patron_pid"] == str(patron3.id)
- # Self checkout should fail if feature flag is not set to true
+def test_self_checkout(app, client, json_headers, users, testdata):
+ """Test self-checkout."""
+
+ def _create_request(patron, document_pid):
+ url = url_for("invenio_app_ils_circulation.loan_request")
+ user = user_login(client, patron, users)
+ params = deepcopy(NEW_LOAN_REQUEST)
+ params["document_pid"] = document_pid
+ params["patron_pid"] = str(user.id)
+ params["transaction_user_pid"] = str(user.id)
+ res = client.post(url, headers=json_headers, data=json.dumps(params))
+ assert res.status_code == 202
+ current_search.flush_and_refresh(index="*")
+
+ def _self_checkout(patron, item_pid, document_pid):
+ params = deepcopy(NEW_LOAN)
+ params["document_pid"] = document_pid
+ params["item_pid"] = dict(type="pitmid", value=item_pid)
+ params["patron_pid"] = str(patron.id)
+ params["transaction_user_pid"] = str(patron.id)
+ return client.post(url, headers=json_headers, data=json.dumps(params))
+
+ url = url_for("invenio_app_ils_circulation.loan_self_checkout")
+
app.config["ILS_SELF_CHECKOUT_ENABLED"] = False
- params = deepcopy(NEW_LOAN)
- params["item_pid"] = dict(type="pitmid", value="itemid-62")
- params["transaction_user_pid"] = str(patron3.id)
- params["patron_pid"] = str(patron3.id)
- url = url_for("invenio_app_ils_circulation.loan_checkout")
- res = client.post(url, headers=json_headers, data=json.dumps(params))
+ # test a logged in user cannot self-checkout when the feature is disabled
+ patron2 = user_login(client, "patron2", users)
+ res = client.post(url, headers=json_headers)
assert res.status_code == 403
+
user_logout(client)
+ app.config["ILS_SELF_CHECKOUT_ENABLED"] = True
- # Self checkout should fail if if patron_pid doesn't match
- patron1 = users["patron1"]
- user_login(client, "patron1", users)
- params = deepcopy(NEW_LOAN)
- params["item_pid"] = dict(type="pitmid", value="itemid-63")
- params["transaction_user_pid"] = str(patron1.id)
- params["patron_pid"] = str(patron3.id)
- url = url_for("invenio_app_ils_circulation.loan_checkout")
- res = client.post(url, headers=json_headers, data=json.dumps(params))
+ # test that anonymous user cannot self-checkout
+ res = client.post(url, headers=json_headers)
+ assert res.status_code == 401
- assert res.status_code == 403
+ # test overbooked books
+ # create multiple requests from different patrons
+ _create_request("patron1", "docid-15")
+ _create_request("patron3", "docid-15")
+
+ document_rec = current_app_ils.document_record_cls.get_record_by_pid("docid-15")
+ document = document_rec.replace_refs()
+ assert document["circulation"]["overbooked"]
+
+ # test that user cannot self-checkout an overbooked book, without having a request
+ patron2 = user_login(client, "patron2", users)
+ res = _self_checkout(patron2, "itemid-71", "docid-15")
+ assert res.status_code == 400
+ response = res.get_json()
+ assert LoanSelfCheckoutDocumentOverbooked.description in response["message"]
+ assert LoanSelfCheckoutDocumentOverbooked.supportCode in response["supportCode"]
+
+ # test that user cannot self-checkout an overbooked book, having a request
+ # create request from the same patron
+ _create_request("patron2", "docid-15")
+ patron2 = user_login(client, "patron2", users)
+ res = _self_checkout(patron2, "itemid-71", "docid-15")
+ assert res.status_code == 400
+ response = res.get_json()
+ assert LoanSelfCheckoutDocumentOverbooked.description in response["message"]
+ assert LoanSelfCheckoutDocumentOverbooked.supportCode in response["supportCode"]
+
+ # test that user can self-checkout having a prior request
+ _create_request("patron2", "docid-16")
+
+ patron2 = user_login(client, "patron2", users)
+ res = _self_checkout(patron2, "itemid-72", "docid-16")
+ assert res.status_code == 202
+ response = res.get_json()
+ assert response["metadata"]["delivery"]["method"] == "SELF-CHECKOUT"
+
+ # test that user can self-checkout without having a prior request, even if there
+ # are other requests on the book (but not overbooked)
+ _create_request("patron1", "docid-17")
+
+ patron2 = user_login(client, "patron2", users)
+ res = _self_checkout(patron2, "itemid-73", "docid-17")
+ assert res.status_code == 202
+ response = res.get_json()
+ assert response["metadata"]["delivery"]["method"] == "SELF-CHECKOUT"
diff --git a/tests/data/items.json b/tests/data/items.json
index 876751bac..ba0071323 100644
--- a/tests/data/items.json
+++ b/tests/data/items.json
@@ -1,7 +1,7 @@
[
{
"pid": "itemid-1",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-1",
"document_pid": "docid-1",
"internal_location_pid": "ilocid-1",
@@ -12,7 +12,7 @@
},
{
"pid": "itemid-2",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-2",
"document_pid": "docid-1",
"internal_location_pid": "ilocid-2",
@@ -23,7 +23,7 @@
},
{
"pid": "itemid-3",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-3",
"document_pid": "docid-2",
"internal_location_pid": "ilocid-1",
@@ -34,7 +34,7 @@
},
{
"pid": "itemid-4",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-4",
"document_pid": "docid-3",
"internal_location_pid": "ilocid-2",
@@ -45,7 +45,7 @@
},
{
"pid": "itemid-5",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-5",
"document_pid": "docid-3",
"internal_location_pid": "ilocid-1",
@@ -56,7 +56,7 @@
},
{
"pid": "itemid-6",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-6",
"document_pid": "docid-3",
"internal_location_pid": "ilocid-1",
@@ -67,7 +67,7 @@
},
{
"pid": "itemid-7",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-7",
"document_pid": "docid-5",
"internal_location_pid": "ilocid-2",
@@ -78,7 +78,7 @@
},
{
"pid": "itemid-8",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-8",
"document_pid": "docid-5",
"internal_location_pid": "ilocid-1",
@@ -89,7 +89,7 @@
},
{
"pid": "itemid-9",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-9",
"document_pid": "docid-5",
"internal_location_pid": "ilocid-1",
@@ -100,7 +100,7 @@
},
{
"pid": "itemid-10",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-10",
"document_pid": "docid-5",
"internal_location_pid": "ilocid-1",
@@ -111,7 +111,7 @@
},
{
"pid": "itemid-50",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-50",
"document_pid": "docid-5",
"internal_location_pid": "ilocid-1",
@@ -123,7 +123,7 @@
},
{
"pid": "itemid-51",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-51",
"document_pid": "docid-5",
"internal_location_pid": "ilocid-2",
@@ -135,7 +135,7 @@
},
{
"pid": "itemid-52",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-52",
"document_pid": "docid-5",
"internal_location_pid": "ilocid-1",
@@ -147,7 +147,7 @@
},
{
"pid": "itemid-53",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-53",
"document_pid": "docid-5",
"internal_location_pid": "ilocid-1",
@@ -159,7 +159,7 @@
},
{
"pid": "itemid-54",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-54",
"document_pid": "docid-5",
"internal_location_pid": "ilocid-1",
@@ -171,7 +171,7 @@
},
{
"pid": "itemid-55",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-55",
"document_pid": "docid-1",
"internal_location_pid": "ilocid-1",
@@ -182,7 +182,7 @@
},
{
"pid": "itemid-56",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-54",
"document_pid": "docid-5",
"internal_location_pid": "ilocid-1",
@@ -193,7 +193,7 @@
},
{
"pid": "itemid-57",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-55",
"document_pid": "docid-1",
"internal_location_pid": "ilocid-1",
@@ -204,7 +204,7 @@
},
{
"pid": "itemid-MISSING",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-55",
"document_pid": "docid-1",
"internal_location_pid": "ilocid-1",
@@ -215,8 +215,8 @@
},
{
"pid": "itemid-60",
- "created_by": {"type": "script", "value": "demo"},
- "barcode": "123456789-55",
+ "created_by": { "type": "script", "value": "demo" },
+ "barcode": "123456789-60",
"document_pid": "docid-1",
"internal_location_pid": "ilocid-1",
"circulation_restriction": "NO_RESTRICTION",
@@ -226,8 +226,8 @@
},
{
"pid": "itemid-61",
- "created_by": {"type": "script", "value": "demo"},
- "barcode": "123456789-55",
+ "created_by": { "type": "script", "value": "demo" },
+ "barcode": "123456789-61",
"document_pid": "docid-1",
"internal_location_pid": "ilocid-1",
"circulation_restriction": "NO_RESTRICTION",
@@ -237,7 +237,7 @@
},
{
"pid": "itemid-62",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-55",
"document_pid": "docid-1",
"internal_location_pid": "ilocid-1",
@@ -248,7 +248,7 @@
},
{
"pid": "itemid-63",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-55",
"document_pid": "docid-1",
"internal_location_pid": "ilocid-1",
@@ -259,10 +259,32 @@
},
{
"pid": "itemid-71",
- "created_by": {"type": "script", "value": "demo"},
+ "created_by": { "type": "script", "value": "demo" },
"barcode": "123456789-55",
+ "document_pid": "docid-15",
+ "internal_location_pid": "ilocid-1",
+ "circulation_restriction": "NO_RESTRICTION",
+ "medium": "NOT_SPECIFIED",
+ "status": "CAN_CIRCULATE",
+ "document": {}
+ },
+ {
+ "pid": "itemid-72",
+ "created_by": { "type": "script", "value": "demo" },
+ "barcode": "123456789-72",
+ "document_pid": "docid-16",
+ "internal_location_pid": "ilocid-1",
+ "circulation_restriction": "NO_RESTRICTION",
+ "medium": "NOT_SPECIFIED",
+ "status": "CAN_CIRCULATE",
+ "document": {}
+ },
+ {
+ "pid": "itemid-73",
+ "created_by": { "type": "script", "value": "demo" },
+ "barcode": "123456789-73",
"document_pid": "docid-17",
- "internal_location_pid": "ilocid-4",
+ "internal_location_pid": "ilocid-1",
"circulation_restriction": "NO_RESTRICTION",
"medium": "NOT_SPECIFIED",
"status": "CAN_CIRCULATE",