diff --git a/src/palace/manager/api/admin/controller/report.py b/src/palace/manager/api/admin/controller/report.py index 2360a66e92..ce84b144d0 100644 --- a/src/palace/manager/api/admin/controller/report.py +++ b/src/palace/manager/api/admin/controller/report.py @@ -1,4 +1,5 @@ import json +import uuid from http import HTTPStatus import flask @@ -13,12 +14,20 @@ InventoryReportCollectionInfo, InventoryReportInfo, ) -from palace.manager.api.admin.problem_details import ADMIN_NOT_AUTHORIZED +from palace.manager.api.admin.problem_details import ( + ADMIN_NOT_AUTHORIZED, + INVALID_REPORT_KEY, +) from palace.manager.celery.tasks.generate_inventory_and_hold_reports import ( generate_inventory_and_hold_reports, library_report_integrations, ) +from palace.manager.celery.tasks.reports import ( + REPORT_KEY_MAPPING, + generate_report, +) from palace.manager.core.problem_details import INTERNAL_SERVER_ERROR +from palace.manager.reporting.reports.library_collection import LibraryCollectionReport from palace.manager.service.integration_registry.license_providers import ( LicenseProvidersRegistry, ) @@ -27,6 +36,7 @@ from palace.manager.sqlalchemy.model.library import Library from palace.manager.util.log import LoggerMixin from palace.manager.util.problem_detail import ProblemDetail, ProblemDetailException +from palace.manager.util.uuid import uuid_encode def _authorize_from_request( @@ -36,7 +46,7 @@ def _authorize_from_request( :param request: A Flask Request object. :return: A 2-tuple of admin and library, if the admin is authorized for the library. - :raise: ProblemDetailException, if no library or if admin not authorized for the library. + :raise: ProblemDetailException, if no library or if admin is not authorized for the library. """ library = required_library_from_request(request) admin = required_admin_from_request(request) @@ -46,10 +56,64 @@ def _authorize_from_request( class ReportController(LoggerMixin): + def __init__(self, db: Session, registry: LicenseProvidersRegistry): self._db = db self.registry = registry + @classmethod + def report_for_key(cls, key: str) -> type[LibraryCollectionReport]: + if key not in REPORT_KEY_MAPPING: + detail = INVALID_REPORT_KEY.detail or "Unknown report key." + raise ProblemDetailException( + INVALID_REPORT_KEY.detailed(f"{detail.rstrip('. ')} (key='{key}').") + ) + return REPORT_KEY_MAPPING[key] + + def generate_report(self, *, report_key: str) -> Response: + """Generate the report indicated by the report_key.""" + + admin, library = _authorize_from_request(flask.request) + email_address = admin.email + + report = self.report_for_key(report_key) + report_title = report.TITLE + + request_id = uuid_encode(uuid.uuid4()) + + self.log.info( + f"Report '{report_title}' ({report_key}) requested by <{email_address}>. (request ID: {request_id})" + ) + try: + task = generate_report.delay( + key=report_key, + request_id=request_id, + library_id=library.id, + email_address=email_address, + ) + except Exception as e: + msg = f"Failed to generate report '{report_title}' ({report_key}). (request ID: {request_id})" + self.log.error(msg=msg, exc_info=e) + raise ProblemDetailException( + INTERNAL_SERVER_ERROR.detailed(detail=msg) + ) from e + + self.log.info( + f"Report task created: '{report_title}' ({report_key}) for <{email_address}>. " + f"(request ID: {request_id}, task ID: {task.id})" + ) + + response_message = ( + f"The '{report_title}' request was received. " + "Report processing may take a few minutes to complete, depending on current server load. " + f"Upon completion, a notification will be sent to {email_address}." + ) + return Response( + json.dumps({"message": response_message}), + HTTPStatus.ACCEPTED, + mimetype=MediaTypes.APPLICATION_JSON_MEDIA_TYPE, + ) + def inventory_report_info(self) -> Response: """InventoryReportInfo response of reportable collections for a library. diff --git a/src/palace/manager/api/admin/problem_details.py b/src/palace/manager/api/admin/problem_details.py index 925dc46c25..ca3a825ab1 100644 --- a/src/palace/manager/api/admin/problem_details.py +++ b/src/palace/manager/api/admin/problem_details.py @@ -46,6 +46,13 @@ _("There was a problem with the edited metadata."), ) +INVALID_REPORT_KEY = pd( + "http://palaceproject.io/terms/problem/invalid-report-key", + 400, + _("Invalid report key"), + _("No currently defined report is associated with the specified key."), +) + METADATA_REFRESH_PENDING = pd( "http://librarysimplified.org/terms/problem/metadata-refresh-pending", 201, diff --git a/src/palace/manager/api/admin/routes.py b/src/palace/manager/api/admin/routes.py index c23016968f..72dcb9425c 100644 --- a/src/palace/manager/api/admin/routes.py +++ b/src/palace/manager/api/admin/routes.py @@ -689,6 +689,14 @@ def generate_inventory_report(): return app.manager.admin_report_controller.generate_inventory_report() +@library_route("/admin/reports/", methods=["POST"]) +@has_library +@returns_json_or_response_or_problem_detail +@requires_admin +def generate_report(report_key: str): + return app.manager.admin_report_controller.generate_report(report_key=report_key) + + @app.route("/admin/sign_in_again") def admin_sign_in_again(): """Allows an admin with expired credentials to sign back in diff --git a/src/palace/manager/celery/tasks/reports.py b/src/palace/manager/celery/tasks/reports.py new file mode 100644 index 0000000000..ab135e4ca2 --- /dev/null +++ b/src/palace/manager/celery/tasks/reports.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from celery import shared_task +from frozendict import frozendict +from typing_extensions import Unpack + +from palace.manager.celery.task import Task +from palace.manager.reporting.reports.library_collection import ( + LibraryCollectionReport, + LibraryReportKwargs, + LibraryTitleLevelReport, +) +from palace.manager.service.celery.celery import QueueNames + +REPORT_KEY_MAPPING: frozendict[str, type[LibraryCollectionReport]] = frozendict( + {report.KEY: report for report in [LibraryTitleLevelReport]} +) + + +@shared_task(queue=QueueNames.high, bind=True) +def generate_report( + task: Task, *, key: str, **kwargs: Unpack[LibraryReportKwargs] +) -> bool: + report_class = REPORT_KEY_MAPPING[key] + report = report_class.from_task(task, **kwargs) + + with task.session() as session: + success = report.run(session=session) + if not success: + task.log.error( + f"Report task failed: '{report.title}' ({report.key}) for <{report.email_address}>. " + f"(request ID: {report.request_id})" + ) + return success diff --git a/src/palace/manager/reporting/reports/library_collection.py b/src/palace/manager/reporting/reports/library_collection.py index bbaedf5d87..72de09cee1 100644 --- a/src/palace/manager/reporting/reports/library_collection.py +++ b/src/palace/manager/reporting/reports/library_collection.py @@ -17,6 +17,7 @@ from palace.manager.celery.task import Task from palace.manager.core.exceptions import IntegrationException from palace.manager.reporting.model import ReportTable, TTabularDataProcessor +from palace.manager.reporting.tables.library_all_title import LibraryAllTitleReportTable from palace.manager.reporting.util import ( RequestIdLoggerAdapter, TimestampFormat, @@ -368,8 +369,7 @@ def _run_report(self, *, session: Session) -> bool: # Send success notification. self.send_success_notification(access_url=location) self.log.info( - f"Emailed notification for report '{self.title}' ({self.key}) for " - f"library {library.name} ({library.short_name}) to {self.email_address}." + f"Completed report '{self.key}' for {library.name} ({library.short_name})." ) return True @@ -400,3 +400,9 @@ def run(self, *, session: Session) -> bool: ) self.send_error_notification() return False + + +class LibraryTitleLevelReport(LibraryCollectionReport): + KEY = "title-level-report" + TITLE = "Title-Level Report" + TABLE_CLASSES = [LibraryAllTitleReportTable] diff --git a/tests/manager/api/admin/controller/test_report.py b/tests/manager/api/admin/controller/test_report.py index 3adb524f13..f9bbb79f6f 100644 --- a/tests/manager/api/admin/controller/test_report.py +++ b/tests/manager/api/admin/controller/test_report.py @@ -1,4 +1,5 @@ import logging +from functools import partial from http import HTTPStatus from types import SimpleNamespace from typing import Any @@ -7,14 +8,18 @@ import pytest from flask import Response -from palace.manager.api.admin.controller import ReportController +from palace.manager.api.admin.controller import ReportController, report 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.api.admin.problem_details import ( + ADMIN_NOT_AUTHORIZED, + INVALID_REPORT_KEY, +) from palace.manager.api.circulation.base import BaseCirculationAPI from palace.manager.api.problem_details import LIBRARY_NOT_FOUND +from palace.manager.celery.tasks.reports import REPORT_KEY_MAPPING from palace.manager.integration.license.opds.opds1.api import OPDSAPI from palace.manager.integration.license.overdrive.api import OverdriveAPI from palace.manager.sqlalchemy.model.admin import Admin, AdminRole @@ -29,6 +34,7 @@ class ReportControllerFixture: def __init__( self, db: DatabaseTransactionFixture, services_fixture: ServicesFixture ): + self.db = db self.controller = ReportController( db.session, services_fixture.services.integration_registry.license_providers(), @@ -43,8 +49,10 @@ def report_fixture( class TestReportController: + @patch.object(report, "generate_inventory_and_hold_reports") def test_generate_inventory_and_hold_reports( self, + mock_generate_reports: MagicMock, report_fixture: ReportControllerFixture, db: DatabaseTransactionFixture, flask_app_fixture: FlaskAppFixture, @@ -56,15 +64,8 @@ def test_generate_inventory_and_hold_reports( system_admin, _ = create(db.session, Admin, email=email_address) system_admin.add_role(AdminRole.SYSTEM_ADMIN) - with ( - flask_app_fixture.test_request_context( - f"/", - admin=system_admin, - library=library, - ), - patch( - "palace.manager.api.admin.controller.report.generate_inventory_and_hold_reports" - ) as mock_generate_reports, + with flask_app_fixture.test_request_context( + "/", admin=system_admin, library=library ): response = ctrl.generate_inventory_report() assert response.status_code == HTTPStatus.ACCEPTED @@ -75,12 +76,12 @@ def test_generate_inventory_and_hold_reports( email_address=email_address, library_id=library_id ) - @patch( - "palace.manager.api.admin.controller.report.generate_inventory_and_hold_reports" - ) + @patch.object(report, "generate_report") + @patch.object(report, "generate_inventory_and_hold_reports") def test_generate_report_authorization( self, - mock_generate_reports: MagicMock, + mock_generate_inventory_reports: MagicMock, + mock_generate_report: MagicMock, report_fixture: ReportControllerFixture, db: DatabaseTransactionFixture, flask_app_fixture: FlaskAppFixture, @@ -92,11 +93,21 @@ def test_generate_report_authorization( ) task_id = 7 - mock_generate_reports.delay.return_value = SimpleNamespace(id=task_id) - log_message_suffix = f"Task Request Id: {task_id})" + mock_generate_inventory_reports.delay.return_value = SimpleNamespace(id=task_id) + mock_generate_report.delay.return_value = SimpleNamespace(id=task_id) + gen_report_success_message = "Upon completion, a notification will be sent to " + gen_inv_report_success_message = "The completed reports will be sent to " + gen_report_log_suffix = f"task ID: {task_id}" + gen_inv_report_log_suffix = f"Task Request Id: {task_id}" controller = report_fixture.controller - method = controller.generate_inventory_report + generate_inventory_report = controller.generate_inventory_report + + # We'll just use the first key from the report map. + valid_report_key = next(iter(REPORT_KEY_MAPPING)) + generate_report = partial( + controller.generate_report, report_key=valid_report_key + ) library1 = db.library() library2 = db.library() @@ -117,46 +128,87 @@ def test_generate_report_authorization( collection.associated_libraries = [library1, library2] def assert_and_clear_caplog( - response: Response | ProblemDetail, email: str + response: Response | ProblemDetail, + email: str, + success_message: str, + log_suffix: str, ) -> None: assert isinstance(response, Response) assert response.status_code == 202 - assert "The completed reports will be sent to" in response.get_json().get( - "message" - ) - assert email in response.get_json().get("message") - assert log_message_suffix in caplog.text + assert success_message in response.json.get("message") + assert email in response.json.get("message") + assert log_suffix in caplog.text caplog.clear() # Sysadmin can get info for any library. with flask_app_fixture.test_request_context( "/", admin=sysadmin, library=library1 ): - assert_and_clear_caplog(method(), sysadmin_email) + assert_and_clear_caplog( + generate_inventory_report(), + sysadmin_email, + gen_inv_report_success_message, + gen_inv_report_log_suffix, + ) + assert_and_clear_caplog( + generate_report(), + sysadmin_email, + gen_report_success_message, + gen_report_log_suffix, + ) with flask_app_fixture.test_request_context( "/", admin=sysadmin, library=library2 ): - assert_and_clear_caplog(method(), sysadmin_email) + assert_and_clear_caplog( + generate_inventory_report(), + sysadmin_email, + gen_inv_report_success_message, + gen_inv_report_log_suffix, + ) + assert_and_clear_caplog( + generate_report(), + sysadmin_email, + gen_report_success_message, + gen_report_log_suffix, + ) # The librarian for library 1 can get info only for that library... with flask_app_fixture.test_request_context( "/", admin=librarian1, library=library1 ): - assert_and_clear_caplog(method(), librarian_email) + assert_and_clear_caplog( + generate_inventory_report(), + librarian_email, + gen_inv_report_success_message, + gen_inv_report_log_suffix, + ) + assert_and_clear_caplog( + generate_report(), + librarian_email, + gen_report_success_message, + gen_report_log_suffix, + ) + # ... 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: - method() - assert exc.value.problem_detail == ADMIN_NOT_AUTHORIZED + with pytest.raises(ProblemDetailException) as exc1: + generate_inventory_report() + with pytest.raises(ProblemDetailException) as exc2: + generate_report() + assert exc1.value.problem_detail == ADMIN_NOT_AUTHORIZED + assert exc2.value.problem_detail == ADMIN_NOT_AUTHORIZED # A library must be provided. with flask_app_fixture.test_request_context("/", admin=sysadmin, library=None): - with pytest.raises(ProblemDetailException) as exc: - method() - assert exc.value.problem_detail == LIBRARY_NOT_FOUND + with pytest.raises(ProblemDetailException) as exc1: + generate_inventory_report() + with pytest.raises(ProblemDetailException) as exc2: + generate_report() + assert exc1.value.problem_detail == LIBRARY_NOT_FOUND + assert exc2.value.problem_detail == LIBRARY_NOT_FOUND def test_inventory_report_info( self, @@ -195,14 +247,14 @@ def test_inventory_report_info( ): admin_response1 = controller.inventory_report_info() assert admin_response1.status_code == 200 - assert admin_response1.get_json() == success_payload_dict + assert admin_response1.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 + assert admin_response2.json == success_payload_dict # The librarian for library 1 can get info only for that library... with flask_app_fixture.test_request_context( @@ -210,7 +262,7 @@ def test_inventory_report_info( ): librarian1_response1 = controller.inventory_report_info() assert librarian1_response1.status_code == 200 - assert librarian1_response1.get_json() == success_payload_dict + assert librarian1_response1.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 @@ -315,4 +367,146 @@ def test_inventory_report_info_reportable_collections( ): response = controller.inventory_report_info() assert response.status_code == 200 - assert response.get_json() == success_payload_dict + assert response.json == success_payload_dict + + @patch.object(report, "generate_report") + @patch.object(report, "uuid_encode") + def test_generate_report_celery_task( + self, + mock_uuid_encode: MagicMock, + mock_celery_task: MagicMock, + report_fixture: ReportControllerFixture, + db: DatabaseTransactionFixture, + flask_app_fixture: FlaskAppFixture, + ): + fake_encoded_uuid = "fake-uuid" + mock_uuid_encode.return_value = fake_encoded_uuid + + email_address = "admin@email.com" + ctrl = report_fixture.controller + library = db.default_library() + library_id = library.id + system_admin, _ = create(db.session, Admin, email=email_address) + system_admin.add_role(AdminRole.SYSTEM_ADMIN) + test_key = next(iter(REPORT_KEY_MAPPING)) + + with flask_app_fixture.test_request_context( + "/", admin=system_admin, library=library + ): + response = ctrl.generate_report(report_key=test_key) + assert response.status_code == HTTPStatus.ACCEPTED + assert isinstance(response, Response) + assert response.json and email_address in response.json["message"] + + mock_celery_task.delay.assert_called_once_with( + key=test_key, + request_id=fake_encoded_uuid, + email_address=email_address, + library_id=library_id, + ) + + @patch.object(report, "generate_report") + @patch.object(report, "uuid_encode") + def test_generate_report( + self, + mock_uuid_encode: MagicMock, + mock_celery_task: MagicMock, + report_fixture: ReportControllerFixture, + flask_app_fixture: FlaskAppFixture, + caplog: pytest.LogCaptureFixture, + ): + db = report_fixture.db + caplog.set_level( + logging.INFO, + "palace.manager.api.admin.controller.report", + ) + + fake_encoded_uuid = "fake-uuid" + mock_uuid_encode.return_value = fake_encoded_uuid + + task_id = 123 + mock_celery_task.delay.return_value = SimpleNamespace(id=task_id) + log_message_suffix = f"task ID: {task_id})" + + controller = report_fixture.controller + library = db.default_library() + sysadmin = flask_app_fixture.admin_user( + email="sysadmin@example.org", role=AdminRole.SYSTEM_ADMIN + ) + + with flask_app_fixture.test_request_context( + "/", admin=sysadmin, library=library + ): + for report_key, report_info in REPORT_KEY_MAPPING.items(): + response = controller.generate_report(report_key=report_key) + assert response.status_code == HTTPStatus.ACCEPTED + assert isinstance(response, Response) + assert ( + "Upon completion, a notification will be sent to " + in response.json.get("message") + ) + assert sysadmin.email in response.json.get("message") + assert log_message_suffix in caplog.text + assert report_info.TITLE in caplog.text + caplog.clear() + mock_celery_task.delay.assert_called_with( + key=report_key, + request_id=fake_encoded_uuid, + library_id=library.id, + email_address=sysadmin.email, + ) + mock_celery_task.delay.reset_mock() + + def test_generate_report_invalid_key( + self, + report_fixture: ReportControllerFixture, + db: DatabaseTransactionFixture, + flask_app_fixture: FlaskAppFixture, + ): + controller = report_fixture.controller + library = db.default_library() + sysadmin = flask_app_fixture.admin_user( + email="sysadmin@example.org", role=AdminRole.SYSTEM_ADMIN + ) + invalid_key = "invalid_key" + + with flask_app_fixture.test_request_context( + "/", admin=sysadmin, library=library + ): + with pytest.raises(ProblemDetailException) as exc: + controller.generate_report(report_key=invalid_key) + assert exc.value.problem_detail.title == INVALID_REPORT_KEY.title + assert exc.value.problem_detail == INVALID_REPORT_KEY.detailed( + f"No currently defined report is associated with the specified key (key='{invalid_key}')." + ) + + @patch.object(report, "generate_report") + def test_generate_report_exception( + self, + mock_generate_report: MagicMock, + report_fixture: ReportControllerFixture, + db: DatabaseTransactionFixture, + flask_app_fixture: FlaskAppFixture, + caplog: pytest.LogCaptureFixture, + ): + caplog.set_level( + logging.INFO, + "palace.manager.api.admin.controller.report", + ) + mock_generate_report.delay.side_effect = Exception("Test Exception") + controller = report_fixture.controller + library = db.default_library() + sysadmin = flask_app_fixture.admin_user( + email="sysadmin@example.org", role=AdminRole.SYSTEM_ADMIN + ) + valid_key = next(iter(REPORT_KEY_MAPPING)) + + with flask_app_fixture.test_request_context( + "/", admin=sysadmin, library=library + ): + with pytest.raises(ProblemDetailException) as exc: + controller.generate_report(report_key=valid_key) + assert exc.value.problem_detail.status_code == 500 + assert exc.value.problem_detail.detail is not None + assert "Failed to generate report" in exc.value.problem_detail.detail + assert "Test Exception" in caplog.text diff --git a/tests/manager/api/admin/test_routes.py b/tests/manager/api/admin/test_routes.py index 14f8814a36..92999877db 100644 --- a/tests/manager/api/admin/test_routes.py +++ b/tests/manager/api/admin/test_routes.py @@ -770,9 +770,10 @@ def test_change_order(self, fixture: AdminRouteFixture): fixture.assert_supported_methods(url, "POST") -class TestAdminInventoryReports: +class TestAdminReports: CONTROLLER_NAME = "admin_report_controller" - URL = "/admin/reports/inventory_report/" + INVENTORY_REPORT_URL = "/admin/reports/inventory_report/" + REPORT_ENDPOINT_URL = "/admin/reports" @pytest.fixture(scope="function") def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: @@ -780,12 +781,12 @@ def fixture(self, admin_route_fixture: AdminRouteFixture) -> AdminRouteFixture: return admin_route_fixture def test_inventory_report(self, fixture: AdminRouteFixture): - fixture.assert_supported_methods(self.URL, "GET", "POST") + fixture.assert_supported_methods(self.INVENTORY_REPORT_URL, "GET", "POST") def test_inventory_report_info( self, fixture: AdminRouteFixture, monkeypatch: pytest.MonkeyPatch ): - url = self.URL + url = self.INVENTORY_REPORT_URL mock_response = MagicMock( return_value=Response( '{"collections": []}', @@ -803,7 +804,7 @@ def test_inventory_report_info( def test_generate_inventory_report( self, fixture: AdminRouteFixture, monkeypatch: pytest.MonkeyPatch ): - url = self.URL + url = self.INVENTORY_REPORT_URL mock_response = MagicMock( return_value=Response( '{"message": "A success message."}', @@ -818,6 +819,28 @@ def test_generate_inventory_report( url, fixture.controller.generate_inventory_report, http_method="POST" ) + def test_generate_report( + self, fixture: AdminRouteFixture, monkeypatch: pytest.MonkeyPatch + ): + test_report_key = "my-report-key" + url = f"{self.REPORT_ENDPOINT_URL}/{test_report_key}" + mock_response = MagicMock( + return_value=Response( + '{"message": "A success message."}', + status=HTTPStatus.ACCEPTED, + mimetype=MediaTypes.APPLICATION_JSON_MEDIA_TYPE, + ) + ) + monkeypatch.setattr( + fixture.controller.generate_report, "response", mock_response + ) + fixture.assert_authenticated_request_calls( + url, + fixture.controller.generate_report, + http_method="POST", + report_key=test_report_key, + ) + class TestTimestamps: CONTROLLER_NAME = "timestamps_controller" diff --git a/tests/manager/celery/tasks/test_reports.py b/tests/manager/celery/tasks/test_reports.py new file mode 100644 index 0000000000..92d3649567 --- /dev/null +++ b/tests/manager/celery/tasks/test_reports.py @@ -0,0 +1,136 @@ +import logging +import unittest +from unittest.mock import MagicMock, patch + +import pytest + +from palace.manager.celery.tasks.reports import ( + generate_report, +) +from tests.fixtures.celery import CeleryFixture +from tests.fixtures.database import DatabaseTransactionFixture + + +class TestGenerateReportTask: + + @pytest.mark.parametrize( + "expected_success", + ( + pytest.param(True, id="success"), + pytest.param(False, id="failure"), + ), + ) + def test_generate_report_sets_up_and_runs_report( + self, + celery_fixture: CeleryFixture, + db: DatabaseTransactionFixture, + caplog: pytest.LogCaptureFixture, + expected_success: bool, + ): + caplog.set_level(logging.ERROR) + + test_key = "test_key" + test_title = "Test Report" + + test_request_id = "test-request-id" + test_library_id = db.default_library().id + test_email = "test@example.com" + kwargs = { + "request_id": test_request_id, + "library_id": test_library_id, + "email_address": test_email, + } + + mock_report = MagicMock( + key=test_key, + title=test_title, + email_address=test_email, + request_id=test_request_id, + ) + mock_report.run.return_value = expected_success + mock_report_class = MagicMock(return_value=mock_report) + mock_report_class.from_task.return_value = mock_report + + with patch( + "palace.manager.celery.tasks.reports.REPORT_KEY_MAPPING", + {test_key: mock_report_class}, + ): + success = generate_report.delay(key=test_key, **kwargs).wait() + + assert success == expected_success + mock_report_class.from_task.assert_called_once_with(unittest.mock.ANY, **kwargs) + mock_report.run.assert_called_once_with(session=db.session) + + if expected_success: + assert "Report task failed:" not in caplog.text + else: + assert ( + f"Report task failed: '{test_title}' ({test_key}) for <{test_email}>. " + f"(request ID: {test_request_id})" + ) in caplog.text + + def test_generate_report_from_task_exception( + self, celery_fixture: CeleryFixture, db: DatabaseTransactionFixture + ): + """If `from_task` throws an exception, that exception is passed on.""" + test_key = "test-report-key" + + report_class = MagicMock() + report_class.from_task = MagicMock(side_effect=Exception("Test Exception")) + + kwargs = { + "request_id": "test_request_id", + "library_id": 1, + "email_address": "test@example.com", + } + + with ( + patch( + "palace.manager.celery.tasks.reports.REPORT_KEY_MAPPING", + {test_key: report_class}, + ), + pytest.raises(Exception, match="Test Exception"), + ): + generate_report.delay(key=test_key, **kwargs).wait() + + report_class.from_task.assert_called_once_with(unittest.mock.ANY, **kwargs) + + def test_generate_report_run_exception( + self, celery_fixture: CeleryFixture, db: DatabaseTransactionFixture + ): + """If the report run throws an exception, that exception is passed on.""" + test_key = "test-report-key" + + report_class = MagicMock() + report_instance = MagicMock() + report_class.from_task = MagicMock(return_value=report_instance) + report_instance.run.side_effect = Exception("Test Exception") + + kwargs = { + "request_id": "test_request_id", + "library_id": 1, + "email_address": "test@example.com", + } + + with ( + patch( + "palace.manager.celery.tasks.reports.REPORT_KEY_MAPPING", + {test_key: report_class}, + ), + pytest.raises(Exception, match="Test Exception"), + ): + generate_report.delay(key=test_key, **kwargs).wait() + + report_class.from_task.assert_called_once_with(unittest.mock.ANY, **kwargs) + report_instance.run.assert_called_once_with(session=db.session) + + def test_generate_report_key_not_found(self, celery_fixture: CeleryFixture): + kwargs = { + "request_id": "test_request_id", + "library_id": 1, + "email_address": "test@example.com", + } + invalid_key = "😱 invalid_key 😱" + + with pytest.raises(KeyError, match=invalid_key): + generate_report.delay(key=invalid_key, **kwargs).wait()