Skip to content

Commit 4922d7a

Browse files
committed
First of three PRs that convert Overdrive API responses to pydantic models (PP-3175)
1 parent cfdac82 commit 4922d7a

7 files changed

Lines changed: 261 additions & 96 deletions

File tree

src/palace/manager/integration/license/overdrive/api.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
OverdriveManifestFulfillment,
7979
)
8080
from palace.manager.integration.license.overdrive.model import (
81+
Availability,
8182
BaseOverdriveModel,
8283
Checkout,
8384
Checkouts,
@@ -1692,25 +1693,31 @@ def update_licensepool(
16921693
status_code,
16931694
)
16941695
return None, None, False
1695-
book.update(json.loads(content))
16961696

1697-
# Update book_id now that we know we have new data.
1698-
book_id = book["id"]
1697+
availability = Availability.model_validate_json(content)
1698+
1699+
# Use the caller-provided ID for LicensePool lookup. This is always
1700+
# set because circulation_lookup creates dict(id=book_id) when called
1701+
# with a string, and book-list dicts always include "id".
1702+
resolved_book_id = cast(str, book.get("id"))
1703+
16991704
license_pool, is_new = LicensePool.for_foreign_id(
17001705
self._db,
17011706
DataSource.OVERDRIVE,
17021707
Identifier.OVERDRIVE_ID,
1703-
cast(str, book_id),
1708+
resolved_book_id,
17041709
collection=self.collection,
17051710
)
17061711
if is_new or not license_pool.work:
1707-
# Either this is the first time we've seen this book or its doesn't
1712+
# Either this is the first time we've seen this book or it doesn't
17081713
# have an associated work. Make sure its identifier has bibliographic coverage.
17091714
self.overdrive_bibliographic_coverage_provider.ensure_coverage(
17101715
license_pool.identifier, force=True
17111716
)
17121717

1713-
return self.update_licensepool_with_book_info(book, license_pool, is_new)
1718+
return self.update_licensepool_with_book_info(
1719+
availability, resolved_book_id, license_pool, is_new
1720+
)
17141721

17151722
# Alias for the CirculationAPI interface
17161723
def update_availability(self, licensepool: LicensePool) -> None:
@@ -1728,18 +1735,26 @@ def _edition(self, licensepool: LicensePool) -> tuple[Edition, bool]:
17281735
)
17291736

17301737
def update_licensepool_with_book_info(
1731-
self, book: dict[str, Any], license_pool: LicensePool, is_new_pool: bool
1738+
self,
1739+
availability: Availability,
1740+
book_id: str,
1741+
license_pool: LicensePool,
1742+
is_new_pool: bool,
17321743
) -> tuple[LicensePool, bool, bool]:
1733-
"""Update a book's LicensePool with information from a JSON
1734-
representation of its circulation info.
1744+
"""Update a book's LicensePool with information from an availability document.
17351745
17361746
Then, create an Edition and make sure it has bibliographic
17371747
coverage. If the new Edition is the only candidate for the
17381748
pool's presentation_edition, promote it to presentation
17391749
status.
1750+
1751+
:param availability: The parsed Overdrive availability document.
1752+
:param book_id: The Overdrive product ID for this book.
1753+
:param license_pool: The LicensePool to update.
1754+
:param is_new_pool: Whether this is a newly created LicensePool.
17401755
"""
17411756
extractor = OverdriveRepresentationExtractor(self)
1742-
circulation = extractor.book_info_to_circulation(book)
1757+
circulation = extractor.book_info_to_circulation(availability, book_id=book_id)
17431758
lp, circulation_changed = circulation.apply(self._db, license_pool.collection)
17441759
if lp is not None:
17451760
license_pool = lp

src/palace/manager/integration/license/overdrive/importer.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
BookInfoEndpoint,
1919
OverdriveAPI,
2020
)
21+
from palace.manager.integration.license.overdrive.model import Availability
2122
from palace.manager.integration.license.overdrive.representation import (
2223
OverdriveRepresentationExtractor,
2324
)
@@ -162,15 +163,16 @@ def _process_book(
162163

163164
# availability needs to be checked/updated in all but a few instances so it is
164165
# probably not worth the compute time to save ourselves a handful of unnecessary updates.
165-
availability = book.get("availabilityV2", None)
166-
if not availability:
166+
availability_data = book.get("availabilityV2", None)
167+
if not availability_data:
167168
# This is a rare and probably transient case where the availabilityV2
168169
# was not retrieved due to a 404 from OD.
169170
self.log.warning(
170171
f"No availabilityV2 found for book {identifier}. book={book}. This state can "
171172
f"arise when the OD returns a 404 for the availability url."
172173
)
173174
else:
175+
availability = Availability.model_validate(availability_data)
174176
circulation = self._extractor.book_info_to_circulation(availability)
175177
# The circulation should never be null here because there is a non-null entry for availabilityV2 in the
176178
# book dictionary. Mypy complains without an assertion or type hints.

src/palace/manager/integration/license/overdrive/model.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import re
33
import typing
4+
from enum import StrEnum
45
from functools import cached_property
56
from typing import Protocol, Self, overload
67
from urllib.parse import quote_plus
@@ -494,3 +495,49 @@ class PatronInformation(BaseOverdriveModel):
494495
default_factory=dict, alias="linkTemplates"
495496
)
496497
actions: list[dict[str, Action]] = Field(default_factory=list)
498+
499+
500+
class AvailabilityType(StrEnum):
501+
"""Availability type for a title in an Overdrive collection."""
502+
503+
NORMAL = "Normal"
504+
ALWAYS_AVAILABLE = "AlwaysAvailable"
505+
LIMITED_AVAILABILITY = "LimitedAvailability"
506+
507+
508+
class AvailabilityAccount(BaseOverdriveModel):
509+
"""
510+
Per-library copy availability within an Overdrive availability document.
511+
512+
See: https://developer.overdrive.com/apis/library-availability-new
513+
"""
514+
515+
id: int
516+
copies_owned: NonNegativeInt = Field(0, alias="copiesOwned")
517+
copies_available: NonNegativeInt = Field(0, alias="copiesAvailable")
518+
shared: bool = False
519+
520+
521+
class Availability(BaseOverdriveModel):
522+
"""
523+
Availability information for a single title.
524+
525+
This model is used for both successful availability responses and error
526+
responses (e.g. ``errorCode: "NotFound"``). Because error responses do not
527+
include ``reserveId``, that field is optional. Callers that receive an error
528+
response must supply the book ID from context via the ``book_id`` parameter
529+
of :meth:`OverdriveRepresentationExtractor.book_info_to_circulation`.
530+
531+
See: https://developer.overdrive.com/apis/library-availability-new
532+
"""
533+
534+
reserve_id: str | None = Field(None, alias="reserveId")
535+
accounts: list[AvailabilityAccount] = Field(default_factory=list)
536+
availability_type: AvailabilityType | None = Field(None, alias="availabilityType")
537+
available: bool = False
538+
copies_available: NonNegativeInt | None = Field(None, alias="copiesAvailable")
539+
copies_owned: NonNegativeInt | None = Field(None, alias="copiesOwned")
540+
number_of_holds: NonNegativeInt = Field(0, alias="numberOfHolds")
541+
is_owned_by_collections: bool | None = Field(None, alias="isOwnedByCollections")
542+
error_code: str | None = Field(None, alias="errorCode")
543+
links: dict[str, Link] = Field(default_factory=dict)

src/palace/manager/integration/license/overdrive/representation.py

Lines changed: 49 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
from palace.manager.integration.license.overdrive.constants import (
1919
OVERDRIVE_MAIN_ACCOUNT_ID,
2020
)
21+
from palace.manager.integration.license.overdrive.model import (
22+
Availability,
23+
AvailabilityAccount,
24+
)
2125
from palace.manager.integration.license.overdrive.util import _make_link_safe
2226
from palace.manager.sqlalchemy.constants import MediaTypes
2327
from palace.manager.sqlalchemy.model.classification import Classification, Subject
@@ -219,31 +223,41 @@ def parse_roles(cls, id: str, rolestring: str) -> list[Contributor.Role]:
219223
processed.append(cls.overdrive_role_to_simplified_role[x])
220224
return processed
221225

222-
def book_info_to_circulation(self, book: dict[str, Any]) -> CirculationData:
223-
"""Note: The json data passed into this method is from a different file/stream
224-
from the json data that goes into the book_info_to_metadata() method.
226+
def book_info_to_circulation(
227+
self, availability: Availability, book_id: str | None = None
228+
) -> CirculationData:
229+
"""Convert an Overdrive availability document into a :class:`CirculationData` object.
230+
231+
Note: The availability data passed into this method is from a different
232+
API endpoint than the metadata data that goes into
233+
:meth:`book_info_to_bibliographic`.
234+
235+
:param availability: The parsed Overdrive availability document.
236+
:param book_id: Optional Overdrive ID to use when the availability
237+
document does not include a ``reserveId`` (e.g. a NotFound error
238+
response). If neither ``availability.reserve_id`` nor ``book_id``
239+
is present, a :exc:`PalaceValueError` is raised.
225240
"""
226-
# In Overdrive, 'reserved' books show up as books on
227-
# hold. There is no separate notion of reserved books.
241+
# In Overdrive, 'reserved' books show up as books on hold.
228242
licenses_reserved = 0
229243

230244
licenses_owned = None
231245
licenses_available = None
232246
patrons_in_hold_queue = None
233247

234-
# TODO: The only reason this works for a NotFound error is the
235-
# circulation code sticks the known book ID into `book` ahead
236-
# of time. That's a code smell indicating that this system
237-
# needs to be refactored.
238-
if "reserveId" in book and not "id" in book:
239-
book["id"] = book["reserveId"]
240-
if not "id" in book:
241-
self.log.error("Book has no ID: %r", book)
248+
# book_id takes precedence over the document's reserveId so that the
249+
# caller can override the identifier (e.g. after a circulation lookup
250+
# where the caller already knows the book's ID). Fall back to reserveId
251+
# when no external book_id is provided (e.g. from the importer).
252+
overdrive_id = book_id or availability.reserve_id
253+
if not overdrive_id:
254+
self.log.error("Availability has no ID: %r", availability)
242255
raise PalaceValueError("Book must have an id to be processed")
243-
overdrive_id = book["id"]
256+
244257
primary_identifier = IdentifierData(
245258
type=Identifier.OVERDRIVE_ID, identifier=overdrive_id
246259
)
260+
247261
# TODO: We might be able to use this information to avoid the
248262
# need for explicit configuration of Advantage collections, or
249263
# at least to keep Advantage collections more up-to-date than
@@ -261,28 +275,24 @@ def book_info_to_circulation(self, book: dict[str, Any]) -> CirculationData:
261275
# similarly, though those can abruptly become unavailable, so
262276
# UNLIMITED_ACCESS is probably not appropriate.
263277

264-
error_code = book.get("errorCode")
265278
# TODO: It's not clear what other error codes there might be.
266279
# The current behavior will respond to errors other than
267280
# NotFound by leaving the book alone, but this might not be
268281
# the right behavior.
269-
if error_code == "NotFound":
282+
if availability.error_code == "NotFound":
270283
licenses_owned = 0
271284
licenses_available = 0
272285
patrons_in_hold_queue = 0
273-
elif book.get("isOwnedByCollections") is not False:
286+
elif availability.is_owned_by_collections is not False:
274287
# We own this book.
275288
licenses_owned = 0
276289
licenses_available = 0
277290

278-
for account in self._get_applicable_accounts(book.get("accounts", [])):
279-
licenses_owned += int(account.get("copiesOwned", 0))
280-
licenses_available += int(account.get("copiesAvailable", 0))
291+
for account in self._get_applicable_accounts(availability.accounts):
292+
licenses_owned += account.copies_owned
293+
licenses_available += account.copies_available
281294

282-
if "numberOfHolds" in book:
283-
if patrons_in_hold_queue is None:
284-
patrons_in_hold_queue = 0
285-
patrons_in_hold_queue += book["numberOfHolds"]
295+
patrons_in_hold_queue = availability.number_of_holds
286296

287297
if licenses_owned is None:
288298
license_status = None
@@ -302,38 +312,26 @@ def book_info_to_circulation(self, book: dict[str, Any]) -> CirculationData:
302312
)
303313

304314
def _get_applicable_accounts(
305-
self, accounts: list[dict[str, Any]]
306-
) -> list[dict[str, Any]]:
307-
"""
308-
Returns those accounts from the accounts array that apply the
309-
current overdrive collection context.
315+
self, accounts: list[AvailabilityAccount]
316+
) -> list[AvailabilityAccount]:
317+
"""Return the accounts from the availability document that apply to the
318+
current Overdrive collection context.
310319
311-
If this is an overdrive parent collection, we want to return accounts
312-
associated with the main OverDrive "library" and any non-main account
313-
with sharing enabled.
320+
For a parent collection, returns accounts for the main OverDrive
321+
"library" and any sub-account with sharing enabled.
314322
315-
If this is a child OverDrive collection, then we return only the
316-
account associated with that child's OverDrive Advantage "library".
317-
Additionally, we want to exclude the account if it is "shared" since
318-
we will be counting it with the parent collection.
323+
For a child Overdrive Advantage collection, returns only the account
324+
matching that child's library ID (excluding shared copies, which are
325+
counted with the parent).
319326
"""
320-
321327
if self.library_id == OVERDRIVE_MAIN_ACCOUNT_ID:
322-
# this is a parent collection
323-
filtered_result = filter(
324-
lambda account: account.get("id") == OVERDRIVE_MAIN_ACCOUNT_ID
325-
or account.get("shared", False),
326-
accounts,
327-
)
328+
# parent collection
329+
return [
330+
a for a in accounts if a.id == OVERDRIVE_MAIN_ACCOUNT_ID or a.shared
331+
]
328332
else:
329-
# this is child collection
330-
filtered_result = filter(
331-
lambda account: account.get("id") == self.library_id
332-
and not account.get("shared", False),
333-
accounts,
334-
)
335-
336-
return list(filtered_result)
333+
# child Advantage collection
334+
return [a for a in accounts if a.id == self.library_id and not a.shared]
337335

338336
@classmethod
339337
def image_link_to_linkdata(

0 commit comments

Comments
 (0)