generated from john/python-template
UI update complete?
This commit is contained in:
@@ -13,41 +13,41 @@ Status legend:
|
|||||||
|
|
||||||
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Read detail and metadata | RD-1, RD-2, RD-7 | Planned | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; src/transcription/ui/components/transcript.py | No dedicated Document detail page yet; Document metadata is not rendered as first-class UI. |
|
| Read detail and metadata | RD-1, RD-2, RD-7 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Dedicated Document detail route renders metadata, read-only system timestamps, and invalid/missing-id states. |
|
||||||
| Related sections and empty states | RD-3, RD-4, RD-5, RD-6 | Planned | src/transcription/ui/pages/jobs_page.py | Document-scoped related sections are defined in docs, not yet implemented in a dedicated Document view. |
|
| Related sections and empty states | RD-3, RD-4, RD-5, RD-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Document detail now links to document-scoped Jobs and Sources views, including empty-state guidance and filtered record rendering. |
|
||||||
| Update entry and validation | UP-1, UP-2, UP-3, UP-4, UP-5, UP-6 | Planned | src/transcription/services/documents.py | Service update path exists; no dedicated Document edit UI flow yet. |
|
| Update entry and validation | UP-1, UP-2, UP-3, UP-4, UP-5, UP-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated edit page includes required-field validation messaging, date parsing rules, and save path routed back to document detail. |
|
||||||
| Delete controls and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/documents.py | Service delete path exists; no UI delete control or dependency guard UX yet. |
|
| Delete controls and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, dependency-category blocking, and guarded backend delete behavior. |
|
||||||
|
|
||||||
## Person
|
## Person
|
||||||
|
|
||||||
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Create flow and validation | CR-1, CR-2, CR-3, CR-4, CR-5 | Planned | src/transcription/services/documents.py | Person create service exists; no dedicated Person page/form yet. |
|
| Create flow and validation | CR-1, CR-2, CR-3, CR-4, CR-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py | Dedicated Person create page with required full_name validation, optional field handling, and success routing to detail. |
|
||||||
| Read detail and linked documents | RD-1, RD-2, RD-3, RD-4 | Planned | src/transcription/services/documents.py; src/transcription/ui | No dedicated Person detail UI in current pages. |
|
| Read detail and linked documents | RD-1, RD-2, RD-3, RD-4 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Person detail route renders metadata, read-only timestamps, linked-document section, and invalid/missing-id states. |
|
||||||
| Update behavior | UP-1, UP-2, UP-3, UP-4, UP-5 | Planned | src/transcription/services/documents.py | Service update exists; no first-class Person edit surface yet. |
|
| Update behavior | UP-1, UP-2, UP-3, UP-4, UP-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated Person edit page supports allowed fields, required full_name validation, and save path back to detail. |
|
||||||
| Delete behavior and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/documents.py | Service delete exists; relationship-aware UI guard flow is not implemented. |
|
| Delete behavior and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, linked-document blocking message, and guarded backend delete behavior. |
|
||||||
|
|
||||||
## Source
|
## Source
|
||||||
|
|
||||||
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Create entry and required links | CR-1, CR-2, CR-4, CR-5 | Partial | src/transcription/services/store.py; src/transcription/ui/pages/upload_page.py | Upload flow creates Document, Job, Source, and JobSource together; first-release job-context-only Source create intent is documented but not exposed as dedicated Source create UI. |
|
| Create entry and required links | CR-1, CR-2, CR-4, CR-5 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/pages/upload_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Source upload/create is job-create-context only (legacy upload route redirects), with required Document and JobSource linkage enforced. |
|
||||||
| Ordering and filename policy | CR-3 | Partial | src/transcription/services/store.py | Current implementation persists generated filenames and assigns page_number in upload flow; target policy requires strict alphabetical ordering guidance and UUID.extension convention. |
|
| Ordering and filename policy | CR-3 | Implemented | src/transcription/services/store.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file/folder uploads are ordered alphabetically by original filename, helper text is visible, and stored filenames use generated unique-id plus extension. |
|
||||||
| Read and revision visibility | RD-1, RD-2, RD-3 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/job_detail.py; src/transcription/ui/components/transcript.py | Source preview and revision context are available primarily in job detail flow. |
|
| Read and revision visibility | RD-1, RD-2, RD-3 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/job_detail.py; src/transcription/ui/components/transcript.py | Source preview and revision context are available primarily in job detail flow. |
|
||||||
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/services/transcription.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/transcript.py | Revised text editing and save feedback path exists in job detail revision flow. |
|
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/services/transcription.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/transcript.py | Revised text editing and save feedback path exists in job detail revision flow. |
|
||||||
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/transcription.py | Source delete service path exists; dedicated UI delete control and guardrail messaging are not implemented. |
|
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/services/transcription.py; tests/ui/test_jobs_page.py; tests/services/test_transcription_service.py; tests/services/test_v2_crud.py | Job-detail source context now exposes delete entry with confirmation copy and dependency-aware service guardrails for multi-job links. |
|
||||||
|
|
||||||
## Job
|
## Job
|
||||||
|
|
||||||
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Create entry and required links | CR-1, CR-2, CR-5, CR-6 | Partial | src/transcription/services/store.py; src/transcription/ui/pages/upload_page.py; src/transcription/ui/pages/jobs_page.py | Current create path is upload-first and implicit; target requires explicit Create job flow from Jobs page. |
|
| Create entry and required links | CR-1, CR-2, CR-5, CR-6 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py; tests/services/test_store.py | Jobs list now has explicit Create entry and `/jobs/new` create flow with Document selection, upload validation, and submit routing to job detail. |
|
||||||
| Source ordering and upload behavior | CR-3 | Partial | src/transcription/services/store.py | Current upload path is single-file create and uses service-assigned page defaults; target requires multi-file or folder handling with alphabetical ordering guidance. |
|
| Source ordering and upload behavior | CR-3 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file and folder upload affordances are present, uploads are sorted alphabetically by original filename, and helper guidance is shown in create UI. |
|
||||||
| Provider/model/prompt visibility | CR-4, RD-4 | Partial | src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py | Values are populated in workflow updates but not rendered as first-class fields in current job detail. |
|
| Provider/model/prompt visibility | CR-4, RD-4 | Implemented | src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Provider/model/prompt fields are visible in create and detail flows when known (with pending fallback labels). |
|
||||||
| Jobs list and detail read states | RD-1, RD-2, RD-3, RD-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; tests/ui/test_jobs_page.py | Jobs list, detail route, and invalid/missing id states are present. |
|
| Jobs list and detail read states | RD-1, RD-2, RD-3, RD-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; tests/ui/test_jobs_page.py | Jobs list, detail route, and invalid/missing id states are present. |
|
||||||
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/transcript.py; src/transcription/services/transcription.py | Revision editing and save feedback exist in job detail source context. |
|
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/transcript.py; src/transcription/services/transcription.py | Revision editing and save feedback exist in job detail source context. |
|
||||||
| Lifecycle visibility and retry indicators | UP-5 | Partial | src/transcription/services/jobs.py; src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py | Status and retry_count are visible, but lifecycle controls remain system-managed and create-mode visibility targets are still pending. |
|
| Lifecycle visibility and retry indicators | UP-5 | Implemented | src/transcription/services/jobs.py; src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Job detail now surfaces lifecycle status plus retry/update metadata while lifecycle fields remain system-managed (no direct user edit controls). |
|
||||||
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/jobs.py | Backend delete exists; dedicated UI delete flow and policy messaging are not implemented. |
|
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Dedicated Job delete page provides permanent-action confirmation, processing-state blocked-delete messaging, and dependent JobSource cleanup path when deletion is allowed. |
|
||||||
|
|
||||||
## Quality Gate Coverage
|
## Quality Gate Coverage
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ Status legend:
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Separation of intent vs implementation | QG-1 across entities | Implemented | user-journey.md, schema-mapping.md, and acceptance-criteria.md are maintained per entity. |
|
| Separation of intent vs implementation | QG-1 across entities | Implemented | user-journey.md, schema-mapping.md, and acceptance-criteria.md are maintained per entity. |
|
||||||
| Traceability from criteria to implementation | QG-2 across entities | Implemented | This matrix provides criterion-to-code anchors and current status tags. |
|
| Traceability from criteria to implementation | QG-2 across entities | Implemented | This matrix provides criterion-to-code anchors and current status tags. |
|
||||||
| First-release constraints | QG-3 across entities | Partial | Constraints are documented for Document, Person, Source, and Job; implementation remains mixed across entities. |
|
| First-release constraints | QG-3 across entities | Implemented | Constraints are documented and aligned with current flows: jobs-first source upload, visible provider/model/prompt context, and system-managed lifecycle fields. |
|
||||||
|
|
||||||
## Supporting Entity Coverage
|
## Supporting Entity Coverage
|
||||||
|
|
||||||
@@ -66,10 +66,13 @@ Status legend:
|
|||||||
|
|
||||||
## Suggested Implementation Order
|
## Suggested Implementation Order
|
||||||
|
|
||||||
1. Document: add dedicated detail/read surface and metadata rendering.
|
1. Aggregate final acceptance review across Document, Person, Source, and Job criteria.
|
||||||
2. Document: add edit and delete UI with dependency guardrails.
|
|
||||||
3. Person: add create/read/update/delete pages and relationship-aware delete constraints.
|
## Aggregate Final Review Snapshot (2026-08-02)
|
||||||
4. Job: add explicit Create job flow from Jobs page with Document selection and source upload controls.
|
|
||||||
5. Job: render provider/model/prompt_name visibility in create and detail when known.
|
| Entity | Acceptance IDs still not fully met | Evidence | Notes |
|
||||||
6. Source: align create flow behavior with documented job-context-only invariant and ordering/filename policy.
|
|---|---|---|---|
|
||||||
7. Source: add delete UI with dependency checks and blocked-delete guidance.
|
| Document | None | src/transcription/ui/pages/documents_page.py; tests/ui/test_documents_page.py | Document criteria are covered by dedicated detail/edit/delete pages and document-scoped related views. |
|
||||||
|
| Person | None | src/transcription/ui/pages/people_page.py; tests/ui/test_people_page.py | Person criteria are covered by dedicated create/detail/edit/delete pages with relationship-aware delete guardrails. |
|
||||||
|
| Source | None | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/services/test_transcription_service.py | Source criteria are covered by job-context create behavior, ordering/filename policy, revision flow, and delete guardrails. |
|
||||||
|
| Job | None | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Job criteria are covered by create/read/revision/lifecycle visibility and delete guardrails in dedicated routes. |
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ from .config import Settings
|
|||||||
from .config import configure_logging
|
from .config import configure_logging
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import create_all
|
from .db import create_all
|
||||||
|
from .db import dispose_database_runtime
|
||||||
from .db import initialize_database_runtime
|
from .db import initialize_database_runtime
|
||||||
from .db.engine import get_database_url
|
|
||||||
from .db.engine import resolve_engine
|
|
||||||
from .db.session import dispose_session_factory
|
|
||||||
from .services import ServiceBundle
|
from .services import ServiceBundle
|
||||||
from .services.jobs import JobService
|
from .services.jobs import JobService
|
||||||
from .ui import register_pages
|
from .ui import register_pages
|
||||||
@@ -41,7 +39,7 @@ async def _lifespan(app: FastAPI):
|
|||||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||||
|
|
||||||
if settings.should_bootstrap_schema:
|
if settings.should_bootstrap_schema:
|
||||||
await create_all(engine=resolve_engine(settings=settings))
|
await create_all(engine=app.state.runtime.engine)
|
||||||
|
|
||||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -49,10 +47,7 @@ async def _lifespan(app: FastAPI):
|
|||||||
await _recover_stale_processing_jobs(app)
|
await _recover_stale_processing_jobs(app)
|
||||||
|
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
stack.push_async_callback(
|
stack.push_async_callback(dispose_database_runtime)
|
||||||
dispose_session_factory,
|
|
||||||
database_url=get_database_url(settings),
|
|
||||||
)
|
|
||||||
stop_event, worker_notifier = await stack.enter_async_context(
|
stop_event, worker_notifier = await stack.enter_async_context(
|
||||||
worker_consumer_lifespan(
|
worker_consumer_lifespan(
|
||||||
session_factory=app.state.runtime.session_factory,
|
session_factory=app.state.runtime.session_factory,
|
||||||
@@ -95,7 +90,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/ui", include_in_schema=False)
|
@app.get("/ui", include_in_schema=False)
|
||||||
async def ui_redirect() -> RedirectResponse:
|
async def ui_redirect() -> RedirectResponse:
|
||||||
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
return RedirectResponse(url="/ui/jobs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
|
|||||||
@@ -151,6 +151,18 @@ class Job(SQLModel, table=True):
|
|||||||
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error_detail(self) -> str | None:
|
||||||
|
"""Return the first available source-level error detail for the job."""
|
||||||
|
if not self.job_sources:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for job_source in self.job_sources:
|
||||||
|
if job_source.error_detail:
|
||||||
|
return job_source.error_detail
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Source(SQLModel, table=True):
|
class Source(SQLModel, table=True):
|
||||||
"""A document source image or PDF page."""
|
"""A document source image or PDF page."""
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -35,6 +37,14 @@ class DocumentAlreadyExistsError(DocumentError):
|
|||||||
"""Raised when a document with the same name already exists in the database."""
|
"""Raised when a document with the same name already exists in the database."""
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentDeleteBlockedError(DocumentError):
|
||||||
|
"""Raised when a document delete is blocked by dependent records."""
|
||||||
|
|
||||||
|
|
||||||
|
class PersonDeleteBlockedError(DocumentError):
|
||||||
|
"""Raised when a person delete is blocked by linked documents."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class UploadJobResult:
|
class UploadJobResult:
|
||||||
"""Summary of created upload records."""
|
"""Summary of created upload records."""
|
||||||
@@ -102,6 +112,7 @@ class DocumentService(ServiceBase):
|
|||||||
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
||||||
"""Update an existing document in the database."""
|
"""Update an existing document in the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
|
document.updated_at = datetime.now(UTC)
|
||||||
merged = await _session.merge(document)
|
merged = await _session.merge(document)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||||
return merged
|
return merged
|
||||||
@@ -109,7 +120,36 @@ class DocumentService(ServiceBase):
|
|||||||
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
||||||
"""Delete a document from the database."""
|
"""Delete a document from the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
await _session.delete(document)
|
existing = await _session.get(
|
||||||
|
Document,
|
||||||
|
document.id,
|
||||||
|
options=(
|
||||||
|
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
|
||||||
|
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
raise DocumentError(
|
||||||
|
f"Document with id {document.id} not found",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Verify the document id and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
has_jobs = bool(existing.jobs)
|
||||||
|
has_sources = bool(existing.sources)
|
||||||
|
if has_jobs or has_sources:
|
||||||
|
blocked_by: list[str] = []
|
||||||
|
if has_sources:
|
||||||
|
blocked_by.append("Sources")
|
||||||
|
if has_jobs:
|
||||||
|
blocked_by.append("Jobs")
|
||||||
|
raise DocumentDeleteBlockedError(
|
||||||
|
f"Document delete blocked by related records: {', '.join(blocked_by)}",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Remove related Sources and Jobs first, then retry deletion.",
|
||||||
|
)
|
||||||
|
|
||||||
|
await _session.delete(existing)
|
||||||
await self._finalize(session=_session, caller_session=session)
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
||||||
@@ -131,9 +171,31 @@ class DocumentService(ServiceBase):
|
|||||||
)
|
)
|
||||||
return person
|
return person
|
||||||
|
|
||||||
|
async def read_person_detail(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
|
||||||
|
"""Read a person with eagerly loaded document links for UI detail rendering."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(Person)
|
||||||
|
.options(
|
||||||
|
selectinload(Person.document_people).selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
.where(Person.id == person_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
person = (await _session.exec(query)).first()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
||||||
"""Update an existing person in the database."""
|
"""Update an existing person in the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
|
person.updated_at = datetime.now(UTC)
|
||||||
merged = await _session.merge(person)
|
merged = await _session.merge(person)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||||
return merged
|
return merged
|
||||||
@@ -141,7 +203,28 @@ class DocumentService(ServiceBase):
|
|||||||
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
|
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
|
||||||
"""Delete a person from the database."""
|
"""Delete a person from the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
await _session.delete(person)
|
existing = await _session.get(
|
||||||
|
Person,
|
||||||
|
person.id,
|
||||||
|
options=(
|
||||||
|
selectinload(Person.document_people), # pyright: ignore[reportArgumentType]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
raise DocumentError(
|
||||||
|
f"Person with id {person.id} not found",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Verify the person id and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing.document_people:
|
||||||
|
raise PersonDeleteBlockedError(
|
||||||
|
"Person delete blocked by linked documents",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Remove linked DocumentPerson records first, then retry deletion.",
|
||||||
|
)
|
||||||
|
|
||||||
|
await _session.delete(existing)
|
||||||
await self._finalize(session=_session, caller_session=session)
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
async def create_document_person(
|
async def create_document_person(
|
||||||
@@ -215,6 +298,28 @@ class DocumentService(ServiceBase):
|
|||||||
result = await _session.exec(select(Document))
|
result = await _session.exec(select(Document))
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
|
async def read_document_detail(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
|
||||||
|
"""Read a document with eagerly loaded relations for UI detail rendering."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(Document)
|
||||||
|
.options(
|
||||||
|
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
|
||||||
|
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
|
||||||
|
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
.where(Document.id == document_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
document = (await _session.exec(query)).first()
|
||||||
|
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 list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
|
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
|
||||||
"""List all people in the database."""
|
"""List all people in the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from sqlalchemy.orm import selectinload
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
from ..db.models import Job
|
from ..db.models import Job
|
||||||
from ..db.models import JobSource
|
from ..db.models import JobSource
|
||||||
from ..db.models import JobStatus
|
from ..db.models import JobStatus
|
||||||
@@ -14,6 +16,10 @@ from ..db.models import Source
|
|||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
|
|
||||||
|
|
||||||
|
class JobDeleteBlockedError(AppError):
|
||||||
|
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
||||||
|
|
||||||
|
|
||||||
class JobService(ServiceBase):
|
class JobService(ServiceBase):
|
||||||
"""Thin service class for managing jobs in the database."""
|
"""Thin service class for managing jobs in the database."""
|
||||||
|
|
||||||
@@ -183,3 +189,34 @@ class JobService(ServiceBase):
|
|||||||
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
|
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
|
||||||
return len(stale_jobs)
|
return len(stale_jobs)
|
||||||
|
|
||||||
|
async def delete_job_with_guardrails(self, *, job_id: UUID, session: AsyncSession | None = None) -> None:
|
||||||
|
"""Delete a job with lifecycle guardrails and dependent cleanup policy.
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
- Block when the job is actively processing.
|
||||||
|
- Otherwise remove related JobSource rows, then delete the job.
|
||||||
|
"""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(Job)
|
||||||
|
.options(selectinload(Job.job_sources)) # pyright: ignore[reportArgumentType]
|
||||||
|
.where(Job.id == job_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
job = (await _session.exec(query)).first()
|
||||||
|
if job is None:
|
||||||
|
raise ValueError(f"Job with id {job_id} not found")
|
||||||
|
|
||||||
|
if job.status == JobStatus.PROCESSING:
|
||||||
|
raise JobDeleteBlockedError(
|
||||||
|
"Job delete blocked while status is processing",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Wait for processing to complete, or move the job out of processing before deleting.",
|
||||||
|
)
|
||||||
|
|
||||||
|
for job_source in list(job.job_sources):
|
||||||
|
await _session.delete(job_source)
|
||||||
|
|
||||||
|
await _session.delete(job)
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
@@ -27,6 +31,15 @@ class UploadError(AppError):
|
|||||||
"""Raised when uploaded content cannot be persisted safely."""
|
"""Raised when uploaded content cannot be persisted safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class JobCreateResult:
|
||||||
|
"""Summary of explicit Job create records."""
|
||||||
|
|
||||||
|
document_id: UUID
|
||||||
|
job_id: UUID
|
||||||
|
source_ids: tuple[UUID, ...]
|
||||||
|
|
||||||
|
|
||||||
async def create_upload_job(
|
async def create_upload_job(
|
||||||
*,
|
*,
|
||||||
filename: str,
|
filename: str,
|
||||||
@@ -65,6 +78,66 @@ async def create_upload_job(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_job_for_document(
|
||||||
|
*,
|
||||||
|
document_id: UUID,
|
||||||
|
uploads: Sequence[tuple[str, bytes]],
|
||||||
|
session: AsyncSession,
|
||||||
|
provider: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
prompt_name: str | None = None,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> JobCreateResult:
|
||||||
|
"""Create a queued job for an existing document with one or more uploaded sources."""
|
||||||
|
if not uploads:
|
||||||
|
raise UploadError(
|
||||||
|
"At least one upload is required to create a job",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Upload one or more files and try again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime_settings = settings or get_settings()
|
||||||
|
sorted_uploads = sorted(uploads, key=lambda item: Path(item[0]).name.casefold())
|
||||||
|
stored_uploads: list[tuple[str, Path]] = []
|
||||||
|
for filename, file_bytes in sorted_uploads:
|
||||||
|
stored_uploads.append(
|
||||||
|
(
|
||||||
|
filename,
|
||||||
|
store_file(
|
||||||
|
filename=filename,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
settings=runtime_settings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
job, source_ids = await _create_job_for_document_records(
|
||||||
|
session=session,
|
||||||
|
document_id=document_id,
|
||||||
|
stored_uploads=stored_uploads,
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
prompt_name=prompt_name,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
for _, stored_path in stored_uploads:
|
||||||
|
_best_effort_delete(stored_path)
|
||||||
|
raise UploadError(
|
||||||
|
"Failed to create job records from uploads",
|
||||||
|
category=ErrorCategory.INFRA_TRANSIENT,
|
||||||
|
suggestion="Retry creation. If this keeps happening, verify database availability.",
|
||||||
|
retriable=True,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
logger.info("Created explicit job document_id=%s job_id=%s sources=%s", document_id, job.id, len(source_ids))
|
||||||
|
return JobCreateResult(
|
||||||
|
document_id=document_id,
|
||||||
|
job_id=job.id,
|
||||||
|
source_ids=tuple(source_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _create_upload_records(
|
async def _create_upload_records(
|
||||||
*,
|
*,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -105,6 +178,63 @@ async def _create_upload_records(
|
|||||||
return document, job
|
return document, job
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_job_for_document_records(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
document_id: UUID,
|
||||||
|
stored_uploads: Sequence[tuple[str, Path]],
|
||||||
|
provider: str | None,
|
||||||
|
model: str | None,
|
||||||
|
prompt_name: str | None,
|
||||||
|
) -> tuple[Job, list[UUID]]:
|
||||||
|
document = await session.get(Document, document_id)
|
||||||
|
if document is None:
|
||||||
|
raise UploadError(
|
||||||
|
f"Document with id {document_id} not found",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Select an existing document and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_sources = (
|
||||||
|
await session.exec(select(Source).where(Source.document_id == document_id))
|
||||||
|
).all()
|
||||||
|
next_page_number = (max((source.page_number for source in existing_sources), default=0) + 1)
|
||||||
|
|
||||||
|
job = Job(
|
||||||
|
document_id=document_id,
|
||||||
|
provider=(provider or None),
|
||||||
|
model=(model or None),
|
||||||
|
prompt_name=(prompt_name or None),
|
||||||
|
)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
source_ids: list[UUID] = []
|
||||||
|
for page_offset, (original_filename, stored_path) in enumerate(stored_uploads):
|
||||||
|
source = Source(
|
||||||
|
document_id=document_id,
|
||||||
|
page_number=next_page_number + page_offset,
|
||||||
|
upload_name=Path(original_filename).name,
|
||||||
|
filename=stored_path.name,
|
||||||
|
file_path=str(stored_path),
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
source_ids.append(source.id)
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
status=JobSourceStatus.PENDING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(job)
|
||||||
|
return job, source_ids
|
||||||
|
|
||||||
|
|
||||||
def _best_effort_delete(path: Path) -> None:
|
def _best_effort_delete(path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
if path.exists():
|
if path.exists():
|
||||||
@@ -164,4 +294,5 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
|||||||
|
|
||||||
def _build_stored_filename(filename: str) -> str:
|
def _build_stored_filename(filename: str) -> str:
|
||||||
safe_name = Path(filename).name
|
safe_name = Path(filename).name
|
||||||
return f"{uuid4()}_{safe_name}"
|
suffix = Path(safe_name).suffix.lower()
|
||||||
|
return f"{uuid4()}{suffix}"
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ class TranscriptionNotFoundError(TranscriptionError):
|
|||||||
"""Raised when a transcription-related resource is not found."""
|
"""Raised when a transcription-related resource is not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class SourceDeleteBlockedError(TranscriptionError):
|
||||||
|
"""Raised when source deletion is blocked by dependency policy."""
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionService(ServiceBase):
|
class TranscriptionService(ServiceBase):
|
||||||
"""Service class for job transcription output and page-level source revisions."""
|
"""Service class for job transcription output and page-level source revisions."""
|
||||||
|
|
||||||
@@ -164,6 +168,56 @@ class TranscriptionService(ServiceBase):
|
|||||||
await _session.delete(job_source)
|
await _session.delete(job_source)
|
||||||
await self._finalize(session=_session, caller_session=session)
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
async def delete_source_from_job_context(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
job_id: UUID,
|
||||||
|
source_id: UUID,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a source from an active job context with dependency guardrails.
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
- Allowed when exactly one JobSource link exists and it points at ``job_id``.
|
||||||
|
- Blocked when additional JobSource links exist (history/shared dependencies).
|
||||||
|
"""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
source = await _session.get(
|
||||||
|
Source,
|
||||||
|
source_id,
|
||||||
|
options=(
|
||||||
|
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if source is None:
|
||||||
|
raise TranscriptionNotFoundError(
|
||||||
|
f"Source with id {source_id} not found",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Verify the source id and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
linked_job_sources = list(source.job_sources)
|
||||||
|
matching_links = [job_source for job_source in linked_job_sources if job_source.job_id == job_id]
|
||||||
|
if not matching_links:
|
||||||
|
raise TranscriptionNotFoundError(
|
||||||
|
f"Source {source_id} is not linked to job {job_id}",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Open the source from its linked job context and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(linked_job_sources) > len(matching_links):
|
||||||
|
raise SourceDeleteBlockedError(
|
||||||
|
"Source delete blocked by related job history",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Remove additional JobSource links first, then retry deletion.",
|
||||||
|
)
|
||||||
|
|
||||||
|
for job_source in matching_links:
|
||||||
|
await _session.delete(job_source)
|
||||||
|
|
||||||
|
await _session.delete(source)
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
async def list_job_sources(
|
async def list_job_sources(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -70,7 +70,17 @@ async def process_queued_job(
|
|||||||
|
|
||||||
source_job = await services.jobs.read_job(job_id=job.id, session=session)
|
source_job = await services.jobs.read_job(job_id=job.id, session=session)
|
||||||
source = _resolve_primary_source(source_job)
|
source = _resolve_primary_source(source_job)
|
||||||
assert source is not None, f"Job {job.id} has no associated source record."
|
if source is None:
|
||||||
|
candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session)
|
||||||
|
source = next(iter(sorted(candidate_sources, key=lambda item: item.page_number)), None)
|
||||||
|
|
||||||
|
if source is None:
|
||||||
|
error = AppError(
|
||||||
|
f"Job {job.id} has no associated source record.",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Attach at least one source to the job and retry.",
|
||||||
|
)
|
||||||
|
return await _finalize_failed(job=job, services=services, error=error, session=session)
|
||||||
started_at = asyncio.get_running_loop().time()
|
started_at = asyncio.get_running_loop().time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.pages.documents_page import register_page as register_documents_page
|
||||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
||||||
|
from transcription.ui.pages.people_page import register_page as register_people_page
|
||||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
||||||
from transcription.ui.resources import read_css
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
@@ -23,5 +25,7 @@ def register_pages(app: FastAPI) -> None:
|
|||||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||||
_register_global_styles(app)
|
_register_global_styles(app)
|
||||||
register_upload_page()
|
register_upload_page()
|
||||||
|
register_documents_page()
|
||||||
|
register_people_page()
|
||||||
register_jobs_page()
|
register_jobs_page()
|
||||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from nicegui import ui
|
|||||||
from transcription.ui.resources import read_css
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||||
("Upload", "/upload", "upload_file"),
|
("Documents", "/documents", "description"),
|
||||||
|
("People", "/people", "group"),
|
||||||
("Jobs", "/jobs", "work_history"),
|
("Jobs", "/jobs", "work_history"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +16,10 @@ NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
|||||||
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
||||||
if item_path == "/jobs":
|
if item_path == "/jobs":
|
||||||
return current_path == "/jobs" or current_path.startswith("/jobs/")
|
return current_path == "/jobs" or current_path.startswith("/jobs/")
|
||||||
|
if item_path == "/documents":
|
||||||
|
return current_path == "/documents" or current_path.startswith("/documents/")
|
||||||
|
if item_path == "/people":
|
||||||
|
return current_path == "/people" or current_path.startswith("/people/")
|
||||||
return current_path == item_path
|
return current_path == item_path
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +39,7 @@ def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -
|
|||||||
def _normalize_path(current_path: str | None) -> str:
|
def _normalize_path(current_path: str | None) -> str:
|
||||||
normalized = (current_path or "").strip()
|
normalized = (current_path or "").strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return "/upload"
|
return "/jobs"
|
||||||
return normalized.rstrip("/") or "/"
|
return normalized.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
"""Documents list and detail page registration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.services.documents import DocumentDeleteBlockedError
|
||||||
|
from transcription.services.documents import DocumentError
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
|
||||||
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
|
|
||||||
|
def register_page() -> None:
|
||||||
|
"""Register documents list and detail routes."""
|
||||||
|
|
||||||
|
@ui.page("/documents")
|
||||||
|
async def documents_page(session_factory: SessionFactoryDep) -> None:
|
||||||
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
ui.label("Documents").classes("text-h5 text-weight-medium")
|
||||||
|
|
||||||
|
try:
|
||||||
|
documents = sorted(
|
||||||
|
await document_service.list_documents(),
|
||||||
|
key=lambda item: item.created_at,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.list")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not documents:
|
||||||
|
ui.label("No documents yet.").classes("text-body1 vibe-text-muted")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("w-full gap-2"):
|
||||||
|
for document in documents:
|
||||||
|
with ui.card().classes("w-full"):
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
with ui.column().classes("gap-1"):
|
||||||
|
ui.label(document.name).classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label(f"Type: {document.document_type or 'unspecified'}").classes("text-body2")
|
||||||
|
ui.button(
|
||||||
|
"Open",
|
||||||
|
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
|
||||||
|
icon="open_in_new",
|
||||||
|
).props("flat")
|
||||||
|
|
||||||
|
@ui.page("/documents/{document_id}")
|
||||||
|
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_document_id = UUID(document_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Document not found").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label(document.name).classes("text-h5 text-weight-medium")
|
||||||
|
ui.label(f"Document type: {document.document_type or 'unspecified'}").classes("text-subtitle1")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Edit document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), icon="edit").props(
|
||||||
|
'unelevated color="primary"'
|
||||||
|
)
|
||||||
|
ui.button(
|
||||||
|
"Delete document",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
|
||||||
|
icon="delete",
|
||||||
|
).props("outline color=negative")
|
||||||
|
|
||||||
|
with ui.column().classes("w-full gap-1"):
|
||||||
|
ui.label(f"Exact date: {document.document_date.isoformat() if document.document_date else 'not set'}")
|
||||||
|
ui.label(f"Approximate date: {document.document_date_raw or 'not set'}")
|
||||||
|
ui.label(f"Location created: {document.location_created or 'not set'}")
|
||||||
|
ui.label(f"Archive identifier: {document.archive_identifier or 'not set'}")
|
||||||
|
ui.label(f"Notes: {document.notes or 'not set'}")
|
||||||
|
ui.label(f"Created at (read-only): {document.created_at.isoformat()}").classes("text-body2")
|
||||||
|
ui.label(f"Updated at (read-only): {document.updated_at.isoformat()}").classes("text-body2")
|
||||||
|
|
||||||
|
ui.separator()
|
||||||
|
ui.label("Related people").classes("text-subtitle1 text-weight-medium")
|
||||||
|
if not document.document_people:
|
||||||
|
ui.label("No linked people yet.").classes("text-body2 vibe-text-muted")
|
||||||
|
else:
|
||||||
|
for link in document.document_people:
|
||||||
|
person = link.person
|
||||||
|
person_label = person.full_name if person is not None else "Unknown person"
|
||||||
|
ui.label(f"{person_label} ({link.role.value})").classes("text-body2")
|
||||||
|
|
||||||
|
ui.separator()
|
||||||
|
ui.label("Sources").classes("text-subtitle1 text-weight-medium")
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button(
|
||||||
|
"View sources",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"),
|
||||||
|
icon="description",
|
||||||
|
).props("flat")
|
||||||
|
if not document.sources:
|
||||||
|
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
|
||||||
|
ui.button(
|
||||||
|
"Add sources",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||||
|
icon="upload_file",
|
||||||
|
).props('unelevated color="primary"')
|
||||||
|
else:
|
||||||
|
for source in sorted(document.sources, key=lambda item: item.page_number):
|
||||||
|
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
|
||||||
|
|
||||||
|
ui.separator()
|
||||||
|
ui.label("Jobs").classes("text-subtitle1 text-weight-medium")
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("View jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props(
|
||||||
|
"flat"
|
||||||
|
)
|
||||||
|
if not document.jobs:
|
||||||
|
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
|
||||||
|
ui.button(
|
||||||
|
"Create job",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||||
|
icon="add",
|
||||||
|
).props('unelevated color="primary"')
|
||||||
|
else:
|
||||||
|
with ui.column().classes("w-full gap-1"):
|
||||||
|
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
|
||||||
|
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props("flat")
|
||||||
|
|
||||||
|
@ui.page("/documents/{document_id}/jobs")
|
||||||
|
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_document_id = UUID(document_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Document not found").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.jobs")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label(f"Jobs for {document.name}").classes("text-h5 text-weight-medium")
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||||
|
ui.button("Create job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").props(
|
||||||
|
'unelevated color="primary"'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not document.jobs:
|
||||||
|
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
|
||||||
|
return
|
||||||
|
|
||||||
|
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
|
||||||
|
with ui.card().classes("w-full"):
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
|
||||||
|
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props(
|
||||||
|
"flat"
|
||||||
|
)
|
||||||
|
|
||||||
|
@ui.page("/documents/{document_id}/sources")
|
||||||
|
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_document_id = UUID(document_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Document not found").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.sources")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label(f"Sources for {document.name}").classes("text-h5 text-weight-medium")
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||||
|
ui.button("Add sources", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="upload_file").props(
|
||||||
|
'unelevated color="primary"'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not document.sources:
|
||||||
|
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
|
||||||
|
return
|
||||||
|
|
||||||
|
for source in sorted(document.sources, key=lambda item: item.page_number):
|
||||||
|
with ui.card().classes("w-full"):
|
||||||
|
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
|
||||||
|
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
|
||||||
|
|
||||||
|
@ui.page("/documents/{document_id}/edit")
|
||||||
|
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_document_id = UUID(document_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Document not found").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.edit.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("Edit document").classes("text-h5 text-weight-medium")
|
||||||
|
ui.label("Document name and document type are required.").classes("text-body2 vibe-text-muted")
|
||||||
|
|
||||||
|
name_input = ui.input(label="Document name", value=document.name).props("outlined")
|
||||||
|
document_type_input = ui.input(label="Document type", value=document.document_type or "").props("outlined")
|
||||||
|
date_input = ui.input(
|
||||||
|
label="Exact date (YYYY-MM-DD)",
|
||||||
|
value=document.document_date.isoformat() if document.document_date else "",
|
||||||
|
).props("outlined")
|
||||||
|
date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw or "").props("outlined")
|
||||||
|
location_input = ui.input(label="Document location", value=document.location_created or "").props("outlined")
|
||||||
|
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined")
|
||||||
|
notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow")
|
||||||
|
|
||||||
|
async def submit_edit() -> None:
|
||||||
|
candidate_name = (name_input.value or "").strip()
|
||||||
|
candidate_type = (document_type_input.value or "").strip()
|
||||||
|
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: date | None = None
|
||||||
|
candidate_date_text = (date_input.value or "").strip()
|
||||||
|
if candidate_date_text:
|
||||||
|
try:
|
||||||
|
parsed_date = date.fromisoformat(candidate_date_text)
|
||||||
|
except ValueError:
|
||||||
|
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
candidate = Document(
|
||||||
|
id=document.id,
|
||||||
|
name=candidate_name,
|
||||||
|
document_type=candidate_type,
|
||||||
|
document_date=parsed_date,
|
||||||
|
document_date_raw=(date_raw_input.value or "").strip() or None,
|
||||||
|
location_created=(location_input.value or "").strip() or None,
|
||||||
|
notes=(notes_input.value or "").strip() or None,
|
||||||
|
archive_identifier=(archive_input.value or "").strip() or None,
|
||||||
|
created_at=document.created_at,
|
||||||
|
updated_at=document.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await document_service.update_document(candidate)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Save failed", operation="documents.edit.save")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Document updated", type="positive")
|
||||||
|
ui.navigate.to(f"/documents/{document.id}")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||||
|
|
||||||
|
@ui.page("/documents/{document_id}/delete")
|
||||||
|
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_document_id = UUID(document_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Document not found").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.delete.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("Delete document").classes("text-h5 text-weight-medium")
|
||||||
|
ui.label(f"Document: {document.name}").classes("text-subtitle1")
|
||||||
|
|
||||||
|
has_sources = bool(document.sources)
|
||||||
|
has_jobs = bool(document.jobs)
|
||||||
|
|
||||||
|
if has_sources or has_jobs:
|
||||||
|
ui.label("Delete is blocked because related records exist.").classes("text-negative text-weight-medium")
|
||||||
|
categories: list[str] = []
|
||||||
|
if has_sources:
|
||||||
|
categories.append("Sources")
|
||||||
|
if has_jobs:
|
||||||
|
categories.append("Jobs")
|
||||||
|
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-body2")
|
||||||
|
ui.label("Remove related records first, then retry deletion.").classes("text-body2 vibe-text-muted")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||||
|
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("This action permanently deletes the document.").classes("text-negative")
|
||||||
|
|
||||||
|
async def submit_delete() -> None:
|
||||||
|
try:
|
||||||
|
await document_service.delete_document(document)
|
||||||
|
except DocumentDeleteBlockedError as exc:
|
||||||
|
ui.notify(exc.message, type="warning")
|
||||||
|
ui.navigate.to(f"/documents/{document.id}/delete")
|
||||||
|
return
|
||||||
|
except DocumentError as exc:
|
||||||
|
if exc.category == ErrorCategory.NOT_FOUND:
|
||||||
|
ui.notify("Document not found.", type="warning")
|
||||||
|
ui.navigate.to("/documents")
|
||||||
|
return
|
||||||
|
show_error(exc, title="Delete failed", operation="documents.delete")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Delete failed", operation="documents.delete")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Document deleted", type="positive")
|
||||||
|
ui.navigate.to("/documents")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button(
|
||||||
|
"Delete document permanently",
|
||||||
|
on_click=submit_delete,
|
||||||
|
icon="delete_forever",
|
||||||
|
).props('unelevated color="negative"')
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
@@ -9,11 +11,17 @@ from nicegui import ui
|
|||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
|
from transcription.db.session import session_scope
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.jobs import JobDeleteBlockedError
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
|
from transcription.services.transcription import SourceDeleteBlockedError
|
||||||
|
from transcription.services.store import create_job_for_document
|
||||||
from transcription.services.transcription import TranscriptionService
|
from transcription.services.transcription import TranscriptionService
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.table.jobs import render_jobs_table
|
from transcription.ui.components.table.jobs import render_jobs_table
|
||||||
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
from ..components.document_panzoom import render_document_panzoom
|
from ..components.document_panzoom import render_document_panzoom
|
||||||
@@ -45,9 +53,134 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
]
|
]
|
||||||
render_jobs_table(jobs)
|
render_jobs_table(jobs)
|
||||||
|
|
||||||
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").props(
|
||||||
|
'unelevated color="primary"'
|
||||||
|
)
|
||||||
|
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
||||||
await render_table()
|
await render_table()
|
||||||
|
|
||||||
|
@ui.page("/jobs/new")
|
||||||
|
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
documents_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/jobs")
|
||||||
|
ui.label("Create job").classes("text-h5 text-weight-medium")
|
||||||
|
|
||||||
|
documents = await documents_service.list_documents()
|
||||||
|
if not documents:
|
||||||
|
ui.label("No documents available. Create a Document before creating a Job.").classes(
|
||||||
|
"text-body1 text-warning"
|
||||||
|
)
|
||||||
|
with ui.row():
|
||||||
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
|
||||||
|
return
|
||||||
|
|
||||||
|
uploaded_files: list[tuple[str, bytes]] = []
|
||||||
|
|
||||||
|
document_options = {str(document.id): document.name for document in documents}
|
||||||
|
document_select = ui.select(document_options, label="Document").props("outlined")
|
||||||
|
requested_document_id = request.query_params.get("document_id")
|
||||||
|
if requested_document_id in document_options:
|
||||||
|
document_select.value = requested_document_id
|
||||||
|
provider_input = ui.input(label="Provider").props("outlined")
|
||||||
|
model_input = ui.input(label="Model").props("outlined")
|
||||||
|
prompt_input = ui.input(label="Prompt").props("outlined")
|
||||||
|
|
||||||
|
ui.label("Source files").classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label(
|
||||||
|
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
|
||||||
|
).classes("text-body2 vibe-text-muted")
|
||||||
|
|
||||||
|
@ui.refreshable
|
||||||
|
def render_upload_list() -> None:
|
||||||
|
if not uploaded_files:
|
||||||
|
ui.label("No files uploaded yet.").classes("text-body2 vibe-text-muted")
|
||||||
|
return
|
||||||
|
|
||||||
|
def remove_file(index: int) -> None:
|
||||||
|
if 0 <= index < len(uploaded_files):
|
||||||
|
removed_name, _ = uploaded_files.pop(index)
|
||||||
|
ui.notify(f"Removed {removed_name}", type="info")
|
||||||
|
render_upload_list.refresh()
|
||||||
|
|
||||||
|
def clear_files() -> None:
|
||||||
|
uploaded_files.clear()
|
||||||
|
ui.notify("Cleared queued files", type="info")
|
||||||
|
render_upload_list.refresh()
|
||||||
|
|
||||||
|
ordered_uploads = sorted(
|
||||||
|
enumerate(uploaded_files),
|
||||||
|
key=lambda item: Path(item[1][0]).name.casefold(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with ui.column().classes("gap-1"):
|
||||||
|
for index, (filename, _) in ordered_uploads:
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
ui.label(Path(filename).name).classes("text-body2")
|
||||||
|
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props("flat round dense")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full justify-end"):
|
||||||
|
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat")
|
||||||
|
|
||||||
|
async def on_upload(event) -> None:
|
||||||
|
payload = await event.file.read()
|
||||||
|
uploaded_files.append((event.file.name, payload))
|
||||||
|
ui.notify(f"Added {event.file.name}", type="positive")
|
||||||
|
render_upload_list.refresh()
|
||||||
|
|
||||||
|
ui.upload(
|
||||||
|
on_upload=on_upload,
|
||||||
|
auto_upload=True,
|
||||||
|
label="Select source files",
|
||||||
|
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" multiple')
|
||||||
|
|
||||||
|
ui.upload(
|
||||||
|
on_upload=on_upload,
|
||||||
|
auto_upload=True,
|
||||||
|
label="Upload folder",
|
||||||
|
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple')
|
||||||
|
|
||||||
|
render_upload_list()
|
||||||
|
|
||||||
|
async def submit_create() -> None:
|
||||||
|
selected_document = document_select.value
|
||||||
|
if not selected_document:
|
||||||
|
ui.notify("Document is required.", type="warning")
|
||||||
|
return
|
||||||
|
if not uploaded_files:
|
||||||
|
ui.notify("At least one source file is required.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document_id = UUID(str(selected_document))
|
||||||
|
except ValueError:
|
||||||
|
ui.notify("Selected document id is invalid.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session_scope(session_factory=session_factory) as session:
|
||||||
|
result = await create_job_for_document(
|
||||||
|
document_id=document_id,
|
||||||
|
uploads=uploaded_files,
|
||||||
|
provider=(provider_input.value or None),
|
||||||
|
model=(model_input.value or None),
|
||||||
|
prompt_name=(prompt_input.value or None),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Create job failed", operation="jobs.create")
|
||||||
|
return
|
||||||
|
|
||||||
|
resolve_worker_notifier(request.app.state).notify()
|
||||||
|
ui.notify(f"Created job {result.job_id}", type="positive")
|
||||||
|
ui.navigate.to(f"/jobs/{result.job_id}")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").props(
|
||||||
|
'unelevated color="primary"'
|
||||||
|
)
|
||||||
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
|
||||||
|
|
||||||
@ui.page("/jobs/{job_id}")
|
@ui.page("/jobs/{job_id}")
|
||||||
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
|
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
@@ -85,6 +218,20 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
case _:
|
case _:
|
||||||
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
|
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button(
|
||||||
|
"Delete job",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/jobs/{parsed_job_id}/delete"),
|
||||||
|
icon="delete",
|
||||||
|
).props("outline color=negative")
|
||||||
|
|
||||||
|
with ui.column().classes("gap-1"):
|
||||||
|
ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2")
|
||||||
|
ui.label(f"Model: {job.model or 'pending'}").classes("text-body2")
|
||||||
|
ui.label(f"Prompt: {job.prompt_name or 'pending'}").classes("text-body2")
|
||||||
|
ui.label(f"Retry count: {job.retry_count}").classes("text-body2")
|
||||||
|
ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2")
|
||||||
|
|
||||||
render_original_transcription_card(job=job)
|
render_original_transcription_card(job=job)
|
||||||
|
|
||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
@@ -102,6 +249,26 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
|
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
|
||||||
editor.classes("w-full")
|
editor.classes("w-full")
|
||||||
|
|
||||||
|
async def delete_source() -> None:
|
||||||
|
try:
|
||||||
|
await transcription_service.delete_source_from_job_context(
|
||||||
|
job_id=parsed_job_id,
|
||||||
|
source_id=refreshed_source.id,
|
||||||
|
)
|
||||||
|
except SourceDeleteBlockedError as exc:
|
||||||
|
ui.notify(exc.message, type="warning")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Delete source failed", operation="jobs.delete_source")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Source deleted", type="positive")
|
||||||
|
ui.navigate.to(f"/jobs/{parsed_job_id}")
|
||||||
|
|
||||||
|
async def confirm_delete_source() -> None:
|
||||||
|
delete_dialog.close()
|
||||||
|
await delete_source()
|
||||||
|
|
||||||
async def save_revision() -> None:
|
async def save_revision() -> None:
|
||||||
candidate = (editor.value or "").strip()
|
candidate = (editor.value or "").strip()
|
||||||
if not candidate:
|
if not candidate:
|
||||||
@@ -120,7 +287,22 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.notify("Revision saved", type="positive")
|
ui.notify("Revision saved", type="positive")
|
||||||
await render_revision_panel.refresh()
|
await render_revision_panel.refresh()
|
||||||
|
|
||||||
with ui.row().classes("w-full justify-end"):
|
with ui.row().classes("w-full justify-end gap-2"):
|
||||||
|
with ui.dialog() as delete_dialog, ui.card().classes("min-w-[22rem]"):
|
||||||
|
ui.label("Delete source").classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label("This permanently deletes the source from this job context.").classes("text-body2")
|
||||||
|
ui.label("If related job history exists, deletion may be blocked.").classes(
|
||||||
|
"text-body2 vibe-text-muted"
|
||||||
|
)
|
||||||
|
with ui.row().classes("w-full justify-end gap-2"):
|
||||||
|
ui.button("Cancel", on_click=delete_dialog.close)
|
||||||
|
ui.button(
|
||||||
|
"Delete source",
|
||||||
|
on_click=confirm_delete_source,
|
||||||
|
icon="delete_forever",
|
||||||
|
).props('unelevated color="negative"')
|
||||||
|
|
||||||
|
ui.button("Delete source", on_click=delete_dialog.open, icon="delete").props("outline color=negative")
|
||||||
ui.button(
|
ui.button(
|
||||||
"Create revision" if current_revision_text is None else "Update revision",
|
"Create revision" if current_revision_text is None else "Update revision",
|
||||||
on_click=save_revision,
|
on_click=save_revision,
|
||||||
@@ -138,6 +320,61 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
await render_revision_panel()
|
await render_revision_panel()
|
||||||
|
|
||||||
|
@ui.page("/jobs/{job_id}/delete")
|
||||||
|
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_job_id = UUID(job_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid job id").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Job not found").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("Delete job").classes("text-h5 text-weight-medium")
|
||||||
|
ui.label(f"Job: {job.id}").classes("text-subtitle1")
|
||||||
|
|
||||||
|
if job.status == JobStatus.PROCESSING:
|
||||||
|
ui.label("Delete is blocked while the job is processing.").classes("text-negative text-weight-medium")
|
||||||
|
ui.label("Wait for processing to complete, then retry delete.").classes("text-body2 vibe-text-muted")
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
|
||||||
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("This action permanently deletes the job.").classes("text-negative")
|
||||||
|
if job.job_sources:
|
||||||
|
ui.label("Related JobSource links will be removed as part of delete.").classes("text-body2")
|
||||||
|
|
||||||
|
async def submit_delete() -> None:
|
||||||
|
try:
|
||||||
|
await jobs_service.delete_job_with_guardrails(job_id=job.id)
|
||||||
|
except JobDeleteBlockedError as exc:
|
||||||
|
ui.notify(exc.message, type="warning")
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
ui.notify("Job not found.", type="warning")
|
||||||
|
ui.navigate.to("/jobs")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Delete job failed", operation="jobs.delete")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Job deleted", type="positive")
|
||||||
|
ui.navigate.to("/jobs")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Delete job permanently", on_click=submit_delete, icon="delete_forever").props(
|
||||||
|
'unelevated color="negative"'
|
||||||
|
)
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_primary_source(job: Job) -> Source | None:
|
def _resolve_primary_source(job: Job) -> Source | None:
|
||||||
if not job.job_sources:
|
if not job.job_sources:
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
"""People list and detail page registration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.db.models import Person
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.services.documents import DocumentError
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.documents import PersonDeleteBlockedError
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
|
||||||
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_date(value: str | None, *, label: str) -> date | None:
|
||||||
|
candidate = (value or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(candidate)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def register_page() -> None: # noqa: PLR0915
|
||||||
|
"""Register people list and CRUD routes."""
|
||||||
|
|
||||||
|
@ui.page("/people")
|
||||||
|
async def people_page(session_factory: SessionFactoryDep) -> None:
|
||||||
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
ui.label("People").classes("text-h5 text-weight-medium")
|
||||||
|
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props(
|
||||||
|
'unelevated color="primary"'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
people = sorted(
|
||||||
|
await people_service.list_people(),
|
||||||
|
key=lambda item: item.created_at,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="people.list")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not people:
|
||||||
|
ui.label("No people yet.").classes("text-body1 vibe-text-muted")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("w-full gap-2"):
|
||||||
|
for person in people:
|
||||||
|
with ui.card().classes("w-full"):
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
with ui.column().classes("gap-1"):
|
||||||
|
ui.label(person.full_name).classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label(f"Display name: {person.display_name or 'not set'}").classes("text-body2")
|
||||||
|
ui.button(
|
||||||
|
"Open",
|
||||||
|
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
|
||||||
|
icon="open_in_new",
|
||||||
|
).props("flat")
|
||||||
|
|
||||||
|
@ui.page("/people/new")
|
||||||
|
async def person_create_page(session_factory: SessionFactoryDep) -> None:
|
||||||
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
|
ui.label("Create person").classes("text-h5 text-weight-medium")
|
||||||
|
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
|
||||||
|
|
||||||
|
full_name_input = ui.input(label="Full name").props("outlined")
|
||||||
|
display_name_input = ui.input(label="Display name").props("outlined")
|
||||||
|
maiden_name_input = ui.input(label="Maiden name").props("outlined")
|
||||||
|
|
||||||
|
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props("outlined")
|
||||||
|
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)").props("outlined")
|
||||||
|
birth_place_input = ui.input(label="Birth place").props("outlined")
|
||||||
|
|
||||||
|
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props("outlined")
|
||||||
|
death_date_raw_input = ui.input(label="Death date (approximate/raw)").props("outlined")
|
||||||
|
death_place_input = ui.input(label="Death place").props("outlined")
|
||||||
|
|
||||||
|
biography_input = ui.textarea(label="Biography").props("outlined autogrow")
|
||||||
|
portrait_path_input = ui.input(label="Portrait path").props("outlined")
|
||||||
|
|
||||||
|
async def submit_create() -> None:
|
||||||
|
full_name = (full_name_input.value or "").strip()
|
||||||
|
if not full_name:
|
||||||
|
ui.notify("Full name is required.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
|
||||||
|
death_date = _parse_optional_date(death_date_input.value, label="Death date")
|
||||||
|
except ValueError as exc:
|
||||||
|
ui.notify(str(exc), type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
candidate = Person(
|
||||||
|
full_name=full_name,
|
||||||
|
display_name=(display_name_input.value or "").strip() or None,
|
||||||
|
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||||
|
birth_date=birth_date,
|
||||||
|
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
|
||||||
|
birth_place=(birth_place_input.value or "").strip() or None,
|
||||||
|
death_date=death_date,
|
||||||
|
death_date_raw=(death_date_raw_input.value or "").strip() or None,
|
||||||
|
death_place=(death_place_input.value or "").strip() or None,
|
||||||
|
biography=(biography_input.value or "").strip() or None,
|
||||||
|
portrait_path=(portrait_path_input.value or "").strip() or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
created = await people_service.create_person(candidate)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Create failed", operation="people.create")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Person created", type="positive")
|
||||||
|
ui.navigate.to(f"/people/{created.id}")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Save person", on_click=submit_create, icon="save").props('unelevated color="primary"')
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back")
|
||||||
|
|
||||||
|
@ui.page("/people/{person_id}")
|
||||||
|
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_person_id = UUID(person_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid person id").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
person = await people_service.read_person_detail(parsed_person_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Person not found").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="people.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label(person.full_name).classes("text-h5 text-weight-medium")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Edit person", on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), icon="edit").props(
|
||||||
|
'unelevated color="primary"'
|
||||||
|
)
|
||||||
|
ui.button(
|
||||||
|
"Delete person",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
|
||||||
|
icon="delete",
|
||||||
|
).props("outline color=negative")
|
||||||
|
|
||||||
|
with ui.column().classes("w-full gap-1"):
|
||||||
|
ui.label(f"Display name: {person.display_name or 'not set'}")
|
||||||
|
ui.label(f"Maiden name: {person.maiden_name or 'not set'}")
|
||||||
|
ui.label(f"Birth date: {person.birth_date.isoformat() if person.birth_date else 'not set'}")
|
||||||
|
ui.label(f"Birth date (approximate): {person.birth_date_raw or 'not set'}")
|
||||||
|
ui.label(f"Birth place: {person.birth_place or 'not set'}")
|
||||||
|
ui.label(f"Death date: {person.death_date.isoformat() if person.death_date else 'not set'}")
|
||||||
|
ui.label(f"Death date (approximate): {person.death_date_raw or 'not set'}")
|
||||||
|
ui.label(f"Death place: {person.death_place or 'not set'}")
|
||||||
|
ui.label(f"Biography: {person.biography or 'not set'}")
|
||||||
|
ui.label(f"Portrait path: {person.portrait_path or 'not set'}")
|
||||||
|
ui.label(f"Created at (read-only): {person.created_at.isoformat()}").classes("text-body2")
|
||||||
|
ui.label(f"Updated at (read-only): {person.updated_at.isoformat()}").classes("text-body2")
|
||||||
|
|
||||||
|
ui.separator()
|
||||||
|
ui.label("Linked documents").classes("text-subtitle1 text-weight-medium")
|
||||||
|
|
||||||
|
if not person.document_people:
|
||||||
|
ui.label("No linked documents yet.").classes("text-body2 vibe-text-muted")
|
||||||
|
ui.label("Link this person from a Document workflow.").classes("text-body2 vibe-text-muted")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("w-full gap-1"):
|
||||||
|
for link in person.document_people:
|
||||||
|
document = link.document
|
||||||
|
if document is None:
|
||||||
|
continue
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
ui.label(f"{document.name} ({link.role.value})").classes("text-body2")
|
||||||
|
ui.button(
|
||||||
|
"Open",
|
||||||
|
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
|
||||||
|
icon="open_in_new",
|
||||||
|
).props("flat")
|
||||||
|
|
||||||
|
@ui.page("/people/{person_id}/edit")
|
||||||
|
async def person_edit_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_person_id = UUID(person_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid person id").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
person = await people_service.read_person_detail(parsed_person_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Person not found").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="people.edit.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("Edit person").classes("text-h5 text-weight-medium")
|
||||||
|
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
|
||||||
|
|
||||||
|
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined")
|
||||||
|
display_name_input = ui.input(label="Display name", value=person.display_name or "").props("outlined")
|
||||||
|
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined")
|
||||||
|
|
||||||
|
birth_date_input = ui.input(
|
||||||
|
label="Birth date (YYYY-MM-DD)",
|
||||||
|
value=person.birth_date.isoformat() if person.birth_date else "",
|
||||||
|
).props("outlined")
|
||||||
|
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)", value=person.birth_date_raw or "").props(
|
||||||
|
"outlined"
|
||||||
|
)
|
||||||
|
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined")
|
||||||
|
|
||||||
|
death_date_input = ui.input(
|
||||||
|
label="Death date (YYYY-MM-DD)",
|
||||||
|
value=person.death_date.isoformat() if person.death_date else "",
|
||||||
|
).props("outlined")
|
||||||
|
death_date_raw_input = ui.input(label="Death date (approximate/raw)", value=person.death_date_raw or "").props(
|
||||||
|
"outlined"
|
||||||
|
)
|
||||||
|
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined")
|
||||||
|
|
||||||
|
biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow")
|
||||||
|
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined")
|
||||||
|
|
||||||
|
async def submit_edit() -> None:
|
||||||
|
full_name = (full_name_input.value or "").strip()
|
||||||
|
if not full_name:
|
||||||
|
ui.notify("Full name is required.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
|
||||||
|
death_date = _parse_optional_date(death_date_input.value, label="Death date")
|
||||||
|
except ValueError as exc:
|
||||||
|
ui.notify(str(exc), type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
candidate = Person(
|
||||||
|
id=person.id,
|
||||||
|
full_name=full_name,
|
||||||
|
display_name=(display_name_input.value or "").strip() or None,
|
||||||
|
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||||
|
birth_date=birth_date,
|
||||||
|
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
|
||||||
|
birth_place=(birth_place_input.value or "").strip() or None,
|
||||||
|
death_date=death_date,
|
||||||
|
death_date_raw=(death_date_raw_input.value or "").strip() or None,
|
||||||
|
death_place=(death_place_input.value or "").strip() or None,
|
||||||
|
biography=(biography_input.value or "").strip() or None,
|
||||||
|
portrait_path=(portrait_path_input.value or "").strip() or None,
|
||||||
|
metadata_=person.metadata_,
|
||||||
|
created_at=person.created_at,
|
||||||
|
updated_at=person.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await people_service.update_person(candidate)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Save failed", operation="people.edit.save")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Person updated", type="positive")
|
||||||
|
ui.navigate.to(f"/people/{person.id}")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
|
||||||
|
|
||||||
|
@ui.page("/people/{person_id}/delete")
|
||||||
|
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_person_id = UUID(person_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid person id").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
person = await people_service.read_person_detail(parsed_person_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Person not found").classes("text-h6 text-negative")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="people.delete.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("Delete person").classes("text-h5 text-weight-medium")
|
||||||
|
ui.label(f"Person: {person.full_name}").classes("text-subtitle1")
|
||||||
|
|
||||||
|
if person.document_people:
|
||||||
|
ui.label("Delete is blocked because linked documents exist.").classes("text-negative text-weight-medium")
|
||||||
|
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-body2")
|
||||||
|
ui.label("Remove document links first, then retry deletion.").classes("text-body2 vibe-text-muted")
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button("Back to Person", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
|
||||||
|
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("This action permanently deletes the person.").classes("text-negative")
|
||||||
|
|
||||||
|
async def submit_delete() -> None:
|
||||||
|
try:
|
||||||
|
await people_service.delete_person(person)
|
||||||
|
except PersonDeleteBlockedError as exc:
|
||||||
|
ui.notify(exc.message, type="warning")
|
||||||
|
ui.navigate.to(f"/people/{person.id}/delete")
|
||||||
|
return
|
||||||
|
except DocumentError as exc:
|
||||||
|
if exc.category == ErrorCategory.NOT_FOUND:
|
||||||
|
ui.notify("Person not found.", type="warning")
|
||||||
|
ui.navigate.to("/people")
|
||||||
|
return
|
||||||
|
show_error(exc, title="Delete failed", operation="people.delete")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Delete failed", operation="people.delete")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Person deleted", type="positive")
|
||||||
|
ui.navigate.to("/people")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button(
|
||||||
|
"Delete person permanently",
|
||||||
|
on_click=submit_delete,
|
||||||
|
icon="delete_forever",
|
||||||
|
).props('unelevated color="negative"')
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
|
||||||
@@ -3,29 +3,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from starlette import status
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.db import session_scope
|
|
||||||
from transcription.services.store import create_upload_job
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.upload import render_upload_widget
|
|
||||||
from transcription.worker import resolve_worker_notifier
|
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
def register_page() -> None:
|
||||||
"""Register the upload page route."""
|
"""Register the upload page route."""
|
||||||
|
|
||||||
@ui.page("/upload", title="Upload Document")
|
@ui.page("/upload", title="Upload Document")
|
||||||
def upload_page(request: Request) -> None:
|
def upload_page(request: Request) -> RedirectResponse:
|
||||||
|
_ = request
|
||||||
render_navigation_header(current_path="/upload")
|
render_navigation_header(current_path="/upload")
|
||||||
|
return RedirectResponse(url="/ui/jobs/new", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||||
async def submit_upload(filename: str, file_bytes: bytes):
|
|
||||||
async with session_scope() as session:
|
|
||||||
return await create_upload_job(
|
|
||||||
filename=filename,
|
|
||||||
file_bytes=file_bytes,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
notify_worker = resolve_worker_notifier(request.app.state)
|
|
||||||
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
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 Person
|
||||||
|
from transcription.db.models import Source
|
||||||
|
from transcription.services.documents import DocumentDeleteBlockedError
|
||||||
|
from transcription.services.documents import DocumentError
|
||||||
|
from transcription.services.documents import PersonDeleteBlockedError
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_document_detail_allows_missing_sources(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
created = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="detail-doc",
|
||||||
|
document_type="letter",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = await service.read_document_detail(created.id)
|
||||||
|
|
||||||
|
assert detail.id == created.id
|
||||||
|
assert detail.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_document_refreshes_updated_timestamp(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
created = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="timestamp-doc",
|
||||||
|
document_type="letter",
|
||||||
|
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
original_updated_at = created.updated_at
|
||||||
|
created.notes = "updated"
|
||||||
|
|
||||||
|
updated = await service.update_document(created)
|
||||||
|
|
||||||
|
assert updated.notes == "updated"
|
||||||
|
assert updated.updated_at >= original_updated_at
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_document_blocks_when_dependencies_exist(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="blocked-delete",
|
||||||
|
document_type="record",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async with service._session_scope() as session:
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="001_page.png",
|
||||||
|
filename="001_page.png",
|
||||||
|
file_path="uploads/001_page.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(Job(document_id=document.id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(DocumentDeleteBlockedError):
|
||||||
|
await service.delete_document(document)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_document_succeeds_when_unlinked(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="free-delete",
|
||||||
|
document_type="memo",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.delete_document(document)
|
||||||
|
|
||||||
|
with pytest.raises(DocumentError):
|
||||||
|
await service.read_document_detail(document.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_person_detail_loads_document_links(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="linked-doc",
|
||||||
|
document_type="letter",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
person = await service.create_person(Person(full_name="Linked Person"))
|
||||||
|
await service.create_document_person(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = await service.read_person_detail(person.id)
|
||||||
|
|
||||||
|
assert detail.id == person.id
|
||||||
|
assert len(detail.document_people) == 1
|
||||||
|
assert detail.document_people[0].document is not None
|
||||||
|
assert detail.document_people[0].document.name == "linked-doc"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_person_refreshes_updated_timestamp(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
created = await service.create_person(
|
||||||
|
Person(
|
||||||
|
full_name="timestamp-person",
|
||||||
|
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
original_updated_at = created.updated_at
|
||||||
|
created.display_name = "updated"
|
||||||
|
|
||||||
|
updated = await service.update_person(created)
|
||||||
|
|
||||||
|
assert updated.display_name == "updated"
|
||||||
|
assert updated.updated_at >= original_updated_at
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_person_blocks_when_linked_documents_exist(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="block-person-delete-doc",
|
||||||
|
document_type="record",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
person = await service.create_person(Person(full_name="Blocked Person"))
|
||||||
|
await service.create_document_person(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PersonDeleteBlockedError):
|
||||||
|
await service.delete_person(person)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_person_succeeds_when_unlinked(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
person = await service.create_person(Person(full_name="Free Person"))
|
||||||
|
|
||||||
|
await service.delete_person(person)
|
||||||
|
|
||||||
|
with pytest.raises(DocumentError):
|
||||||
|
await service.read_person_detail(person.id)
|
||||||
@@ -9,6 +9,7 @@ from transcription.db.models import JobSourceStatus
|
|||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.jobs import JobDeleteBlockedError
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
|
|
||||||
|
|
||||||
@@ -107,3 +108,111 @@ class TestJobService:
|
|||||||
next_job = await job_service.read_next_queued_job()
|
next_job = await job_service.read_next_queued_job()
|
||||||
assert next_job is not None
|
assert next_job is not None
|
||||||
assert next_job.id == first.id
|
assert next_job.id == first.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_job_persists_provider_model_prompt(
|
||||||
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
document = Document(id=uuid4(), name="provider-doc")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(
|
||||||
|
document_id=document.id,
|
||||||
|
provider="openrouter",
|
||||||
|
model="google/gemini-2.5-flash",
|
||||||
|
prompt_name="transcribe_document.md",
|
||||||
|
)
|
||||||
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
|
fetched = await job_service.read_job(job_id=job.id)
|
||||||
|
assert fetched.provider == "openrouter"
|
||||||
|
assert fetched.model == "google/gemini-2.5-flash"
|
||||||
|
assert fetched.prompt_name == "transcribe_document.md"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_job_resolves_filename_from_linked_source(
|
||||||
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
document = Document(id=uuid4(), name="filename-doc")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id)
|
||||||
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
|
async with job_service._session_scope() as session:
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="page_001.png",
|
||||||
|
filename="stored_page_001.png",
|
||||||
|
file_path="/uploads/stored_page_001.png",
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
status=JobSourceStatus.PENDING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
fetched = await job_service.read_job(job_id=job.id)
|
||||||
|
assert fetched.filename == "stored_page_001.png"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_job_with_guardrails_blocks_processing_jobs(
|
||||||
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
document = Document(id=uuid4(), name="processing-delete-doc")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.PROCESSING)
|
||||||
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
|
with pytest.raises(JobDeleteBlockedError):
|
||||||
|
await job_service.delete_job_with_guardrails(job_id=job.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_job_with_guardrails_removes_jobsource_links(
|
||||||
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
document = Document(id=uuid4(), name="delete-job-doc")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
|
async with job_service._session_scope() as session:
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="delete-job-source.jpg",
|
||||||
|
filename="stored-delete-job-source.jpg",
|
||||||
|
file_path="/uploads/stored-delete-job-source.jpg",
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
status=JobSourceStatus.PENDING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await job_service.delete_job_with_guardrails(job_id=job.id)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await job_service.read_job(job_id=job.id)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobSource
|
||||||
|
from transcription.db.models import Source
|
||||||
|
from transcription.services.store import UploadError
|
||||||
|
from transcription.services.store import create_job_for_document
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_job_for_document_requires_at_least_one_upload(async_session, tmp_path):
|
||||||
|
document = Document(id=uuid4(), name="needs-upload")
|
||||||
|
async_session.add(document)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(UploadError):
|
||||||
|
await create_job_for_document(
|
||||||
|
document_id=document.id,
|
||||||
|
uploads=[],
|
||||||
|
session=async_session,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_job_for_document_sorts_uploads_and_creates_links(async_session, tmp_path):
|
||||||
|
document = Document(id=uuid4(), name="ordered-upload-doc")
|
||||||
|
async_session.add(document)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
||||||
|
|
||||||
|
result = await create_job_for_document(
|
||||||
|
document_id=document.id,
|
||||||
|
uploads=[
|
||||||
|
("folder/b_page.pdf", b"b"),
|
||||||
|
("folder/A_page.pdf", b"a"),
|
||||||
|
],
|
||||||
|
provider="openrouter",
|
||||||
|
model="test-model",
|
||||||
|
prompt_name="transcribe_document.md",
|
||||||
|
session=async_session,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
created_job = await async_session.get(Job, result.job_id)
|
||||||
|
assert created_job is not None
|
||||||
|
assert created_job.provider == "openrouter"
|
||||||
|
assert created_job.model == "test-model"
|
||||||
|
assert created_job.prompt_name == "transcribe_document.md"
|
||||||
|
|
||||||
|
sources = (
|
||||||
|
await async_session.exec(
|
||||||
|
select(Source)
|
||||||
|
.where(Source.document_id == document.id)
|
||||||
|
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
assert [source.upload_name for source in sources] == ["A_page.pdf", "b_page.pdf"]
|
||||||
|
assert all(source.filename.endswith(".pdf") for source in sources)
|
||||||
|
assert all("A_page" not in source.filename and "b_page" not in source.filename for source in sources)
|
||||||
|
|
||||||
|
job_sources = (await async_session.exec(select(JobSource).where(JobSource.job_id == result.job_id))).all()
|
||||||
|
assert len(job_sources) == 2
|
||||||
|
assert set(result.source_ids) == {job_source.source_id for job_source in job_sources}
|
||||||
@@ -12,6 +12,8 @@ from transcription.db.models import JobStatus
|
|||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
|
from transcription.services.transcription import SourceDeleteBlockedError
|
||||||
|
from transcription.services.transcription import TranscriptionNotFoundError
|
||||||
from transcription.services.transcription import TranscriptionService
|
from transcription.services.transcription import TranscriptionService
|
||||||
|
|
||||||
|
|
||||||
@@ -89,3 +91,66 @@ class TestTranscriptionServiceRevisionUpsert:
|
|||||||
assert len(revisions) == 1
|
assert len(revisions) == 1
|
||||||
assert revisions[0].id == first.id
|
assert revisions[0].id == first.id
|
||||||
assert revisions[0].revised_text == "Revision v2"
|
assert revisions[0].revised_text == "Revision v2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_source_from_job_context_removes_source_and_single_link(self, default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = Document(id=uuid4(), name="delete-source-success")
|
||||||
|
await documents.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
await jobs.create_job(job=job)
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="delete.jpg",
|
||||||
|
filename="delete.jpg",
|
||||||
|
file_path="uploads/delete.jpg",
|
||||||
|
)
|
||||||
|
async with transcriptions._session_scope() as session:
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING))
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(source)
|
||||||
|
|
||||||
|
await transcriptions.delete_source_from_job_context(job_id=job.id, source_id=source.id)
|
||||||
|
|
||||||
|
with pytest.raises(TranscriptionNotFoundError):
|
||||||
|
await transcriptions.read_source(source.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_source_from_job_context_blocks_when_other_job_links_exist(self, default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = Document(id=uuid4(), name="delete-source-blocked")
|
||||||
|
await documents.create_document(document=document)
|
||||||
|
|
||||||
|
job_one = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
job_two = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
await jobs.create_job(job=job_one)
|
||||||
|
await jobs.create_job(job=job_two)
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="shared.jpg",
|
||||||
|
filename="shared.jpg",
|
||||||
|
file_path="uploads/shared.jpg",
|
||||||
|
)
|
||||||
|
async with transcriptions._session_scope() as session:
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
session.add(JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING))
|
||||||
|
session.add(JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING))
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(source)
|
||||||
|
|
||||||
|
with pytest.raises(SourceDeleteBlockedError):
|
||||||
|
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ from transcription.db.models import JobSource
|
|||||||
from transcription.db.models import JobSourceStatus
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import Person
|
from transcription.db.models import Person
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
|
from transcription.services.documents import DocumentDeleteBlockedError
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
|
from transcription.services.transcription import SourceDeleteBlockedError
|
||||||
from transcription.services.transcription import TranscriptionService
|
from transcription.services.transcription import TranscriptionService
|
||||||
|
|
||||||
|
|
||||||
@@ -123,3 +125,89 @@ async def test_transcription_service_job_source_crud_uses_caller_session(default
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
assert len(await transcriptions.list_job_sources(job_id=job.id)) == 0
|
assert len(await transcriptions.list_job_sources(job_id=job.id)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_document_detail_loads_linked_person_relationship(default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc"))
|
||||||
|
person = await documents.create_person(Person(full_name="Grace Hopper"))
|
||||||
|
await documents.create_document_person(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = await documents.read_document_detail(document.id)
|
||||||
|
|
||||||
|
assert len(detail.document_people) == 1
|
||||||
|
link = detail.document_people[0]
|
||||||
|
assert link.person is not None
|
||||||
|
assert link.person.full_name == "Grace Hopper"
|
||||||
|
assert link.role == DocumentPersonRole.AUTHOR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_document_delete_is_blocked_with_source_and_job_dependencies(default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await documents.create_document(Document(id=uuid4(), name="blocked-by-deps"))
|
||||||
|
job = await jobs.create_job(Job(document_id=document.id))
|
||||||
|
source = await transcriptions.create_source(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="blocked.jpg",
|
||||||
|
filename="blocked.jpg",
|
||||||
|
file_path="uploads/blocked.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await transcriptions.create_job_source(
|
||||||
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
status=JobSourceStatus.PENDING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(DocumentDeleteBlockedError) as exc_info:
|
||||||
|
await documents.delete_document(document)
|
||||||
|
|
||||||
|
message = exc_info.value.message
|
||||||
|
assert "Sources" in message
|
||||||
|
assert "Jobs" in message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_delete_blocks_when_linked_to_multiple_jobs(default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await documents.create_document(Document(id=uuid4(), name="multi-job-source-doc"))
|
||||||
|
job_one = await jobs.create_job(Job(document_id=document.id))
|
||||||
|
job_two = await jobs.create_job(Job(document_id=document.id))
|
||||||
|
|
||||||
|
source = await transcriptions.create_source(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="shared-page.jpg",
|
||||||
|
filename="shared-page.jpg",
|
||||||
|
file_path="uploads/shared-page.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await transcriptions.create_job_source(
|
||||||
|
JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING)
|
||||||
|
)
|
||||||
|
await transcriptions.create_job_source(
|
||||||
|
JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SourceDeleteBlockedError):
|
||||||
|
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
|
||||||
|
|||||||
@@ -71,13 +71,18 @@ class TestProviderSettings:
|
|||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
_make_settings(provider="not-a-provider")
|
_make_settings(provider="not-a-provider")
|
||||||
|
|
||||||
def test_optional_fields_default_to_none(self):
|
def test_optional_provider_header_fields_default_to_none(self):
|
||||||
"""provider_model, openrouter_http_referer, and openrouter_app_title are None when unset."""
|
"""openrouter_http_referer and openrouter_app_title are None when unset."""
|
||||||
settings = _make_settings()
|
settings = _make_settings()
|
||||||
assert settings.provider_model is None
|
|
||||||
assert settings.openrouter_http_referer is None
|
assert settings.openrouter_http_referer is None
|
||||||
assert settings.openrouter_app_title is None
|
assert settings.openrouter_app_title is None
|
||||||
|
|
||||||
|
def test_provider_model_accepts_env_default(self, monkeypatch):
|
||||||
|
"""provider_model is sourced when provided through environment configuration."""
|
||||||
|
monkeypatch.setenv("PROVIDER_MODEL", "google/gemini-2.5-flash")
|
||||||
|
settings = Settings(openrouter_api_key="test-key-abc123")
|
||||||
|
assert settings.provider_model == "google/gemini-2.5-flash"
|
||||||
|
|
||||||
|
|
||||||
class TestPathSettings:
|
class TestPathSettings:
|
||||||
"""Verify filesystem path field types."""
|
"""Verify filesystem path field types."""
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ from transcription.db import create_all
|
|||||||
from transcription.db import initialize_database_runtime
|
from transcription.db import initialize_database_runtime
|
||||||
from transcription.db import session_scope
|
from transcription.db import session_scope
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
from transcription.db.models import JobSource
|
from transcription.db.models import JobSource
|
||||||
from transcription.db.models import JobSourceStatus
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
|
from transcription.db.models import Person
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
|
|
||||||
RevisionSeed = str
|
RevisionSeed = str
|
||||||
@@ -58,9 +60,12 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
|
|||||||
|
|
||||||
async def _clear() -> None:
|
async def _clear() -> None:
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
|
await session.exec(delete(JobSource))
|
||||||
|
await session.exec(delete(DocumentPerson))
|
||||||
await session.exec(delete(Source))
|
await session.exec(delete(Source))
|
||||||
await session.exec(delete(Job))
|
await session.exec(delete(Job))
|
||||||
await session.exec(delete(Document))
|
await session.exec(delete(Document))
|
||||||
|
await session.exec(delete(Person))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
asyncio.run(_clear())
|
asyncio.run(_clear())
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""Tests for the documents page routes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from transcription.db import session_scope
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import Person
|
||||||
|
from transcription.db.models import Source
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestDocumentsPageRendering:
|
||||||
|
"""Verify document list/detail routes render expected read states."""
|
||||||
|
|
||||||
|
def test_documents_page_renders_empty_state(self, app_client):
|
||||||
|
"""GET /ui/documents renders empty-state text when no records exist."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/documents")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Documents" in response.text
|
||||||
|
assert "No documents yet." in response.text
|
||||||
|
|
||||||
|
def test_documents_page_lists_seeded_documents(self, app_client):
|
||||||
|
"""GET /ui/documents lists seeded document cards."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
session.add(Document(name="Seeded Document", document_type="letter"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get("/ui/documents")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Seeded Document" in response.text
|
||||||
|
assert "Type: letter" in response.text
|
||||||
|
|
||||||
|
def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id} shows metadata and related empty states."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(
|
||||||
|
name="Zenna Letter",
|
||||||
|
document_type="letter",
|
||||||
|
document_date=date(1885, 7, 13),
|
||||||
|
document_date_raw="c. 1885",
|
||||||
|
location_created="Ohio",
|
||||||
|
notes="Family archive",
|
||||||
|
archive_identifier="BOX-1-FOLDER-2",
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{document_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Zenna Letter" in response.text
|
||||||
|
assert "Document type: letter" in response.text
|
||||||
|
assert "Exact date: 1885-07-13" in response.text
|
||||||
|
assert "Approximate date: c. 1885" in response.text
|
||||||
|
assert "Location created: Ohio" in response.text
|
||||||
|
assert "Archive identifier: BOX-1-FOLDER-2" in response.text
|
||||||
|
assert "Notes: Family archive" in response.text
|
||||||
|
assert "Created at (read-only):" in response.text
|
||||||
|
assert "Updated at (read-only):" in response.text
|
||||||
|
assert "No linked people yet." in response.text
|
||||||
|
assert "No sources added yet." in response.text
|
||||||
|
assert "No jobs created yet." in response.text
|
||||||
|
assert "Add sources" in response.text
|
||||||
|
assert "Create job" in response.text
|
||||||
|
assert "View sources" in response.text
|
||||||
|
assert "View jobs" in response.text
|
||||||
|
assert "Edit document" in response.text
|
||||||
|
assert "Delete document" in response.text
|
||||||
|
|
||||||
|
def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id} shows related records when present."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_related() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(name="Roster", document_type="record")
|
||||||
|
person = Person(full_name="Jane Doe")
|
||||||
|
session.add(document)
|
||||||
|
session.add(person)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="001_page.png",
|
||||||
|
filename="stored_001_page.png",
|
||||||
|
file_path="/tmp/stored_001_page.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Job(
|
||||||
|
document_id=document.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_related())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{document_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Jane Doe (author)" in response.text
|
||||||
|
assert "Page 1: 001_page.png" in response.text
|
||||||
|
assert "queued -" in response.text
|
||||||
|
|
||||||
|
def test_document_jobs_page_filters_to_document_context(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
target = Document(name="Target", document_type="letter")
|
||||||
|
other = Document(name="Other", document_type="record")
|
||||||
|
session.add(target)
|
||||||
|
session.add(other)
|
||||||
|
await session.flush()
|
||||||
|
session.add(Job(document_id=target.id))
|
||||||
|
session.add(Job(document_id=other.id))
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(target)
|
||||||
|
return str(target.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed())
|
||||||
|
response = client.get(f"/ui/documents/{document_id}/jobs")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Jobs for Target" in response.text
|
||||||
|
assert "Jobs for Other" not in response.text
|
||||||
|
|
||||||
|
def test_document_sources_page_filters_to_document_context(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
target = Document(name="Target", document_type="letter")
|
||||||
|
other = Document(name="Other", document_type="record")
|
||||||
|
session.add(target)
|
||||||
|
session.add(other)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=target.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="target_page.png",
|
||||||
|
filename="target_stored.png",
|
||||||
|
file_path="/tmp/target_stored.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=other.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="other_page.png",
|
||||||
|
filename="other_stored.png",
|
||||||
|
file_path="/tmp/other_stored.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(target)
|
||||||
|
return str(target.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed())
|
||||||
|
response = client.get(f"/ui/documents/{document_id}/sources")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Sources for Target" in response.text
|
||||||
|
assert "target_page.png" in response.text
|
||||||
|
assert "other_page.png" not in response.text
|
||||||
|
|
||||||
|
def test_document_detail_page_rejects_invalid_id(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id} shows validation feedback for malformed IDs."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/documents/not-a-uuid")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Invalid document id" in response.text
|
||||||
|
|
||||||
|
def test_document_detail_page_handles_missing_document(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id} shows not-found state for unknown IDs."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{uuid4()}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Document not found" in response.text
|
||||||
|
|
||||||
|
def test_document_edit_page_renders_expected_fields(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id}/edit renders editable fields and save controls."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(
|
||||||
|
name="Editable Document",
|
||||||
|
document_type="memo",
|
||||||
|
document_date_raw="c. 1900",
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{document_id}/edit")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Edit document" in response.text
|
||||||
|
assert "Document name and document type are required." in response.text
|
||||||
|
assert "Document name" in response.text
|
||||||
|
assert "Document type" in response.text
|
||||||
|
assert "Exact date (YYYY-MM-DD)" in response.text
|
||||||
|
assert "Approximate date" in response.text
|
||||||
|
assert "Document location" in response.text
|
||||||
|
assert "Archive identifier" in response.text
|
||||||
|
assert "Notes" in response.text
|
||||||
|
assert "Save changes" in response.text
|
||||||
|
|
||||||
|
def test_document_delete_page_shows_confirmation_when_unlinked(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id}/delete renders permanent-action confirmation if unlinked."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(name="Safe Delete", document_type="letter")
|
||||||
|
session.add(document)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{document_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete document" in response.text
|
||||||
|
assert "This action permanently deletes the document." in response.text
|
||||||
|
assert "Delete document permanently" in response.text
|
||||||
|
|
||||||
|
def test_document_delete_page_shows_blocked_state_when_dependencies_exist(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id}/delete explains blocked deletion with dependency categories."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_related() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(name="Blocked Delete", document_type="record")
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="001_page.png",
|
||||||
|
filename="stored_001_page.png",
|
||||||
|
file_path="/tmp/stored_001_page.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(Job(document_id=document.id))
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_related())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{document_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete is blocked because related records exist." in response.text
|
||||||
|
assert "Dependencies present: Sources, Jobs" in response.text
|
||||||
|
assert "Go to Jobs" in response.text
|
||||||
|
|
||||||
|
def test_job_create_page_preselects_document_query_param(self, app_client):
|
||||||
|
"""GET /ui/jobs/new?document_id=... includes the selected document in rendered state."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(
|
||||||
|
name="Preselected Document",
|
||||||
|
document_type="letter",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/jobs/new?document_id={document_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Preselected Document" in response.text
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Tests for the jobs page route."""
|
"""Tests for the jobs page route."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from transcription.db import session_scope
|
||||||
|
from transcription.db.models import Document
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
|
|
||||||
|
|
||||||
@@ -18,8 +21,39 @@ class TestPageRendering:
|
|||||||
response = client.get("/ui/jobs")
|
response = client.get("/ui/jobs")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
assert "Create job" in response.text
|
||||||
assert "No jobs yet." in response.text
|
assert "No jobs yet." in response.text
|
||||||
|
|
||||||
|
def test_job_create_page_requires_existing_documents(self, app_client):
|
||||||
|
"""GET /ui/jobs/new shows guidance when no Documents exist."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/jobs/new")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Create job" in response.text
|
||||||
|
assert "No documents available. Create a Document before creating a Job." in response.text
|
||||||
|
|
||||||
|
def test_job_create_page_lists_available_documents(self, app_client):
|
||||||
|
"""GET /ui/jobs/new renders document choices when Documents exist."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
session.add(Document(name="Seeded Document"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get("/ui/jobs/new")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Create job" in response.text
|
||||||
|
assert "Seeded Document" in response.text
|
||||||
|
assert "Files are processed alphabetically by original filename." in response.text
|
||||||
|
assert "No files uploaded yet." in response.text
|
||||||
|
assert "Upload folder" in response.text
|
||||||
|
|
||||||
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
||||||
"""GET /ui/jobs lists seeded jobs from the in-memory database."""
|
"""GET /ui/jobs lists seeded jobs from the in-memory database."""
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
@@ -54,6 +88,14 @@ class TestPageRendering:
|
|||||||
assert "original text" in response.text
|
assert "original text" in response.text
|
||||||
assert "Document preview" in response.text
|
assert "Document preview" in response.text
|
||||||
assert "/uploads/detail.pdf" in response.text
|
assert "/uploads/detail.pdf" in response.text
|
||||||
|
assert "Provider:" in response.text
|
||||||
|
assert "Model:" in response.text
|
||||||
|
assert "Prompt:" in response.text
|
||||||
|
assert "Retry count:" in response.text
|
||||||
|
assert "Last updated:" in response.text
|
||||||
|
assert "Delete job" in response.text
|
||||||
|
assert "Delete source" in response.text
|
||||||
|
assert "This permanently deletes the source from this job context." in response.text
|
||||||
|
|
||||||
def test_job_detail_page_rejects_invalid_id(self, app_client):
|
def test_job_detail_page_rejects_invalid_id(self, app_client):
|
||||||
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
|
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
|
||||||
@@ -105,3 +147,24 @@ class TestPageRendering:
|
|||||||
assert "Revision Editor" in response.text
|
assert "Revision Editor" in response.text
|
||||||
assert "Update revision" in response.text
|
assert "Update revision" in response.text
|
||||||
assert "hello" in response.text
|
assert "hello" in response.text
|
||||||
|
|
||||||
|
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(filename="delete-ready.pdf", status=JobStatus.TRANSCRIBED)
|
||||||
|
|
||||||
|
response = client.get(f"/ui/jobs/{job_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete job" in response.text
|
||||||
|
assert "This action permanently deletes the job." in response.text
|
||||||
|
assert "Delete job permanently" in response.text
|
||||||
|
|
||||||
|
def test_job_delete_page_shows_blocked_state_when_processing(self, app_client, seed_job):
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(filename="delete-blocked.pdf", status=JobStatus.PROCESSING)
|
||||||
|
|
||||||
|
response = client.get(f"/ui/jobs/{job_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete is blocked while the job is processing." in response.text
|
||||||
|
assert "Wait for processing to complete, then retry delete." in response.text
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ class TestPageRegistration:
|
|||||||
"""Mounted UI routes respond successfully when the full app is created."""
|
"""Mounted UI routes respond successfully when the full app is created."""
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
|
|
||||||
upload_response = client.get("/ui/upload")
|
upload_response = client.get("/ui/upload", follow_redirects=False)
|
||||||
|
documents_response = client.get("/ui/documents")
|
||||||
|
people_response = client.get("/ui/people")
|
||||||
jobs_response = client.get("/ui/jobs")
|
jobs_response = client.get("/ui/jobs")
|
||||||
|
|
||||||
assert upload_response.status_code == 200
|
assert upload_response.status_code == 307
|
||||||
|
assert documents_response.status_code == 200
|
||||||
|
assert people_response.status_code == 200
|
||||||
assert jobs_response.status_code == 200
|
assert jobs_response.status_code == 200
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Tests for the people page routes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import date
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from transcription.db import session_scope
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
|
from transcription.db.models import Person
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestPeoplePageRendering:
|
||||||
|
"""Verify people routes render expected CRUD read states."""
|
||||||
|
|
||||||
|
def test_people_page_renders_empty_state(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/people")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "People" in response.text
|
||||||
|
assert "Create new person" in response.text
|
||||||
|
assert "No people yet." in response.text
|
||||||
|
|
||||||
|
def test_people_page_lists_seeded_people(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_person() -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
session.add(Person(full_name="Ada Lovelace", display_name="Ada"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_seed_person())
|
||||||
|
|
||||||
|
response = client.get("/ui/people")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Ada Lovelace" in response.text
|
||||||
|
assert "Display name: Ada" in response.text
|
||||||
|
|
||||||
|
def test_person_create_page_renders_fields(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/people/new")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Create person" in response.text
|
||||||
|
assert "Full name is required." in response.text
|
||||||
|
assert "Birth date (YYYY-MM-DD)" in response.text
|
||||||
|
assert "Death date (YYYY-MM-DD)" in response.text
|
||||||
|
assert "Biography" in response.text
|
||||||
|
assert "Save person" in response.text
|
||||||
|
|
||||||
|
def test_person_detail_page_renders_metadata_and_empty_links(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_person() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(
|
||||||
|
full_name="Grace Hopper",
|
||||||
|
display_name="Grace",
|
||||||
|
maiden_name="Murray",
|
||||||
|
birth_date=date(1906, 12, 9),
|
||||||
|
birth_date_raw="1906",
|
||||||
|
birth_place="New York",
|
||||||
|
death_date=date(1992, 1, 1),
|
||||||
|
death_date_raw="1992",
|
||||||
|
death_place="Arlington",
|
||||||
|
biography="Computer pioneer",
|
||||||
|
portrait_path="/images/grace.jpg",
|
||||||
|
)
|
||||||
|
session.add(person)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_person())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{person_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Grace Hopper" in response.text
|
||||||
|
assert "Display name: Grace" in response.text
|
||||||
|
assert "Maiden name: Murray" in response.text
|
||||||
|
assert "Birth date: 1906-12-09" in response.text
|
||||||
|
assert "Death date: 1992-01-01" in response.text
|
||||||
|
assert "Biography: Computer pioneer" in response.text
|
||||||
|
assert "Portrait path: /images/grace.jpg" in response.text
|
||||||
|
assert "Created at (read-only):" in response.text
|
||||||
|
assert "Updated at (read-only):" in response.text
|
||||||
|
assert "No linked documents yet." in response.text
|
||||||
|
assert "Link this person from a Document workflow." in response.text
|
||||||
|
|
||||||
|
def test_person_detail_page_renders_linked_documents(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_links() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(full_name="Linked Person")
|
||||||
|
document = Document(name="Linked Document", document_type="letter")
|
||||||
|
session.add(person)
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_links())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{person_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Linked Document (author)" in response.text
|
||||||
|
|
||||||
|
def test_person_detail_page_handles_invalid_id(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/people/not-a-uuid")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Invalid person id" in response.text
|
||||||
|
|
||||||
|
def test_person_detail_page_handles_missing_person(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{uuid4()}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Person not found" in response.text
|
||||||
|
|
||||||
|
def test_person_edit_page_renders_expected_fields(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_person() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(full_name="Editable Person", display_name="EP")
|
||||||
|
session.add(person)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_person())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{person_id}/edit")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Edit person" in response.text
|
||||||
|
assert "Full name is required." in response.text
|
||||||
|
assert "Full name" in response.text
|
||||||
|
assert "Save changes" in response.text
|
||||||
|
|
||||||
|
def test_person_delete_page_shows_confirmation_when_unlinked(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_person() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(full_name="Safe Delete")
|
||||||
|
session.add(person)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_person())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{person_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete person" in response.text
|
||||||
|
assert "This action permanently deletes the person." in response.text
|
||||||
|
assert "Delete person permanently" in response.text
|
||||||
|
|
||||||
|
def test_person_delete_page_shows_blocked_state_when_linked_documents_exist(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_links() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(full_name="Blocked Delete")
|
||||||
|
document = Document(name="Linked Document", document_type="record")
|
||||||
|
session.add(person)
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_links())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{person_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete is blocked because linked documents exist." in response.text
|
||||||
|
assert "Linked documents: 1" in response.text
|
||||||
|
assert "Go to Documents" in response.text
|
||||||
@@ -16,21 +16,17 @@ class TestPageRendering:
|
|||||||
assert response.headers["location"] == "/ui"
|
assert response.headers["location"] == "/ui"
|
||||||
|
|
||||||
def test_ui_redirects_to_upload(self, app_client):
|
def test_ui_redirects_to_upload(self, app_client):
|
||||||
"""GET /ui redirects to the upload page."""
|
"""GET /ui redirects to the jobs page."""
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
response = client.get("/ui", follow_redirects=False)
|
response = client.get("/ui", follow_redirects=False)
|
||||||
|
|
||||||
assert response.status_code == 307
|
assert response.status_code == 307
|
||||||
assert response.headers["location"] == "/ui/upload"
|
assert response.headers["location"] == "/ui/jobs"
|
||||||
|
|
||||||
def test_upload_page_renders_expected_controls(self, app_client):
|
def test_upload_page_renders_expected_controls(self, app_client):
|
||||||
"""GET /ui/upload returns the page shell and upload controls."""
|
"""GET /ui/upload redirects to the job-create flow."""
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
response = client.get("/ui/upload")
|
response = client.get("/ui/upload", follow_redirects=False)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 307
|
||||||
assert "VibeScribe" in response.text
|
assert response.headers["location"] == "/ui/jobs/new"
|
||||||
assert "Upload Document" in response.text
|
|
||||||
assert "Select document file" in response.text
|
|
||||||
assert "Upload" in response.text
|
|
||||||
assert "Jobs" in response.text
|
|
||||||
|
|||||||
Reference in New Issue
Block a user