diff --git a/alembic/versions/20241210_d3aaeb6a9e6b_create_selectedbooks.py b/alembic/versions/20241210_d3aaeb6a9e6b_create_selectedbooks.py new file mode 100644 index 000000000..648b05cc1 --- /dev/null +++ b/alembic/versions/20241210_d3aaeb6a9e6b_create_selectedbooks.py @@ -0,0 +1,44 @@ +"""Create SelectedBooks + +Revision ID: d3aaeb6a9e6b +Revises: b28ac9090d40 +Create Date: 2024-12-10 14:16:32.223456+00:00 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "d3aaeb6a9e6b" +down_revision = "b28ac9090d40" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "selected_books", + sa.Column("id", sa.Integer()), + sa.Column("patron_id", sa.Integer()), + sa.Column("work_id", sa.Integer()), + sa.Column("creation_date", sa.DateTime(timezone=True)), + sa.ForeignKeyConstraint( + ["patron_id"], + ["patrons.id"], + ), + sa.ForeignKeyConstraint( + ["work_id"], + ["works.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("patron_id", "work_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("selected_books") + # ### end Alembic commands ### diff --git a/api/authenticator.py b/api/authenticator.py index 1b2faf7ee..3c9f40a50 100644 --- a/api/authenticator.py +++ b/api/authenticator.py @@ -697,6 +697,9 @@ def create_authentication_document(self) -> str: loans_url = url_for( "active_loans", _external=True, library_short_name=self.library_short_name ) + selected_books_url = url_for( + "selected_books", _external=True, library_short_name=self.library_short_name + ) profile_url = url_for( "patron_profile", _external=True, library_short_name=self.library_short_name ) @@ -711,6 +714,13 @@ def create_authentication_document(self) -> str: type=OPDSFeed.ACQUISITION_FEED_TYPE, ) ) + links.append( + dict( + rel="http://opds-spec.org/shelf/selected_books", + href=selected_books_url, + type=OPDSFeed.ACQUISITION_FEED_TYPE, + ) + ) links.append( dict( rel=ProfileController.LINK_RELATION, diff --git a/api/circulation_manager.py b/api/circulation_manager.py index 701b8144c..c43afe94c 100644 --- a/api/circulation_manager.py +++ b/api/circulation_manager.py @@ -25,6 +25,7 @@ from api.controller.patron_auth_token import PatronAuthTokenController from api.controller.playtime_entries import PlaytimeEntriesController from api.controller.profile import ProfileController +from api.controller.select_books import SelectBooksController from api.controller.urn_lookup import URNLookupController from api.controller.work import WorkController from api.custom_index import CustomIndexView @@ -91,6 +92,7 @@ class CirculationManager(LoggerMixin): version: ApplicationVersionController odl_notification_controller: ODLNotificationController playtime_entries: PlaytimeEntriesController + select_books: SelectBooksController # Admin controllers admin_sign_in_controller: SignInController @@ -327,6 +329,7 @@ def setup_one_time_controllers(self): self.patron_auth_token = PatronAuthTokenController(self) self.catalog_descriptions = CatalogDescriptionsController(self) self.playtime_entries = PlaytimeEntriesController(self) + self.select_books = SelectBooksController(self) def setup_configuration_dependent_controllers(self): """Set up all the controllers that depend on the diff --git a/api/controller/loan.py b/api/controller/loan.py index fa59fab49..2414138f5 100644 --- a/api/controller/loan.py +++ b/api/controller/loan.py @@ -152,8 +152,15 @@ def borrow(self, identifier_type, identifier, mechanism_id=None): "status": 201 if is_new else 200, "mime_types": flask.request.accept_mimetypes, } + + work = self.load_work(library, identifier_type, identifier) + selected_book = patron.load_selected_book(work) + return OPDSAcquisitionFeed.single_entry_loans_feed( - self.circulation, loan_or_hold, **response_kwargs + self.circulation, + loan_or_hold, + selected_book=selected_book, + **response_kwargs, ) def _borrow(self, patron, credential, pool, mechanism): @@ -555,6 +562,7 @@ def revoke(self, license_pool_id): def detail(self, identifier_type, identifier): if flask.request.method == "DELETE": + # Causes an error becuase the function is not in LoansController but route! return self.revoke_loan_or_hold(identifier_type, identifier) patron = flask.request.patron @@ -578,9 +586,14 @@ def detail(self, identifier_type, identifier): status_code=404, ) + work = self.load_work(library, identifier_type, identifier) + selected_book = patron.load_selected_book(work) + if flask.request.method == "GET": if loan: item = loan else: item = hold - return OPDSAcquisitionFeed.single_entry_loans_feed(self.circulation, item) + return OPDSAcquisitionFeed.single_entry_loans_feed( + self.circulation, item, selected_book=selected_book + ) diff --git a/api/controller/select_books.py b/api/controller/select_books.py new file mode 100644 index 000000000..0fc1f3065 --- /dev/null +++ b/api/controller/select_books.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import flask + +from api.controller.circulation_manager import CirculationManagerController +from core.feed.acquisition import OPDSAcquisitionFeed +from core.util.problem_detail import ProblemDetail + + +class SelectBooksController(CirculationManagerController): + def fetch_books(self): + """ + Generate an OPDS feed response containing the selected books for the + authenticated patron. + + This method creates an OPDS acquisition feed with the books currently + selected by the patron and returns it as a response. + + :return: An OPDSEntryResponse. + """ + patron = flask.request.patron + + feed = OPDSAcquisitionFeed.selected_books_for(self.circulation, patron) + + response = feed.as_response( + max_age=0, + private=True, + mime_types=flask.request.accept_mimetypes, + ) + + return response + + def unselect(self, identifier_type, identifier): + """ + Unselect a book from the authenticated patron's selected books list. + + This method returns an OPDS entry with loan or hold-specific information. + + :param identifier_type: The type of identifier for the book + :param identifier: The identifier for the book + + :return: An OPDSEntryResponse + """ + library = flask.request.library + work = self.load_work(library, identifier_type, identifier) + patron = flask.request.patron + pools = self.load_licensepools(library, identifier_type, identifier) + + unselected_book = patron.unselect_book(work) + + item = self._get_patron_loan_or_hold(patron, pools) + + return OPDSAcquisitionFeed.single_entry_loans_feed( + self.circulation, item, selected_book=unselected_book + ) + + def select(self, identifier_type, identifier): + """ + Add a book to the authenticated patron's selected books list. + + This method returns an OPDS entry with the selected book and + loan or hold-specific information. + + :param identifier_type: The type of the book identifier (e.g., ISBN). + :param identifier: The identifier for the book. + + :return: An OPDSEntryResponse. + """ + library = flask.request.library + work = self.load_work(library, identifier_type, identifier) + patron = flask.request.patron + pools = self.load_licensepools(library, identifier_type, identifier) + + if isinstance(pools, ProblemDetail): + return pools + + selected_book = patron.select_book(work) + + item = self._get_patron_loan_or_hold(patron, pools) + + return OPDSAcquisitionFeed.single_entry_loans_feed( + self.circulation, item, selected_book=selected_book + ) + + def _get_patron_loan_or_hold(self, patron, pools): + """ + Retrieve the active loan or hold for a patron from a set of license + pools. + + This method checks if the patron has an active loan or hold for any of + the given license pools. If an active loan is found, it is returned + alongside the corresponding license pool. If no loan is found, it + checks for an active hold. If neither a loan nor a hold is found, it + returns the first license pool from the list. + + :param patron: The patron for whom to find an active loan or hold. + :param pools: A list of LicensePool objects associated with the + identifier. + + :return: An active Loan or Hold object, or a LicensePool if no loan + or hold is found. + """ + # TODO: move this function to circulation_manager.py becuase it's + # used in multiple controllers + loan, pool = self.get_patron_loan(patron, pools) + hold = None + + if not loan: + hold, pool = self.get_patron_hold(patron, pools) + + item = loan or hold + pool = pool or pools[0] + return item or pool + + def detail(self, identifier_type, identifier): + """ + Return an OPDS feed entry for a selected book. + + If the request method is DELETE, this method unselects the book. + + Whether the request is GET or DELETE, it returns an OPDS entry with + loan or hold-specific information and the selected book information. + + :param identifier_type: The type of the book identifier (e.g., ISBN). + :param identifier: The identifier for the book. + + :return: An OPDSEntryResponse. + """ + patron = flask.request.patron + library = flask.request.library + + if flask.request.method == "DELETE": + return self.unselect(identifier_type, identifier) + + if flask.request.method == "GET": + pools = self.load_licensepools(library, identifier_type, identifier) + if isinstance(pools, ProblemDetail): + return pools + + item = self._get_patron_loan_or_hold(patron, pools) + + work = self.load_work(library, identifier_type, identifier) + selected_book = patron.load_selected_book(work) + + return OPDSAcquisitionFeed.single_entry_loans_feed( + self.circulation, item, selected_book=selected_book + ) diff --git a/api/controller/work.py b/api/controller/work.py index d50928f8a..49f43f273 100644 --- a/api/controller/work.py +++ b/api/controller/work.py @@ -96,8 +96,7 @@ def contributor( def permalink(self, identifier_type, identifier): """Serve an entry for a single book. - This does not include any loan or hold-specific information for - the authenticated patron. + This includes any loan or hold-specific information as well as selected book information for an authenticated patron. This is different from the /works lookup protocol, in that it returns a single entry while the /works lookup protocol returns a @@ -124,8 +123,10 @@ def permalink(self, identifier_type, identifier): item = loan or hold pool = pool or pools[0] + selected_book = patron.load_selected_book(work) + return OPDSAcquisitionFeed.single_entry_loans_feed( - self.circulation, item or pool + self.circulation, item or pool, selected_book=selected_book ) else: annotator = self.manager.annotator(lane=None) diff --git a/api/routes.py b/api/routes.py index de085b103..2dc718b39 100644 --- a/api/routes.py +++ b/api/routes.py @@ -384,6 +384,51 @@ def patron_auth_token(): return app.manager.patron_auth_token.get_token() +@library_dir_route( + "/works///select_book", methods=["POST"] +) +@has_library +@allows_patron_web +@requires_auth +@returns_problem_detail +@compressible +def select_book(identifier_type, identifier): + return app.manager.select_books.select(identifier_type, identifier) + + +@library_dir_route( + "/works///unselect_book", methods=["DELETE"] +) +@has_library +@allows_patron_web +@requires_auth +@returns_problem_detail +@compressible +def unselect_book(identifier_type, identifier): + return app.manager.select_books.unselect(identifier_type, identifier) + + +@library_dir_route("/selected_books", methods=["GET"]) +@has_library +@allows_patron_web +@requires_auth +@returns_problem_detail +@compressible +def selected_books(): + return app.manager.select_books.fetch_books() + + +@library_route( + "/selected_books//", methods=["GET", "DELETE"] +) +@has_library +@allows_patron_web +@requires_auth +@returns_problem_detail +def selected_book_detail(identifier_type, identifier): + return app.manager.select_books.detail(identifier_type, identifier) + + @library_dir_route("/loans", methods=["GET", "HEAD"]) @has_library @allows_patron_web diff --git a/core/feed/acquisition.py b/core/feed/acquisition.py index 81504e3ed..9be25b1f2 100644 --- a/core/feed/acquisition.py +++ b/core/feed/acquisition.py @@ -26,7 +26,7 @@ from core.model.edition import Edition from core.model.identifier import Identifier from core.model.licensing import LicensePool -from core.model.patron import Hold, Loan, Patron +from core.model.patron import Hold, Loan, Patron, SelectedBook from core.model.work import Work from core.problem_details import INVALID_INPUT from core.util.datetime_helpers import utc_now @@ -511,6 +511,47 @@ def active_loans_for( feed.generate_feed() return feed + @classmethod + def selected_books_for( + cls, + circulation: CirculationAPI | None, + patron: Patron, + annotator: LibraryAnnotator | None = None, + **response_kwargs: Any, + ) -> OPDSAcquisitionFeed: + """ + Generates a patron-specific OPDS acquisition feed containing their selected books. + + Args: + circulation: The circulation API instance (optional). + patron: The authenticated patron. + annotator: The library annotator instance (optional). If not provided, a new instance will be created. + **response_kwargs: Additional keyword arguments to customize the feed generation. + + Returns: + An OPDSAcquisitionFeed object representing the patron's selected books. + """ + selected_books_by_work = {} + for selected_book in patron.selected_books: + work = selected_book.work # type: ignore + if work: + selected_books_by_work[work] = selected_book + + if not annotator: + annotator = LibraryAnnotator(circulation, None, patron.library, patron) + + annotator.selected_books_by_work = selected_books_by_work + url = annotator.url_for( + "selected_books", + library_short_name=patron.library.short_name, + _external=True, + ) + works = patron.get_selected_works() + + feed = OPDSAcquisitionFeed("Selected books", url, works, annotator) + feed.generate_feed() + return feed + @classmethod def single_entry_loans_feed( cls, @@ -518,9 +559,36 @@ def single_entry_loans_feed( item: LicensePool | Loan, annotator: LibraryAnnotator | None = None, fulfillment: FulfillmentInfo | None = None, + selected_book: SelectedBook | None = None, **response_kwargs: Any, ) -> OPDSEntryResponse | ProblemDetail | None: - """A single entry as a standalone feed specific to a patron""" + """ + Returns a single entry feed for a patron's loan or hold, including + information about the loan, hold, and selected book. + + Args: + circulation: The circulation object associated with the patron. + item: The loan or hold object to generate the feed for. Can be + a LicensePool, Loan, or Hold. + annotator: An optional LibraryAnnotator object to use for + annotating the feed. If not provided, a default annotator + will be created. + fulfillment: An optional FulfillmentInfo object to include in + the feed. + selected_book: An optional SelectedBook object to include in + the feed. + **response_kwargs: Additional keyword arguments to pass to the + response generation. + + Returns: + An OPDSEntryResponse object containing the feed, a ProblemDetail + object if an error occurs, or None if the feed cannot be + generated. + + Raises: + ValueError: If the 'item' argument is empty or not an instance of + LicensePool, Loan, or Hold. + """ if not item: raise ValueError("Argument 'item' must be non-empty") @@ -578,6 +646,10 @@ def single_entry_loans_feed( annotator.active_fulfillments_by_work = active_fulfillments_by_work identifier = license_pool.identifier + selected_books_by_work: Any = {} + selected_books_by_work[work] = selected_book + annotator.selected_books_by_work = selected_books_by_work + entry = cls.single_entry(work, annotator, even_if_no_license_pool=True) if isinstance(entry, WorkEntry) and entry.computed: diff --git a/core/feed/annotator/circulation.py b/core/feed/annotator/circulation.py index 8f2f500c7..f2934ce79 100644 --- a/core/feed/annotator/circulation.py +++ b/core/feed/annotator/circulation.py @@ -53,7 +53,7 @@ LicensePool, LicensePoolDeliveryMechanism, ) -from core.model.patron import Hold, Loan, Patron +from core.model.patron import Hold, Loan, Patron, SelectedBook from core.model.work import Work from core.service.container import Services from core.util.datetime_helpers import from_timestamp @@ -696,6 +696,7 @@ def __init__( top_level_title: str = "All Books", library_identifies_patrons: bool = True, facets: FacetsWithEntryPoint | None = None, + selected_books_by_work: dict[Work, SelectedBook] | None = None, ) -> None: """Constructor. @@ -727,6 +728,7 @@ def __init__( self._top_level_title = top_level_title self.identifies_patrons = library_identifies_patrons self.facets = facets or None + self.selected_books_by_work = selected_books_by_work def top_level_title(self) -> str: return self._top_level_title @@ -934,6 +936,13 @@ def annotate_work_entry( ) ) + # Selected books is from LibraryAnnotator + if self.selected_books_by_work and work in self.selected_books_by_work: + if self.selected_books_by_work[work]: + entry.computed.selected = strftime( # type: ignore + self.selected_books_by_work[work].creation_date # type: ignore + ) + if self.analytics.is_configured(): entry.computed.other_links.append( Link( diff --git a/core/feed/serializer/opds.py b/core/feed/serializer/opds.py index 76d78c377..a57b29b0f 100644 --- a/core/feed/serializer/opds.py +++ b/core/feed/serializer/opds.py @@ -29,6 +29,7 @@ "patron": f"{{{OPDSFeed.SIMPLIFIED_NS}}}patron", "series": f"{{{OPDSFeed.SCHEMA_NS}}}series", "hashed_passphrase": f"{{{OPDSFeed.LCP_NS}}}hashed_passphrase", + "selected": f"{{{OPDSFeed.SIMPLIFIED_NS}}}selected", } ATTRIBUTE_MAPPING = { @@ -264,6 +265,9 @@ def serialize_work_entry(self, feed_entry: WorkEntryData) -> etree._Element: for link in feed_entry.other_links: entry.append(OPDSFeed.link(**link.asdict())) + if feed_entry.selected: + entry.append(OPDSFeed.E("selected", feed_entry.selected)) + return entry def serialize_opds_message(self, entry: OPDSMessage) -> etree._Element: diff --git a/core/feed/types.py b/core/feed/types.py index 18cbaaaec..41b06c1e1 100644 --- a/core/feed/types.py +++ b/core/feed/types.py @@ -163,6 +163,7 @@ class WorkEntryData(BaseModel): subtitle: FeedEntryType | None = None series: FeedEntryType | None = None imprint: FeedEntryType | None = None + selected: datetime | date | None = None authors: list[Author] = field(default_factory=list) contributors: list[Author] = field(default_factory=list) diff --git a/core/model/__init__.py b/core/model/__init__.py index 5a18153e5..4060f1fc5 100644 --- a/core/model/__init__.py +++ b/core/model/__init__.py @@ -564,6 +564,7 @@ def _bulk_operation(self): LoanCheckout, Patron, PatronProfileStorage, + SelectedBook, ) from core.model.resource import ( Hyperlink, diff --git a/core/model/patron.py b/core/model/patron.py index 1e0c7272a..4aaea211b 100644 --- a/core/model/patron.py +++ b/core/model/patron.py @@ -194,6 +194,13 @@ class Patron(Base): # than this time. MAX_SYNC_TIME = datetime.timedelta(hours=12) + selected_books: Mapped[list[SelectedBook]] = relationship( + "SelectedBook", + backref="patron", + cascade="delete", + order_by="SelectedBook.creation_date", + ) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.neighborhood: str | None = None @@ -517,6 +524,59 @@ def ensure_tuple(x): log.debug("Both audience and target age match; it's age-appropriate.") return True + def select_book(self, work) -> SelectedBook: + """ + Add a book to patron's selected books. If the book is already selected, the + existing book is returned. + + :param work: Work to select + + :return: SelectedBook object + """ + selected_book = self.load_selected_book(work) + if not selected_book: + selected_book = SelectedBook(patron=self, work=work) + db = Session.object_session(self) + db.add(selected_book) + db.commit() + return selected_book + + def unselect_book(self, work) -> None: + """ + Remove a book from patron's selected books. + + :param work: Work to select + + :return: None + """ + selected_book = self.load_selected_book(work) + if selected_book: + db = Session.object_session(self) + db.delete(selected_book) + db.commit() + return None + + def load_selected_book(self, work) -> SelectedBook | None: + """ + Load the selected book for the given work. + + :param work: Work to load + + :return: SelectedBook object or None + """ + selected_book = [sb for sb in self.selected_books if sb.work_id == work.id] + return selected_book[0] if selected_book else None + + def get_selected_works(self): + """ + Fetch a list of Works that the patron has selected. + + :return: A list of Work objects + """ + selected_book_objects = self.selected_books + selected_works = [sb.work for sb in selected_book_objects] + return selected_works + Index( "ix_patron_library_id_external_identifier", @@ -724,6 +784,29 @@ def update(self, start, end, position): __table_args__ = (UniqueConstraint("patron_id", "license_pool_id"),) +class SelectedBook(Base): + __tablename__ = "selected_books" + + id = Column(Integer, primary_key=True) + patron_id = Column(Integer, ForeignKey("patrons.id")) + work_id = Column(Integer, ForeignKey("works.id")) + creation_date = Column(DateTime(timezone=True)) + + __table_args__ = (UniqueConstraint("patron_id", "work_id"),) + + def __init__(self, patron, work): + self.patron_id = patron.id + self.work_id = work.id + self.creation_date = utc_now() + + def __repr__(self): + return "".format( + self.patron_id, + self.work_id, + self.creation_date, + ) + + class Annotation(Base): # The Web Annotation Data Model defines a basic set of motivations. # https://www.w3.org/TR/annotation-model/#motivation-and-purpose diff --git a/core/model/work.py b/core/model/work.py index 36f59c310..b2a799970 100644 --- a/core/model/work.py +++ b/core/model/work.py @@ -48,6 +48,7 @@ from core.model.edition import Edition from core.model.identifier import Identifier, RecursiveEquivalencyCache from core.model.measurement import Measurement +from core.model.patron import Patron from core.util import LanguageCodes from core.util.datetime_helpers import utc_now @@ -215,6 +216,10 @@ class Work(Base): "summary_text", ] + selected_by_patrons: Mapped[list[Patron]] = relationship( + "SelectedBook", backref="work" + ) + @property def title(self): if self.presentation_edition: diff --git a/tests/api/controller/test_select_books.py b/tests/api/controller/test_select_books.py new file mode 100644 index 000000000..60d55539b --- /dev/null +++ b/tests/api/controller/test_select_books.py @@ -0,0 +1,166 @@ +from typing import TYPE_CHECKING + +import feedparser +import pytest + +from core.model import ( + DataSource, + Edition, + Identifier, + LicensePool, + SelectedBook, + get_one, +) +from tests.fixtures.api_controller import CirculationControllerFixture +from tests.fixtures.database import DatabaseTransactionFixture + +if TYPE_CHECKING: + pass + + +class SelectBooksFixture(CirculationControllerFixture): + identifier: Identifier + lp: LicensePool + datasource: DataSource + edition: Edition + + def __init__(self, db: DatabaseTransactionFixture): + super().__init__(db) + [self.lp] = self.english_1.license_pools + self.identifier = self.lp.identifier + self.edition = self.lp.presentation_edition + self.datasource = self.lp.data_source.name # type: ignore + + +@pytest.fixture(scope="function") +def selected_book_fixture(db: DatabaseTransactionFixture): + fixture = SelectBooksFixture(db) + with fixture.wired_container(): + yield fixture + + +class TestSelectBooksController: + def test_select_unselect_success(self, selected_book_fixture: SelectBooksFixture): + """ + Test that a book can be successfully selected and unselected by a patron. + A successful selection returns a 200 status code, the work is found in + the database and a feed is returned with a tag. + A successful unselection returns a 200 status code, the work is no longer + in the database and a feed is returned without a tag. + """ + + with selected_book_fixture.request_context_with_library( + "/", headers=dict(Authorization=selected_book_fixture.valid_auth) + ): + # We have an authenticated patron + patron = ( + selected_book_fixture.manager.select_books.authenticated_patron_from_request() + ) + identifier_type = Identifier.GUTENBERG_ID + identifier = "1234567890" + edition, _ = selected_book_fixture.db.edition( + title="Test Book", + identifier_type=identifier_type, + identifier_id=identifier, + with_license_pool=True, + ) + # The work has an edition + work = selected_book_fixture.db.work( + "Test Book", presentation_edition=edition, with_license_pool=True + ) + + # 1. The patron selects the work + response = selected_book_fixture.manager.select_books.select( + identifier_type, identifier + ) + + assert 200 == response.status_code + # The book should be in the database + selected_book = get_one( + selected_book_fixture.db.session, SelectedBook, work_id=work.id + ) + assert selected_book != None + # The feed should have a tag + feed = feedparser.parse(response.data) + [entry] = feed.entries + assert entry["selected"] + + # 2. Then the patron unselects the work + response = selected_book_fixture.manager.select_books.unselect( + identifier_type, identifier + ) + assert 200 == response.status_code + # The book should no longer show up in the database + selected_book = get_one( + selected_book_fixture.db.session, SelectedBook, work_id=work.id + ) + assert selected_book == None + # The feed should no longer have a tag + feed = feedparser.parse(response.data) + [entry] = feed.entries + assert "selected" not in entry + + def test_detail(self, selected_book_fixture: SelectBooksFixture): + """ + Test that a selected book's details are fetched successfully. + """ + + with selected_book_fixture.request_context_with_library( + "/", headers=dict(Authorization=selected_book_fixture.valid_auth) + ): + # A patron first selects a work + selected_book_fixture.manager.select_books.authenticated_patron_from_request() + identifier_type = Identifier.GUTENBERG_ID + identifier = "1234567890" + edition, _ = selected_book_fixture.db.edition( + title="Test Book", + identifier_type=identifier_type, + identifier_id=identifier, + with_license_pool=True, + ) + work = selected_book_fixture.db.work( + "Test Book", presentation_edition=edition, with_license_pool=True + ) + selected_book_fixture.manager.select_books.select( + identifier_type, identifier + ) + # And then later on the book's details are requested + response = selected_book_fixture.manager.select_books.detail( + identifier_type, identifier + ) + + assert response.status_code == 200 + feed = feedparser.parse(response.data) + [entry] = feed.entries + assert entry["selected"] + + def test_fetch_selected_books_feed(self, selected_book_fixture: SelectBooksFixture): + """ + Test that the selected books acquisition feed is fetched successfully. + """ + + with selected_book_fixture.request_context_with_library( + "/", headers=dict(Authorization=selected_book_fixture.valid_auth) + ): + # A patron first selects a work + selected_book_fixture.manager.select_books.authenticated_patron_from_request() + identifier_type = Identifier.GUTENBERG_ID + identifier = "1234567890" + edition, _ = selected_book_fixture.db.edition( + title="Test Book", + identifier_type=identifier_type, + identifier_id=identifier, + with_license_pool=True, + ) + work = selected_book_fixture.db.work( + "Test Book", presentation_edition=edition, with_license_pool=True + ) + selected_book_fixture.manager.select_books.select( + identifier_type, identifier + ) + # And then later the selected books feed is requested + response = selected_book_fixture.manager.select_books.fetch_books() + # The feed contains the selected book + assert response.status_code == 200 + feed = feedparser.parse(response.get_data()) + assert len(feed.entries) == 1 diff --git a/tests/api/test_authenticator.py b/tests/api/test_authenticator.py index f1553f066..8bfc533a4 100644 --- a/tests/api/test_authenticator.py +++ b/tests/api/test_authenticator.py @@ -1180,6 +1180,7 @@ def annotate_authentication_document(library, doc, url_for): reset_link, profile, loans, + selected_books, license, logo, privacy_policy, @@ -1200,6 +1201,10 @@ def annotate_authentication_document(library, doc, url_for): assert "http://opds-spec.org/shelf" == loans["rel"] assert OPDSFeed.ACQUISITION_FEED_TYPE == loans["type"] + assert "/selected_books" in selected_books["href"] + assert "http://opds-spec.org/shelf/selected_books" == selected_books["rel"] + assert OPDSFeed.ACQUISITION_FEED_TYPE == selected_books["type"] + assert "/patrons/me" in profile["href"] assert ProfileController.LINK_RELATION == profile["rel"] assert ProfileController.MEDIA_TYPE == profile["type"] diff --git a/tests/api/test_routes.py b/tests/api/test_routes.py index 3d5197d6a..0053e48d9 100644 --- a/tests/api/test_routes.py +++ b/tests/api/test_routes.py @@ -326,6 +326,54 @@ def test_related_books(self, fixture: RouteTestFixture): ) +class TestSelectedBooksController: + CONTROLLER_NAME = "select_books" + + @pytest.fixture(scope="function") + def fixture(self, route_test: RouteTestFixture) -> RouteTestFixture: + route_test.set_controller_name(self.CONTROLLER_NAME) + return route_test + + def test_select(self, fixture: RouteTestFixture): + url = "/works///select_book" + fixture.assert_authenticated_request_calls( + url, + fixture.controller.select, # type: ignore[union-attr] + "", + "", + http_method="POST", + ) + + def test_unselect(self, fixture: RouteTestFixture): + url = "/works///unselect_book" + fixture.assert_authenticated_request_calls( + url, + fixture.controller.unselect, # type: ignore[union-attr] + "", + "", + http_method="DELETE", + ) + + def test_detail(self, fixture: RouteTestFixture): + url = "/selected_books//" + fixture.assert_request_calls_method_using_identifier( + url, + fixture.controller.detail, # type: ignore[union-attr] + "", + "", + authenticated=True, + ) + fixture.assert_supported_methods(url, "GET", "DELETE") + + def test_selected_books(self, fixture: RouteTestFixture): + url = "/selected_books" + fixture.assert_authenticated_request_calls( + url, + fixture.controller.fetch_books, # type: ignore[union-attr] + ) + fixture.assert_supported_methods(url, "GET") + + class TestAnalyticsController: CONTROLLER_NAME = "analytics_controller" diff --git a/tests/core/models/test_patron.py b/tests/core/models/test_patron.py index d2dcb69f1..fe9a00dd0 100644 --- a/tests/core/models/test_patron.py +++ b/tests/core/models/test_patron.py @@ -9,7 +9,15 @@ from core.model.credential import Credential from core.model.datasource import DataSource from core.model.licensing import PolicyException -from core.model.patron import Annotation, Hold, Loan, Patron, PatronProfileStorage +from core.model.patron import ( + Annotation, + Hold, + Loan, + Patron, + PatronProfileStorage, + SelectedBook, +) +from core.model.work import Work from core.util.datetime_helpers import datetime_utc, utc_now from tests.fixtures.database import DatabaseTransactionFixture from tests.fixtures.library import LibraryFixture @@ -737,6 +745,46 @@ def test_age_appropriate_match(self): work_audience, work_target_age, reader_audience, reader_age ) + def test_selected_books(self, db: DatabaseTransactionFixture): + """Test that a patron can have books added to their selected books list + and removed from it. Also, test that the booklist is cleaned up when + the patron is deleted.""" + + patron = db.patron() + book = db.work() + + # Add the book to the patron's booklist + selected_book = patron.select_book(book) + db.session.commit() + assert selected_book in patron.selected_books + + patron.load_selected_book(book) + db.session.commit() + assert selected_book != None + assert selected_book in patron.selected_books + + # Delete the book from the patron's booklist + patron.unselect_book(book) + db.session.commit() + assert book not in patron.selected_books + + # Delete the patron + db.session.delete(patron) + db.session.commit() + + # Check that the patron's booklist is empty + assert ( + len( + db.session.query(SelectedBook) + .filter(SelectedBook.patron_id == patron.id) + .all() + ) + == 0 + ) + + # Check that the book still exists (just for our sanity) + assert len(db.session.query(Work).all()) == 1 + def mock_url_for(url, **kwargs): item_list = [f"{k}={v}" for k, v in kwargs.items()]