Skip to content

Commit

Permalink
Merge pull request #121 from NatLibFi/EKIRJASTO-131-Selected-Books
Browse files Browse the repository at this point in the history
Ekirjasto 131 selected books
  • Loading branch information
natlibfi-kaisa authored Jan 8, 2025
2 parents ddbdee4 + 312aa7c commit 87843a5
Show file tree
Hide file tree
Showing 18 changed files with 714 additions and 9 deletions.
44 changes: 44 additions & 0 deletions alembic/versions/20241210_d3aaeb6a9e6b_create_selectedbooks.py
Original file line number Diff line number Diff line change
@@ -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 ###
10 changes: 10 additions & 0 deletions api/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions api/circulation_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions api/controller/loan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
)
147 changes: 147 additions & 0 deletions api/controller/select_books.py
Original file line number Diff line number Diff line change
@@ -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
)
7 changes: 4 additions & 3 deletions api/controller/work.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,51 @@ def patron_auth_token():
return app.manager.patron_auth_token.get_token()


@library_dir_route(
"/works/<identifier_type>/<path:identifier>/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/<identifier_type>/<path:identifier>/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/<identifier_type>/<path:identifier>", 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
Expand Down
Loading

0 comments on commit 87843a5

Please sign in to comment.