diff --git a/src/palace/manager/api/controller/circulation_manager.py b/src/palace/manager/api/controller/circulation_manager.py index e807d0254c..f81231af7d 100644 --- a/src/palace/manager/api/controller/circulation_manager.py +++ b/src/palace/manager/api/controller/circulation_manager.py @@ -164,6 +164,11 @@ def load_work( ) return NOT_FOUND_ON_REMOTE + if work.is_filtered_for_library(library): + # This work is filtered by library content settings. + # Treat it as if it doesn't exist. + return NOT_FOUND_ON_REMOTE + if work and not work.age_appropriate_for_patron(self.request_patron): # This work is not age-appropriate for the authenticated # patron. Don't show it. diff --git a/src/palace/manager/api/controller/urn_lookup.py b/src/palace/manager/api/controller/urn_lookup.py index ffeaea6a92..2704d72af0 100644 --- a/src/palace/manager/api/controller/urn_lookup.py +++ b/src/palace/manager/api/controller/urn_lookup.py @@ -16,8 +16,11 @@ def work_lookup(self, route_name): """Build a CirculationManagerAnnotor based on the current library's top-level WorkList, and use it to generate an OPDS lookup feed. + + Works are filtered based on the library's content filtering settings + (filtered_audiences and filtered_genres). """ library = get_request_library() top_level_worklist = self.manager.top_level_lanes[library.id] annotator = CirculationManagerAnnotator(top_level_worklist) - return super().work_lookup(annotator, route_name) + return super().work_lookup(annotator, route_name, library=library) diff --git a/src/palace/manager/core/app_server.py b/src/palace/manager/core/app_server.py index 6e8511f05f..49e0e61a60 100644 --- a/src/palace/manager/core/app_server.py +++ b/src/palace/manager/core/app_server.py @@ -312,12 +312,19 @@ def __init__(self, _db): """ self._db = _db - def work_lookup(self, annotator, route_name="lookup", **process_urn_kwargs): - """Generate an OPDS feed describing works identified by identifier.""" + def work_lookup( + self, annotator, route_name="lookup", library=None, **process_urn_kwargs + ): + """Generate an OPDS feed describing works identified by identifier. + + :param annotator: The annotator to use for generating OPDS entries. + :param route_name: The name of the route for generating URLs. + :param library: Optional Library to filter works against. + """ urns = flask.request.args.getlist("urn") this_url = url_for(route_name, _external=True, urn=urns) - handler = self.process_urns(urns, **process_urn_kwargs) + handler = self.process_urns(urns, library=library, **process_urn_kwargs) if isinstance(handler, ProblemDetail): # In a subclass, self.process_urns may return a ProblemDetail @@ -332,17 +339,19 @@ def work_lookup(self, annotator, route_name="lookup", **process_urn_kwargs): opds_feed.generate_feed(annotate=False) return opds_feed.as_response(mime_types=flask.request.accept_mimetypes) - def process_urns(self, urns, **process_urn_kwargs): + def process_urns(self, urns, library=None, **process_urn_kwargs): """Process a number of URNs by instantiating a URNLookupHandler and having it do the work. The information gathered by the URNLookupHandler can be used by the caller to generate an OPDS feed. + :param urns: List of URNs to look up. + :param library: Optional Library to filter works against. :return: A URNLookupHandler, or a ProblemDetail if there's a problem with the request. """ - handler = URNLookupHandler(self._db) + handler = URNLookupHandler(self._db, library=library) handler.process_urns(urns, **process_urn_kwargs) return handler @@ -382,8 +391,15 @@ class URNLookupHandler: WORK_NOT_PRESENTATION_READY = "Work created but not yet presentation-ready." WORK_NOT_CREATED = "Identifier resolved but work not yet created." - def __init__(self, _db): + def __init__(self, _db, library=None): + """ + :param _db: A database session. + :param library: Optional Library to filter works against. If provided, + works matching the library's filtered_audiences or filtered_genres + will be excluded from results. + """ self._db = _db + self.library = library self.works = [] self.precomposed_entries = [] self.unresolved_identifiers = [] @@ -423,6 +439,12 @@ def process_identifier(self, identifier, urn, **kwargs): # There is a work but it's not presentation ready. return self.add_message(urn, 202, self.WORK_NOT_PRESENTATION_READY) + # Check library content filtering + if self.library and work.is_filtered_for_library(self.library): + # This work is filtered by the library's content settings. + # Treat it as if it doesn't exist. + return self.add_message(urn, 404, self.UNRECOGNIZED_IDENTIFIER) + # The work is ready for use in an OPDS feed! return self.add_work(identifier, work) diff --git a/src/palace/manager/integration/configuration/library.py b/src/palace/manager/integration/configuration/library.py index 7ec6f3427a..dfbcbc157b 100644 --- a/src/palace/manager/integration/configuration/library.py +++ b/src/palace/manager/integration/configuration/library.py @@ -21,6 +21,7 @@ INVALID_CONFIGURATION_OPTION, UNKNOWN_LANGUAGE, ) +from palace.manager.core.classifier import Classifier, genres from palace.manager.core.config import Configuration from palace.manager.core.entrypoint import EntryPoint from palace.manager.core.facets import FacetConstants @@ -393,6 +394,28 @@ class LibrarySettings(BaseSettings): level=Level.SYS_ADMIN_ONLY, ), ] = [] + filtered_audiences: Annotated[ + list[str], + LibraryFormMetadata( + label="Filtered audiences", + description="Content for these audiences will be hidden from catalog browse and search results.", + type=FormFieldType.MENU, + options={audience: audience for audience in sorted(Classifier.AUDIENCES)}, + category="Content Filtering", + level=Level.SYS_ADMIN_OR_MANAGER, + ), + ] = [] + filtered_genres: Annotated[ + list[str], + LibraryFormMetadata( + label="Filtered genres", + description="Content in these genres will be hidden from catalog browse and search results.", + type=FormFieldType.MENU, + options=lambda _db: {name: name for name in sorted(genres.keys())}, + category="Content Filtering", + level=Level.SYS_ADMIN_OR_MANAGER, + ), + ] = [] max_outstanding_fines: Annotated[ PositiveFloat | None, LibraryFormMetadata( @@ -583,6 +606,46 @@ def validate_enabled_entry_points(cls, value: list[str]) -> list[str]: ) return value + @field_validator("filtered_audiences") + @classmethod + def validate_filtered_audiences(cls, value: list[str]) -> list[str]: + """Ensure all filtered audiences are valid.""" + if not value: + return value + invalid = [ + audience for audience in value if audience not in Classifier.AUDIENCES + ] + if invalid: + field_label = cls.get_form_field_label("filtered_audiences") + valid_options = ", ".join(sorted(Classifier.AUDIENCES)) + invalid_list = ", ".join(sorted(invalid)) + raise SettingsValidationError( + problem_detail=INVALID_CONFIGURATION_OPTION.detailed( + f"'{field_label}' contains invalid values: {invalid_list}. " + f"Valid options are: {valid_options}." + ) + ) + return value + + @field_validator("filtered_genres") + @classmethod + def validate_filtered_genres(cls, value: list[str]) -> list[str]: + """Ensure all filtered genres are valid.""" + if not value: + return value + valid_genres = set(genres.keys()) + invalid = [genre for genre in value if genre not in valid_genres] + if invalid: + field_label = cls.get_form_field_label("filtered_genres") + invalid_list = ", ".join(sorted(invalid)) + raise SettingsValidationError( + problem_detail=INVALID_CONFIGURATION_OPTION.detailed( + f"'{field_label}' contains invalid values: {invalid_list}. " + f"Please select from the available genre options." + ) + ) + return value + @field_validator("web_primary_color", "web_secondary_color") @classmethod def validate_web_color_contrast(cls, value: str, info: ValidationInfo) -> str: diff --git a/src/palace/manager/search/external_search.py b/src/palace/manager/search/external_search.py index 37fdd6ca6c..797bbcbe5b 100644 --- a/src/palace/manager/search/external_search.py +++ b/src/palace/manager/search/external_search.py @@ -1632,8 +1632,19 @@ def __init__( self.lane_building = kwargs.pop("lane_building", False) - library = kwargs.pop("library", None) - self.library_id = library.id if library else None + # Store library-related filtering information. + # We store the ID and settings values rather than the Library ORM object + # to avoid session detachment issues if the Filter outlives the session. + library: Library | None = kwargs.pop("library", None) + self.library_id: int | None = library.id if library else None + # Store content filtering settings from the library + if library: + settings = library.settings + self.filtered_audiences: list[str] = settings.filtered_audiences + self.filtered_genres: list[str] = settings.filtered_genres + else: + self.filtered_audiences = [] + self.filtered_genres = [] # At this point there should be no keyword arguments -- you can't pass # whatever you want into this method. @@ -1764,6 +1775,19 @@ def build(self, _chain_filters=None): f, Bool(must_not=[Terms(**{"suppressed_for": [self.library_id]})]) ) + # Apply library-level content filtering based on library settings. + # This excludes works matching filtered audiences or genres. + if self.filtered_audiences: + excluded_audiences = scrub_list(self.filtered_audiences) + f = chain(f, Bool(must_not=[Terms(audience=excluded_audiences)])) + if self.filtered_genres: + # Genres are nested documents, so we need a Nested query + genre_exclusion = Nested( + path="genres", + query=Terms(**{"genres.name": self.filtered_genres}), + ) + f = chain(f, Bool(must_not=[genre_exclusion])) + if self.media: f = chain(f, Terms(medium=scrub_list(self.media))) diff --git a/src/palace/manager/sqlalchemy/model/work.py b/src/palace/manager/sqlalchemy/model/work.py index abb8f714b5..4b64f02095 100644 --- a/src/palace/manager/sqlalchemy/model/work.py +++ b/src/palace/manager/sqlalchemy/model/work.py @@ -807,6 +807,30 @@ def age_appropriate_for_patron(self, patron): return True return patron.work_is_age_appropriate(self.audience, self.target_age) + def is_filtered_for_library(self, library: Library) -> bool: + """Check if this Work should be filtered (hidden) for the given Library. + + A work is filtered if its audience or any of its genres match the + library's configured filtered_audiences or filtered_genres settings. + + :param library: The Library to check filtering for. + :return: True if the work should be filtered (hidden), False otherwise. + """ + settings = library.settings + + # Check audience filtering + if self.audience and settings.filtered_audiences: + if self.audience in settings.filtered_audiences: + return True + + # Check genre filtering + if settings.filtered_genres: + work_genre_names = {wg.genre.name for wg in self.work_genres} + if work_genre_names & set(settings.filtered_genres): + return True + + return False + def set_presentation_edition(self, new_presentation_edition): """Sets presentation edition and lets owned pools and editions know. Raises exception if edition to set to is None. diff --git a/tests/manager/api/controller/test_urn_lookup.py b/tests/manager/api/controller/test_urn_lookup.py index f71ef72f49..6efed42a6f 100644 --- a/tests/manager/api/controller/test_urn_lookup.py +++ b/tests/manager/api/controller/test_urn_lookup.py @@ -1,6 +1,8 @@ import feedparser +from palace.manager.core.classifier import Classifier from palace.manager.sqlalchemy.constants import LinkRelations +from palace.manager.sqlalchemy.model.classification import Genre from palace.manager.util.opds_writer import OPDSFeed from tests.fixtures.api_controller import ControllerFixture @@ -41,3 +43,112 @@ def test_work_lookup(self, controller_fixture: ControllerFixture): # CirculationManagerAnnotator. [link] = entry.links assert LinkRelations.OPEN_ACCESS_DOWNLOAD == link["rel"] + + def test_work_lookup_filtered_by_audience( + self, controller_fixture: ControllerFixture + ): + """Test that URN lookup excludes works filtered by audience.""" + library = controller_fixture.db.default_library() + work = controller_fixture.db.work(with_open_access_download=True) + work.audience = Classifier.AUDIENCE_ADULT + [pool] = work.license_pools + urn = pool.identifier.urn + + # Set up audience filtering + library.settings_dict["filtered_audiences"] = ["Adult"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + + with controller_fixture.request_context_with_library("/?urn=%s" % urn): + response = controller_fixture.manager.urn_lookup.work_lookup("work") + + # We get a feed, but with no entries (work was filtered) + assert 200 == response.status_code + feed = feedparser.parse(response.data) + assert 0 == len(feed["entries"]) + + def test_work_lookup_filtered_by_genre(self, controller_fixture: ControllerFixture): + """Test that URN lookup excludes works filtered by genre.""" + library = controller_fixture.db.default_library() + work = controller_fixture.db.work(with_open_access_download=True) + [pool] = work.license_pools + urn = pool.identifier.urn + + # Add a genre to the work + romance_genre, _ = Genre.lookup(controller_fixture.db.session, "Romance") + work.genres = [romance_genre] + + # Set up genre filtering + library.settings_dict["filtered_genres"] = ["Romance"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + + with controller_fixture.request_context_with_library("/?urn=%s" % urn): + response = controller_fixture.manager.urn_lookup.work_lookup("work") + + # We get a feed, but with no entries (work was filtered) + assert 200 == response.status_code + feed = feedparser.parse(response.data) + assert 0 == len(feed["entries"]) + + def test_work_lookup_not_filtered_when_settings_dont_match( + self, controller_fixture: ControllerFixture + ): + """Test that URN lookup works normally when work doesn't match filters.""" + library = controller_fixture.db.default_library() + work = controller_fixture.db.work(with_open_access_download=True) + work.audience = Classifier.AUDIENCE_ADULT + [pool] = work.license_pools + urn = pool.identifier.urn + + # Set up filtering for a different audience + library.settings_dict["filtered_audiences"] = ["Young Adult"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + + with controller_fixture.request_context_with_library("/?urn=%s" % urn): + response = controller_fixture.manager.urn_lookup.work_lookup("work") + + # Work doesn't match filter, should return normally + assert 200 == response.status_code + feed = feedparser.parse(response.data) + assert 1 == len(feed["entries"]) + assert work.title == feed["entries"][0]["title"] + + def test_work_lookup_multiple_urns_with_filtering( + self, controller_fixture: ControllerFixture + ): + """Test that URN lookup correctly filters some works while returning others.""" + library = controller_fixture.db.default_library() + + # Create two works with different audiences + adult_work = controller_fixture.db.work( + title="Adult Book", with_open_access_download=True + ) + adult_work.audience = Classifier.AUDIENCE_ADULT + [adult_pool] = adult_work.license_pools + adult_urn = adult_pool.identifier.urn + + ya_work = controller_fixture.db.work( + title="YA Book", with_open_access_download=True + ) + ya_work.audience = Classifier.AUDIENCE_YOUNG_ADULT + [ya_pool] = ya_work.license_pools + ya_urn = ya_pool.identifier.urn + + # Filter Adult audience + library.settings_dict["filtered_audiences"] = ["Adult"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + + # Request both works + with controller_fixture.request_context_with_library( + f"/?urn={adult_urn}&urn={ya_urn}" + ): + response = controller_fixture.manager.urn_lookup.work_lookup("work") + + # Only the YA work should be returned + assert 200 == response.status_code + feed = feedparser.parse(response.data) + assert 1 == len(feed["entries"]) + assert ya_work.title == feed["entries"][0]["title"] diff --git a/tests/manager/api/controller/test_work.py b/tests/manager/api/controller/test_work.py index b0b07f760b..436e2c1007 100644 --- a/tests/manager/api/controller/test_work.py +++ b/tests/manager/api/controller/test_work.py @@ -295,6 +295,78 @@ def test_permalink(self, work_fixture: WorkFixture): assert expect.data == response.get_data() assert OPDSFeed.ENTRY_TYPE == response.headers["Content-Type"] + def test_permalink_filtered_by_audience(self, work_fixture: WorkFixture): + """Test that permalink returns 404 for works filtered by audience.""" + library = work_fixture.db.default_library() + work = work_fixture.english_1 + work.audience = Classifier.AUDIENCE_ADULT + identifier = work_fixture.identifier + + # Set up audience filtering + library.settings_dict["filtered_audiences"] = ["Adult"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + + with work_fixture.request_context_with_library("/"): + response = work_fixture.manager.work_controller.permalink( + identifier.type, identifier.identifier + ) + + # Work is filtered, should return 404 + assert isinstance(response, ProblemDetail) + assert response.status_code == 404 + assert response.uri == NOT_FOUND_ON_REMOTE.uri + + def test_permalink_filtered_by_genre(self, work_fixture: WorkFixture): + """Test that permalink returns 404 for works filtered by genre.""" + library = work_fixture.db.default_library() + work = work_fixture.english_1 + identifier = work_fixture.identifier + + # Add a genre to the work + from palace.manager.sqlalchemy.model.classification import Genre + + romance_genre, _ = Genre.lookup(work_fixture.db.session, "Romance") + work.genres = [romance_genre] + + # Set up genre filtering + library.settings_dict["filtered_genres"] = ["Romance"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + + with work_fixture.request_context_with_library("/"): + response = work_fixture.manager.work_controller.permalink( + identifier.type, identifier.identifier + ) + + # Work is filtered, should return 404 + assert isinstance(response, ProblemDetail) + assert response.status_code == 404 + assert response.uri == NOT_FOUND_ON_REMOTE.uri + + def test_permalink_not_filtered_when_settings_dont_match( + self, work_fixture: WorkFixture + ): + """Test that permalink works normally when work doesn't match filters.""" + library = work_fixture.db.default_library() + work = work_fixture.english_1 + work.audience = Classifier.AUDIENCE_ADULT + identifier = work_fixture.identifier + + # Set up filtering for a different audience + library.settings_dict["filtered_audiences"] = ["Young Adult"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + + with work_fixture.request_context_with_library("/"): + response = work_fixture.manager.work_controller.permalink( + identifier.type, identifier.identifier + ) + + # Work doesn't match filter, should return normally + assert response.status_code == 200 + assert OPDSFeed.ENTRY_TYPE == response.headers["Content-Type"] + def test_permalink_does_not_return_fulfillment_links_for_authenticated_patrons_without_loans( self, work_fixture: WorkFixture ): diff --git a/tests/manager/integration/configuration/test_library.py b/tests/manager/integration/configuration/test_library.py index c9c8745690..63d4768a81 100644 --- a/tests/manager/integration/configuration/test_library.py +++ b/tests/manager/integration/configuration/test_library.py @@ -3,6 +3,7 @@ import pytest +from palace.manager.core.classifier import Classifier from palace.manager.integration.configuration.library import LibrarySettings from palace.manager.util.problem_detail import ProblemDetailException @@ -94,3 +95,72 @@ def test_minimum_featured_quality_constraints( library_settings(minimum_featured_quality=1.1) assert excinfo.value.problem_detail.detail is not None assert "less than or equal to 1" in excinfo.value.problem_detail.detail + + +class TestFilteredAudiences: + def test_filtered_audiences_valid( + self, library_settings: LibrarySettingsFixture + ) -> None: + """Valid audience values are accepted.""" + settings = library_settings(filtered_audiences=["Adult", "Young Adult"]) + assert settings.filtered_audiences == ["Adult", "Young Adult"] + + def test_filtered_audiences_empty( + self, library_settings: LibrarySettingsFixture + ) -> None: + """Empty list is valid (no filtering).""" + settings = library_settings(filtered_audiences=[]) + assert settings.filtered_audiences == [] + + def test_filtered_audiences_invalid( + self, library_settings: LibrarySettingsFixture + ) -> None: + """Invalid audience values raise validation error.""" + with pytest.raises(ProblemDetailException) as excinfo: + library_settings(filtered_audiences=["Adult", "InvalidAudience"]) + assert excinfo.value.problem_detail.detail is not None + assert "InvalidAudience" in excinfo.value.problem_detail.detail + assert "invalid values" in excinfo.value.problem_detail.detail + + def test_filtered_audiences_all_valid_values( + self, library_settings: LibrarySettingsFixture + ) -> None: + """All defined audience values are accepted.""" + all_audiences = list(Classifier.AUDIENCES) + settings = library_settings(filtered_audiences=all_audiences) + assert set(settings.filtered_audiences) == Classifier.AUDIENCES + + +class TestFilteredGenres: + def test_filtered_genres_valid( + self, library_settings: LibrarySettingsFixture + ) -> None: + """Valid genre names are accepted.""" + settings = library_settings(filtered_genres=["Romance", "Horror"]) + assert settings.filtered_genres == ["Romance", "Horror"] + + def test_filtered_genres_empty( + self, library_settings: LibrarySettingsFixture + ) -> None: + """Empty list is valid (no filtering).""" + settings = library_settings(filtered_genres=[]) + assert settings.filtered_genres == [] + + def test_filtered_genres_invalid( + self, library_settings: LibrarySettingsFixture + ) -> None: + """Invalid genre names raise validation error.""" + with pytest.raises(ProblemDetailException) as excinfo: + library_settings(filtered_genres=["Romance", "NotARealGenre"]) + assert excinfo.value.problem_detail.detail is not None + assert "NotARealGenre" in excinfo.value.problem_detail.detail + assert "invalid values" in excinfo.value.problem_detail.detail + + def test_filtered_genres_case_sensitive( + self, library_settings: LibrarySettingsFixture + ) -> None: + """Genre validation is case-sensitive (must match exactly).""" + with pytest.raises(ProblemDetailException) as excinfo: + library_settings(filtered_genres=["romance"]) # lowercase + assert excinfo.value.problem_detail.detail is not None + assert "romance" in excinfo.value.problem_detail.detail diff --git a/tests/manager/search/test_external_search.py b/tests/manager/search/test_external_search.py index 1c310213b7..cd0568a208 100644 --- a/tests/manager/search/test_external_search.py +++ b/tests/manager/search/test_external_search.py @@ -2,6 +2,7 @@ import re import uuid from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime from typing import Any @@ -1157,6 +1158,204 @@ def expect(availability, works): ) +@dataclass +class LibraryContentFilteringData: + """Data for library content filtering tests.""" + + adult_book: Work + ya_book: Work + children_book: Work + romance_book: Work + horror_book: Work + fantasy_book: Work + ya_romance_book: Work + + +@pytest.fixture +def library_content_filtering_data( + end_to_end_search_fixture: EndToEndSearchFixture, +) -> LibraryContentFilteringData: + """Create and index works for library content filtering tests.""" + create_work = end_to_end_search_fixture.external_search.default_work + + # Works with different audiences + adult_book = create_work(title="Adult Fiction", audience=Classifier.AUDIENCE_ADULT) + ya_book = create_work( + title="YA Adventure", audience=Classifier.AUDIENCE_YOUNG_ADULT + ) + children_book = create_work( + title="Kids Story", audience=Classifier.AUDIENCE_CHILDREN + ) + + # Works with different genres + romance_book = create_work(title="Love Story", genre="Romance") + horror_book = create_work(title="Scary Tale", genre="Horror") + fantasy_book = create_work(title="Magic Quest", genre="Fantasy") + + # Work with both specific audience and genre + ya_romance_book = create_work( + title="Teen Romance", + audience=Classifier.AUDIENCE_YOUNG_ADULT, + genre="Romance", + ) + + end_to_end_search_fixture.populate_search_index() + + return LibraryContentFilteringData( + adult_book=adult_book, + ya_book=ya_book, + children_book=children_book, + romance_book=romance_book, + horror_book=horror_book, + fantasy_book=fantasy_book, + ya_romance_book=ya_romance_book, + ) + + +class TestLibraryContentFiltering: + """Test that library-level content filtering correctly excludes works + from search results based on filtered_audiences and filtered_genres settings. + """ + + def test_library_content_filtering_end_to_end( + self, + end_to_end_search_fixture: EndToEndSearchFixture, + library_content_filtering_data: LibraryContentFilteringData, + ): + """End-to-end test verifying that library content filters are applied + correctly when querying OpenSearch. + """ + fixture = end_to_end_search_fixture + data = library_content_filtering_data + transaction = fixture.external_search.db + library = transaction.default_library() + + # Helper to create a filter with library and expect results + def expect_with_filter(expected_works: list[Work], query: str = ""): + # Clear cached settings before creating filter + if hasattr(library, "_settings"): + delattr(library, "_settings") + f = Filter(library=library) + fixture.expect_results(expected_works, query, filter=f, ordered=False) + + # Test 1: No filtering - all works returned + library.settings_dict["filtered_audiences"] = [] + library.settings_dict["filtered_genres"] = [] + expect_with_filter( + [ + data.adult_book, + data.ya_book, + data.children_book, + data.romance_book, + data.horror_book, + data.fantasy_book, + data.ya_romance_book, + ] + ) + + # Test 2: Filter out Adult audience + library.settings_dict["filtered_audiences"] = ["Adult"] + library.settings_dict["filtered_genres"] = [] + expect_with_filter( + [ + data.ya_book, + data.children_book, + # Romance, horror, fantasy have default Adult audience, so filtered + data.ya_romance_book, + ] + ) + + # Test 3: Filter out Young Adult audience + library.settings_dict["filtered_audiences"] = ["Young Adult"] + library.settings_dict["filtered_genres"] = [] + expect_with_filter( + [ + data.adult_book, + data.children_book, + data.romance_book, + data.horror_book, + data.fantasy_book, + # ya_book and ya_romance_book are filtered (Young Adult) + ] + ) + + # Test 4: Filter out Romance genre + library.settings_dict["filtered_audiences"] = [] + library.settings_dict["filtered_genres"] = ["Romance"] + expect_with_filter( + [ + data.adult_book, + data.ya_book, + data.children_book, + # romance_book filtered + data.horror_book, + data.fantasy_book, + # ya_romance_book filtered (has Romance genre) + ] + ) + + # Test 5: Filter out Horror genre + library.settings_dict["filtered_audiences"] = [] + library.settings_dict["filtered_genres"] = ["Horror"] + expect_with_filter( + [ + data.adult_book, + data.ya_book, + data.children_book, + data.romance_book, + # horror_book filtered + data.fantasy_book, + data.ya_romance_book, + ] + ) + + # Test 6: Filter both audience and genre (AND logic) + # Filter Adult audience AND Romance genre + library.settings_dict["filtered_audiences"] = ["Adult"] + library.settings_dict["filtered_genres"] = ["Romance"] + expect_with_filter( + [ + # adult_book filtered (Adult) + data.ya_book, + data.children_book, + # romance_book filtered (Adult + Romance) + # horror_book filtered (Adult) + # fantasy_book filtered (Adult) + # ya_romance_book filtered (Romance) + ] + ) + + # Test 7: Multiple audiences filtered + library.settings_dict["filtered_audiences"] = ["Adult", "Young Adult"] + library.settings_dict["filtered_genres"] = [] + expect_with_filter( + [ + # adult_book filtered + # ya_book filtered + data.children_book, + # romance_book filtered (Adult) + # horror_book filtered (Adult) + # fantasy_book filtered (Adult) + # ya_romance_book filtered (Young Adult) + ] + ) + + # Test 8: Multiple genres filtered + library.settings_dict["filtered_audiences"] = [] + library.settings_dict["filtered_genres"] = ["Romance", "Horror"] + expect_with_filter( + [ + data.adult_book, + data.ya_book, + data.children_book, + # romance_book filtered + # horror_book filtered + data.fantasy_book, + # ya_romance_book filtered (Romance) + ] + ) + + class TestSearchOrderData: a1: LicensePool a2: LicensePool @@ -4107,6 +4306,81 @@ def test_build_series(self): # But the 'series' that got indexed must not be the empty string. assert {"term": {"series.keyword": ""}} in built.to_dict()["bool"]["must_not"] + def test_build_library_content_filtering( + self, filter_fixture: FilterFixture + ) -> None: + """Test that library-level content filtering excludes works + matching filtered audiences or genres. + """ + transaction = filter_fixture.transaction + library = transaction.default_library() + + # Test 1: No filtering - empty settings don't affect results + # Library with no filtered audiences or genres + filter = Filter(library=library) + built, nested = filter.build() + # Only the suppressed_for and research audience filters should be present + must_not = built.to_dict()["bool"]["must_not"] + # suppressed_for filter + assert {"terms": {"suppressed_for": [library.id]}} in must_not + # default research audience exclusion + assert {"term": {"audience": "research"}} in must_not + # Should only have these two must_not clauses + assert len(must_not) == 2 + + # Test 2: Filter by audiences + library.settings_dict["filtered_audiences"] = ["Adult", "Adults Only"] + # Clear cached settings + if hasattr(library, "_settings"): + delattr(library, "_settings") + filter = Filter(library=library) + built, nested = filter.build() + must_not = built.to_dict()["bool"]["must_not"] + # Should include audience exclusion filter (scrubbed to lowercase/no spaces) + assert {"terms": {"audience": ["adult", "adultsonly"]}} in must_not + + # Test 3: Filter by genres + library.settings_dict["filtered_audiences"] = [] + library.settings_dict["filtered_genres"] = ["Romance", "Horror"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + filter = Filter(library=library) + built, nested = filter.build() + must_not = built.to_dict()["bool"]["must_not"] + # Should include nested genre exclusion filter + genre_filter = { + "nested": { + "path": "genres", + "query": {"terms": {"genres.name": ["Romance", "Horror"]}}, + } + } + assert genre_filter in must_not + + # Test 4: Both filters applied (AND logic) + library.settings_dict["filtered_audiences"] = ["Young Adult"] + library.settings_dict["filtered_genres"] = ["Horror"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + filter = Filter(library=library) + built, nested = filter.build() + must_not = built.to_dict()["bool"]["must_not"] + # Should include both audience and genre exclusion filters + assert {"terms": {"audience": ["youngadult"]}} in must_not + genre_filter = { + "nested": { + "path": "genres", + "query": {"terms": {"genres.name": ["Horror"]}}, + } + } + assert genre_filter in must_not + + # Test 5: No library - no library content filtering + filter = Filter(library=None) + built, nested = filter.build() + must_not = built.to_dict()["bool"]["must_not"] + # Only the default research audience exclusion should be present + assert must_not == [{"term": {"audience": "research"}}] + def test_sort_order(self, filter_fixture: FilterFixture): data, transaction, session = ( filter_fixture, diff --git a/tests/manager/sqlalchemy/model/test_library.py b/tests/manager/sqlalchemy/model/test_library.py index c017ee3b1a..03ed9a35c0 100644 --- a/tests/manager/sqlalchemy/model/test_library.py +++ b/tests/manager/sqlalchemy/model/test_library.py @@ -186,6 +186,8 @@ def test_explain(self, db: DatabaseTransactionFixture): web_header_links='[]' web_header_labels='[]' hidden_content_types='[]' +filtered_audiences='[]' +filtered_genres='[]' """ actual = library.explain() assert expect == "\n".join(actual) diff --git a/tests/manager/sqlalchemy/model/test_work.py b/tests/manager/sqlalchemy/model/test_work.py index 0bf86f14b2..be7a123b70 100644 --- a/tests/manager/sqlalchemy/model/test_work.py +++ b/tests/manager/sqlalchemy/model/test_work.py @@ -1452,6 +1452,78 @@ def test_age_appropriate_for_patron_end_to_end( work.audience = Classifier.AUDIENCE_ADULT assert False == work.age_appropriate_for_patron(patron) + def test_is_filtered_for_library(self, db: DatabaseTransactionFixture): + """Test that is_filtered_for_library correctly identifies works + that should be hidden based on library content filtering settings. + """ + library = db.default_library() + work = db.work() + work.audience = Classifier.AUDIENCE_ADULT + + # With no filtering configured, the work is not filtered + assert work.is_filtered_for_library(library) is False + + # Filter by audience - matching audience should filter + library.settings_dict["filtered_audiences"] = ["Adult"] + # Clear cached settings + if hasattr(library, "_settings"): + delattr(library, "_settings") + assert work.is_filtered_for_library(library) is True + + # Non-matching audience should not filter + library.settings_dict["filtered_audiences"] = ["Young Adult"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + assert work.is_filtered_for_library(library) is False + + # Filter by genre - add a genre to the work + romance_genre, _ = Genre.lookup(db.session, "Romance") + work.genres = [romance_genre] + + # Matching genre should filter + library.settings_dict["filtered_audiences"] = [] + library.settings_dict["filtered_genres"] = ["Romance"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + assert work.is_filtered_for_library(library) is True + + # Non-matching genre should not filter + library.settings_dict["filtered_genres"] = ["Horror"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + assert work.is_filtered_for_library(library) is False + + # Multiple genres - any match should filter + horror_genre, _ = Genre.lookup(db.session, "Horror") + work.genres = [romance_genre, horror_genre] + library.settings_dict["filtered_genres"] = ["Horror"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + assert work.is_filtered_for_library(library) is True + + # Combined audience AND genre filtering (both apply independently) + library.settings_dict["filtered_audiences"] = ["Adult"] + library.settings_dict["filtered_genres"] = [] + if hasattr(library, "_settings"): + delattr(library, "_settings") + # Audience matches, so filtered + assert work.is_filtered_for_library(library) is True + + library.settings_dict["filtered_audiences"] = [] + library.settings_dict["filtered_genres"] = ["Romance"] + if hasattr(library, "_settings"): + delattr(library, "_settings") + # Genre matches, so filtered + assert work.is_filtered_for_library(library) is True + + # Work with no audience set should not be filtered by audience + work.audience = None + library.settings_dict["filtered_audiences"] = ["Adult"] + library.settings_dict["filtered_genres"] = [] + if hasattr(library, "_settings"): + delattr(library, "_settings") + assert work.is_filtered_for_library(library) is False + def test_unlimited_access_books_are_available_by_default( self, db: DatabaseTransactionFixture ):