Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions src/palace/manager/api/admin/controller/collection_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
from palace.manager.api.admin.form_data import ProcessFormData
from palace.manager.api.admin.problem_details import (
CANNOT_DELETE_COLLECTION_WITH_CHILDREN,
IMPORT_NOT_SUPPORTED,
MISSING_COLLECTION,
MISSING_PARENT,
MISSING_SERVICE,
PROTOCOL_DOES_NOT_SUPPORT_PARENTS,
UNKNOWN_PROTOCOL,
)
from palace.manager.api.admin.util.flask import get_request_admin
from palace.manager.api.circulation.base import CirculationApiType
from palace.manager.api.circulation.base import CirculationApiType, SupportsImport
from palace.manager.celery.tasks.collection_delete import collection_delete
from palace.manager.celery.tasks.reaper import (
reap_unassociated_holds,
Expand Down Expand Up @@ -145,14 +147,11 @@ def process_post(self) -> Response | ProblemDetail:
# If we have an importer task for this protocol, we start it
# in the background, so that the collection is ready to go
# as quickly as possible.
try:
if issubclass(impl_cls, SupportsImport):
impl_cls.import_task(integration.collection.id).apply_async(
# Delay the task to ensure the collection has been created by the time the task starts
countdown=10
)
except NotImplementedError:
# If the protocol does not support import tasks, we just skip it.
...

except ProblemDetailException as e:
self._db.rollback()
Expand Down Expand Up @@ -195,6 +194,44 @@ def process_delete(self, service_id: int) -> Response | ProblemDetail:
collection_delete.delay(collection.id)
return Response("Deleted", 200)

def process_import(self, service_id: int) -> Response | ProblemDetail:
"""Queue a collection import task on demand.

:param service_id: The integration configuration ID of the collection to import.
:return: A 200 response on success, or a ProblemDetail on error.
"""
self.require_system_admin()

integration = get_one(
self._db,
IntegrationConfiguration,
id=service_id,
goal=self.registry.goal,
)
if not integration:
return MISSING_SERVICE

collection = integration.collection
if not collection:
return MISSING_COLLECTION

if collection.marked_for_deletion:
return MISSING_COLLECTION

protocol = integration.protocol
if protocol not in self.registry:
return UNKNOWN_PROTOCOL

impl_cls = self.registry[protocol]
force = flask.request.form.get("force", "false").lower() == "true"

if not issubclass(impl_cls, SupportsImport):
return IMPORT_NOT_SUPPORTED

impl_cls.import_task(collection.id, force=force).apply_async()

return Response("Import task queued.", 200)

def process_collection_self_tests(
self, identifier: int | None
) -> Response | ProblemDetail:
Expand Down
7 changes: 7 additions & 0 deletions src/palace/manager/api/admin/problem_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,13 @@
detail=_("You can only make a lane visible if its parent is already visible."),
)

IMPORT_NOT_SUPPORTED = pd(
"http://palaceproject.io/terms/problem/import-not-supported",
status_code=400,
title=_("Import not supported"),
detail=_("The collection's protocol does not support import."),
)

COLLECTION_DOES_NOT_SUPPORT_REGISTRATION = pd(
"http://librarysimplified.org/terms/problem/collection-does-not-support-registration",
status_code=400,
Expand Down
16 changes: 16 additions & 0 deletions src/palace/manager/api/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from palace.manager.api.admin.problem_details import (
ADMIN_NOT_AUTHORIZED,
INVALID_ADMIN_CREDENTIALS,
MISSING_SERVICE,
)
from palace.manager.api.app import app
from palace.manager.api.controller.static_file import StaticFileController
Expand Down Expand Up @@ -418,6 +419,21 @@ def collection(collection_id):
)


@app.route("/admin/collection/<collection_id>/import", methods=["POST"])
@returns_json_or_response_or_problem_detail
@requires_admin
@requires_csrf_token
def collection_import(collection_id):
try:
integration_id = int(collection_id)
except ValueError:
return MISSING_SERVICE

return app.manager.admin_collection_settings_controller.process_import(
integration_id
)


@app.route("/admin/collection_self_tests/<identifier>", methods=["GET", "POST"])
@returns_json_or_response_or_problem_detail
@requires_admin
Expand Down
30 changes: 21 additions & 9 deletions src/palace/manager/api/circulation/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from abc import ABC, abstractmethod
from collections.abc import Iterable, Mapping
from functools import cached_property
from typing import TypedDict, Unpack
from typing import Any, TypedDict, Unpack

from celery.canvas import Signature
from flask_babel import lazy_gettext as _
Expand Down Expand Up @@ -64,6 +64,20 @@ def internal_format(self, delivery_mechanism: LicensePoolDeliveryMechanism) -> s
return internal_format


class SupportsImport(ABC):
"""Mixin for circulation APIs that support collection import."""

@classmethod
@abstractmethod
def import_task(cls, collection_id: int, force: bool = False) -> Signature:
"""Return the Celery task signature for importing the collection.

:param collection_id: The ID of the collection to import.
:param force: If True, the import will be forced even if it has already been done.
"""
...


class BaseCirculationAPI[
SettingsType: BaseCirculationApiSettings, LibrarySettingsType: BaseSettings
](
Expand Down Expand Up @@ -208,14 +222,12 @@ class FulfillKwargs(TypedDict, total=False):
"""The exponent part of the RSA public key used for DRM."""

@classmethod
def import_task(cls, collection_id: int, force: bool = False) -> Signature:
"""
Return the signature for a Celery task that will import the collection.

:param collection_id: The ID of the collection to import.
:param force: If True, the import will be forced even if it has already been done.
"""
raise NotImplementedError()
def protocol_details(cls, db: Session) -> dict[str, Any]:
"""Include ``supports_import`` so the admin UI knows whether to
offer an import button for collections using this protocol."""
details = super().protocol_details(db)
details["supports_import"] = issubclass(cls, SupportsImport)
return details

@property
@abstractmethod
Expand Down
2 changes: 2 additions & 0 deletions src/palace/manager/integration/license/boundless/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from palace.manager.api.circulation.base import (
BaseCirculationAPI,
PatronActivityCirculationAPI,
SupportsImport,
)
from palace.manager.api.circulation.data import HoldInfo, LoanInfo
from palace.manager.api.circulation.exceptions import (
Expand Down Expand Up @@ -77,6 +78,7 @@
class BoundlessApi(
PatronActivityCirculationAPI[BoundlessSettings, BoundlessLibrarySettings],
HasCollectionSelfTests,
SupportsImport,
):
SET_DELIVERY_MECHANISM_AT = BaseCirculationAPI.BORROW_STEP

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from celery.canvas import Signature
from sqlalchemy.orm import Session

from palace.manager.api.circulation.base import BaseCirculationAPI
from palace.manager.api.circulation.base import BaseCirculationAPI, SupportsImport
from palace.manager.api.circulation.data import HoldInfo, LoanInfo
from palace.manager.api.circulation.exceptions import (
CannotFulfill,
Expand Down Expand Up @@ -46,6 +46,7 @@
class OPDSForDistributorsAPI(
BaseCirculationAPI[OPDSForDistributorsSettings, OPDSForDistributorsLibrarySettings],
HasCollectionSelfTests,
SupportsImport,
):
@classmethod
def settings_class(cls) -> type[OPDSForDistributorsSettings]:
Expand Down
3 changes: 2 additions & 1 deletion src/palace/manager/integration/license/opds/odl/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from sqlalchemy.orm import Session
from uritemplate import URITemplate

from palace.manager.api.circulation.base import BaseCirculationAPI
from palace.manager.api.circulation.base import BaseCirculationAPI, SupportsImport
from palace.manager.api.circulation.data import HoldInfo, LoanInfo
from palace.manager.api.circulation.exceptions import (
AlreadyCheckedOut,
Expand Down Expand Up @@ -84,6 +84,7 @@

class OPDS2WithODLApi(
BaseCirculationAPI[OPDS2WithODLSettings, OPDS2WithODLLibrarySettings],
SupportsImport,
):
"""ODL (Open Distribution to Libraries) is a specification that allows
libraries to manage their own loans and holds. It offers a deeper level
Expand Down
5 changes: 4 additions & 1 deletion src/palace/manager/integration/license/opds/opds1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

from celery.canvas import Signature

from palace.manager.api.circulation.base import SupportsImport
from palace.manager.integration.license.opds.base.api import BaseOPDSAPI
from palace.manager.integration.license.opds.opds1.settings import (
OPDSImporterLibrarySettings,
OPDSImporterSettings,
)


class OPDSAPI(BaseOPDSAPI[OPDSImporterSettings, OPDSImporterLibrarySettings]):
class OPDSAPI(
BaseOPDSAPI[OPDSImporterSettings, OPDSImporterLibrarySettings], SupportsImport
):
@classmethod
def settings_class(cls) -> type[OPDSImporterSettings]:
return OPDSImporterSettings
Expand Down
6 changes: 4 additions & 2 deletions src/palace/manager/integration/license/opds/opds2/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from sqlalchemy.orm import Session
from uritemplate import URITemplate

from palace.manager.api.circulation.base import BaseCirculationAPI
from palace.manager.api.circulation.base import BaseCirculationAPI, SupportsImport
from palace.manager.api.circulation.exceptions import CannotFulfill
from palace.manager.api.circulation.fulfillment import RedirectFulfillment
from palace.manager.integration.license.opds.base.api import BaseOPDSAPI
Expand Down Expand Up @@ -48,7 +48,9 @@ class TemplateVariable(StrEnum):
SUPPORTED_TEMPLATE_VARIABLES: frozenset[str] = frozenset(TemplateVariable)


class OPDS2API(BaseOPDSAPI[OPDS2ImporterSettings, OPDS2ImporterLibrarySettings]):
class OPDS2API(
BaseOPDSAPI[OPDS2ImporterSettings, OPDS2ImporterLibrarySettings], SupportsImport
):
TOKEN_AUTH_CONFIG_KEY = "token_auth_endpoint"
LAST_REAP_TIME_KEY = "last_reap_time"

Expand Down
Loading
Loading