generated from john/python-template
V4 implemented. Some tweaking left, but it is working
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""Additive V4 API routes for relationship and classification registries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import Request
|
||||
from fastapi import Response
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentType
|
||||
from transcription.db.models import PersonRole
|
||||
from transcription.services import DocumentService
|
||||
|
||||
router = APIRouter(prefix="/api/v4", tags=["v4-documents"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocumentTypePayload:
|
||||
id: UUID
|
||||
code: str
|
||||
label: str
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PersonRolePayload:
|
||||
id: UUID
|
||||
code: str
|
||||
label: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DocumentTypeRead(BaseModel):
|
||||
id: UUID
|
||||
code: str
|
||||
label: str
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
|
||||
|
||||
class PersonRoleRead(BaseModel):
|
||||
id: UUID
|
||||
code: str
|
||||
label: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class DocumentTypeWriteRequest(BaseModel):
|
||||
document_type_id: UUID | None = None
|
||||
document_type_code: str | None = None
|
||||
|
||||
|
||||
class DocumentTypeWriteResponse(BaseModel):
|
||||
document_id: UUID
|
||||
document_type_id: UUID | None
|
||||
document_type_code: str | None
|
||||
|
||||
|
||||
class DocumentPersonWriteRequest(BaseModel):
|
||||
person_id: UUID
|
||||
role_id: UUID | None = None
|
||||
role_code: str | None = None
|
||||
|
||||
|
||||
class DocumentPersonRoleUpdateRequest(BaseModel):
|
||||
role_id: UUID | None = None
|
||||
role_code: str | None = None
|
||||
|
||||
|
||||
class DocumentPersonRead(BaseModel):
|
||||
id: UUID
|
||||
document_id: UUID
|
||||
person_id: UUID
|
||||
role_id: UUID | None
|
||||
role_code: str
|
||||
person_name: str | None = None
|
||||
|
||||
|
||||
class DocumentPeopleResponse(BaseModel):
|
||||
document_id: UUID
|
||||
links: list[DocumentPersonRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _document_type_to_read(item: DocumentType) -> DocumentTypeRead:
|
||||
return DocumentTypeRead(
|
||||
id=item.id,
|
||||
code=item.code,
|
||||
label=item.label,
|
||||
is_active=item.is_active,
|
||||
sort_order=item.sort_order,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
if item.role_ref is not None:
|
||||
role_code = item.role_ref.code
|
||||
else:
|
||||
role_code = item.role.value
|
||||
|
||||
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,
|
||||
person_name=person_name,
|
||||
)
|
||||
|
||||
|
||||
def _document_to_type_response(item: Document) -> DocumentTypeWriteResponse:
|
||||
return DocumentTypeWriteResponse(
|
||||
document_id=item.id,
|
||||
document_type_id=item.document_type_id,
|
||||
document_type_code=item.document_type,
|
||||
)
|
||||
|
||||
|
||||
def get_document_service(request: Request) -> DocumentService:
|
||||
"""Resolve the document service from app lifespan state when available."""
|
||||
services = getattr(request.app.state, "services", None)
|
||||
if services is not None:
|
||||
return services.documents
|
||||
return DocumentService()
|
||||
|
||||
|
||||
@router.get("/document-types", response_model=list[DocumentTypeRead])
|
||||
async def list_document_types(
|
||||
active_only: bool = True,
|
||||
service: DocumentService = Depends(get_document_service),
|
||||
) -> list[DocumentTypeRead]:
|
||||
items = await service.list_document_types(active_only=active_only)
|
||||
return [_document_type_to_read(item) for item in items]
|
||||
|
||||
|
||||
@router.get("/person-roles", response_model=list[PersonRoleRead])
|
||||
async def list_person_roles(
|
||||
active_only: bool = True,
|
||||
service: DocumentService = Depends(get_document_service),
|
||||
) -> list[PersonRoleRead]:
|
||||
items = await service.list_person_roles(active_only=active_only)
|
||||
return [_person_role_to_read(item) for item in items]
|
||||
|
||||
|
||||
@router.put("/documents/{document_id}/type", response_model=DocumentTypeWriteResponse)
|
||||
async def set_document_type(
|
||||
document_id: UUID,
|
||||
payload: DocumentTypeWriteRequest,
|
||||
service: DocumentService = Depends(get_document_service),
|
||||
) -> DocumentTypeWriteResponse:
|
||||
document = await service.set_document_type(
|
||||
document_id=document_id,
|
||||
document_type_id=payload.document_type_id,
|
||||
document_type_code=payload.document_type_code,
|
||||
)
|
||||
return _document_to_type_response(document)
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}/people", response_model=DocumentPeopleResponse)
|
||||
async def list_document_people(
|
||||
document_id: UUID,
|
||||
service: DocumentService = Depends(get_document_service),
|
||||
) -> DocumentPeopleResponse:
|
||||
links = await service.list_document_people(document_id=document_id)
|
||||
return DocumentPeopleResponse(document_id=document_id, links=[_document_person_to_read(item) for item in links])
|
||||
|
||||
|
||||
@router.post("/documents/{document_id}/people", response_model=DocumentPersonRead)
|
||||
async def add_document_person_link(
|
||||
document_id: UUID,
|
||||
payload: DocumentPersonWriteRequest,
|
||||
service: DocumentService = Depends(get_document_service),
|
||||
) -> DocumentPersonRead:
|
||||
link = await service.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)
|
||||
|
||||
|
||||
@router.patch("/document-people/{document_person_id}", response_model=DocumentPersonRead)
|
||||
async def set_document_person_role(
|
||||
document_person_id: UUID,
|
||||
payload: DocumentPersonRoleUpdateRequest,
|
||||
service: DocumentService = Depends(get_document_service),
|
||||
) -> DocumentPersonRead:
|
||||
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)
|
||||
|
||||
|
||||
@router.delete("/document-people/{document_person_id}", status_code=204)
|
||||
async def delete_document_person_link(
|
||||
document_person_id: UUID,
|
||||
service: DocumentService = Depends(get_document_service),
|
||||
) -> Response:
|
||||
await service.remove_document_person_link(document_person_id=document_person_id)
|
||||
return Response(status_code=204)
|
||||
@@ -16,6 +16,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 .config import Settings
|
||||
from .config import configure_logging
|
||||
from .config import get_settings
|
||||
@@ -99,4 +100,5 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
register_error_handlers(app)
|
||||
register_pages(app)
|
||||
app.include_router(health_router)
|
||||
app.include_router(v4_documents_router)
|
||||
return app
|
||||
|
||||
@@ -11,6 +11,7 @@ from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import BigInteger
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
@@ -44,6 +45,7 @@ class JobStatus(StrEnum):
|
||||
class DocumentPersonRole(StrEnum):
|
||||
AUTHOR = "author"
|
||||
RECIPIENT = "recipient"
|
||||
MENTIONED = "mentioned"
|
||||
|
||||
|
||||
class JobSourceStatus(StrEnum):
|
||||
@@ -52,11 +54,43 @@ class JobSourceStatus(StrEnum):
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class DocumentType(SQLModel, table=True):
|
||||
"""Registry of allowed document types."""
|
||||
|
||||
__tablename__ = "document_type"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
code: str = Field(index=True, unique=True)
|
||||
label: str
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
documents: list["Document"] = Relationship(back_populates="document_type_ref", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
|
||||
|
||||
class PersonRole(SQLModel, table=True):
|
||||
"""Registry of allowed document-person relationship roles."""
|
||||
|
||||
__tablename__ = "person_role"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
code: str = Field(index=True, unique=True)
|
||||
label: str
|
||||
is_active: bool = True
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
document_people: list["DocumentPerson"] = Relationship(back_populates="role_ref", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
|
||||
|
||||
class Document(SQLModel, table=True):
|
||||
"""An historical document."""
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
name: str
|
||||
document_type_id: UUID | None = Field(default=None, foreign_key="document_type.id")
|
||||
document_type: str | None = None
|
||||
document_date: date | None = None
|
||||
document_date_raw: str | None = None
|
||||
@@ -69,6 +103,7 @@ class Document(SQLModel, table=True):
|
||||
jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
sources: list["Source"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
document_people: list["DocumentPerson"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
document_type_ref: Optional["DocumentType"] = Relationship(back_populates="documents", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
|
||||
|
||||
class Person(SQLModel, table=True):
|
||||
@@ -104,15 +139,29 @@ 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: DocumentPersonRole = Field(default=DocumentPersonRole.AUTHOR)
|
||||
role_id: UUID | None = Field(default=None, foreign_key="person_role.id")
|
||||
role: DocumentPersonRole = Field(
|
||||
default=DocumentPersonRole.AUTHOR,
|
||||
sa_column=Column(
|
||||
SAEnum(
|
||||
DocumentPersonRole,
|
||||
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||
native_enum=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
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"),
|
||||
)
|
||||
|
||||
document: Optional["Document"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
person: Optional["Person"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
role_ref: Optional["PersonRole"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"})
|
||||
|
||||
|
||||
class Job(SQLModel, table=True):
|
||||
@@ -120,7 +169,17 @@ class Job(SQLModel, table=True):
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
document_id: UUID = Field(foreign_key="document.id")
|
||||
status: JobStatus = Field(default=JobStatus.QUEUED)
|
||||
status: JobStatus = Field(
|
||||
default=JobStatus.QUEUED,
|
||||
sa_column=Column(
|
||||
SAEnum(
|
||||
JobStatus,
|
||||
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||
native_enum=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
retry_count: int = Field(default=0, ge=0)
|
||||
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
date_updated: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
@@ -227,7 +286,17 @@ class JobSource(SQLModel, table=True):
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
job_id: UUID = Field(foreign_key="job.id")
|
||||
source_id: UUID = Field(foreign_key="source.id")
|
||||
status: JobSourceStatus = Field(default=JobSourceStatus.PENDING)
|
||||
status: JobSourceStatus = Field(
|
||||
default=JobSourceStatus.PENDING,
|
||||
sa_column=Column(
|
||||
SAEnum(
|
||||
JobSourceStatus,
|
||||
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||
native_enum=False,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
raw_transcription: str | None = None
|
||||
ai_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||
raw_api_response: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import select
|
||||
@@ -10,10 +11,28 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from .engine import resolve_engine
|
||||
from .models import Job
|
||||
from .models import JobStatus
|
||||
from .models import DocumentType
|
||||
from .models import PersonRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_PERSON_ROLES: tuple[tuple[str, str], ...] = (
|
||||
("author", "Author"),
|
||||
("recipient", "Recipient"),
|
||||
("mentioned", "Mentioned"),
|
||||
)
|
||||
|
||||
DEFAULT_DOCUMENT_TYPES: tuple[tuple[str, str], ...] = (
|
||||
("letter", "Letter"),
|
||||
("record", "Record"),
|
||||
("memo", "Memo"),
|
||||
("postcard", "Postcard"),
|
||||
("journal", "Journal"),
|
||||
("note", "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.
|
||||
@@ -22,9 +41,29 @@ 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 seed_registry_defaults(engine=active_engine)
|
||||
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
|
||||
|
||||
|
||||
async def seed_registry_defaults(*, engine: AsyncEngine | None = None) -> None:
|
||||
"""Seed default registry rows for role and document type taxonomies."""
|
||||
active_engine = engine or resolve_engine()
|
||||
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))
|
||||
|
||||
type_codes = set((await session.exec(select(DocumentType.code))).all())
|
||||
for sort_order, (code, label) in enumerate(DEFAULT_DOCUMENT_TYPES):
|
||||
if code not in type_codes:
|
||||
session.add(DocumentType(code=code, label=label, sort_order=sort_order))
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
|
||||
"""Get the next queued job, if any."""
|
||||
result = await session.exec(
|
||||
|
||||
@@ -14,7 +14,10 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..db.models import Document
|
||||
from ..db.models import DocumentPerson
|
||||
from ..db.models import DocumentPersonRole
|
||||
from ..db.models import DocumentType
|
||||
from ..db.models import Person
|
||||
from ..db.models import PersonRole
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
from .base import ServiceBase
|
||||
@@ -59,6 +62,126 @@ class UploadJobResult:
|
||||
class DocumentService(ServiceBase):
|
||||
"""Thin service class for managing documents in the database."""
|
||||
|
||||
async def _resolve_or_create_document_type(
|
||||
self,
|
||||
*,
|
||||
session: AsyncSession,
|
||||
document_type_id: UUID | None,
|
||||
document_type_code: str | None,
|
||||
) -> DocumentType | None:
|
||||
"""Resolve canonical document type by id/code with compatibility fallback creation."""
|
||||
if document_type_id is not None:
|
||||
found = await session.get(DocumentType, document_type_id)
|
||||
if found is None:
|
||||
raise DocumentError(
|
||||
f"Document type with id {document_type_id} not found",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Select a valid document type and retry.",
|
||||
)
|
||||
return found
|
||||
|
||||
if document_type_code is None:
|
||||
return None
|
||||
|
||||
normalized_code = document_type_code.strip().lower()
|
||||
if not normalized_code:
|
||||
return None
|
||||
|
||||
existing = (await session.exec(select(DocumentType).where(DocumentType.code == normalized_code))).first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
created = DocumentType(code=normalized_code, label=normalized_code.replace("_", " ").title())
|
||||
session.add(created)
|
||||
await session.flush()
|
||||
return created
|
||||
|
||||
async def _resolve_or_create_person_role(
|
||||
self,
|
||||
*,
|
||||
session: AsyncSession,
|
||||
role_id: UUID | None,
|
||||
role_code: str | None,
|
||||
) -> PersonRole:
|
||||
"""Resolve canonical person role by id/code with compatibility fallback creation."""
|
||||
if role_id is not None:
|
||||
found = await session.get(PersonRole, role_id)
|
||||
if found is None:
|
||||
raise DocumentError(
|
||||
f"Person role with id {role_id} not found",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Select a valid relationship role and retry.",
|
||||
)
|
||||
return found
|
||||
|
||||
normalized_code = (role_code or "").strip().lower()
|
||||
if not normalized_code:
|
||||
normalized_code = DocumentPersonRole.AUTHOR.value
|
||||
|
||||
existing = (await session.exec(select(PersonRole).where(PersonRole.code == normalized_code))).first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
created = PersonRole(code=normalized_code, label=normalized_code.replace("_", " ").title())
|
||||
session.add(created)
|
||||
await session.flush()
|
||||
return created
|
||||
|
||||
async def _sync_document_type_fields(self, *, session: AsyncSession, document: Document) -> None:
|
||||
"""Synchronize legacy and canonical document type fields."""
|
||||
resolved = await self._resolve_or_create_document_type(
|
||||
session=session,
|
||||
document_type_id=document.document_type_id,
|
||||
document_type_code=document.document_type,
|
||||
)
|
||||
if resolved is None:
|
||||
document.document_type_id = None
|
||||
document.document_type = None
|
||||
return
|
||||
|
||||
document.document_type_id = resolved.id
|
||||
document.document_type = resolved.code
|
||||
|
||||
async def _sync_document_person_role_fields(self, *, session: AsyncSession, link: DocumentPerson) -> None:
|
||||
"""Synchronize legacy and canonical relationship role fields."""
|
||||
role_code = link.role.value if isinstance(link.role, DocumentPersonRole) else str(link.role)
|
||||
resolved = await self._resolve_or_create_person_role(
|
||||
session=session,
|
||||
role_id=link.role_id,
|
||||
role_code=role_code,
|
||||
)
|
||||
link.role_id = resolved.id
|
||||
try:
|
||||
link.role = DocumentPersonRole(resolved.code)
|
||||
except ValueError as exc:
|
||||
raise DocumentError(
|
||||
f"Unsupported role code {resolved.code!r} for legacy compatibility",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Use author, recipient, or mentioned for now.",
|
||||
) from exc
|
||||
|
||||
async def _get_document_or_raise(self, *, session: AsyncSession, document_id: UUID) -> Document:
|
||||
"""Get a document by id or raise a not-found service error."""
|
||||
document = await session.get(Document, document_id)
|
||||
if document is None:
|
||||
raise DocumentError(
|
||||
f"Document with id {document_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the document id and retry.",
|
||||
)
|
||||
return document
|
||||
|
||||
async def _get_person_or_raise(self, *, session: AsyncSession, person_id: UUID) -> Person:
|
||||
"""Get a person by id or raise a not-found service error."""
|
||||
person = await session.get(Person, person_id)
|
||||
if person is None:
|
||||
raise DocumentError(
|
||||
f"Person with id {person_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the person id and retry.",
|
||||
)
|
||||
return person
|
||||
|
||||
#
|
||||
# CRUD Operations
|
||||
#
|
||||
@@ -71,6 +194,7 @@ class DocumentService(ServiceBase):
|
||||
) -> Document:
|
||||
"""Create a new document in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await self._sync_document_type_fields(session=_session, document=document)
|
||||
_session.add(document)
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(document,))
|
||||
@@ -113,6 +237,7 @@ class DocumentService(ServiceBase):
|
||||
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
||||
"""Update an existing document in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await self._sync_document_type_fields(session=_session, document=document)
|
||||
document.updated_at = datetime.now(UTC)
|
||||
merged = await _session.merge(document)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
@@ -198,6 +323,7 @@ class DocumentService(ServiceBase):
|
||||
select(Person)
|
||||
.options(
|
||||
selectinload(Person.document_people).selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Person.document_people).selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
.where(Person.id == person_id)
|
||||
.execution_options(populate_existing=True)
|
||||
@@ -251,8 +377,16 @@ class DocumentService(ServiceBase):
|
||||
) -> DocumentPerson:
|
||||
"""Create a document-person association in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await self._sync_document_person_role_fields(session=_session, link=document_person)
|
||||
_session.add(document_person)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(document_person,))
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(document_person,))
|
||||
except IntegrityError as exc:
|
||||
raise DocumentError(
|
||||
"Duplicate relationship link for document/person/role",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Remove the existing relationship link or choose a different role.",
|
||||
) from exc
|
||||
return document_person
|
||||
|
||||
async def read_document_person(
|
||||
@@ -280,8 +414,17 @@ class DocumentService(ServiceBase):
|
||||
) -> DocumentPerson:
|
||||
"""Update an existing document-person association in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await self._sync_document_person_role_fields(session=_session, link=document_person)
|
||||
document_person.updated_at = datetime.now(UTC)
|
||||
merged = await _session.merge(document_person)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
except IntegrityError as exc:
|
||||
raise DocumentError(
|
||||
"Duplicate relationship link for document/person/role",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Remove the existing relationship link or choose a different role.",
|
||||
) from exc
|
||||
return merged
|
||||
|
||||
async def delete_document_person(
|
||||
@@ -323,6 +466,8 @@ class DocumentService(ServiceBase):
|
||||
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Document.document_people).selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Document.document_type_ref), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
.where(Document.id == document_id)
|
||||
.execution_options(populate_existing=True)
|
||||
@@ -354,6 +499,7 @@ class DocumentService(ServiceBase):
|
||||
query = select(DocumentPerson).options(
|
||||
selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
|
||||
selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
|
||||
selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
if document_id is not None:
|
||||
query = query.where(DocumentPerson.document_id == document_id)
|
||||
@@ -361,3 +507,180 @@ class DocumentService(ServiceBase):
|
||||
query = query.where(DocumentPerson.person_id == person_id)
|
||||
result = await _session.exec(query)
|
||||
return result.all()
|
||||
|
||||
async def list_document_types(
|
||||
self,
|
||||
*,
|
||||
active_only: bool = True,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Sequence[DocumentType]:
|
||||
"""List configured document types."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = select(DocumentType)
|
||||
if active_only:
|
||||
query = query.where(DocumentType.is_active.is_(True))
|
||||
query = query.order_by(DocumentType.sort_order, DocumentType.code)
|
||||
result = await _session.exec(query)
|
||||
return result.all()
|
||||
|
||||
async def list_person_roles(
|
||||
self,
|
||||
*,
|
||||
active_only: bool = True,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Sequence[PersonRole]:
|
||||
"""List configured relationship roles."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = select(PersonRole)
|
||||
if active_only:
|
||||
query = query.where(PersonRole.is_active.is_(True))
|
||||
query = query.order_by(PersonRole.code)
|
||||
result = await _session.exec(query)
|
||||
return result.all()
|
||||
|
||||
async def set_document_type(
|
||||
self,
|
||||
*,
|
||||
document_id: UUID,
|
||||
document_type_id: UUID | None = None,
|
||||
document_type_code: str | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Document:
|
||||
"""Set a document type by canonical id or code."""
|
||||
async with self._session_scope(session) as _session:
|
||||
document = await self._get_document_or_raise(session=_session, document_id=document_id)
|
||||
if document_type_id is None and (document_type_code is None or not document_type_code.strip()):
|
||||
raise DocumentError(
|
||||
"Either document_type_id or document_type_code is required",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Provide a valid document type id or code and retry.",
|
||||
)
|
||||
|
||||
if document_type_id is not None and document_type_code and document_type_code.strip():
|
||||
raise DocumentError(
|
||||
"Provide document_type_id or document_type_code, not both",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Send only one document type selector and retry.",
|
||||
)
|
||||
|
||||
document.document_type_id = document_type_id
|
||||
document.document_type = document_type_code
|
||||
await self._sync_document_type_fields(session=_session, document=document)
|
||||
document.updated_at = datetime.now(UTC)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(document,))
|
||||
return document
|
||||
|
||||
async def add_document_person_link(
|
||||
self,
|
||||
*,
|
||||
document_id: UUID,
|
||||
person_id: UUID,
|
||||
role_id: UUID | None = None,
|
||||
role_code: str | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> DocumentPerson:
|
||||
"""Create a document-person link with role selected by id or code."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await self._get_document_or_raise(session=_session, document_id=document_id)
|
||||
await self._get_person_or_raise(session=_session, person_id=person_id)
|
||||
|
||||
link = DocumentPerson(
|
||||
document_id=document_id,
|
||||
person_id=person_id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
role_id=role_id,
|
||||
)
|
||||
if role_code and role_code.strip():
|
||||
try:
|
||||
link.role = DocumentPersonRole(role_code.strip().lower())
|
||||
except ValueError as exc:
|
||||
raise DocumentError(
|
||||
f"Unsupported role code {role_code!r}",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Use author, recipient, or mentioned.",
|
||||
) from exc
|
||||
|
||||
await self._sync_document_person_role_fields(session=_session, link=link)
|
||||
_session.add(link)
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(link,))
|
||||
except IntegrityError as exc:
|
||||
raise DocumentError(
|
||||
"Duplicate relationship link for document/person/role",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Remove the existing relationship link or choose a different role.",
|
||||
) from exc
|
||||
return link
|
||||
|
||||
async def set_document_person_role(
|
||||
self,
|
||||
*,
|
||||
document_person_id: UUID,
|
||||
role_id: UUID | None = None,
|
||||
role_code: str | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> DocumentPerson:
|
||||
"""Update an existing relationship link role by id or code."""
|
||||
async with self._session_scope(session) as _session:
|
||||
link = await _session.get(DocumentPerson, document_person_id)
|
||||
if link is None:
|
||||
raise DocumentError(
|
||||
f"DocumentPerson with id {document_person_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the relationship link id and retry.",
|
||||
)
|
||||
|
||||
if role_id is None and (role_code is None or not role_code.strip()):
|
||||
raise DocumentError(
|
||||
"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 and role_code.strip():
|
||||
raise DocumentError(
|
||||
"Provide role_id or role_code, not both",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Send only one role selector and retry.",
|
||||
)
|
||||
|
||||
link.role_id = role_id
|
||||
if role_code and role_code.strip():
|
||||
try:
|
||||
link.role = DocumentPersonRole(role_code.strip().lower())
|
||||
except ValueError as exc:
|
||||
raise DocumentError(
|
||||
f"Unsupported role code {role_code!r}",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Use author, recipient, or mentioned.",
|
||||
) from exc
|
||||
|
||||
await self._sync_document_person_role_fields(session=_session, link=link)
|
||||
link.updated_at = datetime.now(UTC)
|
||||
try:
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(link,))
|
||||
except IntegrityError as exc:
|
||||
raise DocumentError(
|
||||
"Duplicate relationship link for document/person/role",
|
||||
category=ErrorCategory.CONFLICT,
|
||||
suggestion="Remove the existing relationship link or choose a different role.",
|
||||
) from exc
|
||||
return link
|
||||
|
||||
async def remove_document_person_link(
|
||||
self,
|
||||
*,
|
||||
document_person_id: UUID,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Delete a relationship link by id."""
|
||||
async with self._session_scope(session) as _session:
|
||||
link = await _session.get(DocumentPerson, document_person_id)
|
||||
if link is None:
|
||||
raise DocumentError(
|
||||
f"DocumentPerson with id {document_person_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the relationship link id and retry.",
|
||||
)
|
||||
await _session.delete(link)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
@@ -19,7 +19,12 @@ def _register_global_styles(app: FastAPI) -> None:
|
||||
return
|
||||
|
||||
apply_archival_theme()
|
||||
ui.add_css(read_css("theme.css"), shared=True)
|
||||
try:
|
||||
ui.add_css(read_css("theme.css"), shared=True)
|
||||
except RuntimeError:
|
||||
# NiceGUI shared style registration can raise when the app is constructed
|
||||
# outside an active client context, which happens in unit tests.
|
||||
pass
|
||||
|
||||
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole
|
||||
from transcription.db.models import Document
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.documents import (
|
||||
DocumentDeleteBlockedError,
|
||||
@@ -31,9 +32,6 @@ from transcription.ui.theme import page_header
|
||||
|
||||
from ...db.session import SessionFactoryDep
|
||||
|
||||
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
|
||||
|
||||
|
||||
def register_page() -> None:
|
||||
"""Register documents list and detail routes."""
|
||||
|
||||
@@ -43,19 +41,30 @@ def register_page() -> None:
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||
page_header("Create Document", subtitle="Document name is required.")
|
||||
page_header("Create Document", subtitle="Document name and type are required.")
|
||||
|
||||
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
|
||||
form = _render_document_form_fields(people=people)
|
||||
role_catalog = await document_service.list_person_roles()
|
||||
type_catalog = await document_service.list_document_types()
|
||||
form = _render_document_form_fields(
|
||||
people=people,
|
||||
role_codes=[role.code for role in role_catalog],
|
||||
role_labels={role.code: role.label for role in role_catalog},
|
||||
type_options={doc_type.code: doc_type.label for doc_type in type_catalog},
|
||||
)
|
||||
|
||||
requested_doc_id = request.query_params.get("document_id")
|
||||
return_to = request.query_params.get("return_to")
|
||||
|
||||
async def submit_create() -> None:
|
||||
candidate_name = (form["name"].value or "").strip()
|
||||
candidate_type = _resolve_selected_document_type_code(form["type"].value, form["type_options"])
|
||||
if not candidate_name:
|
||||
ui.notify("Document name is required.", type="warning")
|
||||
return
|
||||
if not candidate_type:
|
||||
ui.notify("Document type is required.", type="warning")
|
||||
return
|
||||
|
||||
parsed_date = _parse_iso_date(form["date"].value)
|
||||
if form["date"].value and parsed_date is None:
|
||||
@@ -64,7 +73,7 @@ def register_page() -> None:
|
||||
|
||||
candidate = Document(
|
||||
name=candidate_name,
|
||||
document_type=(form["type"].value or "").strip() or None,
|
||||
document_type=candidate_type,
|
||||
document_date=parsed_date,
|
||||
document_date_raw=(form["date_raw"].value or "").strip() or None,
|
||||
location_created=(form["location"].value or "").strip() or None,
|
||||
@@ -78,27 +87,17 @@ def register_page() -> None:
|
||||
show_error(exc, title="Create failed", operation="documents.create")
|
||||
return
|
||||
|
||||
selected_author = (form["author"].value or "").strip()
|
||||
if selected_author == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
return
|
||||
if selected_author:
|
||||
parsed_author_id = _parse_uuid(selected_author)
|
||||
if parsed_author_id is None:
|
||||
ui.notify("Selected author is invalid.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
await document_service.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=created.id,
|
||||
person_id=parsed_author_id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
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 document_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="Author link failed", operation="documents.create.link_author")
|
||||
return
|
||||
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":
|
||||
@@ -138,7 +137,7 @@ def register_page() -> None:
|
||||
DocumentTableRow(
|
||||
id=doc.id,
|
||||
name=doc.name,
|
||||
document_type=doc.document_type or "",
|
||||
document_type=(doc.document_type_ref.label if doc.document_type_ref is not None else (doc.document_type or "")),
|
||||
archive_identifier=doc.archive_identifier or "",
|
||||
created_at=doc.created_at.strftime("%b %d, %Y"),
|
||||
)
|
||||
@@ -166,8 +165,9 @@ def register_page() -> None:
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||
type_display = document.document_type_ref.label if document.document_type_ref is not None else (document.document_type or "Unspecified")
|
||||
with section_header_row():
|
||||
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
|
||||
page_header(document.name, subtitle=f"Type: {type_display} | ID: {document.id}")
|
||||
|
||||
with ui.row().classes("items-center gap-2"):
|
||||
ui.button(
|
||||
@@ -254,12 +254,21 @@ def register_page() -> None:
|
||||
page_header("Edit Document Record", subtitle="Document name and document type are required.")
|
||||
|
||||
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
|
||||
existing_author = next((link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR), None)
|
||||
form = _render_document_form_fields(document=document, people=people, existing_author_id=existing_author.person_id if existing_author else None)
|
||||
role_catalog = await document_service.list_person_roles()
|
||||
type_catalog = await document_service.list_document_types(active_only=False)
|
||||
existing_by_role = _existing_people_by_role(document)
|
||||
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={doc_type.code: doc_type.label for doc_type in type_catalog},
|
||||
selected_people_by_role=existing_by_role,
|
||||
)
|
||||
|
||||
async def submit_edit() -> None:
|
||||
candidate_name = (form["name"].value or "").strip()
|
||||
candidate_type = (form["type"].value or "").strip()
|
||||
candidate_type = _resolve_selected_document_type_code(form["type"].value, form["type_options"])
|
||||
if not candidate_name:
|
||||
ui.notify("Document name is required.", type="warning")
|
||||
return
|
||||
@@ -291,26 +300,25 @@ def register_page() -> None:
|
||||
show_error(exc, title="Save failed", operation="documents.edit.save")
|
||||
return
|
||||
|
||||
selected_author = (form["author"].value or "").strip()
|
||||
if selected_author == CREATE_NEW_PERSON_OPTION:
|
||||
ui.navigate.to("/people/new")
|
||||
return
|
||||
|
||||
existing_author_links = [link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR]
|
||||
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:
|
||||
if not selected_author:
|
||||
for link in existing_author_links:
|
||||
await document_service.delete_document_person(link)
|
||||
else:
|
||||
selected_author_id = UUID(selected_author)
|
||||
if not any(link.person_id == selected_author_id for link in existing_author_links):
|
||||
for link in existing_author_links:
|
||||
await document_service.delete_document_person(link)
|
||||
await document_service.create_document_person(
|
||||
DocumentPerson(document_id=document.id, person_id=selected_author_id, role=DocumentPersonRole.AUTHOR)
|
||||
)
|
||||
for role_code, person_id in sorted(desired_links - set(existing_links.keys())):
|
||||
await document_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 document_service.remove_document_person_link(document_person_id=stale_link.id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Author update failed", operation="documents.edit.link_author")
|
||||
show_error(exc, title="Relationship update failed", operation="documents.edit.link_people")
|
||||
return
|
||||
|
||||
ui.notify("Document updated", type="positive")
|
||||
@@ -388,11 +396,23 @@ def register_page() -> None:
|
||||
|
||||
|
||||
def _render_document_form_fields(
|
||||
*, document: Document | None = None, people: list[Any], existing_author_id: UUID | None = None
|
||||
*,
|
||||
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,
|
||||
) -> dict[str, Any]:
|
||||
with archival_card(extra_classes="gap-3"):
|
||||
name_input = ui.input(label="Document name", value=document.name if document else "").props("outlined").classes("w-full ui-form-surface")
|
||||
type_input = ui.input(label="Document type", value=document.document_type if document and document.document_type else "").props("outlined").classes("w-full ui-form-surface")
|
||||
ordered_types = sorted(type_options.items(), key=lambda item: item[1].casefold())
|
||||
type_display_to_code = {f"{label} ({code})": code for code, label in ordered_types}
|
||||
type_display_options = list(type_display_to_code.keys())
|
||||
selected_type = f"{type_options[document.document_type]} ({document.document_type})" if document and document.document_type in type_options else (type_display_options[0] if type_display_options else "")
|
||||
type_input = ui.select(type_display_options, label="Document type").props("outlined").classes("w-full ui-form-surface")
|
||||
if selected_type:
|
||||
type_input.value = selected_type
|
||||
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
||||
date_input = ui.input(
|
||||
@@ -405,18 +425,32 @@ def _render_document_form_fields(
|
||||
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier if document and document.archive_identifier else "").props("outlined").classes("w-full ui-form-surface")
|
||||
notes_input = ui.textarea(label="Notes", value=document.notes if document and document.notes else "").props("outlined autogrow").classes("w-full ui-form-surface")
|
||||
|
||||
author_options = {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} | {str(p.id): p.full_name for p in people}
|
||||
author_select = ui.select(author_options, label="Author (Person)", value=str(existing_author_id) if existing_author_id else "").props("outlined").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): p.full_name 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
|
||||
|
||||
return {
|
||||
"name": name_input,
|
||||
"type": type_input,
|
||||
"type_options": type_display_to_code,
|
||||
"date": date_input,
|
||||
"date_raw": date_raw_input,
|
||||
"location": location_input,
|
||||
"archive": archive_input,
|
||||
"notes": notes_input,
|
||||
"author": author_select,
|
||||
"role_people": role_people_inputs,
|
||||
}
|
||||
|
||||
|
||||
@@ -430,11 +464,12 @@ def _render_bento_viewer_zone(document: Document) -> None:
|
||||
|
||||
|
||||
def _render_bento_metadata_zone(document: Document) -> None:
|
||||
author_link = next((item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR and item.person is not None), None)
|
||||
people_by_role = _group_people_labels_by_role(document)
|
||||
author_names = people_by_role.get("author", [])
|
||||
|
||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||
with archival_card(title="Archival Metadata"):
|
||||
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
|
||||
metadata_row("Author(s):", ", ".join(author_names) if author_names else "Not set")
|
||||
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
|
||||
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
|
||||
metadata_row("Location Created:", document.location_created or "Not set")
|
||||
@@ -455,12 +490,13 @@ def _render_bento_relations_zone(document: Document) -> None:
|
||||
if not document.document_people:
|
||||
render_empty_state("No linked people yet.", italic=True)
|
||||
else:
|
||||
grouped = _group_people_labels_by_role(document)
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
for link in document.document_people:
|
||||
person_label = link.person.full_name if link.person is not None else "Unknown person"
|
||||
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
|
||||
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
|
||||
archival_badge(link.role.value)
|
||||
for role_code in sorted(grouped.keys()):
|
||||
with ui.column().classes("w-full ui-row-surface p-2 gap-1"):
|
||||
archival_badge(role_code)
|
||||
for person_label in grouped[role_code]:
|
||||
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
|
||||
|
||||
with archival_card(title="Pipeline Jobs"):
|
||||
with ui.row().classes("w-full justify-between items-center mb-2"):
|
||||
@@ -487,4 +523,58 @@ def _parse_iso_date(value: str | None) -> date | None:
|
||||
try:
|
||||
return date.fromisoformat(candidate)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_selected_document_type_code(selected_value: Any, type_options: dict[str, str]) -> str | None:
|
||||
candidate = (str(selected_value).strip() if selected_value is not None else "")
|
||||
if not candidate:
|
||||
return None
|
||||
return type_options.get(candidate)
|
||||
|
||||
|
||||
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 []
|
||||
if isinstance(selected, str):
|
||||
selected_ids = [selected]
|
||||
else:
|
||||
selected_ids = 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
|
||||
@@ -383,10 +383,11 @@ def _render_person_biography_zone(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 link.role.value
|
||||
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: {link.role.value}").classes("text-[10px] ui-text-muted")
|
||||
ui.label(f"Role: {role_code}").classes("text-[10px] ui-text-muted")
|
||||
ui.button(
|
||||
"Open",
|
||||
on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"),
|
||||
|
||||
Reference in New Issue
Block a user