3 Commits
Author SHA1 Message Date
John Lancaster f7192a33dc document form 2026-08-04 00:36:27 -05:00
John Lancaster 5932c0d3a1 global theming 2026-08-03 22:56:04 -05:00
John Lancaster edb6967888 split up docs pages 2026-08-03 22:25:07 -05:00
57 changed files with 2596 additions and 3649 deletions
+7 -53
View File
@@ -1,54 +1,8 @@
# --- NiceGUI Server --- PROVIDER=openrouter
# HOST=`0.0.0.0` (default) OPENROUTER_API_KEY=sk-or-...
# PORT=8000 (default) # PROVIDER_MODEL= # optional: OpenRouter adapter supplies default
# LOG_LEVEL: [`critical`, `error`, `warning`, `info` (default), `debug`, `trace`]
# RELOAD=false (default)
# --- AI provider ---
# PROVIDER=[`openrouter`(default), `google_genai`]
PROVIDER=openrouter
# OPENROUTER_API_KEY - Required when `PROVIDER=openrouter`
OPENROUTER_API_KEY=your-api-key-goes-here
# GEMINI_API_KEY - Required when `PROVIDER=google_genai`
# PROVIDER_MODEL= specify model. If left blank OpenRouter will supply default.
PROVIDER_MODEL=google/gemini-2.5-flash
# OPENROUTER_HTTP_REFERER=https://example.com # OPENROUTER_HTTP_REFERER=https://example.com
# OPENROUTER_APP_TITLE="Google: Gemini 2.5 Flash (openrouter)" # OPENROUTER_APP_TITLE=Historical Transcription MVP
# DATABASE_URL=sqlite:///./transcription.db
# --- runtime environment --- # UPLOAD_DIR=./uploads
# ENVIRONMENT: [`development`(default), `test`, `production`] # PROMPT_DIR=./prompts
# --- persistence ---
# Use nested settings with double underscore because env_nested_delimiter="__".
# SQLite example:
# DATABASE__DRIVER=sqlite
# DATABASE__PATH=app.db
#
# SQLite with custom relative path:
# DATABASE__DRIVER=sqlite
DATABASE__PATH=./data/transcription.db
#
# Postgres example:
# DATABASE__DRIVER=postgres
# DATABASE__HOST=localhost
# DATABASE__PORT=5432
# DATABASE__DATABASE=transcription
# DATABASE__USER=postgres
# DATABASE__PASSWORD=change-me
#
# Optional persistence flags:
# BOOTSTRAP_SCHEMA_ON_STARTUP=false
# SQLITE_CHECK_SAME_THREAD=false
# --- filesystem paths ---
UPLOAD_DIR="./data"
PROMPT_DIR="./prompts"
# --- worker reliability ---
WORKER_MAX_RETRIES=0
WORKER_RETRY_BACKOFF_SECONDS=0
# WORKER_PROVIDER_TIMEOUT_SECONDS=[0-20]
WORKER_PROVIDER_TIMEOUT_SECONDS=20
WORKER_MIN_TRANSCRIPTION_CHARS=0
WORKER_MIN_TRANSCRIPTION_LINES=0
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
-1
View File
@@ -17,4 +17,3 @@ wheels/
# Document images # Document images
uploads/* uploads/*
data/*
+1 -2
View File
@@ -50,7 +50,7 @@ erDiagram
JOB { JOB {
UUID id PK UUID id PK
UUID document_id FK UUID document_id FK
VARCHAR status "queued | processing | transcribed | completed | partial_success | failed" VARCHAR status "queued | processing | completed | partial_success | failed"
INTEGER retry_count INTEGER retry_count
TEXT provider TEXT provider
TEXT model TEXT model
@@ -97,7 +97,6 @@ erDiagram
### Page-Level Execution & AI Outputs ### Page-Level Execution & AI Outputs
* Execution Granularity: Every single image execution by an AI model produces a dedicated record in job_source. * Execution Granularity: Every single image execution by an AI model produces a dedicated record in job_source.
* Source vs Execution Status: `source` does not carry a `status` column. Per-source execution state is tracked in `job_source.status` (`pending`, `transcribed`, `failed`).
* Point-in-Time Auditability: job_source.raw_api_response stores the unparsed REST response envelope for that specific image page call. job_source.ai_metadata stores spatial bounding boxes, token usage, and layout details for that specific image page call. * Point-in-Time Auditability: job_source.raw_api_response stores the unparsed REST response envelope for that specific image page call. job_source.ai_metadata stores spatial bounding boxes, token usage, and layout details for that specific image page call.
* Active Output Caching: Upon successful completion of an image call, source.raw_transcription is updated with the latest output string from job_source.raw_transcription for fast UI rendering. * Active Output Caching: Upon successful completion of an image call, source.raw_transcription is updated with the latest output string from job_source.raw_transcription for fast UI rendering.
Binary file not shown.
+3 -3
View File
@@ -12,10 +12,10 @@ def create_cli_app() -> FastAPI:
def main() -> None: def main() -> None:
settings = parse_cli_settings() settings = parse_cli_settings()
application = "transcription.__main__:create_cli_app" if settings.reload else create_app(settings=settings)
uvicorn.run( uvicorn.run(
application, "transcription.__main__:create_cli_app",
factory=settings.reload, factory=True,
host=settings.host, host=settings.host,
port=settings.port, port=settings.port,
log_level=settings.log_level, log_level=settings.log_level,
+5 -5
View File
@@ -5,12 +5,12 @@ from fastapi import APIRouter
router = APIRouter() router = APIRouter()
def healthz() -> dict[str, str]:
"""Return a simple health status payload."""
return {"status": "ok"}
@router.get("/healthz") @router.get("/healthz")
def healthz_route() -> dict[str, str]: def healthz_route() -> dict[str, str]:
"""Route wrapper for health status payload.""" """Route wrapper for health status payload."""
return healthz() return healthz()
def healthz() -> dict[str, str]:
"""Return a simple health status payload."""
return {"status": "ok"}
+4 -4
View File
@@ -24,7 +24,7 @@ from .db import dispose_database_runtime
from .db import initialize_database_runtime from .db import initialize_database_runtime
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.pages import register_pages
from .worker import worker_consumer_lifespan from .worker import worker_consumer_lifespan
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -86,17 +86,17 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
async def root_redirect() -> RedirectResponse: async def root_redirect() -> RedirectResponse:
return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT) return RedirectResponse(url="/ui", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@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/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT) return RedirectResponse(url="/ui/documents", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
@app.get("/healthz") @app.get("/healthz")
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
app.include_router(health_router)
register_error_handlers(app) register_error_handlers(app)
register_pages(app) register_pages(app)
app.include_router(health_router)
return app return app
+41 -43
View File
@@ -9,8 +9,8 @@ from typing import Optional
from uuid import UUID from uuid import UUID
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import Column
from sqlalchemy import JSON from sqlalchemy import JSON
from sqlalchemy import Column
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm.exc import DetachedInstanceError from sqlalchemy.orm.exc import DetachedInstanceError
@@ -67,7 +67,23 @@ class Document(SQLModel, table=True):
jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"}) jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
sources: list["Source"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"}) sources: list["Source"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
document_people: list["DocumentPerson"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"}) document_people: list["DocumentPerson"] = Relationship(
back_populates="document", sa_relationship_kwargs={"lazy": "selectin"}
)
@property
def authors(self):
"""Return linked people whose role is AUTHOR."""
return [
link.person
for link in self.document_people
if link.role == DocumentPersonRole.AUTHOR and link.person is not None
]
@property
def author(self):
"""Return the first linked author for convenience in read paths."""
return self.authors[0] if self.authors else None
class Person(SQLModel, table=True): class Person(SQLModel, table=True):
@@ -92,13 +108,24 @@ class Person(SQLModel, table=True):
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
document_people: list["DocumentPerson"] = Relationship(back_populates="person", sa_relationship_kwargs={"lazy": "selectin"}) document_people: list["DocumentPerson"] = Relationship(
back_populates="person", sa_relationship_kwargs={"lazy": "selectin"}
)
@property
def authored_documents(self):
"""Return documents where this person is linked as AUTHOR."""
return [
link.document
for link in self.document_people
if link.role == DocumentPersonRole.AUTHOR and link.document is not None
]
class DocumentPerson(SQLModel, table=True): class DocumentPerson(SQLModel, table=True):
"""Associates documents with people in a given role.""" """Associates documents with people in a given role."""
__tablename__ = "document_person" __tablename__: str = "document_person"
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id") document_id: UUID = Field(foreign_key="document.id")
@@ -106,12 +133,14 @@ class DocumentPerson(SQLModel, table=True):
role: DocumentPersonRole = Field(default=DocumentPersonRole.AUTHOR) role: DocumentPersonRole = Field(default=DocumentPersonRole.AUTHOR)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
__table_args__ = ( __table_args__ = (UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),)
UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),
)
document: Optional["Document"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}) document: Optional["Document"] = Relationship(
person: Optional["Person"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}) back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}
)
person: Optional["Person"] = Relationship(
back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}
)
class Job(SQLModel, table=True): class Job(SQLModel, table=True):
@@ -178,43 +207,14 @@ class Source(SQLModel, table=True):
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC)) date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
date_revised: datetime | None = None date_revised: datetime | None = None
document: Optional["Document"] = Relationship( document: Optional["Document"] = Relationship(back_populates="sources", sa_relationship_kwargs={"lazy": "selectin"})
back_populates="sources", job_sources: list["JobSource"] = Relationship(back_populates="source", sa_relationship_kwargs={"lazy": "selectin"})
sa_relationship_kwargs={"lazy": "selectin"},
)
job_sources: list["JobSource"] = Relationship(
back_populates="source",
sa_relationship_kwargs={"lazy": "selectin"},
)
@property
def latest_job_source(self) -> Optional["JobSource"]:
"""Return the most recent job execution record for this source."""
if not self.job_sources:
return None
return max(self.job_sources, key=lambda js: js.executed_at)
@property
def latest_status(self) -> JobSourceStatus | None:
"""Return the execution status of the latest job run."""
latest = self.latest_job_source
return latest.status if latest else None
@property
def latest_error_detail(self) -> str | None:
"""Return the error detail from the latest job run, if present."""
latest = self.latest_job_source
return latest.error_detail if latest else None
@property
def document_name(self) -> str | None:
"""Return the parent document name if loaded."""
return self.document.name if self.document else None
class JobSource(SQLModel, table=True): class JobSource(SQLModel, table=True):
"""A single AI execution record for one source page.""" """A single AI execution record for one source page."""
__tablename__ = "job_source" __tablename__: str = "job_source"
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
job_id: UUID = Field(foreign_key="job.id") job_id: UUID = Field(foreign_key="job.id")
@@ -228,5 +228,3 @@ class JobSource(SQLModel, table=True):
job: Optional["Job"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"}) job: Optional["Job"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"})
source: Optional["Source"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"}) source: Optional["Source"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"})
+10
View File
@@ -3,6 +3,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from dataclasses import field from dataclasses import field
from ..db.session import SessionFactory
from .documents import DocumentService from .documents import DocumentService
from .jobs import JobService from .jobs import JobService
from .transcription import TranscriptionService from .transcription import TranscriptionService
@@ -17,3 +18,12 @@ class ServiceBundle:
documents: DocumentService = field(default_factory=DocumentService) documents: DocumentService = field(default_factory=DocumentService)
jobs: JobService = field(default_factory=JobService) jobs: JobService = field(default_factory=JobService)
transcriptions: TranscriptionService = field(default_factory=TranscriptionService) transcriptions: TranscriptionService = field(default_factory=TranscriptionService)
@classmethod
def from_session_factory(cls, session_factory: SessionFactory) -> "ServiceBundle":
"""Create a ServiceBundle from a session factory."""
return cls(
documents=DocumentService(session_factory=session_factory),
jobs=JobService(session_factory=session_factory),
transcriptions=TranscriptionService(session_factory=session_factory),
)
+6 -18
View File
@@ -4,7 +4,6 @@ from dataclasses import dataclass
from datetime import UTC from datetime import UTC
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import shutil
from uuid import UUID from uuid import UUID
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -120,7 +119,6 @@ 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."""
document_id = document.id
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
existing = await _session.get( existing = await _session.get(
Document, Document,
@@ -154,20 +152,6 @@ class DocumentService(ServiceBase):
await _session.delete(existing) await _session.delete(existing)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
self._delete_document_storage_folder(document_id=document_id)
def _delete_document_storage_folder(self, *, document_id: UUID) -> None:
"""Best-effort cleanup for document-scoped source storage."""
document_dir = self.settings.upload_dir / "documents" / str(document_id)
if not document_dir.exists():
return
try:
shutil.rmtree(document_dir)
logger.info("Deleted document storage folder: %s", document_dir)
except OSError:
logger.warning("Failed to delete document storage folder: %s", document_dir)
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person: async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
"""Create a new person in the database.""" """Create a new person in the database."""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
@@ -233,8 +217,12 @@ class DocumentService(ServiceBase):
suggestion="Verify the person id and retry.", suggestion="Verify the person id and retry.",
) )
for link in list(existing.document_people): if existing.document_people:
await _session.delete(link) 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 _session.delete(existing)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
-93
View File
@@ -11,7 +11,6 @@ from ..errors import AppError
from ..errors import ErrorCategory 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 JobSourceStatus
from ..db.models import JobStatus from ..db.models import JobStatus
from ..db.models import Source from ..db.models import Source
from .base import ServiceBase from .base import ServiceBase
@@ -21,14 +20,6 @@ class JobDeleteBlockedError(AppError):
"""Raised when a job delete operation is blocked by lifecycle policy.""" """Raised when a job delete operation is blocked by lifecycle policy."""
class JobCancelBlockedError(AppError):
"""Raised when a job cancel operation is blocked by lifecycle policy."""
class JobResubmitBlockedError(AppError):
"""Raised when a job resubmit 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."""
@@ -230,87 +221,3 @@ class JobService(ServiceBase):
await _session.delete(job) await _session.delete(job)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
async def cancel_job(self, *, job_id: UUID, session: AsyncSession | None = None) -> Job:
"""Cancel a queued/processing job and stop remaining source work."""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(
selectinload(Job.job_sources).selectinload(JobSource.source), # 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 in {JobStatus.TRANSCRIBED, JobStatus.COMPLETED}:
raise JobCancelBlockedError(
"Job cancel is not allowed for transcribed/completed jobs",
category=ErrorCategory.VALIDATION,
suggestion="Use resubmit for reprocessing needs, or leave the terminal job unchanged.",
)
now = datetime.now(UTC)
job.status = JobStatus.FAILED
job.date_updated = now
for job_source in job.job_sources:
if job_source.status == JobSourceStatus.TRANSCRIBED:
continue
job_source.status = JobSourceStatus.FAILED
job_source.raw_transcription = None
job_source.error_detail = "Cancelled by user"
job_source.executed_at = now
if job_source.source is not None:
job_source.source.raw_transcription = None
await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job
async def resubmit_non_transcribed_sources(self, *, job_id: UUID, session: AsyncSession | None = None) -> int:
"""Reset non-transcribed source executions and queue the job for reprocessing."""
async with self._session_scope(session) as _session:
query = (
select(Job)
.options(
selectinload(Job.job_sources).selectinload(JobSource.source), # 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 JobResubmitBlockedError(
"Job resubmit is blocked while processing is active",
category=ErrorCategory.VALIDATION,
suggestion="Cancel processing first, then resubmit remaining sources.",
)
candidates = [job_source for job_source in job.job_sources if job_source.status != JobSourceStatus.TRANSCRIBED]
if not candidates:
raise JobResubmitBlockedError(
"Job has no non-transcribed sources to resubmit",
category=ErrorCategory.VALIDATION,
suggestion="Only failed or pending sources can be resubmitted.",
)
now = datetime.now(UTC)
for job_source in candidates:
job_source.status = JobSourceStatus.PENDING
job_source.raw_transcription = None
job_source.error_detail = None
job_source.executed_at = now
if job_source.source is not None:
job_source.source.raw_transcription = None
job.status = JobStatus.QUEUED
job.date_updated = now
await self._finalize(session=_session, caller_session=session, refresh=(job,))
return len(candidates)
+19 -64
View File
@@ -41,15 +41,6 @@ class JobCreateResult:
source_ids: tuple[UUID, ...] source_ids: tuple[UUID, ...]
@dataclass(frozen=True)
class PendingStoredUpload:
"""Pre-staged upload artifact tied to a source id."""
source_id: UUID
original_filename: str
stored_path: Path
async def create_upload_job( async def create_upload_job(
*, *,
filename: str, filename: str,
@@ -59,20 +50,14 @@ async def create_upload_job(
) -> UploadJobResult: ) -> UploadJobResult:
"""Create upload-backed document and queued job records.""" """Create upload-backed document and queued job records."""
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
document_id = uuid4()
source_id = uuid4()
stored_path = store_file( stored_path = store_file(
filename=filename, filename=filename,
file_bytes=file_bytes, file_bytes=file_bytes,
settings=runtime_settings, settings=runtime_settings,
relative_directory=Path("documents") / str(document_id),
filename_stem=str(source_id),
) )
try: try:
document, job = await _create_upload_records( document, job = await _create_upload_records(
session=session, session=session,
document_id=document_id,
source_id=source_id,
original_filename=filename, original_filename=filename,
stored_path=stored_path, stored_path=stored_path,
) )
@@ -114,19 +99,15 @@ async def create_job_for_document(
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
sorted_uploads = sorted(uploads, key=lambda item: Path(item[0]).name.casefold()) sorted_uploads = sorted(uploads, key=lambda item: Path(item[0]).name.casefold())
stored_uploads: list[PendingStoredUpload] = [] stored_uploads: list[tuple[str, Path]] = []
for filename, file_bytes in sorted_uploads: for filename, file_bytes in sorted_uploads:
source_id = uuid4()
stored_uploads.append( stored_uploads.append(
PendingStoredUpload( (
source_id=source_id, filename,
original_filename=filename, store_file(
stored_path=store_file(
filename=filename, filename=filename,
file_bytes=file_bytes, file_bytes=file_bytes,
settings=runtime_settings, settings=runtime_settings,
relative_directory=Path("documents") / str(document_id),
filename_stem=str(source_id),
), ),
) )
) )
@@ -141,8 +122,8 @@ async def create_job_for_document(
prompt_name=prompt_name, prompt_name=prompt_name,
) )
except Exception as exc: except Exception as exc:
for upload in stored_uploads: for _, stored_path in stored_uploads:
_best_effort_delete(upload.stored_path) _best_effort_delete(stored_path)
raise UploadError( raise UploadError(
"Failed to create job records from uploads", "Failed to create job records from uploads",
category=ErrorCategory.INFRA_TRANSIENT, category=ErrorCategory.INFRA_TRANSIENT,
@@ -161,13 +142,10 @@ async def create_job_for_document(
async def _create_upload_records( async def _create_upload_records(
*, *,
session: AsyncSession, session: AsyncSession,
document_id: UUID,
source_id: UUID,
original_filename: str, original_filename: str,
stored_path: Path, stored_path: Path,
) -> tuple[Document, Job]: ) -> tuple[Document, Job]:
document = Document( document = Document(
id=document_id,
name=Path(original_filename).name, name=Path(original_filename).name,
) )
session.add(document) session.add(document)
@@ -178,7 +156,6 @@ async def _create_upload_records(
await session.flush() await session.flush()
source = Source( source = Source(
id=source_id,
document_id=document.id, document_id=document.id,
page_number=1, page_number=1,
upload_name=Path(original_filename).name, upload_name=Path(original_filename).name,
@@ -206,7 +183,7 @@ async def _create_job_for_document_records(
*, *,
session: AsyncSession, session: AsyncSession,
document_id: UUID, document_id: UUID,
stored_uploads: Sequence[PendingStoredUpload], stored_uploads: Sequence[tuple[str, Path]],
provider: str | None, provider: str | None,
model: str | None, model: str | None,
prompt_name: str | None, prompt_name: str | None,
@@ -234,14 +211,13 @@ async def _create_job_for_document_records(
await session.flush() await session.flush()
source_ids: list[UUID] = [] source_ids: list[UUID] = []
for page_offset, upload in enumerate(stored_uploads): for page_offset, (original_filename, stored_path) in enumerate(stored_uploads):
source = Source( source = Source(
id=upload.source_id,
document_id=document_id, document_id=document_id,
page_number=next_page_number + page_offset, page_number=next_page_number + page_offset,
upload_name=Path(upload.original_filename).name, upload_name=Path(original_filename).name,
filename=upload.stored_path.name, filename=stored_path.name,
file_path=str(upload.stored_path), file_path=str(stored_path),
) )
session.add(source) session.add(source)
await session.flush() await session.flush()
@@ -268,41 +244,22 @@ def _best_effort_delete(path: Path) -> None:
logger.warning("Failed to clean up upload file after DB error: %s", path) logger.warning("Failed to clean up upload file after DB error: %s", path)
def store_file( def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
*,
filename: str,
file_bytes: bytes,
settings: Settings | None = None,
relative_directory: Path | None = None,
filename_stem: str | None = None,
) -> Path:
"""Persist an uploaded file to the configured upload directory.""" """Persist an uploaded file to the configured upload directory."""
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_UPLOAD_EXTENSIONS) _validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_UPLOAD_EXTENSIONS)
return _store_file_bytes( return _store_file_bytes(filename=filename, file_bytes=file_bytes, settings=runtime_settings)
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
relative_directory=relative_directory,
filename_stem=filename_stem,
)
def store_person_portrait( def store_person_portrait(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
*, """Persist a portrait upload under uploads/portraits/person."""
person_id: UUID,
filename: str,
file_bytes: bytes,
settings: Settings | None = None,
) -> Path:
"""Persist a portrait upload under persons/<person_id>."""
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_PORTRAIT_EXTENSIONS) _validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_PORTRAIT_EXTENSIONS)
return _store_file_bytes( return _store_file_bytes(
filename=filename, filename=filename,
file_bytes=file_bytes, file_bytes=file_bytes,
settings=runtime_settings, settings=runtime_settings,
relative_directory=Path("persons") / str(person_id), relative_directory=Path("portraits") / "person",
) )
@@ -312,13 +269,12 @@ def _store_file_bytes(
file_bytes: bytes, file_bytes: bytes,
settings: Settings, settings: Settings,
relative_directory: Path | None = None, relative_directory: Path | None = None,
filename_stem: str | None = None,
) -> Path: ) -> Path:
upload_dir = settings.upload_dir upload_dir = settings.upload_dir
target_dir = upload_dir if relative_directory is None else upload_dir / relative_directory target_dir = upload_dir if relative_directory is None else upload_dir / relative_directory
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename=filename, filename_stem=filename_stem) stored_name = _build_stored_filename(filename)
stored_path = target_dir / stored_name stored_path = target_dir / stored_name
try: try:
@@ -359,8 +315,7 @@ def _validate_upload(*, filename: str, file_bytes: bytes, supported_extensions:
) )
def _build_stored_filename(*, filename: str, filename_stem: str | None = None) -> str: def _build_stored_filename(filename: str) -> str:
safe_name = Path(filename).name safe_name = Path(filename).name
suffix = Path(safe_name).suffix.lower() suffix = Path(safe_name).suffix.lower()
stem = filename_stem or str(uuid4()) return f"{uuid4()}{suffix}"
return f"{stem}{suffix}"
+6 -118
View File
@@ -113,43 +113,10 @@ class TranscriptionService(ServiceBase):
async def delete_source(self, source: Source, *, session: AsyncSession | None = None) -> None: async def delete_source(self, source: Source, *, session: AsyncSession | None = None) -> None:
"""Delete a source page record.""" """Delete a source page record."""
source_file_path = source.file_path
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
await _session.delete(source) await _session.delete(source)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
self._delete_source_file(source_file_path=source_file_path)
async def delete_unlinked_source(self, *, source_id: UUID, session: AsyncSession | None = None) -> None:
"""Delete a source only when no JobSource links exist."""
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.",
)
if source.job_sources:
raise SourceDeleteBlockedError(
"Source delete blocked because it is linked to one or more jobs",
category=ErrorCategory.VALIDATION,
suggestion="Remove JobSource links first, then retry deletion.",
)
source_file_path = source.file_path
await _session.delete(source)
await self._finalize(session=_session, caller_session=session)
self._delete_source_file(source_file_path=source_file_path)
async def list_sources( async def list_sources(
self, self,
*, *,
@@ -269,26 +236,9 @@ class TranscriptionService(ServiceBase):
for job_source in matching_links: for job_source in matching_links:
await _session.delete(job_source) await _session.delete(job_source)
source_file_path = source.file_path
await _session.delete(source) await _session.delete(source)
await self._finalize(session=_session, caller_session=session) await self._finalize(session=_session, caller_session=session)
self._delete_source_file(source_file_path=source_file_path)
def _delete_source_file(self, *, source_file_path: str) -> None:
"""Best-effort cleanup for source media files."""
candidate_path = Path(source_file_path)
resolved_path = candidate_path if candidate_path.is_absolute() else self.settings.upload_dir / candidate_path
if not resolved_path.exists():
return
try:
resolved_path.unlink()
logger.info("Deleted source file: %s", resolved_path)
except OSError:
logger.warning("Failed to delete source file: %s", resolved_path)
async def list_job_sources( async def list_job_sources(
self, self,
*, *,
@@ -342,11 +292,7 @@ class TranscriptionService(ServiceBase):
prompt_name: str = DEFAULT_PROMPT_FILE, prompt_name: str = DEFAULT_PROMPT_FILE,
session: AsyncSession | None = None, session: AsyncSession | None = None,
) -> Job: ) -> Job:
"""Persist transcription output for the first ordered source in a job's document. """Persist original transcription output fields on a job."""
This compatibility helper keeps legacy single-source workflows working.
New multi-source flows should use ``update_job_source_transcription``.
"""
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
job = await _session.get(Job, job_id) job = await _session.get(Job, job_id)
if job is None: if job is None:
@@ -368,72 +314,14 @@ class TranscriptionService(ServiceBase):
) )
source_row = source.first() source_row = source.first()
if source_row is not None: if source_row is not None:
await self.update_job_source_transcription(
job_id=job.id,
source_id=source_row.id,
text=text,
error_detail=error_detail,
provider=provider,
model=model,
prompt_name=prompt_name,
session=_session,
)
await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job
async def update_job_source_transcription(
self,
*,
job_id: UUID,
source_id: UUID,
text: str | None,
error_detail: str | None = None,
provider: str | None = None,
model: str | None = None,
prompt_name: str = DEFAULT_PROMPT_FILE,
session: AsyncSession | None = None,
) -> JobSource:
"""Persist transcription fields for one source within a specific job."""
async with self._session_scope(session) as _session:
job = await _session.get(Job, job_id)
if job is None:
raise TranscriptionNotFoundError(
f"Job with id {job_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the job id and retry.",
)
source = await _session.get(Source, source_id)
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.",
)
if source.document_id != job.document_id:
raise TranscriptionError(
f"Source {source_id} does not belong to job {job_id}",
category=ErrorCategory.VALIDATION,
suggestion="Link the source to the same document as the job and retry.",
)
job.provider = provider or job.provider or self.settings.provider.value
job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
job.date_updated = datetime.now(UTC)
source.raw_transcription = text
existing_job_source = await _session.exec( existing_job_source = await _session.exec(
select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id) select(JobSource).where(JobSource.job_id == job.id).where(JobSource.source_id == source_row.id)
) )
job_source = existing_job_source.first() job_source = existing_job_source.first()
if job_source is None: if job_source is None:
job_source = JobSource( job_source = JobSource(
job_id=job_id, job_id=job.id,
source_id=source_id, source_id=source_row.id,
status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED, status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED,
raw_transcription=text, raw_transcription=text,
error_detail=error_detail, error_detail=error_detail,
@@ -445,8 +333,8 @@ class TranscriptionService(ServiceBase):
job_source.status = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED job_source.status = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
job_source.executed_at = datetime.now(UTC) job_source.executed_at = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(job, source, job_source)) await self._finalize(session=_session, caller_session=session, refresh=(job,))
return job_source return job
async def upsert_revision_for_source( async def upsert_revision_for_source(
self, self,
+25 -141
View File
@@ -6,7 +6,6 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings from ..config import Settings
from ..config import get_settings from ..config import get_settings
from ..db.models import Job from ..db.models import Job
from ..db.models import JobSourceStatus
from ..db.models import JobStatus from ..db.models import JobStatus
from ..db.models import Source from ..db.models import Source
from ..errors import AppError from ..errors import AppError
@@ -70,24 +69,20 @@ async def process_queued_job(
await session.commit() await session.commit()
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)
sources = _resolve_job_sources(source_job) source = _resolve_primary_source(source_job)
if not sources and not source_job.job_sources: if source is None:
candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session) candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session)
sources = list(sorted(candidate_sources, key=lambda item: (item.page_number, item.upload_name.casefold()))) source = next(iter(sorted(candidate_sources, key=lambda item: item.page_number)), None)
if not sources:
return await services.jobs.mark_job_status(job.id, JobStatus.TRANSCRIBED, session=session)
successful_pages: list[tuple[Source, TranscriptionResult]] = []
failed_pages: list[tuple[Source, AppError]] = []
externally_stopped = False
for source in sources:
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
externally_stopped = True
break
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:
result = await asyncio.wait_for( result = await asyncio.wait_for(
transcribe_document_image(source.file_path), transcribe_document_image(source.file_path),
@@ -114,7 +109,15 @@ async def process_queued_job(
) )
_validate_transcription_quality(result=result, settings=runtime_settings) _validate_transcription_quality(result=result, settings=runtime_settings)
successful_pages.append((source, result))
job = await _finalize_transcribed(job=job, services=services, result=result, session=session)
logger.info(
"Job transcribed operation=worker.process_job job_id=%s document_id=%s source_id=%s provider=%s",
job.id,
job.document_id,
source.id,
result.provider,
)
except TimeoutError: except TimeoutError:
error = AppError( error = AppError(
f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s", f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s",
@@ -122,9 +125,9 @@ async def process_queued_job(
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.", suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
retriable=True, retriable=True,
) )
failed_pages.append((source, error)) job = await _finalize_failed(job=job, services=services, error=error, session=session)
logger.error( logger.error(
"Source failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s", "Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
job.id, job.id,
job.document_id, job.document_id,
source.id, source.id,
@@ -138,46 +141,16 @@ async def process_queued_job(
case _: case _:
error = classify_unexpected_error(exc, operation="worker.process_job") error = classify_unexpected_error(exc, operation="worker.process_job")
failed_pages.append((source, error)) job = await _finalize_failed(job=job, services=services, error=error, session=session)
logger.error( logger.error(
"Source failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s", "Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
job.id, job.id,
job.document_id, job.document_id,
source.id, source.id,
error.error_id, error.error_id,
error.category.value, error.category.value,
) )
return job
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
externally_stopped = True
break
terminal_status = JobStatus.TRANSCRIBED
if externally_stopped:
terminal_status = JobStatus.FAILED
elif failed_pages and successful_pages:
terminal_status = JobStatus.PARTIAL_SUCCESS
elif failed_pages and not successful_pages:
terminal_status = JobStatus.FAILED
updated_job = await _finalize_batch_outcome(
job=job,
services=services,
successful_pages=successful_pages,
failed_pages=failed_pages,
status=terminal_status,
session=session,
)
logger.info(
"Job finished operation=worker.process_job job_id=%s document_id=%s status=%s success_pages=%s failed_pages=%s",
updated_job.id,
updated_job.document_id,
updated_job.status.value,
len(successful_pages),
len(failed_pages),
)
return updated_job
async def process_next_queued_job( async def process_next_queued_job(
@@ -334,95 +307,6 @@ def _resolve_primary_source(job: Job) -> Source | None:
return next((job_source.source for job_source in job.job_sources if job_source.source is not None), None) return next((job_source.source for job_source in job.job_sources if job_source.source is not None), None)
def _resolve_job_sources(job: Job) -> list[Source]:
"""Resolve non-transcribed linked sources for a job in deterministic page order."""
if not job.job_sources:
return []
sources = [
job_source.source
for job_source in job.job_sources
if job_source.source is not None and job_source.status != JobSourceStatus.TRANSCRIBED
]
return list(sorted(sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
async def _job_no_longer_processing(
*,
job_id,
services: ServiceBundle,
session: AsyncSession | None = None,
) -> bool:
"""Return True when job status changed externally from PROCESSING."""
latest_job = await services.jobs.read_job(job_id=job_id, session=session)
return latest_job.status != JobStatus.PROCESSING
async def _finalize_batch_outcome(
*,
job: Job,
services: ServiceBundle,
successful_pages: list[tuple[Source, TranscriptionResult]],
failed_pages: list[tuple[Source, AppError]],
status: JobStatus,
session: AsyncSession | None = None,
) -> Job:
"""Transaction B: write per-source outcomes and terminal job status atomically."""
if session is None:
async with services.jobs._session_scope() as local_session:
for source, result in successful_pages:
await services.transcriptions.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text=result.text,
error_detail=None,
provider=result.provider,
model=result.model,
prompt_name=result.prompt_name,
session=local_session,
)
for source, error in failed_pages:
await services.transcriptions.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text=None,
error_detail=format_error_detail(error),
prompt_name=DEFAULT_PROMPT_FILE,
session=local_session,
)
updated_job = await services.jobs.mark_job_status(job.id, status, session=local_session)
await local_session.commit()
return updated_job
for source, result in successful_pages:
await services.transcriptions.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text=result.text,
error_detail=None,
provider=result.provider,
model=result.model,
prompt_name=result.prompt_name,
session=session,
)
for source, error in failed_pages:
await services.transcriptions.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text=None,
error_detail=format_error_detail(error),
prompt_name=DEFAULT_PROMPT_FILE,
session=session,
)
updated_job = await services.jobs.mark_job_status(job.id, status, session=session)
await session.commit()
return updated_job
def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None: def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
text_chars = len(result.text) text_chars = len(result.text)
text_lines = _line_count(result.text) text_lines = _line_count(result.text)
-35
View File
@@ -1,35 +0,0 @@
"""UI page registration exports."""
from fastapi import FastAPI
from nicegui import ui
from transcription.ui.pages.home_page import register_page as register_home_page
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.people_page import register_page as register_people_page
from transcription.ui.pages.sources_page import register_page as register_sources_page
from transcription.ui.pages.upload_page import register_page as register_upload_page
from transcription.ui.resources import read_css
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
def _register_global_styles(app: FastAPI) -> None:
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
return
ui.add_css(read_css("theme.css"), shared=True)
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
_register_global_styles(app)
register_home_page()
register_upload_page()
register_documents_page()
register_people_page()
register_sources_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
+2 -4
View File
@@ -43,7 +43,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 "/homepage" return "/jobs"
return normalized.rstrip("/") or "/" return normalized.rstrip("/") or "/"
@@ -53,9 +53,7 @@ def render_app_shell(*, current_path: str | None = None) -> None:
normalized_path = _normalize_path(current_path) normalized_path = _normalize_path(current_path)
with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"): with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"):
with ui.element("a").props('href="/ui/homepage"').style( with ui.row().classes("app-shell__brand no-wrap"):
"display:flex; align-items:center; gap:0.75rem; text-decoration:none; color:inherit;"
).classes("app-shell__brand no-wrap"):
ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark") ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark")
ui.label("VibeScribe").classes("app-shell__brand-name") ui.label("VibeScribe").classes("app-shell__brand-name")
@@ -0,0 +1,62 @@
"""Documents list and detail page registration."""
from __future__ import annotations
from nicegui import ui
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from .cards import archival_card
from .data_display import archival_badge
from .data_display import metadata_row
from .primitives import render_empty_state
def render_archival_metadata(document: Document, author_link: DocumentPerson | None = None) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
with ui.column().classes("w-full mt-2"):
ui.label("Archival Notes:").classes("ui-text-muted text-xs mb-1")
ui.label(document.notes or "No notes added.").classes("p-2 ui-note-box text-xs")
with archival_card(title="System Logistics"):
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
def render_doc_people_details(document: Document) -> None:
with archival_card(title="Related People"):
if not document.document_people:
render_empty_state("No linked people yet.", italic=True)
else:
with ui.column().classes("w-full gap-2"):
for link in document.document_people:
person_label = link.person.full_name if link.person is not None else "Unknown person"
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
archival_badge(link.role.value)
def render_doc_job_details(document: Document) -> None:
with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"):
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
with ui.row().classes("w-full gap-2 mt-2"):
ui.button(
"View Jobs",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
icon="work_history",
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary text-xs")
@@ -0,0 +1,111 @@
import logging
from uuid import UUID
from nicegui import ui
from nicegui.binding import bindable_dataclass
from sqlmodel.ext.asyncio.session import AsyncSession
from ...db.models import Document
from ...db.models import DocumentPersonRole
from ...services.people import get_name_options
from .cards import archival_card
PROPS = "outlined bg-white"
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
logger = logging.getLogger(__name__)
@bindable_dataclass
class DocumentEditForm:
id: UUID | None = None
name: str | None = None
document_type: str | None = None
document_date: str | None = None
document_date_raw: str | None = None
location_created: str | None = None
archive_identifier: str | None = None
notes: str | None = None
author_id: str | None = None
@classmethod
def from_table_model(cls, model: Document):
existing_author = next(
(item for item in model.document_people if item.role == DocumentPersonRole.AUTHOR),
None,
)
return cls(
id=model.id,
name=model.name,
document_type=model.document_type,
document_date=model.document_date.isoformat() if model.document_date else None,
document_date_raw=model.document_date_raw,
location_created=model.location_created,
archive_identifier=model.archive_identifier,
notes=model.notes,
author_id=str(existing_author.person_id) if existing_author is not None else "",
)
def save(self, session: AsyncSession) -> None:
"""Save the form data to the database."""
doc = session.get(Document, self.id)
if not doc:
logger.error("Document with ID %s not found in the database.", self.id)
return
async def render_document_edit_form(document: Document, session: AsyncSession) -> DocumentEditForm:
edit_form = DocumentEditForm.from_table_model(document)
with archival_card(extra_classes="gap-3"):
(ui.input("Document name").classes("w-full").props("autofocus").props(PROPS).bind_value(edit_form, "name"))
(
ui.input("Document type")
.classes("w-full")
.props("autofocus")
.props(PROPS)
.bind_value(edit_form, "document_type")
)
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
(
ui.input("Exact date (YYYY-MM-DD)")
.props(PROPS)
.props('type="date"')
.bind_value(edit_form, "document_date")
)
(ui.input("Approximate date").bind_value(edit_form, "document_date_raw").props(PROPS))
ui.input("Document location").classes("w-full").props(PROPS).bind_value(edit_form, "location_created")
ui.input("Archive identifier").classes("w-full").props(PROPS).bind_value(edit_form, "archive_identifier")
(
ui.textarea("Notes")
.classes("w-full")
.props(PROPS)
.props("autogrow")
.bind_value(edit_form, "notes")
.props("rows=4")
)
people = await get_name_options(session=session)
author_options = {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} | {
str(person_id): name for person_id, name in people.items()
}
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
(
ui.select(
author_options,
label="Author (Person)",
value=edit_form.author_id or "",
on_change=on_author_change,
)
.classes("w-full")
.props(PROPS)
.bind_value(edit_form, "author_id")
)
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
return edit_form
+19 -29
View File
@@ -4,21 +4,26 @@ import logging
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from nicegui import events, ui from nicegui import events
from nicegui import ui
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _extract_row_id(args: Any) -> str | None: def _extract_row_id(args: Any) -> str | None:
if isinstance(args, list) and len(args) > 1 and isinstance(args[1], dict):
return str(args[1].get("id")) if args[1].get("id") is not None else None
if isinstance(args, dict): if isinstance(args, dict):
row = args.get("row") if isinstance(args.get("row"), dict):
if isinstance(row, dict) and "id" in row: row_id = args["row"].get("id")
return str(row["id"]) return str(row_id) if row_id is not None else None
if "id" in args: row_id = args.get("id")
return str(args["id"]) return str(row_id) if row_id is not None else None
if isinstance(args, list):
for value in args:
if isinstance(value, dict):
row_id = value.get("id")
if row_id is not None:
return str(row_id)
return None return None
@@ -35,6 +40,7 @@ def _bind_row_click_handler(
on_row_click_id(row_id) on_row_click_id(row_id)
table.on("rowClick", handle_row_click) table.on("rowClick", handle_row_click)
logger.debug("Row click handler bound to table")
def build_table( def build_table(
@@ -43,27 +49,15 @@ def build_table(
*, *,
default_sort_by: str | None = None, default_sort_by: str | None = None,
default_descending: bool = False, default_descending: bool = False,
classes: str = "", classes: str = "app-table",
show_search: bool = True,
search_placeholder: str = "Search records...",
on_row_click_id: Callable[[str], None] | None = None, on_row_click_id: Callable[[str], None] | None = None,
) -> Any: ) -> Any:
"""Build a styled Quasar table widget with optional client-side filtering and row-click handlers."""
pagination: dict[str, Any] = {"rowsPerPage": 25} pagination: dict[str, Any] = {"rowsPerPage": 25}
if default_sort_by is not None: if default_sort_by is not None:
pagination["sortBy"] = default_sort_by pagination["sortBy"] = default_sort_by
pagination["descending"] = default_descending pagination["descending"] = default_descending
# Use a parent container to hold both the search bar and the table seamlessly # Quasar props enforce behavior; visual styling is centralized in theme.css.
with ui.column().classes("w-full gap-2"):
if show_search:
with ui.row().classes("w-full items-center justify-end"):
search_input = (
ui.input(placeholder=search_placeholder)
.props("dense outlined clearable icon=search")
.classes("w-64 text-xs bg-white")
)
table = ( table = (
ui.table( ui.table(
rows=rows, rows=rows,
@@ -71,7 +65,7 @@ def build_table(
row_key="id", row_key="id",
pagination=pagination, pagination=pagination,
) )
.classes(f"w-full ui-table {classes}".strip()) .classes(f"w-full ui-table {classes}")
.props( .props(
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" ' 'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
'header-cell-class="ui-table-header text-xs uppercase tracking-wider" ' 'header-cell-class="ui-table-header text-xs uppercase tracking-wider" '
@@ -79,11 +73,7 @@ def build_table(
) )
) )
# Bind client-side text filter if search input is active logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
if show_search:
table.bind_filter_from(search_input, "value")
if on_row_click_id is not None: if on_row_click_id is not None:
_bind_row_click_handler(table, on_row_click_id=on_row_click_id) _bind_row_click_handler(table, on_row_click_id=on_row_click_id)
return table return table
@@ -39,61 +39,21 @@ def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
def render_documents_table(rows: Sequence[DocumentTableRow]) -> None: def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
"""Render documents table with search filtering and custom type chips.""" """Render documents table and open detail page when clicking a row."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No documents in repository yet.") render_empty_state("No documents in repository yet.")
return return
table = build_table( build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{ {"name": "name", "label": "Document Title", "field": "name", "sortable": True, "classes": "font-serif font-semibold"},
"name": "name", {"name": "document_type", "label": "Type", "field": "document_type", "sortable": True},
"label": "Document Title", {"name": "archive_identifier", "label": "Archive Ref", "field": "archive_identifier", "sortable": True, "classes": "font-mono"},
"field": "name", {"name": "created_at", "label": "Created", "field": "created_at", "sortable": True},
"sortable": True,
"classes": "font-serif font-semibold",
},
{
"name": "document_type",
"label": "Type",
"field": "document_type",
"sortable": True,
},
{
"name": "archive_identifier",
"label": "Archive Ref",
"field": "archive_identifier",
"sortable": True,
"classes": "font-mono text-xs",
},
{
"name": "created_at",
"label": "Created",
"field": "created_at",
"sortable": True,
},
], ],
default_sort_by="name", default_sort_by="name",
search_placeholder="Search documents by title, type, or reference...", classes="app-table w-full",
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"), on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
) )
# Render document type using a subtle Quasar badge
table.add_slot(
"body-cell-document_type",
r"""
<q-td :props="props">
<q-chip
dense
square
size="sm"
color="primary"
text-color="white"
>
{{ props.value }}
</q-chip>
</q-td>
""",
)
+13 -68
View File
@@ -4,7 +4,8 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC
from datetime import datetime
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -28,7 +29,7 @@ class JobTableRow:
def _format_timestamp(value: str) -> str: def _format_timestamp(value: str) -> str:
"""Return a friendly local timestamp for table display.""" """Return a friendly UTC timestamp for table display."""
try: try:
parsed = datetime.fromisoformat(value) parsed = datetime.fromisoformat(value)
except ValueError: except ValueError:
@@ -41,7 +42,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
return [ return [
{ {
"id": str(row.id), "id": str(row.id),
"status": row.status.lower(), "status": row.status.upper(),
"filename": row.filename, "filename": row.filename,
"retry_count": row.retry_count, "retry_count": row.retry_count,
"date_created": _format_timestamp(row.date_created), "date_created": _format_timestamp(row.date_created),
@@ -54,80 +55,24 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
def render_jobs_table(rows: Sequence[JobTableRow]) -> None: def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table with search filtering and custom status chips.""" """Render jobs table and open a detail page when clicking a row."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No active or historical processing jobs found.") render_empty_state("No active or historical processing jobs found.")
return return
table = build_table( build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{ {"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"},
"name": "id", {"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold ui-link-primary"},
"label": "Job ID", {"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
"field": "id", {"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
"sortable": True, {"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
"classes": "font-mono text-xs", {"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True},
},
{
"name": "status",
"label": "Status",
"field": "status",
"sortable": True,
"classes": "font-mono",
},
{
"name": "filename",
"label": "Source Filename",
"field": "filename",
"sortable": True,
"classes": "font-mono text-xs",
},
{
"name": "retry_count",
"label": "Retries",
"field": "retry_count",
"sortable": True,
},
{
"name": "date_created",
"label": "Created",
"field": "created_sort",
"sortable": True,
},
{
"name": "date_updated",
"label": "Updated",
"field": "updated_sort",
"sortable": True,
},
], ],
default_sort_by="created_sort", default_sort_by="created_sort",
default_descending=True, default_descending=True,
search_placeholder="Search jobs by ID, filename, or status...", classes="app-table w-full",
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"), on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
) )
# Render job execution status using themed Quasar chips
table.add_slot(
"body-cell-status",
r"""
<q-td :props="props">
<q-chip
dense
square
size="sm"
:color="
props.value === 'completed' || props.value === 'transcribed' ? 'positive' :
props.value === 'failed' ? 'negative' :
props.value === 'processing' ? 'secondary' :
props.value === 'queued' ? 'warning' : 'grey-6'
"
text-color="white"
>
{{ props.value.toUpperCase() }}
</q-chip>
</q-td>
""",
)
@@ -39,56 +39,21 @@ def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]:
def render_people_table(rows: Sequence[PersonTableRow]) -> None: def render_people_table(rows: Sequence[PersonTableRow]) -> None:
"""Render people table with search filtering and custom typography.""" """Render people table and open detail page when clicking a row."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No person records found in repository.") render_empty_state("No person records found in repository.")
return return
table = build_table( build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{ {"name": "full_name", "label": "Full Name", "field": "full_name", "sortable": True, "classes": "font-serif font-semibold"},
"name": "full_name", {"name": "display_name", "label": "Display Name", "field": "display_name", "sortable": True},
"label": "Full Name", {"name": "maiden_name", "label": "Maiden Name", "field": "maiden_name", "sortable": True},
"field": "full_name", {"name": "birth_date", "label": "Birth Date", "field": "birth_date", "sortable": True},
"sortable": True,
"classes": "font-serif font-semibold",
},
{
"name": "display_name",
"label": "Display Name",
"field": "display_name",
"sortable": True,
},
{
"name": "maiden_name",
"label": "Maiden Name",
"field": "maiden_name",
"sortable": True,
},
{
"name": "birth_date",
"label": "Birth Date",
"field": "birth_date",
"sortable": True,
"classes": "font-mono text-xs",
},
], ],
default_sort_by="full_name", default_sort_by="full_name",
search_placeholder="Search people by name or birth date...", classes="app-table w-full",
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"), on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
) )
# Custom column template adding an archival entity icon next to person's name
table.add_slot(
"body-cell-full_name",
r"""
<q-td :props="props">
<div class="row items-center q-gutter-x-xs">
<q-icon name="person" size="xs" color="primary" />
<span class="font-serif font-semibold">{{ props.value }}</span>
</div>
</q-td>
""",
)
@@ -23,9 +23,6 @@ class SourceTableRow:
upload_name: str upload_name: str
filename: str filename: str
document_id: UUID document_id: UUID
document_name: str | None = None
job_source_status: str | None = None
job_source_error_detail: str | None = None
def _serialize_rows(rows: Sequence[SourceTableRow]) -> list[dict[str, Any]]: def _serialize_rows(rows: Sequence[SourceTableRow]) -> list[dict[str, Any]]:
@@ -36,9 +33,6 @@ def _serialize_rows(rows: Sequence[SourceTableRow]) -> list[dict[str, Any]]:
"upload_name": row.upload_name, "upload_name": row.upload_name,
"filename": row.filename, "filename": row.filename,
"document_id": str(row.document_id), "document_id": str(row.document_id),
"document_name": row.document_name or "-",
"job_source_status": row.job_source_status or "-",
"job_source_error_detail": row.job_source_error_detail or "-",
} }
for row in rows for row in rows
] ]
@@ -51,69 +45,15 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
render_empty_state("No source file records found.") render_empty_state("No source file records found.")
return return
table = build_table( build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{ {"name": "page_number", "label": "Page", "field": "page_number", "sortable": True},
"name": "document_name", {"name": "upload_name", "label": "Upload Title", "field": "upload_name", "sortable": True, "classes": "font-serif"},
"label": "Document Name", {"name": "filename", "label": "Stored Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
"field": "document_name", {"name": "document_id", "label": "Document ID", "field": "document_id", "sortable": True, "classes": "font-mono"},
"sortable": True,
"classes": "font-serif",
},
{
"name": "page_number",
"label": "Page Number",
"field": "page_number",
"sortable": True,
},
{
"name": "upload_name",
"label": "Upload Title",
"field": "upload_name",
"sortable": True,
"classes": "font-serif",
},
{
"name": "filename",
"label": "Stored Filename",
"field": "filename",
"sortable": True,
"classes": "font-mono",
},
{
"name": "job_source_status",
"label": "Status",
"field": "job_source_status",
"sortable": True,
"classes": "font-mono",
},
{
"name": "job_source_error_detail",
"label": "Error Detail",
"field": "job_source_error_detail",
"sortable": False,
"classes": "font-mono text-xs truncate max-w-xs vibe-text-muted",
},
], ],
default_sort_by="page_number", default_sort_by="page_number",
classes="app-table w-full",
on_row_click_id=lambda source_id: ui.navigate.to(f"/sources/{source_id}"), on_row_click_id=lambda source_id: ui.navigate.to(f"/sources/{source_id}"),
) )
# Render job execution status using themed Quasar chips
table.add_slot(
"body-cell-job_source_status",
r"""
<q-td :props="props">
<q-chip
dense
square
size="sm"
:color="props.value === 'transcribed' ? 'positive' : props.value === 'failed' ? 'negative' : 'grey-5'"
text-color="white"
>
{{ props.value }}
</q-chip>
</q-td>
""",
)
+17
View File
@@ -0,0 +1,17 @@
from typing import Annotated
from fastapi import Depends
from transcription.db.session import SessionFactory
from transcription.db.session import resolve_session_factory
from ..services import ServiceBundle
type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)]
def _get_service_bundle(session_factory: SessionFactoryDep) -> ServiceBundle:
return ServiceBundle.from_session_factory(session_factory)
type ServicesDep = Annotated[ServiceBundle, Depends(_get_service_bundle)]
-62
View File
@@ -1,62 +0,0 @@
"""File-backed storage helpers for the homepage content."""
from __future__ import annotations
from pathlib import Path
HOME_PAGE_DIR = Path(__file__).resolve().parents[3] / "data" / "homepage"
HOME_PAGE_MARKDOWN_PATH = HOME_PAGE_DIR / "homepage.md"
SUPPORTED_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
def ensure_homepage_storage() -> None:
"""Create the homepage storage directory when needed."""
HOME_PAGE_DIR.mkdir(parents=True, exist_ok=True)
def read_homepage_markdown() -> str:
"""Read the saved homepage markdown text."""
ensure_homepage_storage()
if not HOME_PAGE_MARKDOWN_PATH.exists():
return ""
return HOME_PAGE_MARKDOWN_PATH.read_text(encoding="utf-8")
def save_homepage_markdown(markdown_text: str) -> None:
"""Persist the homepage markdown text."""
ensure_homepage_storage()
HOME_PAGE_MARKDOWN_PATH.write_text(markdown_text, encoding="utf-8")
def store_homepage_image(*, filename: str, file_bytes: bytes) -> Path:
"""Persist an uploaded homepage image in the shared homepage folder."""
ensure_homepage_storage()
safe_name = Path(filename).name
if not safe_name:
msg = "Homepage image filename is required"
raise ValueError(msg)
stored_path = HOME_PAGE_DIR / safe_name
stored_path.write_bytes(file_bytes)
return stored_path
def list_homepage_images() -> list[Path]:
"""List stored homepage images in the order they were last updated."""
ensure_homepage_storage()
image_paths = [
path
for path in HOME_PAGE_DIR.iterdir()
if path.is_file() and path.suffix.lower() in SUPPORTED_IMAGE_SUFFIXES
]
return sorted(image_paths, key=lambda path: (path.stat().st_mtime, path.name))
def latest_homepage_image() -> Path | None:
"""Return the most recently updated homepage image, if one exists."""
image_paths = list_homepage_images()
if not image_paths:
return None
return image_paths[-1]
+23
View File
@@ -0,0 +1,23 @@
from fastapi import FastAPI
from nicegui import ui
from transcription.ui.pages.documents import register_pages as register_documents_pages
from ..theme import register_global_styles
from .jobs_page import register_page as register_jobs_page
from .people_page import register_page as register_people_page
from .sources_page import register_page as register_sources_page
from .upload_page import register_page as register_upload_page
__all__ = ["register_pages"]
def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
register_global_styles(app)
register_upload_page()
register_documents_pages()
register_people_page()
register_sources_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
@@ -0,0 +1,38 @@
from __future__ import annotations
from fastapi import Request
from nicegui import ui
from ...dependency import ServicesDep
from ...dependency import SessionFactoryDep
from .delete_document import render_delete_document_page
from .document_detail import render_document_detail_page
from .document_overview import render_document_overview_page
from .edit_document import render_document_edit_page
from .new_document import render_new_document_page
__all__ = ["register_pages"]
def register_pages() -> None:
"""Register documents list and detail routes."""
@ui.page("/documents")
async def documents_page(services: ServicesDep) -> None:
await render_document_overview_page(services=services)
@ui.page("/documents/new")
async def document_create_page(request: Request, services: ServicesDep) -> None:
await render_new_document_page(request, services=services)
@ui.page("/documents/{document_id}")
async def document_detail_page(document_id: str, services: ServicesDep) -> None:
await render_document_detail_page(document_id, services=services)
@ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, services: ServicesDep, session_factory: SessionFactoryDep) -> None:
await render_document_edit_page(document_id, services=services, session_factory=session_factory)
@ui.page("/documents/{document_id}/delete")
async def document_delete_page(document_id: str, services: ServicesDep) -> None:
await render_delete_document_page(document_id, services=services)
@@ -0,0 +1,101 @@
from __future__ import annotations
from uuid import UUID
from nicegui import ui
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import destructive_button
from transcription.ui.theme import page_header
from ...dependency import ServicesDep
async def render_delete_document_page(document_id: str, services: ServicesDep) -> None:
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await services.documents.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.delete.read")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Document")
with archival_card(extra_classes="gap-2"):
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
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-xs text-red-800 font-bold mt-2"
)
categories: list[str] = []
if has_sources:
categories.append("Sources")
if has_jobs:
categories.append("Jobs")
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs ui-text-muted")
ui.label("Remove related records first, then retry deletion.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
return
ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium")
async def submit_delete() -> None:
try:
await services.documents.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 mt-2"):
destructive_button(
"Delete document permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat"
)
@@ -0,0 +1,90 @@
"""Documents list and detail page registration."""
from __future__ import annotations
from uuid import UUID
from nicegui import ui
from transcription.db.models import DocumentPersonRole
from transcription.services.documents import DocumentError
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.dependency import ServicesDep
from transcription.ui.theme import page_header
from ...components import document_details as details
async def render_document_detail_page(document_id: str, services: ServicesDep) -> None:
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await services.documents.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.read")
return
author_link = next(
(
item
for item in document.document_people
if item.role == DocumentPersonRole.AUTHOR and item.person is not None
),
None,
)
# Main Bento Grid Wrapper
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
# Header Bar
with section_header_row():
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"Edit Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
icon="edit",
).classes("ui-btn-primary text-xs")
destructive_button(
"Delete",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
icon="delete",
extra_classes="text-xs",
)
# High-Density Bento Grid Layout
with ui.grid().classes("w-full grid-cols-12 gap-4"):
# ZONE 1: Source Image Viewer (Cols 1-5)
with ui.column().classes("col-span-12 lg:col-span-5"):
source_path = document.sources[0].file_path if document.sources else None
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
with ui.row().classes("w-full justify-between items-center mt-2"):
ui.button(
"View All Sources",
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description",
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Source",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("ui-btn-primary text-xs")
details.render_archival_metadata(document=document, author_link=author_link)
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
details.render_doc_people_details(document=document)
details.render_doc_job_details(document=document)
@@ -0,0 +1,48 @@
from __future__ import annotations
from nicegui import ui
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.documents import DocumentTableRow
from transcription.ui.components.table.documents import render_documents_table
from transcription.ui.theme import page_header
from ...dependency import ServicesDep
async def render_document_overview_page(services: ServicesDep) -> None:
render_navigation_header(current_path="/documents")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
page_header("Archival Documents")
ui.button(
"Create new document",
on_click=lambda: ui.navigate.to("/documents/new"),
icon="note_add",
).classes("ui-btn-primary")
try:
documents = sorted(
await services.documents.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
# Format documents into read-model rows for the table renderer
rows = [
DocumentTableRow(
id=doc.id,
name=doc.name,
document_type=doc.document_type or "",
archive_identifier=doc.archive_identifier or "",
created_at=doc.created_at.strftime("%b %d, %Y"),
)
for doc in documents
]
render_documents_table(rows)
@@ -0,0 +1,141 @@
from datetime import date
from uuid import UUID
from nicegui import ui
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.services.documents import DocumentError
from ....db.session import SessionFactory
from ...components.app_shell import render_navigation_header
from ...components.document_form import CREATE_NEW_PERSON_OPTION
from ...components.document_form import DocumentEditForm
from ...components.document_form import render_document_edit_form
from ...components.error_presenter import show_error
from ...dependency import ServicesDep
from ...theme import page_header
def _build_updated_document(edit_form: DocumentEditForm, document: Document) -> tuple[Document | None, str | None]:
candidate_name = (edit_form.name or "").strip()
candidate_type = (edit_form.document_type or "").strip()
if not candidate_name:
return None, "Document name is required."
if not candidate_type:
return None, "Document type is required."
parsed_date: date | None = None
candidate_date_text = (edit_form.document_date or "").strip()
if candidate_date_text:
try:
parsed_date = date.fromisoformat(candidate_date_text)
except ValueError:
return None, "Exact date must use YYYY-MM-DD."
return (
Document(
id=document.id,
name=candidate_name,
document_type=candidate_type,
document_date=parsed_date,
document_date_raw=(edit_form.document_date_raw or "").strip() or None,
location_created=(edit_form.location_created or "").strip() or None,
notes=(edit_form.notes or "").strip() or None,
archive_identifier=(edit_form.archive_identifier or "").strip() or None,
created_at=document.created_at,
updated_at=document.updated_at,
),
None,
)
async def _sync_author_links(
services: ServicesDep,
document: Document,
selected_author: str,
) -> None:
existing_author_links = [
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
]
if not selected_author:
for link in existing_author_links:
await services.documents.delete_document_person(link)
return
selected_author_id = UUID(selected_author)
if any(link.person_id == selected_author_id for link in existing_author_links):
return
for link in existing_author_links:
await services.documents.delete_document_person(link)
await services.documents.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=selected_author_id,
role=DocumentPersonRole.AUTHOR,
)
)
async def render_document_edit_page(
document_id: str,
services: ServicesDep,
session_factory: SessionFactory,
) -> None:
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await services.documents.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.edit.read")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Document Record", subtitle="Document name and document type are required.")
edit_form = await render_document_edit_form(document=document, session=session_factory())
async def submit_edit() -> None:
candidate, validation_error = _build_updated_document(edit_form, document)
if validation_error:
ui.notify(validation_error, type="warning")
return
if candidate is None:
ui.notify("Unable to build updated document.", type="warning")
return
try:
await services.documents.update_document(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="documents.edit.save")
return
selected_author = (edit_form.author_id or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
try:
await _sync_author_links(services=services, document=document, selected_author=selected_author)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author update failed", operation="documents.edit.link_author")
return
ui.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat"
)
@@ -0,0 +1,121 @@
from __future__ import annotations
from datetime import date
from uuid import UUID
from fastapi import Request
from nicegui import ui
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.error_presenter import show_error
from transcription.ui.theme import page_header
from ...dependency import ServicesDep
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
async def render_new_document_page(request: Request, services: ServicesDep) -> None:
render_navigation_header(current_path="/documents")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Document", subtitle="Document name is required.")
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name").props("outlined bg-white").classes("w-full")
document_type_input = ui.input(label="Document type").props("outlined bg-white").classes("w-full")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined bg-white type="date"')
date_raw_input = ui.input(label="Approximate date").props("outlined bg-white")
location_input = ui.input(label="Document location").props("outlined bg-white").classes("w-full")
archive_input = ui.input(label="Archive identifier").props("outlined bg-white").classes("w-full")
notes_input = ui.textarea(label="Notes").props("outlined bg-white autogrow").classes("w-full")
people = sorted(await services.documents.list_people(), key=lambda item: item.full_name.casefold())
author_options = {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} | {
str(person.id): person.full_name for person in people
}
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = (
ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change)
.props("outlined bg-white")
.classes("w-full")
)
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
return_to = request.query_params.get("return_to")
async def submit_create() -> None:
candidate_name = (name_input.value or "").strip()
if not candidate_name:
ui.notify("Document name 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(
name=candidate_name,
document_type=(document_type_input.value or "").strip() or None,
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,
)
try:
created = await services.documents.create_document(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="documents.create")
return
selected_author = (author_select.value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
if selected_author:
try:
parsed_person_id = UUID(selected_author)
except ValueError:
ui.notify("Selected author is invalid.", type="warning")
return
try:
await services.documents.create_document_person(
DocumentPerson(
document_id=created.id,
person_id=parsed_person_id,
role=DocumentPersonRole.AUTHOR,
)
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author link failed", operation="documents.create.link_author")
return
ui.notify("Document created", type="positive")
if return_to == "jobs_new":
ui.navigate.to(f"/jobs/new?document_id={created.id}")
return
ui.navigate.to(f"/documents/{created.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save document", on_click=submit_create, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat")
@@ -1,496 +0,0 @@
"""Documents list and detail page registration."""
from __future__ import annotations
from datetime import date
from uuid import UUID
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole
from transcription.errors import ErrorCategory
from transcription.services.documents import (
DocumentDeleteBlockedError,
DocumentError,
DocumentService,
)
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge, metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import (
destructive_button,
render_empty_state,
section_header_row,
)
from transcription.ui.components.table.documents import DocumentTableRow, render_documents_table
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme, page_header
from ...db.session import SessionFactoryDep
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
def register_page() -> None:
"""Register documents list and detail routes."""
@ui.page("/documents/new")
async def document_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Document", subtitle="Document name is required.")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
form = _render_document_form_fields(people=people)
requested_doc_id = request.query_params.get("document_id")
return_to = request.query_params.get("return_to")
async def submit_create() -> None:
candidate_name = (form["name"].value or "").strip()
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
parsed_date = _parse_iso_date(form["date"].value)
if form["date"].value and parsed_date is None:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
candidate = Document(
name=candidate_name,
document_type=(form["type"].value or "").strip() or None,
document_date=parsed_date,
document_date_raw=(form["date_raw"].value or "").strip() or None,
location_created=(form["location"].value or "").strip() or None,
notes=(form["notes"].value or "").strip() or None,
archive_identifier=(form["archive"].value or "").strip() or None,
)
try:
created = await document_service.create_document(candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create failed", operation="documents.create")
return
selected_author = (form["author"].value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
if selected_author:
parsed_author_id = _parse_uuid(selected_author)
if parsed_author_id is None:
ui.notify("Selected author is invalid.", type="warning")
return
try:
await document_service.create_document_person(
DocumentPerson(
document_id=created.id,
person_id=parsed_author_id,
role=DocumentPersonRole.AUTHOR,
)
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author link failed", operation="documents.create.link_author")
return
ui.notify("Document created", type="positive")
if return_to == "jobs_new":
ui.navigate.to(f"/jobs/new?document_id={created.id}")
return
ui.navigate.to(f"/documents/{created.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save document", on_click=submit_create, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat")
@ui.page("/documents")
async def documents_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
page_header("Archival Documents")
ui.button(
"Create new document",
on_click=lambda: ui.navigate.to("/documents/new"),
icon="note_add",
).classes("ui-btn-primary")
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
rows = [
DocumentTableRow(
id=doc.id,
name=doc.name,
document_type=doc.document_type or "",
archive_identifier=doc.archive_identifier or "",
created_at=doc.created_at.strftime("%b %d, %Y"),
)
for doc in documents
]
render_documents_table(rows)
@ui.page("/documents/{document_id}")
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.read")
return
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"Edit Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
icon="edit",
).classes("ui-btn-primary text-xs")
destructive_button(
"Delete",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
icon="delete",
extra_classes="text-xs",
)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
_render_bento_viewer_zone(document)
_render_bento_metadata_zone(document)
_render_bento_relations_zone(document)
@ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.jobs")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with section_header_row():
page_header(f"Jobs for {document.name}")
with ui.row().classes("gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat")
ui.button("Create Job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").classes("ui-btn-primary")
if not document.jobs:
with archival_card(extra_classes="p-6 text-center"):
render_empty_state("No transcription processing jobs created yet.")
return
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with archival_card(extra_classes="p-3"):
with ui.row().classes("w-full items-center justify-between"):
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value)
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary")
ui.button("Open Job", on_click=lambda _=None, jid=job.id: ui.navigate.to(f"/jobs/{jid}"), icon="open_in_new").props("flat dense").classes("text-xs ui-link-primary")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
_ = session_factory
return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
@ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.edit.read")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Document Record", subtitle="Document name and document type are required.")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
existing_author = next((link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR), None)
form = _render_document_form_fields(document=document, people=people, existing_author_id=existing_author.person_id if existing_author else None)
async def submit_edit() -> None:
candidate_name = (form["name"].value or "").strip()
candidate_type = (form["type"].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 = _parse_iso_date(form["date"].value)
if form["date"].value and parsed_date is None:
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=(form["date_raw"].value or "").strip() or None,
location_created=(form["location"].value or "").strip() or None,
notes=(form["notes"].value or "").strip() or None,
archive_identifier=(form["archive"].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
selected_author = (form["author"].value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
existing_author_links = [link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR]
try:
if not selected_author:
for link in existing_author_links:
await document_service.delete_document_person(link)
else:
selected_author_id = UUID(selected_author)
if not any(link.person_id == selected_author_id for link in existing_author_links):
for link in existing_author_links:
await document_service.delete_document_person(link)
await document_service.create_document_person(
DocumentPerson(document_id=document.id, person_id=selected_author_id, role=DocumentPersonRole.AUTHOR)
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Author update failed", operation="documents.edit.link_author")
return
ui.notify("Document updated", type="positive")
ui.navigate.to(f"/documents/{document.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat")
@ui.page("/documents/{document_id}/delete")
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
parsed_doc_id = _parse_uuid(document_id)
if parsed_doc_id is None:
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.delete.read")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Document")
with archival_card(extra_classes="gap-2"):
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
if document.sources or document.jobs:
ui.label("Delete is blocked because related records exist.").classes("text-xs text-red-800 font-bold mt-2")
deps = [cat for cat, present in [("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))] if present]
ui.label(f"Dependencies present: {', '.join(deps)}").classes("text-xs ui-text-muted")
ui.label("Remove related records first, then retry deletion.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").classes("ui-btn-primary text-xs")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props("flat text-xs")
return
ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium")
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 mt-2"):
destructive_button("Delete document permanently", on_click=submit_delete, icon="delete_forever", variant="solid")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props("flat")
# --- Helper Sub-Components ---
def _render_document_form_fields(
*, document: Document | None = None, people: list[Any], existing_author_id: UUID | None = None
) -> dict[str, Any]:
with archival_card(extra_classes="gap-3"):
name_input = ui.input(label="Document name", value=document.name if document else "").props("outlined bg-white").classes("w-full")
type_input = ui.input(label="Document type", value=document.document_type if document and document.document_type else "").props("outlined bg-white").classes("w-full")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
date_input = ui.input(
label="Exact date (YYYY-MM-DD)",
value=document.document_date.isoformat() if document and document.document_date else "",
).props('outlined bg-white type="date"')
date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw if document and document.document_date_raw else "").props("outlined bg-white")
location_input = ui.input(label="Document location", value=document.location_created if document and document.location_created else "").props("outlined bg-white").classes("w-full")
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier if document and document.archive_identifier else "").props("outlined bg-white").classes("w-full")
notes_input = ui.textarea(label="Notes", value=document.notes if document and document.notes else "").props("outlined bg-white autogrow").classes("w-full")
author_options = {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} | {str(p.id): p.full_name for p in people}
author_select = ui.select(author_options, label="Author (Person)", value=str(existing_author_id) if existing_author_id else "").props("outlined bg-white").classes("w-full")
return {
"name": name_input,
"type": type_input,
"date": date_input,
"date_raw": date_raw_input,
"location": location_input,
"archive": archive_input,
"notes": notes_input,
"author": author_select,
}
def _render_bento_viewer_zone(document: Document) -> None:
with ui.column().classes("col-span-12 lg:col-span-5"):
source_path = document.sources[0].file_path if document.sources else None
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
with ui.row().classes("w-full justify-between items-center mt-2"):
ui.button("View All Sources", on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"), icon="description").props("flat dense text-xs").classes("ui-link-primary")
ui.button("+ Add Source", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").classes("ui-btn-primary text-xs")
def _render_bento_metadata_zone(document: Document) -> None:
author_link = next((item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR and item.person is not None), None)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
with ui.column().classes("w-full mt-2"):
ui.label("Archival Notes:").classes("ui-text-muted text-xs mb-1")
ui.label(document.notes or "No notes added.").classes("p-2 ui-note-box text-xs")
with archival_card(title="System Logistics"):
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
def _render_bento_relations_zone(document: Document) -> None:
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
with archival_card(title="Related People"):
if not document.document_people:
render_empty_state("No linked people yet.", italic=True)
else:
with ui.column().classes("w-full gap-2"):
for link in document.document_people:
person_label = link.person.full_name if link.person is not None else "Unknown person"
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
archival_badge(link.role.value)
with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"):
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
with ui.row().classes("w-full gap-2 mt-2"):
ui.button("View Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props("flat dense text-xs").classes("ui-link-primary")
ui.button("+ Add Job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").classes("ui-btn-primary text-xs")
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
def _parse_iso_date(value: str | None) -> date | None:
candidate = (value or "").strip()
if not candidate:
return None
try:
return date.fromisoformat(candidate)
except ValueError:
return None
-110
View File
@@ -1,110 +0,0 @@
"""Homepage registration and handlers."""
from __future__ import annotations
from nicegui import ui
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.homepage_store import latest_homepage_image
from transcription.ui.homepage_store import read_homepage_markdown
from transcription.ui.homepage_store import save_homepage_markdown
from transcription.ui.homepage_store import store_homepage_image
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
def _render_homepage_view(*, markdown_text: str, image_path) -> None:
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(str(image_path) if image_path else None, count_label="Homepage Image")
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"):
if markdown_text:
ui.markdown(markdown_text)
else:
render_empty_state("No homepage text saved yet.")
with ui.column().classes("col-span-12 lg:col-span-3"):
ui.element("div")
def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) -> None:
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Homepage Image"):
ui.upload(on_upload=on_upload, auto_upload=True, label="Upload image").props(
'accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"'
).classes("w-full")
render_image_panel()
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"):
markdown_input[0] = ui.textarea(
label="Homepage markdown",
value=read_homepage_markdown(),
).props("outlined autogrow").classes("w-full")
with ui.column().classes("col-span-12 lg:col-span-3"):
ui.element("div")
def register_page() -> None:
"""Register the homepage routes."""
@ui.page("/homepage", title="VibeScribe Home")
def homepage_page() -> None:
apply_archival_theme()
render_navigation_header(current_path="/homepage")
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header("Home")
ui.button(
"Edit Home Page",
on_click=lambda: ui.navigate.to("/homepage/edit"),
icon="edit",
).classes("ui-btn-primary text-xs")
_render_homepage_view(
markdown_text=read_homepage_markdown().strip(),
image_path=latest_homepage_image(),
)
@ui.page("/homepage/edit", title="Edit Homepage")
def homepage_edit_page() -> None:
apply_archival_theme()
render_navigation_header(current_path="/homepage")
preview_image = [latest_homepage_image()]
markdown_input = [None]
@ui.refreshable
def render_image_panel() -> None:
dark_room_viewer(str(preview_image[0]) if preview_image[0] else None, count_label="Homepage Image")
async def on_upload(event) -> None:
payload = await event.file.read()
preview_image[0] = store_homepage_image(filename=event.file.name, file_bytes=payload)
ui.notify(f"Uploaded {event.file.name}", type="positive")
render_image_panel.refresh()
async def save_homepage() -> None:
save_homepage_markdown((markdown_input[0].value if markdown_input[0] is not None else "") or "")
ui.notify("Homepage saved", type="positive")
ui.navigate.to("/homepage")
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header("Edit Home Page")
with ui.row().classes("items-center gap-2"):
ui.button("Save", on_click=save_homepage, icon="save").classes("ui-btn-primary text-xs")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/homepage"), icon="close").props("flat")
_render_homepage_editor(
render_image_panel=render_image_panel,
markdown_input=markdown_input,
on_upload=on_upload,
)
+166 -293
View File
@@ -8,30 +8,26 @@ from uuid import UUID
from fastapi import Request from fastapi import Request
from nicegui import ui from nicegui import ui
from transcription.db.models import Job, JobSourceStatus, JobStatus from transcription.db.models import JobStatus
from transcription.db.session import session_scope from transcription.db.session import session_scope
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
from transcription.services.jobs import ( from transcription.services.jobs import JobDeleteBlockedError
JobCancelBlockedError, from transcription.services.jobs import JobService
JobDeleteBlockedError,
JobResubmitBlockedError,
JobService,
)
from transcription.services.store import create_job_for_document from transcription.services.store import create_job_for_document
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge, metadata_row from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import ( from transcription.ui.components.primitives import destructive_button
destructive_button, from transcription.ui.components.primitives import render_empty_state
render_empty_state, from transcription.ui.components.primitives import section_header_row
section_header_row, from transcription.ui.components.table.jobs import render_jobs_table
) from transcription.ui.theme import page_header
from transcription.ui.components.table.jobs import JobTableRow, render_jobs_table
from transcription.ui.theme import apply_archival_theme, page_header
from transcription.worker import resolve_worker_notifier from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
from ..components.table.jobs import JobTableRow
def register_page() -> None: # noqa: PLR0915 def register_page() -> None: # noqa: PLR0915
@@ -39,7 +35,7 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs") @ui.page("/jobs")
async def jobs_page(session_factory: SessionFactoryDep) -> None: async def jobs_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory) jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
@@ -71,261 +67,17 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs/new") @ui.page("/jobs/new")
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None: async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
documents_service = DocumentService(session_factory=session_factory) documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Processing Job", subtitle="Queue source files for AI transcription and entity processing.") page_header(
"Create Processing Job", subtitle="Queue source files for AI transcription and entity processing."
)
documents = await documents_service.list_documents() documents = await documents_service.list_documents()
if not documents: if not documents:
_render_no_documents_card()
return
uploaded_files: list[tuple[str, bytes]] = []
with archival_card(extra_classes="gap-3"):
document_options = {str(doc.id): doc.name for doc in documents}
document_select = ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options:
document_select.value = requested_document_id
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
provider_input = ui.input(label="Provider").props("outlined bg-white")
model_input = ui.input(label="Model").props("outlined bg-white")
prompt_input = ui.input(label="Prompt").props("outlined bg-white")
_render_upload_section(uploaded_files)
async def submit_create() -> None:
if not document_select.value:
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(document_select.value))
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 mt-2"):
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes("ui-btn-primary")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
_render_job_detail_header(job)
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
_render_job_logistics(job)
_render_job_document_links(job)
@ui.page("/jobs/{job_id}/cancel")
async def job_cancel_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Cancel Processing Job")
with archival_card(extra_classes="gap-2"):
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
metadata_row("Current Status:", job.status.value)
ui.label("Cancel stops processing and marks remaining non-transcribed sources as failed.").classes(
"text-xs ui-text-muted"
)
async def submit_cancel() -> None:
try:
await jobs_service.cancel_job(job_id=job.id)
except JobCancelBlockedError 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="Cancel job failed", operation="jobs.cancel")
return
resolve_worker_notifier(request.app.state).notify()
ui.notify("Job cancelled", type="positive")
ui.navigate.to(f"/jobs/{job.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button("Cancel job", on_click=submit_cancel, icon="stop_circle", variant="solid")
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}/resubmit")
async def job_resubmit_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
non_transcribed_count = sum(1 for js in job.job_sources if js.status != JobSourceStatus.TRANSCRIBED)
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Resubmit Job")
with archival_card(extra_classes="gap-2"):
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
metadata_row("Current Status:", job.status.value)
metadata_row("Non-Transcribed Sources:", str(non_transcribed_count))
ui.label(
"Resubmit queues all non-transcribed linked sources. New results overwrite prior page-level results."
).classes("text-xs ui-text-muted")
async def submit_resubmit() -> None:
try:
resubmitted_count = await jobs_service.resubmit_non_transcribed_sources(job_id=job.id)
except JobResubmitBlockedError 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="Resubmit failed", operation="jobs.resubmit")
return
resolve_worker_notifier(request.app.state).notify()
ui.notify(f"Resubmitted {resubmitted_count} source(s)", type="positive")
ui.navigate.to(f"/jobs/{job.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Resubmit now", on_click=submit_resubmit, icon="replay").classes("ui-btn-primary")
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
parsed_job_id = _parse_uuid(job_id)
if parsed_job_id is None:
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Processing Job")
with archival_card(extra_classes="gap-2"):
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
if job.status == JobStatus.PROCESSING:
ui.label("Delete is blocked while the job is processing.").classes("text-xs text-red-800 font-bold mt-2")
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").classes(
"ui-btn-primary text-xs"
)
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
return
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
if job.job_sources:
ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs ui-text-muted")
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 mt-2"):
destructive_button("Delete job permanently", on_click=submit_delete, icon="delete_forever", variant="solid")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
# --- Helper Sub-Components ---
def _render_no_documents_card() -> None:
with archival_card(extra_classes="p-6 text-center"): with archival_card(extra_classes="p-6 text-center"):
render_empty_state( render_empty_state(
"No documents available. Create a Document before creating a Job.", "No documents available. Create a Document before creating a Job.",
@@ -337,10 +89,27 @@ def _render_no_documents_card() -> None:
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"), on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add", icon="note_add",
).classes("ui-btn-primary") ).classes("ui-btn-primary")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat") ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props(
"flat"
)
return
uploaded_files: list[tuple[str, bytes]] = []
with archival_card(extra_classes="gap-3"):
document_options = {str(document.id): document.name for document in documents}
document_select = (
ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
)
requested_document_id = request.query_params.get("document_id")
if requested_document_id in document_options:
document_select.value = requested_document_id
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
provider_input = ui.input(label="Provider").props("outlined bg-white")
model_input = ui.input(label="Model").props("outlined bg-white")
prompt_input = ui.input(label="Prompt").props("outlined bg-white")
def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
with archival_card(title="Source Files"): with archival_card(title="Source Files"):
ui.label( ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order." "Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
@@ -377,9 +146,9 @@ def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
) )
with ui.row().classes("w-full justify-end mt-2"): with ui.row().classes("w-full justify-end mt-2"):
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes( ui.button("Clear files", on_click=clear_files, icon="clear_all").props(
"text-xs text-red-800" "flat dense"
) ).classes("text-xs text-red-800")
async def on_upload(event) -> None: async def on_upload(event) -> None:
payload = await event.file.read() payload = await event.file.read()
@@ -395,26 +164,68 @@ def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
render_upload_list() 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
def _render_job_detail_header(job: Job) -> None: 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 mt-2"):
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes(
"ui-btn-primary"
)
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}")
async def job_detail_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-red-800 p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with section_header_row(classes="justify-between items-center"): with section_header_row(classes="justify-between items-center"):
page_header(f"Job Record: {job.id}") page_header(f"Job Record: {job.id}")
with ui.row().classes("items-center gap-2"): with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value.upper()) archival_badge(job.status.value.upper())
if job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}:
destructive_button(
"Cancel",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/cancel"),
icon="stop_circle",
extra_classes="text-xs",
)
if job.status != JobStatus.TRANSCRIBED:
ui.button("Resubmit", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/resubmit"), icon="replay").props(
"outlined"
).classes("text-xs")
destructive_button( destructive_button(
"Delete Job", "Delete Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"), on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"),
@@ -422,8 +233,7 @@ def _render_job_detail_header(job: Job) -> None:
extra_classes="text-xs", extra_classes="text-xs",
) )
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
def _render_job_logistics(job: Job) -> None:
with archival_card(title="Execution Logistics"): with archival_card(title="Execution Logistics"):
metadata_row("Provider:", job.provider or "pending") metadata_row("Provider:", job.provider or "pending")
metadata_row("Model:", job.model or "pending") metadata_row("Model:", job.model or "pending")
@@ -431,8 +241,6 @@ def _render_job_logistics(job: Job) -> None:
metadata_row("Retry Count:", str(job.retry_count)) metadata_row("Retry Count:", str(job.retry_count))
metadata_row("Last Updated:", job.date_updated.isoformat()) metadata_row("Last Updated:", job.date_updated.isoformat())
def _render_job_document_links(job: Job) -> None:
with archival_card(title="Document Links"): with archival_card(title="Document Links"):
ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3") ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3")
with ui.column().classes("w-full gap-2"): with ui.column().classes("w-full gap-2"):
@@ -447,11 +255,76 @@ def _render_job_document_links(job: Job) -> None:
icon="description", icon="description",
).props("flat text-xs").classes("ui-link-primary w-full") ).props("flat text-xs").classes("ui-link-primary w-full")
@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")
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try: try:
return UUID(value) parsed_job_id = UUID(job_id)
except ValueError: except ValueError:
return None ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Processing Job")
with archival_card(extra_classes="gap-2"):
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
if job.status == JobStatus.PROCESSING:
ui.label("Delete is blocked while the job is processing.").classes(
"text-xs text-red-800 font-bold mt-2"
)
ui.label("Wait for processing to complete, then retry delete.").classes(
"text-xs ui-text-muted italic"
)
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
return
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
if job.job_sources:
ui.label("Related JobSource links will be removed as part of delete.").classes(
"text-xs ui-text-muted"
)
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 mt-2"):
destructive_button(
"Delete job permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
+414 -392
View File
@@ -4,404 +4,51 @@ from __future__ import annotations
from datetime import date from datetime import date
from urllib.parse import quote from urllib.parse import quote
from uuid import UUID, uuid4 from uuid import UUID
from fastapi import Request from fastapi import Request
from nicegui import ui from nicegui import ui
from transcription.config import Settings, get_settings from transcription.config import Settings
from transcription.config import get_settings
from transcription.db.models import Person from transcription.db.models import Person
from transcription.errors import ErrorCategory from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentError, DocumentService from transcription.services.documents import DocumentError
from transcription.services.store import UploadError, store_person_portrait from transcription.services.documents import DocumentService
from transcription.services.documents import PersonDeleteBlockedError
from transcription.services.store import UploadError
from transcription.services.store import store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge, metadata_row from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import ( from transcription.ui.components.primitives import destructive_button
destructive_button, from transcription.ui.components.primitives import render_empty_state
render_empty_state, from transcription.ui.components.primitives import section_header_row
section_header_row, from transcription.ui.components.table.people import PersonTableRow
) from transcription.ui.components.table.people import render_people_table
from transcription.ui.components.table.people import PersonTableRow, render_people_table
from transcription.ui.components.viewers import dark_room_viewer from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme, page_header from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
def register_page() -> None: # noqa: PLR0915 def _parse_optional_date(value: str | None, *, label: str) -> date | None:
"""Register people list and CRUD routes.""" candidate = (value or "").strip()
if not candidate:
@ui.page("/people") return None
async def people_page(session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
page_header("Archival Entities: People")
ui.button(
"Create new person",
on_click=lambda: ui.navigate.to("/people/new"),
icon="person_add",
).classes("ui-btn-primary")
try: try:
people = sorted( return date.fromisoformat(candidate)
await people_service.list_people(), except ValueError as exc:
key=lambda item: item.created_at, raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
reverse=True,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.list")
return
rows = [
PersonTableRow(
id=person.id,
full_name=person.full_name,
display_name=person.display_name or "",
maiden_name=person.maiden_name or "",
birth_date=person.birth_date.isoformat() if person.birth_date else "",
)
for person in people
]
render_people_table(rows)
@ui.page("/people/new")
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
draft_person_id = uuid4()
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Person Record", subtitle="Full name is required.")
form = _render_person_form_fields(
request=request,
person_id=draft_person_id,
)
async def submit_create() -> None:
full_name = (form["full_name"].value or "").strip()
if not full_name:
ui.notify("Full name is required.", type="warning")
return
birth_date = _parse_iso_date(form["birth_date"].value)
death_date = _parse_iso_date(form["death_date"].value)
candidate = Person(
id=draft_person_id,
full_name=full_name,
display_name=(form["display_name"].value or "").strip() or None,
maiden_name=(form["maiden_name"].value or "").strip() or None,
birth_date=birth_date,
birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
birth_place=(form["birth_place"].value or "").strip() or None,
death_date=death_date,
death_date_raw=(form["death_date_raw"].value or "").strip() or None,
death_place=(form["death_place"].value or "").strip() or None,
biography=(form["biography"].value or "").strip() or None,
portrait_path=(form["portrait_path"].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 mt-2"):
ui.button("Save person", on_click=submit_create, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}")
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
parsed_person_id = _parse_uuid(person_id)
if parsed_person_id is None:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.read")
return
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"Edit Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
icon="edit",
).classes("ui-btn-primary text-xs")
destructive_button(
"Delete",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
icon="delete",
extra_classes="text-xs",
)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
_render_person_portrait_zone(person)
_render_person_biographical_zone(person)
_render_person_biography_zone(person)
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
parsed_person_id = _parse_uuid(person_id)
if parsed_person_id is None:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.edit.read")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Person Record", subtitle="Full name is required.")
form = _render_person_form_fields(
request=request,
person=person,
person_id=person.id,
)
async def submit_edit() -> None:
full_name = (form["full_name"].value or "").strip()
if not full_name:
ui.notify("Full name is required.", type="warning")
return
birth_date = _parse_iso_date(form["birth_date"].value)
death_date = _parse_iso_date(form["death_date"].value)
candidate = Person(
id=person.id,
full_name=full_name,
display_name=(form["display_name"].value or "").strip() or None,
maiden_name=(form["maiden_name"].value or "").strip() or None,
birth_date=birth_date,
birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
birth_place=(form["birth_place"].value or "").strip() or None,
death_date=death_date,
death_date_raw=(form["death_date_raw"].value or "").strip() or None,
death_place=(form["death_place"].value or "").strip() or None,
biography=(form["biography"].value or "").strip() or None,
portrait_path=(form["portrait_path"].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 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}/delete")
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
parsed_person_id = _parse_uuid(person_id)
if parsed_person_id is None:
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.delete.read")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Person Record")
with archival_card(extra_classes="gap-2"):
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
if person.document_people:
ui.label(
f"This will also remove {len(person.document_people)} linked document relationship(s)."
).classes("text-xs text-red-800 font-bold mt-2")
ui.label("This action permanently deletes the person record.").classes("text-xs text-red-800 font-medium")
async def submit_delete() -> None:
try:
await people_service.delete_person(person)
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 mt-2"):
destructive_button("Delete person permanently", on_click=submit_delete, icon="delete_forever", variant="solid")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
# --- Helper Sub-Components & Form Builders --- def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings) -> None:
def _render_person_form_fields(
*,
request: Request,
person: Person | None = None,
person_id: UUID,
) -> dict[str, Any]:
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name", value=person.full_name if person else "").props("outlined bg-white")
display_name_input = ui.input(label="Display name", value=person.display_name if person and person.display_name else "").props("outlined bg-white")
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name if person and person.maiden_name else "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person and person.birth_date else "",
).props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate)", value=person.birth_date_raw if person and person.birth_date_raw else "").props("outlined bg-white")
birth_place_input = ui.input(label="Birth place", value=person.birth_place if person and person.birth_place else "").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person and person.death_date else "",
).props('outlined bg-white type="date"')
death_date_raw_input = ui.input(label="Death date (approximate)", value=person.death_date_raw if person and person.death_date_raw else "").props("outlined bg-white")
death_place_input = ui.input(label="Death place", value=person.death_place if person and person.death_place else "").props("outlined bg-white")
biography_input = ui.textarea(label="Biography", value=person.biography if person and person.biography else "").props("outlined bg-white autogrow").classes("w-full")
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path if person and person.portrait_path else "").props("outlined bg-white").classes("w-full")
_bind_portrait_file_picker(
portrait_path_input,
settings=_resolve_runtime_settings(request),
person_id=person_id,
)
return {
"full_name": full_name_input,
"display_name": display_name_input,
"maiden_name": maiden_name_input,
"birth_date": birth_date_input,
"birth_date_raw": birth_date_raw_input,
"birth_place": birth_place_input,
"death_date": death_date_input,
"death_date_raw": death_date_raw_input,
"death_place": death_place_input,
"biography": biography_input,
"portrait_path": portrait_path_input,
}
def _render_person_portrait_zone(person: Person) -> None:
portrait_src = _resolve_portrait_src(person.portrait_path)
with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(portrait_src, count_label="Portrait Media")
def _render_person_biographical_zone(person: Person) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biographical Record"):
metadata_row("Full Name:", person.full_name)
metadata_row("Display Name:", person.display_name or "Not set")
metadata_row("Maiden Name:", person.maiden_name or "Not set")
metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set")
metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set")
metadata_row("Birth Place:", person.birth_place or "Not set")
metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set")
metadata_row("Approx. Death Date:", person.death_date_raw or "Not set")
metadata_row("Death Place:", person.death_place or "Not set")
with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
def _render_person_biography_zone(person: Person) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biography"):
ui.label(person.biography or "No biography recorded.").classes("p-2 ui-note-box text-xs w-full")
with archival_card(title="Linked Documents"):
if not person.document_people:
render_empty_state("No linked documents yet.", italic=True)
render_empty_state("Link this person from a Document workflow.")
else:
with ui.column().classes("w-full gap-2"):
for link in person.document_people:
doc = link.document
if doc is None:
continue
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
with ui.column().classes("gap-0"):
ui.label(doc.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"),
icon="open_in_new",
).props("flat dense text-xs").classes("ui-link-primary")
# --- Utilities & Input Binding Helpers ---
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings, person_id: UUID) -> None:
async def on_portrait_selected(event) -> None: async def on_portrait_selected(event) -> None:
payload = await event.file.read() payload = await event.file.read()
try: try:
stored_path = store_person_portrait( stored_path = store_person_portrait(
person_id=person_id,
filename=event.file.name, filename=event.file.name,
file_bytes=payload, file_bytes=payload,
settings=settings, settings=settings,
@@ -426,8 +73,7 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti
auto_upload=True, auto_upload=True,
label="Choose portrait file", label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full") ).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
portrait_dir = settings.upload_dir / "persons" / str(person_id) ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs ui-text-muted")
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-text-muted")
def _resolve_portrait_src(path: str | None) -> str | None: def _resolve_portrait_src(path: str | None) -> str | None:
@@ -437,7 +83,7 @@ def _resolve_portrait_src(path: str | None) -> str | None:
normalized = candidate.replace("\\", "/") normalized = candidate.replace("\\", "/")
lowered = normalized.casefold() lowered = normalized.casefold()
if lowered.startswith(("http://", "https://", "data:")): if lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("data:"):
return normalized return normalized
if normalized.startswith("/"): if normalized.startswith("/"):
return normalized return normalized
@@ -453,20 +99,396 @@ def _resolve_runtime_settings(request: Request) -> Settings:
return get_settings() return get_settings()
def _parse_uuid(value: str | None) -> UUID | None: def register_page() -> None: # noqa: PLR0915
if not value: """Register people list and CRUD routes."""
return None
try:
return UUID(value)
except ValueError:
return None
@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.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
page_header("Archival Entities: People")
ui.button(
"Create new person",
on_click=lambda: ui.navigate.to("/people/new"),
icon="person_add",
).classes("ui-btn-primary")
def _parse_iso_date(value: str | None) -> date | None:
candidate = (value or "").strip()
if not candidate:
return None
try: try:
return date.fromisoformat(candidate) 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
# Format person records into read-model rows for the table renderer
rows = [
PersonTableRow(
id=person.id,
full_name=person.full_name,
display_name=person.display_name or "",
maiden_name=person.maiden_name or "",
birth_date=person.birth_date.isoformat() if person.birth_date else "",
)
for person in people
]
render_people_table(rows)
@ui.page("/people/new")
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Create Person Record", subtitle="Full name is required.")
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name").props("outlined bg-white")
display_name_input = ui.input(label="Display name").props("outlined bg-white")
maiden_name_input = ui.input(label="Maiden name").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(label="Birth date (approximate)").props("outlined bg-white")
birth_place_input = ui.input(label="Birth place").props("outlined bg-white")
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined bg-white type="date"')
death_date_raw_input = ui.input(label="Death date (approximate)").props("outlined bg-white")
death_place_input = ui.input(label="Death place").props("outlined bg-white")
biography_input = ui.textarea(label="Biography").props("outlined bg-white autogrow").classes("w-full")
portrait_path_input = ui.input(label="Portrait path").props("outlined bg-white").classes("w-full")
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
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 mt-2"):
ui.button("Save person", on_click=submit_create, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
@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: except ValueError:
return None ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.read")
return
portrait_src = _resolve_portrait_src(person.portrait_path)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"Edit Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
icon="edit",
).classes("ui-btn-primary text-xs")
destructive_button(
"Delete",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
icon="delete",
extra_classes="text-xs",
)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(portrait_src, count_label="Portrait Media")
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biographical Record"):
metadata_row("Full Name:", person.full_name)
metadata_row("Display Name:", person.display_name or "Not set")
metadata_row("Maiden Name:", person.maiden_name or "Not set")
metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set")
metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set")
metadata_row("Birth Place:", person.birth_place or "Not set")
metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set")
metadata_row("Approx. Death Date:", person.death_date_raw or "Not set")
metadata_row("Death Place:", person.death_place or "Not set")
with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biography"):
ui.label(person.biography or "No biography recorded.").classes("p-2 ui-note-box text-xs w-full")
with archival_card(title="Linked Documents"):
if not person.document_people:
render_empty_state("No linked documents yet.", italic=True)
render_empty_state("Link this person from a Document workflow.")
else:
with ui.column().classes("w-full gap-2"):
for link in person.document_people:
document = link.document
if document is None:
continue
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
with ui.column().classes("gap-0"):
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
f"/documents/{doc_id}"
),
icon="open_in_new",
).props("flat dense text-xs").classes("ui-link-primary")
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, 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-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.edit.read")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
page_header("Edit Person Record", subtitle="Full name is required.")
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined bg-white")
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
"outlined bg-white"
)
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props(
"outlined bg-white"
)
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(
label="Birth date (YYYY-MM-DD)",
value=person.birth_date.isoformat() if person.birth_date else "",
).props('outlined bg-white type="date"')
birth_date_raw_input = ui.input(
label="Birth date (approximate)", value=person.birth_date_raw or ""
).props("outlined bg-white")
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props(
"outlined bg-white"
)
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(
label="Death date (YYYY-MM-DD)",
value=person.death_date.isoformat() if person.death_date else "",
).props('outlined bg-white type="date"')
death_date_raw_input = ui.input(
label="Death date (approximate)", value=person.death_date_raw or ""
).props("outlined bg-white")
death_place_input = ui.input(label="Death place", value=person.death_place or "").props(
"outlined bg-white"
)
biography_input = (
ui.textarea(label="Biography", value=person.biography or "")
.props("outlined bg-white autogrow")
.classes("w-full")
)
portrait_path_input = (
ui.input(label="Portrait path", value=person.portrait_path or "")
.props("outlined bg-white")
.classes("w-full")
)
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
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 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
"flat"
)
@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-red-800 p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except DocumentError:
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.delete.read")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Person Record")
with archival_card(extra_classes="gap-2"):
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
if person.document_people:
ui.label("Delete is blocked because linked documents exist.").classes(
"text-xs text-red-800 font-bold mt-2"
)
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs ui-text-muted")
ui.label("Remove document links first, then retry deletion.").classes(
"text-xs ui-text-muted italic"
)
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
ui.button(
"Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description"
).props("flat text-xs")
return
ui.label("This action permanently deletes the person record.").classes(
"text-xs text-red-800 font-medium"
)
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 mt-2"):
destructive_button(
"Delete person permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
"flat"
)
+246 -145
View File
@@ -1,158 +1,259 @@
"""Sources UI page module for VibeScribe.""" """Sources list and detail page registration."""
from __future__ import annotations from __future__ import annotations
from typing import Optional from urllib.parse import urlencode
from uuid import UUID from uuid import UUID
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui from nicegui import ui
from sqlalchemy.orm import selectinload
from sqlmodel import select
from transcription.db import session_scope
from transcription.db.models import Source from transcription.db.models import Source
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.transcription import TranscriptionNotFoundError
from transcription.services.transcription import TranscriptionService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.sources import SourceTableRow
from transcription.ui.components.table.sources import render_sources_table
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
def _render_header_nav(current_path: str = "/ui/sources") -> None:
"""Render inline header navigation bar."""
with ui.row().classes("w-full items-center justify-between border-b border-slate-800 pb-4 mb-4"):
ui.label("VibeScribe").classes("text-xl font-bold text-amber-500 tracking-wider")
with ui.row().classes("gap-4"):
ui.link("Documents", "/ui/documents").classes("text-slate-300 hover:text-amber-400 font-medium")
ui.link("Jobs", "/ui/jobs").classes("text-slate-300 hover:text-amber-400 font-medium")
ui.link("Sources", "/ui/sources").classes(
"text-amber-400 font-bold" if current_path == "/ui/sources" else "text-slate-300 hover:text-amber-400 font-medium"
)
ui.link("People", "/ui/people").classes("text-slate-300 hover:text-amber-400 font-medium")
ui.link("Upload", "/ui/upload").classes("text-slate-300 hover:text-amber-400 font-medium")
async def sources_page(
document_id: Optional[str] = None,
job_id: Optional[str] = None,
) -> None:
"""Render the master or context-filtered sources page."""
parsed_doc_id: Optional[UUID] = None
parsed_job_id: Optional[UUID] = None
if document_id:
try:
parsed_doc_id = UUID(document_id)
except ValueError:
pass
if job_id:
try:
parsed_job_id = UUID(job_id)
except ValueError:
pass
async with session_scope() as session:
statement = select(Source).options(
selectinload(Source.document),
selectinload(Source.job_sources),
)
if parsed_doc_id:
statement = statement.where(Source.document_id == parsed_doc_id)
result = await session.exec(statement)
sources = list(result.all())
if parsed_job_id:
sources = [
src for src in sources if any(js.job_id == parsed_job_id for js in src.job_sources)
]
header_title = "Source Asset Records"
if parsed_doc_id:
header_title = "Sources for Document"
elif parsed_job_id:
header_title = "Sources for Job"
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
_render_header_nav(current_path="/ui/sources")
with ui.row().classes("w-full items-center justify-between mb-2"):
with ui.column().classes("gap-1"):
ui.label(header_title).classes("text-2xl font-bold text-slate-100")
ui.label("Manage digitized source pages, raw OCR transcripts, and human revisions.").classes(
"text-sm text-slate-400"
)
if not sources:
with archival_card(title="No Source Assets Found"):
ui.label("No source images or pages match the current filter criteria.").classes(
"text-sm text-slate-400 mb-4"
)
ui.button("Upload New Documents", on_click=lambda: ui.navigate.to("/ui/upload")).props("color=amber-6")
return
columns = [
{"name": "upload_name", "label": "Upload Name", "field": "upload_name", "align": "left", "sortable": True},
{"name": "document_name", "label": "Document Context", "field": "document_name", "align": "left", "sortable": True},
{"name": "page_number", "label": "Page #", "field": "page_number", "align": "center", "sortable": True},
{"name": "status", "label": "Latest Status", "field": "status", "align": "center", "sortable": True},
{"name": "revised", "label": "Human Revised", "field": "revised", "align": "center", "sortable": True},
{"name": "actions", "label": "Actions", "field": "actions", "align": "right"},
]
rows = [
{
"id": str(source.id),
"upload_name": source.upload_name,
"document_name": source.document_name or "Unlinked Document",
"page_number": source.page_number,
"status": source.latest_status.value if source.latest_status else "Unprocessed",
"revised": "Yes" if source.revised_text else "No",
"error_detail": source.latest_error_detail,
}
for source in sources
]
with archival_card(extra_classes="p-0 overflow-hidden"):
table = ui.table(columns=columns, rows=rows, row_key="id").classes("w-full bg-transparent text-slate-200")
table.add_slot(
"body-cell-actions",
r"""
<q-td :props="props">
<q-btn flat round dense icon="visibility" color="amber-6" @click="() => $parent.$emit('view_source', props.row.id)" />
<q-btn flat round dense icon="delete" color="red-5" @click="() => $parent.$emit('delete_source', props.row.id)" />
</q-td>
""",
)
table.on("view_source", lambda e: ui.navigate.to(f"/ui/sources/{e.args}"))
table.on("delete_source", lambda e: ui.navigate.to(f"/ui/sources/{e.args}/delete"))
async def source_detail_page(source_id: str) -> None:
"""Render individual source detail preview and revision workspace."""
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
_render_header_nav(current_path="/ui/sources")
with archival_card(title=f"Source Record: {source_id}"):
ui.label("Source Preview & Revision Workspace").classes("text-slate-300")
async def source_delete_page(source_id: str) -> None:
"""Render source deletion confirmation workspace."""
async with session_scope() as session:
source = await session.get(Source, UUID(source_id))
is_linked = bool(source and source.job_sources)
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
_render_header_nav(current_path="/ui/sources")
with archival_card(title="Delete Source Confirmation"):
if is_linked:
ui.label("Cannot delete source linked to active jobs.").classes("text-red-400")
else:
ui.label(f"Are you sure you want to delete source {source_id}?").classes("text-slate-300")
def register_page() -> None: def register_page() -> None:
"""Register all source-related UI routes with the application router.""" """Register source list and detail routes."""
ui.page("/sources")(sources_page)
ui.page("/sources/{source_id}")(source_detail_page) @ui.page("/sources")
ui.page("/sources/{source_id}/delete")(source_delete_page) async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
sources_service = TranscriptionService(session_factory=session_factory)
jobs_service = JobService(session_factory=session_factory)
documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
document_id_text = request.query_params.get("document_id")
job_id_text = request.query_params.get("job_id")
document_id = _parse_uuid(document_id_text)
job_id = _parse_uuid(job_id_text)
document_name = None
job_label = None
back_path = None
sources: list[Source] = []
try:
if document_id is not None:
document = await documents_service.read_document_detail(document_id=document_id)
document_name = document.name
back_path = f"/documents/{document.id}"
sources = sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
elif job_id is not None:
job = await jobs_service.read_job(job_id=job_id)
job_label = str(job.id)
back_path = f"/jobs/{job.id}"
job_sources = await sources_service.list_job_sources(job_id=job.id)
sources = [job_source.source for job_source in job_sources if job_source.source is not None]
sources.sort(key=lambda item: (item.page_number, item.upload_name.casefold()))
else:
sources = sorted(
await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)
)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.list")
return
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
if document_name is not None:
header_title = f"Sources: {document_name}"
elif job_label is not None:
header_title = f"Sources for Job {job_label}"
else:
header_title = "Archival Source Media"
page_header(header_title)
if back_path is not None:
back_label = "Back to Document" if document_id is not None else "Back to Job"
ui.button(
back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back"
).classes("ui-btn-primary text-xs")
# Format source records into read-model rows for the table renderer
rows = [
SourceTableRow(
id=source.id,
page_number=source.page_number,
upload_name=source.upload_name,
filename=source.filename,
document_id=source.document_id,
)
for source in sources
]
render_sources_table(rows)
@ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
try:
parsed_source_id = UUID(source_id)
except ValueError:
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
return
try:
source = await sources_service.read_source_detail(source_id=parsed_source_id)
except TranscriptionNotFoundError:
ui.label("Source not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.read")
return
back_path = _back_path_from_query(request.query_params)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header(
f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}"
)
if back_path is not None:
back_label = (
"Back to Document"
if "document_id" in request.query_params
else "Back to Job"
if "job_id" in request.query_params
else "Back to Sources"
)
ui.button(
back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back"
).classes("ui-btn-primary text-xs")
else:
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
"flat text-xs"
)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-7 gap-4"):
with archival_card(title="Source Inspection Viewer", extra_classes="p-2"):
render_document_panzoom(source=source)
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"):
with archival_card(title="Source Metadata"):
metadata_row("Page Number:", str(source.page_number))
metadata_row("Upload Name:", source.upload_name)
metadata_row("Stored Filename:", source.filename)
metadata_row("Document ID:", str(source.document_id))
metadata_row("Date Uploaded:", source.date_uploaded.isoformat())
metadata_row(
"Date Revised:",
source.date_revised.isoformat() if source.date_revised else "Not revised",
)
with archival_card(title="Automated Raw Transcription"):
ui.textarea(value=_source_transcription_text(source) or "").props(
"outlined autogrow readonly bg-white"
).classes("w-full text-xs font-mono")
with archival_card(title="Curated Human Transcription"):
revision_input = (
ui.textarea(value=source.revised_text or "")
.props("outlined autogrow bg-white")
.classes("w-full text-xs")
)
async def save_revision() -> None:
candidate = (revision_input.value or "").strip()
if not candidate:
ui.notify("Revision text is required.", type="warning")
return
try:
await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="sources.save_revision")
return
ui.notify("Revision saved", type="positive")
ui.navigate.to(request.url.path + _back_query(request.query_params))
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save Revision", on_click=save_revision, icon="save").classes(
"ui-btn-primary text-xs"
)
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str) -> RedirectResponse:
return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
@ui.page("/jobs/{job_id}/sources")
async def job_sources_page(job_id: str) -> RedirectResponse:
return RedirectResponse(url=f"/ui/sources?job_id={job_id}")
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
def _build_filter_query(*, document_id: UUID | None, job_id: UUID | None) -> str:
params: dict[str, str] = {}
if document_id is not None:
params["document_id"] = str(document_id)
if job_id is not None:
params["job_id"] = str(job_id)
return f"?{urlencode(params)}" if params else ""
def _source_detail_path(*, source_id: UUID, document_id: UUID | None, job_id: UUID | None) -> str:
return f"/sources/{source_id}{_build_filter_query(document_id=document_id, job_id=job_id)}"
def _back_query(query_params) -> str:
params = {}
for key in ("document_id", "job_id"):
if query_params.get(key):
params[key] = query_params.get(key)
return f"?{urlencode(params)}" if params else ""
def _back_path_from_query(query_params) -> str | None:
document_id = query_params.get("document_id")
if document_id:
return f"/documents/{document_id}"
job_id = query_params.get("job_id")
if job_id:
return f"/jobs/{job_id}"
return None
def _source_transcription_text(source: Source) -> str | None:
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.raw_transcription:
return job_source.raw_transcription
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.error_detail:
return job_source.error_detail
return None
+7 -15
View File
@@ -229,34 +229,26 @@ input:focus-visible,
font-size: 0.75rem; font-size: 0.75rem;
} }
/* Table Archival Theme Bridge */
.ui-table { .ui-table {
border: 1px solid var(--theme-border); border: 1px solid var(--theme-border);
color: var(--theme-text); color: var(--theme-text);
background: var(--theme-surface-raised); background: var(--theme-surface-raised);
border-radius: 0.125rem; border-radius: 0.125rem;
overflow: hidden;
} }
.ui-table .q-table { .ui-table .q-table tbody tr:hover {
color: var(--theme-text); background: var(--theme-surface) !important;
} cursor: pointer;
.ui-table .q-table th,
.ui-table-header {
color: var(--theme-inverse-text) !important;
background-color: var(--theme-primary) !important;
font-weight: 700;
} }
.ui-table .q-table td { .ui-table .q-table td {
border-bottom: 1px solid var(--theme-border) !important; border-bottom: 1px solid var(--theme-border) !important;
color: var(--theme-text);
} }
.ui-table .q-table tbody tr:hover { .ui-table-header {
background-color: var(--theme-surface) !important; color: var(--theme-inverse-text);
cursor: pointer; background: var(--theme-primary);
font-weight: 700;
} }
.ui-table-body { .ui-table-body {
+18 -8
View File
@@ -1,5 +1,12 @@
from fastapi import FastAPI
from nicegui import app as nicegui_app
from nicegui import ui from nicegui import ui
from .resources import read_css
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
# Runtime bridge for Quasar color slots. Visual ownership remains in theme.css tokens/classes. # Runtime bridge for Quasar color slots. Visual ownership remains in theme.css tokens/classes.
THEME_COLORS = { THEME_COLORS = {
"primary": "#5e6572", "primary": "#5e6572",
@@ -12,17 +19,20 @@ THEME_COLORS = {
"warning": "#a9b4c2", "warning": "#a9b4c2",
} }
_THEME_APPLIED = False
def register_global_styles(app: FastAPI) -> None:
def apply_archival_theme() -> None: if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
"""Apply runtime color slots once; visual styling is defined in theme.css."""
global _THEME_APPLIED
if _THEME_APPLIED:
return return
ui.colors(**THEME_COLORS) theme_css = read_css("theme.css")
_THEME_APPLIED = True try:
ui.add_css(theme_css, shared=True)
except RuntimeError:
# NiceGUI can retain stale slot state across test app factories.
ui.add_head_html(f"<style>{theme_css}</style>", shared=True)
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
nicegui_app.colors(**THEME_COLORS)
def page_header(title: str, subtitle: str | None = None) -> None: def page_header(title: str, subtitle: str | None = None) -> None:
+1 -8
View File
@@ -152,15 +152,8 @@ async def run_worker_loop(
wake_event.clear() wake_event.clear()
processed_any = False processed_any = False
while True: while await process_next_queued_job(session_factory=session_factory):
with handle_worker_exceptions(operation="worker.process_next_queued_job"):
processed = await process_next_queued_job(session_factory=session_factory)
if not processed:
break
processed_any = True processed_any = True
continue
break
if wake_event is None and not processed_any: if wake_event is None and not processed_any:
await asyncio.sleep(poll_interval_seconds) await asyncio.sleep(poll_interval_seconds)
+8 -231
View File
@@ -1,49 +1,25 @@
"""Integration tests for end-to-end upload and worker pipeline behavior.""" """Integration tests for end-to-end upload and worker pipeline behavior."""
from pathlib import Path from pathlib import Path
from uuid import uuid4
import pytest import pytest
from transcription.config import Settings from transcription.config import Settings
from transcription.db.models import Document
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.providers.base import TranscriptionResult from transcription.providers.base import TranscriptionResult
from transcription.services import ServiceBundle from transcription.services import ServiceBundle
from transcription.services.store import create_job_for_document
from transcription.services.store import create_upload_job from transcription.services.store import create_upload_job
from transcription.services.workflows import advance_job from transcription.services.workflows import advance_job
def _build_services(default_session_factory) -> ServiceBundle:
services = ServiceBundle()
object.__setattr__(
services,
"documents",
services.documents.__class__(session_factory=default_session_factory),
)
object.__setattr__(
services,
"jobs",
services.jobs.__class__(session_factory=default_session_factory),
)
object.__setattr__(
services,
"transcriptions",
services.transcriptions.__class__(session_factory=default_session_factory),
)
return services
@pytest.mark.integration @pytest.mark.integration
class TestPipelineSuccessFlow: class TestPipelineSuccessFlow:
"""Verify end-to-end success lifecycle behavior.""" """Verify end-to-end success lifecycle behavior."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_then_worker_persists_transcribed_terminal_state( async def test_upload_then_worker_persists_transcribed_terminal_state(
self, async_session, default_session_factory, tmp_path: Path, monkeypatch self, async_session, tmp_path: Path, monkeypatch
): ):
"""Upload followed by worker processing persists job transcription and transcribed status.""" """Upload followed by worker processing persists job transcription and transcribed status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path) settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
@@ -83,12 +59,12 @@ class TestPipelineSuccessFlow:
_fake_transcribe_document_image, _fake_transcribe_document_image,
) )
services = _build_services(default_session_factory) services = ServiceBundle()
queued_job = await services.jobs.read_job(job_id=upload_result.job_id, session=async_session) queued_job = await services.jobs.read_next_queued_job(session=async_session)
processed = queued_job is not None processed = queued_job is not None
if queued_job is not None: if queued_job is not None:
await advance_job(job=queued_job, services=services, session=async_session) await advance_job(job=queued_job, services=services, session=async_session)
job = await services.jobs.read_job(job_id=upload_result.job_id, session=async_session) job = await async_session.get(Job, upload_result.job_id)
assert processed is True assert processed is True
assert job is not None assert job is not None
@@ -96,212 +72,13 @@ class TestPipelineSuccessFlow:
assert any(job_source.raw_transcription == "Pipeline transcript" for job_source in job.job_sources) assert any(job_source.raw_transcription == "Pipeline transcript" for job_source in job.job_sources)
assert all(job_source.error_detail is None for job_source in job.job_sources) assert all(job_source.error_detail is None for job_source in job.job_sources)
@pytest.mark.asyncio
async def test_worker_transcribes_all_sources_for_multi_page_job(
self,
async_session,
default_session_factory,
tmp_path: Path,
monkeypatch,
):
"""Worker stores transcription output for every source linked to the queued job."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
document = Document(id=uuid4(), name="multi-page-document")
async_session.add(document)
await async_session.commit()
create_result = await create_job_for_document(
document_id=document.id,
uploads=[
("page-01.jpg", b"one"),
("page-02.jpg", b"two"),
("page-03.jpg", b"three"),
],
session=async_session,
settings=settings,
)
async def _fake_transcribe_document_image(
image_path,
*,
prompt_name="transcribe_document.md",
settings=None,
provider=None,
) -> TranscriptionResult:
page_name = Path(image_path).name
_ = (prompt_name, settings, provider)
return TranscriptionResult(
text=f"Transcript for {page_name}",
provider="openrouter",
model="test-model",
prompt_name="transcribe_document.md",
)
monkeypatch.setattr(
"transcription.services.workflows.transcribe_document_image",
_fake_transcribe_document_image,
)
services = _build_services(default_session_factory)
queued_job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert queued_job is not None
await advance_job(job=queued_job, services=services, session=async_session)
job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert job.status == JobStatus.TRANSCRIBED
assert len(job.job_sources) == 3
assert all(job_source.status == JobSourceStatus.TRANSCRIBED for job_source in job.job_sources)
assert all(job_source.raw_transcription for job_source in job.job_sources)
assert all(job_source.source is not None and job_source.source.raw_transcription for job_source in job.job_sources)
@pytest.mark.asyncio
async def test_worker_marks_partial_success_when_some_sources_fail(
self,
async_session,
default_session_factory,
tmp_path: Path,
monkeypatch,
):
"""Mixed page outcomes produce PARTIAL_SUCCESS and preserve per-source status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
document = Document(id=uuid4(), name="partial-page-document")
async_session.add(document)
await async_session.commit()
create_result = await create_job_for_document(
document_id=document.id,
uploads=[
("page-01.jpg", b"one"),
("page-02.jpg", b"two"),
],
session=async_session,
settings=settings,
)
call_count = 0
async def _fake_transcribe_document_image(
image_path,
*,
prompt_name="transcribe_document.md",
settings=None,
provider=None,
) -> TranscriptionResult:
nonlocal call_count
call_count += 1
_ = (prompt_name, settings, provider)
if call_count == 2:
raise RuntimeError("simulated page failure")
return TranscriptionResult(
text="Transcript for first page",
provider="openrouter",
model="test-model",
prompt_name="transcribe_document.md",
)
monkeypatch.setattr(
"transcription.services.workflows.transcribe_document_image",
_fake_transcribe_document_image,
)
services = _build_services(default_session_factory)
queued_job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert queued_job is not None
await advance_job(job=queued_job, services=services, session=async_session)
job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert job.status == JobStatus.PARTIAL_SUCCESS
assert len(job.job_sources) == 2
statuses = {job_source.status for job_source in job.job_sources}
assert statuses == {JobSourceStatus.TRANSCRIBED, JobSourceStatus.FAILED}
assert any(job_source.error_detail is not None for job_source in job.job_sources)
@pytest.mark.asyncio
async def test_worker_skips_already_transcribed_sources_on_resubmit(
self,
async_session,
default_session_factory,
tmp_path: Path,
monkeypatch,
):
"""Queued jobs only process non-transcribed JobSource records."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
document = Document(id=uuid4(), name="resubmit-filter-document")
async_session.add(document)
await async_session.commit()
create_result = await create_job_for_document(
document_id=document.id,
uploads=[
("page-01.jpg", b"one"),
("page-02.jpg", b"two"),
],
session=async_session,
settings=settings,
)
services = _build_services(default_session_factory)
job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
page_one = next(js for js in job.job_sources if js.source is not None and js.source.page_number == 1)
page_two = next(js for js in job.job_sources if js.source is not None and js.source.page_number == 2)
page_one.status = JobSourceStatus.TRANSCRIBED
page_one.raw_transcription = "existing transcript"
page_two.status = JobSourceStatus.PENDING
page_two.raw_transcription = None
await services.transcriptions.update_job_source(job_source=page_one, session=async_session)
await services.transcriptions.update_job_source(job_source=page_two, session=async_session)
await services.jobs.update_job_state(job_id=job.id, status=JobStatus.QUEUED, session=async_session)
await async_session.commit()
call_count = 0
async def _fake_transcribe_document_image(
image_path,
*,
prompt_name="transcribe_document.md",
settings=None,
provider=None,
) -> TranscriptionResult:
nonlocal call_count
_ = (image_path, prompt_name, settings, provider)
call_count += 1
return TranscriptionResult(
text="new transcript",
provider="openrouter",
model="test-model",
prompt_name="transcribe_document.md",
)
monkeypatch.setattr(
"transcription.services.workflows.transcribe_document_image",
_fake_transcribe_document_image,
)
queued_job = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert queued_job is not None
await advance_job(job=queued_job, services=services, session=async_session)
refreshed = await services.jobs.read_job(job_id=create_result.job_id, session=async_session)
assert call_count == 1
statuses = {js.status for js in refreshed.job_sources}
assert statuses == {JobSourceStatus.TRANSCRIBED}
@pytest.mark.integration @pytest.mark.integration
class TestPipelineFailureFlow: class TestPipelineFailureFlow:
"""Verify end-to-end failure lifecycle behavior.""" """Verify end-to-end failure lifecycle behavior."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_then_worker_persists_failed_terminal_state( async def test_upload_then_worker_persists_failed_terminal_state(self, async_session, tmp_path: Path, monkeypatch):
self,
async_session,
default_session_factory,
tmp_path: Path,
monkeypatch,
):
"""Upload followed by worker processing persists error detail and failed status on the job.""" """Upload followed by worker processing persists error detail and failed status on the job."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path) settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
upload_result = await create_upload_job( upload_result = await create_upload_job(
@@ -326,12 +103,12 @@ class TestPipelineFailureFlow:
_fake_transcribe_document_image, _fake_transcribe_document_image,
) )
services = _build_services(default_session_factory) services = ServiceBundle()
queued_job = await services.jobs.read_job(job_id=upload_result.job_id, session=async_session) queued_job = await services.jobs.read_next_queued_job(session=async_session)
processed = queued_job is not None processed = queued_job is not None
if queued_job is not None: if queued_job is not None:
await advance_job(job=queued_job, services=services, session=async_session) await advance_job(job=queued_job, services=services, session=async_session)
job = await services.jobs.read_job(job_id=upload_result.job_id, session=async_session) job = await async_session.get(Job, upload_result.job_id)
assert processed is True assert processed is True
assert job is not None assert job is not None
+4 -43
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from datetime import UTC from datetime import UTC
from datetime import datetime from datetime import datetime
from pathlib import Path
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -15,6 +14,7 @@ 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 DocumentDeleteBlockedError
from transcription.services.documents import DocumentError from transcription.services.documents import DocumentError
from transcription.services.documents import PersonDeleteBlockedError
from transcription.services.documents import DocumentService from transcription.services.documents import DocumentService
@@ -88,9 +88,8 @@ async def test_delete_document_blocks_when_dependencies_exist(default_session_fa
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_document_succeeds_when_unlinked(default_session_factory, tmp_path): async def test_delete_document_succeeds_when_unlinked(default_session_factory):
service = DocumentService(session_factory=default_session_factory) service = DocumentService(session_factory=default_session_factory)
service.settings.upload_dir = tmp_path
document = await service.create_document( document = await service.create_document(
Document( Document(
@@ -100,45 +99,12 @@ async def test_delete_document_succeeds_when_unlinked(default_session_factory, t
) )
) )
document_dir = service.settings.upload_dir / "documents" / str(document.id)
document_dir.mkdir(parents=True, exist_ok=True)
(document_dir / "leftover.txt").write_text("orphan", encoding="utf-8")
await service.delete_document(document) await service.delete_document(document)
assert not document_dir.exists()
with pytest.raises(DocumentError): with pytest.raises(DocumentError):
await service.read_document_detail(document.id) await service.read_document_detail(document.id)
@pytest.mark.asyncio
async def test_delete_document_removes_populated_storage_tree(default_session_factory, tmp_path):
service = DocumentService(session_factory=default_session_factory)
service.settings.upload_dir = tmp_path
document = await service.create_document(
Document(
id=uuid4(),
name="tree-delete",
document_type="memo",
)
)
document_dir = service.settings.upload_dir / "documents" / str(document.id)
(document_dir / "page-1.jpg").parent.mkdir(parents=True, exist_ok=True)
(document_dir / "page-1.jpg").write_bytes(b"one")
(document_dir / "page-2.jpg").write_bytes(b"two")
(document_dir / "nested" / "manifest.json").parent.mkdir(parents=True, exist_ok=True)
(document_dir / "nested" / "manifest.json").write_text('{"ok": true}', encoding="utf-8")
assert document_dir.exists()
await service.delete_document(document)
assert not document_dir.exists()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_read_person_detail_loads_document_links(default_session_factory): async def test_read_person_detail_loads_document_links(default_session_factory):
service = DocumentService(session_factory=default_session_factory) service = DocumentService(session_factory=default_session_factory)
@@ -187,7 +153,7 @@ async def test_update_person_refreshes_updated_timestamp(default_session_factory
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_person_removes_links_when_linked_documents_exist(default_session_factory): async def test_delete_person_blocks_when_linked_documents_exist(default_session_factory):
service = DocumentService(session_factory=default_session_factory) service = DocumentService(session_factory=default_session_factory)
document = await service.create_document( document = await service.create_document(
@@ -206,14 +172,9 @@ async def test_delete_person_removes_links_when_linked_documents_exist(default_s
) )
) )
with pytest.raises(PersonDeleteBlockedError):
await service.delete_person(person) await service.delete_person(person)
links = await service.list_document_people(person_id=person.id)
assert links == []
with pytest.raises(DocumentError):
await service.read_person_detail(person.id)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_person_succeeds_when_unlinked(default_session_factory): async def test_delete_person_succeeds_when_unlinked(default_session_factory):
-156
View File
@@ -13,8 +13,6 @@ 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 JobDeleteBlockedError
from transcription.services.jobs import JobCancelBlockedError
from transcription.services.jobs import JobResubmitBlockedError
from transcription.services.jobs import JobService from transcription.services.jobs import JobService
@@ -226,157 +224,3 @@ class TestJobService:
with pytest.raises(ValueError): with pytest.raises(ValueError):
await job_service.read_job(job_id=job.id) await job_service.read_job(job_id=job.id)
@pytest.mark.asyncio
async def test_cancel_job_marks_non_transcribed_sources_failed(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="cancel-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_one = Source(
document_id=document.id,
page_number=1,
upload_name="cancel-1.jpg",
filename="stored-cancel-1.jpg",
file_path="/uploads/stored-cancel-1.jpg",
)
source_two = Source(
document_id=document.id,
page_number=2,
upload_name="cancel-2.jpg",
filename="stored-cancel-2.jpg",
file_path="/uploads/stored-cancel-2.jpg",
)
session.add(source_one)
session.add(source_two)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source_one.id,
status=JobSourceStatus.TRANSCRIBED,
raw_transcription="done",
)
)
session.add(
JobSource(
job_id=job.id,
source_id=source_two.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
cancelled = await job_service.cancel_job(job_id=job.id)
assert cancelled.status == JobStatus.FAILED
refreshed = await job_service.read_job(job_id=job.id)
statuses = {item.status for item in refreshed.job_sources}
assert JobSourceStatus.TRANSCRIBED in statuses
assert JobSourceStatus.FAILED in statuses
pending_entry = next(item for item in refreshed.job_sources if item.status == JobSourceStatus.FAILED)
assert pending_entry.error_detail == "Cancelled by user"
@pytest.mark.asyncio
async def test_resubmit_non_transcribed_sources_resets_only_non_transcribed(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="resubmit-job-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.FAILED)
await job_service.create_job(job=job)
async with job_service._session_scope() as session:
source_one = Source(
document_id=document.id,
page_number=1,
upload_name="resubmit-1.jpg",
filename="stored-resubmit-1.jpg",
file_path="/uploads/stored-resubmit-1.jpg",
raw_transcription="existing text",
)
source_two = Source(
document_id=document.id,
page_number=2,
upload_name="resubmit-2.jpg",
filename="stored-resubmit-2.jpg",
file_path="/uploads/stored-resubmit-2.jpg",
raw_transcription="done text",
)
session.add(source_one)
session.add(source_two)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source_one.id,
status=JobSourceStatus.FAILED,
raw_transcription=None,
error_detail="prior error",
)
)
session.add(
JobSource(
job_id=job.id,
source_id=source_two.id,
status=JobSourceStatus.TRANSCRIBED,
raw_transcription="done text",
)
)
await session.commit()
count = await job_service.resubmit_non_transcribed_sources(job_id=job.id)
assert count == 1
refreshed = await job_service.read_job(job_id=job.id)
assert refreshed.status == JobStatus.QUEUED
failed_entry = next(item for item in refreshed.job_sources if item.source is not None and item.source.page_number == 1)
transcribed_entry = next(item for item in refreshed.job_sources if item.source is not None and item.source.page_number == 2)
assert failed_entry.status == JobSourceStatus.PENDING
assert failed_entry.error_detail is None
assert failed_entry.source is not None
assert failed_entry.source.raw_transcription is None
assert transcribed_entry.status == JobSourceStatus.TRANSCRIBED
@pytest.mark.asyncio
async def test_resubmit_non_transcribed_sources_blocks_when_processing(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="resubmit-blocked-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(JobResubmitBlockedError):
await job_service.resubmit_non_transcribed_sources(job_id=job.id)
@pytest.mark.asyncio
async def test_cancel_job_blocks_transcribed_terminal_jobs(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="cancel-blocked-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
await job_service.create_job(job=job)
with pytest.raises(JobCancelBlockedError):
await job_service.cancel_job(job_id=job.id)
-48
View File
@@ -1,4 +1,3 @@
from pathlib import Path
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -10,9 +9,7 @@ from transcription.db.models import Job
from transcription.db.models import JobSource from transcription.db.models import JobSource
from transcription.db.models import Source from transcription.db.models import Source
from transcription.services.store import UploadError from transcription.services.store import UploadError
from transcription.services.store import create_upload_job
from transcription.services.store import create_job_for_document from transcription.services.store import create_job_for_document
from transcription.services.store import store_person_portrait
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -69,52 +66,7 @@ async def test_create_job_for_document_sorts_uploads_and_creates_links(async_ses
assert [source.upload_name for source in sources] == ["A_page.pdf", "b_page.pdf"] 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(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) assert all("A_page" not in source.filename and "b_page" not in source.filename for source in sources)
assert all(Path(source.filename).stem == str(source.id) for source in sources)
assert all(Path(source.file_path).parent == (tmp_path / "documents" / str(document.id)) for source in sources)
job_sources = (await async_session.exec(select(JobSource).where(JobSource.job_id == result.job_id))).all() job_sources = (await async_session.exec(select(JobSource).where(JobSource.job_id == result.job_id))).all()
assert len(job_sources) == 2 assert len(job_sources) == 2
assert set(result.source_ids) == {job_source.source_id for job_source in job_sources} assert set(result.source_ids) == {job_source.source_id for job_source in job_sources}
@pytest.mark.asyncio
async def test_create_upload_job_stores_source_under_document_id_directory(async_session, tmp_path):
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
result = await create_upload_job(
filename="single-page.jpg",
file_bytes=b"image-bytes",
session=async_session,
settings=settings,
)
expected_parent = tmp_path / "documents" / str(result.document_id)
assert result.stored_path.parent == expected_parent
assert result.stored_path.exists()
source = (
await async_session.exec(
select(Source)
.where(Source.document_id == result.document_id)
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
)
).first()
assert source is not None
assert Path(source.filename).stem == str(source.id)
assert result.stored_path.name == source.filename
assert Path(source.file_path).parent == expected_parent
def test_store_person_portrait_stores_file_under_person_id_directory(tmp_path):
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
person_id = uuid4()
stored_path = store_person_portrait(
person_id=person_id,
filename="portrait.png",
file_bytes=b"portrait-bytes",
settings=settings,
)
assert stored_path.parent == (tmp_path / "persons" / str(person_id))
assert stored_path.exists()
+2 -65
View File
@@ -93,11 +93,10 @@ class TestTranscriptionServiceRevisionUpsert:
assert revisions[0].revised_text == "Revision v2" assert revisions[0].revised_text == "Revision v2"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_source_from_job_context_removes_source_and_single_link(self, default_session_factory, tmp_path): async def test_delete_source_from_job_context_removes_source_and_single_link(self, default_session_factory):
documents = DocumentService(session_factory=default_session_factory) documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory) jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory) transcriptions = TranscriptionService(session_factory=default_session_factory)
transcriptions.settings.upload_dir = tmp_path
document = Document(id=uuid4(), name="delete-source-success") document = Document(id=uuid4(), name="delete-source-success")
await documents.create_document(document=document) await documents.create_document(document=document)
@@ -105,16 +104,12 @@ class TestTranscriptionServiceRevisionUpsert:
job = Job(document_id=document.id, status=JobStatus.QUEUED) job = Job(document_id=document.id, status=JobStatus.QUEUED)
await jobs.create_job(job=job) await jobs.create_job(job=job)
stored_path = tmp_path / "documents" / str(document.id) / "delete.jpg"
stored_path.parent.mkdir(parents=True, exist_ok=True)
stored_path.write_bytes(b"data")
source = Source( source = Source(
document_id=document.id, document_id=document.id,
page_number=1, page_number=1,
upload_name="delete.jpg", upload_name="delete.jpg",
filename="delete.jpg", filename="delete.jpg",
file_path=str(stored_path), file_path="uploads/delete.jpg",
) )
async with transcriptions._session_scope() as session: async with transcriptions._session_scope() as session:
session.add(source) session.add(source)
@@ -127,7 +122,6 @@ class TestTranscriptionServiceRevisionUpsert:
with pytest.raises(TranscriptionNotFoundError): with pytest.raises(TranscriptionNotFoundError):
await transcriptions.read_source(source.id) await transcriptions.read_source(source.id)
assert not stored_path.exists()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_source_from_job_context_blocks_when_other_job_links_exist(self, default_session_factory): async def test_delete_source_from_job_context_blocks_when_other_job_links_exist(self, default_session_factory):
@@ -160,60 +154,3 @@ class TestTranscriptionServiceRevisionUpsert:
with pytest.raises(SourceDeleteBlockedError): with pytest.raises(SourceDeleteBlockedError):
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id) await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
@pytest.mark.asyncio
async def test_delete_unlinked_source_succeeds(self, default_session_factory, tmp_path):
documents = DocumentService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
transcriptions.settings.upload_dir = tmp_path
document = Document(id=uuid4(), name="delete-unlinked-source")
await documents.create_document(document=document)
stored_path = tmp_path / "documents" / str(document.id) / "orphan.jpg"
stored_path.parent.mkdir(parents=True, exist_ok=True)
stored_path.write_bytes(b"data")
source = Source(
document_id=document.id,
page_number=1,
upload_name="orphan.jpg",
filename="orphan.jpg",
file_path=str(stored_path),
)
await transcriptions.create_source(source=source)
await transcriptions.delete_unlinked_source(source_id=source.id)
with pytest.raises(TranscriptionNotFoundError):
await transcriptions.read_source(source.id)
assert not stored_path.exists()
@pytest.mark.asyncio
async def test_delete_unlinked_source_blocks_when_linked(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-unlinked-blocked")
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="linked.jpg",
filename="linked.jpg",
file_path="uploads/linked.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)
with pytest.raises(SourceDeleteBlockedError):
await transcriptions.delete_unlinked_source(source_id=source.id)
+4 -12
View File
@@ -8,19 +8,11 @@ from transcription import __main__ as entrypoint
@pytest.mark.unit @pytest.mark.unit
def test_main_passes_constructed_app_to_uvicorn(monkeypatch): def test_main_uses_cli_factory_import_string(monkeypatch):
"""Non-reload execution keeps the parsed settings instance in the app.""" """Startup uses an importable factory so Uvicorn owns app creation."""
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="debug", reload=False) settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="debug", reload=False)
application = object()
captured = {} captured = {}
def create_app(*, settings: object) -> object:
assert settings is expected_settings
return application
expected_settings = settings
monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings) monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings)
monkeypatch.setattr(entrypoint, "create_app", create_app)
monkeypatch.setattr( monkeypatch.setattr(
entrypoint.uvicorn, entrypoint.uvicorn,
"run", "run",
@@ -30,8 +22,8 @@ def test_main_passes_constructed_app_to_uvicorn(monkeypatch):
entrypoint.main() entrypoint.main()
assert captured == { assert captured == {
"application": application, "application": "transcription.__main__:create_cli_app",
"factory": False, "factory": True,
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8123, "port": 8123,
"log_level": "debug", "log_level": "debug",
+43 -2
View File
@@ -1,7 +1,5 @@
"""Tests for the V2 SQLModel persistence layer and relationships.""" """Tests for the V2 SQLModel persistence layer and relationships."""
from datetime import UTC
from datetime import datetime
from uuid import UUID from uuid import UUID
import pytest import pytest
@@ -203,3 +201,46 @@ class TestRelationships:
assert len(document.jobs) == 1 assert len(document.jobs) == 1
assert len(document.sources) == 1 assert len(document.sources) == 1
assert len(document.document_people) == 1 assert len(document.document_people) == 1
def test_document_exposes_author_via_role_filtered_relation(self, session):
document = _persist_document(session)
author = _persist_person(session, full_name="Author Person")
recipient = _persist_person(session, full_name="Recipient Person")
session.add(DocumentPerson(document_id=document.id, person_id=author.id, role=DocumentPersonRole.AUTHOR))
session.add(DocumentPerson(document_id=document.id, person_id=recipient.id, role=DocumentPersonRole.RECIPIENT))
session.commit()
session.refresh(document)
assert [person.full_name for person in document.authors] == ["Author Person"]
assert document.author is not None
assert document.author.full_name == "Author Person"
def test_person_exposes_authored_documents_via_role_filtered_relation(self, session):
authored_document = _make_document(name="Authored Doc")
recipient_only_document = _make_document(name="Recipient Doc")
session.add(authored_document)
session.add(recipient_only_document)
session.commit()
session.refresh(authored_document)
session.refresh(recipient_only_document)
person = _persist_person(session, full_name="Dual Role Person")
session.add(
DocumentPerson(
document_id=authored_document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
session.add(
DocumentPerson(
document_id=recipient_only_document.id,
person_id=person.id,
role=DocumentPersonRole.RECIPIENT,
)
)
session.commit()
session.refresh(person)
assert [document.name for document in person.authored_documents] == ["Authored Doc"]
+1 -1
View File
@@ -5,7 +5,7 @@ import re
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from transcription.ui import register_pages from transcription.ui.pages import register_pages
from transcription.ui.resources import read_css from transcription.ui.resources import read_css
-29
View File
@@ -1,29 +0,0 @@
import asyncio
import logging
import pytest
from transcription.worker import run_worker_loop
@pytest.mark.asyncio
async def test_run_worker_loop_survives_process_next_exception(monkeypatch, caplog):
calls = 0
stop_event = asyncio.Event()
async def _fake_process_next_queued_job(*, session=None, session_factory=None):
nonlocal calls
_ = (session, session_factory)
calls += 1
if calls == 1:
raise RuntimeError("boom")
stop_event.set()
return False
monkeypatch.setattr("transcription.worker.process_next_queued_job", _fake_process_next_queued_job)
with caplog.at_level(logging.ERROR):
await run_worker_loop(stop_event=stop_event, poll_interval_seconds=0)
assert calls == 2
assert "Worker loop exception" in caplog.text
+37 -31
View File
@@ -3,35 +3,38 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncGenerator, Callable from collections.abc import Callable
from datetime import UTC, datetime from collections.abc import Generator
from datetime import UTC
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Awaitable
from uuid import UUID from uuid import UUID
import pytest import pytest
import pytest_asyncio
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlmodel import delete from sqlmodel import delete
from transcription.app import create_app from transcription.app import create_app
from transcription.config import Settings, SqliteSettings from transcription.config import Settings
from transcription.db import create_all, initialize_database_runtime, session_scope from transcription.config import SqliteSettings
from transcription.db.models import ( from transcription.db import create_all
Document, from transcription.db import initialize_database_runtime
DocumentPerson, from transcription.db import session_scope
Job, from transcription.db.models import Document
JobSource, from transcription.db.models import DocumentPerson
JobSourceStatus, from transcription.db.models import Job
JobStatus, from transcription.db.models import JobSource
Person, from transcription.db.models import JobSourceStatus
Source, from transcription.db.models import JobStatus
) from transcription.db.models import Person
from transcription.db.models import Source
RevisionSeed = str
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def app_client(tmp_path_factory: pytest.TempPathFactory) -> AsyncGenerator[tuple[FastAPI, TestClient], None]: def app_client(tmp_path_factory: pytest.TempPathFactory) -> Generator[tuple[FastAPI, TestClient]]:
"""Provide a real application and test client backed by in-memory SQLite.""" """Provide a real application and test client backed by in-memory SQLite."""
tmp_path = tmp_path_factory.mktemp("ui") tmp_path = tmp_path_factory.mktemp("ui")
settings = Settings( settings = Settings(
@@ -46,14 +49,16 @@ def app_client(tmp_path_factory: pytest.TempPathFactory) -> AsyncGenerator[tuple
app = create_app() app = create_app()
app.state.runtime = initialize_database_runtime(settings=settings) app.state.runtime = initialize_database_runtime(settings=settings)
asyncio.run(create_all(engine=app.state.runtime.engine)) asyncio.run(create_all(engine=app.state.runtime.engine))
with TestClient(app) as client: with TestClient(app) as client:
yield app, client yield app, client
@pytest_asyncio.fixture(autouse=True) @pytest.fixture(autouse=True)
async def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None: def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
"""Reset UI-facing tables asynchronously before each test for isolation.""" """Reset UI-facing tables before each test for isolation."""
app, _ = app_client
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(JobSource))
await session.exec(delete(DocumentPerson)) await session.exec(delete(DocumentPerson))
@@ -63,22 +68,25 @@ async def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
await session.exec(delete(Person)) await session.exec(delete(Person))
await session.commit() await session.commit()
asyncio.run(_clear())
@pytest_asyncio.fixture
async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., Awaitable[UUID]]: @pytest.fixture
"""Return an async helper for seeding a Document -> Job -> Source tuple.""" def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
"""Return a helper for inserting a document/job/source/(optional revision) tuple."""
app, _ = app_client app, _ = app_client
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
async def _seed( def _seed(
*, *,
filename: str = "sample.pdf", filename: str = "sample.pdf",
status: JobStatus = JobStatus.TRANSCRIBED, status: JobStatus = JobStatus.TRANSCRIBED,
transcription_text: str | None = "Sample transcript text", transcription_text: str | None = "Sample transcript text",
error_detail: str | None = None, error_detail: str | None = None,
revision_text: str | None = None, revision_text: RevisionSeed | None = None,
source_file: Path | None = None, source_file: Path | None = None,
) -> UUID: ) -> UUID:
async def _insert() -> UUID:
async with session_scope() as session: async with session_scope() as session:
stored_path = app.state.settings.upload_dir / filename stored_path = app.state.settings.upload_dir / filename
stored_path.parent.mkdir(parents=True, exist_ok=True) stored_path.parent.mkdir(parents=True, exist_ok=True)
@@ -114,11 +122,7 @@ async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., Awai
JobSource( JobSource(
job_id=job.id, job_id=job.id,
source_id=source.id, source_id=source.id,
status=( status=JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED,
JobSourceStatus.TRANSCRIBED
if transcription_text is not None
else JobSourceStatus.FAILED
),
raw_transcription=transcription_text, raw_transcription=transcription_text,
error_detail=error_detail, error_detail=error_detail,
) )
@@ -132,4 +136,6 @@ async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., Awai
await session.commit() await session.commit()
return job.id return job.id
return asyncio.run(_insert())
return _seed return _seed
+299 -109
View File
@@ -1,176 +1,366 @@
"""Tests for the documents page routes and action handlers.""" """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 import pytest
import pytest_asyncio
from sqlmodel import select
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Job, Person, Source from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
# --- Helper Fixtures --- from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import Source
@pytest_asyncio.fixture
async def seed_person_and_document():
"""Seed a Person and Document linked by DocumentPerson role."""
async with session_scope() as session:
person = Person(full_name="Zenna Cochran")
session.add(person)
await session.flush()
doc = Document(
name="Letter from Hig",
document_type="letter",
archive_identifier="ZC-1924-001",
)
session.add(doc)
await session.flush()
link = DocumentPerson(
document_id=doc.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
session.add(link)
await session.commit()
return str(doc.id), str(person.id)
# --- Integration Tests for Documents Route Handlers ---
@pytest.mark.integration @pytest.mark.integration
class TestDocumentsPageRendering: class TestDocumentsPageRendering:
"""Verify document list, detail, edit, and deletion route behaviors.""" """Verify document list/detail routes render expected read states."""
def test_documents_page_renders_empty_state(self, app_client): def test_documents_page_renders_empty_state(self, app_client):
"""GET /ui/documents renders empty-state text when no records exist."""
_, client = app_client _, client = app_client
response = client.get("/ui/documents") response = client.get("/ui/documents")
assert response.status_code == 200 assert response.status_code == 200
assert "Archival Documents" in response.text assert "Documents" in response.text
assert "Create new document" in response.text
assert "No documents in repository yet." in response.text assert "No documents in repository yet." in response.text
@pytest.mark.asyncio def test_document_create_page_renders_fields(self, app_client):
async def test_documents_page_lists_seeded_documents(self, app_client): """GET /ui/documents/new renders document-create form fields."""
_, client = app_client
async with session_scope() as session:
doc = Document(name="1924 Postcard", document_type="postcard", archive_identifier="PC-001")
session.add(doc)
await session.commit()
response = client.get("/ui/documents")
assert response.status_code == 200
assert "1924 Postcard" in response.text
assert "postcard" in response.text
assert "PC-001" in response.text
def test_document_create_page_renders_form(self, app_client):
_, client = app_client _, client = app_client
response = client.get("/ui/documents/new") response = client.get("/ui/documents/new")
assert response.status_code == 200 assert response.status_code == 200
assert "Create Document" in response.text assert "Create Document" in response.text
assert "Document name is required." in response.text
assert "Document name" in response.text assert "Document name" in response.text
assert "Document type" in response.text
assert "Author (Person)" in response.text assert "Author (Person)" 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 "Create new item" in response.text
assert "Create new person" in response.text
assert "Save document" in response.text
@pytest.mark.asyncio def test_documents_page_lists_seeded_documents(self, app_client):
async def test_document_detail_page_renders_bento_grid_and_metadata( """GET /ui/documents lists seeded document cards."""
self, app_client, seed_person_and_document
):
_, client = app_client _, client = app_client
doc_id, _ = seed_person_and_document
response = client.get(f"/ui/documents/{doc_id}") 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 response.status_code == 200
assert "Letter from Hig" in response.text assert "Seeded Document" in response.text
assert "ZC-1924-001" in response.text assert "letter" in response.text
assert "Zenna Cochran" in response.text
assert "Edit Document" in response.text
@pytest.mark.asyncio def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client):
async def test_document_jobs_page_renders_job_links(self, app_client): """GET /ui/documents/{document_id} shows metadata and related empty states."""
_, client = app_client _, client = app_client
async def _seed_document() -> str:
async with session_scope() as session: async with session_scope() as session:
doc = Document(name="Doc With Job", document_type="letter") document = Document(
session.add(doc) 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 "Type: letter" in response.text
assert "Author:" in response.text
assert "Not set" in response.text
assert "Exact Date:" in response.text
assert "1885-07-13" in response.text
assert "Approx. Date:" in response.text
assert "c. 1885" in response.text
assert "Location Created:" in response.text
assert "Ohio" in response.text
assert "Archive Identifier:" in response.text
assert "BOX-1-FOLDER-2" in response.text
assert "Archival Notes:" in response.text
assert "Family archive" in response.text
assert "Created:" in response.text
assert "Updated:" in response.text
assert "No linked people yet." in response.text
assert "0 Source(s) Linked" in response.text
assert "0 Active Jobs" in response.text
assert "+ Add Source" in response.text
assert "+ Add Job" in response.text
assert "Sources" in response.text
assert "Jobs" in response.text
assert "Edit Document" in response.text
assert "Delete" 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() await session.flush()
job = Job(document_id=doc.id) session.add(
session.add(job) 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.commit()
doc_id = str(doc.id) await session.refresh(document)
job_id = str(job.id) return str(document.id)
response = client.get(f"/ui/documents/{doc_id}/jobs") document_id = asyncio.run(_seed_related())
response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Jobs for Doc With Job" in response.text assert "Jane Doe" in response.text
assert f"Job ID: {job_id}" in response.text assert "author" in response.text
assert "Author:" in response.text
assert "1 Source(s) Linked" in response.text
assert "1 Active Jobs" in response.text
@pytest.mark.asyncio def test_document_jobs_page_filters_to_document_context(self, app_client):
async def test_document_edit_page_prefills_existing_values(
self, app_client, seed_person_and_document
):
_, client = app_client _, client = app_client
doc_id, _ = seed_person_and_document
response = client.get(f"/ui/documents/{doc_id}/edit") 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/sources?document_id={document_id}")
assert response.status_code == 200
assert "Sources: Target" in response.text
assert "Back to Document" 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 response.status_code == 200
assert "Edit Document Record" in response.text assert "Edit Document Record" in response.text
assert "Letter from Hig" in response.text assert "Document name and document type are required." in response.text
assert "ZC-1924-001" in response.text assert "Document name" in response.text
assert "Document type" in response.text
assert "Author (Person)" 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 "Create new item" in response.text
assert "Create new person" in response.text
assert "Save changes" in response.text
@pytest.mark.asyncio def test_document_delete_page_shows_confirmation_when_unlinked(self, app_client):
async def test_document_delete_page_blocks_deletion_when_dependencies_exist( """GET /ui/documents/{document_id}/delete renders permanent-action confirmation if unlinked."""
self, app_client
):
_, client = app_client _, client = app_client
async def _seed_document() -> str:
async with session_scope() as session: async with session_scope() as session:
doc = Document(name="Doc With Source", document_type="letter") document = Document(name="Safe Delete", document_type="letter")
session.add(doc) 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() await session.flush()
source = Source( session.add(
document_id=doc.id, Source(
document_id=document.id,
page_number=1, page_number=1,
upload_name="page_1.png", upload_name="001_page.png",
filename="page_1.png", filename="stored_001_page.png",
file_path="/tmp/page_1.png", file_path="/tmp/stored_001_page.png",
) )
session.add(source) )
session.add(Job(document_id=document.id))
await session.commit() await session.commit()
doc_id = str(doc.id) await session.refresh(document)
return str(document.id)
response = client.get(f"/ui/documents/{doc_id}/delete") document_id = asyncio.run(_seed_related())
response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200 assert response.status_code == 200
assert "Delete Document" in response.text
assert "Delete is blocked because related records exist." in response.text assert "Delete is blocked because related records exist." in response.text
assert "Dependencies present: Sources" in response.text assert "Dependencies present: Sources, Jobs" in response.text
assert "Go to Jobs" in response.text
@pytest.mark.asyncio def test_job_create_page_preselects_document_query_param(self, app_client):
async def test_document_delete_page_allows_unlinked_document_deletion(self, app_client): """GET /ui/jobs/new?document_id=... includes the selected document in rendered state."""
_, client = app_client _, client = app_client
async def _seed_document() -> str:
async with session_scope() as session: async with session_scope() as session:
doc = Document(name="Orphan Document", document_type="note") document = Document(
session.add(doc) name="Preselected Document",
document_type="letter",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(document)
await session.commit() await session.commit()
doc_id = str(doc.id) await session.refresh(document)
return str(document.id)
response = client.get(f"/ui/documents/{doc_id}/delete") document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/jobs/new?document_id={document_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Delete Document" in response.text assert "Preselected Document" in response.text
assert "Delete document permanently" in response.text
assert "Delete is blocked" not in response.text
+87 -113
View File
@@ -1,64 +1,30 @@
"""Tests for the jobs page routes and action handlers.""" """Tests for the jobs page route."""
import asyncio
from uuid import uuid4
import pytest import pytest
import pytest_asyncio
from sqlmodel import select
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document, Job, JobSourceStatus, JobStatus from transcription.db.models import Document
from transcription.db.models import JobStatus
# --- Helper Fixtures ---
@pytest_asyncio.fixture
async def seed_document_with_unlinked_job():
"""Seed a document and a queued job for testing route actions."""
async with session_scope() as session:
document = Document(name="Test Archival Letter", document_type="letter")
session.add(document)
await session.flush()
job = Job(
document_id=document.id,
status=JobStatus.QUEUED,
provider="openai",
model="gpt-4o",
)
session.add(job)
await session.commit()
return str(document.id), str(job.id)
# --- Integration Tests for Jobs Route Handlers ---
@pytest.mark.integration @pytest.mark.integration
class TestJobsPageRendering: class TestPageRendering:
"""Verify jobs list, creation, detail, and lifecycle action routes.""" """Verify jobs routes render correctly with real app wiring."""
def test_jobs_page_renders_empty_state(self, app_client): def test_jobs_page_renders_empty_state(self, app_client):
"""GET /ui/jobs renders the page and empty-state text when no jobs exist."""
_, client = app_client _, client = app_client
response = client.get("/ui/jobs") response = client.get("/ui/jobs")
assert response.status_code == 200 assert response.status_code == 200
assert "Transcription Pipeline Jobs" in response.text assert "Create job" in response.text
assert "No active or historical processing jobs found." in response.text assert "No active or historical processing jobs found." in response.text
@pytest.mark.asyncio def test_job_create_page_requires_existing_documents(self, app_client):
async def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job): """GET /ui/jobs/new shows guidance when no Documents exist."""
_, client = app_client
job_id = await seed_job(filename="seeded-document-page.png")
response = client.get("/ui/jobs")
assert response.status_code == 200
assert str(job_id) in response.text
assert "seeded-document-page.png" in response.text
@pytest.mark.asyncio
async def test_job_create_page_shows_empty_document_warning_when_no_docs(self, app_client):
_, client = app_client _, client = app_client
response = client.get("/ui/jobs/new") response = client.get("/ui/jobs/new")
@@ -66,102 +32,110 @@ class TestJobsPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Create Processing Job" in response.text assert "Create Processing Job" in response.text
assert "No documents available. Create a Document before creating a Job." in response.text assert "No documents available. Create a Document before creating a Job." in response.text
assert "Create document" in response.text
@pytest.mark.asyncio def test_job_create_page_lists_available_documents(self, app_client):
async def test_job_create_page_preselects_document_from_query_param(self, app_client): """GET /ui/jobs/new renders document choices when Documents exist."""
_, client = app_client _, client = app_client
async def _seed_document() -> None:
async with session_scope() as session: async with session_scope() as session:
doc = Document(name="Preselected Journal Entry", document_type="journal") session.add(Document(name="Seeded Document"))
session.add(doc)
await session.commit() await session.commit()
doc_id = str(doc.id)
response = client.get(f"/ui/jobs/new?document_id={doc_id}") asyncio.run(_seed_document())
response = client.get("/ui/jobs/new")
assert response.status_code == 200 assert response.status_code == 200
assert "Create Processing Job" in response.text assert "Create Processing Job" in response.text
assert "Preselected Journal Entry" 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 "Select source files or a folder" in response.text
@pytest.mark.asyncio def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
async def test_job_detail_page_renders_logistics_and_links( """GET /ui/jobs lists seeded jobs from the in-memory database."""
self, app_client, seed_document_with_unlinked_job
):
_, client = app_client _, client = app_client
_, job_id = seed_document_with_unlinked_job seed_job(filename="sample.pdf", status=JobStatus.TRANSCRIBED, transcription_text="done")
response = client.get("/ui/jobs")
assert response.status_code == 200
assert "sample.pdf" in response.text
assert "transcribed" in response.text
def test_job_detail_page_renders_document_links(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} renders document-scoped navigation links."""
_, client = app_client
job_id = seed_job(
filename="detail.pdf",
status=JobStatus.TRANSCRIBED,
transcription_text="original text",
)
response = client.get(f"/ui/jobs/{job_id}") response = client.get(f"/ui/jobs/{job_id}")
assert response.status_code == 200 assert response.status_code == 200
assert f"Job Record: {job_id}" in response.text assert "Job" in response.text
assert "Execution Logistics".upper() in response.text.upper() assert "Provider:" in response.text
assert "openai" in response.text assert "Model:" in response.text
assert "gpt-4o" in response.text assert "Prompt:" in response.text
assert "View Linked Document" in response.text assert "Retry Count:" in response.text
assert "View Linked Sources" in response.text assert "Last Updated:" in response.text
assert "document links" in response.text.lower()
assert "Sources" in response.text
assert "Jobs" in response.text
assert "Delete job" not in response.text
@pytest.mark.asyncio def test_job_detail_page_rejects_invalid_id(self, app_client):
async def test_job_cancel_page_renders_confirmation( """GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
self, app_client, seed_document_with_unlinked_job
):
_, client = app_client _, client = app_client
_, job_id = seed_document_with_unlinked_job response = client.get("/ui/jobs/not-a-uuid")
response = client.get(f"/ui/jobs/{job_id}/cancel")
assert response.status_code == 200 assert response.status_code == 200
assert "Cancel Processing Job" in response.text assert "Invalid job id" in response.text
assert "Cancel stops processing" in response.text
assert "Cancel job" in response.text
@pytest.mark.asyncio def test_job_detail_page_handles_missing_job(self, app_client):
async def test_job_resubmit_page_renders_counts( """GET /ui/jobs/{job_id} shows not-found state for unknown IDs."""
self, app_client, seed_job
):
_, client = app_client _, client = app_client
job_id = await seed_job( missing_id = uuid4()
filename="failed-resubmit.png", response = client.get(f"/ui/jobs/{missing_id}")
status=JobStatus.FAILED,
transcription_text=None, assert response.status_code == 200
error_detail="Timeout", assert "Job not found" in response.text
def test_job_detail_page_hides_delete_action(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} does not expose job deletion controls in this revision."""
_, client = app_client
job_id = seed_job(
filename="no-revision.pdf",
status=JobStatus.TRANSCRIBED,
transcription_text="original text",
) )
response = client.get(f"/ui/jobs/{job_id}/resubmit") response = client.get(f"/ui/jobs/{job_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Resubmit Job" in response.text assert "Delete job" not in response.text
assert "Non-Transcribed Sources:" in response.text
assert "Resubmit now" in response.text
@pytest.mark.asyncio def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
async def test_job_delete_page_blocks_deletion_when_processing(self, app_client):
_, client = app_client _, client = app_client
job_id = seed_job(filename="delete-ready.pdf", status=JobStatus.TRANSCRIBED)
async with session_scope() as session:
doc = Document(name="Processing Doc", document_type="letter")
session.add(doc)
await session.flush()
job = Job(document_id=doc.id, status=JobStatus.PROCESSING)
session.add(job)
await session.commit()
job_id = str(job.id)
response = client.get(f"/ui/jobs/{job_id}/delete") response = client.get(f"/ui/jobs/{job_id}/delete")
assert response.status_code == 200 assert response.status_code == 200
assert "Delete Processing Job" in response.text assert "Delete job" in response.text
assert "Delete is blocked while the job is processing." in response.text assert "This action permanently deletes the job." in response.text
@pytest.mark.asyncio
async def test_job_delete_page_allows_deletion_for_queued_or_completed_job(
self, app_client, seed_document_with_unlinked_job
):
_, client = app_client
_, job_id = seed_document_with_unlinked_job
response = client.get(f"/ui/jobs/{job_id}/delete")
assert response.status_code == 200
assert "Delete Processing Job" in response.text
assert "Delete job permanently" in response.text assert "Delete job permanently" in response.text
assert "Delete is blocked" not 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
-43
View File
@@ -1,43 +0,0 @@
"""Tests for UI entry-points, redirects, and page mounting health-checks."""
import pytest
@pytest.mark.integration
class TestNavigationAndMounts:
"""Verify application entry-point redirects and route mounting."""
@pytest.mark.parametrize(
("url", "expected_status", "expected_redirect"),
[
("/", 307, "/ui/homepage"),
("/ui", 307, "/ui/homepage"),
("/ui/upload", 307, "/ui/jobs/new"),
],
)
def test_entrypoint_redirects(self, app_client, url: str, expected_status: int, expected_redirect: str):
"""Verify root and legacy routes redirect to primary UI views."""
_, client = app_client
response = client.get(url, follow_redirects=False)
assert response.status_code == expected_status
assert response.headers["location"] == expected_redirect
@pytest.mark.parametrize(
"route_path",
[
"/ui/homepage",
"/ui/homepage/edit",
"/ui/documents",
"/ui/people",
"/ui/sources",
"/ui/jobs",
],
)
def test_registered_pages_render_successfully(self, app_client, route_path: str):
"""Smoke test verifying all primary UI routes respond with 200 OK."""
_, client = app_client
response = client.get(route_path)
assert response.status_code == 200
assert "html" in response.headers.get("content-type", "").lower()
-2
View File
@@ -11,14 +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
homepage_response = client.get("/ui/homepage")
upload_response = client.get("/ui/upload", follow_redirects=False) upload_response = client.get("/ui/upload", follow_redirects=False)
documents_response = client.get("/ui/documents") documents_response = client.get("/ui/documents")
people_response = client.get("/ui/people") people_response = client.get("/ui/people")
sources_response = client.get("/ui/sources") sources_response = client.get("/ui/sources")
jobs_response = client.get("/ui/jobs") jobs_response = client.get("/ui/jobs")
assert homepage_response.status_code == 200
assert upload_response.status_code == 307 assert upload_response.status_code == 307
assert documents_response.status_code == 200 assert documents_response.status_code == 200
assert people_response.status_code == 200 assert people_response.status_code == 200
+74 -21
View File
@@ -1,12 +1,16 @@
"""Tests for the people page routes and action handlers.""" """Tests for the people page routes."""
import asyncio
from datetime import date from datetime import date
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Person 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 @pytest.mark.integration
@@ -23,14 +27,16 @@ class TestPeoplePageRendering:
assert "Create new person" in response.text assert "Create new person" in response.text
assert "No person records found in repository." in response.text assert "No person records found in repository." in response.text
@pytest.mark.asyncio def test_people_page_lists_seeded_people(self, app_client):
async def test_people_page_lists_seeded_people(self, app_client):
_, client = app_client _, client = app_client
async def _seed_person() -> None:
async with session_scope() as session: async with session_scope() as session:
session.add(Person(full_name="Ada Lovelace", display_name="Ada")) session.add(Person(full_name="Ada Lovelace", display_name="Ada"))
await session.commit() await session.commit()
asyncio.run(_seed_person())
response = client.get("/ui/people") response = client.get("/ui/people")
assert response.status_code == 200 assert response.status_code == 200
@@ -50,10 +56,10 @@ class TestPeoplePageRendering:
assert "Biography" in response.text assert "Biography" in response.text
assert "Save person" in response.text assert "Save person" in response.text
@pytest.mark.asyncio def test_person_detail_page_renders_metadata_and_empty_links(self, app_client):
async def test_person_detail_page_renders_metadata_and_empty_links(self, app_client):
_, client = app_client _, client = app_client
async def _seed_person() -> str:
async with session_scope() as session: async with session_scope() as session:
person = Person( person = Person(
full_name="Grace Hopper", full_name="Grace Hopper",
@@ -70,7 +76,10 @@ class TestPeoplePageRendering:
) )
session.add(person) session.add(person)
await session.commit() await session.commit()
person_id = str(person.id) await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}") response = client.get(f"/ui/people/{person_id}")
@@ -88,11 +97,12 @@ class TestPeoplePageRendering:
assert "Created:" in response.text assert "Created:" in response.text
assert "Updated:" in response.text assert "Updated:" in response.text
assert "No linked documents yet." in response.text assert "No linked documents yet." in response.text
assert "Link this person from a Document workflow." in response.text
@pytest.mark.asyncio def test_person_detail_page_resolves_relative_portrait_path_to_uploads_mount(self, app_client):
async def test_person_detail_page_resolves_relative_portrait_path(self, app_client):
_, client = app_client _, client = app_client
async def _seed_person() -> str:
async with session_scope() as session: async with session_scope() as session:
person = Person( person = Person(
full_name="Portrait Person", full_name="Portrait Person",
@@ -100,21 +110,24 @@ class TestPeoplePageRendering:
) )
session.add(person) session.add(person)
await session.commit() await session.commit()
person_id = str(person.id) await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}") response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "/uploads/portraits/person/seeded.png" in response.text assert "/uploads/portraits/person/seeded.png" in response.text
@pytest.mark.asyncio def test_person_detail_page_renders_linked_documents(self, app_client):
async def test_person_detail_page_renders_linked_documents(self, app_client):
_, client = app_client _, client = app_client
async def _seed_links() -> str:
async with session_scope() as session: async with session_scope() as session:
person = Person(full_name="Linked Person") person = Person(full_name="Linked Person")
document = Document(name="Linked Document", document_type="letter") document = Document(name="Linked Document", document_type="letter")
session.add_all([person, document]) session.add(person)
session.add(document)
await session.flush() await session.flush()
session.add( session.add(
@@ -125,7 +138,10 @@ class TestPeoplePageRendering:
) )
) )
await session.commit() await session.commit()
person_id = str(person.id) await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_links())
response = client.get(f"/ui/people/{person_id}") response = client.get(f"/ui/people/{person_id}")
@@ -149,33 +165,39 @@ class TestPeoplePageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Person not found" in response.text assert "Person not found" in response.text
@pytest.mark.asyncio def test_person_edit_page_renders_expected_fields(self, app_client):
async def test_person_edit_page_renders_expected_fields(self, app_client):
_, client = app_client _, client = app_client
async def _seed_person() -> str:
async with session_scope() as session: async with session_scope() as session:
person = Person(full_name="Editable Person", display_name="EP") person = Person(full_name="Editable Person", display_name="EP")
session.add(person) session.add(person)
await session.commit() await session.commit()
person_id = str(person.id) await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}/edit") response = client.get(f"/ui/people/{person_id}/edit")
assert response.status_code == 200 assert response.status_code == 200
assert "Edit Person Record" in response.text assert "Edit Person Record" in response.text
assert "Full name is required." in response.text assert "Full name is required." in response.text
assert "Editable Person" in response.text assert "Full name" in response.text
assert "Save changes" in response.text assert "Save changes" in response.text
@pytest.mark.asyncio def test_person_delete_page_shows_confirmation_when_unlinked(self, app_client):
async def test_person_delete_page_shows_confirmation_when_unlinked(self, app_client):
_, client = app_client _, client = app_client
async def _seed_person() -> str:
async with session_scope() as session: async with session_scope() as session:
person = Person(full_name="Safe Delete") person = Person(full_name="Safe Delete")
session.add(person) session.add(person)
await session.commit() await session.commit()
person_id = str(person.id) await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}/delete") response = client.get(f"/ui/people/{person_id}/delete")
@@ -183,3 +205,34 @@ class TestPeoplePageRendering:
assert "Delete Person Record" in response.text assert "Delete Person Record" in response.text
assert "This action permanently deletes the person record." in response.text assert "This action permanently deletes the person record." in response.text
assert "Delete person permanently" 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
+45 -152
View File
@@ -1,60 +1,15 @@
"""Tests for the sources page routes and Source model properties.""" """Tests for the sources page routes."""
import asyncio
from pathlib import Path from pathlib import Path
import pytest import pytest
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.db import session_scope from transcription.db import session_scope
from transcription.db.models import Document, Job, JobSourceStatus, JobStatus, Source from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import Source
# --- Unit Tests for Model @property Definitions ---
class TestSourceModelProperties:
"""Direct unit tests for Source computed properties."""
@pytest.mark.asyncio
async def test_source_properties_with_no_job_sources(self):
source = Source(
page_number=1,
upload_name="page_one.png",
filename="stored_page_one.png",
file_path="/tmp/stored_page_one.png",
)
assert source.latest_job_source is None
assert source.latest_status is None
assert source.latest_error_detail is None
assert source.document_name is None
@pytest.mark.asyncio
async def test_source_properties_with_document_and_job_sources(self, seed_job):
job_id = await seed_job(
filename="source_prop_test.png",
status=JobStatus.FAILED,
transcription_text=None,
error_detail="Timeout during OCR parsing",
)
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
assert source is not None
# Validate computed properties
assert source.document_name is not None
assert source.latest_status == JobSourceStatus.FAILED
assert source.latest_error_detail == "Timeout during OCR parsing"
assert source.latest_job_source is not None
# --- Integration Tests for Page Rendering ---
@pytest.mark.integration @pytest.mark.integration
@@ -67,13 +22,13 @@ class TestSourcesPageRendering:
response = client.get("/ui/sources") response = client.get("/ui/sources")
assert response.status_code == 200 assert response.status_code == 200
assert "Source Asset Records" in response.text assert "Sources" in response.text
assert "No Source Assets Found".upper() in response.text.upper() assert "No source file records found." in response.text
@pytest.mark.asyncio def test_sources_page_lists_seeded_sources(self, app_client):
async def test_sources_page_lists_seeded_sources(self, app_client):
_, client = app_client _, client = app_client
async def _seed() -> None:
async with session_scope() as session: async with session_scope() as session:
document = Document(name="Source Document", document_type="letter") document = Document(name="Source Document", document_type="letter")
session.add(document) session.add(document)
@@ -89,99 +44,78 @@ class TestSourcesPageRendering:
) )
await session.commit() await session.commit()
asyncio.run(_seed())
response = client.get("/ui/sources") response = client.get("/ui/sources")
assert response.status_code == 200 assert response.status_code == 200
assert "page_one.png" in response.text assert "page_one.png" in response.text
assert "Source Document" in response.text assert "stored_page_one.png" in response.text
@pytest.mark.asyncio def test_sources_page_filters_to_document_context(self, app_client):
async def test_sources_page_filters_to_document_context(self, app_client):
_, client = app_client _, client = app_client
async def _seed() -> str:
async with session_scope() as session: async with session_scope() as session:
target = Document(name="Target", document_type="letter") target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record") other = Document(name="Other", document_type="record")
session.add_all([target, other]) session.add(target)
session.add(other)
await session.flush() await session.flush()
session.add_all( session.add(
[
Source( Source(
document_id=target.id, document_id=target.id,
page_number=1, page_number=1,
upload_name="target_page.png", upload_name="target_page.png",
filename="target_stored.png", filename="target_stored.png",
file_path="/tmp/target_stored.png", file_path="/tmp/target_stored.png",
), )
)
session.add(
Source( Source(
document_id=other.id, document_id=other.id,
page_number=1, page_number=1,
upload_name="other_page.png", upload_name="other_page.png",
filename="other_stored.png", filename="other_stored.png",
file_path="/tmp/other_stored.png", file_path="/tmp/other_stored.png",
), )
]
) )
await session.commit() await session.commit()
target_id = str(target.id) return str(target.id)
response = client.get(f"/ui/sources?document_id={target_id}") document_id = asyncio.run(_seed())
response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Sources for Document" in response.text assert "Sources: Target" in response.text
assert "Back to Document" in response.text
assert "target_page.png" in response.text assert "target_page.png" in response.text
assert "other_page.png" not in response.text assert "other_page.png" not in response.text
@pytest.mark.asyncio def test_sources_page_filters_to_job_context(self, app_client, seed_job):
async def test_sources_page_filters_to_job_context(self, app_client, seed_job):
_, client = app_client _, client = app_client
job_id = await seed_job(filename="job-page.png", transcription_text="job text") job_id = seed_job(filename="job-page.png", transcription_text="job text")
response = client.get(f"/ui/sources?job_id={job_id}") response = client.get(f"/ui/sources?job_id={job_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Sources for Job" in response.text assert "Sources for Job" in response.text
assert "Back to Job" in response.text
assert "job-page.png" in response.text assert "job-page.png" in response.text
@pytest.mark.asyncio def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
async def test_sources_page_job_context_shows_job_source_status_and_error_detail(
self, app_client, seed_job
):
_, client = app_client _, client = app_client
job_id = await seed_job( fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "small_png.png"
filename="job-failed-page.png", job_id = seed_job(
status=JobStatus.FAILED,
transcription_text=None,
error_detail="Provider timed out",
)
response = client.get(f"/ui/sources?job_id={job_id}")
assert response.status_code == 200
assert "job-failed-page.png" in response.text
assert "failed" in response.text.lower()
assert "Provider timed out" in response.text
@pytest.mark.asyncio
async def test_source_detail_page_renders_preview_and_revision_box(
self, app_client, seed_job
):
_, client = app_client
fixture_path = (
Path(__file__).resolve().parents[1]
/ "fixtures"
/ "images"
/ "valid"
/ "small_png.png"
)
job_id = await seed_job(
filename="detail-source.png", filename="detail-source.png",
transcription_text="original transcription text", transcription_text="original transcription text",
revision_text="human revision text", revision_text="human revision text",
source_file=fixture_path, source_file=fixture_path,
) )
async def _get_source_id() -> str:
async with session_scope() as session: async with session_scope() as session:
job = await session.get(Job, job_id) job = await session.get(Job, job_id)
assert job is not None assert job is not None
@@ -189,59 +123,18 @@ class TestSourcesPageRendering:
await session.exec(select(Source).where(Source.document_id == job.document_id)) await session.exec(select(Source).where(Source.document_id == job.document_id))
).first() ).first()
assert source is not None assert source is not None
source_id = str(source.id) return str(source.id)
source_id = asyncio.run(_get_source_id())
response = client.get(f"/ui/sources/{source_id}") response = client.get(f"/ui/sources/{source_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "SOURCE RECORD:" in response.text.upper() assert "Source Page 1: detail-source.png" in response.text
assert source_id.upper() in response.text.upper() assert "Back to Sources" in response.text
assert "SOURCE PREVIEW" in response.text.upper() assert "automated raw transcription" in response.text.lower()
assert "original transcription text" in response.text
@pytest.mark.asyncio assert "curated human transcription" in response.text.lower()
async def test_source_delete_page_blocks_when_source_is_job_linked( assert "human revision text" in response.text
self, app_client, seed_job assert "Page Number:" in response.text
): assert "Stored Filename:" in response.text
_, client = app_client
job_id = await seed_job(filename="linked-source.png", transcription_text="linked text")
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
assert source is not None
source_id = str(source.id)
response = client.get(f"/ui/sources/{source_id}/delete")
assert response.status_code == 200
assert "Delete Source Confirmation".upper() in response.text.upper()
assert "Cannot delete source linked to active jobs." in response.text
@pytest.mark.asyncio
async def test_source_delete_page_allows_unlinked_source(self, app_client):
_, client = app_client
async with session_scope() as session:
document = Document(name="Unlinked Source Doc", document_type="memo")
session.add(document)
await session.flush()
source = Source(
document_id=document.id,
page_number=1,
upload_name="orphan-source.png",
filename="orphan-source.png",
file_path="/tmp/orphan-source.png",
)
session.add(source)
await session.commit()
source_id = str(source.id)
response = client.get(f"/ui/sources/{source_id}/delete")
assert response.status_code == 200
assert "Delete Source Confirmation".upper() in response.text.upper()
assert f"Are you sure you want to delete source {source_id}?" in response.text
assert "Cannot delete source linked to active jobs." not in response.text
+4 -23
View File
@@ -13,34 +13,15 @@ class TestPageRendering:
response = client.get("/", follow_redirects=False) response = client.get("/", follow_redirects=False)
assert response.status_code == 307 assert response.status_code == 307
assert response.headers["location"] == "/ui/homepage" assert response.headers["location"] == "/ui"
def test_ui_redirects_to_homepage(self, app_client): def test_ui_redirects_to_documents(self, app_client):
"""GET /ui redirects to the homepage.""" """GET /ui redirects to the documents 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/homepage" assert response.headers["location"] == "/ui/documents"
def test_homepage_page_renders(self, app_client):
"""GET /ui/homepage renders the homepage page."""
_, client = app_client
response = client.get("/ui/homepage")
assert response.status_code == 200
assert "Home" in response.text
assert "Edit Home Page" in response.text
assert '/ui/homepage' in response.text
def test_homepage_edit_page_renders(self, app_client):
"""GET /ui/homepage/edit renders the edit page."""
_, client = app_client
response = client.get("/ui/homepage/edit")
assert response.status_code == 200
assert "Edit Home Page" in response.text
assert "Homepage markdown" in response.text
def test_upload_page_renders_expected_controls(self, app_client): def test_upload_page_renders_expected_controls(self, app_client):
"""GET /ui/upload redirects to the job-create flow.""" """GET /ui/upload redirects to the job-create flow."""