Skip to content

Commit

Permalink
Adds collection subscriptions. (PP-1875) (#2287)
Browse files Browse the repository at this point in the history
* Distinguish between associated and active libraries/collections.

* CustomLists for active collections only.

* Remove unused function.
  • Loading branch information
tdilauro authored Feb 26, 2025
1 parent 928e7c6 commit b978251
Show file tree
Hide file tree
Showing 32 changed files with 894 additions and 226 deletions.
2 changes: 1 addition & 1 deletion src/palace/manager/api/admin/controller/admin_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def search_field_values(self) -> dict:
- Subject
"""
library = get_request_library()
collection_ids = [coll.id for coll in library.associated_collections if coll.id]
collection_ids = [coll.id for coll in library.active_collections if coll.id]
return self._search_field_values_cached(collection_ids)

@classmethod
Expand Down
6 changes: 3 additions & 3 deletions src/palace/manager/api/admin/controller/custom_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
ADMIN_NOT_AUTHORIZED,
AUTO_UPDATE_CUSTOM_LIST_CANNOT_HAVE_ENTRIES,
CANNOT_CHANGE_LIBRARY_FOR_CUSTOM_LIST,
COLLECTION_NOT_ASSOCIATED_WITH_LIBRARY,
COLLECTION_NOT_ACTIVE_FOR_LIST_LIBRARY,
CUSTOM_LIST_NAME_ALREADY_IN_USE,
CUSTOMLIST_CANNOT_DELETE_SHARE,
MISSING_COLLECTION,
Expand Down Expand Up @@ -286,9 +286,9 @@ def _create_or_update_list(
if not collection:
self._db.rollback()
return MISSING_COLLECTION
if list.library not in collection.associated_libraries:
if list.library not in collection.active_libraries:
self._db.rollback()
return COLLECTION_NOT_ASSOCIATED_WITH_LIBRARY
return COLLECTION_NOT_ACTIVE_FOR_LIST_LIBRARY
new_collections.append(collection)
list.collections = new_collections

Expand Down
6 changes: 3 additions & 3 deletions src/palace/manager/api/admin/problem_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,12 +420,12 @@
detail=_("Entries are automatically managed for auto update custom lists"),
)

COLLECTION_NOT_ASSOCIATED_WITH_LIBRARY = pd(
COLLECTION_NOT_ACTIVE_FOR_LIST_LIBRARY = pd(
"http://librarysimplified.org/terms/problem/collection-not-associated-with-library",
status_code=400,
title=_("Collection not associated with library"),
title=_("Collection not active for library"),
detail=_(
"You can't add a collection to a list unless it is associated with the list's library."
"You can't add a collection to a list unless it is active for the list's library."
),
)

Expand Down
6 changes: 3 additions & 3 deletions src/palace/manager/api/controller/marc.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,13 @@ def download_page_body(self, session: Session, library: Library) -> str:
marc_files = self.get_files(session, library)

if len(marc_files) == 0:
# Are there any collections configured to export MARC records?
if any(c.export_marc_records for c in library.associated_collections):
# Are there any active collections configured to export MARC records?
if any(c.export_marc_records for c in library.active_collections):
return "<p>" + "MARC files aren't ready to download yet." + "</p>"
else:
return (
"<p>"
+ "No collections are configured to export MARC records."
+ "No active collections are configured to export MARC records."
+ "</p>"
)

Expand Down
17 changes: 9 additions & 8 deletions src/palace/manager/api/lanes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
fiction_genres,
nonfiction_genres,
)
from palace.manager.sqlalchemy.model.collection import Collection
from palace.manager.sqlalchemy.model.contributor import Contributor
from palace.manager.sqlalchemy.model.datasource import DataSource
from palace.manager.sqlalchemy.model.edition import Edition
Expand Down Expand Up @@ -1389,15 +1390,15 @@ class CrawlableCollectionBasedLane(CrawlableLane):
LIBRARY_ROUTE = "crawlable_library_feed"
COLLECTION_ROUTE = "crawlable_collection_feed"

def initialize(self, library_or_collections):
def initialize(self, library_or_collections: Library | list[Collection]): # type: ignore[override]
self.collection_feed = False

if isinstance(library_or_collections, Library):
# We're looking at all the collections in a given library.
# We're looking at only the active collections for the given library.
library = library_or_collections
collections = library.associated_collections
collections = library.active_collections
identifier = library.name
else:
elif isinstance(library_or_collections, list):
# We're looking at collections directly, without respect
# to the libraries that might use them.
library = None
Expand Down Expand Up @@ -1528,10 +1529,10 @@ def __init__(self, library, facets):
# a client might need to run.
self.children = []

# Add one or more WorkLists for every collection in the
# system, so that a client can test borrowing a book from
# every collection.
for collection in sorted(library.associated_collections, key=lambda x: x.name):
# Add one or more WorkLists for every active collection for the
# library, so that a client can test borrowing a book from
# any of them.
for collection in sorted(library.active_collections, key=lambda x: x.name):
for medium in Edition.FULFILLABLE_MEDIA:
# Give each Worklist a name that is distinctive
# and easy for a client to parse.
Expand Down
4 changes: 1 addition & 3 deletions src/palace/manager/api/metadata/novelist.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,9 +550,7 @@ def get_items_from_query(self, library: Library) -> list[dict[str, str]]:
:return: a list of Novelist objects to send
"""
collectionList = []
for c in library.associated_collections:
collectionList.append(c.id)
collectionList = [c.id for c in library.active_collections]

LEFT_OUTER_JOIN = True
i1 = aliased(Identifier)
Expand Down
7 changes: 4 additions & 3 deletions src/palace/manager/core/query/customlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ def share_locally_with_library(
f"Attempting to share customlist '{customlist.name}' with library '{library.name}'."
)
for collection in customlist.collections:
if collection not in library.associated_collections:
if collection not in library.active_collections:
log.info(
f"Unable to share customlist: Collection '{collection.name}' is missing from the library."
f"Unable to share customlist: Collection '{collection.name}'"
" is missing from or inactive for the library."
)
return CUSTOMLIST_SOURCE_COLLECTION_MISSING

# All entries must be valid for the library
library_collection_ids = [c.id for c in library.associated_collections]
library_collection_ids = [c.id for c in library.active_collections]
entry: CustomListEntry
missing_work_id_count = 0
for entry in customlist.entries:
Expand Down
11 changes: 7 additions & 4 deletions src/palace/manager/scripts/informational.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,11 +474,14 @@ def check_library(self, library):
self.out("Checking library %s", library.name)

# Make sure it has collections.
if not library.associated_collections:
self.out(" This library has no collections -- that's a problem.")
if not (associated_collections := set(library.associated_collections)):
self.out(" This library has no associated collections -- that's a problem.")
elif not (active_collections := set(library.active_collections)):
self.out(" This library has no active collections -- that's a problem.")
else:
for collection in library.associated_collections:
self.out(" Associated with collection %s.", collection.name)
for collection in associated_collections:
active = collection in active_collections
self.out(f" Associated with collection {collection.name} ({active=}).")

# Make sure it has lanes.
if not library.lanes:
Expand Down
4 changes: 2 additions & 2 deletions src/palace/manager/search/external_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -1577,8 +1577,8 @@ def __init__(
"""

if isinstance(collections, Library):
# Find all works in this Library's collections.
collections = collections.associated_collections
# Find all works in this Library's active collections.
collections = collections.active_collections
self.collection_ids = self._filter_ids(collections)

self.media = media
Expand Down
4 changes: 3 additions & 1 deletion src/palace/manager/service/redis/models/patron_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,9 @@ def collections_ready_for_sync(
patron activity sync. This indicates that the collection is ready to be
synced.
"""
collections = patron.library.associated_collections
# TODO: What should happen to loans that are in a collection that is not active?
# For now, we'll handle loans only for active collections.
collections = patron.library.active_collections
keys = [
cls._get_key(redis_client, patron.id, collection.id)
for collection in collections
Expand Down
101 changes: 99 additions & 2 deletions src/palace/manager/sqlalchemy/model/collection.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
from __future__ import annotations

import datetime
from typing import TYPE_CHECKING, Any, TypeVar

from dependency_injector.wiring import Provide, inject
from sqlalchemy import (
Boolean,
Column,
Date,
ForeignKey,
Integer,
Table,
UniqueConstraint,
exists,
not_,
select,
)
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, Query, mapper, relationship
from sqlalchemy.orm import Mapped, Query, aliased, mapper, relationship
from sqlalchemy.orm.session import Session
from sqlalchemy.sql import Select
from sqlalchemy.sql.expression import and_, or_
from sqlalchemy.sql.functions import count

from palace.manager.core.exceptions import BasePalaceException
from palace.manager.integration.goals import Goals
Expand All @@ -28,7 +33,10 @@
from palace.manager.sqlalchemy.model.coverage import CoverageRecord, Timestamp
from palace.manager.sqlalchemy.model.datasource import DataSource
from palace.manager.sqlalchemy.model.identifier import Identifier
from palace.manager.sqlalchemy.model.integration import IntegrationConfiguration
from palace.manager.sqlalchemy.model.integration import (
IntegrationConfiguration,
IntegrationLibraryConfiguration,
)
from palace.manager.sqlalchemy.model.library import Library
from palace.manager.sqlalchemy.model.licensing import (
LicensePool,
Expand Down Expand Up @@ -270,6 +278,95 @@ def by_protocol(

return qu

@property
def is_active(self) -> bool:
"""Return True if the collection is active, False otherwise."""
active_query = self.active_collections_filter(sa_select=select(count())).where(
Collection.id == self.id
)
_db = Session.object_session(self)
count_ = _db.execute(active_query).scalar()
return False if count_ is None else count_ > 0

@classmethod
def active_collections_filter(
cls, *, sa_select: Select | None = None, today: datetime.date | None = None
) -> Select:
"""Filter to select from only collections that are considered active.
A collection is considered active if it either:
- has no activation/expiration settings; or
- meets the criteria specified by the activation/expiration settings.
:param sa_select: A SQLAlchemy Select object. Defaults to an empty Select.
:param today: The date to use as the current date. Defaults to today.
:return: A filtered SQLAlchemy Select object.
"""
sa_select = sa_select if sa_select is not None else select()
if today is None:
today = datetime.date.today()
return cls._filter_active_collections(
sa_select=(sa_select.select_from(Collection).join(IntegrationConfiguration))
)

@staticmethod
def _filter_active_collections(
*, sa_select: Select, today: datetime.date | None = None
) -> Select:
"""Constrain to only active collections.
A collection is considered active if it either:
- has no activation/expiration settings; or
- meets the criteria specified by the activation/expiration settings.
:param sa_select: A SQLAlchemy Select object.
:param today: The date to use as the current date. Defaults to today.
:return: A filtered SQLAlchemy Select object.
"""
if today is None:
today = datetime.date.today()
return sa_select.where(
or_(
not_(
IntegrationConfiguration.settings_dict.has_key(
"subscription_activation_date"
)
),
IntegrationConfiguration.settings_dict[
"subscription_activation_date"
].astext.cast(Date)
<= today,
),
or_(
not_(
IntegrationConfiguration.settings_dict.has_key(
"subscription_expiration_date"
)
),
IntegrationConfiguration.settings_dict[
"subscription_expiration_date"
].astext.cast(Date)
>= today,
),
)

@property
def active_libraries(self) -> list[Library]:
"""Return a list of libraries that are active for this collection.
Active means either that there is no subscription activation/expiration
criteria set, or that the criteria specified are satisfied.
"""
library = aliased(Library, name="library")
query = (
self.active_collections_filter(sa_select=select(library))
.join(IntegrationLibraryConfiguration)
.join(library)
.where(Collection.id == self.id)
)
_db = Session.object_session(self)
return [row.library for row in _db.execute(query)]

@property
def name(self) -> str:
"""What is the name of this collection?"""
Expand Down
4 changes: 2 additions & 2 deletions src/palace/manager/sqlalchemy/model/lane.py
Original file line number Diff line number Diff line change
Expand Up @@ -1438,7 +1438,7 @@ def initialize(
self.library_id = library.id
if self.collection_ids is None:
self.collection_ids = [
collection.id for collection in library.associated_collections_ids
collection.id for collection in library.active_collections
]
self.display_name = display_name
if genres:
Expand Down Expand Up @@ -2731,7 +2731,7 @@ def get_library(self, _db):

@property
def collection_ids(self):
return [x.id for x in self.library.associated_collections]
return [x.id for x in self.library.active_collections]

@property
def children(self):
Expand Down
Loading

0 comments on commit b978251

Please sign in to comment.