diff --git a/src/palace/manager/feed/annotator/base.py b/src/palace/manager/feed/annotator/base.py index ff1fe07e8..cefab8b88 100644 --- a/src/palace/manager/feed/annotator/base.py +++ b/src/palace/manager/feed/annotator/base.py @@ -237,10 +237,10 @@ def content(cls, work: Work | None) -> str: and work.summary.representation.content ): content = work.summary.representation.content - if isinstance(content, bytes): - content = content.decode("utf-8") - work.summary_text = content - summary = work.summary_text + content_str = ( + content.decode("utf-8") if isinstance(content, bytes) else content + ) + summary = work.summary_text = content_str return summary diff --git a/src/palace/manager/sqlalchemy/model/admin.py b/src/palace/manager/sqlalchemy/model/admin.py index 8a3043844..9d3437565 100644 --- a/src/palace/manager/sqlalchemy/model/admin.py +++ b/src/palace/manager/sqlalchemy/model/admin.py @@ -40,7 +40,7 @@ class Admin(Base, HasSessionCache): # An Admin may have many roles. roles: Mapped[list[AdminRole]] = relationship( - "AdminRole", backref="admin", cascade="all, delete-orphan", uselist=True + "AdminRole", back_populates="admin", cascade="all, delete-orphan", uselist=True ) # Token age is max 30 minutes, in seconds @@ -290,6 +290,7 @@ class AdminRole(Base, HasSessionCache): id = Column(Integer, primary_key=True) admin_id = Column(Integer, ForeignKey("admins.id"), nullable=False, index=True) + admin: Mapped[Admin] = relationship("Admin", back_populates="roles") library_id = Column(Integer, ForeignKey("libraries.id"), nullable=True, index=True) library: Mapped[Library] = relationship("Library", back_populates="adminroles") role = Column(Unicode, nullable=False, index=True) diff --git a/src/palace/manager/sqlalchemy/model/circulationevent.py b/src/palace/manager/sqlalchemy/model/circulationevent.py index b41d846ed..63522521a 100644 --- a/src/palace/manager/sqlalchemy/model/circulationevent.py +++ b/src/palace/manager/sqlalchemy/model/circulationevent.py @@ -1,14 +1,20 @@ # CirculationEvent - +from __future__ import annotations import logging +from typing import TYPE_CHECKING from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, String, Unicode +from sqlalchemy.orm import Mapped, relationship from palace.manager.sqlalchemy.model.base import Base from palace.manager.sqlalchemy.util import get_one_or_create from palace.manager.util.datetime_helpers import utc_now +if TYPE_CHECKING: + from palace.manager.sqlalchemy.model.library import Library + from palace.manager.sqlalchemy.model.licensing import LicensePool + class CirculationEvent(Base): """Changes to a license pool's circulation status. @@ -25,6 +31,9 @@ class CirculationEvent(Base): # One LicensePool can have many circulation events. license_pool_id = Column(Integer, ForeignKey("licensepools.id"), index=True) + license_pool: Mapped[LicensePool] = relationship( + "LicensePool", back_populates="circulation_events" + ) type = Column(String(32), index=True) start = Column(DateTime(timezone=True), index=True) @@ -36,6 +45,9 @@ class CirculationEvent(Base): # The Library associated with the event, if it happened in the # context of a particular Library and we know which one. library_id = Column(Integer, ForeignKey("libraries.id"), index=True, nullable=True) + library: Mapped[Library] = relationship( + "Library", back_populates="circulation_events" + ) # The geographic location associated with the event. This string # may mean different things for different libraries. It might be a diff --git a/src/palace/manager/sqlalchemy/model/classification.py b/src/palace/manager/sqlalchemy/model/classification.py index d066487e5..8c4516e53 100644 --- a/src/palace/manager/sqlalchemy/model/classification.py +++ b/src/palace/manager/sqlalchemy/model/classification.py @@ -137,6 +137,7 @@ class Subject(Base): # Each Subject may claim affinity with one Genre. genre_id = Column(Integer, ForeignKey("genres.id"), index=True) + genre: Mapped[Genre] = relationship("Genre", back_populates="subjects") # A locked Subject has been reviewed by a human and software will # not mess with it without permission. @@ -351,11 +352,15 @@ class Classification(Base): __tablename__ = "classifications" id = Column(Integer, primary_key=True) identifier_id = Column(Integer, ForeignKey("identifiers.id"), index=True) - identifier: Mapped[Identifier | None] + identifier: Mapped[Identifier] = relationship( + "Identifier", back_populates="classifications" + ) subject_id = Column(Integer, ForeignKey("subjects.id"), index=True) subject: Mapped[Subject] = relationship("Subject", back_populates="classifications") data_source_id = Column(Integer, ForeignKey("datasources.id"), index=True) - data_source: Mapped[DataSource | None] + data_source: Mapped[DataSource] = relationship( + "DataSource", back_populates="classifications" + ) # How much weight the data source gives to this classification. weight = Column(Integer) @@ -486,7 +491,7 @@ class Genre(Base, HasSessionCache): name = Column(Unicode, unique=True, index=True) # One Genre may have affinity with many Subjects. - subjects: Mapped[list[Subject]] = relationship("Subject", backref="genre") + subjects: Mapped[list[Subject]] = relationship("Subject", back_populates="genre") # One Genre may participate in many WorkGenre assignments. works = association_proxy("work_genres", "work") @@ -495,7 +500,9 @@ class Genre(Base, HasSessionCache): "WorkGenre", back_populates="genre", cascade="all, delete-orphan" ) - lane_genres: Mapped[list[LaneGenre]] = relationship("LaneGenre", backref="genre") + lane_genres: Mapped[list[LaneGenre]] = relationship( + "LaneGenre", back_populates="genre" + ) def __repr__(self): if classifier.genres.get(self.name): diff --git a/src/palace/manager/sqlalchemy/model/collection.py b/src/palace/manager/sqlalchemy/model/collection.py index d35ab3812..9b184b907 100644 --- a/src/palace/manager/sqlalchemy/model/collection.py +++ b/src/palace/manager/sqlalchemy/model/collection.py @@ -130,13 +130,13 @@ class Collection(Base, HasSessionCache, RedisKeyMixin): ) catalog: Mapped[list[Identifier]] = relationship( - "Identifier", secondary="collections_identifiers", backref="collections" + "Identifier", secondary="collections_identifiers", back_populates="collections" ) # A Collection can be associated with multiple CoverageRecords # for Identifiers in its catalog. coverage_records: Mapped[list[CoverageRecord]] = relationship( - "CoverageRecord", backref="collection", cascade="all" + "CoverageRecord", back_populates="collection", cascade="all" ) # A collection may be associated with one or more custom lists. @@ -145,7 +145,7 @@ class Collection(Base, HasSessionCache, RedisKeyMixin): # the list and they won't be added back, so the list doesn't # necessarily match the collection. customlists: Mapped[list[CustomList]] = relationship( - "CustomList", secondary="collections_customlists", backref="collections" + "CustomList", secondary="collections_customlists", back_populates="collections" ) export_marc_records = Column(Boolean, default=False, nullable=False) diff --git a/src/palace/manager/sqlalchemy/model/coverage.py b/src/palace/manager/sqlalchemy/model/coverage.py index 923e5b123..549e1627c 100644 --- a/src/palace/manager/sqlalchemy/model/coverage.py +++ b/src/palace/manager/sqlalchemy/model/coverage.py @@ -347,6 +347,9 @@ class CoverageRecord(Base, BaseCoverageRecord): # coverage has taken place. This is currently only applicable # for Metadata Wrangler coverage. collection_id = Column(Integer, ForeignKey("collections.id"), nullable=True) + collection: Mapped[Collection] = relationship( + "Collection", back_populates="coverage_records" + ) __table_args__ = ( Index( diff --git a/src/palace/manager/sqlalchemy/model/customlist.py b/src/palace/manager/sqlalchemy/model/customlist.py index 99decb5e7..c94a58dad 100644 --- a/src/palace/manager/sqlalchemy/model/customlist.py +++ b/src/palace/manager/sqlalchemy/model/customlist.py @@ -31,6 +31,8 @@ if TYPE_CHECKING: from palace.manager.sqlalchemy.model.collection import Collection + from palace.manager.sqlalchemy.model.edition import Edition + from palace.manager.sqlalchemy.model.lane import Lane from palace.manager.sqlalchemy.model.library import Library @@ -43,7 +45,6 @@ class CustomList(Base): INIT = "init" UPDATED = "updated" REPOPULATE = "repopulate" - auto_update_status_enum = Enum(INIT, UPDATED, REPOPULATE, name="auto_update_status") __tablename__ = "customlists" id = Column(Integer, primary_key=True) @@ -59,13 +60,14 @@ class CustomList(Base): updated = Column(DateTime(timezone=True), index=True) responsible_party = Column(Unicode) library_id = Column(Integer, ForeignKey("libraries.id"), index=True, nullable=True) + library: Mapped[Library] = relationship("Library", back_populates="custom_lists") # How many titles are in this list? This is calculated and # cached when the list contents change. size = Column(Integer, nullable=False, default=0) entries: Mapped[list[CustomListEntry]] = relationship( - "CustomListEntry", backref="customlist", uselist=True + "CustomListEntry", back_populates="customlist", uselist=True ) # List sharing mechanisms @@ -80,11 +82,17 @@ class CustomList(Base): auto_update_query = Column(Unicode, nullable=True) # holds json data auto_update_facets = Column(Unicode, nullable=True) # holds json data auto_update_last_update = Column(DateTime, nullable=True) - auto_update_status: Mapped[str] = Column(auto_update_status_enum, default=INIT) # type: ignore[assignment] + auto_update_status = Column( + Enum(INIT, UPDATED, REPOPULATE, name="auto_update_status"), default=INIT + ) + + lanes: Mapped[list[Lane]] = relationship( + "Lane", back_populates="customlists", secondary="lanes_customlists" + ) - # Typing specific - collections: list[Collection] - library: Library + collections: list[Collection] = relationship( + "Collection", secondary="collections_customlists", back_populates="customlists" + ) __table_args__ = ( UniqueConstraint("data_source_id", "foreign_identifier"), @@ -364,8 +372,15 @@ class CustomListEntry(Base): __tablename__ = "customlistentries" id = Column(Integer, primary_key=True) list_id = Column(Integer, ForeignKey("customlists.id"), index=True) + customlist: Mapped[CustomList] = relationship( + "CustomList", back_populates="entries" + ) edition_id = Column(Integer, ForeignKey("editions.id"), index=True) + edition: Mapped[Edition] = relationship( + "Edition", back_populates="custom_list_entries" + ) work_id = Column(Integer, ForeignKey("works.id"), index=True) + work: Mapped[Work] = relationship("Work", back_populates="custom_list_entries") featured = Column(Boolean, nullable=False, default=False) annotation = Column(Unicode) diff --git a/src/palace/manager/sqlalchemy/model/datasource.py b/src/palace/manager/sqlalchemy/model/datasource.py index fa12285ca..8f19bdf9a 100644 --- a/src/palace/manager/sqlalchemy/model/datasource.py +++ b/src/palace/manager/sqlalchemy/model/datasource.py @@ -64,10 +64,14 @@ class DataSource(Base, HasSessionCache, DataSourceConstants): ) # One DataSource can provide many Hyperlinks. - links: Mapped[list[Hyperlink]] = relationship("Hyperlink", backref="data_source") + links: Mapped[list[Hyperlink]] = relationship( + "Hyperlink", back_populates="data_source" + ) # One DataSource can provide many Resources. - resources: Mapped[list[Resource]] = relationship("Resource", backref="data_source") + resources: Mapped[list[Resource]] = relationship( + "Resource", back_populates="data_source" + ) # One DataSource can generate many Measurements. measurements: Mapped[list[Measurement]] = relationship( @@ -76,7 +80,7 @@ class DataSource(Base, HasSessionCache, DataSourceConstants): # One DataSource can provide many Classifications. classifications: Mapped[list[Classification]] = relationship( - "Classification", backref="data_source" + "Classification", back_populates="data_source" ) # One DataSource can have many associated Credentials. @@ -92,7 +96,7 @@ class DataSource(Base, HasSessionCache, DataSourceConstants): # One DataSource can provide many LicensePoolDeliveryMechanisms. delivery_mechanisms: Mapped[list[LicensePoolDeliveryMechanism]] = relationship( "LicensePoolDeliveryMechanism", - backref="data_source", + back_populates="data_source", foreign_keys="LicensePoolDeliveryMechanism.data_source_id", ) diff --git a/src/palace/manager/sqlalchemy/model/devicetokens.py b/src/palace/manager/sqlalchemy/model/devicetokens.py index d55d2b1e5..2b69ff759 100644 --- a/src/palace/manager/sqlalchemy/model/devicetokens.py +++ b/src/palace/manager/sqlalchemy/model/devicetokens.py @@ -2,7 +2,7 @@ from sqlalchemy import Column, Enum, ForeignKey, Index, Integer, Unicode from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Mapped, backref, relationship +from sqlalchemy.orm import Mapped, relationship from palace.manager.core.exceptions import BasePalaceException from palace.manager.sqlalchemy.model.base import Base @@ -32,9 +32,7 @@ class DeviceToken(Base): index=True, nullable=False, ) - patron: Mapped[Patron] = relationship( - "Patron", backref=backref("device_tokens", passive_deletes=True) - ) + patron: Mapped[Patron] = relationship("Patron", back_populates="device_tokens") token_type_enum = Enum( DeviceTokenTypes.FCM_ANDROID, DeviceTokenTypes.FCM_IOS, name="token_types" diff --git a/src/palace/manager/sqlalchemy/model/edition.py b/src/palace/manager/sqlalchemy/model/edition.py index 33893241e..3d548e4b6 100644 --- a/src/palace/manager/sqlalchemy/model/edition.py +++ b/src/palace/manager/sqlalchemy/model/edition.py @@ -41,6 +41,7 @@ if TYPE_CHECKING: from palace.manager.sqlalchemy.model.customlist import CustomListEntry + from palace.manager.sqlalchemy.model.resource import Resource from palace.manager.sqlalchemy.model.work import Work @@ -85,17 +86,19 @@ class Edition(Base, EditionConstants): # it. Through the Equivalency class, it is associated with a # (probably huge) number of other identifiers. primary_identifier_id = Column(Integer, ForeignKey("identifiers.id"), index=True) - primary_identifier: Identifier # for typing + primary_identifier: Mapped[Identifier] = relationship( + "Identifier", back_populates="primarily_identifies" + ) # An Edition may be the presentation edition for a single Work. If it's not # a presentation edition for a work, work will be None. work: Mapped[Work] = relationship( - "Work", uselist=False, backref="presentation_edition" + "Work", uselist=False, back_populates="presentation_edition" ) # An Edition may show up in many CustomListEntries. custom_list_entries: Mapped[list[CustomListEntry]] = relationship( - "CustomListEntry", backref="edition" + "CustomListEntry", back_populates="edition" ) # An Edition may be the presentation edition for many LicensePools. @@ -133,9 +136,7 @@ class Edition(Base, EditionConstants): # A Project Gutenberg text was likely `published` long before being `issued`. published = Column(Date) - MEDIUM_ENUM = Enum(*EditionConstants.KNOWN_MEDIA, name="medium") - - medium = Column(MEDIUM_ENUM, index=True) + medium = Column(Enum(*EditionConstants.KNOWN_MEDIA, name="medium"), index=True) # The playtime duration of an audiobook (seconds) # https://github.com/readium/webpub-manifest/tree/master/contexts/default#duration-and-number-of-pages @@ -146,6 +147,8 @@ class Edition(Base, EditionConstants): ForeignKey("resources.id", use_alter=True, name="fk_editions_summary_id"), index=True, ) + cover: Mapped[Resource] = relationship("Resource", back_populates="cover_editions") + # These two let us avoid actually loading up the cover Resource # every time. cover_full_url = Column(Unicode) diff --git a/src/palace/manager/sqlalchemy/model/identifier.py b/src/palace/manager/sqlalchemy/model/identifier.py index 68eba02c1..1d934c8b3 100644 --- a/src/palace/manager/sqlalchemy/model/identifier.py +++ b/src/palace/manager/sqlalchemy/model/identifier.py @@ -46,6 +46,7 @@ from palace.manager.util.summary import SummaryEvaluator if TYPE_CHECKING: + from palace.manager.sqlalchemy.model.collection import Collection from palace.manager.sqlalchemy.model.edition import Edition from palace.manager.sqlalchemy.model.patron import Annotation from palace.manager.sqlalchemy.model.resource import Hyperlink @@ -272,7 +273,7 @@ def __repr__(self): # One Identifier may serve as the primary identifier for # several Editions. primarily_identifies: Mapped[list[Edition]] = relationship( - "Edition", backref="primary_identifier" + "Edition", back_populates="primary_identifier" ) # One Identifier may serve as the identifier for many @@ -286,29 +287,34 @@ def __repr__(self): # One Identifier may have many Links. links: Mapped[list[Hyperlink]] = relationship( - "Hyperlink", backref="identifier", uselist=True + "Hyperlink", back_populates="identifier", uselist=True ) # One Identifier may be the subject of many Measurements. measurements: Mapped[list[Measurement]] = relationship( - "Measurement", backref="identifier" + "Measurement", back_populates="identifier" ) # One Identifier may participate in many Classifications. classifications: Mapped[list[Classification]] = relationship( - "Classification", backref="identifier" + "Classification", back_populates="identifier" ) # One identifier may participate in many Annotations. annotations: Mapped[list[Annotation]] = relationship( - "Annotation", backref="identifier" + "Annotation", back_populates="identifier" ) # One Identifier can have many LicensePoolDeliveryMechanisms. delivery_mechanisms: Mapped[list[LicensePoolDeliveryMechanism]] = relationship( "LicensePoolDeliveryMechanism", - backref="identifier", - foreign_keys=lambda: [LicensePoolDeliveryMechanism.identifier_id], + back_populates="identifier", + ) + + collections: Mapped[list[Collection]] = relationship( + "Collection", + secondary="collections_identifiers", + back_populates="catalog", ) # Type + identifier is unique. diff --git a/src/palace/manager/sqlalchemy/model/lane.py b/src/palace/manager/sqlalchemy/model/lane.py index 6bcd34a65..48934352c 100644 --- a/src/palace/manager/sqlalchemy/model/lane.py +++ b/src/palace/manager/sqlalchemy/model/lane.py @@ -27,7 +27,6 @@ from sqlalchemy.orm import ( Mapped, aliased, - backref, contains_eager, joinedload, query, @@ -2562,7 +2561,9 @@ class LaneGenre(Base): __tablename__ = "lanes_genres" id = Column(Integer, primary_key=True) lane_id = Column(Integer, ForeignKey("lanes.id"), index=True, nullable=False) + lane: Mapped[Lane] = relationship("Lane", back_populates="lane_genres") genre_id = Column(Integer, ForeignKey("genres.id"), index=True, nullable=False) + genre: Mapped[Genre] = relationship(Genre, back_populates="lane_genres") # An inclusive relationship means that books classified under the # genre are included in the lane. An exclusive relationship means @@ -2605,6 +2606,10 @@ class Lane(Base, DatabaseBackedWorkList, HierarchyWorkList): library: Mapped[Library] = relationship(Library, back_populates="lanes") parent_id = Column(Integer, ForeignKey("lanes.id"), index=True, nullable=True) + parent: Mapped[Lane] = relationship( + "Lane", back_populates="sublanes", remote_side=[id] + ) + priority = Column(Integer, index=True, nullable=False, default=0) # How many titles are in this lane? This is periodically @@ -2616,18 +2621,14 @@ class Lane(Base, DatabaseBackedWorkList, HierarchyWorkList): size_by_entrypoint = Column(JSON, nullable=True) # A lane may have one parent lane and many sublanes. - sublanes: Mapped[list[Lane]] = relationship( - "Lane", - backref=backref("parent", remote_side=[id]), - ) + sublanes: Mapped[list[Lane]] = relationship("Lane", back_populates="parent") # A lane may have multiple associated LaneGenres. For most lanes, # this is how the contents of the lanes are defined. genres = association_proxy("lane_genres", "genre", creator=LaneGenre.from_genre) lane_genres: Mapped[list[LaneGenre]] = relationship( "LaneGenre", - foreign_keys="LaneGenre.lane_id", - backref="lane", + back_populates="lane", cascade="all, delete-orphan", ) @@ -2684,7 +2685,7 @@ class Lane(Base, DatabaseBackedWorkList, HierarchyWorkList): # Only the books on these specific CustomLists will be shown. customlists: Mapped[list[CustomList]] = relationship( - "CustomList", secondary=lambda: lanes_customlists, backref="lane" # type: ignore + "CustomList", secondary="lanes_customlists", back_populates="lanes" ) # This has no effect unless list_datasource_id or diff --git a/src/palace/manager/sqlalchemy/model/library.py b/src/palace/manager/sqlalchemy/model/library.py index d4b8b1c0e..69fc9c983 100644 --- a/src/palace/manager/sqlalchemy/model/library.py +++ b/src/palace/manager/sqlalchemy/model/library.py @@ -107,7 +107,7 @@ class Library(Base, HasSessionCache): # A Library may have many CustomLists. custom_lists: Mapped[list[CustomList]] = relationship( - "CustomList", backref="library", uselist=True + "CustomList", back_populates="library", uselist=True ) # Lists shared with this library @@ -124,7 +124,7 @@ class Library(Base, HasSessionCache): # A Library may have many CirculationEvents circulation_events: Mapped[list[CirculationEvent]] = relationship( - "CirculationEvent", backref="library", cascade="all, delete-orphan" + "CirculationEvent", back_populates="library", cascade="all, delete-orphan" ) library_announcements: Mapped[list[Announcement]] = relationship( diff --git a/src/palace/manager/sqlalchemy/model/licensing.py b/src/palace/manager/sqlalchemy/model/licensing.py index 2105e3e17..382c4b3d1 100644 --- a/src/palace/manager/sqlalchemy/model/licensing.py +++ b/src/palace/manager/sqlalchemy/model/licensing.py @@ -261,7 +261,7 @@ class LicensePool(Base): # One LicensePool can have many CirculationEvents circulation_events: Mapped[list[CirculationEvent]] = relationship( - "CirculationEvent", backref="license_pool", cascade="all, delete-orphan" + "CirculationEvent", back_populates="license_pool", cascade="all, delete-orphan" ) # The date this LicensePool was first created in our db @@ -1472,10 +1472,16 @@ class LicensePoolDeliveryMechanism(Base): data_source_id = Column( Integer, ForeignKey("datasources.id"), index=True, nullable=False ) + data_source: Mapped[DataSource] = relationship( + "DataSource", back_populates="delivery_mechanisms" + ) identifier_id = Column( Integer, ForeignKey("identifiers.id"), index=True, nullable=False ) + identifier: Mapped[Identifier] = relationship( + "Identifier", back_populates="delivery_mechanisms" + ) delivery_mechanism_id = Column( Integer, ForeignKey("deliverymechanisms.id"), index=True, nullable=False @@ -1495,6 +1501,9 @@ class LicensePoolDeliveryMechanism(Base): # One LicensePoolDeliveryMechanism may be associated with one RightsStatus. rightsstatus_id = Column(Integer, ForeignKey("rightsstatus.id"), index=True) + rights_status: Mapped[RightsStatus] = relationship( + "RightsStatus", back_populates="licensepooldeliverymechanisms" + ) @classmethod def set( @@ -2002,12 +2011,12 @@ class RightsStatus(Base): # One RightsStatus may apply to many LicensePoolDeliveryMechanisms. licensepooldeliverymechanisms: Mapped[list[LicensePoolDeliveryMechanism]] = ( - relationship("LicensePoolDeliveryMechanism", backref="rights_status") + relationship("LicensePoolDeliveryMechanism", back_populates="rights_status") ) # One RightsStatus may apply to many Resources. resources: Mapped[list[Resource]] = relationship( - "Resource", backref="rights_status" + "Resource", back_populates="rights_status" ) @classmethod diff --git a/src/palace/manager/sqlalchemy/model/measurement.py b/src/palace/manager/sqlalchemy/model/measurement.py index 60b600eaf..ca3a4fa1c 100644 --- a/src/palace/manager/sqlalchemy/model/measurement.py +++ b/src/palace/manager/sqlalchemy/model/measurement.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from palace.manager.sqlalchemy.model.datasource import DataSource + from palace.manager.sqlalchemy.model.identifier import Identifier class Measurement(Base): @@ -713,6 +714,9 @@ class Measurement(Base): # A Measurement is always associated with some Identifier. identifier_id = Column(Integer, ForeignKey("identifiers.id"), index=True) + identifier: Mapped[Identifier] = relationship( + "Identifier", back_populates="measurements" + ) # A Measurement always comes from some DataSource. data_source_id = Column(Integer, ForeignKey("datasources.id"), index=True) diff --git a/src/palace/manager/sqlalchemy/model/patron.py b/src/palace/manager/sqlalchemy/model/patron.py index 118a7b782..e8b3a3fe4 100644 --- a/src/palace/manager/sqlalchemy/model/patron.py +++ b/src/palace/manager/sqlalchemy/model/patron.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: from palace.manager.sqlalchemy.model.devicetokens import DeviceToken + from palace.manager.sqlalchemy.model.identifier import Identifier from palace.manager.sqlalchemy.model.lane import Lane from palace.manager.sqlalchemy.model.library import Library from palace.manager.sqlalchemy.model.licensing import ( @@ -183,7 +184,9 @@ class Patron(Base, RedisKeyMixin): "Credential", back_populates="patron", cascade="delete" ) - device_tokens: list[DeviceToken] + device_tokens: Mapped[list[DeviceToken]] = relationship( + "DeviceToken", back_populates="patron", passive_deletes=True + ) __table_args__ = ( UniqueConstraint("library_id", "username"), @@ -712,6 +715,9 @@ class Annotation(Base): patron: Mapped[Patron] = relationship("Patron", back_populates="annotations") identifier_id = Column(Integer, ForeignKey("identifiers.id"), index=True) + identifier: Mapped[Identifier] = relationship( + "Identifier", back_populates="annotations" + ) motivation = Column(Unicode, index=True) timestamp = Column(DateTime(timezone=True), index=True) active = Column(Boolean, default=True) diff --git a/src/palace/manager/sqlalchemy/model/resource.py b/src/palace/manager/sqlalchemy/model/resource.py index 2e113ada3..1c6309966 100644 --- a/src/palace/manager/sqlalchemy/model/resource.py +++ b/src/palace/manager/sqlalchemy/model/resource.py @@ -11,6 +11,7 @@ from collections.abc import Mapping from hashlib import md5 from io import BytesIO +from typing import TYPE_CHECKING from urllib.parse import quote, urlparse, urlsplit import requests @@ -27,7 +28,7 @@ ) from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.ext.mutable import MutableDict -from sqlalchemy.orm import Mapped, backref, relationship +from sqlalchemy.orm import Mapped, relationship from sqlalchemy.orm.session import Session from palace.manager.sqlalchemy.constants import ( @@ -38,11 +39,18 @@ ) from palace.manager.sqlalchemy.model.base import Base from palace.manager.sqlalchemy.model.edition import Edition -from palace.manager.sqlalchemy.model.licensing import LicensePoolDeliveryMechanism +from palace.manager.sqlalchemy.model.licensing import ( + LicensePoolDeliveryMechanism, + RightsStatus, +) from palace.manager.sqlalchemy.util import get_one, get_one_or_create from palace.manager.util.datetime_helpers import utc_now from palace.manager.util.http import HTTP +if TYPE_CHECKING: + from palace.manager.sqlalchemy.model.datasource import DataSource + from palace.manager.sqlalchemy.model.identifier import Identifier + class Resource(Base): """An external resource that may be mirrored locally. @@ -68,7 +76,7 @@ class Resource(Base): # Many Editions may choose this resource (as opposed to other # resources linked to them with rel="image") as their cover image. cover_editions: Mapped[list[Edition]] = relationship( - "Edition", backref="cover", foreign_keys=[Edition.cover_id] + "Edition", back_populates="cover", foreign_keys=[Edition.cover_id] ) # Many Works may use this resource (as opposed to other resources @@ -76,7 +84,7 @@ class Resource(Base): from palace.manager.sqlalchemy.model.work import Work summary_works: Mapped[list[Work]] = relationship( - "Work", backref="summary", foreign_keys=[Work.summary_id] + "Work", back_populates="summary", foreign_keys=[Work.summary_id] ) # Many LicensePools (but probably one at most) may use this @@ -89,16 +97,27 @@ class Resource(Base): ) ) - links: Mapped[list[Hyperlink]] = relationship("Hyperlink", backref="resource") + links: Mapped[list[Hyperlink]] = relationship( + "Hyperlink", back_populates="resource" + ) # The DataSource that is the controlling authority for this Resource. data_source_id = Column(Integer, ForeignKey("datasources.id"), index=True) + data_source: Mapped[DataSource] = relationship( + "DataSource", back_populates="resources" + ) # An archived Representation of this Resource. representation_id = Column(Integer, ForeignKey("representations.id"), index=True) + representation: Mapped[Representation] = relationship( + "Representation", back_populates="resource" + ) # The rights status of this Resource. rights_status_id = Column(Integer, ForeignKey("rightsstatus.id")) + rights_status: Mapped[RightsStatus] = relationship( + "RightsStatus", back_populates="resources" + ) # An optional explanation of the rights status. rights_explanation = Column(Unicode) @@ -108,7 +127,7 @@ class Resource(Base): "ResourceTransformation", foreign_keys="ResourceTransformation.original_id", lazy="joined", - backref=backref("original", uselist=False), + back_populates="original", uselist=True, ) @@ -116,7 +135,7 @@ class Resource(Base): derived_through: Mapped[ResourceTransformation] = relationship( "ResourceTransformation", foreign_keys="ResourceTransformation.derivative_id", - backref=backref("derivative", uselist=False), + back_populates="derivative", lazy="joined", uselist=False, ) @@ -372,9 +391,15 @@ class ResourceTransformation(Base): derivative_id = Column( Integer, ForeignKey("resources.id"), index=True, primary_key=True ) + derivative: Mapped[Resource] = relationship( + "Resource", back_populates="derived_through", foreign_keys=[derivative_id] + ) # The original resource that was transformed into the derivative. original_id = Column(Integer, ForeignKey("resources.id"), index=True) + original: Mapped[Resource] = relationship( + "Resource", back_populates="transformations", foreign_keys=[original_id] + ) # The settings used for the transformation. settings: Mapped[dict[str, str]] = Column(MutableDict.as_mutable(JSON), default={}) @@ -391,11 +416,13 @@ class Hyperlink(Base, LinkRelations): identifier_id = Column( Integer, ForeignKey("identifiers.id"), index=True, nullable=False ) + identifier: Mapped[Identifier] = relationship("Identifier", back_populates="links") # The DataSource through which this link was discovered. data_source_id = Column( Integer, ForeignKey("datasources.id"), index=True, nullable=False ) + data_source: Mapped[DataSource] = relationship("DataSource", back_populates="links") # The link relation between the Identifier and the Resource. rel = Column(Unicode, index=True, nullable=False) @@ -404,7 +431,7 @@ class Hyperlink(Base, LinkRelations): resource_id = Column( Integer, ForeignKey("resources.id"), index=True, nullable=False ) - resource: Resource + resource: Mapped[Resource] = relationship("Resource", back_populates="links") @classmethod def generic_uri(cls, data_source, identifier, rel, content=None): @@ -462,7 +489,7 @@ class Representation(Base, MediaTypes): media_type = Column(Unicode) resource: Mapped[Resource] = relationship( - "Resource", backref="representation", uselist=False + "Resource", back_populates="representation", uselist=False ) ### Records of things we tried to do with this representation. @@ -499,10 +526,16 @@ class Representation(Base, MediaTypes): # An image Representation may be a thumbnail version of another # Representation. thumbnail_of_id = Column(Integer, ForeignKey("representations.id"), index=True) + thumbnail_of: Mapped[Representation] = relationship( + "Representation", + remote_side=[id], + back_populates="thumbnails", + post_update=True, + ) thumbnails: Mapped[list[Representation]] = relationship( "Representation", - backref=backref("thumbnail_of", remote_side=[id]), + back_populates="thumbnail_of", lazy="joined", post_update=True, ) diff --git a/src/palace/manager/sqlalchemy/model/work.py b/src/palace/manager/sqlalchemy/model/work.py index 38bae8304..943dbeb2d 100644 --- a/src/palace/manager/sqlalchemy/model/work.py +++ b/src/palace/manager/sqlalchemy/model/work.py @@ -74,6 +74,7 @@ from palace.manager.sqlalchemy.model.customlist import CustomListEntry from palace.manager.sqlalchemy.model.library import Library from palace.manager.sqlalchemy.model.licensing import LicensePool + from palace.manager.sqlalchemy.model.resource import Resource class WorkGenre(Base): @@ -85,6 +86,7 @@ class WorkGenre(Base): genre: Mapped[Genre] = relationship("Genre", back_populates="work_genres") work_id = Column(Integer, ForeignKey("works.id"), index=True) + work: Mapped[Work] = relationship("Work", back_populates="work_genres") affinity = Column(Float, index=True, default=0) @classmethod @@ -146,6 +148,9 @@ class Work(Base, LoggerMixin): # A Work takes its presentation metadata from a single Edition. # But this Edition is a composite of provider, admin interface, etc.-derived Editions. presentation_edition_id = Column(Integer, ForeignKey("editions.id"), index=True) + presentation_edition: Mapped[Edition] = relationship( + "Edition", back_populates="work" + ) # One Work may have many associated WorkCoverageRecords. coverage_records: Mapped[list[WorkCoverageRecord]] = relationship( @@ -156,13 +161,13 @@ class Work(Base, LoggerMixin): # However, a CustomListEntry may lose its Work without # ceasing to exist. custom_list_entries: Mapped[list[CustomListEntry]] = relationship( - "CustomListEntry", backref="work" + "CustomListEntry", back_populates="work" ) # One Work may participate in many WorkGenre assignments. genres = association_proxy("work_genres", "genre", creator=WorkGenre.from_genre) work_genres: Mapped[list[WorkGenre]] = relationship( - "WorkGenre", backref="work", cascade="all, delete-orphan" + "WorkGenre", back_populates="work", cascade="all, delete-orphan" ) audience = Column(Unicode, index=True) target_age = Column(INT4RANGE, index=True) @@ -173,6 +178,7 @@ class Work(Base, LoggerMixin): ForeignKey("resources.id", use_alter=True, name="fk_works_summary_id"), index=True, ) + summary: Mapped[Resource] = relationship("Resource", back_populates="summary_works") # This gives us a convenient place to store a cleaned-up version of # the content of the summary Resource. summary_text = Column(Unicode) @@ -1533,8 +1539,8 @@ def to_search_documents( # Create JSON results = [] for item in rows: - item.identifiers = list(filter(lambda idx: idx[0] == item.id, identifiers)) # type: ignore - item.classifications = list( # type: ignore + item.identifiers = list(filter(lambda idx: idx[0] == item.id, identifiers)) # type: ignore[attr-defined] + item.classifications = list( # type: ignore[attr-defined] filter(lambda idx: idx[0] == item.id, all_subjects) ) @@ -1648,28 +1654,25 @@ def _set_value(parent, key, target): result["contributors"] = [] if doc.presentation_edition and doc.presentation_edition.contributions: - for item in doc.presentation_edition.contributions: + for c in doc.presentation_edition.contributions: contributor: dict = {} - _set_value(item.contributor, "contributor", contributor) - _set_value(item, "contribution", contributor) + _set_value(c.contributor, "contributor", contributor) + _set_value(c, "contribution", contributor) result["contributors"].append(contributor) result["licensepools"] = [] if doc.license_pools: - for item in doc.license_pools: - if not ( - item.open_access or item.unlimited_access or item.licenses_owned > 0 - ): + for lp in doc.license_pools: + if not (lp.open_access or lp.unlimited_access or lp.licenses_owned > 0): continue lc: dict = {} - _set_value(item, "licensepools", lc) - # lc["availability_time"] = getattr(item, "availability_time").timestamp() - lc["available"] = item.unlimited_access or item.licenses_available > 0 - lc["licensed"] = item.unlimited_access or item.licenses_owned > 0 + _set_value(lp, "licensepools", lc) + lc["available"] = lp.unlimited_access or lp.licenses_available > 0 + lc["licensed"] = lp.unlimited_access or lp.licenses_owned > 0 if doc.presentation_edition: lc["medium"] = doc.presentation_edition.medium - lc["licensepool_id"] = item.id + lc["licensepool_id"] = lp.id lc["quality"] = doc.quality result["licensepools"].append(lc) @@ -1686,24 +1689,24 @@ def _set_value(parent, key, target): result["genres"].append(genre) result["identifiers"] = [] - if doc.identifiers: # type: ignore - for item in doc.identifiers: # type: ignore + if doc.identifiers: # type: ignore[attr-defined] + for i in doc.identifiers: # type: ignore[attr-defined] identifier: dict = {} - _set_value(item, "identifiers", identifier) + _set_value(i, "identifiers", identifier) result["identifiers"].append(identifier) result["classifications"] = [] - if doc.classifications: # type: ignore - for item in doc.classifications: # type: ignore + if doc.classifications: # type: ignore[attr-defined] + for c in doc.classifications: # type: ignore[attr-defined] classification: dict = {} - _set_value(item, "classifications", classification) + _set_value(c, "classifications", classification) result["classifications"].append(classification) result["customlists"] = [] if doc.custom_list_entries: - for item in doc.custom_list_entries: + for cl in doc.custom_list_entries: customlist: dict = {} - _set_value(item, "custom_list_entries", customlist) + _set_value(cl, "custom_list_entries", customlist) result["customlists"].append(customlist) # No empty lists, they should be null diff --git a/tests/manager/api/controller/test_work.py b/tests/manager/api/controller/test_work.py index b4ee11410..d15b357b5 100644 --- a/tests/manager/api/controller/test_work.py +++ b/tests/manager/api/controller/test_work.py @@ -79,12 +79,11 @@ def test_contributor(self, work_fixture: WorkFixture): # Find a real Contributor put in the system through the setup # process. [contribution] = work_fixture.english_1.presentation_edition.contributions - contributor = contribution.contributor # The contributor is created with both .sort_name and # .display_name, but we want to test what happens when both # pieces of data aren't avaiable, so unset .sort_name. - contributor.sort_name = None + contribution.contributor.sort_name = None # No contributor name -> ProblemDetail with work_fixture.request_context_with_library("/"): @@ -101,7 +100,7 @@ def test_contributor(self, work_fixture: WorkFixture): assert NO_SUCH_LANE.uri == response.uri assert "Unknown contributor: Unknown Author" == response.detail - contributor = contributor.display_name + contributor = contribution.contributor.display_name # Bad facet data -> ProblemDetail with work_fixture.request_context_with_library("/?order=nosuchorder"):