From 0c5328e69972c9fe82bc3940e1e2f2b8f5447021 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Tue, 26 Nov 2024 15:45:15 -0400 Subject: [PATCH] Replace sqlalchemy backref parameter with back_populates (#2194) The sqlalchemy backref parameter is considered legacy see: https://docs.sqlalchemy.org/en/14/orm/backref.html. Using this construct means that mypy (and IDE) can't infer the type of the dynamically generated parameter. --- src/palace/manager/feed/acquisition.py | 11 ++-- src/palace/manager/feed/annotator/base.py | 4 +- .../manager/feed/annotator/circulation.py | 4 +- .../manager/feed/annotator/loan_and_hold.py | 6 +- src/palace/manager/sqlalchemy/model/admin.py | 3 +- .../sqlalchemy/model/circulationevent.py | 14 ++++- .../sqlalchemy/model/classification.py | 15 +++-- .../manager/sqlalchemy/model/collection.py | 6 +- .../manager/sqlalchemy/model/coverage.py | 3 + .../manager/sqlalchemy/model/customlist.py | 26 ++++++-- .../manager/sqlalchemy/model/datasource.py | 12 ++-- .../manager/sqlalchemy/model/devicetokens.py | 6 +- .../manager/sqlalchemy/model/edition.py | 12 +++- .../manager/sqlalchemy/model/identifier.py | 18 +++--- src/palace/manager/sqlalchemy/model/lane.py | 16 +++-- .../manager/sqlalchemy/model/library.py | 4 +- .../manager/sqlalchemy/model/licensing.py | 15 ++++- .../manager/sqlalchemy/model/measurement.py | 4 ++ src/palace/manager/sqlalchemy/model/patron.py | 9 ++- .../manager/sqlalchemy/model/resource.py | 59 +++++++++++++----- src/palace/manager/sqlalchemy/model/work.py | 61 ++++++++++++------- 21 files changed, 215 insertions(+), 93 deletions(-) diff --git a/src/palace/manager/feed/acquisition.py b/src/palace/manager/feed/acquisition.py index 9997094846..9dae74cb77 100644 --- a/src/palace/manager/feed/acquisition.py +++ b/src/palace/manager/feed/acquisition.py @@ -212,7 +212,7 @@ def _create_entry( cls, work: Work, active_licensepool: LicensePool | None, - edition: Edition, + edition: Edition | None, identifier: Identifier, annotator: Annotator, ) -> WorkEntry: @@ -926,10 +926,11 @@ def single_entry(cls, work: tuple[Identifier, Work], annotator: Annotator) -> Wo if error_status: return cls.error_message(identifier, error_status, error_message or "") - if active_licensepool: - edition = active_licensepool.presentation_edition - else: - edition = _work.presentation_edition + edition = ( + active_licensepool.presentation_edition + if active_licensepool + else _work.presentation_edition + ) try: return cls._create_entry( _work, active_licensepool, edition, identifier, annotator diff --git a/src/palace/manager/feed/annotator/base.py b/src/palace/manager/feed/annotator/base.py index ff1fe07e81..8eb0ea9f2e 100644 --- a/src/palace/manager/feed/annotator/base.py +++ b/src/palace/manager/feed/annotator/base.py @@ -236,9 +236,7 @@ def content(cls, work: Work | None) -> str: and work.summary.representation and work.summary.representation.content ): - content = work.summary.representation.content - if isinstance(content, bytes): - content = content.decode("utf-8") + content = work.summary.representation.content.decode("utf-8") work.summary_text = content summary = work.summary_text return summary diff --git a/src/palace/manager/feed/annotator/circulation.py b/src/palace/manager/feed/annotator/circulation.py index dd4dbcf645..59b4123f05 100644 --- a/src/palace/manager/feed/annotator/circulation.py +++ b/src/palace/manager/feed/annotator/circulation.py @@ -365,7 +365,7 @@ def annotate_work_entry( updated: datetime.datetime | None = None, ) -> None: work = entry.work - identifier = entry.identifier or work.presentation_edition.primary_identifier + identifier = entry.identifier active_license_pool = entry.license_pool or self.active_licensepool_for(work) # If OpenSearch included a more accurate last_update_time, # use it instead of Work.last_update_time @@ -875,7 +875,7 @@ def annotate_work_entry( return work = entry.work - identifier = entry.identifier or work.presentation_edition.primary_identifier + identifier = entry.identifier permalink_uri, permalink_type = self.permalink_for(identifier) # TODO: Do not force OPDS types diff --git a/src/palace/manager/feed/annotator/loan_and_hold.py b/src/palace/manager/feed/annotator/loan_and_hold.py index 40b4c6e4c7..a4e0602eeb 100644 --- a/src/palace/manager/feed/annotator/loan_and_hold.py +++ b/src/palace/manager/feed/annotator/loan_and_hold.py @@ -97,11 +97,13 @@ def annotate_work_entry( active_license_pool = entry.license_pool work = entry.work edition = work.presentation_edition - identifier = edition.primary_identifier + identifier = edition.primary_identifier if edition else None # Only OPDS for Distributors should get the time tracking link # And only if there is an active loan for the work if ( - edition.medium == EditionConstants.AUDIO_MEDIUM + edition + and identifier + and edition.medium == EditionConstants.AUDIO_MEDIUM and active_license_pool and active_license_pool.should_track_playtime is True and work in self.active_loans_by_work diff --git a/src/palace/manager/sqlalchemy/model/admin.py b/src/palace/manager/sqlalchemy/model/admin.py index 8a30438447..9d34375651 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 b41d846eda..763409dacc 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 | None] = 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 | None] = 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 aeb132567f..d43e3c63bb 100644 --- a/src/palace/manager/sqlalchemy/model/classification.py +++ b/src/palace/manager/sqlalchemy/model/classification.py @@ -132,6 +132,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. @@ -346,11 +347,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 | None] = 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 | None] = relationship( + "DataSource", back_populates="classifications" + ) # How much weight the data source gives to this classification. weight = Column(Integer) @@ -481,7 +486,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") @@ -490,7 +495,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 c1f20596fb..b07952b9d2 100644 --- a/src/palace/manager/sqlalchemy/model/collection.py +++ b/src/palace/manager/sqlalchemy/model/collection.py @@ -129,13 +129,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. @@ -144,7 +144,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 923e5b123f..ddeeb2d1d6 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 | None] = 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 b3e388534a..f6b5adb579 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,6 +60,7 @@ 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. @@ -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: Mapped[str] = Column( + Enum(INIT, UPDATED, REPOPULATE, name="auto_update_status"), default=INIT + ) + + collections: Mapped[list[Collection]] = relationship( + "Collection", secondary="collections_customlists", back_populates="customlists" + ) - # Typing specific - collections: list[Collection] - library: Library + lane: Mapped[list[Lane]] = relationship( + "Lane", secondary="lanes_customlists", back_populates="customlists" + ) __table_args__ = ( UniqueConstraint("data_source_id", "foreign_identifier"), @@ -369,7 +377,15 @@ class CustomListEntry(Base): ) edition_id = Column(Integer, ForeignKey("editions.id"), index=True) + edition: Mapped[Edition | None] = relationship( + "Edition", back_populates="custom_list_entries" + ) + work_id = Column(Integer, ForeignKey("works.id"), index=True) + work: Mapped[Work | None] = 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 fa12285ca2..8f19bdf9aa 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 d55d2b1e55..2b69ff7598 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 33893241e5..c071af001d 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. @@ -146,6 +149,9 @@ class Edition(Base, EditionConstants): ForeignKey("resources.id", use_alter=True, name="fk_editions_summary_id"), index=True, ) + cover: Mapped[Resource | None] = relationship( + "Resource", back_populates="cover_editions", foreign_keys=[cover_id] + ) # 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 ab530ce0aa..7bcc8b9ae6 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 @@ -240,6 +241,10 @@ class Identifier(Base, IdentifierConstants): type = Column(String(64), index=True) identifier = Column(String, index=True) + collections: Mapped[list[Collection]] = relationship( + "Collection", secondary="collections_identifiers", back_populates="catalog" + ) + equivalencies: Mapped[list[Equivalency]] = relationship( "Equivalency", foreign_keys="Equivalency.input_id", @@ -272,7 +277,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 +291,28 @@ 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", ) # Type + identifier is unique. diff --git a/src/palace/manager/sqlalchemy/model/lane.py b/src/palace/manager/sqlalchemy/model/lane.py index 0b30460487..191cbf11e1 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, @@ -2601,7 +2600,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 @@ -2644,6 +2645,12 @@ 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 | None] = 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 @@ -2657,7 +2664,7 @@ class Lane(Base, DatabaseBackedWorkList, HierarchyWorkList): # A lane may have one parent lane and many sublanes. sublanes: Mapped[list[Lane]] = relationship( "Lane", - backref=backref("parent", remote_side=[id]), + back_populates="parent", ) # A lane may have multiple associated LaneGenres. For most lanes, @@ -2665,8 +2672,7 @@ class Lane(Base, DatabaseBackedWorkList, HierarchyWorkList): 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", ) @@ -2723,7 +2729,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="lane" ) # 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 e8136a9c12..8ec8341424 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 cc04b49125..f5569d7b73 100644 --- a/src/palace/manager/sqlalchemy/model/licensing.py +++ b/src/palace/manager/sqlalchemy/model/licensing.py @@ -271,7 +271,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 @@ -1508,10 +1508,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 @@ -1531,6 +1537,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 | None] = relationship( + "RightsStatus", back_populates="licensepooldeliverymechanisms" + ) @classmethod def set( @@ -2038,12 +2047,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 60b600eaf7..86b5085629 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 | None] = 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 d00244a2d7..242b7f65e6 100644 --- a/src/palace/manager/sqlalchemy/model/patron.py +++ b/src/palace/manager/sqlalchemy/model/patron.py @@ -37,6 +37,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 ( @@ -190,7 +191,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"), @@ -719,6 +722,10 @@ class Annotation(Base): patron: Mapped[Patron] = relationship("Patron", back_populates="annotations") identifier_id = Column(Integer, ForeignKey("identifiers.id"), index=True) + identifier: Mapped[Identifier | None] = 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 2e113ada33..5bc4396040 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,19 @@ ) 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 + from palace.manager.sqlalchemy.model.work import Work + class Resource(Base): """An external resource that may be mirrored locally. @@ -68,15 +77,11 @@ 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 - # linked to them with rel="description") as their summary. - 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 +94,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 | None] = 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", uselist=False + ) # The rights status of this Resource. rights_status_id = Column(Integer, ForeignKey("rightsstatus.id")) + rights_status: Mapped[RightsStatus | None] = relationship( + "RightsStatus", back_populates="resources" + ) # An optional explanation of the rights status. rights_explanation = Column(Unicode) @@ -108,7 +124,7 @@ class Resource(Base): "ResourceTransformation", foreign_keys="ResourceTransformation.original_id", lazy="joined", - backref=backref("original", uselist=False), + back_populates="original", uselist=True, ) @@ -116,7 +132,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 +388,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 +413,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 +428,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 +486,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 +523,15 @@ 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 | None] = relationship( + "Representation", + back_populates="thumbnails", + remote_side=[id], + 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 80d6af970a..d3d53b5a1f 100644 --- a/src/palace/manager/sqlalchemy/model/work.py +++ b/src/palace/manager/sqlalchemy/model/work.py @@ -75,6 +75,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): @@ -86,6 +87,8 @@ 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 @@ -147,6 +150,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 | None] = relationship( + "Edition", back_populates="work" + ) # One Work may have many associated WorkCoverageRecords. coverage_records: Mapped[list[WorkCoverageRecord]] = relationship( @@ -157,13 +163,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) @@ -174,6 +180,9 @@ class Work(Base, LoggerMixin): ForeignKey("resources.id", use_alter=True, name="fk_works_summary_id"), index=True, ) + summary: Mapped[Resource | None] = relationship( + "Resource", foreign_keys=[summary_id], 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) @@ -1649,62 +1658,68 @@ 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 contribution in doc.presentation_edition.contributions: contributor: dict = {} - _set_value(item.contributor, "contributor", contributor) - _set_value(item, "contribution", contributor) + _set_value(contribution.contributor, "contributor", contributor) + _set_value(contribution, "contribution", contributor) result["contributors"].append(contributor) result["licensepools"] = [] if doc.license_pools: - for item in doc.license_pools: + for license_pool in doc.license_pools: if not ( - item.open_access or item.unlimited_access or item.licenses_owned > 0 + license_pool.open_access + or license_pool.unlimited_access + or license_pool.licenses_owned > 0 ): continue lc: dict = {} - _set_value(item, "licensepools", lc) + _set_value(license_pool, "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 + lc["available"] = ( + license_pool.unlimited_access or license_pool.licenses_available > 0 + ) + lc["licensed"] = ( + license_pool.unlimited_access or license_pool.licenses_owned > 0 + ) if doc.presentation_edition: lc["medium"] = doc.presentation_edition.medium - lc["licensepool_id"] = item.id + lc["licensepool_id"] = license_pool.id lc["quality"] = doc.quality result["licensepools"].append(lc) # Extra special genre massaging result["genres"] = [] if doc.work_genres: - for item in doc.work_genres: + for work_genre in doc.work_genres: genre = { "scheme": Subject.SIMPLIFIED_GENRE, - "term": item.genre.id, - "name": item.genre.name, - "weight": item.affinity, + "term": work_genre.genre.id, + "name": work_genre.genre.name, + "weight": work_genre.affinity, } 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 ident in doc.identifiers: # type: ignore[attr-defined] identifier: dict = {} - _set_value(item, "identifiers", identifier) + _set_value(ident, "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 _classification in doc.classifications: # type: ignore[attr-defined] classification: dict = {} - _set_value(item, "classifications", classification) + _set_value(_classification, "classifications", classification) result["classifications"].append(classification) result["customlists"] = [] if doc.custom_list_entries: - for item in doc.custom_list_entries: + for custom_list_entry in doc.custom_list_entries: customlist: dict = {} - _set_value(item, "custom_list_entries", customlist) + _set_value(custom_list_entry, "custom_list_entries", customlist) result["customlists"].append(customlist) # No empty lists, they should be null