V4.4 Complete

This commit is contained in:
Jim Lancaster
2026-08-15 14:30:33 -05:00
parent 63373bf24d
commit 7db4df1729
32 changed files with 1529 additions and 716 deletions
+6 -54
View File
@@ -12,7 +12,6 @@ from fastapi import Response
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field
from pydantic import model_validator
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
@@ -28,27 +27,6 @@ class ApiModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
class SelectorRequest(ApiModel):
@model_validator(mode="after")
def require_exactly_one_selector(self):
values = (self.selector_id, self.selector_code)
if sum(value is not None for value in values) != 1:
raise ValueError(f"Provide exactly one of {self.selector_names[0]} or {self.selector_names[1]}")
return self
@property
def selector_id(self) -> UUID | None:
raise NotImplementedError
@property
def selector_code(self) -> str | None:
raise NotImplementedError
@property
def selector_names(self) -> tuple[str, str]:
raise NotImplementedError
class DocumentTypeRead(ApiModel):
id: UUID
label: str
@@ -57,7 +35,6 @@ class DocumentTypeRead(ApiModel):
class PersonRoleRead(ApiModel):
id: UUID
code: str
label: str
is_active: bool
@@ -73,39 +50,19 @@ class DocumentTypeWriteResponse(ApiModel):
class DocumentPersonWriteRequest(ApiModel):
person_id: UUID
role_id: UUID | None = None
role_code: str | None = Field(default=None, min_length=1, pattern=r"^[a-z0-9_]+$")
@model_validator(mode="after")
def reject_conflicting_role_selectors(self):
if self.role_id is not None and self.role_code is not None:
raise ValueError("Provide role_id or role_code, not both")
return self
role_id: UUID
class DocumentPersonRoleUpdateRequest(SelectorRequest):
role_id: UUID | None = None
role_code: str | None = Field(default=None, min_length=1, pattern=r"^[a-z0-9_]+$")
@property
def selector_id(self) -> UUID | None:
return self.role_id
@property
def selector_code(self) -> str | None:
return self.role_code
@property
def selector_names(self) -> tuple[str, str]:
return "role_id", "role_code"
class DocumentPersonRoleUpdateRequest(ApiModel):
role_id: UUID
class DocumentPersonRead(ApiModel):
id: UUID
document_id: UUID
person_id: UUID
role_id: UUID | None
role_code: str
role_id: UUID
role_label: str | None = None
person_name: str | None = None
@@ -125,22 +82,19 @@ def _document_type_to_read(item: DocumentType) -> DocumentTypeRead:
def _person_role_to_read(item: PersonRole) -> PersonRoleRead:
return PersonRoleRead(
id=item.id,
code=item.code,
label=item.label,
is_active=item.is_active,
)
def _document_person_to_read(item: DocumentPerson) -> DocumentPersonRead:
role_code = item.role_ref.code if item.role_ref is not None else str(item.role)
person_name = item.person.full_name if item.person is not None else None
return DocumentPersonRead(
id=item.id,
document_id=item.document_id,
person_id=item.person_id,
role_id=item.role_id,
role_code=role_code,
role_label=item.role_ref.label if item.role_ref is not None else None,
person_name=person_name,
)
@@ -224,7 +178,6 @@ async def add_document_person_link(
document_id=document_id,
person_id=payload.person_id,
role_id=payload.role_id,
role_code=payload.role_code,
)
return _document_person_to_read(link)
@@ -238,7 +191,6 @@ async def set_document_person_role(
link = await service.set_document_person_role(
document_person_id=document_person_id,
role_id=payload.role_id,
role_code=payload.role_code,
)
return _document_person_to_read(link)
+54
View File
@@ -0,0 +1,54 @@
"""Safe media route for V4.4 Document print previews."""
from __future__ import annotations
from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Request
from fastapi.responses import FileResponse
from transcription.services.sources import SOURCE_MIME_TYPES
from transcription.services.sources import SourceService
router = APIRouter(prefix="/api/v4", tags=["v4-print"])
def get_source_service(request: Request) -> SourceService:
services = getattr(request.app.state, "services", None)
if services is not None:
return services.sources
return SourceService()
SourceServiceDependency = Annotated[SourceService, Depends(get_source_service)]
@router.get("/documents/{document_id}/sources/{source_id}/media", response_class=FileResponse)
async def read_document_source_media(
document_id: UUID,
source_id: UUID,
service: SourceServiceDependency,
) -> FileResponse:
"""Serve one validated Source through record identifiers, never a supplied path."""
source = await service.read_source(source_id)
if source.document_id != document_id:
raise HTTPException(status_code=404, detail="Source not found for Document")
path = Path(source.file_path).resolve()
upload_root = service.settings.upload_dir.resolve()
try:
path.relative_to(upload_root)
except ValueError as exc:
raise HTTPException(status_code=404, detail="Source media is outside managed storage") from exc
if not path.is_file():
raise HTTPException(status_code=404, detail="Source media is unavailable")
media_type = SOURCE_MIME_TYPES.get(path.suffix.lower())
if media_type is None:
raise HTTPException(status_code=415, detail="Unsupported Source media type")
return FileResponse(path, media_type=media_type, filename=source.upload_name)
+2
View File
@@ -17,6 +17,7 @@ from fastapi.staticfiles import StaticFiles
from .api.errors import register_error_handlers
from .api.health import router as health_router
from .api.v4_documents import router as v4_documents_router
from .api.v4_print import router as v4_print_router
from .config import Settings
from .config import configure_logging
from .config import get_settings
@@ -105,5 +106,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
register_error_handlers(app)
app.include_router(health_router)
app.include_router(v4_documents_router)
app.include_router(v4_print_router)
register_pages(app)
return app
+5 -13
View File
@@ -44,12 +44,6 @@ class JobStatus(StrEnum):
FAILED = "failed"
class DocumentPersonRole(StrEnum):
AUTHOR = "author"
RECIPIENT = "recipient"
MENTIONED = "mentioned"
class JobSourceStatus(StrEnum):
PENDING = "pending"
TRANSCRIBED = "transcribed"
@@ -62,6 +56,7 @@ class DocumentType(SQLModel, table=True):
__tablename__ = "document_type"
id: UUID = Field(default_factory=uuid4, primary_key=True)
semantic_key: str | None = Field(default=None, index=True, unique=True)
label: str
normalized_label: str = Field(index=True, unique=True)
is_active: bool = True
@@ -79,8 +74,9 @@ class PersonRole(SQLModel, table=True):
__tablename__ = "person_role"
id: UUID = Field(default_factory=uuid4, primary_key=True)
code: str = Field(index=True, unique=True)
semantic_key: str | None = Field(default=None, index=True, unique=True)
label: str
normalized_label: str = Field(index=True, unique=True)
is_active: bool = True
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
@@ -150,15 +146,11 @@ class DocumentPerson(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id")
person_id: UUID = Field(foreign_key="person.id")
role_id: UUID | None = Field(default=None, foreign_key="person_role.id")
role: str = Field(default=DocumentPersonRole.AUTHOR.value, nullable=False)
role_id: UUID = Field(foreign_key="person_role.id")
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
__table_args__ = (
UniqueConstraint("document_id", "person_id", "role_id", name="uq_document_person_role_id"),
UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),
)
__table_args__ = (UniqueConstraint("document_id", "person_id", name="uq_document_person"),)
document: Optional["Document"] = Relationship(
back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}
+22 -94
View File
@@ -16,26 +16,12 @@ from .models import DocumentType
from .models import Job
from .models import JobStatus
from .models import PersonRole
from .registries import BUILT_IN_DOCUMENT_TYPES
from .registries import BUILT_IN_PERSON_ROLES
logger = logging.getLogger(__name__)
DEFAULT_PERSON_ROLES: tuple[tuple[str, str], ...] = (
("author", "Author"),
("recipient", "Recipient"),
("mentioned", "Mentioned"),
)
DEFAULT_DOCUMENT_TYPES: tuple[str, ...] = (
"Letter",
"Record",
"Memo",
"Postcard",
"Journal",
"Note",
)
async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Create any missing tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap.
@@ -44,7 +30,6 @@ async def create_all(*, engine: AsyncEngine | None = None) -> None:
active_engine = engine or resolve_engine()
async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
await _upgrade_document_type_uuid_identity(connection)
await _upgrade_person_family_search_id(connection)
await _upgrade_v42_evidence_tables(connection)
await seed_registry_defaults(engine=active_engine)
@@ -55,7 +40,6 @@ async def upgrade_schema(*, engine: AsyncEngine | None = None) -> None:
"""Apply non-destructive additive upgrades to an existing schema."""
active_engine = engine or resolve_engine()
async with active_engine.begin() as connection:
await _upgrade_document_type_uuid_identity(connection)
await _upgrade_person_family_search_id(connection)
await _upgrade_v42_evidence_tables(connection)
@@ -70,73 +54,6 @@ async def _upgrade_v42_evidence_tables(connection: AsyncConnection) -> None:
await connection.run_sync(create_tables)
async def _upgrade_document_type_uuid_identity(connection: AsyncConnection) -> None:
"""Backfill UUID references and retire legacy Document Type code/order columns."""
def inspect_schema(sync_connection) -> tuple[set[str], set[str]]:
database = inspect(sync_connection)
tables = set(database.get_table_names())
type_columns = (
{column["name"] for column in database.get_columns("document_type")} if "document_type" in tables else set()
)
document_columns = (
{column["name"] for column in database.get_columns("document")} if "document" in tables else set()
)
return type_columns, document_columns
type_columns, document_columns = await connection.run_sync(inspect_schema)
if not type_columns:
return
if "normalized_label" not in type_columns:
await connection.execute(text("ALTER TABLE document_type ADD COLUMN normalized_label VARCHAR"))
await connection.execute(
text("UPDATE document_type SET normalized_label = lower(trim(label)) WHERE normalized_label IS NULL")
)
duplicates = (
await connection.execute(
text("SELECT normalized_label FROM document_type GROUP BY normalized_label HAVING count(*) > 1")
)
).first()
if duplicates is not None:
raise RuntimeError(
"Document Type migration requires unique labels ignoring case and whitespace; "
f"duplicate normalized label: {duplicates[0]!r}"
)
if "code" in type_columns and {"document_type", "document_type_id"}.issubset(document_columns):
await connection.execute(
text(
"UPDATE document SET document_type_id = ("
"SELECT id FROM document_type WHERE "
"lower(trim(document_type.code)) = lower(trim(document.document_type))"
") WHERE document_type_id IS NULL AND document_type IS NOT NULL"
)
)
if connection.dialect.name == "postgresql":
await connection.execute(text("ALTER TABLE document_type ALTER COLUMN normalized_label SET NOT NULL"))
if "code" in type_columns:
await connection.execute(text("ALTER TABLE document_type DROP COLUMN code CASCADE"))
if "sort_order" in type_columns:
await connection.execute(text("ALTER TABLE document_type DROP COLUMN sort_order"))
if "document_type" in document_columns:
await connection.execute(text("ALTER TABLE document DROP COLUMN document_type"))
elif connection.dialect.name == "sqlite":
await connection.execute(text("DROP INDEX IF EXISTS ix_document_type_code"))
if "code" in type_columns:
await connection.execute(text("ALTER TABLE document_type DROP COLUMN code"))
if "sort_order" in type_columns:
await connection.execute(text("ALTER TABLE document_type DROP COLUMN sort_order"))
if "document_type" in document_columns:
await connection.execute(text("ALTER TABLE document DROP COLUMN document_type"))
await connection.execute(
text("CREATE UNIQUE INDEX IF NOT EXISTS ix_document_type_normalized_label ON document_type (normalized_label)")
)
async def _upgrade_person_family_search_id(connection: AsyncConnection) -> None:
"""Add the nullable V4.1 FamilySearch field to an existing database."""
@@ -169,16 +86,27 @@ async def seed_registry_defaults(*, engine: AsyncEngine | None = None) -> None:
session_factory = async_sessionmaker(active_engine, class_=AsyncSession, expire_on_commit=False)
async with session_factory() as session:
role_codes = set((await session.exec(select(PersonRole.code))).all())
for code, label in DEFAULT_PERSON_ROLES:
if code not in role_codes:
session.add(PersonRole(code=code, label=label))
role_keys = set((await session.exec(select(PersonRole.semantic_key))).all())
for semantic_key, label in BUILT_IN_PERSON_ROLES:
if semantic_key not in role_keys:
session.add(
PersonRole(
semantic_key=semantic_key,
label=label,
normalized_label=label.casefold(),
)
)
type_labels = set((await session.exec(select(DocumentType.normalized_label))).all())
for label in DEFAULT_DOCUMENT_TYPES:
normalized_label = label.casefold()
if normalized_label not in type_labels:
session.add(DocumentType(label=label, normalized_label=normalized_label))
type_keys = set((await session.exec(select(DocumentType.semantic_key))).all())
for semantic_key, label in BUILT_IN_DOCUMENT_TYPES:
if semantic_key not in type_keys:
session.add(
DocumentType(
semantic_key=semantic_key,
label=label,
normalized_label=label.casefold(),
)
)
await session.commit()
+20
View File
@@ -0,0 +1,20 @@
"""Application-defined semantic registry entries."""
from __future__ import annotations
BUILT_IN_DOCUMENT_TYPES: tuple[tuple[str, str], ...] = (
("book", "Book"),
("letter", "Letter"),
("postcard", "Postcard"),
("photo", "Photo"),
("journal", "Journal"),
("form", "Form"),
)
BUILT_IN_PERSON_ROLES: tuple[tuple[str, str], ...] = (
("author", "Author"),
("recipient", "Recipient"),
("mentioned", "Mentioned"),
)
AUTHOR_ROLE_SEMANTIC_KEY = "author"
+101
View File
@@ -3,6 +3,7 @@ import shutil
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC
from datetime import date
from datetime import datetime
from uuid import UUID
@@ -16,9 +17,11 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..db.models import Document
from ..db.models import DocumentPerson
from ..db.models import DocumentType
from ..db.registries import AUTHOR_ROLE_SEMANTIC_KEY
from ..errors import AppError
from ..errors import ErrorCategory
from .base import ServiceBase
from .sources import source_mime_type
logger = logging.getLogger(__name__)
@@ -65,9 +68,43 @@ class DocumentTypeSummary:
id: UUID
label: str
is_active: bool
is_built_in: bool
document_count: int
@dataclass(frozen=True, slots=True)
class DocumentPrintSource:
id: UUID
page_number: int
media_type: str
current_text: str | None
@dataclass(frozen=True, slots=True)
class DocumentPrintJob:
id: UUID
date_created: datetime
provider: str | None
model: str | None
prompt_name: str | None
retry_count: int
status: str
@dataclass(frozen=True, slots=True)
class DocumentPrintProjection:
id: UUID
title: str
authors: tuple[str, ...]
document_date: date | None
document_date_raw: str | None
location_created: str | None
archive_identifier: str | None
notes: str | None
sources: tuple[DocumentPrintSource, ...]
jobs: tuple[DocumentPrintJob, ...]
class DocumentService(ServiceBase):
"""Thin service class for managing documents in the database."""
@@ -256,6 +293,58 @@ class DocumentService(ServiceBase):
)
return document
async def read_document_print_projection(
self,
document_id: UUID,
*,
session: AsyncSession | None = None,
) -> DocumentPrintProjection:
"""Build the safe, deterministic read model used by print previews."""
document = await self.read_document_detail(document_id, session=session)
authors = sorted(
(
link.person.full_name
for link in document.document_people
if link.person is not None
and link.role_ref is not None
and link.role_ref.semantic_key == AUTHOR_ROLE_SEMANTIC_KEY
),
key=str.casefold,
)
sources = tuple(
DocumentPrintSource(
id=source.id,
page_number=source.page_number,
media_type=source_mime_type(source.filename),
current_text=_current_print_text(source.revised_text, source.raw_transcription),
)
for source in sorted(document.sources, key=lambda item: (item.page_number, item.id))
)
jobs = tuple(
DocumentPrintJob(
id=job.id,
date_created=job.date_created,
provider=job.provider,
model=job.model,
prompt_name=job.prompt_name,
retry_count=job.retry_count,
status=getattr(job.status, "value", str(job.status)),
)
for job in sorted(document.jobs, key=lambda item: (item.date_created, item.id))
)
return DocumentPrintProjection(
id=document.id,
title=document.name,
authors=tuple(authors),
document_date=document.document_date,
document_date_raw=document.document_date_raw,
location_created=document.location_created,
archive_identifier=document.archive_identifier,
notes=document.notes,
sources=sources,
jobs=jobs,
)
async def list_document_types(
self,
*,
@@ -290,6 +379,7 @@ class DocumentService(ServiceBase):
id=document_type.id,
label=document_type.label,
is_active=document_type.is_active,
is_built_in=document_type.semantic_key is not None,
document_count=int(document_count),
)
for document_type, document_count in rows
@@ -383,6 +473,12 @@ class DocumentService(ServiceBase):
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Document Type.",
)
if document_type.semantic_key is not None:
raise DocumentTypeError(
f"Built-in Document Type {document_type.label!r} cannot be deleted",
category=ErrorCategory.CONFLICT,
suggestion="Deactivate the type instead; its built-in meaning must remain available.",
)
if await self._document_type_is_referenced(session=_session, document_type=document_type):
raise DocumentTypeError(
f"Document Type {document_type.label!r} is referenced and cannot be deleted",
@@ -438,3 +534,8 @@ class DocumentService(ServiceBase):
document.updated_at = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(document,))
return document
def _current_print_text(revised_text: str | None, raw_transcription: str | None) -> str | None:
selected = revised_text if revised_text is not None else raw_transcription
return selected if selected is not None and selected.strip() else None
+194 -92
View File
@@ -5,12 +5,14 @@ from __future__ import annotations
import logging
import re
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from pathlib import Path
from uuid import UUID
from uuid import uuid4
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlmodel import select
@@ -20,7 +22,6 @@ from ..config import Settings
from ..config import get_settings
from ..db.models import Document
from ..db.models import DocumentPerson
from ..db.models import DocumentPersonRole
from ..db.models import Person
from ..db.models import PersonRole
from ..errors import AppError
@@ -45,20 +46,6 @@ class PersonRoleError(PeopleError):
"""Raised when Person Role maintenance fails."""
REGISTRY_CODE_PATTERN = re.compile(r"^[a-z0-9_]+$")
def _normalize_role_code(code: str) -> str:
normalized = code.strip().lower()
if not normalized or not REGISTRY_CODE_PATTERN.fullmatch(normalized):
raise PersonRoleError(
"Person Role code must contain only lowercase letters, numbers, and underscores",
category=ErrorCategory.VALIDATION,
suggestion="Enter a stable code such as witness or record_keeper.",
)
return normalized
def _normalize_role_label(label: str) -> str:
normalized = label.strip()
if not normalized:
@@ -84,6 +71,29 @@ def normalize_family_search_id(value: str | None) -> str | None:
return normalized
def _person_role_label_key(label: str) -> str:
return _normalize_role_label(label).casefold()
@dataclass(frozen=True, slots=True)
class PersonRoleSummary:
"""Settings read model for a Person Role and its usage count."""
id: UUID
label: str
is_active: bool
is_built_in: bool
link_count: int
@dataclass(frozen=True, slots=True)
class DocumentPersonInput:
"""Complete desired relationship for one Person on a Document."""
person_id: UUID
role_id: UUID
class PeopleService(ServiceBase):
"""Manage People, relationship roles, and document-person links."""
@@ -136,7 +146,7 @@ class PeopleService(ServiceBase):
session: AsyncSession | None = None,
) -> DocumentPerson:
async with self._session_scope(session) as _session:
await self._sync_role_fields(session=_session, link=document_person)
await self._validate_role(session=_session, role_id=document_person.role_id, require_active=True)
_session.add(document_person)
return await self._finalize_link(session=_session, caller_session=session, link=document_person)
@@ -159,7 +169,14 @@ class PeopleService(ServiceBase):
session: AsyncSession | None = None,
) -> DocumentPerson:
async with self._session_scope(session) as _session:
await self._sync_role_fields(session=_session, link=document_person)
existing = await _session.get(DocumentPerson, document_person.id)
if existing is None:
raise self._not_found(f"DocumentPerson with id {document_person.id} not found")
await self._validate_role(
session=_session,
role_id=document_person.role_id,
require_active=existing.role_id != document_person.role_id,
)
document_person.updated_at = datetime.now(UTC)
merged = await _session.merge(document_person)
return await self._finalize_link(session=_session, caller_session=session, link=merged)
@@ -204,20 +221,44 @@ class PeopleService(ServiceBase):
query = select(PersonRole)
if active_only:
query = query.where(PersonRole.is_active.is_(True))
return (await _session.exec(query.order_by(PersonRole.label, PersonRole.code))).all()
return (await _session.exec(query.order_by(PersonRole.normalized_label, PersonRole.id))).all()
async def list_person_role_summaries(
self,
*,
session: AsyncSession | None = None,
) -> Sequence[PersonRoleSummary]:
"""List Person Roles alphabetically with current link counts."""
async with self._session_scope(session) as _session:
query = (
select(PersonRole, func.count(DocumentPerson.id))
.outerjoin(DocumentPerson, DocumentPerson.role_id == PersonRole.id)
.group_by(PersonRole.id)
.order_by(PersonRole.normalized_label, PersonRole.id)
)
rows = (await _session.exec(query)).all()
return [
PersonRoleSummary(
id=role.id,
label=role.label,
is_active=role.is_active,
is_built_in=role.semantic_key is not None,
link_count=int(link_count),
)
for role, link_count in rows
]
async def create_person_role(
self,
*,
code: str,
label: str,
is_active: bool = True,
session: AsyncSession | None = None,
) -> PersonRole:
"""Create a Person Role with an immutable normalized code."""
"""Create a custom Person Role with a unique label."""
role = PersonRole(
code=_normalize_role_code(code),
label=_normalize_role_label(label),
normalized_label=_person_role_label_key(label),
is_active=is_active,
)
async with self._session_scope(session) as _session:
@@ -226,9 +267,9 @@ class PeopleService(ServiceBase):
await self._finalize(session=_session, caller_session=session, refresh=(role,))
except IntegrityError as exc:
raise PersonRoleError(
f"Person Role code {role.code!r} already exists",
f"Person Role label {role.label!r} already exists",
category=ErrorCategory.CONFLICT,
suggestion="Choose a different stable code or edit the existing role.",
suggestion="Choose a different label or edit the existing role.",
) from exc
return role
@@ -257,7 +298,7 @@ class PeopleService(ServiceBase):
is_active: bool,
session: AsyncSession | None = None,
) -> PersonRole:
"""Update mutable Person Role fields without changing its code."""
"""Update mutable Person Role fields without changing semantic identity."""
async with self._session_scope(session) as _session:
role = await _session.get(PersonRole, person_role_id)
if role is None:
@@ -267,9 +308,17 @@ class PeopleService(ServiceBase):
suggestion="Refresh Settings and select an available Person Role.",
)
role.label = _normalize_role_label(label)
role.normalized_label = _person_role_label_key(label)
role.is_active = is_active
role.updated_at = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(role,))
try:
await self._finalize(session=_session, caller_session=session, refresh=(role,))
except IntegrityError as exc:
raise PersonRoleError(
f"Person Role label {role.label!r} already exists",
category=ErrorCategory.CONFLICT,
suggestion="Choose a different label or edit the existing role.",
) from exc
return role
async def delete_person_role(
@@ -287,6 +336,12 @@ class PeopleService(ServiceBase):
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Person Role.",
)
if role.semantic_key is not None:
raise PersonRoleError(
f"Built-in Person Role {role.label!r} cannot be deleted",
category=ErrorCategory.CONFLICT,
suggestion="Deactivate the role instead; its built-in meaning must remain available.",
)
if await self._person_role_is_referenced(session=_session, role=role):
raise PersonRoleError(
f"Person Role {role.label!r} is referenced and cannot be deleted",
@@ -302,7 +357,7 @@ class PeopleService(ServiceBase):
*,
session: AsyncSession | None = None,
) -> bool:
"""Return whether canonical or compatibility data references a Person Role."""
"""Return whether a document-person link references a Person Role."""
async with self._session_scope(session) as _session:
role = await _session.get(PersonRole, person_role_id)
if role is None:
@@ -319,15 +374,26 @@ class PeopleService(ServiceBase):
session: AsyncSession,
role: PersonRole,
) -> bool:
reference = (
await session.exec(
select(DocumentPerson.id).where(
(DocumentPerson.role_id == role.id) | (DocumentPerson.role == role.code)
)
)
).first()
reference = (await session.exec(select(DocumentPerson.id).where(DocumentPerson.role_id == role.id))).first()
return reference is not None
async def read_person_role_by_semantic_key(
self,
semantic_key: str,
*,
session: AsyncSession | None = None,
) -> PersonRole:
"""Resolve one application-defined built-in role."""
async with self._session_scope(session) as _session:
role = (await _session.exec(select(PersonRole).where(PersonRole.semantic_key == semantic_key))).first()
if role is None:
raise PersonRoleError(
f"Built-in Person Role {semantic_key!r} is unavailable",
category=ErrorCategory.NOT_FOUND,
suggestion="Recreate the built-in registry rows and retry.",
)
return role
async def list_document_people(
self,
*,
@@ -352,8 +418,7 @@ class PeopleService(ServiceBase):
*,
document_id: UUID,
person_id: UUID,
role_id: UUID | None = None,
role_code: str | None = None,
role_id: UUID,
session: AsyncSession | None = None,
) -> DocumentPerson:
async with self._session_scope(session) as _session:
@@ -361,15 +426,8 @@ class PeopleService(ServiceBase):
if await _session.get(Person, person_id) is None:
raise self._not_found(f"Person with id {person_id} not found")
link = DocumentPerson(
document_id=document_id,
person_id=person_id,
role=DocumentPersonRole.AUTHOR,
role_id=role_id,
)
if role_code and role_code.strip():
link.role = self._legacy_role(role_code)
await self._sync_role_fields(session=_session, link=link)
await self._validate_role(session=_session, role_id=role_id, require_active=True)
link = DocumentPerson(document_id=document_id, person_id=person_id, role_id=role_id)
_session.add(link)
return await self._finalize_link(session=_session, caller_session=session, link=link)
@@ -377,31 +435,19 @@ class PeopleService(ServiceBase):
self,
*,
document_person_id: UUID,
role_id: UUID | None = None,
role_code: str | None = None,
role_id: UUID,
session: AsyncSession | None = None,
) -> DocumentPerson:
async with self._session_scope(session) as _session:
link = await _session.get(DocumentPerson, document_person_id)
if link is None:
raise self._not_found(f"DocumentPerson with id {document_person_id} not found")
if role_id is None and not (role_code or "").strip():
raise PeopleError(
"Either role_id or role_code is required",
category=ErrorCategory.VALIDATION,
suggestion="Provide a valid role id or code and retry.",
)
if role_id is not None and (role_code or "").strip():
raise PeopleError(
"Provide role_id or role_code, not both",
category=ErrorCategory.VALIDATION,
suggestion="Send only one relationship role selector and retry.",
)
await self._validate_role(
session=_session,
role_id=role_id,
require_active=link.role_id != role_id,
)
link.role_id = role_id
if role_code and role_code.strip():
link.role = self._legacy_role(role_code)
await self._sync_role_fields(session=_session, link=link)
link.updated_at = datetime.now(UTC)
return await self._finalize_link(session=_session, caller_session=session, link=link)
@@ -418,37 +464,97 @@ class PeopleService(ServiceBase):
await _session.delete(link)
await self._finalize(session=_session, caller_session=session)
async def _resolve_or_create_role(
async def sync_document_people(
self,
*,
document_id: UUID,
links: Sequence[DocumentPersonInput],
session: AsyncSession | None = None,
) -> Sequence[DocumentPerson]:
"""Synchronize one Document's complete Person link set."""
person_ids = [link.person_id for link in links]
if len(person_ids) != len(set(person_ids)):
raise PeopleError(
"A Person can be linked to a Document only once",
category=ErrorCategory.CONFLICT,
suggestion="Edit the existing Linked People row instead of adding another.",
)
async with self._session_scope(session) as _session:
await self._require_document(session=_session, document_id=document_id)
existing_links = (
await _session.exec(select(DocumentPerson).where(DocumentPerson.document_id == document_id))
).all()
existing_by_person = {link.person_id: link for link in existing_links}
desired_by_person = {link.person_id: link for link in links}
roles: dict[UUID, PersonRole] = {}
for desired in links:
if await _session.get(Person, desired.person_id) is None:
raise self._not_found(f"Person with id {desired.person_id} not found")
role = roles.get(desired.role_id)
if role is None:
role = await self._validate_role(session=_session, role_id=desired.role_id)
roles[desired.role_id] = role
current = existing_by_person.get(desired.person_id)
if (current is None or current.role_id != desired.role_id) and not role.is_active:
raise PeopleError(
f"Inactive Person Role {role.label!r} cannot be assigned",
category=ErrorCategory.VALIDATION,
suggestion="Select an active Person Role and retry.",
)
for person_id, existing in existing_by_person.items():
if person_id not in desired_by_person:
await _session.delete(existing)
synchronized: list[DocumentPerson] = []
for desired in links:
existing = existing_by_person.get(desired.person_id)
if existing is None:
existing = DocumentPerson(
document_id=document_id,
person_id=desired.person_id,
role_id=desired.role_id,
)
_session.add(existing)
elif existing.role_id != desired.role_id:
existing.role_id = desired.role_id
existing.updated_at = datetime.now(UTC)
synchronized.append(existing)
try:
await self._finalize(session=_session, caller_session=session, refresh=synchronized)
except IntegrityError as exc:
raise PeopleError(
"A Person can be linked to a Document only once",
category=ErrorCategory.CONFLICT,
suggestion="Edit the existing Linked People row instead of adding another.",
) from exc
return synchronized
async def _validate_role(
self,
*,
session: AsyncSession,
role_id: UUID | None,
role_code: str,
role_id: UUID,
require_active: bool = False,
) -> PersonRole:
if role_id is not None:
role = await session.get(PersonRole, role_id)
if role is None:
raise PeopleError(
f"Person role with id {role_id} not found",
category=ErrorCategory.VALIDATION,
suggestion="Select a valid relationship role and retry.",
)
return role
normalized_code = role_code.strip().lower() or DocumentPersonRole.AUTHOR.value
role = (await session.exec(select(PersonRole).where(PersonRole.code == normalized_code))).first()
role = await session.get(PersonRole, role_id)
if role is None:
role = PersonRole(code=normalized_code, label=normalized_code.replace("_", " ").title())
session.add(role)
await session.flush()
raise PeopleError(
f"Person role with id {role_id} not found",
category=ErrorCategory.VALIDATION,
suggestion="Select a valid relationship role and retry.",
)
if require_active and not role.is_active:
raise PeopleError(
f"Inactive Person Role {role.label!r} cannot be assigned",
category=ErrorCategory.VALIDATION,
suggestion="Select an active Person Role and retry.",
)
return role
async def _sync_role_fields(self, *, session: AsyncSession, link: DocumentPerson) -> None:
role_code = link.role.value if isinstance(link.role, DocumentPersonRole) else str(link.role)
role = await self._resolve_or_create_role(session=session, role_id=link.role_id, role_code=role_code)
link.role_id = role.id
link.role = self._legacy_role(role.code)
async def _finalize_link(
self,
*,
@@ -460,9 +566,9 @@ class PeopleService(ServiceBase):
await self._finalize(session=session, caller_session=caller_session, refresh=(link,))
except IntegrityError as exc:
raise PeopleError(
"Duplicate relationship link for document/person/role",
"This Person is already linked to the Document",
category=ErrorCategory.CONFLICT,
suggestion="Remove the existing relationship link or choose a different role.",
suggestion="Edit the existing relationship instead of adding another one.",
) from exc
return link
@@ -478,10 +584,6 @@ class PeopleService(ServiceBase):
suggestion="Open the existing person record or enter a different FamilySearch ID.",
)
@staticmethod
def _legacy_role(role_code: str) -> str:
return _normalize_role_code(role_code)
@staticmethod
def _not_found(message: str) -> PeopleError:
return PeopleError(
+34 -3
View File
@@ -9,10 +9,12 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..config import get_settings
from ..db.models import Document
from ..db.models import Job
from ..db.models import JobSourceStatus
from ..db.models import JobStatus
from ..db.models import Source
from ..db.session import transaction_scope
from ..errors import AppError
from ..errors import ErrorCategory
from ..errors import classify_unexpected_error
@@ -24,6 +26,9 @@ from ..providers import TranscriptionProvider
from ..providers import TranscriptionResult
from ..providers import TransportEvidence
from . import ServiceBundle
from .documents import DocumentService
from .people import DocumentPersonInput
from .people import PeopleService
from .sources import PromptExecution
from .sources import build_prompt_execution
from .sources import hash_prompt_text
@@ -33,6 +38,34 @@ from .sources import transcribe_document_image
logger = logging.getLogger(__name__)
async def create_document_with_people(
*,
document: Document,
links: list[DocumentPersonInput],
documents: DocumentService,
people: PeopleService,
) -> Document:
"""Create a Document and its complete Linked People set atomically."""
async with transaction_scope(session_factory=documents.session_factory) as session:
created = await documents.create_document(document, session=session)
await people.sync_document_people(document_id=created.id, links=links, session=session)
return created
async def update_document_with_people(
*,
document: Document,
links: list[DocumentPersonInput],
documents: DocumentService,
people: PeopleService,
) -> Document:
"""Update a Document and its complete Linked People set atomically."""
async with transaction_scope(session_factory=documents.session_factory) as session:
updated = await documents.update_document(document, session=session)
await people.sync_document_people(document_id=updated.id, links=links, session=session)
return updated
@dataclass(frozen=True)
class _SuccessfulPage:
source: Source
@@ -392,9 +425,7 @@ async def _persist_page_outcome_durably(
session: AsyncSession | None,
) -> None:
"""Commit one completed provider call before processing the next source."""
task = asyncio.create_task(
_persist_page_outcome(job=job, services=services, page=page, session=session)
)
task = asyncio.create_task(_persist_page_outcome(job=job, services=services, page=page, session=session))
try:
await asyncio.shield(task)
except asyncio.CancelledError:
+2
View File
@@ -10,6 +10,7 @@ from transcription.ui.pages.documents_page import register_page as register_docu
from transcription.ui.pages.home_page import register_page as register_home_page
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.pages.people_page import register_page as register_people_page
from transcription.ui.pages.print_preview_page import register_page as register_print_preview_page
from transcription.ui.pages.settings_page import register_page as register_settings_page
from transcription.ui.pages.sources_page import register_page as register_sources_page
from transcription.ui.resources import read_css
@@ -37,6 +38,7 @@ def register_pages(app: FastAPI) -> None:
register_home_page()
register_documents_page()
register_people_page()
register_print_preview_page()
register_sources_page()
register_jobs_page()
register_settings_page(settings=getattr(app.state, "settings", None) or get_settings())
@@ -0,0 +1,180 @@
"""Shared staged Linked People editor."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from nicegui import ui
from transcription.db.models import DocumentPerson
from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.services.people import DocumentPersonInput
from transcription.ui.components.formatters import person_selector_label
from transcription.ui.components.primitives import destructive_button
@dataclass(frozen=True, slots=True)
class StagedLinkedPerson:
person_id: UUID
role_id: UUID
class LinkedPeopleEditor:
"""Render and retain an unsaved one-role-per-Person link set."""
def __init__(
self,
*,
people: list[Person],
roles: list[PersonRole],
initial_links: list[DocumentPerson] | None = None,
staged_links: list[StagedLinkedPerson] | None = None,
) -> None:
self.people = {person.id: person for person in people}
self.roles = {role.id: role for role in roles}
self.links = list(staged_links or self._from_existing(initial_links or []))
self.mode: str | None = None
self.editing_person_id: UUID | None = None
self.table: Any = None
@staticmethod
def _from_existing(links: list[DocumentPerson]) -> list[StagedLinkedPerson]:
return [StagedLinkedPerson(person_id=link.person_id, role_id=link.role_id) for link in links]
def values(self) -> list[DocumentPersonInput]:
"""Return the complete staged link set for persistence."""
return [DocumentPersonInput(person_id=link.person_id, role_id=link.role_id) for link in self.links]
@ui.refreshable
def render(self) -> None:
ui.label("Linked People").classes("text-sm font-semibold ui-text-primary mt-2")
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props(
"flat dense"
).classes("self-start")
rows = [
{
"person_id": str(link.person_id),
"person": self._person_label(link.person_id),
"role": self._role_label(link.role_id),
}
for link in sorted(self.links, key=lambda item: self._person_label(item.person_id).casefold())
]
self.table = ui.table(
columns=[
{"name": "person", "label": "Person", "field": "person", "align": "left", "sortable": True},
{"name": "role", "label": "Role", "field": "role", "align": "left", "sortable": True},
],
rows=rows,
row_key="person_id",
selection="multiple",
pagination={"rowsPerPage": 0, "sortBy": "person"},
).classes("w-full ui-table")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Add", icon="add", on_click=self._begin_add).classes("ui-btn-primary")
ui.button("Edit", icon="edit", on_click=self._begin_edit).props("flat")
destructive_button("Delete", icon="delete", on_click=self._delete_selected)
if self.mode is not None:
self._render_inline_editor()
def _render_inline_editor(self) -> None:
current = self._editing_link()
person_options = {
str(person_id): person_selector_label(person)
for person_id, person in sorted(
self.people.items(),
key=lambda item: person_selector_label(item[1]).casefold(),
)
if person_id == self.editing_person_id or all(link.person_id != person_id for link in self.links)
}
role_options = {
str(role_id): self._role_option_label(role)
for role_id, role in sorted(self.roles.items(), key=lambda item: item[1].normalized_label)
if role.is_active or (current is not None and role_id == current.role_id)
}
with ui.column().classes("w-full gap-2 p-3 ui-row-surface"):
ui.label("Add Linked Person" if self.mode == "add" else "Edit Linked Person").classes("font-medium")
person_input = ui.select(person_options, label="Person").props("outlined").classes("w-full")
role_input = ui.select(role_options, label="Person Role").props("outlined").classes("w-full")
if current is not None:
person_input.value = str(current.person_id)
role_input.value = str(current.role_id)
def save() -> None:
person_id = self._parse_uuid(person_input.value)
role_id = self._parse_uuid(role_input.value)
if person_id is None or role_id is None:
ui.notify("Select both a Person and Person Role.", type="warning")
return
if any(link.person_id == person_id and link.person_id != self.editing_person_id for link in self.links):
ui.notify("That Person is already linked to this Document.", type="warning")
return
replacement = StagedLinkedPerson(person_id=person_id, role_id=role_id)
if self.mode == "edit":
self.links = [
replacement if link.person_id == self.editing_person_id else link for link in self.links
]
else:
self.links.append(replacement)
self._close_editor()
with ui.row().classes("w-full justify-end gap-2"):
ui.button("Cancel", on_click=self._close_editor).props("flat")
ui.button("Save", icon="save", on_click=save).classes("ui-btn-primary")
def _begin_add(self) -> None:
self.mode = "add"
self.editing_person_id = None
self.render.refresh()
def _begin_edit(self) -> None:
selected = self.table.selected or []
if len(selected) != 1:
ui.notify("Select one Linked Person to edit.", type="warning")
return
self.mode = "edit"
self.editing_person_id = UUID(str(selected[0]["person_id"]))
self.render.refresh()
def _delete_selected(self) -> None:
selected = self.table.selected or []
if not selected:
ui.notify("Select one or more Linked People to delete.", type="warning")
return
selected_ids = {UUID(str(row["person_id"])) for row in selected}
self.links = [link for link in self.links if link.person_id not in selected_ids]
self._close_editor()
def _close_editor(self) -> None:
self.mode = None
self.editing_person_id = None
self.render.refresh()
def _editing_link(self) -> StagedLinkedPerson | None:
if self.editing_person_id is None:
return None
return next((link for link in self.links if link.person_id == self.editing_person_id), None)
def _person_label(self, person_id: UUID) -> str:
person = self.people.get(person_id)
return person_selector_label(person) if person is not None else "Unknown person"
def _role_label(self, role_id: UUID) -> str:
role = self.roles.get(role_id)
return role.label if role is not None else "Unknown role"
@staticmethod
def _role_option_label(role: PersonRole) -> str:
return role.label if role.is_active else f"{role.label} (inactive)"
@staticmethod
def _parse_uuid(value: Any) -> UUID | None:
try:
return UUID(str(value))
except (TypeError, ValueError):
return None
+70 -134
View File
@@ -11,18 +11,22 @@ from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import Document
from transcription.db.registries import AUTHOR_ROLE_SEMANTIC_KEY
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.people import PeopleService
from transcription.services.workflows import create_document_with_people
from transcription.services.workflows import update_document_with_people
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.formatters import compact_date
from transcription.ui.components.formatters import person_selector_label
from transcription.ui.components.linked_people import LinkedPeopleEditor
from transcription.ui.components.linked_people import StagedLinkedPerson
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
@@ -47,20 +51,32 @@ def register_page() -> None: # noqa: PLR0915
page_header("Create Document", subtitle="Document name and type are required.")
people = sorted(await people_service.list_people(), key=lambda item: item.full_name.casefold())
role_catalog = await people_service.list_person_roles()
role_catalog = list(await people_service.list_person_roles(active_only=False))
type_catalog = await document_service.list_document_types()
requested_person_id = _parse_uuid(request.query_params.get("person_id"))
selected_people_by_role: dict[str, list[UUID]] = {}
staged_links: list[StagedLinkedPerson] = []
if requested_person_id is not None and any(person.id == requested_person_id for person in people):
selected_people_by_role["author"] = [requested_person_id]
try:
author_role = await people_service.read_person_role_by_semantic_key(AUTHOR_ROLE_SEMANTIC_KEY)
if author_role.is_active:
staged_links.append(StagedLinkedPerson(person_id=requested_person_id, role_id=author_role.id))
else:
ui.notify(
"The Author role is inactive, so the Person could not be preselected.",
type="warning",
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author role unavailable", operation="documents.create.preselect")
elif request.query_params.get("person_id"):
ui.notify("The requested person could not be preselected.", type="warning")
form = _render_document_form_fields(
linked_people = LinkedPeopleEditor(
people=people,
role_codes=[role.code for role in role_catalog],
role_labels={role.code: role.label for role in role_catalog},
roles=role_catalog,
staged_links=staged_links,
)
form = _render_document_form_fields(
type_options={str(doc_type.id): doc_type.label for doc_type in type_catalog},
selected_people_by_role=selected_people_by_role,
linked_people=linked_people,
)
return_to = request.query_params.get("return_to")
@@ -91,25 +107,16 @@ def register_page() -> None: # noqa: PLR0915
)
try:
created = await document_service.create_document(candidate)
created = await create_document_with_people(
document=candidate,
links=linked_people.values(),
documents=document_service,
people=people_service,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="documents.create")
return
desired_links = _collect_role_link_candidates(
form["role_people"], role_codes=[role.code for role in role_catalog]
)
try:
for role_code, person_id in sorted(desired_links):
await people_service.add_document_person_link(
document_id=created.id,
person_id=person_id,
role_code=role_code,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Relationship link failed", operation="documents.create.link_people")
return
ui.notify("Document created", type="positive")
if return_to == "jobs_new":
ui.navigate.to(f"/jobs/new?document_id={created.id}")
@@ -149,7 +156,7 @@ def register_page() -> None: # noqa: PLR0915
id=doc.id,
name=doc.name,
document_type=(doc.document_type_ref.label if doc.document_type_ref is not None else ""),
authors=", ".join(_group_people_labels_by_role(doc).get("author", [])),
authors=", ".join(_author_names(doc)),
document_date=compact_date(doc.document_date, doc.document_date_raw),
archive_identifier=doc.archive_identifier or "",
)
@@ -182,6 +189,11 @@ def register_page() -> None: # noqa: PLR0915
page_header(document.name, subtitle=f"Type: {type_display} | ID: {document.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"Print",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/print"),
icon="print",
).props("flat").classes("text-xs")
ui.button(
"Edit Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
@@ -278,16 +290,17 @@ def register_page() -> None: # noqa: PLR0915
page_header("Edit Document Record", subtitle="Document name and document type are required.")
people = sorted(await people_service.list_people(), key=lambda item: item.full_name.casefold())
role_catalog = await people_service.list_person_roles()
role_catalog = list(await people_service.list_person_roles(active_only=False))
type_catalog = await document_service.list_document_types(active_only=False)
existing_by_role = _existing_people_by_role(document)
linked_people = LinkedPeopleEditor(
people=people,
roles=role_catalog,
initial_links=list(document.document_people),
)
form = _render_document_form_fields(
document=document,
people=people,
role_codes=[role.code for role in role_catalog],
role_labels={role.code: role.label for role in role_catalog},
type_options={str(doc_type.id): doc_type.label for doc_type in type_catalog},
selected_people_by_role=existing_by_role,
linked_people=linked_people,
)
async def submit_edit() -> None:
@@ -319,34 +332,16 @@ def register_page() -> None: # noqa: PLR0915
)
try:
await document_service.update_document(candidate)
await update_document_with_people(
document=candidate,
links=linked_people.values(),
documents=document_service,
people=people_service,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="documents.edit.save")
return
existing_links = {
(_resolve_link_role_code(link), link.person_id): link
for link in document.document_people
if _resolve_link_role_code(link) is not None
}
desired_links = _collect_role_link_candidates(
form["role_people"], role_codes=[role.code for role in role_catalog]
)
try:
for role_code, person_id in sorted(desired_links - set(existing_links.keys())):
await people_service.add_document_person_link(
document_id=document.id,
person_id=person_id,
role_code=role_code,
)
for stale_key in sorted(set(existing_links.keys()) - desired_links):
stale_link = existing_links[stale_key]
await people_service.remove_document_person_link(document_person_id=stale_link.id)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Relationship update failed", operation="documents.edit.link_people")
return
ui.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}")
@@ -444,11 +439,8 @@ def register_page() -> None: # noqa: PLR0915
def _render_document_form_fields(
*,
document: Document | None = None,
people: list[Any],
role_codes: list[str],
role_labels: dict[str, str],
type_options: dict[str, str],
selected_people_by_role: dict[str, list[UUID]] | None = None,
linked_people: LinkedPeopleEditor,
) -> dict[str, Any]:
with archival_card(extra_classes="gap-3"):
name_input = (
@@ -510,27 +502,7 @@ def _render_document_form_fields(
.classes("w-full ui-form-surface")
)
ui.label("Linked People by Role").classes("text-sm font-semibold ui-text-primary mt-2")
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props(
"flat dense"
).classes("self-start")
people_options = {str(p.id): person_selector_label(p) for p in people}
existing = selected_people_by_role or {}
role_people_inputs: dict[str, Any] = {}
for role_code in role_codes:
label = role_labels.get(role_code, role_code.replace("_", " ").title())
current_people = [str(person_id) for person_id in existing.get(role_code, [])]
role_people_inputs[role_code] = (
ui.select(
people_options,
label=f"{label} people",
multiple=True,
)
.props("outlined use-chips")
.classes("w-full ui-form-surface")
)
if current_people:
role_people_inputs[role_code].value = current_people
linked_people.render()
return {
"name": name_input,
@@ -541,7 +513,6 @@ def _render_document_form_fields(
"location": location_input,
"archive": archive_input,
"notes": notes_input,
"role_people": role_people_inputs,
}
@@ -552,8 +523,7 @@ def _render_bento_viewer_zone(document: Document) -> None:
def _render_bento_metadata_zone(document: Document) -> None:
people_by_role = _group_people_labels_by_role(document)
author_names = people_by_role.get("author", [])
author_names = _author_names(document)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
@@ -585,10 +555,10 @@ def _render_related_people_card(document: Document) -> None:
grouped = _group_people_by_role(document)
with ui.column().classes("w-full gap-2"):
for role_code in sorted(grouped.keys()):
for role_label in sorted(grouped.keys(), key=str.casefold):
with ui.column().classes("w-full ui-row-surface p-2 gap-1"):
archival_badge(role_code)
for person in grouped[role_code]:
archival_badge(role_label)
for person in grouped[role_label]:
ui.button(
person.full_name,
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
@@ -641,58 +611,24 @@ def _resolve_selected_document_type_id(selected_value: Any, type_options: dict[s
return _parse_uuid(selected_id)
def _resolve_link_role_code(link: Any) -> str | None:
if getattr(link, "role_ref", None) is not None and getattr(link.role_ref, "code", None):
return str(link.role_ref.code)
role = getattr(link, "role", None)
if role is None:
return None
value = getattr(role, "value", role)
return str(value)
def _existing_people_by_role(document: Document) -> dict[str, list[UUID]]:
people_by_role: dict[str, list[UUID]] = {}
for link in document.document_people:
role_code = _resolve_link_role_code(link)
if role_code is None:
continue
people_by_role.setdefault(role_code, []).append(link.person_id)
return people_by_role
def _collect_role_link_candidates(
role_people_inputs: dict[str, Any], *, role_codes: list[str]
) -> set[tuple[str, UUID]]:
desired: set[tuple[str, UUID]] = set()
for role_code in role_codes:
selected = role_people_inputs[role_code].value or []
selected_ids = [selected] if isinstance(selected, str) else list(selected)
for selected_id in selected_ids:
parsed = _parse_uuid(selected_id)
if parsed is not None:
desired.add((role_code, parsed))
return desired
def _group_people_labels_by_role(document: Document) -> dict[str, list[str]]:
grouped: dict[str, list[str]] = {}
for link in document.document_people:
role_code = _resolve_link_role_code(link)
if role_code is None:
continue
person_label = link.person.full_name if link.person is not None else "Unknown person"
grouped.setdefault(role_code, []).append(person_label)
return grouped
def _group_people_by_role(document: Document) -> dict[str, list[Any]]:
grouped: dict[str, list[Any]] = {}
for link in document.document_people:
role_code = _resolve_link_role_code(link)
if role_code is not None and link.person is not None:
grouped.setdefault(role_code, []).append(link.person)
if link.role_ref is not None and link.person is not None:
grouped.setdefault(link.role_ref.label, []).append(link.person)
for people in grouped.values():
people.sort(key=lambda person: person.full_name.casefold())
return grouped
def _author_names(document: Document) -> list[str]:
return sorted(
(
link.person.full_name
for link in document.document_people
if link.person is not None
and link.role_ref is not None
and link.role_ref.semantic_key == AUTHOR_ROLE_SEMANTIC_KEY
),
key=str.casefold,
)
+2 -2
View File
@@ -473,11 +473,11 @@ def _render_linked_documents(person: Person) -> None:
doc = link.document
if doc is None:
continue
role_code = link.role_ref.code if link.role_ref is not None else str(link.role)
role_label = link.role_ref.label if link.role_ref is not None else "Unknown role"
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
with ui.column().classes("gap-0"):
ui.label(doc.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {role_code}").classes("text-[10px] ui-text-muted")
ui.label(f"Role: {role_label}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"),
@@ -0,0 +1,165 @@
"""Browser-native Document print preview."""
from __future__ import annotations
import re
from uuid import UUID
from nicegui import ui
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentPrintJob
from transcription.services.documents import DocumentPrintProjection
from transcription.services.documents import DocumentPrintSource
from transcription.services.documents import DocumentService
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.formatters import compact_date
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
PRINT_UNAVAILABLE = "Transcription unavailable"
def register_page() -> None:
"""Register the persisted Document print-preview route."""
@ui.page("/documents/{document_id}/print")
async def document_print_preview_page(document_id: str, session_factory: SessionFactoryDep) -> None:
try:
parsed_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
return
service = DocumentService(session_factory=session_factory)
try:
projection = await service.read_document_print_projection(parsed_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Print preview unavailable", operation="documents.print.read")
return
mode = {"value": "facsimile"}
with ui.column().classes("print-preview w-full max-w-7xl mx-auto p-4 gap-4"):
with ui.row().classes("no-print w-full items-center justify-between gap-3"):
page_header("Print Document", subtitle=projection.title)
with ui.row().classes("items-center gap-2"):
format_input = ui.toggle(
{"facsimile": "Facsimile", "text": "Text only"},
value=mode["value"],
)
ui.button("Print", icon="print", on_click=lambda: ui.run_javascript("window.print()")).classes(
"ui-btn-primary"
)
ui.button(
"Back",
icon="arrow_back",
on_click=lambda: ui.navigate.to(f"/documents/{projection.id}"),
).props("flat")
@ui.refreshable
def render_preview() -> None:
_render_print_document(projection, mode=str(format_input.value or "facsimile"))
format_input.on_value_change(lambda event: (mode.update(value=event.value), render_preview.refresh()))
render_preview()
def _render_print_document(projection: DocumentPrintProjection, *, mode: str) -> None:
with ui.column().classes("print-document w-full gap-5"):
ui.label(projection.title).classes("print-title text-3xl font-bold")
ui.label("Archival Metadata").classes("print-section-title text-xl font-semibold")
_render_metadata_table(projection)
ui.label("Notes").classes("print-section-title text-xl font-semibold")
ui.label(projection.notes or "No notes recorded").classes("print-notes whitespace-pre-wrap")
ui.label("Document").classes("print-section-title text-xl font-semibold")
if not projection.sources:
ui.label("No Source pages are linked to this Document.").classes("ui-text-muted")
for source in projection.sources:
classes = "print-source w-full gap-3"
if mode == "facsimile":
classes += " print-page-break"
with ui.column().classes(classes):
ui.label(f"Page {source.page_number}").classes("text-lg font-semibold")
text = source.current_text or PRINT_UNAVAILABLE
if mode == "facsimile":
_render_facsimile_source(document_id=projection.id, source=source, text=text)
else:
for paragraph in reflow_transcription(text):
ui.label(paragraph).classes("print-transcription")
ui.label("Transcription Job Metadata").classes("print-section-title text-xl font-semibold")
_render_job_table(projection.jobs)
def _render_facsimile_source(*, document_id: UUID, source: DocumentPrintSource, text: str) -> None:
with ui.row().classes("print-facsimile-row w-full items-start gap-4"):
media_url = f"/api/v4/documents/{document_id}/sources/{source.id}/media"
if source.media_type == "application/pdf":
ui.html(
f'<iframe class="print-source-pdf" src="{media_url}" title="Source page {source.page_number}"></iframe>'
)
else:
ui.image(media_url).classes("print-source-image")
ui.label(text).classes("print-transcription print-preserve-lines")
def _render_metadata_table(projection: DocumentPrintProjection) -> None:
rows = [
{"field": "Author", "value": ", ".join(projection.authors) or "Not set"},
{
"field": "Date",
"value": compact_date(projection.document_date, projection.document_date_raw) or "Not set",
},
{"field": "Location Created", "value": projection.location_created or "Not set"},
{"field": "Archival Identifier", "value": projection.archive_identifier or "Not set"},
]
ui.table(
columns=[
{"name": "field", "label": "", "field": "field", "align": "left"},
{"name": "value", "label": "", "field": "value", "align": "left"},
],
rows=rows,
row_key="field",
pagination={"rowsPerPage": 0},
).props("flat hide-header").classes("print-metadata-table w-full")
def _render_job_table(jobs: tuple[DocumentPrintJob, ...]) -> None:
columns = [{"name": "field", "label": "", "field": "field", "align": "left"}]
for index in range(1, len(jobs) + 1):
columns.append({"name": f"job_{index}", "label": f"Job {index}", "field": f"job_{index}", "align": "left"})
fields = (
("Job ID", lambda job: str(job.id)),
("Date", lambda job: job.date_created.isoformat()),
("Provider", lambda job: job.provider or "Not set"),
("Model", lambda job: job.model or "Not set"),
("Prompt", lambda job: job.prompt_name or "Not set"),
("Retry Count", lambda job: str(job.retry_count)),
("Status", lambda job: job.status),
)
rows = [
{
"field": field,
**{f"job_{index}": value(job) for index, job in enumerate(jobs, start=1)},
}
for field, value in fields
]
ui.table(
columns=columns,
rows=rows,
row_key="field",
pagination={"rowsPerPage": 0},
).props("flat hide-bottom").classes("print-job-table w-full")
def reflow_transcription(text: str) -> list[str]:
"""Join single line breaks while preserving blank-line paragraph boundaries."""
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
paragraphs = re.split(r"\n[ \t]*\n+", normalized)
return [re.sub(r"[ \t]*\n[ \t]*", " ", paragraph).strip() for paragraph in paragraphs if paragraph.strip()]
+140 -84
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from uuid import UUID
@@ -59,6 +58,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
"label": item.label,
"document_count": item.document_count,
"is_active": item.is_active,
"is_built_in": item.is_built_in,
}
for item in document_types
]
@@ -84,6 +84,12 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
"field": "is_active",
"align": "center",
},
{
"name": "is_built_in",
"label": "Built-in",
"field": "is_built_in",
"align": "center",
},
],
rows=rows,
row_key="id",
@@ -98,6 +104,14 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
</q-td>
""",
)
table.add_slot(
"body-cell-is_built_in",
"""
<q-td :props="props" class="text-center">
{{ props.value ? 'Yes' : 'No' }}
</q-td>
""",
)
async def save_type(
*,
@@ -177,45 +191,141 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
destructive_button("Delete", icon="delete", on_click=delete_selected_type)
@ui.refreshable
async def render_person_roles() -> None:
async def render_person_roles() -> None: # noqa: PLR0915
with archival_card("Person Roles"):
ui.label(
"Codes are permanent. Roles are ordered by label then code; referenced roles cannot be deleted."
"Roles are listed alphabetically. Built-ins cannot be deleted; "
"referenced custom roles must be deactivated."
).classes("text-xs ui-text-muted mb-3")
try:
roles = await people.list_person_roles(active_only=False)
roles = await people.list_person_role_summaries()
except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Roles unavailable", operation="settings.roles.list")
return
with ui.row().classes("w-full items-end gap-2"):
code = ui.input("Stable code").props("dense")
label = ui.input("Label").props("dense")
async def create_role() -> None:
try:
await people.create_person_role(
code=str(code.value or ""),
label=str(label.value or ""),
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Role creation failed", operation="settings.roles.create")
return
ui.notify("Person Role created", type="positive")
render_person_roles.refresh()
ui.button("Add role", icon="add", on_click=create_role).classes("ui-btn-primary")
if not roles:
render_empty_state("No Person Roles are configured.", extra_classes="mt-3")
for role in roles:
is_referenced = await people.is_person_role_referenced(role.id)
_person_role_row(
role,
is_referenced=is_referenced,
on_save=_save_role(people, role.id, render_person_roles.refresh),
on_delete=_delete_role(people, role.id, render_person_roles.refresh),
rows = [
{
"id": str(role.id),
"label": role.label,
"link_count": role.link_count,
"is_active": role.is_active,
"is_built_in": role.is_built_in,
}
for role in roles
]
table = ui.table(
columns=[
{"name": "label", "label": "Label", "field": "label", "align": "left", "sortable": True},
{
"name": "link_count",
"label": "Links",
"field": "link_count",
"align": "right",
"sortable": True,
},
{"name": "is_active", "label": "Active", "field": "is_active", "align": "center"},
{
"name": "is_built_in",
"label": "Built-in",
"field": "is_built_in",
"align": "center",
},
],
rows=rows,
row_key="id",
selection="single",
pagination={"rowsPerPage": 0, "sortBy": "label"},
).classes("w-full ui-table")
table.add_slot(
"body-cell-is_active",
"""
<q-td :props="props" class="text-center">
<q-checkbox :model-value="props.value" disable dense />
</q-td>
""",
)
table.add_slot(
"body-cell-is_built_in",
"""
<q-td :props="props" class="text-center">
{{ props.value ? 'Yes' : 'No' }}
</q-td>
""",
)
async def save_role(
*,
item_id: UUID | None,
label: str,
is_active: bool,
) -> bool:
try:
if item_id is None:
await people.create_person_role(label=label, is_active=is_active)
else:
await people.update_person_role(item_id, label=label, is_active=is_active)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Role save failed", operation="settings.roles.save")
return False
ui.notify("Person Role saved", type="positive")
render_person_roles.refresh()
return True
def open_role_editor(*, creating: bool) -> None:
selected = _selected_table_row(table)
if not creating and selected is None:
ui.notify("Select one Person Role to edit.", type="warning")
return
if creating:
item_id = None
current_label = ""
current_active = True
else:
assert selected is not None
item_id = UUID(str(selected["id"]))
current_label = str(selected["label"])
current_active = bool(selected["is_active"])
with ui.dialog() as dialog, ui.card().classes("w-full max-w-lg ui-card-surface"):
ui.label("Add Person Role" if creating else "Edit Person Role").classes(
"text-lg font-semibold"
)
label_input = ui.input("Label", value=current_label).props("outlined").classes("w-full")
active_input = ui.checkbox("Active", value=current_active)
async def submit() -> None:
saved = await save_role(
item_id=item_id,
label=str(label_input.value or ""),
is_active=bool(active_input.value),
)
if saved:
dialog.close()
with ui.row().classes("w-full justify-end gap-2"):
ui.button("Cancel", on_click=dialog.close).props("flat")
ui.button("Save", icon="save", on_click=submit).classes("ui-btn-primary")
dialog.open()
async def delete_selected_role() -> None:
selected = _selected_table_row(table)
if selected is None:
ui.notify("Select one Person Role to delete.", type="warning")
return
try:
await people.delete_person_role(UUID(str(selected["id"])))
except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Role deletion failed", operation="settings.roles.delete")
return
ui.notify("Person Role deleted", type="positive")
render_person_roles.refresh()
with ui.row().classes("w-full items-center gap-2 mt-3"):
ui.button("Add", icon="add", on_click=lambda: open_role_editor(creating=True)).classes(
"ui-btn-primary"
)
ui.button("Edit", icon="edit", on_click=lambda: open_role_editor(creating=False)).props("flat")
destructive_button("Delete", icon="delete", on_click=delete_selected_role)
@ui.refreshable
def render_prompts() -> None:
@@ -297,57 +407,3 @@ def _selected_table_row(table: Any) -> dict[str, Any] | None:
if len(selected) != 1:
return None
return selected[0]
def _person_role_row(
role: Any,
*,
is_referenced: bool,
on_save: Callable[..., Any],
on_delete: Callable[..., Any],
) -> None:
with ui.row().classes("w-full items-end gap-2 py-2 ui-header-divider"):
ui.input("Code", value=role.code).props("dense readonly").classes("min-w-44")
label = ui.input("Label", value=role.label).props("dense").classes("grow")
is_active = ui.switch("Active", value=role.is_active)
ui.button(
"Save",
icon="save",
on_click=lambda: on_save(label=str(label.value or ""), is_active=bool(is_active.value)),
).props("flat").classes("ui-link-primary")
if not is_referenced:
destructive_button("Delete", icon="delete", on_click=on_delete, extra_classes="text-xs")
def _save_role(
service: PeopleService,
item_id: UUID,
refresh: Callable[[], Any],
) -> Callable[..., Any]:
async def save(*, label: str, is_active: bool) -> None:
try:
await service.update_person_role(item_id, label=label, is_active=is_active)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Role update failed", operation="settings.roles.update")
return
ui.notify("Person Role updated", type="positive")
refresh()
return save
def _delete_role(
service: PeopleService,
item_id: UUID,
refresh: Callable[[], Any],
) -> Callable[[], Any]:
async def delete() -> None:
try:
await service.delete_person_role(item_id)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Role deletion failed", operation="settings.roles.delete")
return
ui.notify("Person Role deleted", type="positive")
refresh()
return delete
+63
View File
@@ -54,6 +54,69 @@ input:focus-visible,
outline-offset: 2px;
}
.print-source-image,
.print-source-pdf {
width: 40%;
max-height: 9.5in;
}
.print-source-image {
object-fit: contain;
}
.print-source-pdf {
height: 9.5in;
border: 0;
}
.print-transcription {
flex: 1;
line-height: 1.5;
}
.print-preserve-lines {
white-space: pre-wrap;
}
@media print {
@page {
margin: 0.6in;
}
body,
.q-layout,
.q-page-container {
background: white !important;
color: black !important;
}
.no-print,
header,
nav {
display: none !important;
}
.print-preview {
max-width: none !important;
padding: 0 !important;
}
.print-page-break {
break-before: page;
page-break-before: always;
}
.print-facsimile-row {
flex-wrap: nowrap !important;
}
.print-source,
.print-metadata-table,
.print-job-table {
break-inside: avoid;
}
}
/* Semantic utility classes */
.ui-text-primary {
color: var(--theme-text);