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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions backend/maint-scripts/update_title_flavours_from_books.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
# ruff: noqa: T201
"""This script sets the title flavours from the set of all books belonging to the title.

It also fixes books whose flavours start with an underscore.
"""

from typing import Any

from sqlalchemy import select
from sqlalchemy.orm import Session as OrmSession

from cms_backend import logger
from cms_backend.db import Session
from cms_backend.db.models import Book, Title
from cms_backend.db.title import get_title_by_id


def update_title_flavour(session: OrmSession, title: Title) -> tuple[bool, str]:
if title.archived:
logger.info(f"Skipping archived title {title.id} ({title.name})")
return (False, "Title is archived")

books = session.scalars(
select(Book)
.join(Title, Book.title_id == Title.id)
.distinct()
.order_by(Book.flavour)
.where(Book.flavour.isnot(None), Book.title_id == title.id)
).all()
for book in books:
if book.flavour and book.flavour.startswith("_"):
new_flavour = book.flavour[1:]
logger.info(
f"Updated book {book.name} from {book.flavour} to {new_flavour}"
)
book.flavour = new_flavour
session.add(book)

flavours = [book.flavour for book in books if book.flavour is not None]
title.flavours = []

if not flavours:
logger.info(
f"No flavours found in books belonging to title {title.id} ({title.name})"
)
return (
False,
f"No flavours found in books belonging to title {title.id} ({title.name})",
)

logger.info(f"✓ Updated title {title.id} ({title.name}) flavours to {flavours}")
return (True, "")


def main():

with Session.begin() as session:
title_ids = session.scalars(select(Title.id)).all()
logger.info(f"Found {len(title_ids)} titles to process")
nb_titles_updated = 0
nb_titles_skipped = 0
reasons: list[dict[str, Any]] = []

for title_id in title_ids:
title = get_title_by_id(session, title_id=title_id)
processed, reason = update_title_flavour(session, title)
if processed:
nb_titles_updated += 1
else:
nb_titles_skipped += 1
reasons.append({title.name: reason})

logger.info(
f"Updated {nb_titles_updated} title(s) metadata, skipped "
f"{nb_titles_skipped} titles(s)"
)

if reasons:
print("\nSkipped titles summary:")
print("| Title Name | Reason |")
print("|------------|--------|")
for entry in reasons:
for title_name, reason in entry.items():
print(f"| {title_name} | {reason} |")


if __name__ == "__main__":
main()
8 changes: 8 additions & 0 deletions backend/src/cms_backend/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ async def request_validation_error_handler(_, exc: RequestValidationError):
)


@app.exception_handler(ValueError)
async def value_error_handler(_, exc: ValueError):
return JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"success": False, "message": exc.args[0]},
)


@app.exception_handler(ValidationError)
async def validation_error_handler(_, exc: ValidationError):
# transform the pydantic validation errors to a dictionary mapping
Expand Down
3 changes: 3 additions & 0 deletions backend/src/cms_backend/api/routes/titles.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class BaseTitleCreateUpdateSchema(BaseModel):
publisher: NotEmptyString | None = None
language: NotEmptyString | None = None
illustration_48x48_at_1: Base64Str | None = None
flavours: list[str] | None = None

@model_validator(mode="after")
def validate_unique_collection_titles(self) -> Self:
Expand Down Expand Up @@ -158,6 +159,7 @@ def create_title(
source=title_data.source,
long_description=title_data.long_description,
description=title_data.description,
flavours=title_data.flavours,
)
return create_title_light_schema(title)

Expand Down Expand Up @@ -188,6 +190,7 @@ def update_title(
license_=title_data.license,
relation=title_data.relation,
source=title_data.source,
flavours=title_data.flavours,
)
return create_title_light_schema(title)

Expand Down
9 changes: 9 additions & 0 deletions backend/src/cms_backend/db/book.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ def create_book_full_schema(book: Book) -> BookFullSchema:
current_locations=current_locations,
target_locations=target_locations,
title_archived=book.title.archived if book.title else False,
has_flavour_mismatch=book.flavour not in book.title.flavours
if book.title
else False,
)


Expand Down Expand Up @@ -251,6 +254,12 @@ def move_book(
if not book.title:
raise ValueError(f"Book {book_id} has no associated title.")

if destination == "prod" and book.flavour not in book.title.flavours:
raise ValueError(
f"Book flavour '{book.flavour}' is not in title expected flavours "
f"{book.title.flavours}"
)

existing_filename = current_location.filename

goes_to_staging = destination == "staging"
Expand Down
16 changes: 8 additions & 8 deletions backend/src/cms_backend/db/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,8 @@ def get_books(
Book.date,
Book.flavour,
Book.issues,
).order_by(
Book.has_error.desc(),
Book.location_kind,
Book.needs_file_operation.desc(),
Book.created_at.desc(),
Book.id,
)
Title.flavours,
).join(Title, Book.title_id == Title.id, isouter=True)

if book_id is not None:
stmt = stmt.where(Book.id.cast(String).ilike(f"%{book_id}%"))
Expand Down Expand Up @@ -124,6 +119,9 @@ def get_books(
date=date,
flavour=flavour,
issues=book_issues,
has_flavour_mismatch=flavour not in title_flavours
if title_flavours is not None
else False,
)
for (
book_id_result,
Expand All @@ -138,6 +136,7 @@ def get_books(
date,
flavour,
book_issues,
title_flavours,
) in session.execute(
stmt.offset(skip)
.limit(limit)
Expand All @@ -146,6 +145,7 @@ def get_books(
Book.location_kind,
Book.needs_file_operation,
Book.created_at.desc(),
Book.id,
)
).all()
],
Expand Down Expand Up @@ -327,7 +327,7 @@ def get_book_languages(session: OrmSession) -> BookLanguagesSchema:


def get_book_flavours(session: OrmSession) -> ListResult[str]:
"""Get a list of book falavours"""
"""Get a list of book flavours"""
stmt = (
select(Book.flavour)
.distinct()
Expand Down
3 changes: 3 additions & 0 deletions backend/src/cms_backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ class Title(Base):
maturity: Mapped[str] = mapped_column(init=False, index=True, default="unstable")
events: Mapped[list[str]] = mapped_column(init=False, default_factory=list)
archived: Mapped[bool] = mapped_column(default=False, server_default=false())
flavours: Mapped[list[str]] = mapped_column(
default_factory=list, server_default="{}"
)

books: Mapped[list["Book"]] = relationship(
back_populates="title",
Expand Down
12 changes: 12 additions & 0 deletions backend/src/cms_backend/db/title.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def create_title_full_schema(title: Title) -> TitleFullSchema:
license=title.license,
relation=title.relation,
source=title.source,
flavours=title.flavours,
books=[
BookLightSchema(
id=book.id,
Expand All @@ -64,6 +65,7 @@ def create_title_full_schema(title: Title) -> TitleFullSchema:
date=book.date,
flavour=book.flavour,
issues=book.issues,
has_flavour_mismatch=book.flavour not in title.flavours,
)
for book in sorted(
title.books,
Expand Down Expand Up @@ -107,6 +109,7 @@ def create_title_light_schema(title: Title) -> TitleLightSchema:
license=title.license,
relation=title.relation,
source=title.source,
flavours=title.flavours,
)


Expand Down Expand Up @@ -183,6 +186,7 @@ def get_titles(
Title.license.label("title_license"),
Title.relation.label("title_relation"),
Title.source.label("title_source"),
Title.flavours.label("title_flavours"),
)
.join(CollectionTitle, CollectionTitle.title_id == Title.id, isouter=True)
.join(Collection, CollectionTitle.collection_id == Collection.id, isouter=True)
Expand Down Expand Up @@ -226,6 +230,7 @@ def get_titles(
license=title_license,
relation=title_relation,
source=title_source,
flavours=title_flavours,
)
for (
title_id,
Expand All @@ -242,6 +247,7 @@ def get_titles(
title_license,
title_relation,
title_source,
title_flavours,
) in session.execute(stmt.offset(skip).limit(limit)).all()
],
)
Expand All @@ -263,6 +269,7 @@ def create_title(
license_: str | None = None,
relation: str | None = None,
source: str | None = None,
flavours: list[str] | None = None,
) -> Title:
"""Create a new title"""

Expand All @@ -281,6 +288,7 @@ def create_title(
title.source = source
title.description = description
title.long_description = long_description
title.flavours = [] if flavours is None else flavours
title.events.append(f"{getnow()}: title created")

session.add(title)
Expand Down Expand Up @@ -332,6 +340,7 @@ def update_title(
license_: str | None = None,
relation: str | None = None,
source: str | None = None,
flavours: list[str] | None = None,
) -> Title:
"""Update a title's details

Expand Down Expand Up @@ -428,6 +437,9 @@ def update_title(
title.source = source
title.events.append(f"{getnow()}: source updated from {old_source} to {source}")

if flavours is not None:
title.flavours = flavours

name_changed: bool = False
# Update name if provided
if name and name != title.name:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""add flavours to title

Revision ID: 9dc25bcae26f
Revises: a8f64135b053
Create Date: 2026-05-26 12:24:41.086893

"""

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "9dc25bcae26f"
down_revision = "e70e0c595eb9"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"title",
sa.Column(
"flavours",
postgresql.ARRAY(sa.String()),
server_default="{}",
nullable=False,
),
)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("title", "flavours")
# ### end Alembic commands ###
18 changes: 13 additions & 5 deletions backend/src/cms_backend/mill/processors/title.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,29 @@ def add_book_to_title(session: OrmSession, book: Book, title: Title):
title.relation = book.zim_metadata.get("Relation")
title.source = book.zim_metadata.get("Source")

issues: list[str] = []

different_metadata_keys = get_differing_metadata_keys(book)
if different_metadata_keys:
book.issues = ["metadata mismatch"]
issues.append("metadata mismatch")
book.events.append(
f"{getnow()}: book metadata is different from title metadata: "
f"{','.join(different_metadata_keys)}"
)

if title.flavours and book.flavour not in title.flavours:
issues.append("flavour mismatch")
book.events.append(
f"{getnow()}: book flavour is not in list of title flavours"
)

book.issues = issues

# Determine if this book goes to staging or prod based on
# - title maturity: For now, only 'stable' maturity move straight to prod,
# other maturity moves through staging first
# - if book has different metadata from title
goes_to_staging = (
title.maturity != "stable" or len(different_metadata_keys) != 0
)
# - issues: books with any issues move to staging regardless of maturity
goes_to_staging = title.maturity != "stable" or len(issues) != 0

target_locations = (
[
Expand Down
2 changes: 2 additions & 0 deletions backend/src/cms_backend/schemas/orms.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class TitleLightSchema(BaseModel):
license: str | None
relation: str | None
source: str | None
flavours: list[str]


class BaseTitleCollectionSchema(BaseModel):
Expand Down Expand Up @@ -116,6 +117,7 @@ class BookLightSchema(BaseModel):
date: str | None
flavour: str | None
issues: list[str]
has_flavour_mismatch: bool


class BookFullSchema(BookLightSchema):
Expand Down
Loading
Loading