generated from john/python-template
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eeb552273 | ||
|
|
d9f5fbb1a4 | ||
|
|
271633d1d5 | ||
|
|
6c6589d8ff |
+53
-7
@@ -1,8 +1,54 @@
|
|||||||
PROVIDER=openrouter
|
# --- NiceGUI Server ---
|
||||||
OPENROUTER_API_KEY=sk-or-...
|
# HOST=`0.0.0.0` (default)
|
||||||
# PROVIDER_MODEL= # optional: OpenRouter adapter supplies default
|
# PORT=8000 (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=Historical Transcription MVP
|
# OPENROUTER_APP_TITLE="Google: Gemini 2.5 Flash (openrouter)"
|
||||||
# DATABASE_URL=sqlite:///./transcription.db
|
|
||||||
# UPLOAD_DIR=./uploads
|
# --- runtime environment ---
|
||||||
# PROMPT_DIR=./prompts
|
# ENVIRONMENT: [`development`(default), `test`, `production`]
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ wheels/
|
|||||||
|
|
||||||
# Document images
|
# Document images
|
||||||
uploads/*
|
uploads/*
|
||||||
|
data/*
|
||||||
|
|||||||
+2
-1
@@ -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 | completed | partial_success | failed"
|
VARCHAR status "queued | processing | transcribed | completed | partial_success | failed"
|
||||||
INTEGER retry_count
|
INTEGER retry_count
|
||||||
TEXT provider
|
TEXT provider
|
||||||
TEXT model
|
TEXT model
|
||||||
@@ -97,6 +97,7 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
"transcription.__main__:create_cli_app",
|
application,
|
||||||
factory=True,
|
factory=settings.reload,
|
||||||
host=settings.host,
|
host=settings.host,
|
||||||
port=settings.port,
|
port=settings.port,
|
||||||
log_level=settings.log_level,
|
log_level=settings.log_level,
|
||||||
|
|||||||
@@ -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"}
|
|
||||||
|
|||||||
@@ -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.pages import register_pages
|
from .ui 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", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
return RedirectResponse(url="/ui/homepage", 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/documents", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
return RedirectResponse(url="/ui/homepage", 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
|
||||||
|
|||||||
@@ -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 JSON
|
|
||||||
from sqlalchemy import Column
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy import JSON
|
||||||
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,23 +67,7 @@ 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(
|
document_people: list["DocumentPerson"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
||||||
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):
|
||||||
@@ -108,24 +92,13 @@ 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(
|
document_people: list["DocumentPerson"] = Relationship(back_populates="person", sa_relationship_kwargs={"lazy": "selectin"})
|
||||||
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__: str = "document_person"
|
__tablename__ = "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")
|
||||||
@@ -133,14 +106,12 @@ 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__ = (UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),)
|
__table_args__ = (
|
||||||
|
UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),
|
||||||
|
)
|
||||||
|
|
||||||
document: Optional["Document"] = Relationship(
|
document: Optional["Document"] = 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"})
|
||||||
)
|
|
||||||
person: Optional["Person"] = Relationship(
|
|
||||||
back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Job(SQLModel, table=True):
|
class Job(SQLModel, table=True):
|
||||||
@@ -214,7 +185,7 @@ class Source(SQLModel, table=True):
|
|||||||
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__: str = "job_source"
|
__tablename__ = "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,3 +199,5 @@ 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"})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
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
|
||||||
@@ -18,12 +17,3 @@ 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),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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
|
||||||
@@ -119,6 +120,7 @@ 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,
|
||||||
@@ -152,6 +154,20 @@ 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:
|
||||||
@@ -217,12 +233,8 @@ class DocumentService(ServiceBase):
|
|||||||
suggestion="Verify the person id and retry.",
|
suggestion="Verify the person id and retry.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing.document_people:
|
for link in list(existing.document_people):
|
||||||
raise PersonDeleteBlockedError(
|
await _session.delete(link)
|
||||||
"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)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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
|
||||||
@@ -20,6 +21,14 @@ 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."""
|
||||||
|
|
||||||
@@ -221,3 +230,87 @@ 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)
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ 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,
|
||||||
@@ -50,14 +59,20 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -99,15 +114,19 @@ 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[tuple[str, Path]] = []
|
stored_uploads: list[PendingStoredUpload] = []
|
||||||
for filename, file_bytes in sorted_uploads:
|
for filename, file_bytes in sorted_uploads:
|
||||||
|
source_id = uuid4()
|
||||||
stored_uploads.append(
|
stored_uploads.append(
|
||||||
(
|
PendingStoredUpload(
|
||||||
filename,
|
source_id=source_id,
|
||||||
store_file(
|
original_filename=filename,
|
||||||
|
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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -122,8 +141,8 @@ async def create_job_for_document(
|
|||||||
prompt_name=prompt_name,
|
prompt_name=prompt_name,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
for _, stored_path in stored_uploads:
|
for upload in stored_uploads:
|
||||||
_best_effort_delete(stored_path)
|
_best_effort_delete(upload.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,
|
||||||
@@ -142,10 +161,13 @@ 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)
|
||||||
@@ -156,6 +178,7 @@ 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,
|
||||||
@@ -183,7 +206,7 @@ async def _create_job_for_document_records(
|
|||||||
*,
|
*,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
document_id: UUID,
|
document_id: UUID,
|
||||||
stored_uploads: Sequence[tuple[str, Path]],
|
stored_uploads: Sequence[PendingStoredUpload],
|
||||||
provider: str | None,
|
provider: str | None,
|
||||||
model: str | None,
|
model: str | None,
|
||||||
prompt_name: str | None,
|
prompt_name: str | None,
|
||||||
@@ -211,13 +234,14 @@ async def _create_job_for_document_records(
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
source_ids: list[UUID] = []
|
source_ids: list[UUID] = []
|
||||||
for page_offset, (original_filename, stored_path) in enumerate(stored_uploads):
|
for page_offset, upload 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(original_filename).name,
|
upload_name=Path(upload.original_filename).name,
|
||||||
filename=stored_path.name,
|
filename=upload.stored_path.name,
|
||||||
file_path=str(stored_path),
|
file_path=str(upload.stored_path),
|
||||||
)
|
)
|
||||||
session.add(source)
|
session.add(source)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -244,22 +268,41 @@ 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(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
def store_file(
|
||||||
|
*,
|
||||||
|
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(filename=filename, file_bytes=file_bytes, settings=runtime_settings)
|
return _store_file_bytes(
|
||||||
|
filename=filename,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
settings=runtime_settings,
|
||||||
|
relative_directory=relative_directory,
|
||||||
|
filename_stem=filename_stem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def store_person_portrait(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
def store_person_portrait(
|
||||||
"""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("portraits") / "person",
|
relative_directory=Path("persons") / str(person_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -269,12 +312,13 @@ 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)
|
stored_name = _build_stored_filename(filename=filename, filename_stem=filename_stem)
|
||||||
stored_path = target_dir / stored_name
|
stored_path = target_dir / stored_name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -315,7 +359,8 @@ def _validate_upload(*, filename: str, file_bytes: bytes, supported_extensions:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_stored_filename(filename: str) -> str:
|
def _build_stored_filename(*, filename: str, filename_stem: str | None = None) -> str:
|
||||||
safe_name = Path(filename).name
|
safe_name = Path(filename).name
|
||||||
suffix = Path(safe_name).suffix.lower()
|
suffix = Path(safe_name).suffix.lower()
|
||||||
return f"{uuid4()}{suffix}"
|
stem = filename_stem or str(uuid4())
|
||||||
|
return f"{stem}{suffix}"
|
||||||
|
|||||||
@@ -113,10 +113,43 @@ 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,
|
||||||
*,
|
*,
|
||||||
@@ -236,9 +269,26 @@ 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,
|
||||||
*,
|
*,
|
||||||
@@ -292,7 +342,11 @@ 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 original transcription output fields on a job."""
|
"""Persist transcription output for the first ordered source in a job's document.
|
||||||
|
|
||||||
|
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:
|
||||||
@@ -314,28 +368,86 @@ class TranscriptionService(ServiceBase):
|
|||||||
)
|
)
|
||||||
source_row = source.first()
|
source_row = source.first()
|
||||||
if source_row is not None:
|
if source_row is not None:
|
||||||
existing_job_source = await _session.exec(
|
await self.update_job_source_transcription(
|
||||||
select(JobSource).where(JobSource.job_id == job.id).where(JobSource.source_id == source_row.id)
|
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,
|
||||||
)
|
)
|
||||||
job_source = existing_job_source.first()
|
|
||||||
if job_source is None:
|
|
||||||
job_source = JobSource(
|
|
||||||
job_id=job.id,
|
|
||||||
source_id=source_row.id,
|
|
||||||
status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED,
|
|
||||||
raw_transcription=text,
|
|
||||||
error_detail=error_detail,
|
|
||||||
)
|
|
||||||
_session.add(job_source)
|
|
||||||
else:
|
|
||||||
job_source.raw_transcription = text
|
|
||||||
job_source.error_detail = error_detail
|
|
||||||
job_source.status = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
|
|
||||||
job_source.executed_at = datetime.now(UTC)
|
|
||||||
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
return 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(
|
||||||
|
select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id)
|
||||||
|
)
|
||||||
|
job_source = existing_job_source.first()
|
||||||
|
if job_source is None:
|
||||||
|
job_source = JobSource(
|
||||||
|
job_id=job_id,
|
||||||
|
source_id=source_id,
|
||||||
|
status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED,
|
||||||
|
raw_transcription=text,
|
||||||
|
error_detail=error_detail,
|
||||||
|
)
|
||||||
|
_session.add(job_source)
|
||||||
|
else:
|
||||||
|
job_source.raw_transcription = text
|
||||||
|
job_source.error_detail = error_detail
|
||||||
|
job_source.status = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
|
||||||
|
job_source.executed_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(job, source, job_source))
|
||||||
|
return job_source
|
||||||
|
|
||||||
async def upsert_revision_for_source(
|
async def upsert_revision_for_source(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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
|
||||||
@@ -69,88 +70,114 @@ 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)
|
||||||
source = _resolve_primary_source(source_job)
|
sources = _resolve_job_sources(source_job)
|
||||||
if source is None:
|
if not sources and not source_job.job_sources:
|
||||||
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)
|
||||||
source = next(iter(sorted(candidate_sources, key=lambda item: item.page_number)), None)
|
sources = list(sorted(candidate_sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
|
||||||
|
|
||||||
if source is None:
|
if not sources:
|
||||||
error = AppError(
|
return await services.jobs.mark_job_status(job.id, JobStatus.TRANSCRIBED, session=session)
|
||||||
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()
|
|
||||||
|
|
||||||
try:
|
successful_pages: list[tuple[Source, TranscriptionResult]] = []
|
||||||
result = await asyncio.wait_for(
|
failed_pages: list[tuple[Source, AppError]] = []
|
||||||
transcribe_document_image(source.file_path),
|
externally_stopped = False
|
||||||
timeout=runtime_settings.worker_provider_timeout_seconds,
|
|
||||||
)
|
|
||||||
elapsed_seconds = asyncio.get_running_loop().time() - started_at
|
|
||||||
logger.info(
|
|
||||||
"Provider response diagnostics operation=worker.provider_response "
|
|
||||||
"job_id=%s document_id=%s source_id=%s provider=%s model=%s "
|
|
||||||
"finish_reason=%s usage_input_tokens=%s usage_output_tokens=%s usage_total_tokens=%s "
|
|
||||||
"latency_seconds=%.3f text_chars=%s text_lines=%s",
|
|
||||||
job.id,
|
|
||||||
job.document_id,
|
|
||||||
source.id,
|
|
||||||
result.provider,
|
|
||||||
result.model,
|
|
||||||
result.finish_reason or "unknown",
|
|
||||||
result.usage_input_tokens,
|
|
||||||
result.usage_output_tokens,
|
|
||||||
result.usage_total_tokens,
|
|
||||||
elapsed_seconds,
|
|
||||||
len(result.text),
|
|
||||||
_line_count(result.text),
|
|
||||||
)
|
|
||||||
|
|
||||||
_validate_transcription_quality(result=result, settings=runtime_settings)
|
for source in sources:
|
||||||
|
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
|
||||||
|
externally_stopped = True
|
||||||
|
break
|
||||||
|
|
||||||
job = await _finalize_transcribed(job=job, services=services, result=result, session=session)
|
started_at = asyncio.get_running_loop().time()
|
||||||
logger.info(
|
try:
|
||||||
"Job transcribed operation=worker.process_job job_id=%s document_id=%s source_id=%s provider=%s",
|
result = await asyncio.wait_for(
|
||||||
job.id,
|
transcribe_document_image(source.file_path),
|
||||||
job.document_id,
|
timeout=runtime_settings.worker_provider_timeout_seconds,
|
||||||
source.id,
|
)
|
||||||
result.provider,
|
elapsed_seconds = asyncio.get_running_loop().time() - started_at
|
||||||
)
|
logger.info(
|
||||||
except TimeoutError:
|
"Provider response diagnostics operation=worker.provider_response "
|
||||||
error = AppError(
|
"job_id=%s document_id=%s source_id=%s provider=%s model=%s "
|
||||||
f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s",
|
"finish_reason=%s usage_input_tokens=%s usage_output_tokens=%s usage_total_tokens=%s "
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
"latency_seconds=%.3f text_chars=%s text_lines=%s",
|
||||||
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
|
job.id,
|
||||||
retriable=True,
|
job.document_id,
|
||||||
)
|
source.id,
|
||||||
job = await _finalize_failed(job=job, services=services, error=error, session=session)
|
result.provider,
|
||||||
logger.error(
|
result.model,
|
||||||
"Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
|
result.finish_reason or "unknown",
|
||||||
job.id,
|
result.usage_input_tokens,
|
||||||
job.document_id,
|
result.usage_output_tokens,
|
||||||
source.id,
|
result.usage_total_tokens,
|
||||||
error.error_id,
|
elapsed_seconds,
|
||||||
error.category.value,
|
len(result.text),
|
||||||
)
|
_line_count(result.text),
|
||||||
except Exception as exc: # noqa: BLE001
|
)
|
||||||
match exc:
|
|
||||||
case AppError() as error:
|
|
||||||
pass
|
|
||||||
case _:
|
|
||||||
error = classify_unexpected_error(exc, operation="worker.process_job")
|
|
||||||
|
|
||||||
job = await _finalize_failed(job=job, services=services, error=error, session=session)
|
_validate_transcription_quality(result=result, settings=runtime_settings)
|
||||||
logger.error(
|
successful_pages.append((source, result))
|
||||||
"Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
|
except TimeoutError:
|
||||||
job.id,
|
error = AppError(
|
||||||
job.document_id,
|
f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s",
|
||||||
source.id,
|
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||||
error.error_id,
|
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
|
||||||
error.category.value,
|
retriable=True,
|
||||||
)
|
)
|
||||||
return job
|
failed_pages.append((source, error))
|
||||||
|
logger.error(
|
||||||
|
"Source failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
|
||||||
|
job.id,
|
||||||
|
job.document_id,
|
||||||
|
source.id,
|
||||||
|
error.error_id,
|
||||||
|
error.category.value,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
match exc:
|
||||||
|
case AppError() as error:
|
||||||
|
pass
|
||||||
|
case _:
|
||||||
|
error = classify_unexpected_error(exc, operation="worker.process_job")
|
||||||
|
|
||||||
|
failed_pages.append((source, error))
|
||||||
|
logger.error(
|
||||||
|
"Source failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
|
||||||
|
job.id,
|
||||||
|
job.document_id,
|
||||||
|
source.id,
|
||||||
|
error.error_id,
|
||||||
|
error.category.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -307,6 +334,95 @@ 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)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""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)
|
||||||
|
|||||||
@@ -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 "/jobs"
|
return "/homepage"
|
||||||
return normalized.rstrip("/") or "/"
|
return normalized.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
@@ -53,7 +53,9 @@ 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.row().classes("app-shell__brand no-wrap"):
|
with ui.element("a").props('href="/ui/homepage"').style(
|
||||||
|
"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")
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -23,6 +23,8 @@ class SourceTableRow:
|
|||||||
upload_name: str
|
upload_name: str
|
||||||
filename: str
|
filename: str
|
||||||
document_id: UUID
|
document_id: UUID
|
||||||
|
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]]:
|
||||||
@@ -33,6 +35,8 @@ 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),
|
||||||
|
"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,6 +55,20 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
|
|||||||
{"name": "page_number", "label": "Page", "field": "page_number", "sortable": True},
|
{"name": "page_number", "label": "Page", "field": "page_number", "sortable": True},
|
||||||
{"name": "upload_name", "label": "Upload Title", "field": "upload_name", "sortable": True, "classes": "font-serif"},
|
{"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": "filename", "label": "Stored Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
|
||||||
|
{
|
||||||
|
"name": "job_source_status",
|
||||||
|
"label": "Job Source Status",
|
||||||
|
"field": "job_source_status",
|
||||||
|
"sortable": True,
|
||||||
|
"classes": "font-mono",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "job_source_error_detail",
|
||||||
|
"label": "Job Source Error Detail",
|
||||||
|
"field": "job_source_error_detail",
|
||||||
|
"sortable": False,
|
||||||
|
"classes": "font-mono text-xs",
|
||||||
|
},
|
||||||
{"name": "document_id", "label": "Document ID", "field": "document_id", "sortable": True, "classes": "font-mono"},
|
{"name": "document_id", "label": "Document ID", "field": "document_id", "sortable": True, "classes": "font-mono"},
|
||||||
],
|
],
|
||||||
default_sort_by="page_number",
|
default_sort_by="page_number",
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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)]
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""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]
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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)
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
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"
|
|
||||||
)
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
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"
|
|
||||||
)
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
"""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
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.services.documents import DocumentDeleteBlockedError
|
||||||
|
from transcription.services.documents import DocumentError
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.data_display import archival_badge
|
||||||
|
from transcription.ui.components.data_display import metadata_row
|
||||||
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
from transcription.ui.components.primitives import destructive_button
|
||||||
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
|
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.components.viewers import dark_room_viewer
|
||||||
|
from transcription.ui.theme import apply_archival_theme
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
|
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
|
||||||
|
|
||||||
|
|
||||||
|
def register_page() -> None:
|
||||||
|
"""Register documents list and detail routes."""
|
||||||
|
|
||||||
|
@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.")
|
||||||
|
|
||||||
|
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 document_service.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 document_service.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 document_service.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")
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
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 document_service.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")
|
||||||
|
|
||||||
|
# ZONE 2: Metadata & Archival Attributes (Cols 6-8)
|
||||||
|
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")
|
||||||
|
|
||||||
|
# ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
|
||||||
|
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")
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
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 document_service.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.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, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"),
|
||||||
|
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")
|
||||||
|
|
||||||
|
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 document_service.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.")
|
||||||
|
|
||||||
|
with archival_card(extra_classes="gap-3"):
|
||||||
|
name_input = ui.input(label="Document name", value=document.name).props("outlined bg-white").classes("w-full")
|
||||||
|
document_type_input = (
|
||||||
|
ui.input(label="Document type", value=document.document_type or "")
|
||||||
|
.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.document_date else "",
|
||||||
|
).props('outlined bg-white type="date"')
|
||||||
|
date_raw_input = (
|
||||||
|
ui.input(label="Approximate date", value=document.document_date_raw or "")
|
||||||
|
.props("outlined bg-white")
|
||||||
|
)
|
||||||
|
|
||||||
|
location_input = (
|
||||||
|
ui.input(label="Document location", value=document.location_created or "")
|
||||||
|
.props("outlined bg-white")
|
||||||
|
.classes("w-full")
|
||||||
|
)
|
||||||
|
archive_input = (
|
||||||
|
ui.input(label="Archive identifier", value=document.archive_identifier or "")
|
||||||
|
.props("outlined bg-white")
|
||||||
|
.classes("w-full")
|
||||||
|
)
|
||||||
|
notes_input = (
|
||||||
|
ui.textarea(label="Notes", value=document.notes or "").props("outlined bg-white autogrow").classes("w-full")
|
||||||
|
)
|
||||||
|
|
||||||
|
people = sorted(await document_service.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}
|
||||||
|
)
|
||||||
|
existing_author = next(
|
||||||
|
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
author_value = str(existing_author.person_id) if existing_author is not None else ""
|
||||||
|
|
||||||
|
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=author_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")
|
||||||
|
|
||||||
|
async def submit_edit() -> None:
|
||||||
|
candidate_name = (name_input.value or "").strip()
|
||||||
|
candidate_type = (document_type_input.value or "").strip()
|
||||||
|
if not candidate_name:
|
||||||
|
ui.notify("Document name is required.", type="warning")
|
||||||
|
return
|
||||||
|
if not candidate_type:
|
||||||
|
ui.notify("Document type is required.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
parsed_date: date | None = None
|
||||||
|
candidate_date_text = (date_input.value or "").strip()
|
||||||
|
if candidate_date_text:
|
||||||
|
try:
|
||||||
|
parsed_date = date.fromisoformat(candidate_date_text)
|
||||||
|
except ValueError:
|
||||||
|
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
candidate = Document(
|
||||||
|
id=document.id,
|
||||||
|
name=candidate_name,
|
||||||
|
document_type=candidate_type,
|
||||||
|
document_date=parsed_date,
|
||||||
|
document_date_raw=(date_raw_input.value or "").strip() or None,
|
||||||
|
location_created=(location_input.value or "").strip() or None,
|
||||||
|
notes=(notes_input.value or "").strip() or None,
|
||||||
|
archive_identifier=(archive_input.value or "").strip() or None,
|
||||||
|
created_at=document.created_at,
|
||||||
|
updated_at=document.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await document_service.update_document(candidate)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Save failed", operation="documents.edit.save")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_author = (author_select.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")
|
||||||
|
|
||||||
|
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 document_service.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 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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
@@ -8,10 +8,13 @@ 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 JobSourceStatus
|
||||||
from transcription.db.models import 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 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
|
||||||
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
|
||||||
@@ -23,6 +26,7 @@ from transcription.ui.components.primitives import destructive_button
|
|||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
from transcription.ui.components.table.jobs import render_jobs_table
|
from transcription.ui.components.table.jobs import render_jobs_table
|
||||||
|
from transcription.ui.theme import apply_archival_theme
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
from transcription.worker import resolve_worker_notifier
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
@@ -35,7 +39,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")
|
||||||
|
|
||||||
@@ -67,14 +71,12 @@ 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(
|
page_header("Create Processing Job", subtitle="Queue source files for AI transcription and entity processing.")
|
||||||
"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:
|
||||||
@@ -89,9 +91,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
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(
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
||||||
"flat"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
uploaded_files: list[tuple[str, bytes]] = []
|
uploaded_files: list[tuple[str, bytes]] = []
|
||||||
@@ -139,16 +139,18 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with ui.column().classes("gap-1 w-full mt-2"):
|
with ui.column().classes("gap-1 w-full mt-2"):
|
||||||
for index, (filename, _) in ordered_uploads:
|
for index, (filename, _) in ordered_uploads:
|
||||||
with ui.row().classes("w-full items-center justify-between ui-row-surface p-2"):
|
with ui.row().classes(
|
||||||
|
"w-full items-center justify-between ui-row-surface p-2"
|
||||||
|
):
|
||||||
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
|
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
|
||||||
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
|
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
|
||||||
"flat round dense color=negative text-xs"
|
"flat round dense color=negative text-xs"
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes(
|
||||||
"flat dense"
|
"text-xs text-red-800"
|
||||||
).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()
|
||||||
@@ -204,8 +206,8 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
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")
|
||||||
|
|
||||||
@ui.page("/jobs/{job_id}")
|
@ui.page("/jobs/{job_id}")
|
||||||
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
async def job_detail_page(job_id: str, request: Request, 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")
|
||||||
|
|
||||||
@@ -226,6 +228,20 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
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"),
|
||||||
@@ -255,9 +271,117 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
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}/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")
|
||||||
|
|
||||||
|
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-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")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
non_transcribed_count = sum(1 for job_source in job.job_sources if job_source.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")
|
@ui.page("/jobs/{job_id}/delete")
|
||||||
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
async def job_delete_page(job_id: str, 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")
|
||||||
|
|
||||||
@@ -283,9 +407,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.label("Delete is blocked while the job is processing.").classes(
|
ui.label("Delete is blocked while the job is processing.").classes(
|
||||||
"text-xs text-red-800 font-bold mt-2"
|
"text-xs text-red-800 font-bold mt-2"
|
||||||
)
|
)
|
||||||
ui.label("Wait for processing to complete, then retry delete.").classes(
|
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs ui-text-muted italic")
|
||||||
"text-xs ui-text-muted italic"
|
|
||||||
)
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||||
ui.button(
|
ui.button(
|
||||||
"Back to Job",
|
"Back to Job",
|
||||||
@@ -299,9 +421,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
|
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
|
||||||
if job.job_sources:
|
if job.job_sources:
|
||||||
ui.label("Related JobSource links will be removed as part of delete.").classes(
|
ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs ui-text-muted")
|
||||||
"text-xs ui-text-muted"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def submit_delete() -> None:
|
async def submit_delete() -> None:
|
||||||
try:
|
try:
|
||||||
@@ -327,4 +447,4 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
icon="delete_forever",
|
icon="delete_forever",
|
||||||
variant="solid",
|
variant="solid",
|
||||||
)
|
)
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
|
||||||
@@ -5,19 +5,19 @@ 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
|
from uuid import UUID
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings, get_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
|
from transcription.services.documents import (
|
||||||
from transcription.services.documents import DocumentService
|
DocumentError,
|
||||||
from transcription.services.documents import PersonDeleteBlockedError
|
DocumentService,
|
||||||
from transcription.services.store import UploadError
|
)
|
||||||
from transcription.services.store import store_person_portrait
|
from transcription.services.store import UploadError, 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 metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
@@ -25,9 +25,9 @@ from transcription.ui.components.error_presenter import show_error
|
|||||||
from transcription.ui.components.primitives import destructive_button
|
from transcription.ui.components.primitives import destructive_button
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
from transcription.ui.components.table.people import PersonTableRow
|
from transcription.ui.components.table.people import PersonTableRow, render_people_table
|
||||||
from transcription.ui.components.table.people import 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
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
@@ -44,11 +44,12 @@ def _parse_optional_date(value: str | None, *, label: str) -> date | None:
|
|||||||
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
|
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
|
||||||
|
|
||||||
|
|
||||||
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings) -> None:
|
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,
|
||||||
@@ -73,7 +74,8 @@ 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")
|
||||||
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs ui-text-muted")
|
portrait_dir = settings.upload_dir / "persons" / str(person_id)
|
||||||
|
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:
|
||||||
@@ -104,7 +106,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/people")
|
@ui.page("/people")
|
||||||
async def people_page(session_factory: SessionFactoryDep) -> None:
|
async def people_page(session_factory: SessionFactoryDep) -> None:
|
||||||
|
apply_archival_theme()
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
@@ -142,9 +144,10 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/people/new")
|
@ui.page("/people/new")
|
||||||
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
apply_archival_theme()
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
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"):
|
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.")
|
page_header("Create Person Record", subtitle="Full name is required.")
|
||||||
@@ -167,7 +170,11 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
biography_input = ui.textarea(label="Biography").props("outlined bg-white autogrow").classes("w-full")
|
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")
|
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))
|
_bind_portrait_file_picker(
|
||||||
|
portrait_path_input,
|
||||||
|
settings=_resolve_runtime_settings(request),
|
||||||
|
person_id=draft_person_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def submit_create() -> None:
|
async def submit_create() -> None:
|
||||||
full_name = (full_name_input.value or "").strip()
|
full_name = (full_name_input.value or "").strip()
|
||||||
@@ -183,6 +190,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
return
|
return
|
||||||
|
|
||||||
candidate = Person(
|
candidate = Person(
|
||||||
|
id=draft_person_id,
|
||||||
full_name=full_name,
|
full_name=full_name,
|
||||||
display_name=(display_name_input.value or "").strip() or None,
|
display_name=(display_name_input.value or "").strip() or None,
|
||||||
maiden_name=(maiden_name_input.value or "").strip() or None,
|
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||||
@@ -211,7 +219,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/people/{person_id}")
|
@ui.page("/people/{person_id}")
|
||||||
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
apply_archival_theme()
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
@@ -271,7 +279,9 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||||
with archival_card(title="Biography"):
|
with archival_card(title="Biography"):
|
||||||
ui.label(person.biography or "No biography recorded.").classes("p-2 ui-note-box text-xs w-full")
|
ui.label(person.biography or "No biography recorded.").classes(
|
||||||
|
"p-2 ui-note-box text-xs w-full"
|
||||||
|
)
|
||||||
|
|
||||||
with archival_card(title="Linked Documents"):
|
with archival_card(title="Linked Documents"):
|
||||||
if not person.document_people:
|
if not person.document_people:
|
||||||
@@ -283,7 +293,9 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
document = link.document
|
document = link.document
|
||||||
if document is None:
|
if document is None:
|
||||||
continue
|
continue
|
||||||
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
|
with ui.row().classes(
|
||||||
|
"w-full justify-between items-center ui-row-surface p-2"
|
||||||
|
):
|
||||||
with ui.column().classes("gap-0"):
|
with ui.column().classes("gap-0"):
|
||||||
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
|
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.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
|
||||||
@@ -297,7 +309,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/people/{person_id}/edit")
|
@ui.page("/people/{person_id}/edit")
|
||||||
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
apply_archival_theme()
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
@@ -325,9 +337,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
|
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
|
||||||
"outlined bg-white"
|
"outlined bg-white"
|
||||||
)
|
)
|
||||||
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props(
|
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined bg-white")
|
||||||
"outlined bg-white"
|
|
||||||
)
|
|
||||||
|
|
||||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
birth_date_input = ui.input(
|
birth_date_input = ui.input(
|
||||||
@@ -337,9 +347,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
birth_date_raw_input = ui.input(
|
birth_date_raw_input = ui.input(
|
||||||
label="Birth date (approximate)", value=person.birth_date_raw or ""
|
label="Birth date (approximate)", value=person.birth_date_raw or ""
|
||||||
).props("outlined bg-white")
|
).props("outlined bg-white")
|
||||||
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props(
|
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined bg-white")
|
||||||
"outlined bg-white"
|
|
||||||
)
|
|
||||||
|
|
||||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
death_date_input = ui.input(
|
death_date_input = ui.input(
|
||||||
@@ -349,21 +357,19 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
death_date_raw_input = ui.input(
|
death_date_raw_input = ui.input(
|
||||||
label="Death date (approximate)", value=person.death_date_raw or ""
|
label="Death date (approximate)", value=person.death_date_raw or ""
|
||||||
).props("outlined bg-white")
|
).props("outlined bg-white")
|
||||||
death_place_input = ui.input(label="Death place", value=person.death_place or "").props(
|
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined bg-white")
|
||||||
"outlined bg-white"
|
|
||||||
)
|
|
||||||
|
|
||||||
biography_input = (
|
biography_input = (
|
||||||
ui.textarea(label="Biography", value=person.biography or "")
|
ui.textarea(label="Biography", value=person.biography or "").props("outlined bg-white autogrow").classes("w-full")
|
||||||
.props("outlined bg-white autogrow")
|
|
||||||
.classes("w-full")
|
|
||||||
)
|
)
|
||||||
portrait_path_input = (
|
portrait_path_input = (
|
||||||
ui.input(label="Portrait path", value=person.portrait_path or "")
|
ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined bg-white").classes("w-full")
|
||||||
.props("outlined bg-white")
|
)
|
||||||
.classes("w-full")
|
_bind_portrait_file_picker(
|
||||||
|
portrait_path_input,
|
||||||
|
settings=_resolve_runtime_settings(request),
|
||||||
|
person_id=person.id,
|
||||||
)
|
)
|
||||||
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
|
|
||||||
|
|
||||||
async def submit_edit() -> None:
|
async def submit_edit() -> None:
|
||||||
full_name = (full_name_input.value or "").strip()
|
full_name = (full_name_input.value or "").strip()
|
||||||
@@ -407,13 +413,11 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
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("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(
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
|
||||||
"flat"
|
|
||||||
)
|
|
||||||
|
|
||||||
@ui.page("/people/{person_id}/delete")
|
@ui.page("/people/{person_id}/delete")
|
||||||
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
apply_archival_theme()
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
@@ -439,35 +443,15 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
|
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
|
||||||
|
|
||||||
if person.document_people:
|
if person.document_people:
|
||||||
ui.label("Delete is blocked because linked documents exist.").classes(
|
ui.label(
|
||||||
"text-xs text-red-800 font-bold mt-2"
|
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(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(
|
ui.label("This action permanently deletes the person record.").classes("text-xs text-red-800 font-medium")
|
||||||
"text-xs text-red-800 font-medium"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def submit_delete() -> None:
|
async def submit_delete() -> None:
|
||||||
try:
|
try:
|
||||||
await people_service.delete_person(person)
|
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:
|
except DocumentError as exc:
|
||||||
if exc.category == ErrorCategory.NOT_FOUND:
|
if exc.category == ErrorCategory.NOT_FOUND:
|
||||||
ui.notify("Person not found.", type="warning")
|
ui.notify("Person not found.", type="warning")
|
||||||
@@ -489,6 +473,4 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
icon="delete_forever",
|
icon="delete_forever",
|
||||||
variant="solid",
|
variant="solid",
|
||||||
)
|
)
|
||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
|
||||||
"flat"
|
|
||||||
)
|
|
||||||
@@ -9,20 +9,23 @@ from fastapi import Request
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import JobSource, Source
|
||||||
from transcription.services.documents import DocumentError
|
from transcription.services.documents import DocumentError, DocumentService
|
||||||
from transcription.services.documents import DocumentService
|
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
from transcription.services.transcription import TranscriptionNotFoundError
|
from transcription.services.transcription import (
|
||||||
from transcription.services.transcription import TranscriptionService
|
SourceDeleteBlockedError,
|
||||||
|
TranscriptionNotFoundError,
|
||||||
|
TranscriptionService,
|
||||||
|
)
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.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.data_display import metadata_row
|
||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
from transcription.ui.components.error_presenter import show_error
|
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.primitives import section_header_row
|
||||||
from transcription.ui.components.table.sources import SourceTableRow
|
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
|
||||||
from transcription.ui.components.table.sources import render_sources_table
|
from transcription.ui.theme import apply_archival_theme
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
@@ -33,7 +36,7 @@ def register_page() -> None:
|
|||||||
|
|
||||||
@ui.page("/sources")
|
@ui.page("/sources")
|
||||||
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
apply_archival_theme()
|
||||||
sources_service = TranscriptionService(session_factory=session_factory)
|
sources_service = TranscriptionService(session_factory=session_factory)
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
documents_service = DocumentService(session_factory=session_factory)
|
documents_service = DocumentService(session_factory=session_factory)
|
||||||
@@ -49,24 +52,27 @@ def register_page() -> None:
|
|||||||
job_label = None
|
job_label = None
|
||||||
back_path = None
|
back_path = None
|
||||||
sources: list[Source] = []
|
sources: list[Source] = []
|
||||||
|
job_source_by_source_id: dict[UUID, JobSource] = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if document_id is not None:
|
if document_id is not None:
|
||||||
document = await documents_service.read_document_detail(document_id=document_id)
|
document = await documents_service.read_document_detail(document_id=document_id)
|
||||||
document_name = document.name
|
document_name = document.name
|
||||||
back_path = f"/documents/{document.id}"
|
back_path = f"/documents/{document.id}"
|
||||||
sources = sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
sources = list(sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
|
||||||
elif job_id is not None:
|
elif job_id is not None:
|
||||||
job = await jobs_service.read_job(job_id=job_id)
|
job = await jobs_service.read_job(job_id=job_id)
|
||||||
job_label = str(job.id)
|
job_label = str(job.id)
|
||||||
back_path = f"/jobs/{job.id}"
|
back_path = f"/jobs/{job.id}"
|
||||||
job_sources = await sources_service.list_job_sources(job_id=job.id)
|
job_sources = await sources_service.list_job_sources(job_id=job.id)
|
||||||
|
job_source_by_source_id = {
|
||||||
|
job_source.source_id: job_source
|
||||||
|
for job_source in job_sources
|
||||||
|
}
|
||||||
sources = [job_source.source for job_source in job_sources if job_source.source is not None]
|
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()))
|
sources.sort(key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||||
else:
|
else:
|
||||||
sources = sorted(
|
sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)))
|
||||||
await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)
|
|
||||||
)
|
|
||||||
except DocumentError:
|
except DocumentError:
|
||||||
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||||
return
|
return
|
||||||
@@ -90,9 +96,9 @@ def register_page() -> None:
|
|||||||
|
|
||||||
if back_path is not None:
|
if back_path is not None:
|
||||||
back_label = "Back to Document" if document_id is not None else "Back to Job"
|
back_label = "Back to Document" if document_id is not None else "Back to Job"
|
||||||
ui.button(
|
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
|
||||||
back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back"
|
"ui-btn-primary text-xs"
|
||||||
).classes("ui-btn-primary text-xs")
|
)
|
||||||
|
|
||||||
# Format source records into read-model rows for the table renderer
|
# Format source records into read-model rows for the table renderer
|
||||||
rows = [
|
rows = [
|
||||||
@@ -102,6 +108,16 @@ def register_page() -> None:
|
|||||||
upload_name=source.upload_name,
|
upload_name=source.upload_name,
|
||||||
filename=source.filename,
|
filename=source.filename,
|
||||||
document_id=source.document_id,
|
document_id=source.document_id,
|
||||||
|
job_source_status=(
|
||||||
|
job_source_by_source_id[source.id].status.value
|
||||||
|
if source.id in job_source_by_source_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
job_source_error_detail=(
|
||||||
|
job_source_by_source_id[source.id].error_detail
|
||||||
|
if source.id in job_source_by_source_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for source in sources
|
for source in sources
|
||||||
]
|
]
|
||||||
@@ -109,6 +125,7 @@ def register_page() -> None:
|
|||||||
|
|
||||||
@ui.page("/sources/{source_id}")
|
@ui.page("/sources/{source_id}")
|
||||||
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
apply_archival_theme()
|
||||||
sources_service = TranscriptionService(session_factory=session_factory)
|
sources_service = TranscriptionService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/sources")
|
render_navigation_header(current_path="/sources")
|
||||||
|
|
||||||
@@ -131,24 +148,30 @@ def register_page() -> None:
|
|||||||
|
|
||||||
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||||
with section_header_row():
|
with section_header_row():
|
||||||
page_header(
|
page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
|
||||||
f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if back_path is not None:
|
with ui.row().classes("items-center gap-2"):
|
||||||
back_label = (
|
if back_path is not None:
|
||||||
"Back to Document"
|
back_label = (
|
||||||
if "document_id" in request.query_params
|
"Back to Document"
|
||||||
else "Back to Job"
|
if "document_id" in request.query_params
|
||||||
if "job_id" in request.query_params
|
else "Back to Job"
|
||||||
else "Back to Sources"
|
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"
|
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
|
||||||
).classes("ui-btn-primary text-xs")
|
"ui-btn-primary text-xs"
|
||||||
else:
|
)
|
||||||
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
|
else:
|
||||||
"flat text-xs"
|
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
|
||||||
|
"flat text-xs"
|
||||||
|
)
|
||||||
|
|
||||||
|
destructive_button(
|
||||||
|
"Delete Source",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/sources/{source.id}/delete{_back_query(request.query_params)}"),
|
||||||
|
icon="delete",
|
||||||
|
extra_classes="text-xs",
|
||||||
)
|
)
|
||||||
|
|
||||||
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||||
@@ -168,10 +191,21 @@ def register_page() -> None:
|
|||||||
source.date_revised.isoformat() if source.date_revised else "Not revised",
|
source.date_revised.isoformat() if source.date_revised else "Not revised",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with archival_card(title="Job Source Outcomes"):
|
||||||
|
if not source.job_sources:
|
||||||
|
ui.label("No job-source execution records found for this source.").classes("text-xs ui-text-muted")
|
||||||
|
else:
|
||||||
|
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
|
||||||
|
with ui.column().classes("w-full gap-1 p-2 ui-row-surface rounded"):
|
||||||
|
metadata_row("Job ID:", str(job_source.job_id))
|
||||||
|
metadata_row("Status:", job_source.status.value)
|
||||||
|
metadata_row("Executed At:", job_source.executed_at.isoformat())
|
||||||
|
metadata_row("Error Detail:", job_source.error_detail or "None")
|
||||||
|
|
||||||
with archival_card(title="Automated Raw Transcription"):
|
with archival_card(title="Automated Raw Transcription"):
|
||||||
ui.textarea(value=_source_transcription_text(source) or "").props(
|
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes(
|
||||||
"outlined autogrow readonly bg-white"
|
"w-full text-xs font-mono"
|
||||||
).classes("w-full text-xs font-mono")
|
)
|
||||||
|
|
||||||
with archival_card(title="Curated Human Transcription"):
|
with archival_card(title="Curated Human Transcription"):
|
||||||
revision_input = (
|
revision_input = (
|
||||||
@@ -196,9 +230,74 @@ def register_page() -> None:
|
|||||||
ui.navigate.to(request.url.path + _back_query(request.query_params))
|
ui.navigate.to(request.url.path + _back_query(request.query_params))
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
ui.button("Save Revision", on_click=save_revision, icon="save").classes(
|
ui.button("Save Revision", on_click=save_revision, icon="save").classes("ui-btn-primary text-xs")
|
||||||
"ui-btn-primary text-xs"
|
|
||||||
)
|
@ui.page("/sources/{source_id}/delete")
|
||||||
|
async def source_delete_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
apply_archival_theme()
|
||||||
|
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.delete.load")
|
||||||
|
return
|
||||||
|
|
||||||
|
back_path = _back_path_from_query(request.query_params) or "/sources"
|
||||||
|
next_sources_path = f"/sources{_back_query(request.query_params)}"
|
||||||
|
linked_count = len(source.job_sources)
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||||
|
page_header("Delete Source Record")
|
||||||
|
|
||||||
|
with archival_card(extra_classes="gap-2"):
|
||||||
|
metadata_row("Source ID:", str(source.id))
|
||||||
|
metadata_row("Upload Name:", source.upload_name)
|
||||||
|
metadata_row("Linked Jobs:", str(linked_count))
|
||||||
|
|
||||||
|
if linked_count > 0:
|
||||||
|
ui.label("Delete is only available for unlinked sources.").classes("text-xs text-red-800 font-bold mt-2")
|
||||||
|
ui.label("This source is linked to one or more jobs and cannot be deleted from this view.").classes(
|
||||||
|
"text-xs ui-text-muted italic"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ui.label("This action permanently deletes the source record.").classes("text-xs text-red-800 font-medium")
|
||||||
|
|
||||||
|
async def submit_delete() -> None:
|
||||||
|
try:
|
||||||
|
await sources_service.delete_unlinked_source(source_id=source.id)
|
||||||
|
except SourceDeleteBlockedError as exc:
|
||||||
|
ui.notify(exc.message, type="warning")
|
||||||
|
return
|
||||||
|
except TranscriptionNotFoundError:
|
||||||
|
ui.notify("Source not found.", type="warning")
|
||||||
|
ui.navigate.to(next_sources_path)
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Delete source failed", operation="sources.delete")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Source deleted", type="positive")
|
||||||
|
ui.navigate.to(next_sources_path)
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
destructive_button(
|
||||||
|
"Delete source permanently",
|
||||||
|
on_click=submit_delete,
|
||||||
|
icon="delete_forever",
|
||||||
|
variant="solid",
|
||||||
|
)
|
||||||
|
ui.button("Cancel", on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
@ui.page("/documents/{document_id}/sources")
|
@ui.page("/documents/{document_id}/sources")
|
||||||
async def document_sources_page(document_id: str) -> RedirectResponse:
|
async def document_sources_page(document_id: str) -> RedirectResponse:
|
||||||
@@ -256,4 +355,4 @@ def _source_transcription_text(source: Source) -> str | None:
|
|||||||
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
|
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
|
||||||
if job_source.error_detail:
|
if job_source.error_detail:
|
||||||
return job_source.error_detail
|
return job_source.error_detail
|
||||||
return None
|
return None
|
||||||
File diff suppressed because one or more lines are too long
@@ -152,8 +152,15 @@ async def run_worker_loop(
|
|||||||
wake_event.clear()
|
wake_event.clear()
|
||||||
|
|
||||||
processed_any = False
|
processed_any = False
|
||||||
while await process_next_queued_job(session_factory=session_factory):
|
while True:
|
||||||
processed_any = True
|
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
|
||||||
|
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)
|
||||||
|
|||||||
@@ -1,25 +1,49 @@
|
|||||||
"""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, tmp_path: Path, monkeypatch
|
self, async_session, default_session_factory, 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)
|
||||||
@@ -59,12 +83,12 @@ class TestPipelineSuccessFlow:
|
|||||||
_fake_transcribe_document_image,
|
_fake_transcribe_document_image,
|
||||||
)
|
)
|
||||||
|
|
||||||
services = ServiceBundle()
|
services = _build_services(default_session_factory)
|
||||||
queued_job = await services.jobs.read_next_queued_job(session=async_session)
|
queued_job = await services.jobs.read_job(job_id=upload_result.job_id, 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 async_session.get(Job, upload_result.job_id)
|
job = await services.jobs.read_job(job_id=upload_result.job_id, session=async_session)
|
||||||
|
|
||||||
assert processed is True
|
assert processed is True
|
||||||
assert job is not None
|
assert job is not None
|
||||||
@@ -72,13 +96,212 @@ 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(self, async_session, tmp_path: Path, monkeypatch):
|
async def test_upload_then_worker_persists_failed_terminal_state(
|
||||||
|
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(
|
||||||
@@ -103,12 +326,12 @@ class TestPipelineFailureFlow:
|
|||||||
_fake_transcribe_document_image,
|
_fake_transcribe_document_image,
|
||||||
)
|
)
|
||||||
|
|
||||||
services = ServiceBundle()
|
services = _build_services(default_session_factory)
|
||||||
queued_job = await services.jobs.read_next_queued_job(session=async_session)
|
queued_job = await services.jobs.read_job(job_id=upload_result.job_id, 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 async_session.get(Job, upload_result.job_id)
|
job = await services.jobs.read_job(job_id=upload_result.job_id, session=async_session)
|
||||||
|
|
||||||
assert processed is True
|
assert processed is True
|
||||||
assert job is not None
|
assert job is not None
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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
|
||||||
@@ -14,7 +15,6 @@ 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,8 +88,9 @@ 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):
|
async def test_delete_document_succeeds_when_unlinked(default_session_factory, tmp_path):
|
||||||
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(
|
||||||
@@ -99,12 +100,45 @@ async def test_delete_document_succeeds_when_unlinked(default_session_factory):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -153,7 +187,7 @@ async def test_update_person_refreshes_updated_timestamp(default_session_factory
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_delete_person_blocks_when_linked_documents_exist(default_session_factory):
|
async def test_delete_person_removes_links_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(
|
||||||
@@ -172,8 +206,13 @@ async def test_delete_person_blocks_when_linked_documents_exist(default_session_
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from transcription.db.models import JobStatus
|
|||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.services.jobs import 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
|
||||||
|
|
||||||
|
|
||||||
@@ -224,3 +226,157 @@ 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)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -9,7 +10,9 @@ 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
|
||||||
@@ -66,7 +69,52 @@ 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()
|
||||||
|
|||||||
@@ -93,10 +93,11 @@ 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):
|
async def test_delete_source_from_job_context_removes_source_and_single_link(self, default_session_factory, tmp_path):
|
||||||
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)
|
||||||
@@ -104,12 +105,16 @@ 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="uploads/delete.jpg",
|
file_path=str(stored_path),
|
||||||
)
|
)
|
||||||
async with transcriptions._session_scope() as session:
|
async with transcriptions._session_scope() as session:
|
||||||
session.add(source)
|
session.add(source)
|
||||||
@@ -122,6 +127,7 @@ 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):
|
||||||
@@ -154,3 +160,60 @@ 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)
|
||||||
|
|||||||
+12
-4
@@ -8,11 +8,19 @@ from transcription import __main__ as entrypoint
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_main_uses_cli_factory_import_string(monkeypatch):
|
def test_main_passes_constructed_app_to_uvicorn(monkeypatch):
|
||||||
"""Startup uses an importable factory so Uvicorn owns app creation."""
|
"""Non-reload execution keeps the parsed settings instance in the app."""
|
||||||
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",
|
||||||
@@ -22,8 +30,8 @@ def test_main_uses_cli_factory_import_string(monkeypatch):
|
|||||||
entrypoint.main()
|
entrypoint.main()
|
||||||
|
|
||||||
assert captured == {
|
assert captured == {
|
||||||
"application": "transcription.__main__:create_cli_app",
|
"application": application,
|
||||||
"factory": True,
|
"factory": False,
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8123,
|
"port": 8123,
|
||||||
"log_level": "debug",
|
"log_level": "debug",
|
||||||
|
|||||||
+2
-43
@@ -1,5 +1,7 @@
|
|||||||
"""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
|
||||||
@@ -201,46 +203,3 @@ 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"]
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import re
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from transcription.ui.pages import register_pages
|
from transcription.ui import register_pages
|
||||||
from transcription.ui.resources import read_css
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
@@ -86,7 +86,7 @@ class TestPageRendering:
|
|||||||
assert "document links" in response.text.lower()
|
assert "document links" in response.text.lower()
|
||||||
assert "Sources" in response.text
|
assert "Sources" in response.text
|
||||||
assert "Jobs" in response.text
|
assert "Jobs" in response.text
|
||||||
assert "Delete job" not in response.text
|
assert "Delete Job" in response.text
|
||||||
|
|
||||||
def test_job_detail_page_rejects_invalid_id(self, app_client):
|
def test_job_detail_page_rejects_invalid_id(self, app_client):
|
||||||
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
|
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
|
||||||
@@ -105,19 +105,41 @@ class TestPageRendering:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Job not found" in response.text
|
assert "Job not found" in response.text
|
||||||
|
|
||||||
def test_job_detail_page_hides_delete_action(self, app_client, seed_job):
|
def test_job_detail_page_shows_cancel_and_resubmit_when_queued(self, app_client, seed_job):
|
||||||
"""GET /ui/jobs/{job_id} does not expose job deletion controls in this revision."""
|
"""GET /ui/jobs/{job_id} exposes cancel/resubmit controls for queued jobs."""
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
job_id = seed_job(
|
job_id = seed_job(
|
||||||
filename="no-revision.pdf",
|
filename="no-revision.pdf",
|
||||||
status=JobStatus.TRANSCRIBED,
|
status=JobStatus.QUEUED,
|
||||||
transcription_text="original text",
|
transcription_text=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
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 "Delete job" not in response.text
|
assert "Cancel" in response.text
|
||||||
|
assert "Resubmit" in response.text
|
||||||
|
assert "Delete Job" in response.text
|
||||||
|
|
||||||
|
def test_job_cancel_page_renders_confirmation(self, app_client, seed_job):
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(filename="cancel-ready.pdf", status=JobStatus.PROCESSING)
|
||||||
|
|
||||||
|
response = client.get(f"/ui/jobs/{job_id}/cancel")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Cancel Processing Job" in response.text
|
||||||
|
assert "Cancel job" in response.text
|
||||||
|
|
||||||
|
def test_job_resubmit_page_renders_confirmation(self, app_client, seed_job):
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(filename="resubmit-ready.pdf", status=JobStatus.FAILED, transcription_text=None)
|
||||||
|
|
||||||
|
response = client.get(f"/ui/jobs/{job_id}/resubmit")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Resubmit Job" in response.text
|
||||||
|
assert "Resubmit now" in response.text
|
||||||
|
|
||||||
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
|
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ 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
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ class TestPeoplePageRendering:
|
|||||||
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):
|
def test_person_delete_page_warns_links_will_be_removed_when_linked_documents_exist(self, app_client):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
|
|
||||||
async def _seed_links() -> str:
|
async def _seed_links() -> str:
|
||||||
@@ -233,6 +233,5 @@ class TestPeoplePageRendering:
|
|||||||
response = client.get(f"/ui/people/{person_id}/delete")
|
response = client.get(f"/ui/people/{person_id}/delete")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Delete is blocked because linked documents exist." in response.text
|
assert "This will also remove 1 linked document relationship(s)." in response.text
|
||||||
assert "Linked documents: 1" in response.text
|
assert "Delete person permanently" in response.text
|
||||||
assert "Go to Documents" in response.text
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlmodel import select
|
|||||||
from transcription.db import session_scope
|
from transcription.db import session_scope
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobStatus
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +105,23 @@ class TestSourcesPageRendering:
|
|||||||
assert "Sources for Job" in response.text
|
assert "Sources for Job" in response.text
|
||||||
assert "Back to 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
|
||||||
|
assert "Job Source Status" in response.text
|
||||||
|
|
||||||
|
def test_sources_page_job_context_shows_job_source_status_and_error_detail(self, app_client, seed_job):
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(
|
||||||
|
filename="job-failed-page.png",
|
||||||
|
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
|
||||||
|
|
||||||
def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
|
def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
@@ -138,3 +156,81 @@ class TestSourcesPageRendering:
|
|||||||
assert "human revision text" in response.text
|
assert "human revision text" in response.text
|
||||||
assert "Page Number:" in response.text
|
assert "Page Number:" in response.text
|
||||||
assert "Stored Filename:" in response.text
|
assert "Stored Filename:" in response.text
|
||||||
|
assert "Delete Source" in response.text
|
||||||
|
|
||||||
|
def test_source_detail_page_displays_job_source_status_and_error_detail(self, app_client, seed_job):
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(
|
||||||
|
filename="failed-source.png",
|
||||||
|
status=JobStatus.FAILED,
|
||||||
|
transcription_text=None,
|
||||||
|
error_detail="Provider timed out",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_source_id() -> str:
|
||||||
|
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
|
||||||
|
return str(source.id)
|
||||||
|
|
||||||
|
source_id = asyncio.run(_get_source_id())
|
||||||
|
response = client.get(f"/ui/sources/{source_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "JOB SOURCE OUTCOMES" in response.text
|
||||||
|
assert "Status:" in response.text
|
||||||
|
assert "failed" in response.text.lower()
|
||||||
|
assert "Error Detail:" in response.text
|
||||||
|
assert "Provider timed out" in response.text
|
||||||
|
|
||||||
|
def test_source_delete_page_blocks_when_source_is_job_linked(self, app_client, seed_job):
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(filename="linked-source.png", transcription_text="linked text")
|
||||||
|
|
||||||
|
async def _get_source_id() -> str:
|
||||||
|
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
|
||||||
|
return str(source.id)
|
||||||
|
|
||||||
|
source_id = asyncio.run(_get_source_id())
|
||||||
|
response = client.get(f"/ui/sources/{source_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete Source Record" in response.text
|
||||||
|
assert "Delete is only available for unlinked sources." in response.text
|
||||||
|
|
||||||
|
def test_source_delete_page_allows_unlinked_source(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_unlinked_source() -> str:
|
||||||
|
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()
|
||||||
|
return str(source.id)
|
||||||
|
|
||||||
|
source_id = asyncio.run(_seed_unlinked_source())
|
||||||
|
response = client.get(f"/ui/sources/{source_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete Source Record" in response.text
|
||||||
|
assert "Delete source permanently" in response.text
|
||||||
|
assert "Delete is only available for unlinked sources." not in response.text
|
||||||
|
|||||||
@@ -13,15 +13,34 @@ 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"
|
assert response.headers["location"] == "/ui/homepage"
|
||||||
|
|
||||||
def test_ui_redirects_to_documents(self, app_client):
|
def test_ui_redirects_to_homepage(self, app_client):
|
||||||
"""GET /ui redirects to the documents page."""
|
"""GET /ui redirects to the homepage."""
|
||||||
_, 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/documents"
|
assert response.headers["location"] == "/ui/homepage"
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user