Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds collection subscriptions. (PP-1875) #2287

Merged
merged 15 commits into from
Feb 26, 2025
Merged
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