generated from john/python-template
V4.4 Complete
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()]
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user