From 0ace10269f60a0ce7a0a89afe0bdc136f12fa87d Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:07:19 -0500 Subject: [PATCH] V4 implemented. Some tweaking left, but it is working --- README.md | 8 +- docs/api_qa.md | 219 +++++++++++++ docs/ver4/implementation_plan_v4.md | 2 + docs/ver4/requirements_v4.md | 2 +- src/transcription/api/v4_documents.py | 225 +++++++++++++ src/transcription/app.py | 2 + src/transcription/db/models.py | 75 ++++- src/transcription/db/operations.py | 39 +++ src/transcription/services/documents.py | 327 ++++++++++++++++++- src/transcription/ui/__init__.py | 7 +- src/transcription/ui/pages/documents_page.py | 216 ++++++++---- src/transcription/ui/pages/people_page.py | 3 +- tests/api/test_v4_documents.py | 169 ++++++++++ tests/services/test_document_service.py | 61 ++++ tests/services/test_v2_crud.py | 4 + tests/test_db.py | 27 ++ tests/test_models.py | 69 +++- tests/ui/test_documents_page.py | 3 +- ui_test_documents.log | Bin 0 -> 394704 bytes 19 files changed, 1379 insertions(+), 79 deletions(-) create mode 100644 docs/api_qa.md create mode 100644 src/transcription/api/v4_documents.py create mode 100644 tests/api/test_v4_documents.py create mode 100644 ui_test_documents.log diff --git a/README.md b/README.md index d8876e6..4fa1528 100644 --- a/README.md +++ b/README.md @@ -145,11 +145,13 @@ The canonical MVP prompt is: ## Destructive test procedure (with data backup) -Use the cross-platform Python wrapper below whenever a test run might alter local `./data`. +AI execution policy: before running any unit tests, create a backup of `./data` first. After tests succeed, always pause and ask whether to restore now. + +Use the cross-platform Python wrapper below whenever an AI agent runs tests against this repository. 1. Create backup of `./data`. 2. Run your test command. -3. On success, prompt whether to restore now. +3. On success, always prompt whether to restore now (do not auto-restore unless explicitly approved). 4. On failure, keep backup and current state for inspection. Preflight behavior: @@ -165,6 +167,8 @@ uv run python tools/run_destructive_tests.py -- pytest tests/services/test_job_s After tests pass, the script asks whether to restore backup immediately. +This is the required default mode for AI-assisted test runs because it gives time to verify and accept code changes before any restoration happens. + ### Run with automatic restore (non-interactive) ```bash diff --git a/docs/api_qa.md b/docs/api_qa.md new file mode 100644 index 0000000..6b8f73e --- /dev/null +++ b/docs/api_qa.md @@ -0,0 +1,219 @@ +# API Questions & Answers + +## Question +``` +what is v4_documents.py? +``` + +## Response +v4_documents.py is the new additive V4 API router I added in Phase 4. + +What it does: +- Defines V4 endpoints under /api/v4 for registry-driven document/relationship workflows. +- Exposes read endpoints for catalogs: + - GET /api/v4/document-types + - GET /api/v4/person-roles +- Exposes write endpoints for code-or-id updates: + - PUT /api/v4/documents/{document_id}/type + - POST /api/v4/documents/{document_id}/people + - PATCH /api/v4/document-people/{document_person_id} + - DELETE /api/v4/document-people/{document_person_id} +- Uses DocumentService methods as the backend (no direct DB access in the route layer). +- Returns typed request/response models for backward-compatible, additive API evolution. + +It is registered in app.py, so it is live when the app starts. + +If you want, I can walk endpoint-by-endpoint with examples of request/response payloads. + +--- + +## Question +``` +Please do +``` + +## Response +Absolutely. Here is a quick endpoint-by-endpoint walkthrough of v4_documents.py, with practical request/response examples. + +**What This Router Is** +- Prefix: /api/v4 +- Purpose: additive V4 contract for: +1. Registry catalogs (document types, person roles) +2. Role-aware document-person linking +3. Code-or-id document type assignment +- It delegates to DocumentService and does not access DB sessions directly in route handlers. + +**Dependency Wiring** +- Route dependency: get_document_service in v4_documents.py +- Behavior: +1. Uses app.state.services.documents when running in the app lifecycle +2. Falls back to a direct DocumentService() instance if needed (useful in isolated tests) + +**1) List Document Types** +- Endpoint: GET /api/v4/document-types +- Query param: active_only=true by default +- Backing service call: list_document_types(active_only=...) +- Response shape: +[ + { + "id": "uuid", + "code": "letter", + "label": "Letter", + "is_active": true, + "sort_order": 0 + } +] + +**2) List Person Roles** +- Endpoint: GET /api/v4/person-roles +- Query param: active_only=true by default +- Backing service call: list_person_roles(active_only=...) +- Response shape: +[ + { + "id": "uuid", + "code": "author", + "label": "Author", + "is_active": true + } +] + +**3) Set Document Type (By Id Or Code)** +- Endpoint: PUT /api/v4/documents/{document_id}/type +- Body options: +1. document_type_id +2. document_type_code +- Rule: provide one selector, not both +- Example request: +{ + "document_type_code": "record" +} +- Example response: +{ + "document_id": "uuid", + "document_type_id": "uuid", + "document_type_code": "record" +} + +**4) List Document-Person Links For A Document** +- Endpoint: GET /api/v4/documents/{document_id}/people +- Backing service call: list_document_people(document_id=...) +- Example response: +{ + "document_id": "uuid", + "links": [ + { + "id": "uuid", + "document_id": "uuid", + "person_id": "uuid", + "role_id": "uuid", + "role_code": "author", + "person_name": "Zenna Cochran" + } + ] +} + +**5) Add A Document-Person Link** +- Endpoint: POST /api/v4/documents/{document_id}/people +- Body fields: +1. person_id required +2. role_id optional +3. role_code optional +- If duplicate document/person/role link is attempted, returns conflict envelope via shared error handlers. +- Example request: +{ + "person_id": "uuid", + "role_code": "mentioned" +} +- Example success response: +{ + "id": "uuid", + "document_id": "uuid", + "person_id": "uuid", + "role_id": "uuid", + "role_code": "mentioned", + "person_name": "Ada Lovelace" +} + +**6) Update Link Role** +- Endpoint: PATCH /api/v4/document-people/{document_person_id} +- Body options: +1. role_id +2. role_code +- Rule: provide one selector, not both +- Example request: +{ + "role_code": "recipient" +} +- Example response: +{ + "id": "uuid", + "document_id": "uuid", + "person_id": "uuid", + "role_id": "uuid", + "role_code": "recipient", + "person_name": "Ada Lovelace" +} + +**7) Delete Link** +- Endpoint: DELETE /api/v4/document-people/{document_person_id} +- Response: HTTP 204 No Content + +**Error Behavior** +- Uses shared registration in errors.py +- So AppError categories map to consistent envelope + status codes: +1. validation_error -> 400 +2. not_found_error -> 404 +3. conflict_error -> 409 +4. internal_unexpected_error -> 500 + +**Where It Gets Mounted** +- Included in app factory at app.py +- That makes all /api/v4 endpoints available during normal app startup. + +If you want, next I can generate a compact curl-style smoke checklist for all 7 endpoints so you can test them in under 2 minutes. + +--- + +## Question +``` +Should there be one of these for people, jobs, and sources too? +``` + +## Response + +Short answer: yes, but not all at once. + +Given your V4 scope, the strongest split is: + +1. Keep v4_documents.py for document-centric workflows +This file already owns: +- document type catalog/use +- document-person role links +- document-level relationship mutations + +2. Add a V4 people router next +Recommended new file: +- src/transcription/api/v4_people.py + +Why: +- You likely need person-centric queries and updates that are not naturally document-scoped +- It keeps API boundaries clearer as UI grows + +3. Do not rush a separate jobs/sources V4 router unless contracts are diverging +For now, jobs and sources are still tightly tied to the existing transcription pipeline behavior. A new v4_jobs or v4_sources router is useful only when you need genuinely new contract shape, such as: +- job lifecycle contract changes +- source revision/audit contract changes +- new pagination/filtering semantics incompatible with current endpoints + +Practical rule of thumb: +1. New router if the domain has new V4 semantics and independent lifecycle +2. Keep existing router if only additive fields are needed + +Suggested target structure: +1. v4_documents.py +2. src/transcription/api/v4_people.py +3. optional later: src/transcription/api/v4_jobs.py +4. optional later: src/transcription/api/v4_sources.py + +If you want, I can scaffold v4_people next with additive endpoints that mirror your current service capabilities and keep error envelope behavior consistent. \ No newline at end of file diff --git a/docs/ver4/implementation_plan_v4.md b/docs/ver4/implementation_plan_v4.md index 92a74f8..84e8bab 100644 --- a/docs/ver4/implementation_plan_v4.md +++ b/docs/ver4/implementation_plan_v4.md @@ -61,6 +61,8 @@ Implement the Version 4 project definition from the current repository state whi - Add API tests for relationship behavior and document-type selection. - Add UI tests or walkthrough coverage for grouped roles and type selection. - Add regression coverage for delete and cleanup semantics. +- Enforce backup-first test execution for AI-run unit tests: backup `./data` before tests, then always prompt for restore after successful tests. +- Keep restore confirmation-gated by default so code and test outcomes can be reviewed before data is reverted. ## Done When diff --git a/docs/ver4/requirements_v4.md b/docs/ver4/requirements_v4.md index eb2aa09..3292df8 100644 --- a/docs/ver4/requirements_v4.md +++ b/docs/ver4/requirements_v4.md @@ -24,7 +24,7 @@ This document defines the baseline requirements for the document transcription s | REQ-15 | Data Constraint | Calculate and store cryptographic file hashes (SHA-256) and file sizes for uploaded source images. | test | | REQ-16 | Data Constraint | Preserve a portable relational model across supported backends using SQLModel, SQLAlchemy, SQLite, and PostgreSQL. | inspection | | REQ-17 | Reliability | Ensure delete and update flows for documents, people, and relationship links remain deterministic and safe. | test | -| REQ-18 | Operations Constraint | Keep canonical development, testing, restore, and recovery workflows OS-independent. | inspection | +| REQ-18 | Operations Constraint | Keep canonical development, testing, restore, and recovery workflows OS-independent; for AI-run unit tests, require a pre-test backup of `./data` and an always-shown post-success confirmation prompt before any restore action. | inspection | | REQ-19 | Quality | Provide automated coverage for async transcription workflows, relationship-role enforcement, document-type selection, and regression behavior. | test | ## Clarifying Constraints diff --git a/src/transcription/api/v4_documents.py b/src/transcription/api/v4_documents.py new file mode 100644 index 0000000..a4d5d14 --- /dev/null +++ b/src/transcription/api/v4_documents.py @@ -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) diff --git a/src/transcription/app.py b/src/transcription/app.py index d1bb172..b09f0e1 100644 --- a/src/transcription/app.py +++ b/src/transcription/app.py @@ -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 diff --git a/src/transcription/db/models.py b/src/transcription/db/models.py index 7c17623..3fe49ba 100644 --- a/src/transcription/db/models.py +++ b/src/transcription/db/models.py @@ -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)) diff --git a/src/transcription/db/operations.py b/src/transcription/db/operations.py index 0a34ce5..157a8e9 100644 --- a/src/transcription/db/operations.py +++ b/src/transcription/db/operations.py @@ -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( diff --git a/src/transcription/services/documents.py b/src/transcription/services/documents.py index b19fdc1..661b9e6 100644 --- a/src/transcription/services/documents.py +++ b/src/transcription/services/documents.py @@ -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) diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py index a105398..fae2dbe 100644 --- a/src/transcription/ui/__init__.py +++ b/src/transcription/ui/__init__.py @@ -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) diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index 14e4f99..fad8f97 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index 88b0431..b5c7af2 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -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}"), diff --git a/tests/api/test_v4_documents.py b/tests/api/test_v4_documents.py new file mode 100644 index 0000000..3e53073 --- /dev/null +++ b/tests/api/test_v4_documents.py @@ -0,0 +1,169 @@ +"""Integration tests for additive V4 document and relationship API routes.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path +from uuid import UUID + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from transcription.api.errors import register_error_handlers +from transcription.api.v4_documents import get_document_service +from transcription.api.v4_documents import router +from transcription.config import Settings +from transcription.config import SqliteSettings +from transcription.db import create_all +from transcription.db.engine import get_database_url +from transcription.db.engine import get_engine +from transcription.db.session import dispose_session_factory +from transcription.db.session import session_scope +from transcription.db.models import Document +from transcription.db.models import Person +from transcription.services.documents import DocumentService + + +def _seed_document_and_person(*, db_url: str, document_name: str = "API Doc", person_name: str = "API Person") -> tuple[UUID, UUID]: + async def _seed() -> tuple[UUID, UUID]: + async with session_scope(database_url=db_url) as session: + document = Document(name=document_name) + person = Person(full_name=person_name) + session.add(document) + session.add(person) + await session.commit() + await session.refresh(document) + await session.refresh(person) + return document.id, person.id + + return asyncio.run(_seed()) + + +@contextmanager +def _v4_api_client(tmp_path: Path, *, db_filename: str) -> Generator[tuple[TestClient, str], None, None]: + settings = Settings( + openrouter_api_key="test-key", + database=SqliteSettings(path=str(tmp_path / db_filename)), + environment="test", + bootstrap_schema_on_startup=True, + upload_dir=tmp_path / "uploads", + prompt_dir=tmp_path / "prompts", + ) + db_url = get_database_url(settings) + session_factory = None + + async def _bootstrap() -> None: + nonlocal session_factory + from transcription.db.session import get_session_factory + + session_factory = get_session_factory(database_url=db_url) + await create_all(engine=get_engine(db_url)) + + asyncio.run(_bootstrap()) + + service = DocumentService(session_factory=session_factory) + app = FastAPI() + register_error_handlers(app) + app.include_router(router) + app.dependency_overrides[get_document_service] = lambda: service + + try: + with TestClient(app) as client: + yield client, db_url + finally: + asyncio.run(dispose_session_factory(db_url)) + + +def test_list_document_types_returns_seeded_registry(tmp_path): + with _v4_api_client(tmp_path, db_filename="api-types.db") as (client, _db_url): + response = client.get("/api/v4/document-types") + + assert response.status_code == 200 + payload = response.json() + codes = {item["code"] for item in payload} + assert {"letter", "record", "memo"}.issubset(codes) + + +def test_list_person_roles_returns_seeded_registry(tmp_path): + with _v4_api_client(tmp_path, db_filename="api-roles.db") as (client, _db_url): + response = client.get("/api/v4/person-roles") + + assert response.status_code == 200 + payload = response.json() + codes = {item["code"] for item in payload} + assert {"author", "recipient", "mentioned"}.issubset(codes) + + +def test_set_document_type_by_code_updates_canonical_fields(tmp_path): + with _v4_api_client(tmp_path, db_filename="api-doc-type.db") as (client, db_url): + document_id, _ = _seed_document_and_person(db_url=db_url) + response = client.put( + f"/api/v4/documents/{document_id}/type", + json={"document_type_code": "record"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["document_id"] == str(document_id) + assert payload["document_type_id"] is not None + assert payload["document_type_code"] == "record" + + +def test_document_people_role_aware_write_read_and_delete(tmp_path): + with _v4_api_client(tmp_path, db_filename="api-links.db") as (client, db_url): + document_id, person_id = _seed_document_and_person(db_url=db_url) + + create_response = client.post( + f"/api/v4/documents/{document_id}/people", + json={"person_id": str(person_id), "role_code": "author"}, + ) + assert create_response.status_code == 200 + created = create_response.json() + assert created["document_id"] == str(document_id) + assert created["person_id"] == str(person_id) + assert created["role_code"] == "author" + assert created["role_id"] is not None + + link_id = created["id"] + update_response = client.patch( + f"/api/v4/document-people/{link_id}", + json={"role_code": "recipient"}, + ) + assert update_response.status_code == 200 + updated = update_response.json() + assert updated["role_code"] == "recipient" + + list_response = client.get(f"/api/v4/documents/{document_id}/people") + assert list_response.status_code == 200 + links = list_response.json()["links"] + assert len(links) == 1 + assert links[0]["role_code"] == "recipient" + + delete_response = client.delete(f"/api/v4/document-people/{link_id}") + assert delete_response.status_code == 204 + + list_after_delete = client.get(f"/api/v4/documents/{document_id}/people") + assert list_after_delete.status_code == 200 + assert list_after_delete.json()["links"] == [] + + +def test_duplicate_document_person_link_returns_conflict_envelope(tmp_path): + with _v4_api_client(tmp_path, db_filename="api-dup.db") as (client, db_url): + document_id, person_id = _seed_document_and_person(db_url=db_url) + + first = client.post( + f"/api/v4/documents/{document_id}/people", + json={"person_id": str(person_id), "role_code": "author"}, + ) + assert first.status_code == 200 + + second = client.post( + f"/api/v4/documents/{document_id}/people", + json={"person_id": str(person_id), "role_code": "author"}, + ) + + assert second.status_code == 409 + payload = second.json() + assert payload["category"] == "conflict_error" diff --git a/tests/services/test_document_service.py b/tests/services/test_document_service.py index cfec7a1..3e3c83e 100644 --- a/tests/services/test_document_service.py +++ b/tests/services/test_document_service.py @@ -6,12 +6,15 @@ from pathlib import Path from uuid import uuid4 import pytest +from sqlmodel import select from transcription.db.models import Document from transcription.db.models import Job from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPersonRole +from transcription.db.models import DocumentType from transcription.db.models import Person +from transcription.db.models import PersonRole from transcription.db.models import Source from transcription.services.documents import DocumentDeleteBlockedError from transcription.services.documents import DocumentError @@ -34,6 +37,9 @@ async def test_read_document_detail_allows_missing_sources(default_session_facto assert detail.id == created.id assert detail.sources == [] + assert detail.document_type_id is not None + assert detail.document_type_ref is not None + assert detail.document_type_ref.code == "letter" @pytest.mark.asyncio @@ -135,6 +141,12 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp ) ) + links_before_delete = await service.list_document_people(document_id=document.id) + assert len(links_before_delete) == 1 + assert links_before_delete[0].role_id is not None + assert links_before_delete[0].role_ref is not None + assert links_before_delete[0].role_ref.code == "author" + document_dir = service.settings.upload_dir / "documents" / str(document.id) document_dir.mkdir(parents=True, exist_ok=True) @@ -260,3 +272,52 @@ async def test_delete_person_succeeds_when_unlinked(default_session_factory): with pytest.raises(DocumentError): await service.read_person_detail(person.id) + + +@pytest.mark.asyncio +async def test_create_document_reuses_existing_document_type_registry(default_session_factory): + service = DocumentService(session_factory=default_session_factory) + + async with service._session_scope() as session: + existing = DocumentType(code="record", label="Record") + session.add(existing) + await session.commit() + await session.refresh(existing) + + created = await service.create_document(Document(id=uuid4(), name="typed-doc", document_type="record")) + + assert created.document_type_id is not None + assert created.document_type_id == existing.id + + +@pytest.mark.asyncio +async def test_update_document_person_sets_role_id_from_legacy_role(default_session_factory): + service = DocumentService(session_factory=default_session_factory) + + document = await service.create_document(Document(id=uuid4(), name="role-sync-doc", document_type="letter")) + person = await service.create_person(Person(full_name="Role Sync Person")) + link = await service.create_document_person( + DocumentPerson( + document_id=document.id, + person_id=person.id, + role=DocumentPersonRole.AUTHOR, + ) + ) + + updated = await service.update_document_person( + DocumentPerson( + id=link.id, + document_id=document.id, + person_id=person.id, + role=DocumentPersonRole.RECIPIENT, + role_id=None, + ) + ) + + assert updated.role == DocumentPersonRole.RECIPIENT + assert updated.role_id is not None + + async with service._session_scope() as session: + recipient_role = (await session.exec(select(PersonRole).where(PersonRole.code == "recipient"))).first() + assert recipient_role is not None + assert updated.role_id == recipient_role.id diff --git a/tests/services/test_v2_crud.py b/tests/services/test_v2_crud.py index ebb5508..478a964 100644 --- a/tests/services/test_v2_crud.py +++ b/tests/services/test_v2_crud.py @@ -24,6 +24,8 @@ async def test_document_service_handles_person_and_document_person_crud(default_ document = await documents.create_document(Document(id=uuid4(), name="person-doc")) person = await documents.create_person(Person(full_name="Ada Lovelace")) + assert document.document_type_id is None + link = await documents.create_document_person( DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR) ) @@ -31,11 +33,13 @@ async def test_document_service_handles_person_and_document_person_crud(default_ fetched = await documents.read_document_person(link.id) assert fetched.id == link.id assert fetched.role == DocumentPersonRole.AUTHOR + assert fetched.role_id is not None updated_link = await documents.update_document_person( DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role=DocumentPersonRole.RECIPIENT) ) assert updated_link.role == DocumentPersonRole.RECIPIENT + assert updated_link.role_id is not None listed = await documents.list_document_people(document_id=document.id) assert len(listed) == 1 diff --git a/tests/test_db.py b/tests/test_db.py index eb0079e..e56496e 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -2,6 +2,8 @@ import pytest from sqlalchemy import inspect +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession from transcription.config import Settings from transcription.config import SqliteSettings @@ -9,6 +11,8 @@ from transcription.db import create_all from transcription.db import dispose_database_runtime from transcription.db import initialize_database_runtime from transcription.db import session_scope +from transcription.db.models import DocumentType +from transcription.db.models import PersonRole @pytest.mark.asyncio @@ -26,7 +30,9 @@ async def test_create_all_creates_expected_tables(tmp_path): table_names = set(await conn.run_sync(lambda c: inspect(c).get_table_names())) assert "document" in table_names + assert "document_type" in table_names assert "person" in table_names + assert "person_role" in table_names assert "document_person" in table_names assert "job" in table_names assert "source" in table_names @@ -52,6 +58,27 @@ async def test_get_session_yields_async_session(tmp_path): await dispose_database_runtime() +@pytest.mark.asyncio +async def test_create_all_seeds_default_registry_rows(tmp_path): + settings = Settings( + openrouter_api_key="test-key", + database=SqliteSettings(path=str(tmp_path / "seed.db")), + environment="test", + ) + runtime = initialize_database_runtime(settings=settings) + + try: + await create_all(engine=runtime.engine) + async with AsyncSession(runtime.engine, expire_on_commit=False) as session: + role_codes = set((await session.exec(select(PersonRole.code))).all()) + type_codes = set((await session.exec(select(DocumentType.code))).all()) + + assert {"author", "recipient", "mentioned"}.issubset(role_codes) + assert {"letter", "record", "memo"}.issubset(type_codes) + finally: + await dispose_database_runtime() + + def test_bootstrap_policy_production_defaults_false(): settings = Settings(openrouter_api_key="test-key", environment="production") assert settings.should_bootstrap_schema is False diff --git a/tests/test_models.py b/tests/test_models.py index 6d90b5c..d8d6c96 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,6 +8,7 @@ import pytest from sqlalchemy.exc import IntegrityError from transcription.db.models import Document +from transcription.db.models import DocumentType from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPersonRole from transcription.db.models import Job @@ -15,21 +16,38 @@ from transcription.db.models import JobSource from transcription.db.models import JobSourceStatus from transcription.db.models import JobStatus from transcription.db.models import Person +from transcription.db.models import PersonRole from transcription.db.models import Source def _make_document(**overrides) -> Document: defaults = { "name": "letter bundle", - "document_type": "letter", "notes": "Family correspondence", } defaults.update(overrides) return Document(**defaults) +def _persist_document_type(session, *, code: str = "letter", label: str = "Letter") -> DocumentType: + document_type = DocumentType(code=code, label=label) + session.add(document_type) + session.commit() + session.refresh(document_type) + return document_type + + +def _persist_person_role(session, *, code: str = "author", label: str = "Author") -> PersonRole: + role = PersonRole(code=code, label=label) + session.add(role) + session.commit() + session.refresh(role) + return role + + def _persist_document(session) -> Document: - document = _make_document() + document_type = _persist_document_type(session) + document = _make_document(document_type_id=document_type.id, document_type=document_type.code) session.add(document) session.commit() session.refresh(document) @@ -100,6 +118,14 @@ class TestDocumentModel: assert document.created_at is not None assert document.updated_at is not None + def test_can_reference_document_type_registry(self, session): + document_type = _persist_document_type(session, code="record", label="Record") + document = _make_document(document_type_id=document_type.id, document_type=document_type.code) + session.add(document) + session.commit() + session.refresh(document) + assert document.document_type_id == document_type.id + class TestJobModel: def test_can_be_created_for_document(self, session): @@ -158,12 +184,23 @@ class TestPersonAndDocumentPersonModel: def test_document_person_role_is_unique_per_document_person(self, session): document = _persist_document(session) person = _persist_person(session) + person_role = _persist_person_role(session) - first = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR) + first = DocumentPerson( + document_id=document.id, + person_id=person.id, + role=DocumentPersonRole.AUTHOR, + role_id=person_role.id, + ) session.add(first) session.commit() - duplicate = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR) + duplicate = DocumentPerson( + document_id=document.id, + person_id=person.id, + role=DocumentPersonRole.AUTHOR, + role_id=person_role.id, + ) session.add(duplicate) with pytest.raises(IntegrityError): session.commit() @@ -196,8 +233,14 @@ class TestRelationships: _persist_job(session, document) _persist_source(session, document) person = _persist_person(session) + person_role = _persist_person_role(session) - link = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR) + link = DocumentPerson( + document_id=document.id, + person_id=person.id, + role=DocumentPersonRole.AUTHOR, + role_id=person_role.id, + ) session.add(link) session.commit() @@ -205,3 +248,19 @@ class TestRelationships: assert len(document.jobs) == 1 assert len(document.sources) == 1 assert len(document.document_people) == 1 + + +class TestRegistryModels: + def test_person_role_code_is_unique(self, session): + _persist_person_role(session, code="mentioned", label="Mentioned") + duplicate = PersonRole(code="mentioned", label="Mentioned Again") + session.add(duplicate) + with pytest.raises(IntegrityError): + session.commit() + + def test_document_type_code_is_unique(self, session): + _persist_document_type(session, code="journal", label="Journal") + duplicate = DocumentType(code="journal", label="Journal Duplicate") + session.add(duplicate) + with pytest.raises(IntegrityError): + session.commit() diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index a12deeb..7358aaf 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -77,7 +77,8 @@ class TestDocumentsPageRendering: assert response.status_code == 200 assert "Create Document" in response.text assert "Document name" in response.text - assert "Author (Person)" in response.text + assert "Linked People by Role" in response.text + assert "Document type" in response.text @pytest.mark.asyncio async def test_document_detail_page_renders_bento_grid_and_metadata( diff --git a/ui_test_documents.log b/ui_test_documents.log new file mode 100644 index 0000000000000000000000000000000000000000..f359b4c159b8c85865e1b7462f811f580eb0d018 GIT binary patch literal 394704 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 Kc>kg*C*=lGsXgrIBZ8c`{Ts>#-`&8D)x3S{4DE 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 ad<6A@&@04coHQK3XdS8C|I22s^8Wz>cfjHR literal 0 HcmV?d00001