Skip to content

Commit 47b22ef

Browse files
Ekir 463 delete expired loans (#175)
* EKIR-463 Delete expired loans daily Delete all loans that expired yesterday and before that on a daily basis. * EKIR-463 Delete an expired loan * EKIR-463 Delete very old loans as well * EKIR-463 Fix test
1 parent 17fc0ab commit 47b22ef

9 files changed

Lines changed: 177 additions & 25 deletions

File tree

api/controller/odl_notification.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from flask import Response
55
from flask_babel import lazy_gettext as _
66
from pydantic import ValidationError
7-
from sqlalchemy.orm.exc import StaleDataError
87

98
from api.controller.circulation_manager import CirculationManagerController
109
from api.lcp.status import LoanStatus
@@ -19,7 +18,6 @@
1918
from core.model.credential import Credential
2019
from core.model.licensing import License
2120
from core.model.patron import Loan, Patron
22-
from core.util.datetime_helpers import utc_now
2321
from core.util.problem_detail import ProblemDetailException
2422

2523

@@ -96,14 +94,10 @@ def _process_notification(self, loan: Loan | None) -> Response:
9694

9795
# We might be out of sync with the distributor. Mark the loan as expired and update the license pool.
9896
if not status_doc.active:
99-
try:
100-
with self._db.begin_nested():
101-
loan.end = utc_now()
102-
except StaleDataError:
103-
# This can happen if this callback happened while we were returning this
104-
# item. We can fetch the loan, but it's deleted by the time we go to do
105-
# the update. This is not a problem, as we were just marking the loan as
106-
# completed anyway so we just continue.
107-
...
97+
self.log.info(f"Loan status was {status_doc.status}, will delete loan")
98+
api = self.manager.circulation_apis[library.id].api_for_license_pool(
99+
loan.license_pool
100+
)
101+
api.delete_expired_loan(loan)
108102

109103
return Response(_("Success"), 200)

api/odl.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,16 @@ def patron_activity(self, patron: Patron, pin: str) -> list[LoanInfo | HoldInfo]
833833
def update_availability(self, licensepool: LicensePool) -> None:
834834
licensepool.update_availability_from_licenses()
835835

836+
def delete_expired_loan(self, loan: Loan) -> None:
837+
"""
838+
Delete a loan we know to be expired and update the license pool.
839+
"""
840+
_db = Session.object_session(loan)
841+
loan.license.checkin()
842+
self.log.info(f"Deleting loan #{loan.id}")
843+
_db.delete(loan)
844+
self.update_licensepool_and_hold_queue(loan.license_pool)
845+
836846

837847
class ODLAPI(
838848
BaseODLAPI[ODLSettings, ODLLibrarySettings],

api/odl2.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import annotations
22

3+
import datetime
34
import logging
45
from collections.abc import Callable
56
from typing import TYPE_CHECKING, Any
67

78
from flask_babel import lazy_gettext as _
89
from pydantic import PositiveInt
910
from sqlalchemy.orm import Session
11+
from sqlalchemy.sql.expression import and_, or_
1012
from webpub_manifest_parser.odl import ODLFeedParserFactory
1113
from webpub_manifest_parser.opds2.registry import OPDS2LinkRelationsRegistry
1214

@@ -18,17 +20,18 @@
1820
ConfigurationFormItemType,
1921
FormField,
2022
)
21-
from core.metadata_layer import FormatData
22-
from core.model import Edition, RightsStatus
23+
from core.metadata_layer import FormatData, TimestampData
24+
from core.model import Edition, LicensePool, Loan, RightsStatus
2325
from core.model.configuration import ExternalIntegration
26+
from core.monitor import CollectionMonitor
2427
from core.opds2_import import (
2528
OPDS2Importer,
2629
OPDS2ImporterSettings,
2730
OPDS2ImportMonitor,
2831
RWPMManifestParser,
2932
)
3033
from core.util import first_or_default
31-
from core.util.datetime_helpers import to_utc
34+
from core.util.datetime_helpers import to_utc, utc_now
3235

3336
if TYPE_CHECKING:
3437
from webpub_manifest_parser.core.ast import Metadata
@@ -316,3 +319,62 @@ def __init__(
316319
super().__init__(
317320
_db, collection, import_class, force_reimport=True, **import_class_kwargs
318321
)
322+
323+
324+
class ODL2LoanReaper(CollectionMonitor):
325+
"""Check for loans that have expired and delete them, and update
326+
the holds queues for their pools."""
327+
328+
SERVICE_NAME = "ODL2 Loan Reaper"
329+
PROTOCOL = ODL2API.label()
330+
331+
def __init__(
332+
self,
333+
_db: Session,
334+
collection: Collection,
335+
api: ODL2API | None = None,
336+
**kwargs: Any,
337+
):
338+
super().__init__(_db, collection, **kwargs)
339+
self.api = api or ODL2API(_db, collection)
340+
341+
def run_once(self, progress: TimestampData) -> TimestampData:
342+
# Find loans that have expired.
343+
self.log.info("Loan Reaper Job started")
344+
now = utc_now()
345+
expired_loans = (
346+
self._db.query(Loan)
347+
.join(Loan.license_pool)
348+
.filter(
349+
and_(
350+
LicensePool.open_access == False,
351+
or_(
352+
Loan.end
353+
< now
354+
- datetime.timedelta(
355+
days=1
356+
), # Loans that ended before yesterday
357+
Loan.start < now - datetime.timedelta(days=90),
358+
Loan.end == None,
359+
), # Loans that started more than 90 days ago and have no end date
360+
)
361+
)
362+
)
363+
364+
changed_pools = set()
365+
total_deleted_loans = 0
366+
for loan in expired_loans:
367+
changed_pools.add(loan.license_pool)
368+
loan.license.checkin()
369+
self._db.delete(loan)
370+
total_deleted_loans += 1
371+
372+
for pool in changed_pools:
373+
self.api.update_licensepool_and_hold_queue(pool)
374+
375+
message = "Loans deleted: %d. License pools updated: %d" % (
376+
total_deleted_loans,
377+
len(changed_pools),
378+
)
379+
progress = TimestampData(achievements=message)
380+
return progress

bin/odl2_loan_reaper

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env python
2+
"""Check for loans that have expired and delete them."""
3+
import os
4+
import sys
5+
6+
bin_dir = os.path.split(__file__)[0]
7+
package_dir = os.path.join(bin_dir, "..")
8+
sys.path.append(os.path.abspath(package_dir))
9+
from api.odl2 import ODL2LoanReaper
10+
from core.scripts import RunCollectionMonitorScript
11+
12+
RunCollectionMonitorScript(ODL2LoanReaper).run()

docker/services/cron/cron.d/circulation

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,10 @@ HOME=/var/www/circulation
9090
15 * * * * root core/bin/run odl_import_monitor >> /var/log/cron.log 2>&1
9191
0 */4 * * * root core/bin/run odl_hold_reaper >> /var/log/cron.log 2>&1
9292

93-
# OPDS 2.x + ODL import
93+
# OPDS 2.x + ODL
9494
#
9595
0 3 * * * root core/bin/run odl2_import_monitor >> /var/log/cron.log 2>&1
96+
0 4 * * * root core/bin/run odl2_loan_reaper >> /var/log/cron.log 2>&1
9697

9798
# SAML
9899
#

tests/api/controller/test_odl_notify.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -237,14 +237,11 @@ def test_notify_success(
237237
)
238238
assert odl_fixture.license.identifier is not None
239239
assert 200 == response.status_code
240-
241-
# Since we had a loan and it's not active, we're out of sync with the remote. We've set the loan to end now.
242-
assert loan.end == utc_now()
243-
244-
# The pool's availability has been updated.
245-
api = controller_fixture.manager.circulation_apis[
246-
db.default_library().id
247-
].api_for_license_pool(loan.license_pool)
240+
# The pool's availability has been updated.
241+
api = controller_fixture.manager.circulation_apis[
242+
db.default_library().id
243+
].api_for_license_pool(loan.license_pool)
244+
assert [loan.license_pool] == api.availability_updated_for
248245

249246
def test_notify_errors(
250247
self, controller_fixture: ControllerFixture, odl_fixture: ODLFixture

tests/api/mockapi/circulation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ def release_hold(self, patron, pin, licensepool):
8282
def internal_format(self, delivery_mechanism):
8383
return delivery_mechanism
8484

85-
def update_loan(self, loan, status_doc):
85+
# Only called TestODLNotificationController
86+
def delete_expired_loan(self, loan):
8687
self.availability_updated_for.append(loan.license_pool)
8788

8889
def queue_checkout(self, response):

tests/api/test_odl.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,27 @@ def test_patron_activity_loan(
13711371
assert 1 == pool2.licenses_available
13721372
assert 0 == pool2.licenses_reserved
13731373

1374+
def test_delete_expired_loan(
1375+
self,
1376+
db: DatabaseTransactionFixture,
1377+
opds2_with_odl_api_fixture: OPDS2WithODLApiFixture,
1378+
) -> None:
1379+
opds2_with_odl_api_fixture.setup_license(concurrency=1, available=0)
1380+
# This loan expired a few days ago
1381+
loan, _ = opds2_with_odl_api_fixture.license.loan_to(
1382+
opds2_with_odl_api_fixture.patron,
1383+
end=utc_now() - datetime.timedelta(days=3),
1384+
)
1385+
assert 1 == db.session.query(Loan).count()
1386+
1387+
opds2_with_odl_api_fixture.api.delete_expired_loan(loan)
1388+
1389+
# There's no more loans
1390+
assert 0 == db.session.query(Loan).count()
1391+
# The pool's and license's availability has increased
1392+
assert 1 == opds2_with_odl_api_fixture.pool.licenses_available
1393+
assert 1 == opds2_with_odl_api_fixture.license.checkouts_available
1394+
13741395

13751396
class TestODLImporter:
13761397
@freeze_time("2019-01-01T00:00:00+00:00")

tests/api/test_odl2.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,25 @@
99
)
1010

1111
from api.circulation_exceptions import PatronHoldLimitReached, PatronLoanLimitReached
12-
from api.odl2 import ODL2Importer
12+
from api.odl2 import ODL2Importer, ODL2LoanReaper
1313
from core.coverage import CoverageFailure
1414
from core.model import (
15+
Collection,
1516
Contribution,
1617
Contributor,
18+
DataSource,
1719
DeliveryMechanism,
1820
Edition,
1921
EditionConstants,
2022
LicensePool,
2123
LicensePoolDeliveryMechanism,
24+
Loan,
2225
MediaTypes,
2326
Work,
2427
)
2528
from core.model.constants import IdentifierConstants
2629
from core.model.resource import Hyperlink
30+
from core.util.datetime_helpers import utc_now
2731
from tests.fixtures.api_odl import (
2832
LicenseHelper,
2933
LicenseInfoHelper,
@@ -440,3 +444,53 @@ def test_hold_limit(
440444
with pytest.raises(PatronHoldLimitReached) as exc:
441445
odl2_api_fixture.place_hold(odl2_api_fixture.patron, pool)
442446
assert exc.value.limit == 1
447+
448+
449+
class TestODL2LoanReaper:
450+
def test_run_once(
451+
self, odl2_api_fixture: ODL2ApiFixture, db: DatabaseTransactionFixture
452+
):
453+
data_source = DataSource.lookup(db.session, "Feedbooks", autocreate=True)
454+
DatabaseTransactionFixture.set_settings(
455+
odl2_api_fixture.collection.integration_configuration,
456+
**{Collection.DATA_SOURCE_NAME_SETTING: data_source.name},
457+
)
458+
reaper = ODL2LoanReaper(
459+
db.session, odl2_api_fixture.collection, api=odl2_api_fixture.api
460+
)
461+
462+
now = utc_now()
463+
yesterday = now - datetime.timedelta(days=1)
464+
tomorrow = now + datetime.timedelta(days=1)
465+
466+
# License with all its checkouts on loan to 4 patrons.
467+
odl2_api_fixture.setup_license(concurrency=4, available=0)
468+
expired_loan1, ignore = odl2_api_fixture.license.loan_to(
469+
db.patron(), end=yesterday
470+
)
471+
expired_loan2, ignore = odl2_api_fixture.license.loan_to(
472+
db.patron(), end=yesterday
473+
)
474+
current_loan, ignore = odl2_api_fixture.license.loan_to(
475+
db.patron(), end=tomorrow
476+
)
477+
very_old_loan, ignore = odl2_api_fixture.license.loan_to(
478+
db.patron(), start=now - datetime.timedelta(days=90), end=None
479+
)
480+
assert 4 == db.session.query(Loan).count()
481+
assert 0 == odl2_api_fixture.pool.licenses_available
482+
483+
progress = reaper.run_once(reaper.timestamp().to_data())
484+
485+
# The expired loans have been deleted and the current loan remains.
486+
assert 1 == db.session.query(Loan).count()
487+
488+
assert 3 == odl2_api_fixture.pool.licenses_available
489+
490+
# The TimestampData returned reflects what work was done.
491+
assert "Loans deleted: 3. License pools updated: 1" == progress.achievements
492+
493+
# The TimestampData does not include any timing information --
494+
# that will be applied by run().
495+
assert None == progress.start
496+
assert None == progress.finish

0 commit comments

Comments
 (0)