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",