Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

circulation: Support self checkout by patrons #1213

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
Changes
=======

Version 3.0.0rc3 (released 2024-06-07)

- circulation: Support self checkout by patrons
- Dockerfile: update backend base image python3.6 -> inveniosoftware/almalinux:1

Version 3.0.0rc2 (released 2024-05-28)

- mappings: Add alternative_titles in brwReqs and AcqOrders
Expand Down
2 changes: 1 addition & 1 deletion invenio_app_ils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@

"""invenio-app-ils."""

__version__ = "3.0.0rc2"
__version__ = "3.0.0rc3"

__all__ = ("__version__",)
3 changes: 2 additions & 1 deletion invenio_app_ils/circulation/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
loan_extend_circulation_permission,
patron_owner_permission,
superuser_permission,
loan_checkout_permission,
)

from .api import ILS_CIRCULATION_LOAN_FETCHER, ILS_CIRCULATION_LOAN_MINTER
Expand Down Expand Up @@ -161,7 +162,7 @@
dest="ITEM_ON_LOAN",
trigger="checkout",
transition=ILSToItemOnLoan,
permission_factory=backoffice_permission,
permission_factory=loan_checkout_permission,
),
],
"PENDING": [
Expand Down
4 changes: 4 additions & 0 deletions invenio_app_ils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,7 @@ def _(x):

# ILS
# ===

ILS_VIEWS_PERMISSIONS_FACTORY = views_permissions_factory
"""Permissions factory for ILS views to handle all ILS actions."""

Expand Down Expand Up @@ -1071,3 +1072,6 @@ def _(x):
ILS_PATRON_SYSTEM_AGENT_CLASS = SystemAgent

DB_VERSIONING_USER_MODEL = None

# Feature Toggles
ILS_SELF_CHECKOUT_ENABLED = False
14 changes: 14 additions & 0 deletions invenio_app_ils/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,20 @@ def __init__(self, patron_pid, document_pid, **kwargs):
)


class LoanCheckoutByPatronForbidden(IlsException):
"""A patron cannot checkout an item for another patron."""

code = 403
description = "Forbidden. Patron '{current_user_pid}' cannot checkout item for another Patron '{patron_pid}'."

def __init__(self, patron_pid, current_user_pid, **kwargs):
"""Initialize LoanCheckoutByPatronForbidden exception."""
super().__init__(**kwargs)
self.description = self.description.format(
patron_pid=patron_pid, current_user_pid=current_user_pid
)


class NotImplementedConfigurationError(IlsException):
"""Exception raised when function is not implemented."""

Expand Down
26 changes: 25 additions & 1 deletion invenio_app_ils/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from invenio_access.permissions import Permission, authenticated_user, superuser_access
from invenio_records_rest.utils import allow_all, deny_all

from invenio_app_ils.errors import InvalidLoanExtendError
from invenio_app_ils.errors import InvalidLoanExtendError, LoanCheckoutByPatronForbidden
from invenio_app_ils.proxies import current_app_ils

backoffice_access_action = action_factory("ils-backoffice-access")
Expand Down Expand Up @@ -131,6 +131,30 @@ def patron_owner_permission(record):
return PatronOwnerPermission(record)


def loan_checkout_permission(*args, **kwargs):
"""Return permission to allow admins and librarians to checkout and patrons to self-checkout if enabled."""
if not has_request_context():
# If from CLI, don't allow self-checkout
return backoffice_permission()
if current_user.is_anonymous:
abort(401)

is_admin_or_librarian = backoffice_permission().allows(g.identity)
if is_admin_or_librarian:
return backoffice_permission()
if len(args):
loan = args[0]
else:
loan = kwargs.get("record", {})
is_patron_current_user = current_user.id == int(loan.get("patron_pid"))
if (
current_app.config.get("ILS_SELF_CHECKOUT_ENABLED", False)
and is_patron_current_user
):
return authenticated_user_permission()
raise LoanCheckoutByPatronForbidden(int(loan.get("patron_pid")), current_user.id)


class PatronOwnerPermission(Permission):
"""Return Permission to evaluate if the current user owns the record."""

Expand Down
83 changes: 82 additions & 1 deletion tests/api/circulation/test_loan_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
from invenio_access.permissions import Permission

from invenio_app_ils.items.api import Item
from tests.helpers import user_login
from invenio_app_ils.permissions import (
authenticated_user_permission,
loan_checkout_permission,
views_permissions_factory,
)
from tests.helpers import user_login, user_logout

NEW_LOAN = {
"item_pid": "CHANGE ME IN EACH TEST",
Expand Down Expand Up @@ -208,3 +213,79 @@ def test_checkout_loader_start_end_dates(app, client, json_headers, users, testd
params["transaction_user_pid"] = str(librarian.id)
res = client.post(url, headers=json_headers, data=json.dumps(params))
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(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"]
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)

# 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))

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
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))

assert res.status_code == 403
user_logout(client)

# 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))

assert res.status_code == 403
Loading