From 8605a17d7d953fc91206f6ae7f1353837d45e291 Mon Sep 17 00:00:00 2001 From: Uchechukwu Orji Date: Fri, 3 Jul 2026 14:54:39 +0100 Subject: [PATCH] add API logic to promote book --- backend/src/cms_backend/api/routes/books.py | 44 + backend/src/cms_backend/api/routes/titles.py | 11 +- backend/src/cms_backend/db/book.py | 15 +- backend/src/cms_backend/db/book_actions.py | 351 ++++++ backend/src/cms_backend/db/title.py | 9 +- backend/src/cms_backend/schemas/models.py | 28 +- backend/tests/api/routes/test_books.py | 105 +- backend/tests/db/test_book_actions.py | 1111 ++++++++++++++++++ 8 files changed, 1656 insertions(+), 18 deletions(-) create mode 100644 backend/src/cms_backend/db/book_actions.py create mode 100644 backend/tests/db/test_book_actions.py diff --git a/backend/src/cms_backend/api/routes/books.py b/backend/src/cms_backend/api/routes/books.py index cf929e2..54fad79 100644 --- a/backend/src/cms_backend/api/routes/books.py +++ b/backend/src/cms_backend/api/routes/books.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, Path, Query from fastapi.responses import JSONResponse +from pydantic import Field from sqlalchemy.orm import Session as OrmSession from cms_backend.api.routes.dependencies import get_current_account, require_permission @@ -26,6 +27,10 @@ from cms_backend.db.book import revert_book as db_revert_book from cms_backend.db.book import update_book as db_update_book from cms_backend.db.book import update_book_issues as db_update_book_issues +from cms_backend.db.book_actions import ( + apply_book_promotion_actions, + get_book_promotion_actions, +) from cms_backend.db.books import get_book_flavours as db_get_book_flavours from cms_backend.db.books import get_book_languages as db_get_book_languages from cms_backend.db.books import get_books as db_get_books @@ -33,6 +38,7 @@ from cms_backend.db.models import Account from cms_backend.schemas import BaseModel from cms_backend.schemas.models import ( + BaseBookPromotionAction, BookLanguagesSchema, BookUpdateSchema, ZimUrlsSchema, @@ -230,6 +236,44 @@ def get_book_issues( ) +class PromoteBook(BaseModel): + actions: list[BaseBookPromotionAction] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + + +@router.patch( + "/{book_id}/promote", + dependencies=[ + Depends(require_permission(namespace="book", name="update")), + Depends(require_permission(namespace="title", name="update")), + ], +) +def promote_book( + book_id: Annotated[UUID, Path()], + request: PromoteBook, + session: Annotated[OrmSession, Depends(gen_dbsession)], + current_account: Account = Depends(get_current_account), + *, + dry_run: Annotated[bool, Query()] = True, +) -> JSONResponse: + if dry_run: + actions = get_book_promotion_actions(session, book_id=book_id) + return JSONResponse( + content={"actions": [action.model_dump(mode="json") for action in actions]}, + status_code=HTTPStatus.OK, + ) + else: + apply_book_promotion_actions( + session, + book_id=book_id, + author_id=current_account.id, + actions=request.actions, + ) + return JSONResponse( + content={"actions": []}, + status_code=HTTPStatus.OK, + ) + + @router.get( "/{book_id}/history", dependencies=[Depends(require_permission(namespace="book", name="update"))], diff --git a/backend/src/cms_backend/api/routes/titles.py b/backend/src/cms_backend/api/routes/titles.py index 3b7da59..b7a3a46 100644 --- a/backend/src/cms_backend/api/routes/titles.py +++ b/backend/src/cms_backend/api/routes/titles.py @@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends, Path, Query, Response from fastapi.responses import JSONResponse -from pydantic import Field from sqlalchemy.orm import Session as OrmSession from cms_backend.api.routes.dependencies import ( @@ -41,7 +40,11 @@ from cms_backend.db.title import revert_title as db_revert_title from cms_backend.db.title import update_title as db_update_title from cms_backend.schemas import BaseModel -from cms_backend.schemas.models import TitleCreateSchema, TitleUpdateSchema +from cms_backend.schemas.models import ( + RestoreTitlesSchema, + TitleCreateSchema, + TitleUpdateSchema, +) from cms_backend.schemas.orms import ( TitleFullSchema, TitleHistorySchema, @@ -60,10 +63,6 @@ class TitlesGetSchema(BaseModel): archived: bool = False -class RestoreTitlesSchema(BaseModel): - title_names: list[NotEmptyString] = Field(default_factory=list) - - class RevertTitleSchema(BaseModel): comment: NotEmptyString | None = None diff --git a/backend/src/cms_backend/db/book.py b/backend/src/cms_backend/db/book.py index d975d5f..d83f90c 100644 --- a/backend/src/cms_backend/db/book.py +++ b/backend/src/cms_backend/db/book.py @@ -691,6 +691,15 @@ def get_book_media_count_issues(*, book: Book, latest_book: Book) -> list[str]: return [] +def get_book_unsupported_languages(book: Book) -> list[str]: + + unknown_languages: list[str] = [] + for language_code in book.zim_metadata["Language"].split(","): + if pycountry.languages.get(alpha_3=language_code) is None: # pyright: ignore[reportUnknownMemberType] + unknown_languages.append(language_code) + return unknown_languages + + def get_book_issues( session: OrmSession, book: Book, *, raise_exceptions: bool = False ) -> dict[str, list[str]]: @@ -700,11 +709,7 @@ def get_book_issues( Makes the same assumptions as the update_book_issues function """ issues: dict[str, list[str]] = {} - unknown_languages: list[str] = [] - for language_code in book.zim_metadata["Language"].split(","): - if pycountry.languages.get(alpha_3=language_code) is None: # pyright: ignore[reportUnknownMemberType] - unknown_languages.append(language_code) - + unknown_languages = get_book_unsupported_languages(book) if unknown_languages: issues["invalid language code"] = [ f"book has unknown language code(s) {','.join(unknown_languages)}" diff --git a/backend/src/cms_backend/db/book_actions.py b/backend/src/cms_backend/db/book_actions.py new file mode 100644 index 0000000..d8d0a3d --- /dev/null +++ b/backend/src/cms_backend/db/book_actions.py @@ -0,0 +1,351 @@ +from collections import deque +from typing import Any +from uuid import UUID + +from sqlalchemy.orm import Session as OrmSession + +from cms_backend.db.book import ( + get_book, + get_book_metadata_issues, + get_book_or_none, + get_book_unsupported_languages, + get_differing_metadata_keys, + get_zimcheck_errors, +) +from cms_backend.db.event import create_title_modified_event +from cms_backend.db.exceptions import RecordDoesNotExistError +from cms_backend.db.rules import ( + has_flavour_mismatch, + title_is_missing_mandatory_metadata, +) +from cms_backend.db.title import create_title, restore_title, update_title +from cms_backend.schemas.models import ( + BaseBookPromotionAction, + BookPromotionAction, + RestoreTitlesSchema, + TitleCreateSchema, + TitleUpdateSchema, +) +from cms_backend.schemas.orms import ( + ZimcheckSummarySchema, +) +from cms_backend.utils.zim import ( + get_missing_keys, + get_missing_metadata_keys, +) + + +def get_book_promotion_actions( + session: OrmSession, *, book_id: UUID +) -> list[BookPromotionAction]: + """Get actions required to promote a book to 'prod'.""" + book = get_book_or_none( + session, + book_id=book_id, + has_error=False, + needs_file_operation=False, + needs_processing=False, + locations=["staging", "quarantine"], + ) + if book is None: + raise RecordDoesNotExistError( + f"Book {book_id} does not meet criteria to be validated" + ) + + missing_metadata_keys = get_missing_metadata_keys(book.zim_metadata) + if missing_metadata_keys: + raise ValueError( + "Book is missing mandatory metadata keys and cannot " + "possibly be promoted through to 'prod'" + ) + + metadata_issues = get_book_metadata_issues(book) + if metadata_issues: + raise ValueError( + "Book has bad metadata and cannot be promoted through to 'prod'\n" + f"{'\n'.join(metadata_issues)}" + ) + + actions: list[BookPromotionAction] = [] + unknown_languages = get_book_unsupported_languages(book) + if unknown_languages: + actions.append( + BookPromotionAction( + kind="whitelist_language_codes", + required=False, + data={}, + message=( + "Book has unsupported language code(s): " + f"{','.join(unknown_languages)}. " + "Please contact a CMS admin to whitelist these codes." + ), + ) + ) + + zimcheck_errors = get_zimcheck_errors(book, raise_exceptions=False) + if zimcheck_errors and book.zimcheck_summary: + zimcheck_summary = ZimcheckSummarySchema.model_validate(book.zimcheck_summary) + actions.append( + BookPromotionAction( + kind="whitelist_scraper_from_zimcheck", + required=False, + data={}, + message=( + f"Book has {zimcheck_summary.error_count} zimcheck error(s). " + "Please contact a CMS admin to whitelist this scraper " + "from ZIMcheck checks." + ), + ) + ) + + if book.title is None: + actions.append( + BookPromotionAction( + kind="create_title", + required=True, + data={ + "name": book.name, + "maturity": "stable", + "title": book.zim_metadata["Title"], + "creator": book.zim_metadata["Creator"], + "publisher": book.zim_metadata["Publisher"], + "description": book.zim_metadata["Description"], + "language": book.zim_metadata["Language"], + "illustration_48x48_at_1": book.zim_metadata[ + "Illustration_48x48@1" + ], + "flavours": [] if book.flavour is None else [book.flavour], + "collection_titles": [{"collection_name": "", "path": ""}], + }, + message=( + "No title exists for this book. " + "Create one from the book's metadata. " + "Be sure to add collection entries so the book is assigned to " + "a location." + ), + ) + ) + return actions + + title = book.title + + if title.archived: + actions.append( + BookPromotionAction( + kind="restore_title", + required=True, + data={"title_names": [title.name]}, + message=f"Restore title '{title.name}' from archive", + ) + ) + + if title_is_missing_mandatory_metadata(title) or get_differing_metadata_keys(book): + actions.append( + BookPromotionAction( + kind="update_title_metadata", + required=True, + data={ + "title": book.zim_metadata["Title"], + "creator": book.zim_metadata["Creator"], + "publisher": book.zim_metadata["Publisher"], + "description": book.zim_metadata["Description"], + "language": book.zim_metadata["Language"], + "illustration_48x48_at_1": book.zim_metadata[ + "Illustration_48x48@1" + ], + }, + message=( + "Title is missing or has outdated mandatory metadata. " + "Update it from the book's ZIM metadata." + ), + ) + ) + + if title.maturity != "stable": + actions.append( + BookPromotionAction( + kind="update_title_maturity", + required=False, + data={ + "maturity": "stable", + }, + message=( + f"Title maturity is '{title.maturity}', which prevents " + "books from being promoted directly to 'prod'. " + "Update it to 'stable'." + ), + ) + ) + if len(title.collections) == 0: + actions.append( + BookPromotionAction( + kind="update_title_collections", + required=True, + data={ + "collection_titles": [{"collection_name": "", "path": ""}], + }, + message=( + "Title has no collection entries. " + "Add a collection with a path so book locations can be created." + ), + ) + ) + if has_flavour_mismatch(book.flavour, title.flavours): + if book.flavour is None: + actions.append( + BookPromotionAction( + kind="update_title_flavours", + required=True, + data={"flavours": []}, + message=( + "Book has no flavour but the title has: " + f"{','.join(title.flavours)}. " + "This action will clear all title flavours." + ), + ) + ) + else: + actions.append( + BookPromotionAction( + kind="update_title_flavours", + required=True, + data={"flavours": [book.flavour, *title.flavours]}, + message=( + f"Add '{book.flavour}' to title flavours: " + f"{','.join(title.flavours)}" + ), + ) + ) + + return actions + + +def apply_book_promotion_actions( + session: OrmSession, + *, + book_id: UUID, + actions: list[BaseBookPromotionAction], + author_id: UUID, +) -> None: + """Apply a list of actions to book so that it can be promoted to 'prod'""" + if len(actions) == 0: + raise ValueError("At least one action must be provided to promote the book") + action_kinds = {action.kind for action in actions} + if len(action_kinds) != len(actions): + raise ValueError("Provided actions contain duplicates") + + book = get_book(session, book_id=book_id) + expected_actions = get_book_promotion_actions(session, book_id=book_id) + + expected_actions_set = {action.kind for action in expected_actions} + provided_actions_set = {action.kind for action in actions} + + unknown_actions = provided_actions_set - expected_actions_set + if unknown_actions: + raise ValueError( + "One or more provided actions are not in the list of expected actions " + f"to promote the book. Unexpected actions: " + f"{','.join(sorted(unknown_actions))}; " + f"expected actions: {','.join(sorted(expected_actions_set))}" + ) + expected_required_actions = { + action.kind for action in expected_actions if action.required + } + provided_required_actions = {action.kind for action in actions if action.required} + if expected_required_actions != provided_required_actions: + raise ValueError( + "The required actions provided do not match the expected required " + "actions. Expected required: " + f"{','.join(sorted(expected_required_actions))}; " + f"provided required: {','.join(sorted(provided_required_actions))}" + ) + actions_todo = deque(actions) + create_title_updated_event = False + # Batch the updates to a title in a common payload + title_update_payload: dict[str, Any] = {} + while actions_todo: + action = actions_todo.popleft() + match action.kind: + case "create_title": + payload = TitleCreateSchema.model_validate(action.data) + if not payload.collection_titles: + raise ValueError("Titles must be created with collection details") + create_title(session, author_id=author_id, payload=payload) + case "restore_title": + if not book.title: + raise ValueError( + "Book does not have an associated title to restore" + ) + payload = RestoreTitlesSchema.model_validate(action.data) + if ( + len(payload.title_names) != 1 + or payload.title_names[0] != book.title.name + ): + raise ValueError( + "Only the book's title should be specified in " + "the restore payload" + ) + restore_title( + session, + title_identifier=payload.title_names[0], + author_id=author_id, + ) + create_title_updated_event = True + case "update_title_metadata": + missing_keys = get_missing_keys( + action.data, + "title", + "creator", + "publisher", + "description", + "language", + "illustration_48x48_at_1", + ) + if missing_keys: + raise ValueError("Title must be updated with mandatory metadata") + create_title_updated_event = True + title_update_payload.update(**action.data) + case "update_title_maturity": + if get_missing_keys(action.data, "maturity"): + raise ValueError( + "Action to update title maturity must set maturity value" + ) + create_title_updated_event = True + title_update_payload.update(**action.data) + case "update_title_flavours": + if get_missing_keys(action.data, "flavours"): + raise ValueError( + "Action to update title flavours must set flavours value" + ) + create_title_updated_event = True + title_update_payload.update(**action.data) + case "update_title_collections": + if get_missing_keys(action.data, "collection_titles"): + raise ValueError( + "Action to update title collections must provide " + "must provide collection details" + ) + create_title_updated_event = True + title_update_payload.update(**action.data) + case "whitelist_language_codes" | "whitelist_scraper_from_zimcheck": + pass + if title_update_payload: + if book.title is None: + raise ValueError("Book does not have an associated title to update") + payload = TitleUpdateSchema.model_validate(title_update_payload) + update_title( + session, + title_identifier=book.title.name, + author_id=author_id, + payload=payload, + create_event=False, + ) + if create_title_updated_event: + # Unlink book from title so it will be re-associated with the book during + # event processing + title = book.title + book.title = None + session.add(book) + create_title_modified_event( + session, action="updated", title_name=title.name, title_id=title.id + ) diff --git a/backend/src/cms_backend/db/title.py b/backend/src/cms_backend/db/title.py index 70304dc..5218679 100644 --- a/backend/src/cms_backend/db/title.py +++ b/backend/src/cms_backend/db/title.py @@ -369,6 +369,7 @@ def update_title( title_identifier: str, author_id: UUID, payload: TitleUpdateSchema, + create_event: bool = True, ) -> Title: """Update a title's details @@ -488,14 +489,14 @@ def update_title( f"{getnow()}: locations updated due to title collection change" ) - if name_changed: + for book in title.books: + update_book_issues(session, book) + + if name_changed and create_event: create_title_modified_event( session, action="updated", title_name=title.name, title_id=title.id ) - for book in title.books: - update_book_issues(session, book) - create_title_history_entry(session, title, author_id, payload.comment) return get_title_by_id(session, title_id=title.id) diff --git a/backend/src/cms_backend/schemas/models.py b/backend/src/cms_backend/schemas/models.py index 8ecba0f..1610fc3 100644 --- a/backend/src/cms_backend/schemas/models.py +++ b/backend/src/cms_backend/schemas/models.py @@ -1,7 +1,7 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Annotated, Literal, Self +from typing import Annotated, Any, Literal, Self from uuid import UUID from pydantic import AfterValidator, AnyUrl, Field, model_validator @@ -124,6 +124,32 @@ class TitleUpdateSchema(BaseTitleCreateUpdateSchema): comment: NotEmptyString | None = None +class RestoreTitlesSchema(BaseModel): + title_names: list[NotEmptyString] = Field(default_factory=list) + + class BookUpdateSchema(BaseModel): comment: NotEmptyString | None = None flavour: NotEmptyString | None = None + + +type BookPromotionActionKind = Literal[ + "whitelist_language_codes", + "whitelist_scraper_from_zimcheck", + "create_title", + "restore_title", + "update_title_metadata", + "update_title_maturity", + "update_title_collections", + "update_title_flavours", +] + + +class BaseBookPromotionAction(BaseModel): + kind: BookPromotionActionKind + data: dict[str, Any] + required: bool + + +class BookPromotionAction(BaseBookPromotionAction): + message: str diff --git a/backend/tests/api/routes/test_books.py b/backend/tests/api/routes/test_books.py index bf18f41..e4ca831 100644 --- a/backend/tests/api/routes/test_books.py +++ b/backend/tests/api/routes/test_books.py @@ -2,6 +2,7 @@ from collections.abc import Callable from http import HTTPStatus from pathlib import Path +from unittest.mock import MagicMock, patch from uuid import UUID, uuid4 import pytest @@ -9,8 +10,9 @@ from sqlalchemy.orm import Session as OrmSession from cms_backend.api.token import generate_access_token -from cms_backend.context import Context +from cms_backend.context import Context, parse_bool from cms_backend.db.book import update_book +from cms_backend.db.book_actions import get_book_promotion_actions from cms_backend.db.models import ( Account, Book, @@ -20,7 +22,7 @@ Warehouse, ) from cms_backend.roles import RoleEnum -from cms_backend.schemas.models import BookUpdateSchema +from cms_backend.schemas.models import BaseBookPromotionAction, BookUpdateSchema from cms_backend.utils.datetime import getnow @@ -788,3 +790,102 @@ def test_get_book_issues( headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == expected_status_code + + +@pytest.mark.parametrize( + "permission,dry_run,expected_status_code", + [ + pytest.param(RoleEnum.EDITOR, "true", HTTPStatus.OK, id="editor-dry-run"), + pytest.param(RoleEnum.EDITOR, "false", HTTPStatus.OK, id="editor-apply"), + pytest.param( + RoleEnum.VIEWER, "true", HTTPStatus.UNAUTHORIZED, id="viewer-dry-run" + ), + pytest.param( + RoleEnum.VIEWER, "false", HTTPStatus.UNAUTHORIZED, id="viewer-apply" + ), + ], +) +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_promote_book_permissions( + mock_get_book_unsupported_languages: MagicMock, + mock_get_zimcheck_errors: MagicMock, + dbsession: OrmSession, + client: TestClient, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + create_collection: Callable[..., Collection], + illustration_48x48_at_1: str, + create_account: Callable[..., Account], + dry_run: str, + permission: RoleEnum, + expected_status_code: HTTPStatus, +): + """Test permissions required to promote book""" + account = create_account(permission=permission) + access_token = generate_access_token( + account_id=str(account.id), issue_time=getnow() + ) + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + book = create_book( + flavour="nopic", + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator 2", + "Publisher": "openZIM", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + title = create_title( + name="test_en_all", + title="Test Article", + creator=None, # missing + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + collection = create_collection(name="mycollection") + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + kinds = {a.kind for a in actions} + assert "update_title_metadata" in kinds # creator differs/missing + assert "update_title_maturity" in kinds # default "unstable" + assert "update_title_collections" in kinds # no collections + assert "update_title_flavours" in kinds # flavour mismatch + + # Build apply actions from the generated ones + apply_actions: list[BaseBookPromotionAction] = [] + for action in actions: + data = dict(action.data) + if action.kind == "update_title_collections": + data["collection_titles"] = [ + {"collection_name": collection.name, "path": "/test/path"} + ] + apply_actions.append( + BaseBookPromotionAction( + kind=action.kind, + data=data, + required=action.required, + ) + ) + + response = client.patch( + f"/v1/books/{book.id}/promote", + headers={"Authorization": f"Bearer {access_token}"}, + json={ + "actions": [] + if parse_bool(dry_run) + else [action.model_dump(mode="json") for action in apply_actions] + }, + ) + assert response.status_code == expected_status_code diff --git a/backend/tests/db/test_book_actions.py b/backend/tests/db/test_book_actions.py new file mode 100644 index 0000000..3aabf09 --- /dev/null +++ b/backend/tests/db/test_book_actions.py @@ -0,0 +1,1111 @@ +from collections.abc import Callable +from typing import Any +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from sqlalchemy.orm import Session as OrmSession + +from cms_backend.db.book_actions import ( + apply_book_promotion_actions, + get_book_promotion_actions, +) +from cms_backend.db.exceptions import RecordDoesNotExistError +from cms_backend.db.models import ( + Account, + Book, + Collection, + CollectionTitle, + Title, +) +from cms_backend.schemas.models import BaseBookPromotionAction + + +def test_promotion_actions_book_not_eligible_raises_error( + dbsession: OrmSession, + create_book: Callable[..., Book], +): + """A book with errors or in 'prod' should raise RecordDoesNotExistError.""" + book = create_book(location_kind="prod") + + with pytest.raises( + RecordDoesNotExistError, match=r"does not meet criteria to be validated" + ): + get_book_promotion_actions(dbsession, book_id=book.id) + + +def test_promotion_actions_missing_metadata_keys_raises_error( + dbsession: OrmSession, + create_book: Callable[..., Book], +): + """Book missing mandatory ZIM metadata keys should raise ValueError.""" + book = create_book( + zim_metadata={"Name": "test_en_all"}, + location_kind="quarantine", + ) + + with pytest.raises(ValueError, match=r"missing mandatory metadata keys"): + get_book_promotion_actions(dbsession, book_id=book.id) + + +def test_promotion_actions_bad_metadata_raises_error( + dbsession: OrmSession, + create_book: Callable[..., Book], + illustration_48x48_at_1: str, +): + """Book with bad metadata (e.g. invalid name) should raise ValueError.""" + book = create_book( + zim_metadata={ + "Name": "bad name with spaces", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + + with pytest.raises(ValueError, match=r"bad metadata"): + get_book_promotion_actions(dbsession, book_id=book.id) + + +def test_promotion_actions_unsupported_languages( + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """ + Book with unsupported language codes produces a whitelist_language_codes action + """ + book = create_book( + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "xxx,yyy", # unsupported codes + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + title = create_title( + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="xxx,yyy", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + lang_actions = [ + action for action in actions if action.kind == "whitelist_language_codes" + ] + assert len(lang_actions) == 1 + assert lang_actions[0].required is False + assert lang_actions[0].data == {} + assert "xxx,yyy" in lang_actions[0].message + + +def test_promotion_actions_zimcheck_errors( + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, + monkeypatch: pytest.MonkeyPatch, +): + """Book with ZIMcheck errors produces a whitelist_scraper_from_zimcheck action.""" + monkeypatch.setattr( + "cms_backend.context.Context.zimcheck_scrapers_whitelist_regex", None + ) + book = create_book( + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + book.zimcheck_summary = { + "zimcheck_version": "1.0.0", + "status": False, + "checks": ["internal_urls"], + "error_count": 3, + "warning_count": 1, + "retcode": 1, + } + title = create_title( + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + zimcheck_actions = [ + action for action in actions if action.kind == "whitelist_scraper_from_zimcheck" + ] + assert len(zimcheck_actions) == 1 + assert zimcheck_actions[0].required is False + assert zimcheck_actions[0].data == {} + assert "3" in zimcheck_actions[0].message + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_promotion_actions_no_title( + mock_get_book_unsupported_languages: MagicMock, + mock_get_zimcheck_errors: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + illustration_48x48_at_1: str, +): + """Book without a title produces a create_title action""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + book = create_book( + name="test_en_all", + flavour="maxi", + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + assert len(actions) == 1 + assert actions[0].kind == "create_title" + assert actions[0].required is True + assert actions[0].data["name"] == "test_en_all" + assert actions[0].data["maturity"] == "stable" + assert actions[0].data["flavours"] == ["maxi"] + assert "No title exists" in actions[0].message + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_promotion_actions_archived_title( + mock_get_book_unsupported_languages: MagicMock, + mock_get_zimcheck_errors: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """Book with an archived title produces a restore_title action.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + book = create_book( + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + title = create_title( + name="test_en_all", + archived=True, + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + restore_actions = [a for a in actions if a.kind == "restore_title"] + assert len(restore_actions) == 1 + assert restore_actions[0].required is True + assert restore_actions[0].data["title_names"] == ["test_en_all"] + assert "Restore title" in restore_actions[0].message + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_promotion_actions_missing_mandatory_title_metadata( + mock_get_book_unsupported_languages: MagicMock, + mock_get_zimcheck_errors: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """Title missing mandatory metadata produces an update_title_metadata action.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + book = create_book( + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + # Create title with missing illustration (mandatory metadata) + title = create_title( + name="test_en_all", + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=None, # missing + ) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + metadata_actions = [ + action for action in actions if action.kind == "update_title_metadata" + ] + assert len(metadata_actions) == 1 + assert metadata_actions[0].required is True + assert metadata_actions[0].data["title"] == "Test Article" + assert ( + metadata_actions[0].data["illustration_48x48_at_1"] == illustration_48x48_at_1 + ) + assert "missing or has outdated mandatory metadata" in metadata_actions[0].message + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_promotion_actions_differing_metadata( + mock_get_book_unsupported_languages: MagicMock, + mock_get_zimcheck_errors: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """ + Book with different metadata than its title produces an update_title_metadata + action. + """ + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + book = create_book( + zim_metadata={ + "Name": "test_en_all", + "Title": "Updated Title", + "Creator": "Updated Creator", + "Publisher": "Updated Publisher", + "Date": "2025-01-01", + "Description": "Updated description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + title = create_title( + name="test_en_all", + title="Old Title", + creator="Old Creator", + publisher="Old Publisher", + description="Old description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + metadata_actions = [a for a in actions if a.kind == "update_title_metadata"] + assert len(metadata_actions) == 1 + assert metadata_actions[0].data["title"] == "Updated Title" + assert metadata_actions[0].data["creator"] == "Updated Creator" + assert metadata_actions[0].data["publisher"] == "Updated Publisher" + assert metadata_actions[0].data["description"] == "Updated description" + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_promotion_actions_maturity_not_stable( + mock_get_book_unsupported_languages: MagicMock, + mock_get_zimcheck_errors: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """Title with non-stable maturity produces an update_title_maturity action.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + book = create_book( + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + title = create_title( + name="test_en_all", + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + maturity_actions = [ + action for action in actions if action.kind == "update_title_maturity" + ] + assert len(maturity_actions) == 1 + assert maturity_actions[0].required is False + assert maturity_actions[0].data == {"maturity": "stable"} + assert "prevents" in maturity_actions[0].message + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_promotion_actions_no_collections( + mock_get_book_unsupported_languages: MagicMock, + mock_get_zimcheck_errors: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """Title without collection entries produces an update_title_collections action.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + book = create_book( + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + title = create_title( + name="test_en_all", + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + collection_actions = [a for a in actions if a.kind == "update_title_collections"] + assert len(collection_actions) == 1 + assert collection_actions[0].required is True + assert collection_actions[0].data == { + "collection_titles": [{"collection_name": "", "path": ""}] + } + assert "no collection" in collection_actions[0].message.lower() + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_promotion_actions_flavour_mismatch_no_book_flavour( + mock_get_book_unsupported_languages: MagicMock, + mock_get_zimcheck_errors: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """ + Book with no flavour and title with flavours produces action to clear title + flavours + """ + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + book = create_book( + flavour=None, + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + title = create_title( + name="test_en_all", + flavours=["maxi", "mini"], + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + flavour_actions = [ + action for action in actions if action.kind == "update_title_flavours" + ] + assert len(flavour_actions) == 1 + assert flavour_actions[0].required is True + assert flavour_actions[0].data["flavours"] == [] + assert "clear all title flavours" in flavour_actions[0].message + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_promotion_actions_flavour_mismatch_add_flavour( + mock_get_book_unsupported_languages: MagicMock, + mock_get_zimcheck_errors: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """Book with a flavour not in title produces update_title_flavours action + with book flavour in title flavours + """ + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + book = create_book( + flavour="nopic", + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + title = create_title( + name="test_en_all", + flavours=["maxi", "mini"], + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + flavour_actions = [ + action for action in actions if action.kind == "update_title_flavours" + ] + assert len(flavour_actions) == 1 + assert flavour_actions[0].required is True + assert flavour_actions[0].data["flavours"] == ["nopic", "maxi", "mini"] + assert "Add" in flavour_actions[0].message + + +def test_promotion_actions_multiple_actions( + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, + monkeypatch: pytest.MonkeyPatch, +): + """Book requiring multiple fixes produces all relevant actions.""" + # Ensure scraper is NOT whitelisted + monkeypatch.setattr( + "cms_backend.context.Context.zimcheck_scrapers_whitelist_regex", None + ) + book = create_book( + flavour="nopic", + zim_metadata={ + "Name": "test_en_all", + "Title": "Updated Title", + "Creator": "Updated Creator", + "Publisher": "Updated Publisher", + "Date": "2025-01-01", + "Description": "Updated description", + "Language": "xxx", # unsupported + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + book.zimcheck_summary = { + "zimcheck_version": "1.0.0", + "status": False, + "checks": ["internal_urls"], + "error_count": 2, + "warning_count": 0, + "retcode": 1, + } + # Title: no collections, different metadata (missing Title), not stable maturity + title = create_title( + name="test_en_all", + flavours=["maxi"], + title=None, + creator="Old Creator", + publisher="Old Publisher", + description="Old description", + language="xxx", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + + kinds = {action.kind for action in actions} + assert "whitelist_language_codes" in kinds + assert "whitelist_scraper_from_zimcheck" in kinds + assert "update_title_metadata" in kinds + assert "update_title_maturity" in kinds + assert "update_title_collections" in kinds + assert "update_title_flavours" in kinds + + +def test_promotion_actions_ready_for_prod( + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + create_collection_title: Callable[..., CollectionTitle], + illustration_48x48_at_1: str, +): + """A book with all prerequisites met returns no actions.""" + book = create_book( + flavour="maxi", + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + title = create_title( + name="test_en_all", + flavours=["maxi"], + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + title.maturity = "stable" # override default "unstable" + book.title = title + dbsession.add(book) + dbsession.flush() + + create_collection_title(title=title) + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + assert len(actions) == 0 + + +def test_apply_actions_no_actions_raises_error( + dbsession: OrmSession, +): + """Providing an empty actions list raises ValueError.""" + with pytest.raises(ValueError, match=r"At least one action"): + apply_book_promotion_actions( + dbsession, book_id=uuid4(), actions=[], author_id=uuid4() + ) + + +def test_apply_actions_duplicate_actions_raises_error( + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """Providing duplicate action kinds raises ValueError.""" + book = _create_valid_book(create_book, illustration_48x48_at_1) + title = _create_valid_title(create_title, illustration_48x48_at_1) + book.title = title + dbsession.add(book) + dbsession.flush() + + with pytest.raises(ValueError, match=r"duplicates"): + apply_book_promotion_actions( + dbsession, + book_id=book.id, + actions=[ + BaseBookPromotionAction( + kind="update_title_maturity", + data={"maturity": "stable"}, + required=False, + ), + BaseBookPromotionAction( + kind="update_title_maturity", + data={"maturity": "stable"}, + required=False, + ), + ], + author_id=uuid4(), + ) + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_apply_actions_unknown_action_raises_error( + mock_get_zimcheck_errors: MagicMock, + mock_get_book_unsupported_languages: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + create_collection_title: Callable[..., CollectionTitle], + illustration_48x48_at_1: str, +): + """Providing an action not in expected actions raises ValueError.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + + book = _create_valid_book(create_book, illustration_48x48_at_1) + title = _create_valid_title(create_title, illustration_48x48_at_1) + title.maturity = "stable" + book.title = title + dbsession.add(book) + dbsession.flush() + create_collection_title(title=title) + + with pytest.raises(ValueError, match=r"Unexpected actions"): + apply_book_promotion_actions( + dbsession, + book_id=book.id, + actions=[ + BaseBookPromotionAction( + kind="create_title", + data={}, + required=True, + ), + ], + author_id=uuid4(), + ) + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_apply_actions_required_mismatch_raises_error( + mock_get_zimcheck_errors: MagicMock, + mock_get_book_unsupported_languages: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """Marking an optional action as required raises ValueError.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + + book = _create_valid_book(create_book, illustration_48x48_at_1) + title = _create_valid_title(create_title, illustration_48x48_at_1) + book.title = title + dbsession.add(book) + dbsession.flush() + + with pytest.raises(ValueError, match=r"required actions"): + apply_book_promotion_actions( + dbsession, + book_id=book.id, + actions=[ + BaseBookPromotionAction( + kind="update_title_maturity", + data={"maturity": "stable"}, + required=True, # should be False + ), + ], + author_id=uuid4(), + ) + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_apply_actions_restore_title_wrong_name_raises_error( + mock_get_zimcheck_errors: MagicMock, + mock_get_book_unsupported_languages: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_collection_title: Callable[..., CollectionTitle], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """Restoring with a title name different from the book's title raises ValueError.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + + book = _create_valid_book(create_book, illustration_48x48_at_1) + title = create_title( + name="test_en_all", + archived=True, + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + create_collection_title(title=title) + book.title = title + dbsession.add(book) + dbsession.flush() + + with pytest.raises(ValueError, match=r"Only the book's title should be specified"): + apply_book_promotion_actions( + dbsession, + book_id=book.id, + actions=[ + BaseBookPromotionAction( + kind="restore_title", + data={"title_names": ["wrong_title"]}, + required=True, + ), + ], + author_id=uuid4(), + ) + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_apply_actions_update_collections_without_entries_raises_error( + mock_get_zimcheck_errors: MagicMock, + mock_get_book_unsupported_languages: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + illustration_48x48_at_1: str, +): + """Applying update_title_collections with empty entries raises ValueError.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + + book = _create_valid_book(create_book, illustration_48x48_at_1) + title = _create_valid_title(create_title, illustration_48x48_at_1) + title.maturity = "stable" + book.title = title + dbsession.add(book) + dbsession.flush() + + with pytest.raises(ValueError, match=r"must provide collection details"): + apply_book_promotion_actions( + dbsession, + book_id=book.id, + actions=[ + BaseBookPromotionAction( + kind="update_title_collections", + data={}, # no collection_titles + required=True, + ), + ], + author_id=uuid4(), + ) + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_apply_actions_create_title( + mock_get_zimcheck_errors: MagicMock, + mock_get_book_unsupported_languages: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_collection: Callable[..., Collection], + illustration_48x48_at_1: str, + account: Account, +): + """Applying a create_title action creates a new title.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + + book = create_book( + name="test_en_all", + flavour="maxi", + zim_metadata=_make_metadata(illustration_48x48_at_1), + location_kind="quarantine", + ) + collection = create_collection(name="mycollection") + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + assert len(actions) == 1 + assert actions[0].kind == "create_title" + + # Fill in the collection details + apply_data = dict(actions[0].data) + apply_data["collection_titles"] = [ + {"collection_name": collection.name, "path": "/test/path"} + ] + + apply_book_promotion_actions( + dbsession, + book_id=book.id, + actions=[ + BaseBookPromotionAction( + kind="create_title", + data=apply_data, + required=True, + ), + ], + author_id=account.id, + ) + + # Verify the title was created + title = dbsession.query(Title).filter(Title.name == "test_en_all").one_or_none() + assert title is not None + assert title.title == "Test Article" + assert title.flavours == ["maxi"] + assert title.maturity == "stable" + assert len(title.collections) == 1 + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +def test_apply_actions_restore_title( + mock_get_zimcheck_errors: MagicMock, + mock_get_book_unsupported_languages: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + create_collection_title: Callable[..., CollectionTitle], + illustration_48x48_at_1: str, + account: Account, +): + """Applying a restore_title action restores an archived title.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + + book = _create_valid_book(create_book, illustration_48x48_at_1) + title = create_title( + name="test_en_all", + archived=True, + title="Test Article", + creator="Test Creator", + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + create_collection_title(title=title) + book.title = title + dbsession.add(book) + dbsession.flush() + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + restore_actions = [a for a in actions if a.kind == "restore_title"] + assert len(restore_actions) == 1 + + apply_book_promotion_actions( + dbsession, + book_id=book.id, + actions=[ + BaseBookPromotionAction( + kind="restore_title", + data={"title_names": ["test_en_all"]}, + required=True, + ), + ], + author_id=account.id, + ) + + dbsession.refresh(title) + assert title.archived is False + + +@patch("cms_backend.db.book_actions.get_book_unsupported_languages") +@patch("cms_backend.db.book_actions.get_zimcheck_errors") +@patch("cms_backend.db.book_actions.create_title_modified_event") +def test_apply_actions_batched_updates( + mock_create_title_modified_event: MagicMock, + mock_get_book_unsupported_languages: MagicMock, + mock_get_zimcheck_errors: MagicMock, + dbsession: OrmSession, + create_book: Callable[..., Book], + create_title: Callable[..., Title], + create_collection: Callable[..., Collection], + illustration_48x48_at_1: str, + account: Account, +): + """Multiple update_title_* actions are batched into a single update call.""" + mock_get_zimcheck_errors.return_value = [] + mock_get_book_unsupported_languages.return_value = [] + book = create_book( + flavour="nopic", + zim_metadata={ + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator 2", + "Publisher": "openZIM", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + }, + location_kind="quarantine", + ) + title = create_title( + name="test_en_all", + title="Test Article", + creator=None, # missing + publisher="Test Publisher", + description="Test description", + language="eng", + illustration_48x48_at_1=illustration_48x48_at_1, + ) + book.title = title + dbsession.add(book) + dbsession.flush() + collection = create_collection(name="mycollection") + + actions = get_book_promotion_actions(dbsession, book_id=book.id) + kinds = {a.kind for a in actions} + assert "update_title_metadata" in kinds # creator differs/missing + assert "update_title_maturity" in kinds # default "unstable" + assert "update_title_collections" in kinds # no collections + assert "update_title_flavours" in kinds # flavour mismatch + + # Build apply actions from the generated ones + apply_actions: list[BaseBookPromotionAction] = [] + for action in actions: + data = dict(action.data) + if action.kind == "update_title_collections": + data["collection_titles"] = [ + {"collection_name": collection.name, "path": "/test/path"} + ] + apply_actions.append( + BaseBookPromotionAction( + kind=action.kind, + data=data, + required=action.required, + ) + ) + + apply_book_promotion_actions( + dbsession, + book_id=book.id, + actions=apply_actions, + author_id=account.id, + ) + + dbsession.refresh(title) + assert title.creator == "Test Creator 2" + assert title.maturity == "stable" + assert len(title.collections) == 1 + assert "nopic" in title.flavours + mock_create_title_modified_event.assert_called_once() + + +def _make_metadata(illustration_48x48_at_1: str) -> dict[str, Any]: + """Return valid ZIM metadata for a test book.""" + return { + "Name": "test_en_all", + "Title": "Test Article", + "Creator": "Test Creator", + "Publisher": "Test Publisher", + "Date": "2025-01-01", + "Description": "Test description", + "Language": "eng", + "Illustration_48x48@1": illustration_48x48_at_1, + } + + +def _create_valid_book( + create_book: Callable[..., Book], + illustration_48x48_at_1: str, +) -> Book: + """Create a book with all mandatory metadata, in quarantine, ready for promotion.""" + return create_book( + zim_metadata=_make_metadata(illustration_48x48_at_1), + location_kind="quarantine", + ) + + +def _create_valid_title( + create_title: Callable[..., Title], + illustration_48x48_at_1: str, + **kwargs: Any, +) -> Title: + """Create a title with full mandatory metadata.""" + defaults: dict[str, Any] = { + "name": "test_en_all", + "title": "Test Article", + "creator": "Test Creator", + "publisher": "Test Publisher", + "description": "Test description", + "language": "eng", + "illustration_48x48_at_1": illustration_48x48_at_1, + } + defaults.update(kwargs) + return create_title(**defaults)