Skip to content

Commit

Permalink
Adds API to get library-specific info on inventory reports.
Browse files Browse the repository at this point in the history
  • Loading branch information
tdilauro committed May 12, 2024
1 parent dcb23df commit 16b181c
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 2 deletions.
59 changes: 58 additions & 1 deletion src/palace/manager/api/admin/controller/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,80 @@

import flask
from flask import Response
from sqlalchemy import not_, select
from sqlalchemy.orm import Session

from palace.manager.api.admin.model.inventory_report import (
InventoryReportCollectionInfo,
InventoryReportInfo,
)
from palace.manager.api.admin.problem_details import ADMIN_NOT_AUTHORIZED
from palace.manager.celery.tasks.generate_inventory_and_hold_reports import (
generate_inventory_and_hold_reports,
)
from palace.manager.core.problem_details import INTERNAL_SERVER_ERROR
from palace.manager.integration.goals import Goals
from palace.manager.sqlalchemy.constants import MediaTypes
from palace.manager.sqlalchemy.model.admin import Admin
from palace.manager.sqlalchemy.model.collection import Collection
from palace.manager.sqlalchemy.model.integration import (
IntegrationConfiguration,
IntegrationLibraryConfiguration,
)
from palace.manager.sqlalchemy.model.library import Library
from palace.manager.util.log import LoggerMixin
from palace.manager.util.problem_detail import ProblemDetail
from palace.manager.util.problem_detail import ProblemDetail, ProblemDetailException


class ReportController(LoggerMixin):
def __init__(self, db: Session):
self._db = db

def inventory_report_info(self) -> Response:
"""InventoryReportInfo response of reportable collections for a library.
returns: Inventory report info response, if the library exists and
the admin is authorized.
Otherwise, return a 404 response if the library does not exist
or raise an ADMIN_NOT_AUTHORIZED ProblemDetailException, if the
admin is not authorized.
"""
library: Library | None = getattr(flask.request, "library")
if library is None:
return Response(status=404)

admin: Admin = getattr(flask.request, "admin")
if not admin.is_librarian(library):
raise ProblemDetailException(ADMIN_NOT_AUTHORIZED)

collections = self._db.scalars(
select(Collection)
.join(IntegrationConfiguration)
.join(IntegrationLibraryConfiguration)
.where(
IntegrationLibraryConfiguration.library_id == library.id,
IntegrationConfiguration.goal == Goals.LICENSE_GOAL,
not_(
IntegrationConfiguration.settings_dict.contains(
{"include_in_inventory_report": False}
)
),
)
).all()
info = InventoryReportInfo(
collections=[
InventoryReportCollectionInfo(
id=c.id, name=c.integration_configuration.name
)
for c in collections
]
)
return Response(
json.dumps(info.api_dict()),
status=HTTPStatus.OK,
mimetype=MediaTypes.APPLICATION_JSON_MEDIA_TYPE,
)

def generate_inventory_report(self) -> Response | ProblemDetail:
library: Library = getattr(flask.request, "library")
admin: Admin = getattr(flask.request, "admin")
Expand Down
18 changes: 18 additions & 0 deletions src/palace/manager/api/admin/model/inventory_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from pydantic import Field

from palace.manager.util.flask_util import CustomBaseModel


class InventoryReportCollectionInfo(CustomBaseModel):
"""Collection information."""

id: int = Field(..., description="Collection identifier.")
name: str = Field(..., description="Collection name.")


class InventoryReportInfo(CustomBaseModel):
"""Inventory report information."""

collections: list[InventoryReportCollectionInfo] = Field(
..., description="List of collections."
)
18 changes: 18 additions & 0 deletions src/palace/manager/api/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from palace.manager.api.admin.controller.custom_lists import CustomListsController
from palace.manager.api.admin.dashboard_stats import generate_statistics
from palace.manager.api.admin.model.dashboard_statistics import StatisticsResponse
from palace.manager.api.admin.model.inventory_report import InventoryReportInfo
from palace.manager.api.admin.model.quicksight import (
QuicksightDashboardNamesResponse,
QuicksightGenerateUrlRequest,
Expand Down Expand Up @@ -707,6 +708,23 @@ def diagnostics():
return app.manager.timestamps_controller.diagnostics()


@app.route(
"/admin/reports/inventory_report/<path:library_short_name>",
methods=["GET"],
)
@api_spec.validate(
resp=SpecResponse(
HTTP_200=InventoryReportInfo, HTTP_403=ProblemDetailModel, HTTP_404=None
),
tags=["admin.inventory"],
)
@allows_library
@returns_json_or_response_or_problem_detail
@requires_admin
def inventory_report_info():
return app.manager.admin_report_controller.inventory_report_info()


@app.route(
"/admin/reports/generate_inventory_report/<path:library_short_name>",
methods=["POST"],
Expand Down
5 changes: 4 additions & 1 deletion tests/fixtures/api_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ def __init__(self, controller, name):
self.name = name
self.callable_name = name

def response(self):
return flask.Response(f"I called {repr(self)}", 200)

def __call__(self, *args, **kwargs):
"""Simulate a successful method call.
Expand All @@ -59,7 +62,7 @@ def __call__(self, *args, **kwargs):
"""
self.args = args
self.kwargs = kwargs
response = flask.Response("I called %s" % repr(self), 200)
response = self.response()
response.method = self
return response

Expand Down
112 changes: 112 additions & 0 deletions tests/manager/api/admin/controller/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@
import pytest
from flask import Response

from palace.manager.api.admin.controller import ReportController
from palace.manager.api.admin.model.inventory_report import (
InventoryReportCollectionInfo,
InventoryReportInfo,
)
from palace.manager.api.admin.problem_details import ADMIN_NOT_AUTHORIZED
from palace.manager.sqlalchemy.model.admin import Admin, AdminRole
from palace.manager.sqlalchemy.util import create
from palace.manager.util.problem_detail import ProblemDetailException
from tests.fixtures.api_admin import AdminControllerFixture
from tests.fixtures.api_controller import ControllerFixture
from tests.fixtures.database import DatabaseTransactionFixture
from tests.fixtures.flask import FlaskAppFixture


class ReportControllerFixture(AdminControllerFixture):
Expand Down Expand Up @@ -52,3 +61,106 @@ def test_generate_inventory_and_hold_reports(
mock_generate_reports.delay.assert_called_once_with(
email_address=email_address, library_id=library_id
)

def test_inventory_report_info(
self, db: DatabaseTransactionFixture, flask_app_fixture: FlaskAppFixture
):
controller = ReportController(db.session)

library1 = db.library()
library2 = db.library()

sysadmin = flask_app_fixture.admin_user(
email="[email protected]", role=AdminRole.SYSTEM_ADMIN
)
librarian1 = flask_app_fixture.admin_user(
email="[email protected]", role=AdminRole.LIBRARIAN, library=library1
)

collection = db.collection()
collection.libraries = [library1, library2]

success_payload_dict = InventoryReportInfo(
collections=[
InventoryReportCollectionInfo(
id=collection.id, name=collection.integration_configuration.name
)
]
).api_dict()

# Sysadmin can get info for any library.
with flask_app_fixture.test_request_context(
"/", admin=sysadmin, library=library1
):
admin_response1 = controller.inventory_report_info()
assert admin_response1.status_code == 200
assert admin_response1.get_json() == success_payload_dict

with flask_app_fixture.test_request_context(
"/", admin=sysadmin, library=library2
):
admin_response2 = controller.inventory_report_info()
assert admin_response2.status_code == 200
assert admin_response2.get_json() == success_payload_dict

# The librarian for library 1 can get info only for that library...
with flask_app_fixture.test_request_context(
"/", admin=librarian1, library=library1
):
librarian1_response1 = controller.inventory_report_info()
assert librarian1_response1.status_code == 200
assert librarian1_response1.get_json() == success_payload_dict
# ... since it does not have an admin role for library2.
with flask_app_fixture.test_request_context(
"/", admin=librarian1, library=library2
):
with pytest.raises(ProblemDetailException) as exc:
controller.inventory_report_info()
assert exc.value.problem_detail == ADMIN_NOT_AUTHORIZED

# A library must be provided.
with flask_app_fixture.test_request_context("/", admin=sysadmin, library=None):
admin_response_none = controller.inventory_report_info()
assert admin_response_none.status_code == 404

@pytest.mark.parametrize(
"settings, expect_collection",
(
({}, True),
({"include_in_inventory_report": False}, False),
({"include_in_inventory_report": True}, True),
),
)
def test_inventory_report_info_reportable_collections(
self,
db: DatabaseTransactionFixture,
flask_app_fixture: FlaskAppFixture,
settings: dict,
expect_collection: bool,
):
controller = ReportController(db.session)
sysadmin = flask_app_fixture.admin_user(role=AdminRole.SYSTEM_ADMIN)

library = db.library()
collection = db.collection()
collection.libraries = [library]
collection._set_settings(**settings)

expected_collections = (
[InventoryReportCollectionInfo(id=collection.id, name=collection.name)]
if expect_collection
else []
)
expected_collection_count = 1 if expect_collection else 0
success_payload_dict = InventoryReportInfo(
collections=expected_collections
).api_dict()
assert len(expected_collections) == expected_collection_count

# Sysadmin can get info for any library.
with flask_app_fixture.test_request_context(
"/", admin=sysadmin, library=library
):
response = controller.inventory_report_info()
assert response.status_code == 200
assert response.get_json() == success_payload_dict
30 changes: 30 additions & 0 deletions tests/manager/api/admin/test_routes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import logging
from collections.abc import Generator
from http import HTTPStatus
from typing import Any
from unittest.mock import MagicMock

import flask
import pytest
Expand All @@ -17,6 +19,7 @@
from palace.manager.api.controller.circulation_manager import (
CirculationManagerController,
)
from palace.manager.sqlalchemy.constants import MediaTypes
from palace.manager.util.problem_detail import ProblemDetail, ProblemDetailException
from tests.fixtures.api_controller import ControllerFixture
from tests.fixtures.api_routes import MockApp, MockController, MockManager
Expand Down Expand Up @@ -748,6 +751,33 @@ def test_change_order(self, fixture: AdminRouteFixture):
fixture.assert_supported_methods(url, "POST")


class TestAdminInventoryReports:
CONTROLLER_NAME = "admin_report_controller"

@pytest.fixture(scope="function")
def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture:
admin_route_fixture.set_controller_name(self.CONTROLLER_NAME)
return admin_route_fixture

def test_inventory_report_info(
self, fixture: AdminRouteFixture, monkeypatch: pytest.MonkeyPatch
):
url = "/admin/reports/inventory_report/<library_short_name>"
fixture.assert_supported_methods(url, "GET")

mock_response = MagicMock(
return_value=Response(
'{"collections": []}',
status=HTTPStatus.OK,
mimetype=MediaTypes.APPLICATION_JSON_MEDIA_TYPE,
)
)
monkeypatch.setattr(fixture.controller.inventory_report_info, "response", mock_response) # type: ignore
fixture.assert_authenticated_request_calls(
url, fixture.controller.inventory_report_info # type: ignore
)


class TestTimestamps:
CONTROLLER_NAME = "timestamps_controller"

Expand Down

0 comments on commit 16b181c

Please sign in to comment.