From ab0ca0ae6b7d191a04674d30b261aac2cd8fd599 Mon Sep 17 00:00:00 2001 From: Daniel Bernstein Date: Thu, 14 Mar 2024 11:40:04 -0700 Subject: [PATCH] Add test for generate inventory report --- bin/generate_inventory_reports | 1 + core/scripts.py | 175 +++++++++++++-------------------- tests/core/test_scripts.py | 138 ++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 104 deletions(-) diff --git a/bin/generate_inventory_reports b/bin/generate_inventory_reports index c0dfe0db54..3fff5d7d07 100755 --- a/bin/generate_inventory_reports +++ b/bin/generate_inventory_reports @@ -6,6 +6,7 @@ import sys bin_dir = os.path.split(__file__)[0] package_dir = os.path.join(bin_dir, "..") sys.path.append(os.path.abspath(package_dir)) + from core.scripts import GenerateInventoryReports GenerateInventoryReports().run() diff --git a/core/scripts.py b/core/scripts.py index ebb302e4ff..d9ba67ebac 100644 --- a/core/scripts.py +++ b/core/scripts.py @@ -26,7 +26,6 @@ from core.lane import Lane from core.metadata_layer import TimestampData from core.model import ( - Admin, BaseCoverageRecord, Collection, Contributor, @@ -2770,31 +2769,6 @@ def suppress_work(self, library: Library, identifier: Identifier) -> None: class GenerateInventoryReports(Script): """Generate inventory reports from queued report tasks""" - HEADER = [ - "title", - "author", - "isbn", - "other_identifier", - "language", - "genre", - "publisher", - "audience", - "format", - "library", - "collection", - "license_duration_in_days", - "license_expiration_date", - "initial_loan_count", - "consumed_loans", - "remaining_loans", - "allowed_concurrent_users", - "max_loan_duration_in_days", - "active_holds_for_library", - "active_loans_for_library", - "active_holds_for_collection", - "active_loans_for_collection", - ] - DATA_SOURCES = [ "Palace Marketplace", "BiblioBoard", @@ -2817,12 +2791,6 @@ def parse_command_line( parser = cls.arg_parser(_db) return parser.parse_known_args(cmd_args)[0] - def load_admin(self, admin_id: int) -> Admin: - admin = self._db.query(Admin).filter(Admin.id == admin_id).first() - if not admin: - raise ValueError(f"Unknown Admin: id = {id}") - return admin - def do_run(self, cmd_args: list[str] | None = None) -> None: parsed = self.parse_command_line(self._db, cmd_args=cmd_args) @@ -2835,8 +2803,6 @@ def do_run(self, cmd_args: list[str] | None = None) -> None: def process_task(self, task: AsyncTask): data = InventoryReportTaskData(**task.data) - - admin = self.load_admin(data.admin_id) files = [] try: current_time = datetime.datetime.now() @@ -2844,7 +2810,8 @@ def process_task(self, task: AsyncTask): attachments = {} for data_source_name in self.DATA_SOURCES: - prefix = f"palace-inventory-report-{data_source_name}-{date_str}" + formatted_ds_name = data_source_name.lower().replace(" ", "_") + prefix = f"palace-inventory-report-{formatted_ds_name}-{date_str}" suffix = ".csv" with tempfile.NamedTemporaryFile( "w", @@ -2888,75 +2855,75 @@ def generate_report(self, data_source_name: str, library_id: int, output_file): writer.writerows(rows) def inventory_report_query(self) -> str: - return """select lp.id as license_pool_id, - e.title, - e.author, - i.identifier, - e.language, - e.publisher, - e.medium as format, - ic.name collection_name, - DATE_PART('day', l.expires::date) - DATE_PART('day',lp.availability_time::date) as license_duration_days, - l.expires license_expiration_date, - l.checkouts_available initial_loan_count, - (l.checkouts_available-l.checkouts_left) consumed_loans, - l.checkouts_left remaining_loans, - l.terms_concurrency allowed_concurrent_users, - coalesce(lib_holds.active_hold_count, 0) library_active_hold_count, - - coalesce(lib_loans.active_loan_count, 0) library_active_loan_count, - CASE WHEN collection_sharing.is_shared_collection THEN lp.patrons_in_hold_queue - ELSE -1 - END shared_hold_queue, - CASE WHEN collection_sharing.is_shared_collection THEN lp.licenses_reserved - ELSE -1 - END shared_hold_queue - from datasources d, - collections c, - integration_configurations ic, - integration_library_configurations il, - libraries lib, - editions e, - identifiers i, - (select ic.parent_id, - count(ic.parent_id) > 1 is_shared_collection - from integration_library_configurations ic, - integration_configurations i, - collections c - where c.integration_configuration_id = i.id and - i.id = ic.parent_id group by ic.parent_id) collection_sharing, - licensepools lp left outer join licenses l on lp.id = l.license_pool_id - left outer join (select h.license_pool_id, - p.library_id, - count(h.id) active_hold_count - from holds h, - patrons p, - libraries l - where p.id = h.patron_id and - p.library_id = l.id and - l.id = :library_id - group by p.library_id, h.license_pool_id) lib_holds on lp.id = lib_holds.license_pool_id - left outer join (select ln.license_pool_id, - p.library_id, - count(ln.id) active_loan_count - from loans ln, - patrons p, - libraries l - where p.id = ln.patron_id and - p.library_id = l.id and - l.id = :library_id - group by p.library_id, ln.license_pool_id) lib_loans on lp.id = lib_holds.license_pool_id - where lp.identifier_id = i.id and - e.primary_identifier_id = i.id and - d.id = e.data_source_id and - c.id = lp.collection_id and - c.integration_configuration_id = ic.id and - ic.id = il.parent_id and - ic.id = collection_sharing.parent_id and - il.library_id = lib.id and - d.name = :data_source_name and - lib.id = :library_id - order by title, author + return """ + select + e.title, + e.author, + i.identifier, + e.language, + e.publisher, + e.medium as format, + ic.name collection_name, + DATE_PART('day', l.expires::date) - DATE_PART('day',lp.availability_time::date) as license_duration_days, + l.expires license_expiration_date, + l.checkouts_available initial_loan_count, + (l.checkouts_available-l.checkouts_left) consumed_loans, + l.checkouts_left remaining_loans, + l.terms_concurrency allowed_concurrent_users, + coalesce(lib_holds.active_hold_count, 0) library_active_hold_count, + coalesce(lib_loans.active_loan_count, 0) library_active_loan_count, + CASE WHEN collection_sharing.is_shared_collection THEN lp.patrons_in_hold_queue + ELSE -1 + END shared_active_hold_count, + CASE WHEN collection_sharing.is_shared_collection THEN lp.licenses_reserved + ELSE -1 + END shared_active_loan_count + from datasources d, + collections c, + integration_configurations ic, + integration_library_configurations il, + libraries lib, + editions e, + identifiers i, + (select ic.parent_id, + count(ic.parent_id) > 1 is_shared_collection + from integration_library_configurations ic, + integration_configurations i, + collections c + where c.integration_configuration_id = i.id and + i.id = ic.parent_id group by ic.parent_id) collection_sharing, + licensepools lp left outer join licenses l on lp.id = l.license_pool_id + left outer join (select h.license_pool_id, + p.library_id, + count(h.id) active_hold_count + from holds h, + patrons p, + libraries l + where p.id = h.patron_id and + p.library_id = l.id and + l.id = :library_id + group by p.library_id, h.license_pool_id) lib_holds on lp.id = lib_holds.license_pool_id + left outer join (select ln.license_pool_id, + p.library_id, + count(ln.id) active_loan_count + from loans ln, + patrons p, + libraries l + where p.id = ln.patron_id and + p.library_id = l.id and + l.id = :library_id + group by p.library_id, ln.license_pool_id) lib_loans on lp.id = lib_holds.license_pool_id + where lp.identifier_id = i.id and + e.primary_identifier_id = i.id and + d.id = e.data_source_id and + c.id = lp.collection_id and + c.integration_configuration_id = ic.id and + ic.id = il.parent_id and + ic.id = collection_sharing.parent_id and + il.library_id = lib.id and + d.name = :data_source_name and + lib.id = :library_id + order by title, author """ diff --git a/tests/core/test_scripts.py b/tests/core/test_scripts.py index bf9eb29cac..f550593dbd 100644 --- a/tests/core/test_scripts.py +++ b/tests/core/test_scripts.py @@ -1,8 +1,10 @@ from __future__ import annotations +import csv import datetime import json import random +from dataclasses import asdict from io import StringIO from unittest.mock import MagicMock, call, create_autospec, patch @@ -35,6 +37,12 @@ get_one, get_one_or_create, ) +from core.model.asynctask import ( + AsyncTaskStatus, + AsyncTaskType, + InventoryReportTaskData, + queue_task, +) from core.model.classification import Classification, Subject from core.model.customlist import CustomList from core.model.devicetokens import DeviceToken, DeviceTokenTypes @@ -52,6 +60,7 @@ CustomListUpdateEntriesScript, DeleteInvisibleLanesScript, Explain, + GenerateInventoryReports, IdentifierInputScript, LaneSweeperScript, LibraryInputScript, @@ -2554,6 +2563,135 @@ def test_suppress_work(self, db: DatabaseTransactionFixture): assert work.suppressed_for == [test_library] +class TestGenerateInventoryReports: + def test_do_run(self, db: DatabaseTransactionFixture): + # create some test data + library = db.library(short_name="test") + collection = db.collection( + name="BiblioBoard Test collection", data_source_name="BiblioBoard" + ) + collection.libraries = [library] + ds = collection.data_source + title = "Leaves of Grass" + author = "Walt Whitman" + email = "test@email.com" + checkouts_left = 10 + checkouts_available = 11 + terms_concurrency = 5 + edition = db.edition(data_source_name=ds.name) + edition.title = title + edition.author = author + work = db.work( + language="eng", + fiction=True, + with_license_pool=False, + data_source_name=ds.name, + presentation_edition=edition, + collection=collection, + ) + licensepool = db.licensepool( + edition=edition, + open_access=False, + data_source_name=ds.name, + set_edition_as_presentation=True, + collection=collection, + ) + + db.license( + pool=licensepool, + checkouts_available=checkouts_available, + checkouts_left=checkouts_left, + terms_concurrency=terms_concurrency, + ) + + data = InventoryReportTaskData( + admin_id=1, library_id=library.id, admin_email=email + ) + task, is_new = queue_task( + db.session, task_type=AsyncTaskType.INVENTORY_REPORT, data=asdict(data) + ) + + assert task.status == AsyncTaskStatus.READY + + script = GenerateInventoryReports(db.session) + send_email_mock = create_autospec(script.services.email.container.send_email) + script.services.email.container.send_email = send_email_mock + script.do_run() + send_email_mock.assert_called_once() + args, kwargs = send_email_mock.call_args + assert task.status == AsyncTaskStatus.SUCCESS + assert kwargs["receivers"] == [email] + assert kwargs["subject"].__contains__("Inventory Report") + attachments: dict = kwargs["attachments"] + csv_titles_strings = [ + "biblioboard", + "palace_marketplace", + "unlimited_listens", + "palace_bookshelf", + ] + + def at_least_one_contains_the_other(list1: [str], list2: [str]) -> bool: + for s1 in list1: + for s2 in list2: + if s1.__contains__(s2): + return True + return False + + assert at_least_one_contains_the_other(attachments.keys(), csv_titles_strings) + + for key in attachments.keys(): + value = attachments[key] + assert len(value) > 0 + if str(key).__contains__("biblioboard"): + csv_file = StringIO(value) + reader = csv.reader(csv_file, delimiter=",") + first_row = None + row_count = 0 + + for row in reader: + row_count += 1 + if not first_row: + first_row = row + row_headers = [ + "title", + "author", + "identifier", + "language", + "publisher", + "format", + "collection_name", + "license_duration_days", + "license_expiration_date", + "initial_loan_count", + "consumed_loans", + "remaining_loans", + "allowed_concurrent_users", + "library_active_hold_count", + "library_active_loan_count", + "shared_active_hold_count", + "shared_active_loan_count", + ] + for h in row_headers: + assert h in row + continue + + assert row[first_row.index("title")] == title + assert row[first_row.index("author")] == author + assert row[first_row.index("shared_active_hold_count")] == "-1" + assert row[first_row.index("shared_active_loan_count")] == "-1" + assert row[first_row.index("initial_loan_count")] == str( + checkouts_available + ) + assert row[first_row.index("consumed_loans")] == str( + checkouts_available - checkouts_left + ) + assert row[first_row.index("allowed_concurrent_users")] == str( + terms_concurrency + ) + + assert row_count == 2 + + class TestWorkConsolidationScript: """TODO"""