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

Ekir 213 enable loan when not at loan limit #97

Merged
merged 22 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b0f5016
EKIR-213 When not at_loan_limit but at_hold_limit
natlibfi-kaisa Sep 12, 2024
f7e6939
EKIR-213 Update hold info with existing hold
natlibfi-kaisa Sep 12, 2024
bd63945
EKIR-213 Fix incorrect numbering in test
natlibfi-kaisa Sep 12, 2024
506f4ad
EKIR-213 Test hold updated when NoAvailableCopies
natlibfi-kaisa Sep 12, 2024
678fd9f
EKIR-213 Extend patron's hold.end with two weeks
natlibfi-kaisa Sep 13, 2024
015217e
EKIR-213 Fix asserts and add documentation
natlibfi-kaisa Sep 13, 2024
a533fb6
EKIR-213 Hold end date is in 3 days
natlibfi-kaisa Sep 16, 2024
148e6ca
EKIR-213 Update comments and docstring
natlibfi-kaisa Sep 16, 2024
722a9b8
EKIR-213 Update tests related to hold limit changes
natlibfi-kaisa Sep 16, 2024
84b5e14
EKIR-213 Add exception to be raised at the end
natlibfi-kaisa Sep 17, 2024
f4e8852
EKIR-213 Check patron's hold limit before new hold
natlibfi-kaisa Sep 17, 2024
67d6ba4
EKIR-213 Update availability after updating hold
natlibfi-kaisa Sep 17, 2024
2b807af
EKIR-213 Update comment
natlibfi-kaisa Sep 17, 2024
86e3676
EKIR-213 Fix test to have two license pools
natlibfi-kaisa Sep 17, 2024
ab727d9
EKIR-213 Update test to check new exception
natlibfi-kaisa Sep 17, 2024
96913e2
EKIR-213 Add new exception and problem detail
natlibfi-kaisa Sep 17, 2024
38d9de5
EKIR-213 Fix linting
natlibfi-kaisa Sep 17, 2024
1932ced
EKIR-213 Fix test that fails with python 3.10
natlibfi-kaisa Sep 18, 2024
8b69b4e
EKIR-213 Raise the new error
natlibfi-kaisa Sep 18, 2024
2feba5b
EKIR-213 Add test for new exception
natlibfi-kaisa Sep 18, 2024
59b9dea
EKIR-213 Fix incorrect problem detail document
natlibfi-kaisa Sep 18, 2024
b0bbb6c
EKIR-213 Update new status code in test
natlibfi-kaisa Sep 18, 2024
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
110 changes: 80 additions & 30 deletions api/circulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,7 @@ def borrow(
delivery_mechanism: LicensePoolDeliveryMechanism,
hold_notification_email: str | None = None,
) -> tuple[Loan | None, Hold | None, bool]:
"""Either borrow a book or put it on hold. Don't worry about fulfilling
"""Either borrow a book or put it or leave it on hold. Don't worry about fulfilling
the loan yet.

:return: A 3-tuple (`Loan`, `Hold`, `is_new`). Either `Loan`
Expand Down Expand Up @@ -964,6 +964,15 @@ def borrow(
on_multiple="interchangeable",
)

# Or maybe we have it on hold?
existing_hold = get_one(
self._db,
Hold,
patron=patron,
license_pool=licensepool,
on_multiple="interchangeable",
)

loan_info = None
hold_info = None
if existing_loan and isinstance(api, PatronActivityCirculationAPI):
Expand Down Expand Up @@ -1002,6 +1011,11 @@ def borrow(
# Enforce any library-specific limits on loans or holds.
self.enforce_limits(patron, licensepool)

# When the patron is at the top of the hold queue ("reverved")
# but the loan fails, we want to raise an exception when that
# happens.
reserved_license_exception = False

# Since that didn't raise an exception, we don't know of any
# reason why the patron shouldn't be able to get a loan or a
# hold. There are race conditions that will allow someone to
Expand Down Expand Up @@ -1069,6 +1083,24 @@ def borrow(
raise CannotRenew(
_("You cannot renew a loan if other patrons have the work on hold.")
)

# The patron had a hold and was in the hold queue's 0th position believing
# there were copies available for them to checkout.
if existing_hold and existing_hold.position == 0:
# Update the hold so the patron doesn't lose their hold. Extend the hold to expire in the
# next 3 days.
hold_info = HoldInfo(
licensepool.collection,
licensepool.data_source,
licensepool.identifier.type,
licensepool.identifier.identifier,
existing_hold.start,
datetime.datetime.now() + datetime.timedelta(days=3),
existing_hold.position,
)
# Update availability information
api.update_availability(licensepool)
reserved_license_exception = True
else:
# That's fine, we'll just (try to) place a hold.
#
Expand Down Expand Up @@ -1131,35 +1163,42 @@ def borrow(
# Checking out a book didn't work, so let's try putting
# the book on hold.
if not hold_info:
try:
hold_info = api.place_hold(
patron, pin, licensepool, hold_notification_email
)
except AlreadyOnHold as e:
hold_info = HoldInfo(
licensepool.collection,
licensepool.data_source,
licensepool.identifier.type,
licensepool.identifier.identifier,
None,
None,
None,
)
except CurrentlyAvailable:
if loan_exception:
# We tried to take out a loan and got an
# exception. But we weren't sure whether the real
# problem was the exception we got or the fact
# that the book wasn't available. Then we tried to
# place a hold, which didn't work because the book
# is currently available. That answers the
# question: we should have let the first exception
# go through. Raise it now.
raise loan_exception

# This shouldn't normally happen, but if it does,
# treat it as any other exception.
raise
# At this point, we need to check to see if the patron
# has reached their hold limit since we did not raise
# an exception for it earlier when limits were enforced.
at_hold_limit = self.patron_at_hold_limit(patron)
if not at_hold_limit:
try:
hold_info = api.place_hold(
patron, pin, licensepool, hold_notification_email
)
except AlreadyOnHold as e:
hold_info = HoldInfo(
licensepool.collection,
licensepool.data_source,
licensepool.identifier.type,
licensepool.identifier.identifier,
None,
None,
None,
)
except CurrentlyAvailable:
if loan_exception:
# We tried to take out a loan and got an
# exception. But we weren't sure whether the real
# problem was the exception we got or the fact
# that the book wasn't available. Then we tried to
# place a hold, which didn't work because the book
# is currently available. That answers the
# question: we should have let the first exception
# go through. Raise it now.
raise loan_exception

# This shouldn't normally happen, but if it does,
# treat it as any other exception.
raise
else:
raise PatronHoldLimitReached

# It's pretty rare that we'd go from having a loan for a book
# to needing to put it on hold, but we do check for that case.
Expand All @@ -1186,6 +1225,12 @@ def borrow(
if existing_loan:
self._db.delete(existing_loan)
__transaction.commit()

# Raise the exception of failed loan when the patron falsely believed
# there was an available licanse at the top of the hold queue.
if reserved_license_exception:
raise NoAvailableCopiesWhenReserved

return None, hold, is_new

def enforce_limits(self, patron: Patron, pool: LicensePool) -> None:
Expand Down Expand Up @@ -1214,6 +1259,11 @@ def enforce_limits(self, patron: Patron, pool: LicensePool) -> None:
# limits don't apply.
return

if not at_loan_limit and at_hold_limit:
# This patron can take out a loan, but not a hold. This is relevant when
# the patron can't place a hold but can take out a loan.
return

if at_loan_limit and at_hold_limit:
# This patron can neither take out a loan or place a hold.
# Raise PatronLoanLimitReached for the most understandable
Expand Down
11 changes: 11 additions & 0 deletions api/circulation_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ class CannotRenew(CirculationException):
status_code = 400


class NoAvailableCopiesWhenReserved(CannotLoan):
"""The patron can't check this book out because all available
copies are already checked out. The book is "reserved" to the
patron.
"""

def as_problem_detail_document(self, debug=False):
"""Return a suitable problem detail document."""
return NO_COPIES_WHEN_RESERVED


class NoAvailableCopies(CannotLoan):
"""The patron can't check this book out because all available
copies are already checked out.
Expand Down
6 changes: 6 additions & 0 deletions api/controller/loan.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
DeliveryMechanismError,
FormatNotAvailable,
NoActiveLoan,
NoAvailableCopiesWhenReserved,
NoOpenAccessDownload,
NotFoundOnRemote,
OutstandingFines,
Expand Down Expand Up @@ -203,6 +204,11 @@ def _borrow(self, patron, credential, pool, mechanism):
result = e.as_problem_detail_document(debug=False)
except CannotLoan as e:
result = CHECKOUT_FAILED.with_debug(str(e))
except CannotLoan as e:
if isinstance(e, NoAvailableCopiesWhenReserved):
result = e.as_problem_detail_document()
else:
result = CHECKOUT_FAILED.with_debug(str(e))
except CannotHold as e:
result = HOLD_FAILED.with_debug(str(e))
except CannotRenew as e:
Expand Down
8 changes: 8 additions & 0 deletions api/problem_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@
_("All licenses for this book are loaned out."),
)

# E-kirjasto
NO_COPIES_WHEN_RESERVED = pd(
"http://librarysimplified.org/terms/problem/cannot-issue-loan",
502,
_("No available license."),
_("All copies of this book are loaned out after all. You are still next in line."),
)

NO_ACCEPTABLE_FORMAT = pd(
"http://librarysimplified.org/terms/problem/no-acceptable-format",
400,
Expand Down
22 changes: 22 additions & 0 deletions tests/api/controller/test_loan.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from api.circulation_exceptions import (
AlreadyOnHold,
NoAvailableCopies,
NoAvailableCopiesWhenReserved,
NoLicenses,
NotFoundOnRemote,
PatronHoldLimitReached,
Expand All @@ -30,6 +31,7 @@
CANNOT_RELEASE_HOLD,
HOLD_LIMIT_REACHED,
NO_ACTIVE_LOAN,
NO_COPIES_WHEN_RESERVED,
NOT_FOUND_ON_REMOTE,
OUTSTANDING_FINES,
)
Expand Down Expand Up @@ -1091,6 +1093,26 @@ def test_hold_fails_when_patron_is_at_hold_limit(self, loan_fixture: LoanFixture
assert isinstance(response, ProblemDetail)
assert HOLD_LIMIT_REACHED.uri == response.uri

def test_loan_fails_when_patron_is_at_hold_limit_and_hold_position_zero(
self, loan_fixture: LoanFixture
):
edition, pool = loan_fixture.db.edition(with_license_pool=True)
pool.open_access = False
with loan_fixture.request_context_with_library(
"/", headers=dict(Authorization=loan_fixture.valid_auth)
):
patron = loan_fixture.manager.loans.authenticated_patron_from_request()
loan_fixture.manager.d_circulation.queue_checkout(pool, NoAvailableCopies())
loan_fixture.manager.d_circulation.queue_hold(
pool, NoAvailableCopiesWhenReserved()
)
response = loan_fixture.manager.loans.borrow(
pool.identifier.type, pool.identifier.identifier
)
assert isinstance(response, ProblemDetail)
assert NO_COPIES_WHEN_RESERVED.uri == response.uri
assert 502 == response.status_code

def test_borrow_fails_with_outstanding_fines(
self, loan_fixture: LoanFixture, library_fixture: LibraryFixture
):
Expand Down
75 changes: 63 additions & 12 deletions tests/api/test_circulationapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -864,30 +864,40 @@ def patron_at_hold_limit(self, patron):
assert patron == circulation.patron_at_hold_limit_calls.pop()
assert pool == api.availability_updated.pop()

# Sub-test 3: patron is at hold limit but not loan limit
# Sub-test 4: patron is at hold limit but not loan limit
#
circulation.at_loan_limit = False
circulation.at_hold_limit = True

# If the book is not available, we get PatronHoldLimitReached
# Test updated: If the book is not available, we should NOT yet
# raise PatronHoldLimitReached. The patron is at their hold limit
# but they may be trying to take out a loan on a book that is
# reserved for them (hold position 0). We want to let them proceed
# at this point.
pool.licenses_available = 0
with pytest.raises(PatronHoldLimitReached) as hold_limit_info:
try:
circulation.enforce_limits(patron, pool)
assert 12 == hold_limit_info.value.limit
except PatronHoldLimitReached:
assert False, "PatronHoldLimitReached is raised when it shouldn't"
else:
# If no exception is raised, the test should pass
assert True

# Reaching this conclusion required checking both patron
# limits and asking the remote API for updated availability
# limits. The remote API isn't queried for updated availability
# information for this LicensePool.
assert patron == circulation.patron_at_loan_limit_calls.pop()
assert patron == circulation.patron_at_hold_limit_calls.pop()
assert pool == api.availability_updated.pop()
assert [] == api.availability_updated

# If the book is available, we're fine -- we're not at our loan limit.
# The remote API isn't queried for updated availability
# information for this LicensePool.
pool.licenses_available = 1
circulation.enforce_limits(patron, pool)
assert patron == circulation.patron_at_loan_limit_calls.pop()
assert patron == circulation.patron_at_hold_limit_calls.pop()
assert pool == api.availability_updated.pop()
assert [] == api.availability_updated

def test_borrow_hold_limit_reached(
self, circulation_api: CirculationAPIFixture, library_fixture: LibraryFixture
Expand All @@ -902,16 +912,14 @@ def test_borrow_hold_limit_reached(
other_pool = circulation_api.db.licensepool(None)
other_pool.open_access = False
other_pool.on_hold_to(circulation_api.patron)
# The patron wants to take out a loan on another title which is not available.
circulation_api.remote.queue_checkout(NoAvailableCopies())

# The patron wants to take out a loan on an unavailable title.
circulation_api.pool.licenses_available = 0
try:
self.borrow(circulation_api)
except Exception as e:
# The result is a PatronHoldLimitReached configured with the
# library's hold limit.
# The result is a PatronHoldLimitReached.
assert isinstance(e, PatronHoldLimitReached)
assert 1 == e.limit

# If we increase the limit, borrow succeeds.
library_fixture.settings(circulation_api.patron.library).hold_limit = 2
Expand All @@ -930,6 +938,49 @@ def test_borrow_hold_limit_reached(
loan, hold, is_new = self.borrow(circulation_api)
assert hold != None

def test_borrow_no_available_copies_and_update_existing_hold(
self, circulation_api: CirculationAPIFixture, library_fixture: LibraryFixture
):
"""
The hold limit is 1, and the patron has a hold with position 0 in the hold queue on a book they're
trying to checkout. When the patron tries to borrow the book but it turns out to not be available, the
hold is updated to have a new end date with all other properties unchanged.
The circulation information for the book is immediately updated, to reduce the risk that other patrons
would encounter the same problem. Finally, the patron gets a NoAvailableCopiesWhenReserved exception.
"""

# The hold limit is 1
library_fixture.settings(circulation_api.patron.library).hold_limit = 1

# The patron has a hold with position 0 in the hold queue
existing_hold, is_new = circulation_api.pool.on_hold_to(
circulation_api.patron,
start=self.YESTERDAY,
end=self.TOMORROW,
position=0,
)
original_hold_end_date = existing_hold.end

# The patron wants to take out their hold for loan but which turns out to not be available.
circulation_api.remote.queue_checkout(NoAvailableCopies())

# The patron tries to borrow the book but gets a NoAvailableCopiesWhenReserved exception
try:
self.borrow(circulation_api)
except Exception as e:
assert isinstance(e, NoAvailableCopiesWhenReserved)

# Nonetheless, the hold is updated to have a new extended end date.
assert existing_hold.position == 0
assert (
existing_hold.end != original_hold_end_date
) # The updated hold should have a new end date.
# When NoAvailableCopies was raised, the circulation
# information for the book was immediately updated, to reduce
# the risk that other patrons would encounter the same
# problem.
assert [circulation_api.pool] == circulation_api.remote.availability_updated_for

def test_fulfill_errors(self, circulation_api: CirculationAPIFixture):
# Here's an open-access title.
collection = circulation_api.db.collection(
Expand Down