diff --git a/alembic/versions/20230726_2f1a51aa0ee8_remove_integration_client.py b/alembic/versions/20230726_2f1a51aa0ee8_remove_integration_client.py new file mode 100644 index 0000000000..f13772ba3b --- /dev/null +++ b/alembic/versions/20230726_2f1a51aa0ee8_remove_integration_client.py @@ -0,0 +1,122 @@ +"""Remove integration client + +Revision ID: 2f1a51aa0ee8 +Revises: 892c8e0c89f8 +Create Date: 2023-07-26 13:34:02.924885+00:00 + +""" +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2f1a51aa0ee8" +down_revision = "892c8e0c89f8" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_index("ix_datasources_integration_client_id", table_name="datasources") + op.drop_constraint( + "datasources_integration_client_id_fkey", "datasources", type_="foreignkey" + ) + op.drop_column("datasources", "integration_client_id") + op.drop_index("ix_holds_integration_client_id", table_name="holds") + op.drop_constraint("holds_integration_client_id_fkey", "holds", type_="foreignkey") + op.drop_column("holds", "integration_client_id") + op.drop_index("ix_loans_integration_client_id", table_name="loans") + op.drop_constraint("loans_integration_client_id_fkey", "loans", type_="foreignkey") + op.drop_column("loans", "integration_client_id") + op.drop_index( + "ix_integrationclients_shared_secret", table_name="integrationclients" + ) + op.drop_table("integrationclients") + + +def downgrade() -> None: + op.create_table( + "integrationclients", + sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column("url", sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column("shared_secret", sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column("enabled", sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.Column( + "created", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + ), + sa.Column( + "last_accessed", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + ), + sa.PrimaryKeyConstraint("id", name="integrationclients_pkey"), + sa.UniqueConstraint("url", name="integrationclients_url_key"), + ) + op.create_index( + "ix_integrationclients_shared_secret", + "integrationclients", + ["shared_secret"], + unique=False, + ) + op.add_column( + "loans", + sa.Column( + "integration_client_id", sa.INTEGER(), autoincrement=False, nullable=True + ), + ) + op.create_foreign_key( + "loans_integration_client_id_fkey", + "loans", + "integrationclients", + ["integration_client_id"], + ["id"], + ) + op.create_index( + "ix_loans_integration_client_id", + "loans", + ["integration_client_id"], + unique=False, + ) + op.add_column( + "holds", + sa.Column( + "integration_client_id", sa.INTEGER(), autoincrement=False, nullable=True + ), + ) + op.create_foreign_key( + "holds_integration_client_id_fkey", + "holds", + "integrationclients", + ["integration_client_id"], + ["id"], + ) + op.create_index( + "ix_holds_integration_client_id", + "holds", + ["integration_client_id"], + unique=False, + ) + op.add_column( + "datasources", + sa.Column( + "integration_client_id", sa.INTEGER(), autoincrement=False, nullable=True + ), + ) + op.create_foreign_key( + "datasources_integration_client_id_fkey", + "datasources", + "integrationclients", + ["integration_client_id"], + ["id"], + ) + op.create_index( + "ix_datasources_integration_client_id", + "datasources", + ["integration_client_id"], + unique=False, + ) diff --git a/api/circulation.py b/api/circulation.py index 3eb87d873b..d0e6a4405b 100644 --- a/api/circulation.py +++ b/api/circulation.py @@ -405,14 +405,12 @@ def __init__( end_date, hold_position, external_identifier=None, - integration_client=None, ): super().__init__(collection, data_source_name, identifier_type, identifier) self.start_date = start_date self.end_date = end_date self.hold_position = hold_position self.external_identifier = external_identifier - self.integration_client = integration_client def __repr__(self): return "".format( @@ -1250,7 +1248,7 @@ def borrow( # to needing to put it on hold, but we do check for that case. __transaction = self._db.begin_nested() hold, is_new = licensepool.on_hold_to( - hold_info.integration_client or patron, + patron, hold_info.start_date or now, hold_info.end_date, hold_info.hold_position, diff --git a/api/controller.py b/api/controller.py index 0d67ac77fd..0749b4b3ac 100644 --- a/api/controller.py +++ b/api/controller.py @@ -60,7 +60,6 @@ DeliveryMechanism, Hold, Identifier, - IntegrationClient, Library, LicensePool, LicensePoolDeliveryMechanism, @@ -82,11 +81,10 @@ from core.user_profile import ProfileController as CoreProfileController from core.util.authentication_for_opds import AuthenticationForOPDSDocument from core.util.datetime_helpers import utc_now -from core.util.http import HTTP, RemoteIntegrationException +from core.util.http import RemoteIntegrationException from core.util.log import elapsed_time_logging, log_elapsed_time from core.util.opds_writer import OPDSFeed from core.util.problem_detail import ProblemError -from core.util.string_helpers import base64 from .annotations import AnnotationParser, AnnotationWriter from .authenticator import Authenticator, CirculationPatronProfileStorage @@ -116,11 +114,8 @@ CirculationManagerAnnotator, LibraryAnnotator, LibraryLoanAndHoldAnnotator, - SharedCollectionAnnotator, - SharedCollectionLoanAndHoldAnnotator, ) from .problem_details import * -from .shared_collection import SharedCollectionAPI if TYPE_CHECKING: from werkzeug import Response as wkResponse @@ -190,7 +185,6 @@ class CirculationManager: patron_devices: DeviceTokensController version: ApplicationVersionController odl_notification_controller: ODLNotificationController - shared_collection_controller: SharedCollectionController static_files: StaticFileController playtime_entries: PlaytimeEntriesController @@ -349,7 +343,6 @@ def load_settings(self): self.top_level_lanes = new_top_level_lanes self.circulation_apis = new_circulation_apis self.custom_index_views = new_custom_index_views - self.shared_collection_api = self.setup_shared_collection() # Assemble the list of patron web client domains from individual # library registration settings as well as a sitewide setting. @@ -437,9 +430,6 @@ def setup_circulation(self, library, analytics): """Set up the Circulation object.""" return CirculationAPI(self._db, library, analytics) - def setup_shared_collection(self): - return SharedCollectionAPI(self._db) - def setup_one_time_controllers(self): """Set up all the controllers that will be used by the web app. @@ -459,7 +449,6 @@ def setup_one_time_controllers(self): self.patron_devices = DeviceTokensController(self) self.version = ApplicationVersionController() self.odl_notification_controller = ODLNotificationController(self) - self.shared_collection_controller = SharedCollectionController(self) self.static_files = StaticFileController(self) self.patron_auth_token = PatronAuthTokenController(self) self.playtime_entries = PlaytimeEntriesController(self) @@ -581,11 +570,6 @@ def circulation(self): library_id = flask.request.library.id return self.manager.circulation_apis[library_id] - @property - def shared_collection(self): - """Return the appropriate SharedCollectionAPI for the request library.""" - return self.manager.shared_collection_api - @property def search_engine(self): """Return the configured external search engine, or a @@ -1036,14 +1020,7 @@ def crawlable_collection_feed(self, collection_name): ) lane = CrawlableCollectionBasedLane() lane.initialize([collection]) - if collection.protocol in [ODLAPI.NAME]: - annotator = SharedCollectionAnnotator(collection, lane) - else: - # We'll get a generic CirculationManagerAnnotator. - annotator = None - return self._crawlable_feed( - title=title, url=url, worklist=lane, annotator=annotator - ) + return self._crawlable_feed(title=title, url=url, worklist=lane) def crawlable_list_feed(self, list_name): """Build or retrieve a crawlable, paginated acquisition feed for the @@ -2492,230 +2469,6 @@ def notify(self, loan_id): return Response(_("Success"), 200) -class SharedCollectionController(CirculationManagerController): - """Enable this circulation manager to share its collections with - libraries on other circulation managers, for collection types that - support it.""" - - def info(self, collection_name): - """Return an OPDS2 catalog-like document with a link to register.""" - collection = get_one(self._db, Collection, name=collection_name) - if not collection: - return NO_SUCH_COLLECTION - - register_url = url_for( - "shared_collection_register", - collection_name=collection_name, - _external=True, - ) - register_link = dict(href=register_url, rel="register") - content = json.dumps(dict(links=[register_link])) - headers = dict() - headers[ - "Content-Type" - ] = "application/opds+json;profile=https://librarysimplified.org/rel/profile/directory" - return Response(content, 200, headers) - - def load_collection(self, collection_name): - collection = get_one(self._db, Collection, name=collection_name) - if not collection: - return NO_SUCH_COLLECTION - return collection - - def register(self, collection_name): - collection = self.load_collection(collection_name) - if isinstance(collection, ProblemDetail): - return collection - url = flask.request.form.get("url") - try: - response = self.shared_collection.register(collection, url) - except InvalidInputException as e: - return INVALID_REGISTRATION.detailed(str(e)) - except AuthorizationFailedException as e: - return INVALID_CREDENTIALS.detailed(str(e)) - except RemoteInitiatedServerError as e: - return e.as_problem_detail_document(debug=False) - - return Response(json.dumps(response), 200) - - def authenticated_client_from_request(self): - header = flask.request.headers.get("Authorization") - if header and "bearer" in header.lower(): - shared_secret = base64.b64decode(header.split(" ")[1]) - client = IntegrationClient.authenticate(self._db, shared_secret) - if client: - return client - return INVALID_CREDENTIALS - - def loan_info(self, collection_name, loan_id): - collection = self.load_collection(collection_name) - if isinstance(collection, ProblemDetail): - return collection - client = self.authenticated_client_from_request() - if isinstance(client, ProblemDetail): - return client - loan = get_one(self._db, Loan, id=loan_id, integration_client=client) - if not loan or loan.license_pool.collection != collection: - return LOAN_NOT_FOUND - - return SharedCollectionLoanAndHoldAnnotator.single_item_feed(collection, loan) - - def borrow(self, collection_name, identifier_type, identifier, hold_id): - collection = self.load_collection(collection_name) - if isinstance(collection, ProblemDetail): - return collection - client = self.authenticated_client_from_request() - if isinstance(client, ProblemDetail): - return client - if identifier_type and identifier: - pools = ( - self._db.query(LicensePool) - .join(LicensePool.identifier) - .filter(Identifier.type == identifier_type) - .filter(Identifier.identifier == identifier) - .filter(LicensePool.collection_id == collection.id) - .all() - ) - if not pools: - return NO_LICENSES.detailed( - _("The item you're asking about (%s/%s) isn't in this collection.") - % (identifier_type, identifier) - ) - pool = pools[0] - hold = None - elif hold_id: - hold = get_one(self._db, Hold, id=hold_id) - pool = hold.license_pool - - try: - loan = self.shared_collection.borrow(collection, client, pool, hold) - except AuthorizationFailedException as e: - return INVALID_CREDENTIALS.detailed(str(e)) - except NoAvailableCopies as e: - return NO_AVAILABLE_LICENSE.detailed(str(e)) - except CannotLoan as e: - return CHECKOUT_FAILED.detailed(str(e)) - except RemoteIntegrationException as e: - return e.as_problem_detail_document(debug=False) - if loan: - return SharedCollectionLoanAndHoldAnnotator.single_item_feed( - collection, loan, status=201 - ) - - def revoke_loan(self, collection_name, loan_id): - collection = self.load_collection(collection_name) - if isinstance(collection, ProblemDetail): - return collection - client = self.authenticated_client_from_request() - if isinstance(client, ProblemDetail): - return client - loan = get_one(self._db, Loan, id=loan_id, integration_client=client) - if not loan or not loan.license_pool.collection == collection: - return LOAN_NOT_FOUND - - try: - self.shared_collection.revoke_loan(collection, client, loan) - except AuthorizationFailedException as e: - return INVALID_CREDENTIALS.detailed(str(e)) - except NotCheckedOut as e: - return NO_ACTIVE_LOAN.detailed(str(e)) - except CannotReturn as e: - return COULD_NOT_MIRROR_TO_REMOTE.detailed(str(e)) - return Response(_("Success"), 200) - - def fulfill( - self, collection_name, loan_id, mechanism_id, do_get=HTTP.get_with_timeout - ): - collection = self.load_collection(collection_name) - if isinstance(collection, ProblemDetail): - return collection - client = self.authenticated_client_from_request() - if isinstance(client, ProblemDetail): - return client - loan = get_one(self._db, Loan, id=loan_id) - if not loan or not loan.license_pool.collection == collection: - return LOAN_NOT_FOUND - - mechanism = None - if mechanism_id: - mechanism = self.load_licensepooldelivery(loan.license_pool, mechanism_id) - if isinstance(mechanism, ProblemDetail): - return mechanism - - if not mechanism: - # See if the loan already has a mechanism set. We can use that. - if loan and loan.fulfillment: - mechanism = loan.fulfillment - else: - return BAD_DELIVERY_MECHANISM.detailed( - _("You must specify a delivery mechanism to fulfill this loan.") - ) - - try: - fulfillment = self.shared_collection.fulfill( - collection, client, loan, mechanism - ) - except AuthorizationFailedException as e: - return INVALID_CREDENTIALS.detailed(str(e)) - except CannotFulfill as e: - return CANNOT_FULFILL.detailed(str(e)) - except RemoteIntegrationException as e: - return e.as_problem_detail_document(debug=False) - headers = dict() - content = fulfillment.content - if fulfillment.content_link: - # If we have a link to the content on a remote server, web clients may not - # be able to access it if the remote server does not support CORS requests. - # We need to fetch the content and return it instead of redirecting to it. - try: - response = do_get(fulfillment.content_link) - status_code = response.status_code - headers = dict(response.headers) - content = response.content - except RemoteIntegrationException as e: - return e.as_problem_detail_document(debug=False) - else: - status_code = 200 - if fulfillment.content_type: - headers["Content-Type"] = fulfillment.content_type - - return Response(content, status_code, headers) - - def hold_info(self, collection_name, hold_id): - collection = self.load_collection(collection_name) - if isinstance(collection, ProblemDetail): - return collection - client = self.authenticated_client_from_request() - if isinstance(client, ProblemDetail): - return client - hold = get_one(self._db, Hold, id=hold_id, integration_client=client) - if not hold or not hold.license_pool.collection == collection: - return HOLD_NOT_FOUND - - return SharedCollectionLoanAndHoldAnnotator.single_item_feed(collection, hold) - - def revoke_hold(self, collection_name, hold_id): - collection = self.load_collection(collection_name) - if isinstance(collection, ProblemDetail): - return collection - client = self.authenticated_client_from_request() - if isinstance(client, ProblemDetail): - return client - hold = get_one(self._db, Hold, id=hold_id, integration_client=client) - if not hold or not hold.license_pool.collection == collection: - return HOLD_NOT_FOUND - - try: - self.shared_collection.revoke_hold(collection, client, hold) - except AuthorizationFailedException as e: - return INVALID_CREDENTIALS.detailed(str(e)) - except NotOnHold as e: - return NO_ACTIVE_HOLD.detailed(str(e)) - except CannotReleaseHold as e: - return CANNOT_RELEASE_HOLD.detailed(str(e)) - return Response(_("Success"), 200) - - class StaticFileController(CirculationManagerController): def static_file(self, directory, filename): max_age = ConfigurationSetting.sitewide( diff --git a/api/odl.py b/api/odl.py index bf625e4696..5841166652 100644 --- a/api/odl.py +++ b/api/odl.py @@ -36,6 +36,7 @@ ExternalIntegration, Hold, Hyperlink, + Library, LicensePool, LicensePoolDeliveryMechanism, Loan, @@ -63,7 +64,6 @@ ) from .circulation_exceptions import * from .lcp.hash import Hasher, HasherFactory, HashingAlgorithm -from .shared_collection import BaseSharedCollectionAPI, BaseSharedCollectionSettings class ODLAPIConstants: @@ -72,7 +72,7 @@ class ODLAPIConstants: DEFAULT_ENCRYPTION_ALGORITHM = HashingAlgorithm.SHA256.value -class ODLSettings(BaseSharedCollectionSettings, BaseImporterSettings): +class ODLSettings(BaseImporterSettings): external_account_id: Optional[HttpUrl] = FormField( key=Collection.EXTERNAL_ACCOUNT_ID_KEY, form=ConfigurationFormItem( @@ -165,7 +165,6 @@ class ODLLibrarySettings(BaseCirculationEbookLoanSettings): class ODLAPI( BaseCirculationAPI[ODLSettings, ODLLibrarySettings], - BaseSharedCollectionAPI, HasExternalIntegration, HasLibraryIntegrationConfiguration, ): @@ -330,25 +329,16 @@ def get_license_status_document(self, loan): else: id = loan.license.identifier checkout_id = str(uuid.uuid1()) - if loan.patron: - default_loan_period = self.collection(_db).default_loan_period( - loan.patron.library - ) - else: - # TODO: should integration clients be able to specify their own loan period? - default_loan_period = self.collection(_db).default_loan_period( - loan.integration_client - ) + default_loan_period = self.collection(_db).default_loan_period( + loan.patron.library + ) + expires = utc_now() + datetime.timedelta(days=default_loan_period) # The patron UUID is generated randomly on each loan, so the distributor # doesn't know when multiple loans come from the same patron. patron_id = str(uuid.uuid1()) - if loan.patron: - library_short_name = loan.patron.library.short_name - else: - # If this is for an integration client, choose an arbitrary library. - library_short_name = self.collection(_db).libraries[0].short_name + library_short_name = loan.patron.library.short_name db = Session.object_session(loan) patron = loan.patron @@ -477,8 +467,8 @@ def checkout(self, patron, pin, licensepool, internal_format): external_identifier=loan.external_identifier, ) - def _checkout(self, patron_or_client, licensepool, hold=None): - _db = Session.object_session(patron_or_client) + def _checkout(self, patron: Patron, licensepool, hold=None): + _db = Session.object_session(patron) if not any(l for l in licensepool.licenses if not l.is_inactive): raise NoLicenses() @@ -489,7 +479,7 @@ def _checkout(self, patron_or_client, licensepool, hold=None): if hold: self._update_hold_data(hold) - # If there's a holds queue, the patron or client must have a non-expired hold + # If there's a holds queue, the patron must have a non-expired hold # with position 0 to check out the book. if ( not hold or hold.position > 0 or (hold.end and hold.end < utc_now()) @@ -501,7 +491,7 @@ def _checkout(self, patron_or_client, licensepool, hold=None): license = licensepool.best_available_license() if not license: raise NoAvailableCopies() - loan, ignore = license.loan_to(patron_or_client) + loan, ignore = license.loan_to(patron) doc = self.get_license_status_document(loan) status = doc.get("status") @@ -667,14 +657,13 @@ def _update_hold_data(self, hold: Hold): hold.end, hold.position, ) - library = hold.patron.library if hold.patron_id else None - client = hold.integration_client if hold.integration_client_id else None - self._update_hold_end_date(holdinfo, pool, library=library, client=client) + library = hold.patron.library + self._update_hold_end_date(holdinfo, pool, library=library) hold.end = holdinfo.end_date hold.position = holdinfo.hold_position def _update_hold_end_date( - self, holdinfo: HoldInfo, pool: LicensePool, client=None, library=None + self, holdinfo: HoldInfo, pool: LicensePool, library: Library ): _db = Session.object_session(pool) @@ -683,9 +672,7 @@ def _update_hold_end_date( original_position = holdinfo.hold_position self._update_hold_position(holdinfo, pool) - default_loan_period = self.collection(_db).default_loan_period( - library or client - ) + default_loan_period = self.collection(_db).default_loan_period(library) default_reservation_period = self.collection(_db).default_reservation_period # If the hold was already to check out and already has an end date, @@ -801,8 +788,8 @@ def place_hold(self, patron, pin, licensepool, notification_email_address): """Create a new hold.""" return self._place_hold(patron, licensepool) - def _place_hold(self, patron_or_client, licensepool): - _db = Session.object_session(patron_or_client) + def _place_hold(self, patron, licensepool): + _db = Session.object_session(patron) # Make sure pool info is updated. self.update_licensepool(licensepool) @@ -810,18 +797,11 @@ def _place_hold(self, patron_or_client, licensepool): if licensepool.licenses_available > 0: raise CurrentlyAvailable() - patron_id, client_id = None, None - if isinstance(patron_or_client, Patron): - patron_id = patron_or_client.id - else: - client_id = patron_or_client.id - # Check for local hold hold = get_one( _db, Hold, - patron_id=patron_id, - integration_client_id=client_id, + patron_id=patron.id, license_pool_id=licensepool.id, ) @@ -838,14 +818,8 @@ def _place_hold(self, patron_or_client, licensepool): 0, 0, ) - client = patron_or_client if client_id else None - library = patron_or_client.library if patron_id else None - self._update_hold_end_date( - holdinfo, licensepool, library=library, client=client - ) - - if client is not None: - holdinfo.integration_client = client + library = patron.library + self._update_hold_end_date(holdinfo, licensepool, library=library) return holdinfo @@ -961,21 +935,6 @@ def update_loan(self, loan, status_doc=None): _db.delete(loan) self.update_licensepool(loan.license_pool) - def checkout_to_external_library(self, client, licensepool, hold=None): - try: - return self._checkout(client, licensepool, hold) - except NoAvailableCopies as e: - return self._place_hold(client, licensepool) - - def checkin_from_external_library(self, client, loan): - self._checkin(loan) - - def fulfill_for_external_library(self, client, loan, mechanism): - return self._fulfill(loan) - - def release_hold_from_external_library(self, client, hold): - return self._release_hold(hold) - def update_availability(self, licensepool): pass diff --git a/api/odl2.py b/api/odl2.py index ff7f53bf01..fbf32a14d9 100644 --- a/api/odl2.py +++ b/api/odl2.py @@ -80,31 +80,31 @@ def __init__(self, _db, collection): self.loan_limit = config.loan_limit self.hold_limit = config.hold_limit - def _checkout(self, patron_or_client: Patron, licensepool, hold=None): + def _checkout(self, patron: Patron, licensepool, hold=None): # If the loan limit is not None or 0 if self.loan_limit: loans = list( filter( lambda x: x.license_pool.collection.id == self.collection_id, - patron_or_client.loans, + patron.loans, ) ) if len(loans) >= self.loan_limit: raise PatronLoanLimitReached(limit=self.loan_limit) - return super()._checkout(patron_or_client, licensepool, hold) + return super()._checkout(patron, licensepool, hold) - def _place_hold(self, patron_or_client: Patron, licensepool): + def _place_hold(self, patron: Patron, licensepool): # If the hold limit is not None or 0 if self.hold_limit: holds = list( filter( lambda x: x.license_pool.collection.id == self.collection_id, - patron_or_client.holds, + patron.holds, ) ) if len(holds) >= self.hold_limit: raise PatronHoldLimitReached(limit=self.hold_limit) - return super()._place_hold(patron_or_client, licensepool) + return super()._place_hold(patron, licensepool) class ODL2Importer(OPDS2Importer, HasExternalIntegration): diff --git a/api/opds.py b/api/opds.py index 105652df6c..1209ea139c 100644 --- a/api/opds.py +++ b/api/opds.py @@ -1410,197 +1410,6 @@ def active_licensepool_for(self, work): return super().active_licensepool_for(work, library=self.library) -class SharedCollectionAnnotator(CirculationManagerAnnotator): - def __init__( - self, - collection, - lane, - active_loans_by_work={}, - active_holds_by_work={}, - active_fulfillments_by_work={}, - test_mode=False, - ): - super().__init__( - lane, - active_loans_by_work=active_loans_by_work, - active_holds_by_work=active_holds_by_work, - active_fulfillments_by_work=active_fulfillments_by_work, - test_mode=test_mode, - ) - self.collection = collection - - def top_level_title(self): - return self.collection.name - - def default_lane_url(self): - return self.feed_url(None, default_route="crawlable_collection_feed") - - def lane_url(self, lane): - return self.feed_url(lane, default_route="crawlable_collection_feed") - - def feed_url(self, lane, facets=None, pagination=None, default_route="feed"): - extra_kwargs = dict(collection_name=self.collection.name) - return super().feed_url(lane, facets, pagination, default_route, extra_kwargs) - - def acquisition_links( - self, - active_license_pool, - active_loan, - active_hold, - active_fulfillment, - feed, - identifier, - ): - """Generate a number of tags that enumerate all acquisition methods.""" - links = super().acquisition_links( - active_license_pool, - active_loan, - active_hold, - active_fulfillment, - feed, - identifier, - ) - - info_links = [] - if active_loan: - url = self.url_for( - "shared_collection_loan_info", - collection_name=self.collection.name, - loan_id=active_loan.id, - _external=True, - ) - kw = dict(href=url, rel="self") - info_link_tag = OPDSFeed.makeelement("link", **kw) - info_links.append(info_link_tag) - - if active_hold and active_hold: - url = self.url_for( - "shared_collection_hold_info", - collection_name=self.collection.name, - hold_id=active_hold.id, - _external=True, - ) - kw = dict(href=url, rel="self") - info_link_tag = OPDSFeed.makeelement("link", **kw) - info_links.append(info_link_tag) - return links + info_links - - def revoke_link(self, active_license_pool, active_loan, active_hold): - url = None - if active_loan: - url = self.url_for( - "shared_collection_revoke_loan", - collection_name=self.collection.name, - loan_id=active_loan.id, - _external=True, - ) - elif active_hold: - url = self.url_for( - "shared_collection_revoke_hold", - collection_name=self.collection.name, - hold_id=active_hold.id, - _external=True, - ) - - if url: - kw = dict(href=url, rel=OPDSFeed.REVOKE_LOAN_REL) - revoke_link_tag = OPDSFeed.makeelement("link", **kw) - return revoke_link_tag - - def borrow_link( - self, - active_license_pool, - borrow_mechanism, - fulfillment_mechanisms, - active_hold=None, - ): - if active_license_pool.open_access: - # No need to borrow from a shared collection when the book - # already has an open access link. - return None - - identifier = active_license_pool.identifier - if active_hold: - borrow_url = self.url_for( - "shared_collection_borrow", - collection_name=self.collection.name, - hold_id=active_hold.id, - _external=True, - ) - else: - borrow_url = self.url_for( - "shared_collection_borrow", - collection_name=self.collection.name, - identifier_type=identifier.type, - identifier=identifier.identifier, - _external=True, - ) - rel = OPDSFeed.BORROW_REL - borrow_link = AcquisitionFeed.link( - rel=rel, href=borrow_url, type=OPDSFeed.ENTRY_TYPE - ) - - indirect_acquisitions = [] - for lpdm in fulfillment_mechanisms: - # We have information about one or more delivery - # mechanisms that will be available at the point of - # fulfillment. To the extent possible, put information - # about these mechanisms into the tag as - # tags. - - # These are the formats mentioned in the indirect - # acquisition. - format_types = AcquisitionFeed.format_types(lpdm.delivery_mechanism) - - # If we can borrow this book, add this delivery mechanism - # to the borrow link as an . - if format_types: - indirect_acquisition = AcquisitionFeed.indirect_acquisition( - format_types - ) - indirect_acquisitions.append(indirect_acquisition) - - if not indirect_acquisitions: - # If there's no way to actually get the book, cancel the creation - # of an OPDS entry altogether. - raise UnfulfillableWork() - - borrow_link.extend(indirect_acquisitions) - return borrow_link - - def fulfill_link( - self, - license_pool, - active_loan, - delivery_mechanism, - rel=OPDSFeed.ACQUISITION_REL, - ): - """Create a new fulfillment link.""" - if isinstance(delivery_mechanism, LicensePoolDeliveryMechanism): - logging.warn( - "LicensePoolDeliveryMechanism passed into fulfill_link instead of DeliveryMechanism!" - ) - delivery_mechanism = delivery_mechanism.delivery_mechanism - format_types = AcquisitionFeed.format_types(delivery_mechanism) - if not format_types: - return None - - fulfill_url = self.url_for( - "shared_collection_fulfill", - collection_name=license_pool.collection.name, - loan_id=active_loan.id, - mechanism_id=delivery_mechanism.id, - _external=True, - ) - link_tag = AcquisitionFeed.acquisition_link( - rel=rel, href=fulfill_url, types=format_types, active_loan=active_loan - ) - - children = AcquisitionFeed.license_tags(license_pool, active_loan, None) - link_tag.extend(children) - return link_tag - - class LibraryLoanAndHoldAnnotator(LibraryAnnotator): @classmethod def active_loans_for(cls, circulation, patron, test_mode=False, **response_kwargs): @@ -1844,58 +1653,3 @@ def annotate_work_entry( _external=True, ), ) - - -class SharedCollectionLoanAndHoldAnnotator(SharedCollectionAnnotator): - @classmethod - def single_item_feed( - cls, - collection, - item, - fulfillment=None, - test_mode=False, - feed_class=AcquisitionFeed, - **response_kwargs, - ): - """Create an OPDS entry representing a single loan or hold. - - TODO: This and LibraryLoanAndHoldAnnotator.single_item_feed - can potentially be refactored. The main obstacle is different - routes and arguments for 'loan info' and 'hold info'. - - :return: An OPDSEntryResponse - - """ - _db = Session.object_session(item) - license_pool = item.license_pool - work = license_pool.work or license_pool.presentation_edition.work - identifier = license_pool.identifier - - active_loans_by_work = {} - active_holds_by_work = {} - active_fulfillments_by_work = {} - if fulfillment: - active_fulfillments_by_work[work] = fulfillment - if isinstance(item, Loan): - d = active_loans_by_work - route = "shared_collection_loan_info" - route_kwargs = dict(loan_id=item.id) - elif isinstance(item, Hold): - d = active_holds_by_work - route = "shared_collection_hold_info" - route_kwargs = dict(hold_id=item.id) - d[work] = item - annotator = cls( - collection, - None, - active_loans_by_work=active_loans_by_work, - active_holds_by_work=active_holds_by_work, - active_fulfillments_by_work=active_fulfillments_by_work, - test_mode=test_mode, - ) - url = annotator.url_for( - route, collection_name=collection.name, _external=True, **route_kwargs - ) - return annotator._single_entry_response( - _db, work, annotator, url, feed_class, **response_kwargs - ) diff --git a/api/routes.py b/api/routes.py index 9c27ce1873..840bd4812a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -377,75 +377,6 @@ def opds2_navigation(): return app.manager.opds2_feeds.navigation() -@app.route("/collections/") -@returns_problem_detail -def shared_collection_info(collection_name): - return app.manager.shared_collection_controller.info(collection_name) - - -@app.route("/collections//register", methods=["POST"]) -@returns_problem_detail -def shared_collection_register(collection_name): - return app.manager.shared_collection_controller.register(collection_name) - - -@app.route( - "/collections////borrow", - methods=["GET", "POST"], - defaults=dict(hold_id=None), -) -@app.route( - "/collections//holds//borrow", - methods=["GET", "POST"], - defaults=dict(identifier_type=None, identifier=None), -) -@returns_problem_detail -def shared_collection_borrow(collection_name, identifier_type, identifier, hold_id): - return app.manager.shared_collection_controller.borrow( - collection_name, identifier_type, identifier, hold_id - ) - - -@app.route("/collections//loans/") -@returns_problem_detail -def shared_collection_loan_info(collection_name, loan_id): - return app.manager.shared_collection_controller.loan_info(collection_name, loan_id) - - -@app.route("/collections//loans//revoke") -@returns_problem_detail -def shared_collection_revoke_loan(collection_name, loan_id): - return app.manager.shared_collection_controller.revoke_loan( - collection_name, loan_id - ) - - -@app.route( - "/collections//loans//fulfill", - defaults=dict(mechanism_id=None), -) -@app.route("/collections//loans//fulfill/") -@returns_problem_detail -def shared_collection_fulfill(collection_name, loan_id, mechanism_id): - return app.manager.shared_collection_controller.fulfill( - collection_name, loan_id, mechanism_id - ) - - -@app.route("/collections//holds/") -@returns_problem_detail -def shared_collection_hold_info(collection_name, hold_id): - return app.manager.shared_collection_controller.hold_info(collection_name, hold_id) - - -@app.route("/collections//holds//revoke") -@returns_problem_detail -def shared_collection_revoke_hold(collection_name, hold_id): - return app.manager.shared_collection_controller.revoke_hold( - collection_name, hold_id - ) - - @library_route("/marc") @has_library @returns_problem_detail diff --git a/api/shared_collection.py b/api/shared_collection.py deleted file mode 100644 index 878b1d9301..0000000000 --- a/api/shared_collection.py +++ /dev/null @@ -1,265 +0,0 @@ -import base64 -import json -import logging -from typing import List - -from flask_babel import lazy_gettext as _ -from pydantic import HttpUrl, PositiveInt - -from core.config import CannotLoadConfiguration -from core.integration.settings import ( - BaseSettings, - ConfigurationFormItem, - ConfigurationFormItemType, - FormField, -) -from core.model import Collection, IntegrationClient, get_one -from core.util.http import HTTP - -from .circulation_exceptions import * -from .config import Configuration - - -class SharedCollectionAPI: - """Logic for circulating books to patrons of libraries on other - circulation managers. This can be used for something like ODL where the - circulation manager is responsible for managing loans and holds rather - than the distributor, or potentially for inter-library loans for other - collection types. - """ - - def __init__(self, _db, api_map=None): - """Constructor. - - :param _db: A database session (probably a scoped session). - - :param api_map: A dictionary mapping Collection protocols to - API classes that should be instantiated to deal with these - protocols. The default map will work fine unless you're a - unit test. - - Since instantiating these API classes may result in API - calls, we only instantiate one SharedCollectionAPI, - and keep it around as long as possible. - """ - # TODO: Should there be analytics events for external libraries? - self._db = _db - api_map = api_map or self.default_api_map - - self.api_for_collection = {} - self.initialization_exceptions = {} - - self.log = logging.getLogger("Shared Collection API") - for collection in _db.query(Collection): - if collection.protocol in api_map: - api = None - try: - api = api_map[collection.protocol](_db, collection) - except CannotLoadConfiguration as e: - self.log.error( - "Error loading configuration for %s: %s", - collection.name, - str(e), - ) - self.initialization_exceptions[collection.id] = e - if api: - self.api_for_collection[collection.id] = api - - @property - def default_api_map(self): - """When you see a Collection that implements protocol X, instantiate - API class Y to handle that collection. - """ - from .odl import ODLAPI - - return { - ODLAPI.NAME: ODLAPI, - } - - def api_for_licensepool(self, pool): - """Find the API to use for the given license pool.""" - return self.api_for_collection.get(pool.collection.id) - - def api(self, collection): - """Find the API to use for the given collection, and raise an exception - if there isn't one.""" - api = self.api_for_collection.get(collection.id) - if not api: - raise CirculationException( - _( - "Collection %(collection)s is not a shared collection.", - collection=collection.name, - ) - ) - return api - - def register(self, collection, auth_document_url, do_get=HTTP.get_with_timeout): - """Register a library on an external circulation manager for access to this - collection. The library's auth document url must be whitelisted in the - collection's settings.""" - if not auth_document_url: - raise InvalidInputException( - _("An authentication document URL is required to register a library.") - ) - - auth_response = do_get(auth_document_url, allowed_response_codes=["2xx", "3xx"]) - try: - auth_document = json.loads(auth_response.content) - except ValueError as e: - raise RemoteInitiatedServerError( - _( - "Authentication document at %(auth_document_url)s was not valid JSON.", - auth_document_url=auth_document_url, - ), - _("Remote authentication document"), - ) - - links = auth_document.get("links") - start_url = None - for link in links: - if link.get("rel") == "start": - start_url = link.get("href") - break - - if not start_url: - raise RemoteInitiatedServerError( - _( - "Authentication document at %(auth_document_url)s did not contain a start link.", - auth_document_url=auth_document_url, - ), - _("Remote authentication document"), - ) - - external_library_urls = ( - collection.integration_configuration.settings_dict.get( - BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS - ) - or [] - ) - - if start_url not in external_library_urls: - raise AuthorizationFailedException( - _( - "Your library's URL is not one of the allowed URLs for this collection. Ask the collection administrator to add %(library_url)s to the list of allowed URLs.", - library_url=start_url, - ) - ) - - public_key = auth_document.get("public_key") - if ( - not public_key - or not public_key.get("type") == "RSA" - or not public_key.get("value") - ): - raise RemoteInitiatedServerError( - _( - "Authentication document at %(auth_document_url)s did not contain an RSA public key.", - auth_document_url=auth_document_url, - ), - _("Remote authentication document"), - ) - - public_key = public_key.get("value") - encryptor = Configuration.cipher(public_key) - - normalized_url = IntegrationClient.normalize_url(start_url) - client = get_one(self._db, IntegrationClient, url=normalized_url) - if not client: - client, ignore = IntegrationClient.register(self._db, start_url) - - shared_secret = client.shared_secret.encode("utf-8") - encrypted_secret = encryptor.encrypt(shared_secret) - return dict(metadata=dict(shared_secret=base64.b64encode(encrypted_secret))) - - def check_client_authorization(self, collection, client): - """Verify that an IntegrationClient is whitelisted for access to the collection.""" - external_library_urls = collection.integration_configuration.settings_dict.get( - BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS, [] - ) - if client.url not in [ - IntegrationClient.normalize_url(url) for url in external_library_urls - ]: - raise AuthorizationFailedException() - - def borrow(self, collection, client, pool, hold=None): - api = self.api(collection) - self.check_client_authorization(collection, client) - if hold and hold.integration_client != client: - raise CannotLoan(_("This hold belongs to a different library.")) - return api.checkout_to_external_library(client, pool, hold=hold) - - def revoke_loan(self, collection, client, loan): - api = self.api(collection) - self.check_client_authorization(collection, client) - if loan.integration_client != client: - raise NotCheckedOut(_("This loan belongs to a different library.")) - return api.checkin_from_external_library(client, loan) - - def fulfill(self, collection, client, loan, mechanism): - api = self.api(collection) - self.check_client_authorization(collection, client) - - if loan.integration_client != client: - raise CannotFulfill(_("This loan belongs to a different library.")) - - fulfillment = api.fulfill_for_external_library(client, loan, mechanism) - if not fulfillment or not (fulfillment.content_link or fulfillment.content): - raise CannotFulfill() - - if loan.fulfillment is None and not mechanism.delivery_mechanism.is_streaming: - __transaction = self._db.begin_nested() - loan.fulfillment = mechanism - __transaction.commit() - return fulfillment - - def revoke_hold(self, collection, client, hold): - api = self.api(collection) - self.check_client_authorization(collection, client) - if hold and hold.integration_client != client: - raise CannotReleaseHold(_("This hold belongs to a different library.")) - return api.release_hold_from_external_library(client, hold) - - -class BaseSharedCollectionSettings(BaseSettings): - external_library_urls: Optional[List[HttpUrl]] = FormField( - form=ConfigurationFormItem( - label=_( - "URLs for libraries on other circulation managers that use this collection" - ), - description=_( - "A URL should include the library's short name (e.g. https://circulation.librarysimplified.org/NYNYPL/), even if it is the only library on the circulation manager." - ), - type=ConfigurationFormItemType.LIST, - ) - ) - ebook_loan_duration: Optional[PositiveInt] = FormField( - default=Collection.STANDARD_DEFAULT_LOAN_PERIOD, - form=ConfigurationFormItem( - label=_( - "Ebook Loan Duration for libraries on other circulation managers (in Days)" - ), - description=_( - "When a patron from another library borrows an ebook from this collection, the circulation manager will ask for a loan that lasts this number of days. This must be equal to or less than the maximum loan duration negotiated with the distributor." - ), - type=ConfigurationFormItemType.NUMBER, - ), - ) - - -class BaseSharedCollectionAPI: - """APIs that permit external circulation managers to access their collections - should extend this class.""" - - EXTERNAL_LIBRARY_URLS = "external_library_urls" - - def checkout_to_external_library(self, client, pool, hold=None): - raise NotImplementedError() - - def checkin_from_external_library(self, client, loan): - raise NotImplementedError() - - def fulfill_for_external_library(self, client, loan, mechanism): - raise NotImplementedError() - - def release_hold_from_external_library(self, client, hold): - raise NotImplementedError() diff --git a/core/metadata_layer.py b/core/metadata_layer.py index 022d407b91..15c4ef54e4 100644 --- a/core/metadata_layer.py +++ b/core/metadata_layer.py @@ -1994,10 +1994,8 @@ def _key(classification): DataSource.BIBLIOTHECA, DataSource.AXIS_360, ] - if ( - work_requires_new_presentation_edition - and (not data_source.integration_client) - and (data_source.name not in METADATA_UPLOAD_BLACKLIST) + if work_requires_new_presentation_edition and ( + data_source.name not in METADATA_UPLOAD_BLACKLIST ): # Create a transient failure CoverageRecord for this edition # so it will be processed by the MetadataUploadCoverageProvider. diff --git a/core/model/__init__.py b/core/model/__init__.py index 44bc971aeb..f709131633 100644 --- a/core/model/__init__.py +++ b/core/model/__init__.py @@ -562,7 +562,6 @@ def _bulk_operation(self): from .hassessioncache import HasSessionCache from .identifier import Equivalency, Identifier from .integration import IntegrationConfiguration, IntegrationLibraryConfiguration -from .integrationclient import IntegrationClient from .library import Library from .licensing import ( DeliveryMechanism, diff --git a/core/model/collection.py b/core/model/collection.py index 6a367720be..8a5f5a0640 100644 --- a/core/model/collection.py +++ b/core/model/collection.py @@ -44,7 +44,6 @@ from .edition import Edition from .hassessioncache import HasSessionCache from .identifier import Identifier -from .integrationclient import IntegrationClient from .library import Library from .licensing import LicensePool, LicensePoolDeliveryMechanism from .work import Work @@ -348,10 +347,7 @@ def default_loan_period_setting( collection has it for this number of days. """ key = self.loan_period_key(medium) - if isinstance(library, Library): - config = self.integration_configuration.for_library(library.id) - elif isinstance(library, IntegrationClient): - config = self.integration_configuration + config = self.integration_configuration.for_library(library.id) if config: return config.settings_dict.get(key) diff --git a/core/model/datasource.py b/core/model/datasource.py index 13a178363c..d4bf6ecc5b 100644 --- a/core/model/datasource.py +++ b/core/model/datasource.py @@ -5,10 +5,10 @@ from typing import TYPE_CHECKING, Dict, List from urllib.parse import quote, unquote -from sqlalchemy import Boolean, Column, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, Integer, String from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.ext.mutable import MutableDict -from sqlalchemy.orm import Mapped, backref, relationship +from sqlalchemy.orm import Mapped, relationship from . import Base, get_one, get_one_or_create from .constants import DataSourceConstants, IdentifierConstants @@ -27,7 +27,6 @@ Edition, Equivalency, Hyperlink, - IntegrationClient, LicensePool, Measurement, Resource, @@ -45,18 +44,6 @@ class DataSource(Base, HasSessionCache, DataSourceConstants): primary_identifier_type = Column(String, index=True) extra: Mapped[Dict[str, str]] = Column(MutableDict.as_mutable(JSON), default={}) - # One DataSource can have one IntegrationClient. - integration_client_id = Column( - Integer, - ForeignKey("integrationclients.id"), - unique=True, - index=True, - nullable=True, - ) - integration_client: Mapped[IntegrationClient] = relationship( - "IntegrationClient", backref=backref("data_source", uselist=False) - ) - # One DataSource can generate many Editions. editions: Mapped[List[Edition]] = relationship( "Edition", back_populates="data_source", uselist=True diff --git a/core/model/integrationclient.py b/core/model/integrationclient.py deleted file mode 100644 index fdd7197939..0000000000 --- a/core/model/integrationclient.py +++ /dev/null @@ -1,103 +0,0 @@ -# IntegrationClient -from __future__ import annotations - -import re -from typing import TYPE_CHECKING, List - -from sqlalchemy import Boolean, Column, DateTime, Integer, Unicode -from sqlalchemy.orm import Mapped, relationship - -from ..util.datetime_helpers import utc_now -from ..util.string_helpers import random_string -from . import Base, get_one, get_one_or_create - -if TYPE_CHECKING: - from core.model import Hold, Loan # noqa: autoflake - - -class IntegrationClient(Base): - """A client that has authenticated access to this application. - - Currently used to represent circulation managers that have access - to the metadata wrangler. - """ - - __tablename__ = "integrationclients" - - id = Column(Integer, primary_key=True) - - # URL (or human readable name) to represent the server. - url = Column(Unicode, unique=True) - - # Shared secret - shared_secret = Column(Unicode, unique=True, index=True) - - # It may be necessary to disable an integration client until it - # upgrades to fix a known bug. - enabled = Column(Boolean, default=True) - - created = Column(DateTime(timezone=True)) - last_accessed = Column(DateTime(timezone=True)) - - loans: Mapped[List[Loan]] = relationship("Loan", backref="integration_client") - holds: Mapped[List[Hold]] = relationship( - "Hold", back_populates="integration_client" - ) - - def __repr__(self): - return f"" - - @classmethod - def for_url(cls, _db, url): - """Finds the IntegrationClient for the given server URL. - - :return: an IntegrationClient. If it didn't already exist, - it will be created. If it didn't already have a secret, no - secret will be set. - """ - url = cls.normalize_url(url) - now = utc_now() - client, is_new = get_one_or_create( - _db, cls, url=url, create_method_kwargs=dict(created=now) - ) - client.last_accessed = now - return client, is_new - - @classmethod - def register(cls, _db, url, submitted_secret=None): - """Creates a new server with client details.""" - client, is_new = cls.for_url(_db, url) - - if not is_new and ( - not submitted_secret or submitted_secret != client.shared_secret - ): - raise ValueError( - "Cannot update existing IntegratedClient without valid shared_secret" - ) - - generate_secret = (client.shared_secret is None) or submitted_secret - if generate_secret: - client.randomize_secret() - - return client, is_new - - @classmethod - def normalize_url(cls, url): - url = re.sub(r"^(http://|https://)", "", url) - url = re.sub(r"^www\.", "", url) - if url.endswith("/"): - url = url[:-1] - return str(url.lower()) - - @classmethod - def authenticate(cls, _db, shared_secret): - client = get_one(_db, cls, shared_secret=str(shared_secret)) - if client: - client.last_accessed = utc_now() - # Committing immediately reduces the risk of contention. - _db.commit() - return client - return None - - def randomize_secret(self): - self.shared_secret = random_string(24) diff --git a/core/model/licensing.py b/core/model/licensing.py index 783ccede46..38b1cee650 100644 --- a/core/model/licensing.py +++ b/core/model/licensing.py @@ -17,7 +17,7 @@ from core.model.hybrid import hybrid_property from ..util.datetime_helpers import utc_now -from . import Base, create, flush, get_one, get_one_or_create +from . import Base, flush, get_one, get_one_or_create from .circulationevent import CirculationEvent from .constants import DataSourceConstants, EditionConstants, LinkRelations, MediaTypes from .hassessioncache import HasSessionCache @@ -133,6 +133,9 @@ class License(Base, LicenseFunctions): # A License belongs to one LicensePool. license_pool_id = Column(Integer, ForeignKey("licensepools.id"), index=True) + license_pool: Mapped[LicensePool] = relationship( + "LicensePool", back_populates="licenses" + ) # One License can have many Loans. loans: Mapped[List[Loan]] = relationship( @@ -141,8 +144,8 @@ class License(Base, LicenseFunctions): __table_args__ = (UniqueConstraint("identifier", "license_pool_id"),) - def loan_to(self, patron_or_client, **kwargs): - loan, is_new = self.license_pool.loan_to(patron_or_client, **kwargs) + def loan_to(self, patron: Patron, **kwargs): + loan, is_new = self.license_pool.loan_to(patron, **kwargs) loan.license = self return loan, is_new @@ -211,7 +214,10 @@ class LicensePool(Base): # If the source provides information about individual licenses, the # LicensePool may have many Licenses. licenses: Mapped[List[License]] = relationship( - "License", backref="license_pool", cascade="all, delete-orphan", uselist=True + "License", + back_populates="license_pool", + cascade="all, delete-orphan", + uselist=True, ) # One LicensePool can have many Loans. @@ -987,38 +993,28 @@ def _part(message, args, string, old_value, new_value): def loan_to( self, - patron_or_client, + patron: Patron, start=None, end=None, fulfillment=None, external_identifier=None, ): - _db = Session.object_session(patron_or_client) + _db = Session.object_session(patron) kwargs = dict(start=start or utc_now(), end=end) - if isinstance(patron_or_client, Patron): - loan, is_new = get_one_or_create( - _db, - Loan, - patron=patron_or_client, - license_pool=self, - create_method_kwargs=kwargs, - ) + loan, is_new = get_one_or_create( + _db, + Loan, + patron=patron, + license_pool=self, + create_method_kwargs=kwargs, + ) + + if is_new: + # This action creates uncertainty about what the patron's + # loan activity actually is. We'll need to sync with the + # vendor APIs. + patron.last_loan_activity_sync = None - if is_new: - # This action creates uncertainty about what the patron's - # loan activity actually is. We'll need to sync with the - # vendor APIs. - patron_or_client.last_loan_activity_sync = None - else: - # An IntegrationClient can have multiple loans, so this always creates - # a new loan rather than returning an existing loan. - loan, is_new = create( - _db, - Loan, - integration_client=patron_or_client, - license_pool=self, - create_method_kwargs=kwargs, - ) if fulfillment: loan.fulfillment = fulfillment if external_identifier: @@ -1027,34 +1023,22 @@ def loan_to( def on_hold_to( self, - patron_or_client, + patron: Patron, start=None, end=None, position=None, external_identifier=None, ): - _db = Session.object_session(patron_or_client) - if ( - isinstance(patron_or_client, Patron) - and not patron_or_client.library.settings.allow_holds - ): + _db = Session.object_session(patron) + if not patron.library.settings.allow_holds: raise PolicyException("Holds are disabled for this library.") start = start or utc_now() - if isinstance(patron_or_client, Patron): - hold, new = get_one_or_create( - _db, Hold, patron=patron_or_client, license_pool=self - ) - # This action creates uncertainty about what the patron's - # loan activity actually is. We'll need to sync with the - # vendor APIs. - if new: - patron_or_client.last_loan_activity_sync = None - else: - # An IntegrationClient can have multiple holds, so this always creates - # a new hold rather than returning an existing loan. - hold, new = create( - _db, Hold, integration_client=patron_or_client, license_pool=self - ) + hold, new = get_one_or_create(_db, Hold, patron=patron, license_pool=self) + # This action creates uncertainty about what the patron's + # loan activity actually is. We'll need to sync with the + # vendor APIs. + if new: + patron.last_loan_activity_sync = None hold.update(start, end, position) if external_identifier: hold.external_identifier = external_identifier diff --git a/core/model/patron.py b/core/model/patron.py index 84b0b71fe1..35b3cec612 100644 --- a/core/model/patron.py +++ b/core/model/patron.py @@ -31,12 +31,8 @@ from .credential import Credential if TYPE_CHECKING: - from core.model import IntegrationClient # noqa: autoflake - from core.model.library import Library # noqa: autoflake - from core.model.licensing import ( # noqa: autoflake - LicensePool, - LicensePoolDeliveryMechanism, - ) + from core.model.library import Library + from core.model.licensing import LicensePool, LicensePoolDeliveryMechanism from .devicetokens import DeviceToken @@ -539,11 +535,6 @@ class Loan(Base, LoanAndHoldMixin): patron_id = Column(Integer, ForeignKey("patrons.id"), index=True) patron: Patron # typing - integration_client_id = Column( - Integer, ForeignKey("integrationclients.id"), index=True - ) - integration_client: IntegrationClient - # A Loan is always associated with a LicensePool. license_pool_id = Column(Integer, ForeignKey("licensepools.id"), index=True) license_pool: Mapped[LicensePool] = relationship( @@ -584,9 +575,6 @@ class Hold(Base, LoanAndHoldMixin): __tablename__ = "holds" id = Column(Integer, primary_key=True) patron_id = Column(Integer, ForeignKey("patrons.id"), index=True) - integration_client_id = Column( - Integer, ForeignKey("integrationclients.id"), index=True - ) license_pool_id = Column(Integer, ForeignKey("licensepools.id"), index=True) license_pool: Mapped[LicensePool] = relationship( "LicensePool", back_populates="holds" @@ -599,9 +587,6 @@ class Hold(Base, LoanAndHoldMixin): patron: Mapped[Patron] = relationship( "Patron", back_populates="holds", lazy="joined" ) - integration_client: Mapped[IntegrationClient] = relationship( - "IntegrationClient", back_populates="holds", lazy="joined" - ) def __lt__(self, other): return self.id < other.id diff --git a/core/opds.py b/core/opds.py index 8089c8f299..962eaa2c8d 100644 --- a/core/opds.py +++ b/core/opds.py @@ -1772,7 +1772,7 @@ def license_tags(cls, license_pool, loan, hold): elif hold: obj = hold default_loan_period = datetime.timedelta( - collection.default_loan_period(obj.library or obj.integration_client) + collection.default_loan_period(obj.library) ) if loan: status = "available" diff --git a/tests/api/lcp/test_controller.py b/tests/api/lcp/test_controller.py index b557209cbe..6bf2b7d3b6 100644 --- a/tests/api/lcp/test_controller.py +++ b/tests/api/lcp/test_controller.py @@ -12,16 +12,11 @@ from core.model import ExternalIntegration from core.model.library import Library from tests.api.lcp import lcp_strings -from tests.api.mockapi.circulation import ( - MockCirculationAPI, - MockCirculationManager, - MockSharedCollectionAPI, -) +from tests.api.mockapi.circulation import MockCirculationAPI, MockCirculationManager from tests.fixtures.api_controller import ControllerFixture manager_api_cls = dict( circulationapi_cls=MockCirculationAPI, - sharedcollectionapi_cls=MockSharedCollectionAPI, externalsearch_cls=MockExternalSearchIndex, ) diff --git a/tests/api/mockapi/circulation.py b/tests/api/mockapi/circulation.py index 0f77c98c04..bf0863a2a2 100644 --- a/tests/api/mockapi/circulation.py +++ b/tests/api/mockapi/circulation.py @@ -4,7 +4,6 @@ from api.circulation import BaseCirculationAPI, CirculationAPI, HoldInfo, LoanInfo from api.controller import CirculationManager -from api.shared_collection import SharedCollectionAPI from core.external_search import MockExternalSearchIndex from core.integration.settings import BaseSettings from core.model import DataSource, Hold, Loan @@ -163,61 +162,6 @@ def api_for_license_pool(self, licensepool): return self.remotes[source] -class MockSharedCollectionAPI(SharedCollectionAPI): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.responses = defaultdict(list) - - def _queue(self, k, v): - self.responses[k].append(v) - - def _return_or_raise(self, k): - self.log.debug(k) - l = self.responses[k] - v = l.pop(0) - if isinstance(v, Exception): - raise v - return v - - def queue_register(self, response): - self._queue("register", response) - - def register(self, collection, url): - return self._return_or_raise("register") - - def queue_borrow(self, response): - self._queue("borrow", response) - - def borrow(self, collection, client, pool, hold=None): - return self._return_or_raise("borrow") - - def queue_revoke_loan(self, response): - self._queue("revoke-loan", response) - - def revoke_loan(self, collection, client, loan): - return self._return_or_raise("revoke-loan") - - def queue_fulfill(self, response): - self._queue("fulfill", response) - - def fulfill( - self, - patron, - pin, - licensepool, - internal_format=None, - part=None, - fulfill_part_url=None, - ): - return self._return_or_raise("fulfill") - - def queue_revoke_hold(self, response): - self._queue("revoke-hold", response) - - def revoke_hold(self, collection, client, hold): - return self._return_or_raise("revoke-hold") - - class MockCirculationManager(CirculationManager): d_circulation: MockCirculationAPI @@ -228,6 +172,3 @@ def setup_search(self): def setup_circulation(self, library, analytics): """Set up the Circulation object.""" return MockCirculationAPI(self._db, library, analytics) - - def setup_shared_collection(self): - return MockSharedCollectionAPI(self._db) diff --git a/tests/api/test_controller_cm.py b/tests/api/test_controller_cm.py index 3d2306d32a..4166e0f96c 100644 --- a/tests/api/test_controller_cm.py +++ b/tests/api/test_controller_cm.py @@ -7,7 +7,6 @@ from api.opds import CirculationManagerAnnotator, LibraryAnnotator from api.problem_details import * from api.registration.registry import Registration -from api.shared_collection import SharedCollectionAPI from core.external_search import MockExternalSearchIndex from core.lane import Facets, WorkList from core.model import Admin, CachedFeed, ConfigurationSetting, ExternalIntegration @@ -29,7 +28,6 @@ def test_load_settings(self, circulation_fixture: CirculationControllerFixture): # which are about to be reloaded. manager._external_search = object() manager.auth = object() - manager.shared_collection_api = object() manager.patron_web_domains = object() # But some fields are _not_ about to be reloaded @@ -105,9 +103,6 @@ def mock_for_library(incoming_library): # The ExternalSearch object has been reset. assert isinstance(manager.external_search, MockExternalSearchIndex) - # So has the SharecCollectionAPI. - assert isinstance(manager.shared_collection_api, SharedCollectionAPI) - # So have the patron web domains, and their paths have been # removed. assert {"http://sitewide", "http://registration"} == manager.patron_web_domains diff --git a/tests/api/test_controller_crawlfeed.py b/tests/api/test_controller_crawlfeed.py index bdc44288f0..0750124a2a 100644 --- a/tests/api/test_controller_crawlfeed.py +++ b/tests/api/test_controller_crawlfeed.py @@ -11,8 +11,7 @@ CrawlableFacets, DynamicLane, ) -from api.odl import ODLAPI -from api.opds import CirculationManagerAnnotator, SharedCollectionAnnotator +from api.opds import CirculationManagerAnnotator from api.problem_details import NO_SUCH_COLLECTION, NO_SUCH_LIST from core.external_search import MockSearchResult, SortKeyPagination from core.opds import AcquisitionFeed @@ -133,21 +132,6 @@ def test_crawlable_collection_feed( # library context--a CirculationManagerAnnotator. assert None == kwargs.pop("annotator") - # A specific annotator _is_ created for an ODL collection: - # A SharedCollectionAnnotator that knows about the Collection - # _and_ the WorkList. - collection.protocol = ODLAPI.NAME - with circulation_fixture.app.test_request_context("/"): - with self.mock_crawlable_feed(circulation_fixture): - response = controller.crawlable_collection_feed( - collection_name=collection.name - ) - kwargs = self._crawlable_feed_called_with - annotator = kwargs["annotator"] - assert isinstance(annotator, SharedCollectionAnnotator) - assert collection == annotator.collection - assert kwargs["worklist"] == annotator.lane - def test_crawlable_list_feed( self, circulation_fixture: CirculationControllerFixture ): diff --git a/tests/api/test_controller_shared_collect.py b/tests/api/test_controller_shared_collect.py deleted file mode 100644 index 9a5f00fbbe..0000000000 --- a/tests/api/test_controller_shared_collect.py +++ /dev/null @@ -1,842 +0,0 @@ -import datetime -import json -from contextlib import contextmanager - -import feedparser -import flask -import pytest -from werkzeug.datastructures import ImmutableMultiDict - -from api.circulation import FulfillmentInfo -from api.circulation_exceptions import ( - AuthorizationFailedException, - CannotFulfill, - CannotLoan, - CannotReleaseHold, - CannotReturn, - InvalidInputException, - NoAvailableCopies, - NotCheckedOut, - NotOnHold, - RemoteInitiatedServerError, -) -from api.problem_details import ( - BAD_DELIVERY_MECHANISM, - CANNOT_FULFILL, - CANNOT_RELEASE_HOLD, - CHECKOUT_FAILED, - COULD_NOT_MIRROR_TO_REMOTE, - HOLD_NOT_FOUND, - INVALID_CREDENTIALS, - INVALID_REGISTRATION, - LOAN_NOT_FOUND, - NO_ACTIVE_HOLD, - NO_ACTIVE_LOAN, - NO_AVAILABLE_LICENSE, - NO_LICENSES, - NO_SUCH_COLLECTION, -) -from core.model import ( - Collection, - Hold, - IntegrationClient, - LicensePool, - LicensePoolDeliveryMechanism, - Loan, - Work, - create, -) -from core.problem_details import INTEGRATION_ERROR -from core.util.datetime_helpers import utc_now -from core.util.http import RemoteIntegrationException -from core.util.string_helpers import base64 -from tests.core.mock import MockRequestsResponse -from tests.fixtures.api_controller import ControllerFixture -from tests.fixtures.database import DatabaseTransactionFixture - - -class SharedCollectionFixture(ControllerFixture): - collection: Collection - client: IntegrationClient - work: Work - pool: LicensePool - delivery_mechanism: LicensePoolDeliveryMechanism - - def __init__(self, db: DatabaseTransactionFixture): - super().__init__(db, setup_cm=False) - from api.odl import ODLAPI - - self.collection = db.collection(protocol=ODLAPI.NAME) - self.collection.integration_configuration.settings_dict = dict( - username="username", - password="password", - data_source="data_source", - passphrase_hint="Try Me!", - passphrase_hint_url="http://hint.url", - ) - db.default_library().collections = [self.collection] - self.client, ignore = IntegrationClient.register( - db.session, "http://library.org" - ) - self.app.manager = self.circulation_manager_setup() - self.work = db.work(with_license_pool=True, collection=self.collection) - self.pool = self.work.license_pools[0] - [self.delivery_mechanism] = self.pool.delivery_mechanisms - - -@pytest.fixture(scope="function") -def shared_fixture(db: DatabaseTransactionFixture): - return SharedCollectionFixture(db) - - -class TestSharedCollectionController: - """Test that other circ managers can register to borrow books - from a shared collection.""" - - @contextmanager - def request_context_with_client( - self, shared_fixture: SharedCollectionFixture, route, *args, **kwargs - ): - if "client" in kwargs: - client = kwargs.pop("client") - else: - client = shared_fixture.client - if "headers" in kwargs: - headers = kwargs.pop("headers") - else: - headers = dict() - headers["Authorization"] = "Bearer " + base64.b64encode(client.shared_secret) - kwargs["headers"] = headers - with shared_fixture.app.test_request_context(route, *args, **kwargs) as c: - yield c - - def test_info(self, shared_fixture: SharedCollectionFixture): - with shared_fixture.app.test_request_context("/"): - collection = shared_fixture.manager.shared_collection_controller.info( - shared_fixture.db.fresh_str() - ) - assert NO_SUCH_COLLECTION == collection - - response = shared_fixture.manager.shared_collection_controller.info( - shared_fixture.collection.name - ) - assert 200 == response.status_code - assert response.headers.get("Content-Type").startswith( - "application/opds+json" - ) - links = json.loads(response.get_data(as_text=True)).get("links") - [register_link] = [link for link in links if link.get("rel") == "register"] - assert ( - "/collections/%s/register" % shared_fixture.collection.name - in register_link.get("href") - ) - - def test_load_collection(self, shared_fixture: SharedCollectionFixture): - with shared_fixture.app.test_request_context("/"): - collection = ( - shared_fixture.manager.shared_collection_controller.load_collection( - shared_fixture.db.fresh_str() - ) - ) - assert NO_SUCH_COLLECTION == collection - - collection = ( - shared_fixture.manager.shared_collection_controller.load_collection( - shared_fixture.collection.name - ) - ) - assert shared_fixture.collection == collection - - def test_register(self, shared_fixture: SharedCollectionFixture): - with shared_fixture.app.test_request_context("/"): - api = ( - shared_fixture.app.manager.shared_collection_controller.shared_collection - ) - flask.request.form = ImmutableMultiDict([("url", "http://test")]) - - api.queue_register(InvalidInputException()) - response = shared_fixture.manager.shared_collection_controller.register( - shared_fixture.collection.name - ) - assert 400 == response.status_code - assert INVALID_REGISTRATION.uri == response.uri - - api.queue_register(AuthorizationFailedException()) - response = shared_fixture.manager.shared_collection_controller.register( - shared_fixture.collection.name - ) - assert 401 == response.status_code - assert INVALID_CREDENTIALS.uri == response.uri - - api.queue_register(RemoteInitiatedServerError("Error", "Service")) - response = shared_fixture.manager.shared_collection_controller.register( - shared_fixture.collection.name - ) - assert 502 == response.status_code - assert INTEGRATION_ERROR.uri == response.uri - - api.queue_register(dict(shared_secret="secret")) - response = shared_fixture.manager.shared_collection_controller.register( - shared_fixture.collection.name - ) - assert 200 == response.status_code - assert "secret" == json.loads(response.get_data(as_text=True)).get( - "shared_secret" - ) - - def test_loan_info(self, shared_fixture: SharedCollectionFixture): - now = utc_now() - tomorrow = utc_now() + datetime.timedelta(days=1) - - other_client, ignore = IntegrationClient.register( - shared_fixture.db.session, "http://otherlibrary" - ) - other_client_loan, ignore = create( - shared_fixture.db.session, - Loan, - license_pool=shared_fixture.pool, - integration_client=other_client, - ) - - ignore, other_pool = shared_fixture.db.edition( - with_license_pool=True, - collection=shared_fixture.db.collection(), - ) - other_pool_loan, ignore = create( - shared_fixture.db.session, - Loan, - license_pool=other_pool, - integration_client=shared_fixture.client, - ) - - loan, ignore = create( - shared_fixture.db.session, - Loan, - license_pool=shared_fixture.pool, - integration_client=shared_fixture.client, - start=now, - end=tomorrow, - ) - with self.request_context_with_client(shared_fixture, "/"): - # This loan doesn't exist. - response = shared_fixture.manager.shared_collection_controller.loan_info( - shared_fixture.collection.name, 1234567 - ) - assert LOAN_NOT_FOUND == response - - # This loan belongs to a different library. - response = shared_fixture.manager.shared_collection_controller.loan_info( - shared_fixture.collection.name, other_client_loan.id - ) - assert LOAN_NOT_FOUND == response - - # This loan's pool belongs to a different collection. - response = shared_fixture.manager.shared_collection_controller.loan_info( - shared_fixture.collection.name, other_pool_loan.id - ) - assert LOAN_NOT_FOUND == response - - # This loan is ours. - response = shared_fixture.manager.shared_collection_controller.loan_info( - shared_fixture.collection.name, loan.id - ) - assert 200 == response.status_code - feed = feedparser.parse(response.data) - [entry] = feed.get("entries") - availability = entry.get("opds_availability") - since = availability.get("since") - until = availability.get("until") - assert datetime.datetime.strftime(now, "%Y-%m-%dT%H:%M:%S+00:00") == since - assert ( - datetime.datetime.strftime(tomorrow, "%Y-%m-%dT%H:%M:%S+00:00") == until - ) - [revoke_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "http://librarysimplified.org/terms/rel/revoke" - ] - assert ( - f"/collections/{shared_fixture.collection.name}/loans/{loan.id}/revoke" - in revoke_url - ) - [fulfill_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "http://opds-spec.org/acquisition" - ] - assert ( - "/collections/%s/loans/%s/fulfill/%s" - % ( - shared_fixture.collection.name, - loan.id, - shared_fixture.delivery_mechanism.delivery_mechanism.id, - ) - in fulfill_url - ) - [self_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "self" - ] - assert f"/collections/{shared_fixture.collection.name}/loans/{loan.id}" - - def test_borrow(self, shared_fixture: SharedCollectionFixture): - now = utc_now() - tomorrow = utc_now() + datetime.timedelta(days=1) - loan, ignore = create( - shared_fixture.db.session, - Loan, - license_pool=shared_fixture.pool, - integration_client=shared_fixture.client, - start=now, - end=tomorrow, - ) - - hold, ignore = create( - shared_fixture.db.session, - Hold, - license_pool=shared_fixture.pool, - integration_client=shared_fixture.client, - start=now, - end=tomorrow, - ) - - no_pool = shared_fixture.db.identifier() - with self.request_context_with_client(shared_fixture, "/"): - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, no_pool.type, no_pool.identifier, None - ) - assert NO_LICENSES.uri == response.uri - - api = ( - shared_fixture.app.manager.shared_collection_controller.shared_collection - ) - - # Attempt to borrow without a previous hold. - api.queue_borrow(AuthorizationFailedException()) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, - shared_fixture.pool.identifier.type, - shared_fixture.pool.identifier.identifier, - None, - ) - assert INVALID_CREDENTIALS.uri == response.uri - - api.queue_borrow(CannotLoan()) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, - shared_fixture.pool.identifier.type, - shared_fixture.pool.identifier.identifier, - None, - ) - assert CHECKOUT_FAILED.uri == response.uri - - api.queue_borrow(NoAvailableCopies()) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, - shared_fixture.pool.identifier.type, - shared_fixture.pool.identifier.identifier, - None, - ) - assert NO_AVAILABLE_LICENSE.uri == response.uri - - api.queue_borrow(RemoteIntegrationException("error!", "service")) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, - shared_fixture.pool.identifier.type, - shared_fixture.pool.identifier.identifier, - None, - ) - assert INTEGRATION_ERROR.uri == response.uri - - api.queue_borrow(loan) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, - shared_fixture.pool.identifier.type, - shared_fixture.pool.identifier.identifier, - None, - ) - assert 201 == response.status_code - feed = feedparser.parse(response.data) - [entry] = feed.get("entries") - availability = entry.get("opds_availability") - since = availability.get("since") - until = availability.get("until") - assert datetime.datetime.strftime(now, "%Y-%m-%dT%H:%M:%S+00:00") == since - assert ( - datetime.datetime.strftime(tomorrow, "%Y-%m-%dT%H:%M:%S+00:00") == until - ) - assert "available" == availability.get("status") - [revoke_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "http://librarysimplified.org/terms/rel/revoke" - ] - assert ( - f"/collections/{shared_fixture.collection.name}/loans/{loan.id}/revoke" - in revoke_url - ) - [fulfill_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "http://opds-spec.org/acquisition" - ] - assert ( - "/collections/%s/loans/%s/fulfill/%s" - % ( - shared_fixture.collection.name, - loan.id, - shared_fixture.delivery_mechanism.delivery_mechanism.id, - ) - in fulfill_url - ) - [self_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "self" - ] - assert f"/collections/{shared_fixture.collection.name}/loans/{loan.id}" - - # Now try to borrow when we already have a previous hold. - api.queue_borrow(AuthorizationFailedException()) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, - shared_fixture.pool.identifier.type, - shared_fixture.pool.identifier.identifier, - hold.id, - ) - assert INVALID_CREDENTIALS.uri == response.uri - - api.queue_borrow(CannotLoan()) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, None, None, hold.id - ) - assert CHECKOUT_FAILED.uri == response.uri - - api.queue_borrow(NoAvailableCopies()) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, None, None, hold.id - ) - assert NO_AVAILABLE_LICENSE.uri == response.uri - - api.queue_borrow(RemoteIntegrationException("error!", "service")) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, None, None, hold.id - ) - assert INTEGRATION_ERROR.uri == response.uri - - api.queue_borrow(loan) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, None, None, hold.id - ) - assert 201 == response.status_code - feed = feedparser.parse(response.data) - [entry] = feed.get("entries") - availability = entry.get("opds_availability") - since = availability.get("since") - until = availability.get("until") - assert "available" == availability.get("status") - assert datetime.datetime.strftime(now, "%Y-%m-%dT%H:%M:%S+00:00") == since - assert ( - datetime.datetime.strftime(tomorrow, "%Y-%m-%dT%H:%M:%S+00:00") == until - ) - [revoke_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "http://librarysimplified.org/terms/rel/revoke" - ] - assert ( - f"/collections/{shared_fixture.collection.name}/loans/{loan.id}/revoke" - in revoke_url - ) - [fulfill_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "http://opds-spec.org/acquisition" - ] - assert ( - "/collections/%s/loans/%s/fulfill/%s" - % ( - shared_fixture.collection.name, - loan.id, - shared_fixture.delivery_mechanism.delivery_mechanism.id, - ) - in fulfill_url - ) - [self_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "self" - ] - assert f"/collections/{shared_fixture.collection.name}/loans/{loan.id}" - - # Now try to borrow, but actually get a hold. - api.queue_borrow(hold) - response = shared_fixture.manager.shared_collection_controller.borrow( - shared_fixture.collection.name, - shared_fixture.pool.identifier.type, - shared_fixture.pool.identifier.identifier, - None, - ) - assert 201 == response.status_code - feed = feedparser.parse(response.data) - [entry] = feed.get("entries") - availability = entry.get("opds_availability") - since = availability.get("since") - until = availability.get("until") - assert datetime.datetime.strftime(now, "%Y-%m-%dT%H:%M:%S+00:00") == since - assert ( - datetime.datetime.strftime(tomorrow, "%Y-%m-%dT%H:%M:%S+00:00") == until - ) - assert "reserved" == availability.get("status") - [revoke_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "http://librarysimplified.org/terms/rel/revoke" - ] - assert ( - f"/collections/{shared_fixture.collection.name}/holds/{hold.id}/revoke" - in revoke_url - ) - assert [] == [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "http://opds-spec.org/acquisition" - ] - [self_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "self" - ] - assert f"/collections/{shared_fixture.collection.name}/holds/{hold.id}" - - def test_revoke_loan(self, shared_fixture: SharedCollectionFixture): - now = utc_now() - tomorrow = utc_now() + datetime.timedelta(days=1) - loan, ignore = create( - shared_fixture.db.session, - Loan, - license_pool=shared_fixture.pool, - integration_client=shared_fixture.client, - start=now, - end=tomorrow, - ) - - other_client, ignore = IntegrationClient.register( - shared_fixture.db.session, "http://otherlibrary" - ) - other_client_loan, ignore = create( - shared_fixture.db.session, - Loan, - license_pool=shared_fixture.pool, - integration_client=other_client, - ) - - ignore, other_pool = shared_fixture.db.edition( - with_license_pool=True, - collection=shared_fixture.db.collection(), - ) - other_pool_loan, ignore = create( - shared_fixture.db.session, - Loan, - license_pool=other_pool, - integration_client=shared_fixture.client, - ) - - with self.request_context_with_client(shared_fixture, "/"): - response = shared_fixture.manager.shared_collection_controller.revoke_loan( - shared_fixture.collection.name, other_pool_loan.id - ) - assert LOAN_NOT_FOUND.uri == response.uri - - response = shared_fixture.manager.shared_collection_controller.revoke_loan( - shared_fixture.collection.name, other_client_loan.id - ) - assert LOAN_NOT_FOUND.uri == response.uri - - api = ( - shared_fixture.app.manager.shared_collection_controller.shared_collection - ) - - api.queue_revoke_loan(AuthorizationFailedException()) - response = shared_fixture.manager.shared_collection_controller.revoke_loan( - shared_fixture.collection.name, loan.id - ) - assert INVALID_CREDENTIALS.uri == response.uri - - api.queue_revoke_loan(CannotReturn()) - response = shared_fixture.manager.shared_collection_controller.revoke_loan( - shared_fixture.collection.name, loan.id - ) - assert COULD_NOT_MIRROR_TO_REMOTE.uri == response.uri - - api.queue_revoke_loan(NotCheckedOut()) - response = shared_fixture.manager.shared_collection_controller.revoke_loan( - shared_fixture.collection.name, loan.id - ) - assert NO_ACTIVE_LOAN.uri == response.uri - - def test_fulfill(self, shared_fixture: SharedCollectionFixture): - now = utc_now() - tomorrow = utc_now() + datetime.timedelta(days=1) - loan, ignore = create( - shared_fixture.db.session, - Loan, - license_pool=shared_fixture.pool, - integration_client=shared_fixture.client, - start=now, - end=tomorrow, - ) - - ignore, other_pool = shared_fixture.db.edition( - with_license_pool=True, - collection=shared_fixture.db.collection(), - ) - other_pool_loan, ignore = create( - shared_fixture.db.session, - Loan, - license_pool=other_pool, - integration_client=shared_fixture.client, - ) - - with self.request_context_with_client(shared_fixture, "/"): - response = shared_fixture.manager.shared_collection_controller.fulfill( - shared_fixture.collection.name, other_pool_loan.id, None - ) - assert LOAN_NOT_FOUND.uri == response.uri - - api = ( - shared_fixture.app.manager.shared_collection_controller.shared_collection - ) - - # If the loan doesn't have a mechanism set, we need to specify one. - response = shared_fixture.manager.shared_collection_controller.fulfill( - shared_fixture.collection.name, loan.id, None - ) - assert BAD_DELIVERY_MECHANISM.uri == response.uri - - loan.fulfillment = shared_fixture.delivery_mechanism - - api.queue_fulfill(AuthorizationFailedException()) - response = shared_fixture.manager.shared_collection_controller.fulfill( - shared_fixture.collection.name, loan.id, None - ) - assert INVALID_CREDENTIALS.uri == response.uri - - api.queue_fulfill(CannotFulfill()) - response = shared_fixture.manager.shared_collection_controller.fulfill( - shared_fixture.collection.name, loan.id, None - ) - assert CANNOT_FULFILL.uri == response.uri - - api.queue_fulfill(RemoteIntegrationException("error!", "service")) - response = shared_fixture.manager.shared_collection_controller.fulfill( - shared_fixture.collection.name, - loan.id, - shared_fixture.delivery_mechanism.delivery_mechanism.id, - ) - assert INTEGRATION_ERROR.uri == response.uri - - fulfillment_info = FulfillmentInfo( - shared_fixture.collection, - shared_fixture.pool.data_source.name, - shared_fixture.pool.identifier.type, - shared_fixture.pool.identifier.identifier, - "http://content", - "text/html", - None, - utc_now(), - ) - - api.queue_fulfill(fulfillment_info) - - def do_get_error(url): - raise RemoteIntegrationException("error!", "service") - - response = shared_fixture.manager.shared_collection_controller.fulfill( - shared_fixture.collection.name, - loan.id, - shared_fixture.delivery_mechanism.delivery_mechanism.id, - do_get=do_get_error, - ) - assert INTEGRATION_ERROR.uri == response.uri - - api.queue_fulfill(fulfillment_info) - - def do_get_success(url): - return MockRequestsResponse(200, content="Content") - - response = shared_fixture.manager.shared_collection_controller.fulfill( - shared_fixture.collection.name, - loan.id, - shared_fixture.delivery_mechanism.delivery_mechanism.id, - do_get=do_get_success, - ) - assert 200 == response.status_code - assert "Content" == response.get_data(as_text=True) - assert "text/html" == response.headers.get("Content-Type") - - fulfillment_info.content_link = None - fulfillment_info.content = "Content" - api.queue_fulfill(fulfillment_info) - response = shared_fixture.manager.shared_collection_controller.fulfill( - shared_fixture.collection.name, - loan.id, - shared_fixture.delivery_mechanism.delivery_mechanism.id, - ) - assert 200 == response.status_code - assert "Content" == response.get_data(as_text=True) - assert "text/html" == response.headers.get("Content-Type") - - def test_hold_info(self, shared_fixture: SharedCollectionFixture): - now = utc_now() - tomorrow = utc_now() + datetime.timedelta(days=1) - - other_client, ignore = IntegrationClient.register( - shared_fixture.db.session, "http://otherlibrary" - ) - other_client_hold, ignore = create( - shared_fixture.db.session, - Hold, - license_pool=shared_fixture.pool, - integration_client=other_client, - ) - - ignore, other_pool = shared_fixture.db.edition( - with_license_pool=True, - collection=shared_fixture.db.collection(), - ) - other_pool_hold, ignore = create( - shared_fixture.db.session, - Hold, - license_pool=other_pool, - integration_client=shared_fixture.client, - ) - - hold, ignore = create( - shared_fixture.db.session, - Hold, - license_pool=shared_fixture.pool, - integration_client=shared_fixture.client, - start=now, - end=tomorrow, - ) - with self.request_context_with_client(shared_fixture, "/"): - # This hold doesn't exist. - response = shared_fixture.manager.shared_collection_controller.hold_info( - shared_fixture.collection.name, 1234567 - ) - assert HOLD_NOT_FOUND == response - - # This hold belongs to a different library. - response = shared_fixture.manager.shared_collection_controller.hold_info( - shared_fixture.collection.name, other_client_hold.id - ) - assert HOLD_NOT_FOUND == response - - # This hold's pool belongs to a different collection. - response = shared_fixture.manager.shared_collection_controller.hold_info( - shared_fixture.collection.name, other_pool_hold.id - ) - assert HOLD_NOT_FOUND == response - - # This hold is ours. - response = shared_fixture.manager.shared_collection_controller.hold_info( - shared_fixture.collection.name, hold.id - ) - assert 200 == response.status_code - feed = feedparser.parse(response.data) - [entry] = feed.get("entries") - availability = entry.get("opds_availability") - since = availability.get("since") - until = availability.get("until") - assert datetime.datetime.strftime(now, "%Y-%m-%dT%H:%M:%S+00:00") == since - assert ( - datetime.datetime.strftime(tomorrow, "%Y-%m-%dT%H:%M:%S+00:00") == until - ) - [revoke_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "http://librarysimplified.org/terms/rel/revoke" - ] - assert ( - f"/collections/{shared_fixture.collection.name}/holds/{hold.id}/revoke" - in revoke_url - ) - assert [] == [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "http://opds-spec.org/acquisition" - ] - [self_url] = [ - link.get("href") - for link in entry.get("links") - if link.get("rel") == "self" - ] - assert f"/collections/{shared_fixture.collection.name}/holds/{hold.id}" - - def test_revoke_hold(self, shared_fixture: SharedCollectionFixture): - now = utc_now() - tomorrow = utc_now() + datetime.timedelta(days=1) - hold, ignore = create( - shared_fixture.db.session, - Hold, - license_pool=shared_fixture.pool, - integration_client=shared_fixture.client, - start=now, - end=tomorrow, - ) - - other_client, ignore = IntegrationClient.register( - shared_fixture.db.session, "http://otherlibrary" - ) - other_client_hold, ignore = create( - shared_fixture.db.session, - Hold, - license_pool=shared_fixture.pool, - integration_client=other_client, - ) - - ignore, other_pool = shared_fixture.db.edition( - with_license_pool=True, - collection=shared_fixture.db.collection(), - ) - other_pool_hold, ignore = create( - shared_fixture.db.session, - Hold, - license_pool=other_pool, - integration_client=shared_fixture.client, - ) - - with self.request_context_with_client(shared_fixture, "/"): - response = shared_fixture.manager.shared_collection_controller.revoke_hold( - shared_fixture.collection.name, other_pool_hold.id - ) - assert HOLD_NOT_FOUND.uri == response.uri - - response = shared_fixture.manager.shared_collection_controller.revoke_hold( - shared_fixture.collection.name, other_client_hold.id - ) - assert HOLD_NOT_FOUND.uri == response.uri - - api = ( - shared_fixture.app.manager.shared_collection_controller.shared_collection - ) - - api.queue_revoke_hold(AuthorizationFailedException()) - response = shared_fixture.manager.shared_collection_controller.revoke_hold( - shared_fixture.collection.name, hold.id - ) - assert INVALID_CREDENTIALS.uri == response.uri - - api.queue_revoke_hold(CannotReleaseHold()) - response = shared_fixture.manager.shared_collection_controller.revoke_hold( - shared_fixture.collection.name, hold.id - ) - assert CANNOT_RELEASE_HOLD.uri == response.uri - - api.queue_revoke_hold(NotOnHold()) - response = shared_fixture.manager.shared_collection_controller.revoke_hold( - shared_fixture.collection.name, hold.id - ) - assert NO_ACTIVE_HOLD.uri == response.uri diff --git a/tests/api/test_odl.py b/tests/api/test_odl.py index 35350475b2..230d2c3f32 100644 --- a/tests/api/test_odl.py +++ b/tests/api/test_odl.py @@ -1437,244 +1437,6 @@ def test_update_loan_removes_loan_with_hold_queue( assert 0 == hold.position assert 0 == db.session.query(Loan).count() - def test_checkout_from_external_library( - self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture - ): - # This book is available to check out. - odl_api_test_fixture.pool.licenses_owned = 6 - odl_api_test_fixture.pool.licenses_available = 6 - odl_api_test_fixture.license.checkouts_available = 6 - odl_api_test_fixture.license.checkouts_left = 10 - - # An integration client checks out the book successfully. - loan_url = db.fresh_str() - lsd = json.dumps( - { - "status": "ready", - "potential_rights": {"end": "3017-10-21T11:12:13Z"}, - "links": [ - { - "rel": "self", - "href": loan_url, - } - ], - } - ) - - odl_api_test_fixture.api.queue_response(200, content=lsd) - loan = odl_api_test_fixture.api.checkout_to_external_library( - odl_api_test_fixture.client, odl_api_test_fixture.pool - ) - assert odl_api_test_fixture.client == loan.integration_client - assert odl_api_test_fixture.pool == loan.license_pool - assert loan.start > utc_now() - datetime.timedelta(minutes=1) - assert loan.start < utc_now() + datetime.timedelta(minutes=1) - assert datetime_utc(3017, 10, 21, 11, 12, 13) == loan.end - assert loan_url == loan.external_identifier - assert 1 == db.session.query(Loan).count() - - # The pool's availability and the license's remaining checkouts have decreased. - assert 5 == odl_api_test_fixture.pool.licenses_available - assert 9 == odl_api_test_fixture.license.checkouts_left - - # The book can also be placed on hold to an external library, - # if there are no copies available. - odl_api_test_fixture.license.setup(concurrency=1, available=0) # type: ignore[attr-defined] - - holdinfo = odl_api_test_fixture.api.checkout_to_external_library( - odl_api_test_fixture.client, odl_api_test_fixture.pool - ) - - assert 1 == odl_api_test_fixture.pool.patrons_in_hold_queue - assert odl_api_test_fixture.client == holdinfo.integration_client - assert holdinfo.start_date > utc_now() - datetime.timedelta(minutes=1) - assert holdinfo.start_date < utc_now() + datetime.timedelta(minutes=1) - assert holdinfo.end_date > utc_now() + datetime.timedelta(days=7) - assert 1 == holdinfo.hold_position - - def test_checkout_from_external_library_with_hold( - self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture - ): - # An integration client has this book on hold, and the book just became available to check out. - odl_api_test_fixture.pool.licenses_owned = 1 - odl_api_test_fixture.pool.licenses_available = 0 - odl_api_test_fixture.pool.licenses_reserved = 1 - odl_api_test_fixture.pool.patrons_in_hold_queue = 1 - hold, ignore = odl_api_test_fixture.pool.on_hold_to( - odl_api_test_fixture.client, - start=utc_now() - datetime.timedelta(days=1), - position=0, - ) - - # The patron checks out the book. - loan_url = db.fresh_str() - lsd = json.dumps( - { - "status": "ready", - "potential_rights": {"end": "3017-10-21T11:12:13Z"}, - "links": [ - { - "rel": "self", - "href": loan_url, - } - ], - } - ) - - odl_api_test_fixture.api.queue_response(200, content=lsd) - - # The patron gets a loan successfully. - loan = odl_api_test_fixture.api.checkout_to_external_library( - odl_api_test_fixture.client, odl_api_test_fixture.pool, hold - ) - assert odl_api_test_fixture.client == loan.integration_client - assert odl_api_test_fixture.pool == loan.license_pool - assert loan.start > utc_now() - datetime.timedelta(minutes=1) - assert loan.start < utc_now() + datetime.timedelta(minutes=1) - assert datetime_utc(3017, 10, 21, 11, 12, 13) == loan.end - assert loan_url == loan.external_identifier - assert 1 == db.session.query(Loan).count() - - # The book is no longer reserved for the patron, and the hold has been deleted. - assert 0 == odl_api_test_fixture.pool.licenses_reserved - assert 0 == odl_api_test_fixture.pool.licenses_available - assert 0 == odl_api_test_fixture.pool.patrons_in_hold_queue - assert 0 == db.session.query(Hold).count() - - def test_checkin_from_external_library( - self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture - ): - # An integration client has a copy of this book checked out. - odl_api_test_fixture.license.setup(concurrency=7, available=6) # type: ignore[attr-defined] - loan, ignore = odl_api_test_fixture.license.loan_to(odl_api_test_fixture.client) - loan.external_identifier = "http://loan/" + db.fresh_str() - loan.end = utc_now() + datetime.timedelta(days=3) - - # The patron returns the book successfully. - lsd = json.dumps( - { - "status": "ready", - "links": [ - { - "rel": "return", - "href": "http://return", - } - ], - } - ) - returned_lsd = json.dumps( - { - "status": "returned", - } - ) - - odl_api_test_fixture.api.queue_response(200, content=lsd) - odl_api_test_fixture.api.queue_response(200) - odl_api_test_fixture.api.queue_response(200, content=returned_lsd) - odl_api_test_fixture.api.checkin_from_external_library( - odl_api_test_fixture.client, loan - ) - assert 3 == len(odl_api_test_fixture.api.requests) - assert "http://loan" in odl_api_test_fixture.api.requests[0][0] - assert "http://return" == odl_api_test_fixture.api.requests[1][0] - assert "http://loan" in odl_api_test_fixture.api.requests[2][0] - - # The pool's availability has increased, and the local loan has - # been deleted. - assert 7 == odl_api_test_fixture.pool.licenses_available - assert 0 == db.session.query(Loan).count() - - def test_fulfill_for_external_library( - self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture - ): - loan, ignore = odl_api_test_fixture.license.loan_to(odl_api_test_fixture.client) - loan.external_identifier = db.fresh_str() - loan.end = utc_now() + datetime.timedelta(days=3) - - lsd = json.dumps( - { - "status": "ready", - "potential_rights": {"end": "2017-10-21T11:12:13Z"}, - "links": [ - { - "rel": "license", - "href": "http://acsm", - "type": DeliveryMechanism.ADOBE_DRM, - } - ], - } - ) - - odl_api_test_fixture.api.queue_response(200, content=lsd) - fulfillment = odl_api_test_fixture.api.fulfill_for_external_library( - odl_api_test_fixture.client, loan, None - ) - assert odl_api_test_fixture.collection == fulfillment.collection(db.session) - assert ( - odl_api_test_fixture.pool.data_source.name == fulfillment.data_source_name - ) - assert odl_api_test_fixture.pool.identifier.type == fulfillment.identifier_type - assert odl_api_test_fixture.pool.identifier.identifier == fulfillment.identifier - assert datetime_utc(2017, 10, 21, 11, 12, 13) == fulfillment.content_expires - assert "http://acsm" == fulfillment.content_link - assert DeliveryMechanism.ADOBE_DRM == fulfillment.content_type - - def test_release_hold_from_external_library( - self, db: DatabaseTransactionFixture, odl_api_test_fixture: ODLAPITestFixture - ): - odl_api_test_fixture.license.setup(concurrency=1, available=1) # type: ignore[attr-defined] - other_patron = db.patron() - odl_api_test_fixture.checkout(patron=other_patron) - hold, ignore = odl_api_test_fixture.pool.on_hold_to( - odl_api_test_fixture.client, position=1 - ) - - assert ( - odl_api_test_fixture.api.release_hold_from_external_library( - odl_api_test_fixture.client, hold - ) - is True - ) - assert 0 == odl_api_test_fixture.pool.licenses_available - assert 0 == odl_api_test_fixture.pool.licenses_reserved - assert 0 == odl_api_test_fixture.pool.patrons_in_hold_queue - assert 0 == db.session.query(Hold).count() - - odl_api_test_fixture.checkin(patron=other_patron) - hold, ignore = odl_api_test_fixture.pool.on_hold_to( - odl_api_test_fixture.client, position=0 - ) - - assert ( - odl_api_test_fixture.api.release_hold_from_external_library( - odl_api_test_fixture.client, hold - ) - is True - ) - assert 1 == odl_api_test_fixture.pool.licenses_available - assert 0 == odl_api_test_fixture.pool.licenses_reserved - assert 0 == odl_api_test_fixture.pool.patrons_in_hold_queue - assert 0 == db.session.query(Hold).count() - - hold, ignore = odl_api_test_fixture.pool.on_hold_to( - odl_api_test_fixture.client, position=0 - ) - other_hold, ignore = odl_api_test_fixture.pool.on_hold_to( - db.patron(), position=2 - ) - - assert ( - odl_api_test_fixture.api.release_hold_from_external_library( - odl_api_test_fixture.client, hold - ) - is True - ) - assert 0 == odl_api_test_fixture.pool.licenses_available - assert 1 == odl_api_test_fixture.pool.licenses_reserved - assert 1 == odl_api_test_fixture.pool.patrons_in_hold_queue - assert 1 == db.session.query(Hold).count() - assert 0 == other_hold.position - class TestODLImporter: class MockGet: diff --git a/tests/api/test_opds.py b/tests/api/test_opds.py index 413ac2c874..9eb25d3d78 100644 --- a/tests/api/test_opds.py +++ b/tests/api/test_opds.py @@ -19,8 +19,6 @@ CirculationManagerAnnotator, LibraryAnnotator, LibraryLoanAndHoldAnnotator, - SharedCollectionAnnotator, - SharedCollectionLoanAndHoldAnnotator, ) from api.problem_details import NOT_FOUND_ON_REMOTE from core.analytics import Analytics @@ -2362,385 +2360,3 @@ def test_annotate_work_entry(self, db: DatabaseTransactionFixture): f"link[@rel='{LinkRelations.TIME_TRACKING}']" ) assert len(time_tracking_links) == 0 - - -class SharedCollectionAnnotatorFixture: - def __init__(self, db: DatabaseTransactionFixture): - self.db = db - self.work = db.work(with_open_access_download=True) - self.collection = db.collection() - self.lane = db.lane(display_name="Fantasy") - self.annotator = SharedCollectionAnnotator( - self.collection, - self.lane, - test_mode=True, - ) - - -@pytest.fixture(scope="function") -def shared_collection( - db: DatabaseTransactionFixture, -) -> SharedCollectionAnnotatorFixture: - return SharedCollectionAnnotatorFixture(db) - - -class TestSharedCollectionAnnotator: - def test_top_level_title(self, shared_collection: SharedCollectionAnnotatorFixture): - assert ( - shared_collection.collection.name - == shared_collection.annotator.top_level_title() - ) - - def test_feed_url(self, shared_collection: SharedCollectionAnnotatorFixture): - feed_url_fantasy = shared_collection.annotator.feed_url( - shared_collection.lane, dict(), dict() - ) - assert "feed" in feed_url_fantasy - assert str(shared_collection.lane.id) in feed_url_fantasy - assert shared_collection.collection.name in feed_url_fantasy - - def get_parsed_feed( - self, shared_collection: SharedCollectionAnnotatorFixture, works, lane=None - ): - if not lane: - lane = shared_collection.db.lane(display_name="Main Lane") - feed = AcquisitionFeed( - shared_collection.db.session, - "test", - "url", - works, - SharedCollectionAnnotator( - shared_collection.collection, lane, test_mode=True - ), - ) - return feedparser.parse(str(feed)) - - def assert_link_on_entry( - self, entry, link_type=None, rels=None, partials_by_rel=None - ): - """Asserts that a link with a certain 'rel' value exists on a - given feed or entry, as well as its link 'type' value and parts - of its 'href' value. - """ - - def get_link_by_rel(rel, should_exist=True): - try: - [link] = [x for x in entry["links"] if x["rel"] == rel] - except ValueError as e: - raise AssertionError - if link_type: - assert link_type == link.type - return link - - if rels: - [get_link_by_rel(rel) for rel in rels] - - partials_by_rel = partials_by_rel or dict() - for rel, uri_partials in list(partials_by_rel.items()): - link = get_link_by_rel(rel) - if not isinstance(uri_partials, list): - uri_partials = [uri_partials] - for part in uri_partials: - assert part in link.href - - def test_work_entry_includes_updated( - self, shared_collection: SharedCollectionAnnotatorFixture - ): - work = shared_collection.db.work(with_open_access_download=True) - work.license_pools[0].availability_time = datetime_utc(2018, 1, 1, 0, 0, 0) - work.last_update_time = datetime_utc(2018, 2, 4, 0, 0, 0) - - feed = self.get_parsed_feed(shared_collection, [work]) - [entry] = feed.entries - assert "2018-02-04" in entry.get("updated") - - def test_work_entry_includes_open_access_or_borrow_link( - self, shared_collection: SharedCollectionAnnotatorFixture - ): - open_access_work = shared_collection.db.work(with_open_access_download=True) - licensed_work = shared_collection.db.work(with_license_pool=True) - licensed_work.license_pools[0].open_access = False - - feed = self.get_parsed_feed( - shared_collection, [open_access_work, licensed_work] - ) - [open_access_entry, licensed_entry] = feed.entries - - self.assert_link_on_entry( - open_access_entry, rels=[Hyperlink.OPEN_ACCESS_DOWNLOAD] - ) - self.assert_link_on_entry(licensed_entry, rels=[OPDSFeed.BORROW_REL]) - - # The open access entry shouldn't have a borrow link, and the licensed entry - # shouldn't have an open access link. - links = [ - x for x in open_access_entry["links"] if x["rel"] == OPDSFeed.BORROW_REL - ] - assert 0 == len(links) - links = [ - x - for x in licensed_entry["links"] - if x["rel"] == Hyperlink.OPEN_ACCESS_DOWNLOAD - ] - assert 0 == len(links) - - def test_borrow_link_raises_unfulfillable_work( - self, shared_collection: SharedCollectionAnnotatorFixture - ): - edition, pool = shared_collection.db.edition(with_license_pool=True) - kindle_mechanism = pool.set_delivery_mechanism( - DeliveryMechanism.KINDLE_CONTENT_TYPE, - DeliveryMechanism.KINDLE_DRM, - RightsStatus.IN_COPYRIGHT, - None, - ) - epub_mechanism = pool.set_delivery_mechanism( - Representation.EPUB_MEDIA_TYPE, - DeliveryMechanism.ADOBE_DRM, - RightsStatus.IN_COPYRIGHT, - None, - ) - data_source_name = pool.data_source.name - identifier = pool.identifier - - annotator = SharedCollectionLoanAndHoldAnnotator( - shared_collection.collection, None, test_mode=True - ) - - # If there's no way to fulfill the book, borrow_link raises - # UnfulfillableWork. - pytest.raises(UnfulfillableWork, annotator.borrow_link, pool, None, []) - - pytest.raises( - UnfulfillableWork, annotator.borrow_link, pool, None, [kindle_mechanism] - ) - - # If there's a fulfillable mechanism, everything's fine. - link = annotator.borrow_link(pool, None, [epub_mechanism]) - assert link != None - - link = annotator.borrow_link(pool, None, [epub_mechanism, kindle_mechanism]) - assert link != None - - def test_acquisition_links( - self, shared_collection: SharedCollectionAnnotatorFixture - ): - annotator = SharedCollectionLoanAndHoldAnnotator( - shared_collection.collection, None, test_mode=True - ) - feed = AcquisitionFeed( - shared_collection.db.session, "test", "url", [], annotator - ) - - client = shared_collection.db.integration_client() - - now = utc_now() - tomorrow = now + datetime.timedelta(days=1) - - # Loan of an open-access book. - work1 = shared_collection.db.work(with_open_access_download=True) - loan1, ignore = work1.license_pools[0].loan_to(client, start=now) - - # Loan of a licensed book. - work2 = shared_collection.db.work(with_license_pool=True) - loan2, ignore = work2.license_pools[0].loan_to(client, start=now, end=tomorrow) - - # Hold on a licensed book. - work3 = shared_collection.db.work(with_license_pool=True) - hold, ignore = work3.license_pools[0].on_hold_to( - client, start=now, end=tomorrow - ) - - # Book with no loans or holds yet. - work4 = shared_collection.db.work(with_license_pool=True) - - loan1_links = annotator.acquisition_links( - loan1.license_pool, loan1, None, None, feed, loan1.license_pool.identifier - ) - # Fulfill, open access, revoke, and loan info. - [revoke, fulfill, open_access, info] = sorted( - loan1_links, key=lambda x: x.attrib.get("rel") - ) - assert "shared_collection_revoke_loan" in revoke.attrib.get("href") - assert "http://librarysimplified.org/terms/rel/revoke" == revoke.attrib.get( - "rel" - ) - assert "shared_collection_fulfill" in fulfill.attrib.get("href") - assert "http://opds-spec.org/acquisition" == fulfill.attrib.get("rel") - assert work1.license_pools[0].delivery_mechanisms[ - 0 - ].resource.representation.mirror_url == open_access.attrib.get("href") - assert "http://opds-spec.org/acquisition/open-access" == open_access.attrib.get( - "rel" - ) - assert "shared_collection_loan_info" in info.attrib.get("href") - assert "self" == info.attrib.get("rel") - - loan2_links = annotator.acquisition_links( - loan2.license_pool, loan2, None, None, feed, loan2.license_pool.identifier - ) - # Fulfill, revoke, and loan info. - [revoke, fulfill, info] = sorted(loan2_links, key=lambda x: x.attrib.get("rel")) - assert "shared_collection_revoke_loan" in revoke.attrib.get("href") - assert "http://librarysimplified.org/terms/rel/revoke" == revoke.attrib.get( - "rel" - ) - assert "shared_collection_fulfill" in fulfill.attrib.get("href") - assert "http://opds-spec.org/acquisition" == fulfill.attrib.get("rel") - assert "shared_collection_loan_info" in info.attrib.get("href") - assert "self" == info.attrib.get("rel") - - hold_links = annotator.acquisition_links( - hold.license_pool, None, hold, None, feed, hold.license_pool.identifier - ) - # Borrow, revoke, and hold info. - [revoke, borrow, info] = sorted(hold_links, key=lambda x: x.attrib.get("rel")) - assert "shared_collection_revoke_hold" in revoke.attrib.get("href") - assert "http://librarysimplified.org/terms/rel/revoke" == revoke.attrib.get( - "rel" - ) - assert "shared_collection_borrow" in borrow.attrib.get("href") - assert "http://opds-spec.org/acquisition/borrow" == borrow.attrib.get("rel") - assert "shared_collection_hold_info" in info.attrib.get("href") - assert "self" == info.attrib.get("rel") - - work4_links = annotator.acquisition_links( - work4.license_pools[0], - None, - None, - None, - feed, - work4.license_pools[0].identifier, - ) - # Borrow only. - [borrow] = work4_links - assert "shared_collection_borrow" in borrow.attrib.get("href") - assert "http://opds-spec.org/acquisition/borrow" == borrow.attrib.get("rel") - - def test_single_item_feed( - self, shared_collection: SharedCollectionAnnotatorFixture - ): - # Test the generation of single-item OPDS feeds for loans (with and - # without fulfillment) and holds. - class MockAnnotator(SharedCollectionLoanAndHoldAnnotator): - def url_for(self, controller, **kwargs): - self.url_for_called_with = (controller, kwargs) - return "a URL" - - def _single_entry_response(self, *args, **kwargs): - self._single_entry_response_called_with = (args, kwargs) - # Return the annotator itself so we can look at it. - return self - - def test_annotator(item, fulfillment, expect_route, expect_route_kwargs): - # Call MockAnnotator.single_item_feed with certain arguments - # and make some general assertions about the return value. - test_mode = object() - feed_class = object() - result = MockAnnotator.single_item_feed( - shared_collection.collection, - item, - fulfillment, - test_mode, - feed_class, - extra_arg="value", - ) - - # The final result is a MockAnnotator object. This isn't - # normal; it's because - # MockAnnotator._single_entry_response returns the - # MockAnnotator it creates, for us to examine. - assert isinstance(result, MockAnnotator) - - # Let's examine the MockAnnotator itself. - assert shared_collection.collection == result.collection - assert test_mode == result.test_mode - - # Now let's see what we did with it after calling its - # constructor. - - # First, we generated a URL to a controller for the - # license pool's identifier. _Which_ controller we used - # depends on what `item` is. - url_call = result.url_for_called_with - route, route_kwargs = url_call - - # The route is the one we expect. - assert expect_route == route - - # Apart from a few keyword arguments that are always the same, - # the keyword arguments are the ones we expect. - assert shared_collection.collection.name == route_kwargs.pop( - "collection_name" - ) - assert True == route_kwargs.pop("_external") - assert expect_route_kwargs == route_kwargs - - # The return value of that was the string "a URL". We then - # passed that into _single_entry_response, along with - # `item` and a number of arguments that we made up. - response_call = result._single_entry_response_called_with - (_db, _work, annotator, url, _feed_class), kwargs = response_call - assert shared_collection.db.session == _db - assert work == _work - assert result == annotator - assert "a URL" == url - assert feed_class == _feed_class - - # The only keyword argument is an extra argument propagated from - # the single_item_feed call. - assert "value" == kwargs.pop("extra_arg") - - # Return the MockAnnotator for further examination. - return result - - # Now we're going to call test_annotator a couple times in - # different situations. - work = shared_collection.work - [pool] = work.license_pools - patron = shared_collection.db.patron() - loan, ignore = pool.loan_to(patron) - - # First, let's ask for a single-item feed for a loan. - annotator = test_annotator( - loan, - None, - expect_route="shared_collection_loan_info", - expect_route_kwargs=dict(loan_id=loan.id), - ) - - # Everything tested by test_annotator happened, but _also_, - # when the annotator was created, the Loan was stored in - # active_loans_by_work. - assert {work: loan} == annotator.active_loans_by_work - - # Since we passed in a loan rather than a hold, - # active_holds_by_work is empty. - assert {} == annotator.active_holds_by_work - - # Since we didn't pass in a fulfillment for the loan, - # active_fulfillments_by_work is empty. - assert {} == annotator.active_fulfillments_by_work - - # Now try it again, but give the loan a fulfillment. - fulfillment = object() - annotator = test_annotator( - loan, - fulfillment, - expect_route="shared_collection_loan_info", - expect_route_kwargs=dict(loan_id=loan.id), - ) - assert {work: loan} == annotator.active_loans_by_work - assert {work: fulfillment} == annotator.active_fulfillments_by_work - - # Finally, try it with a hold. - hold, ignore = pool.on_hold_to(patron) - annotator = test_annotator( - hold, - None, - expect_route="shared_collection_hold_info", - expect_route_kwargs=dict(hold_id=hold.id), - ) - assert {work: hold} == annotator.active_holds_by_work - assert {} == annotator.active_loans_by_work - assert {} == annotator.active_fulfillments_by_work diff --git a/tests/api/test_routes.py b/tests/api/test_routes.py index 85111a61ff..7d80e92bfb 100644 --- a/tests/api/test_routes.py +++ b/tests/api/test_routes.py @@ -121,86 +121,6 @@ def test_marc_page(self, fixture: RouteTestFixture): fixture.assert_request_calls(url, fixture.controller.download_page) # type: ignore[union-attr] -class TestSharedCollection: - - CONTROLLER_NAME = "shared_collection_controller" - - @pytest.fixture(scope="function") - def fixture(self, route_test: RouteTestFixture) -> RouteTestFixture: - route_test.set_controller_name(self.CONTROLLER_NAME) - return route_test - - def test_shared_collection_info(self, fixture: RouteTestFixture): - url = "/collections/" - fixture.assert_request_calls(url, fixture.controller.info, "") # type: ignore[union-attr] - - def test_shared_collection_register(self, fixture: RouteTestFixture): - url = "/collections//register" - fixture.assert_request_calls( - url, fixture.controller.register, "", http_method="POST" # type: ignore[union-attr] - ) - fixture.assert_supported_methods(url, "POST") - - def test_shared_collection_borrow_identifier(self, fixture: RouteTestFixture): - url = "/collections////borrow" - fixture.assert_request_calls_method_using_identifier( - url, - fixture.controller.borrow, # type: ignore[union-attr] - "", - "", - "", - None, - ) - fixture.assert_supported_methods(url, "GET", "POST") - - def test_shared_collection_borrow_hold_id(self, fixture: RouteTestFixture): - url = "/collections//holds//borrow" - fixture.assert_request_calls( - url, fixture.controller.borrow, "", None, None, "" # type: ignore[union-attr] - ) - fixture.assert_supported_methods(url, "GET", "POST") - - def test_shared_collection_loan_info(self, fixture: RouteTestFixture): - url = "/collections//loans/" - fixture.assert_request_calls( - url, fixture.controller.loan_info, "", "" # type: ignore[union-attr] - ) - - def test_shared_collection_revoke_loan(self, fixture: RouteTestFixture): - url = "/collections//loans//revoke" - fixture.assert_request_calls( - url, fixture.controller.revoke_loan, "", "" # type: ignore[union-attr] - ) - - def test_shared_collection_fulfill_no_mechanism(self, fixture: RouteTestFixture): - url = "/collections//loans//fulfill" - fixture.assert_request_calls( - url, fixture.controller.fulfill, "", "", None # type: ignore[union-attr] - ) - - def test_shared_collection_fulfill_with_mechanism(self, fixture: RouteTestFixture): - url = "/collections//loans//fulfill/" - fixture.assert_request_calls( - url, - fixture.controller.fulfill, # type: ignore[union-attr] - "", - "", - "", - ) - - def test_shared_collection_hold_info(self, fixture: RouteTestFixture): - url = "/collections//holds/" - fixture.assert_request_calls( - url, fixture.controller.hold_info, "", "" # type: ignore[union-attr] - ) - - def test_shared_collection_revoke_hold(self, fixture: RouteTestFixture): - url = "/collections//holds//revoke" - fixture.assert_request_calls( - url, fixture.controller.revoke_hold, "", "" # type: ignore[union-attr] - ) - - class TestProfileController: CONTROLLER_NAME = "profiles" diff --git a/tests/api/test_shared_collection.py b/tests/api/test_shared_collection.py deleted file mode 100644 index 716cfc75b2..0000000000 --- a/tests/api/test_shared_collection.py +++ /dev/null @@ -1,435 +0,0 @@ -import base64 -import json - -import pytest -from Crypto.Cipher import PKCS1_OAEP -from Crypto.PublicKey import RSA - -from api.circulation import FulfillmentInfo -from api.circulation_exceptions import * -from api.odl import ODLAPI -from api.shared_collection import BaseSharedCollectionAPI, SharedCollectionAPI -from core.config import CannotLoadConfiguration -from core.model import Hold, IntegrationClient, Loan, create, get_one -from tests.core.mock import MockRequestsResponse -from tests.fixtures.database import DatabaseTransactionFixture - - -class MockAPI(BaseSharedCollectionAPI): - def __init__(self, _db, collection): - self.checkouts = [] - self.returns = [] - self.fulfills = [] - self.holds = [] - self.released_holds = [] - self.fulfillment = None - - def checkout_to_external_library(self, client, pool, hold=None): - self.checkouts.append((client, pool)) - - def checkin_from_external_library(self, client, loan): - self.returns.append((client, loan)) - - def fulfill_for_external_library(self, client, loan, mechanism): - self.fulfills.append((client, loan, mechanism)) - return self.fulfillment - - def release_hold_from_external_library(self, client, hold): - self.released_holds.append((client, hold)) - - -class SharedCollectionFixture: - def __init__(self, db: DatabaseTransactionFixture): - self.db = db - self.collection = db.collection(protocol="Mock") - self.collection.integration_configuration.settings_dict = dict( - username="username", password="password", data_source="data_source" - ) - self.shared_collection = SharedCollectionAPI( - db.session, api_map={"Mock": MockAPI} - ) - self.api = self.shared_collection.api(self.collection) - DatabaseTransactionFixture.set_settings( - self.collection.integration_configuration, - **{BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS: ["http://library.org"]} - ) - self.client, ignore = IntegrationClient.register( - db.session, "http://library.org" - ) - edition, self.pool = db.edition( - with_license_pool=True, collection=self.collection - ) - [self.delivery_mechanism] = self.pool.delivery_mechanisms - - -@pytest.fixture(scope="function") -def shared_collection_fixture( - db: DatabaseTransactionFixture, -) -> SharedCollectionFixture: - return SharedCollectionFixture(db) - - -class TestSharedCollectionAPI: - def test_initialization_exception( - self, shared_collection_fixture: SharedCollectionFixture - ): - db = shared_collection_fixture.db - - class MisconfiguredAPI: - def __init__(self, _db, collection): - raise CannotLoadConfiguration("doomed!") - - api_map = {db.default_collection().protocol: MisconfiguredAPI} - shared_collection = SharedCollectionAPI(db.session, api_map=api_map) - # Although the SharedCollectionAPI was created, it has no functioning - # APIs. - assert {} == shared_collection.api_for_collection - - # Instead, the CannotLoadConfiguration exception raised by the - # constructor has been stored in initialization_exceptions. - e = shared_collection.initialization_exceptions[db.default_collection().id] - assert isinstance(e, CannotLoadConfiguration) - assert "doomed!" == str(e) - - def test_api_for_licensepool( - self, shared_collection_fixture: SharedCollectionFixture - ): - db = shared_collection_fixture.db - - collection = db.collection(protocol=ODLAPI.NAME) - collection.integration_configuration.settings_dict = dict( - username="username", password="password", data_source="data_source" - ) - edition, pool = db.edition(with_license_pool=True, collection=collection) - shared_collection = SharedCollectionAPI(db.session) - assert isinstance(shared_collection.api_for_licensepool(pool), ODLAPI) - - def test_api_for_collection( - self, shared_collection_fixture: SharedCollectionFixture - ): - db = shared_collection_fixture.db - - collection = db.collection() - collection.integration_configuration.settings_dict = dict( - username="username", password="password", data_source="data_source" - ) - shared_collection = SharedCollectionAPI(db.session) - # The collection isn't a shared collection, so looking up its API - # raises an exception. - pytest.raises(CirculationException, shared_collection.api, collection) - - collection.protocol = ODLAPI.NAME - shared_collection = SharedCollectionAPI(db.session) - assert isinstance(shared_collection.api(collection), ODLAPI) - - def test_register(self, shared_collection_fixture: SharedCollectionFixture): - db = shared_collection_fixture.db - - # An auth document URL is required to register. - pytest.raises( - InvalidInputException, - shared_collection_fixture.shared_collection.register, - shared_collection_fixture.collection, - None, - ) - - # If the url doesn't return a valid auth document, there's an exception. - auth_response = "not json" - - def do_get(*args, **kwargs): - return MockRequestsResponse(200, content=auth_response) - - pytest.raises( - RemoteInitiatedServerError, - shared_collection_fixture.shared_collection.register, - shared_collection_fixture.collection, - "http://library.org/auth", - do_get=do_get, - ) - - # The auth document also must have a link to the library's catalog. - auth_response = json.dumps({"links": []}) - pytest.raises( - RemoteInitiatedServerError, - shared_collection_fixture.shared_collection.register, - shared_collection_fixture.collection, - "http://library.org/auth", - do_get=do_get, - ) - - # If no external library URLs are configured, no one can register. - auth_response = json.dumps( - {"links": [{"href": "http://library.org", "rel": "start"}]} - ) - DatabaseTransactionFixture.set_settings( - shared_collection_fixture.collection.integration_configuration, - **{BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS: None} - ) - pytest.raises( - AuthorizationFailedException, - shared_collection_fixture.shared_collection.register, - shared_collection_fixture.collection, - "http://library.org/auth", - do_get=do_get, - ) - - # If the library's URL isn't in the configuration, it can't register. - auth_response = json.dumps( - {"links": [{"href": "http://differentlibrary.org", "rel": "start"}]} - ) - DatabaseTransactionFixture.set_settings( - shared_collection_fixture.collection.integration_configuration, - **{BaseSharedCollectionAPI.EXTERNAL_LIBRARY_URLS: ["http://library.org"]} - ) - pytest.raises( - AuthorizationFailedException, - shared_collection_fixture.shared_collection.register, - shared_collection_fixture.collection, - "http://differentlibrary.org/auth", - do_get=do_get, - ) - - # Or if the public key is missing from the auth document. - auth_response = json.dumps( - {"links": [{"href": "http://library.org", "rel": "start"}]} - ) - pytest.raises( - RemoteInitiatedServerError, - shared_collection_fixture.shared_collection.register, - shared_collection_fixture.collection, - "http://library.org/auth", - do_get=do_get, - ) - - auth_response = json.dumps( - { - "public_key": {"type": "not RSA", "value": "123"}, - "links": [{"href": "http://library.org", "rel": "start"}], - } - ) - pytest.raises( - RemoteInitiatedServerError, - shared_collection_fixture.shared_collection.register, - shared_collection_fixture.collection, - "http://library.org/auth", - do_get=do_get, - ) - - auth_response = json.dumps( - { - "public_key": {"type": "RSA"}, - "links": [{"href": "http://library.org", "rel": "start"}], - } - ) - pytest.raises( - RemoteInitiatedServerError, - shared_collection_fixture.shared_collection.register, - shared_collection_fixture.collection, - "http://library.org/auth", - do_get=do_get, - ) - - # Here's an auth document with a valid key. - key = RSA.generate(2048) - public_key = key.publickey().exportKey().decode("utf-8") - encryptor = PKCS1_OAEP.new(key) - auth_response = json.dumps( - { - "public_key": {"type": "RSA", "value": public_key}, - "links": [{"href": "http://library.org", "rel": "start"}], - } - ) - response = shared_collection_fixture.shared_collection.register( - shared_collection_fixture.collection, - "http://library.org/auth", - do_get=do_get, - ) - - # An IntegrationClient has been created. - client = get_one( - db.session, - IntegrationClient, - url=IntegrationClient.normalize_url("http://library.org/"), - ) - decrypted_secret = encryptor.decrypt( - base64.b64decode(response.get("metadata", {}).get("shared_secret")) - ) - assert client is not None - assert client.shared_secret == decrypted_secret.decode("utf-8") - - def test_borrow(self, shared_collection_fixture: SharedCollectionFixture): - db = shared_collection_fixture.db - - # This client is registered, but isn't one of the allowed URLs for the collection - # (maybe it was registered for a different shared collection). - other_client, ignore = IntegrationClient.register( - db.session, "http://other_library.org" - ) - - # Trying to borrow raises an exception. - pytest.raises( - AuthorizationFailedException, - shared_collection_fixture.shared_collection.borrow, - shared_collection_fixture.collection, - other_client, - shared_collection_fixture.pool, - ) - - # A client that's registered with the collection can borrow. - shared_collection_fixture.shared_collection.borrow( - shared_collection_fixture.collection, - shared_collection_fixture.client, - shared_collection_fixture.pool, - ) - assert [ - (shared_collection_fixture.client, shared_collection_fixture.pool) - ] == shared_collection_fixture.api.checkouts - - # If the client's checking out an existing hold, the hold must be for that client. - hold, ignore = create( - db.session, - Hold, - integration_client=other_client, - license_pool=shared_collection_fixture.pool, - ) - pytest.raises( - CannotLoan, - shared_collection_fixture.shared_collection.borrow, - shared_collection_fixture.collection, - shared_collection_fixture.client, - shared_collection_fixture.pool, - hold=hold, - ) - - hold.integration_client = shared_collection_fixture.client - shared_collection_fixture.shared_collection.borrow( - shared_collection_fixture.collection, - shared_collection_fixture.client, - shared_collection_fixture.pool, - hold=hold, - ) - assert [ - (shared_collection_fixture.client, shared_collection_fixture.pool) - ] == shared_collection_fixture.api.checkouts[1:] - - def test_revoke_loan(self, shared_collection_fixture: SharedCollectionFixture): - db = shared_collection_fixture.db - - other_client, ignore = IntegrationClient.register( - db.session, "http://other_library.org" - ) - loan, ignore = create( - db.session, - Loan, - integration_client=other_client, - license_pool=shared_collection_fixture.pool, - ) - pytest.raises( - NotCheckedOut, - shared_collection_fixture.shared_collection.revoke_loan, - shared_collection_fixture.collection, - shared_collection_fixture.client, - loan, - ) - - loan.integration_client = shared_collection_fixture.client - shared_collection_fixture.shared_collection.revoke_loan( - shared_collection_fixture.collection, shared_collection_fixture.client, loan - ) - assert [ - (shared_collection_fixture.client, loan) - ] == shared_collection_fixture.api.returns - - def test_fulfill(self, shared_collection_fixture: SharedCollectionFixture): - db = shared_collection_fixture.db - - other_client, ignore = IntegrationClient.register( - db.session, "http://other_library.org" - ) - loan, ignore = create( - db.session, - Loan, - integration_client=other_client, - license_pool=shared_collection_fixture.pool, - ) - pytest.raises( - CannotFulfill, - shared_collection_fixture.shared_collection.fulfill, - shared_collection_fixture.collection, - shared_collection_fixture.client, - loan, - shared_collection_fixture.delivery_mechanism, - ) - - loan.integration_client = shared_collection_fixture.client - - # If the API does not return content or a content link, the loan can't be fulfilled. - pytest.raises( - CannotFulfill, - shared_collection_fixture.shared_collection.fulfill, - shared_collection_fixture.collection, - shared_collection_fixture.client, - loan, - shared_collection_fixture.delivery_mechanism, - ) - assert [ - ( - shared_collection_fixture.client, - loan, - shared_collection_fixture.delivery_mechanism, - ) - ] == shared_collection_fixture.api.fulfills - - shared_collection_fixture.api.fulfillment = FulfillmentInfo( - shared_collection_fixture.collection, - shared_collection_fixture.pool.data_source.name, - shared_collection_fixture.pool.identifier.type, - shared_collection_fixture.pool.identifier.identifier, - "http://content", - "text/html", - None, - None, - ) - fulfillment = shared_collection_fixture.shared_collection.fulfill( - shared_collection_fixture.collection, - shared_collection_fixture.client, - loan, - shared_collection_fixture.delivery_mechanism, - ) - assert [ - ( - shared_collection_fixture.client, - loan, - shared_collection_fixture.delivery_mechanism, - ) - ] == shared_collection_fixture.api.fulfills[1:] - assert shared_collection_fixture.delivery_mechanism == loan.fulfillment - - def test_revoke_hold(self, shared_collection_fixture: SharedCollectionFixture): - db = shared_collection_fixture.db - - other_client, ignore = IntegrationClient.register( - db.session, "http://other_library.org" - ) - hold, ignore = create( - db.session, - Hold, - integration_client=other_client, - license_pool=shared_collection_fixture.pool, - ) - - pytest.raises( - CannotReleaseHold, - shared_collection_fixture.shared_collection.revoke_hold, - shared_collection_fixture.collection, - shared_collection_fixture.client, - hold, - ) - - hold.integration_client = shared_collection_fixture.client - shared_collection_fixture.shared_collection.revoke_hold( - shared_collection_fixture.collection, shared_collection_fixture.client, hold - ) - assert [ - (shared_collection_fixture.client, hold) - ] == shared_collection_fixture.api.released_holds diff --git a/tests/core/models/test_collection.py b/tests/core/models/test_collection.py index bfe229e5ef..c053371431 100644 --- a/tests/core/models/test_collection.py +++ b/tests/core/models/test_collection.py @@ -333,38 +333,6 @@ def test_default_loan_period( example_collection_fixture.set_default_loan_period(audio, 606, library=library) assert 606 == test_collection.default_loan_period(library, audio) - # Given an integration client rather than a library, use - # a sitewide integration setting rather than a library-specific - # setting. - client = db.integration_client() - - # The default when no value is set. - assert ( - Collection.STANDARD_DEFAULT_LOAN_PERIOD - == test_collection.default_loan_period(client, ebook) - ) - - assert ( - Collection.STANDARD_DEFAULT_LOAN_PERIOD - == test_collection.default_loan_period(client, audio) - ) - - # Set a value, and it's used. - example_collection_fixture.set_default_loan_period(ebook, 347) - assert 347 == test_collection.default_loan_period(client) - assert ( - Collection.STANDARD_DEFAULT_LOAN_PERIOD - == test_collection.default_loan_period(client, audio) - ) - - example_collection_fixture.set_default_loan_period(audio, 349) - assert 349 == test_collection.default_loan_period(client, audio) - - # The same value is used for other clients. - client2 = db.integration_client() - assert 347 == test_collection.default_loan_period(client) - assert 349 == test_collection.default_loan_period(client, audio) - def test_default_reservation_period( self, example_collection_fixture: ExampleCollectionFixture ): diff --git a/tests/core/models/test_integrationclient.py b/tests/core/models/test_integrationclient.py deleted file mode 100644 index a0a94d65a4..0000000000 --- a/tests/core/models/test_integrationclient.py +++ /dev/null @@ -1,81 +0,0 @@ -import datetime - -import pytest - -from core.model.integrationclient import IntegrationClient -from core.util.datetime_helpers import utc_now -from tests.fixtures.database import DatabaseTransactionFixture - - -class TestIntegrationClient: - def test_for_url(self, db: DatabaseTransactionFixture): - now = utc_now() - url = db.fresh_url() - client, is_new = IntegrationClient.for_url(db.session, url) - - # A new IntegrationClient has been created. - assert True == is_new - - # Its .url is a normalized version of the provided URL. - assert client.url == IntegrationClient.normalize_url(url) - - # It has timestamps for created & last_accessed. - assert client.created and client.last_accessed - assert client.created > now - assert True == isinstance(client.created, datetime.datetime) - assert client.created == client.last_accessed - - # It does not have a shared secret. - assert None == client.shared_secret - - # Calling it again on the same URL gives the same object. - client2, is_new = IntegrationClient.for_url(db.session, url) - assert client == client2 - - def test_register(self, db: DatabaseTransactionFixture): - now = utc_now() - client, is_new = IntegrationClient.register(db.session, db.fresh_url()) - - # It creates a shared_secret. - assert client.shared_secret - # And sets a timestamp for created & last_accessed. - assert client.created and client.last_accessed - assert client.created > now - assert True == isinstance(client.created, datetime.datetime) - assert client.created == client.last_accessed - - # It raises an error if the url is already registered and the - # submitted shared_secret is inaccurate. - pytest.raises(ValueError, IntegrationClient.register, db.session, client.url) - pytest.raises( - ValueError, IntegrationClient.register, db.session, client.url, "wrong" - ) - - def test_authenticate(self, db: DatabaseTransactionFixture): - client = db.integration_client() - - result = IntegrationClient.authenticate(db.session, "secret") - assert client == result - - result = IntegrationClient.authenticate(db.session, "wrong_secret") - assert None == result - - def test_normalize_url(self): - # http/https protocol is removed. - url = "https://fake.com" - assert "fake.com" == IntegrationClient.normalize_url(url) - - url = "http://really-fake.com" - assert "really-fake.com" == IntegrationClient.normalize_url(url) - - # www is removed if it exists, along with any trailing / - url = "https://www.also-fake.net/" - assert "also-fake.net" == IntegrationClient.normalize_url(url) - - # Subdomains and paths are retained. - url = "https://www.super.fake.org/wow/" - assert "super.fake.org/wow" == IntegrationClient.normalize_url(url) - - # URL is lowercased. - url = "http://OMG.soVeryFake.gov" - assert "omg.soveryfake.gov" == IntegrationClient.normalize_url(url) diff --git a/tests/core/models/test_patron.py b/tests/core/models/test_patron.py index a9bff58274..4378544f20 100644 --- a/tests/core/models/test_patron.py +++ b/tests/core/models/test_patron.py @@ -89,21 +89,6 @@ def test_on_hold_to(self, db: DatabaseTransactionFixture): assert later == hold.end assert 0 == hold.position - # Make sure we can also hold this book for an IntegrationClient. - client = db.integration_client() - hold, was_new = pool.on_hold_to(client) - assert True == was_new - assert client == hold.integration_client - assert pool == hold.license_pool - - # Holding the book twice for the same IntegrationClient creates two holds, - # since they might be for different patrons on the client. - hold2, was_new = pool.on_hold_to(client) - assert True == was_new - assert client == hold2.integration_client - assert pool == hold2.license_pool - assert hold != hold2 - def test_holds_not_allowed( self, db: DatabaseTransactionFixture, library_fixture: LibraryFixture ): @@ -322,21 +307,6 @@ def test_open_access_loan(self, db: DatabaseTransactionFixture): assert loan == loan2 assert False == was_new - # Make sure we can also loan this book to an IntegrationClient. - client = db.integration_client() - loan, was_new = pool.loan_to(client) - assert True == was_new - assert client == loan.integration_client - assert pool == loan.license_pool - - # Loaning the book to the same IntegrationClient twice creates two loans, - # since these loans could be on behalf of different patrons on the client. - loan2, was_new = pool.loan_to(client) - assert True == was_new - assert client == loan2.integration_client - assert pool == loan2.license_pool - assert loan != loan2 - def test_work(self, db: DatabaseTransactionFixture): """Test the attribute that finds the Work for a Loan or Hold.""" patron = db.patron() @@ -370,11 +340,6 @@ def test_library(self, db: DatabaseTransactionFixture): assert db.default_library() == loan.library loan.patron = None - client = db.integration_client() - loan.integration_client = client - assert None == loan.library - - loan.integration_client = None assert None == loan.library patron.library = db.library() diff --git a/tests/fixtures/database.py b/tests/fixtures/database.py index c63cf6f81a..b874ff578a 100644 --- a/tests/fixtures/database.py +++ b/tests/fixtures/database.py @@ -39,7 +39,6 @@ Genre, Hyperlink, Identifier, - IntegrationClient, Library, LicensePool, MediaTypes, @@ -648,16 +647,6 @@ def identifier(self, identifier_type=Identifier.GUTENBERG_ID, foreign_id=None): id_value = self.fresh_str() return Identifier.for_foreign_id(self.session, identifier_type, id_value)[0] - def integration_client(self, url=None, shared_secret=None) -> IntegrationClient: - url = url or self.fresh_url() - secret = shared_secret or "secret" - return get_one_or_create( - self.session, - IntegrationClient, - shared_secret=secret, - create_method_kwargs=dict(url=url), - )[0] - def fresh_url(self) -> str: return "http://foo.com/" + self.fresh_str() diff --git a/tests/fixtures/odl.py b/tests/fixtures/odl.py index d1c5f8b559..9ef40a2519 100644 --- a/tests/fixtures/odl.py +++ b/tests/fixtures/odl.py @@ -10,7 +10,6 @@ from api.odl2 import ODL2API from core.model import ( Collection, - IntegrationClient, Library, License, LicensePool, @@ -133,9 +132,6 @@ def api(self, collection): api.responses = [] return api - def client(self): - return self.db.integration_client() - def checkin(self, api, patron: Patron, pool: LicensePool) -> Callable[[], None]: """Create a function that, when evaluated, performs a checkin.""" @@ -220,7 +216,6 @@ def __init__( license: License, api, patron: Patron, - client: IntegrationClient, ): self.fixture = odl_fixture self.db = odl_fixture.db @@ -231,8 +226,7 @@ def __init__( self.license = license self.api = api self.patron = patron - self.pool = license.license_pool # type: ignore - self.client = client + self.pool = license.license_pool def checkin( self, patron: Optional[Patron] = None, pool: Optional[LicensePool] = None @@ -263,9 +257,8 @@ def odl_api_test_fixture(odl_test_fixture: ODLTestFixture) -> ODLAPITestFixture: license = odl_test_fixture.license(work) api = odl_test_fixture.api(collection) patron = odl_test_fixture.db.patron() - client = odl_test_fixture.client() return ODLAPITestFixture( - odl_test_fixture, library, collection, work, license, api, patron, client + odl_test_fixture, library, collection, work, license, api, patron ) @@ -307,7 +300,6 @@ def odl2_api_test_fixture(odl2_test_fixture: ODL2TestFixture) -> ODL2APITestFixt license = odl2_test_fixture.license(work) api = odl2_test_fixture.api(collection) patron = odl2_test_fixture.db.patron() - client = odl2_test_fixture.client() return ODL2APITestFixture( - odl2_test_fixture, library, collection, work, license, api, patron, client + odl2_test_fixture, library, collection, work, license, api, patron )