generated from john/python-template
UI update complete?
This commit is contained in:
@@ -20,10 +20,8 @@ from .config import Settings
|
||||
from .config import configure_logging
|
||||
from .config import get_settings
|
||||
from .db import create_all
|
||||
from .db import dispose_database_runtime
|
||||
from .db import initialize_database_runtime
|
||||
from .db.engine import get_database_url
|
||||
from .db.engine import resolve_engine
|
||||
from .db.session import dispose_session_factory
|
||||
from .services import ServiceBundle
|
||||
from .services.jobs import JobService
|
||||
from .ui import register_pages
|
||||
@@ -41,7 +39,7 @@ async def _lifespan(app: FastAPI):
|
||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||
|
||||
if settings.should_bootstrap_schema:
|
||||
await create_all(engine=resolve_engine(settings=settings))
|
||||
await create_all(engine=app.state.runtime.engine)
|
||||
|
||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -49,10 +47,7 @@ async def _lifespan(app: FastAPI):
|
||||
await _recover_stale_processing_jobs(app)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
stack.push_async_callback(
|
||||
dispose_session_factory,
|
||||
database_url=get_database_url(settings),
|
||||
)
|
||||
stack.push_async_callback(dispose_database_runtime)
|
||||
stop_event, worker_notifier = await stack.enter_async_context(
|
||||
worker_consumer_lifespan(
|
||||
session_factory=app.state.runtime.session_factory,
|
||||
@@ -95,7 +90,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
@app.get("/ui", include_in_schema=False)
|
||||
async def ui_redirect() -> RedirectResponse:
|
||||
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
return RedirectResponse(url="/ui/jobs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
|
||||
@app.get("/healthz")
|
||||
def health() -> dict[str, str]:
|
||||
|
||||
@@ -151,6 +151,18 @@ class Job(SQLModel, table=True):
|
||||
|
||||
return "unknown"
|
||||
|
||||
@property
|
||||
def error_detail(self) -> str | None:
|
||||
"""Return the first available source-level error detail for the job."""
|
||||
if not self.job_sources:
|
||||
return None
|
||||
|
||||
for job_source in self.job_sources:
|
||||
if job_source.error_detail:
|
||||
return job_source.error_detail
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class Source(SQLModel, table=True):
|
||||
"""A document source image or PDF page."""
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
@@ -35,6 +37,14 @@ class DocumentAlreadyExistsError(DocumentError):
|
||||
"""Raised when a document with the same name already exists in the database."""
|
||||
|
||||
|
||||
class DocumentDeleteBlockedError(DocumentError):
|
||||
"""Raised when a document delete is blocked by dependent records."""
|
||||
|
||||
|
||||
class PersonDeleteBlockedError(DocumentError):
|
||||
"""Raised when a person delete is blocked by linked documents."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UploadJobResult:
|
||||
"""Summary of created upload records."""
|
||||
@@ -102,6 +112,7 @@ class DocumentService(ServiceBase):
|
||||
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
||||
"""Update an existing document in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
document.updated_at = datetime.now(UTC)
|
||||
merged = await _session.merge(document)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
return merged
|
||||
@@ -109,7 +120,36 @@ class DocumentService(ServiceBase):
|
||||
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
||||
"""Delete a document from the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await _session.delete(document)
|
||||
existing = await _session.get(
|
||||
Document,
|
||||
document.id,
|
||||
options=(
|
||||
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
|
||||
),
|
||||
)
|
||||
if existing is None:
|
||||
raise DocumentError(
|
||||
f"Document with id {document.id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the document id and retry.",
|
||||
)
|
||||
|
||||
has_jobs = bool(existing.jobs)
|
||||
has_sources = bool(existing.sources)
|
||||
if has_jobs or has_sources:
|
||||
blocked_by: list[str] = []
|
||||
if has_sources:
|
||||
blocked_by.append("Sources")
|
||||
if has_jobs:
|
||||
blocked_by.append("Jobs")
|
||||
raise DocumentDeleteBlockedError(
|
||||
f"Document delete blocked by related records: {', '.join(blocked_by)}",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Remove related Sources and Jobs first, then retry deletion.",
|
||||
)
|
||||
|
||||
await _session.delete(existing)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
||||
@@ -131,9 +171,31 @@ class DocumentService(ServiceBase):
|
||||
)
|
||||
return person
|
||||
|
||||
async def read_person_detail(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
|
||||
"""Read a person with eagerly loaded document links for UI detail rendering."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(Person)
|
||||
.options(
|
||||
selectinload(Person.document_people).selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
.where(Person.id == person_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
person = (await _session.exec(query)).first()
|
||||
|
||||
if person is None:
|
||||
raise DocumentError(
|
||||
f"Person with id {person_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the person id and retry.",
|
||||
)
|
||||
return person
|
||||
|
||||
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
||||
"""Update an existing person in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
person.updated_at = datetime.now(UTC)
|
||||
merged = await _session.merge(person)
|
||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||
return merged
|
||||
@@ -141,7 +203,28 @@ class DocumentService(ServiceBase):
|
||||
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
|
||||
"""Delete a person from the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
await _session.delete(person)
|
||||
existing = await _session.get(
|
||||
Person,
|
||||
person.id,
|
||||
options=(
|
||||
selectinload(Person.document_people), # pyright: ignore[reportArgumentType]
|
||||
),
|
||||
)
|
||||
if existing is None:
|
||||
raise DocumentError(
|
||||
f"Person with id {person.id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the person id and retry.",
|
||||
)
|
||||
|
||||
if existing.document_people:
|
||||
raise PersonDeleteBlockedError(
|
||||
"Person delete blocked by linked documents",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Remove linked DocumentPerson records first, then retry deletion.",
|
||||
)
|
||||
|
||||
await _session.delete(existing)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def create_document_person(
|
||||
@@ -215,6 +298,28 @@ class DocumentService(ServiceBase):
|
||||
result = await _session.exec(select(Document))
|
||||
return result.all()
|
||||
|
||||
async def read_document_detail(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
|
||||
"""Read a document with eagerly loaded relations for UI detail rendering."""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(Document)
|
||||
.options(
|
||||
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
.where(Document.id == document_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
document = (await _session.exec(query)).first()
|
||||
if document is None:
|
||||
raise DocumentError(
|
||||
f"Document with id {document_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the document id and retry.",
|
||||
)
|
||||
return document
|
||||
|
||||
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
|
||||
"""List all people in the database."""
|
||||
async with self._session_scope(session) as _session:
|
||||
|
||||
@@ -7,6 +7,8 @@ from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
from ..db.models import Job
|
||||
from ..db.models import JobSource
|
||||
from ..db.models import JobStatus
|
||||
@@ -14,6 +16,10 @@ from ..db.models import Source
|
||||
from .base import ServiceBase
|
||||
|
||||
|
||||
class JobDeleteBlockedError(AppError):
|
||||
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
||||
|
||||
|
||||
class JobService(ServiceBase):
|
||||
"""Thin service class for managing jobs in the database."""
|
||||
|
||||
@@ -183,3 +189,34 @@ class JobService(ServiceBase):
|
||||
|
||||
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
|
||||
return len(stale_jobs)
|
||||
|
||||
async def delete_job_with_guardrails(self, *, job_id: UUID, session: AsyncSession | None = None) -> None:
|
||||
"""Delete a job with lifecycle guardrails and dependent cleanup policy.
|
||||
|
||||
Policy:
|
||||
- Block when the job is actively processing.
|
||||
- Otherwise remove related JobSource rows, then delete the job.
|
||||
"""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(Job)
|
||||
.options(selectinload(Job.job_sources)) # pyright: ignore[reportArgumentType]
|
||||
.where(Job.id == job_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
job = (await _session.exec(query)).first()
|
||||
if job is None:
|
||||
raise ValueError(f"Job with id {job_id} not found")
|
||||
|
||||
if job.status == JobStatus.PROCESSING:
|
||||
raise JobDeleteBlockedError(
|
||||
"Job delete blocked while status is processing",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Wait for processing to complete, or move the job out of processing before deleting.",
|
||||
)
|
||||
|
||||
for job_source in list(job.job_sources):
|
||||
await _session.delete(job_source)
|
||||
|
||||
await _session.delete(job)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from transcription.config import Settings
|
||||
@@ -27,6 +31,15 @@ class UploadError(AppError):
|
||||
"""Raised when uploaded content cannot be persisted safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobCreateResult:
|
||||
"""Summary of explicit Job create records."""
|
||||
|
||||
document_id: UUID
|
||||
job_id: UUID
|
||||
source_ids: tuple[UUID, ...]
|
||||
|
||||
|
||||
async def create_upload_job(
|
||||
*,
|
||||
filename: str,
|
||||
@@ -65,6 +78,66 @@ async def create_upload_job(
|
||||
)
|
||||
|
||||
|
||||
async def create_job_for_document(
|
||||
*,
|
||||
document_id: UUID,
|
||||
uploads: Sequence[tuple[str, bytes]],
|
||||
session: AsyncSession,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
prompt_name: str | None = None,
|
||||
settings: Settings | None = None,
|
||||
) -> JobCreateResult:
|
||||
"""Create a queued job for an existing document with one or more uploaded sources."""
|
||||
if not uploads:
|
||||
raise UploadError(
|
||||
"At least one upload is required to create a job",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Upload one or more files and try again.",
|
||||
)
|
||||
|
||||
runtime_settings = settings or get_settings()
|
||||
sorted_uploads = sorted(uploads, key=lambda item: Path(item[0]).name.casefold())
|
||||
stored_uploads: list[tuple[str, Path]] = []
|
||||
for filename, file_bytes in sorted_uploads:
|
||||
stored_uploads.append(
|
||||
(
|
||||
filename,
|
||||
store_file(
|
||||
filename=filename,
|
||||
file_bytes=file_bytes,
|
||||
settings=runtime_settings,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
job, source_ids = await _create_job_for_document_records(
|
||||
session=session,
|
||||
document_id=document_id,
|
||||
stored_uploads=stored_uploads,
|
||||
provider=provider,
|
||||
model=model,
|
||||
prompt_name=prompt_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
for _, stored_path in stored_uploads:
|
||||
_best_effort_delete(stored_path)
|
||||
raise UploadError(
|
||||
"Failed to create job records from uploads",
|
||||
category=ErrorCategory.INFRA_TRANSIENT,
|
||||
suggestion="Retry creation. If this keeps happening, verify database availability.",
|
||||
retriable=True,
|
||||
) from exc
|
||||
|
||||
logger.info("Created explicit job document_id=%s job_id=%s sources=%s", document_id, job.id, len(source_ids))
|
||||
return JobCreateResult(
|
||||
document_id=document_id,
|
||||
job_id=job.id,
|
||||
source_ids=tuple(source_ids),
|
||||
)
|
||||
|
||||
|
||||
async def _create_upload_records(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
@@ -105,6 +178,63 @@ async def _create_upload_records(
|
||||
return document, job
|
||||
|
||||
|
||||
async def _create_job_for_document_records(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
document_id: UUID,
|
||||
stored_uploads: Sequence[tuple[str, Path]],
|
||||
provider: str | None,
|
||||
model: str | None,
|
||||
prompt_name: str | None,
|
||||
) -> tuple[Job, list[UUID]]:
|
||||
document = await session.get(Document, document_id)
|
||||
if document is None:
|
||||
raise UploadError(
|
||||
f"Document with id {document_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Select an existing document and retry.",
|
||||
)
|
||||
|
||||
existing_sources = (
|
||||
await session.exec(select(Source).where(Source.document_id == document_id))
|
||||
).all()
|
||||
next_page_number = (max((source.page_number for source in existing_sources), default=0) + 1)
|
||||
|
||||
job = Job(
|
||||
document_id=document_id,
|
||||
provider=(provider or None),
|
||||
model=(model or None),
|
||||
prompt_name=(prompt_name or None),
|
||||
)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
|
||||
source_ids: list[UUID] = []
|
||||
for page_offset, (original_filename, stored_path) in enumerate(stored_uploads):
|
||||
source = Source(
|
||||
document_id=document_id,
|
||||
page_number=next_page_number + page_offset,
|
||||
upload_name=Path(original_filename).name,
|
||||
filename=stored_path.name,
|
||||
file_path=str(stored_path),
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
source_ids.append(source.id)
|
||||
|
||||
session.add(
|
||||
JobSource(
|
||||
job_id=job.id,
|
||||
source_id=source.id,
|
||||
status=JobSourceStatus.PENDING,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(job)
|
||||
return job, source_ids
|
||||
|
||||
|
||||
def _best_effort_delete(path: Path) -> None:
|
||||
try:
|
||||
if path.exists():
|
||||
@@ -164,4 +294,5 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
||||
|
||||
def _build_stored_filename(filename: str) -> str:
|
||||
safe_name = Path(filename).name
|
||||
return f"{uuid4()}_{safe_name}"
|
||||
suffix = Path(safe_name).suffix.lower()
|
||||
return f"{uuid4()}{suffix}"
|
||||
|
||||
@@ -51,6 +51,10 @@ class TranscriptionNotFoundError(TranscriptionError):
|
||||
"""Raised when a transcription-related resource is not found."""
|
||||
|
||||
|
||||
class SourceDeleteBlockedError(TranscriptionError):
|
||||
"""Raised when source deletion is blocked by dependency policy."""
|
||||
|
||||
|
||||
class TranscriptionService(ServiceBase):
|
||||
"""Service class for job transcription output and page-level source revisions."""
|
||||
|
||||
@@ -164,6 +168,56 @@ class TranscriptionService(ServiceBase):
|
||||
await _session.delete(job_source)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def delete_source_from_job_context(
|
||||
self,
|
||||
*,
|
||||
job_id: UUID,
|
||||
source_id: UUID,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Delete a source from an active job context with dependency guardrails.
|
||||
|
||||
Policy:
|
||||
- Allowed when exactly one JobSource link exists and it points at ``job_id``.
|
||||
- Blocked when additional JobSource links exist (history/shared dependencies).
|
||||
"""
|
||||
async with self._session_scope(session) as _session:
|
||||
source = await _session.get(
|
||||
Source,
|
||||
source_id,
|
||||
options=(
|
||||
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
|
||||
),
|
||||
)
|
||||
if source is None:
|
||||
raise TranscriptionNotFoundError(
|
||||
f"Source with id {source_id} not found",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Verify the source id and retry.",
|
||||
)
|
||||
|
||||
linked_job_sources = list(source.job_sources)
|
||||
matching_links = [job_source for job_source in linked_job_sources if job_source.job_id == job_id]
|
||||
if not matching_links:
|
||||
raise TranscriptionNotFoundError(
|
||||
f"Source {source_id} is not linked to job {job_id}",
|
||||
category=ErrorCategory.NOT_FOUND,
|
||||
suggestion="Open the source from its linked job context and retry.",
|
||||
)
|
||||
|
||||
if len(linked_job_sources) > len(matching_links):
|
||||
raise SourceDeleteBlockedError(
|
||||
"Source delete blocked by related job history",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Remove additional JobSource links first, then retry deletion.",
|
||||
)
|
||||
|
||||
for job_source in matching_links:
|
||||
await _session.delete(job_source)
|
||||
|
||||
await _session.delete(source)
|
||||
await self._finalize(session=_session, caller_session=session)
|
||||
|
||||
async def list_job_sources(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -70,7 +70,17 @@ async def process_queued_job(
|
||||
|
||||
source_job = await services.jobs.read_job(job_id=job.id, session=session)
|
||||
source = _resolve_primary_source(source_job)
|
||||
assert source is not None, f"Job {job.id} has no associated source record."
|
||||
if source is None:
|
||||
candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session)
|
||||
source = next(iter(sorted(candidate_sources, key=lambda item: item.page_number)), None)
|
||||
|
||||
if source is None:
|
||||
error = AppError(
|
||||
f"Job {job.id} has no associated source record.",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Attach at least one source to the job and retry.",
|
||||
)
|
||||
return await _finalize_failed(job=job, services=services, error=error, session=session)
|
||||
started_at = asyncio.get_running_loop().time()
|
||||
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
from fastapi import FastAPI
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.ui.pages.documents_page import register_page as register_documents_page
|
||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
||||
from transcription.ui.pages.people_page import register_page as register_people_page
|
||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
||||
from transcription.ui.resources import read_css
|
||||
|
||||
@@ -23,5 +25,7 @@ def register_pages(app: FastAPI) -> None:
|
||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||
_register_global_styles(app)
|
||||
register_upload_page()
|
||||
register_documents_page()
|
||||
register_people_page()
|
||||
register_jobs_page()
|
||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
||||
|
||||
@@ -7,7 +7,8 @@ from nicegui import ui
|
||||
from transcription.ui.resources import read_css
|
||||
|
||||
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||
("Upload", "/upload", "upload_file"),
|
||||
("Documents", "/documents", "description"),
|
||||
("People", "/people", "group"),
|
||||
("Jobs", "/jobs", "work_history"),
|
||||
)
|
||||
|
||||
@@ -15,6 +16,10 @@ NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
||||
if item_path == "/jobs":
|
||||
return current_path == "/jobs" or current_path.startswith("/jobs/")
|
||||
if item_path == "/documents":
|
||||
return current_path == "/documents" or current_path.startswith("/documents/")
|
||||
if item_path == "/people":
|
||||
return current_path == "/people" or current_path.startswith("/people/")
|
||||
return current_path == item_path
|
||||
|
||||
|
||||
@@ -34,7 +39,7 @@ def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -
|
||||
def _normalize_path(current_path: str | None) -> str:
|
||||
normalized = (current_path or "").strip()
|
||||
if not normalized:
|
||||
return "/upload"
|
||||
return "/jobs"
|
||||
return normalized.rstrip("/") or "/"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Documents list and detail page registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db.models import Document
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.documents import DocumentDeleteBlockedError
|
||||
from transcription.services.documents import DocumentError
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
|
||||
from ...db.session import SessionFactoryDep
|
||||
|
||||
|
||||
def register_page() -> None:
|
||||
"""Register documents list and detail routes."""
|
||||
|
||||
@ui.page("/documents")
|
||||
async def documents_page(session_factory: SessionFactoryDep) -> None:
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
ui.label("Documents").classes("text-h5 text-weight-medium")
|
||||
|
||||
try:
|
||||
documents = sorted(
|
||||
await document_service.list_documents(),
|
||||
key=lambda item: item.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.list")
|
||||
return
|
||||
|
||||
if not documents:
|
||||
ui.label("No documents yet.").classes("text-body1 vibe-text-muted")
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
for document in documents:
|
||||
with ui.card().classes("w-full"):
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label(document.name).classes("text-subtitle1 text-weight-medium")
|
||||
ui.label(f"Type: {document.document_type or 'unspecified'}").classes("text-body2")
|
||||
ui.button(
|
||||
"Open",
|
||||
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
|
||||
icon="open_in_new",
|
||||
).props("flat")
|
||||
|
||||
@ui.page("/documents/{document_id}")
|
||||
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
try:
|
||||
parsed_document_id = UUID(document_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 text-negative")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.read")
|
||||
return
|
||||
|
||||
ui.label(document.name).classes("text-h5 text-weight-medium")
|
||||
ui.label(f"Document type: {document.document_type or 'unspecified'}").classes("text-subtitle1")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Edit document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), icon="edit").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
ui.button(
|
||||
"Delete document",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
|
||||
icon="delete",
|
||||
).props("outline color=negative")
|
||||
|
||||
with ui.column().classes("w-full gap-1"):
|
||||
ui.label(f"Exact date: {document.document_date.isoformat() if document.document_date else 'not set'}")
|
||||
ui.label(f"Approximate date: {document.document_date_raw or 'not set'}")
|
||||
ui.label(f"Location created: {document.location_created or 'not set'}")
|
||||
ui.label(f"Archive identifier: {document.archive_identifier or 'not set'}")
|
||||
ui.label(f"Notes: {document.notes or 'not set'}")
|
||||
ui.label(f"Created at (read-only): {document.created_at.isoformat()}").classes("text-body2")
|
||||
ui.label(f"Updated at (read-only): {document.updated_at.isoformat()}").classes("text-body2")
|
||||
|
||||
ui.separator()
|
||||
ui.label("Related people").classes("text-subtitle1 text-weight-medium")
|
||||
if not document.document_people:
|
||||
ui.label("No linked people yet.").classes("text-body2 vibe-text-muted")
|
||||
else:
|
||||
for link in document.document_people:
|
||||
person = link.person
|
||||
person_label = person.full_name if person is not None else "Unknown person"
|
||||
ui.label(f"{person_label} ({link.role.value})").classes("text-body2")
|
||||
|
||||
ui.separator()
|
||||
ui.label("Sources").classes("text-subtitle1 text-weight-medium")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button(
|
||||
"View sources",
|
||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"),
|
||||
icon="description",
|
||||
).props("flat")
|
||||
if not document.sources:
|
||||
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
|
||||
ui.button(
|
||||
"Add sources",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||
icon="upload_file",
|
||||
).props('unelevated color="primary"')
|
||||
else:
|
||||
for source in sorted(document.sources, key=lambda item: item.page_number):
|
||||
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
|
||||
|
||||
ui.separator()
|
||||
ui.label("Jobs").classes("text-subtitle1 text-weight-medium")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("View jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history").props(
|
||||
"flat"
|
||||
)
|
||||
if not document.jobs:
|
||||
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
|
||||
ui.button(
|
||||
"Create job",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||
icon="add",
|
||||
).props('unelevated color="primary"')
|
||||
else:
|
||||
with ui.column().classes("w-full gap-1"):
|
||||
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
|
||||
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props("flat")
|
||||
|
||||
@ui.page("/documents/{document_id}/jobs")
|
||||
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
try:
|
||||
parsed_document_id = UUID(document_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 text-negative")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.jobs")
|
||||
return
|
||||
|
||||
ui.label(f"Jobs for {document.name}").classes("text-h5 text-weight-medium")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||
ui.button("Create job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
|
||||
if not document.jobs:
|
||||
ui.label("No jobs created yet.").classes("text-body2 vibe-text-muted")
|
||||
return
|
||||
|
||||
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
|
||||
with ui.card().classes("w-full"):
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.label(f"{job.status.value} - {job.id}").classes("text-body2")
|
||||
ui.button("Open", on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), icon="open_in_new").props(
|
||||
"flat"
|
||||
)
|
||||
|
||||
@ui.page("/documents/{document_id}/sources")
|
||||
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
try:
|
||||
parsed_document_id = UUID(document_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 text-negative")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.sources")
|
||||
return
|
||||
|
||||
ui.label(f"Sources for {document.name}").classes("text-h5 text-weight-medium")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||
ui.button("Add sources", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="upload_file").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
|
||||
if not document.sources:
|
||||
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
|
||||
return
|
||||
|
||||
for source in sorted(document.sources, key=lambda item: item.page_number):
|
||||
with ui.card().classes("w-full"):
|
||||
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
|
||||
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
|
||||
|
||||
@ui.page("/documents/{document_id}/edit")
|
||||
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
try:
|
||||
parsed_document_id = UUID(document_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 text-negative")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.edit.read")
|
||||
return
|
||||
|
||||
ui.label("Edit document").classes("text-h5 text-weight-medium")
|
||||
ui.label("Document name and document type are required.").classes("text-body2 vibe-text-muted")
|
||||
|
||||
name_input = ui.input(label="Document name", value=document.name).props("outlined")
|
||||
document_type_input = ui.input(label="Document type", value=document.document_type or "").props("outlined")
|
||||
date_input = ui.input(
|
||||
label="Exact date (YYYY-MM-DD)",
|
||||
value=document.document_date.isoformat() if document.document_date else "",
|
||||
).props("outlined")
|
||||
date_raw_input = ui.input(label="Approximate date", value=document.document_date_raw or "").props("outlined")
|
||||
location_input = ui.input(label="Document location", value=document.location_created or "").props("outlined")
|
||||
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined")
|
||||
notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow")
|
||||
|
||||
async def submit_edit() -> None:
|
||||
candidate_name = (name_input.value or "").strip()
|
||||
candidate_type = (document_type_input.value or "").strip()
|
||||
if not candidate_name:
|
||||
ui.notify("Document name is required.", type="warning")
|
||||
return
|
||||
if not candidate_type:
|
||||
ui.notify("Document type is required.", type="warning")
|
||||
return
|
||||
|
||||
parsed_date: date | None = None
|
||||
candidate_date_text = (date_input.value or "").strip()
|
||||
if candidate_date_text:
|
||||
try:
|
||||
parsed_date = date.fromisoformat(candidate_date_text)
|
||||
except ValueError:
|
||||
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||
return
|
||||
|
||||
candidate = Document(
|
||||
id=document.id,
|
||||
name=candidate_name,
|
||||
document_type=candidate_type,
|
||||
document_date=parsed_date,
|
||||
document_date_raw=(date_raw_input.value or "").strip() or None,
|
||||
location_created=(location_input.value or "").strip() or None,
|
||||
notes=(notes_input.value or "").strip() or None,
|
||||
archive_identifier=(archive_input.value or "").strip() or None,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
)
|
||||
|
||||
try:
|
||||
await document_service.update_document(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Save failed", operation="documents.edit.save")
|
||||
return
|
||||
|
||||
ui.notify("Document updated", type="positive")
|
||||
ui.navigate.to(f"/documents/{document.id}")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||
|
||||
@ui.page("/documents/{document_id}/delete")
|
||||
async def document_delete_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/documents")
|
||||
|
||||
try:
|
||||
parsed_document_id = UUID(document_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid document id").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
try:
|
||||
document = await document_service.read_document_detail(document_id=parsed_document_id)
|
||||
except DocumentError:
|
||||
ui.label("Document not found").classes("text-h6 text-negative")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="documents.delete.read")
|
||||
return
|
||||
|
||||
ui.label("Delete document").classes("text-h5 text-weight-medium")
|
||||
ui.label(f"Document: {document.name}").classes("text-subtitle1")
|
||||
|
||||
has_sources = bool(document.sources)
|
||||
has_jobs = bool(document.jobs)
|
||||
|
||||
if has_sources or has_jobs:
|
||||
ui.label("Delete is blocked because related records exist.").classes("text-negative text-weight-medium")
|
||||
categories: list[str] = []
|
||||
if has_sources:
|
||||
categories.append("Sources")
|
||||
if has_jobs:
|
||||
categories.append("Jobs")
|
||||
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-body2")
|
||||
ui.label("Remove related records first, then retry deletion.").classes("text-body2 vibe-text-muted")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
|
||||
return
|
||||
|
||||
ui.label("This action permanently deletes the document.").classes("text-negative")
|
||||
|
||||
async def submit_delete() -> None:
|
||||
try:
|
||||
await document_service.delete_document(document)
|
||||
except DocumentDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
ui.navigate.to(f"/documents/{document.id}/delete")
|
||||
return
|
||||
except DocumentError as exc:
|
||||
if exc.category == ErrorCategory.NOT_FOUND:
|
||||
ui.notify("Document not found.", type="warning")
|
||||
ui.navigate.to("/documents")
|
||||
return
|
||||
show_error(exc, title="Delete failed", operation="documents.delete")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Delete failed", operation="documents.delete")
|
||||
return
|
||||
|
||||
ui.notify("Document deleted", type="positive")
|
||||
ui.navigate.to("/documents")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button(
|
||||
"Delete document permanently",
|
||||
on_click=submit_delete,
|
||||
icon="delete_forever",
|
||||
).props('unelevated color="negative"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from nicegui import ui
|
||||
@@ -9,11 +11,17 @@ from nicegui import ui
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import JobStatus
|
||||
from transcription.db.models import Source
|
||||
from transcription.db.session import session_scope
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.jobs import JobDeleteBlockedError
|
||||
from transcription.services.jobs import JobService
|
||||
from transcription.services.transcription import SourceDeleteBlockedError
|
||||
from transcription.services.store import create_job_for_document
|
||||
from transcription.services.transcription import TranscriptionService
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
from transcription.ui.components.table.jobs import render_jobs_table
|
||||
from transcription.worker import resolve_worker_notifier
|
||||
|
||||
from ...db.session import SessionFactoryDep
|
||||
from ..components.document_panzoom import render_document_panzoom
|
||||
@@ -45,9 +53,134 @@ def register_page() -> None: # noqa: PLR0915
|
||||
]
|
||||
render_jobs_table(jobs)
|
||||
|
||||
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
||||
await render_table()
|
||||
|
||||
@ui.page("/jobs/new")
|
||||
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
documents_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/jobs")
|
||||
ui.label("Create job").classes("text-h5 text-weight-medium")
|
||||
|
||||
documents = await documents_service.list_documents()
|
||||
if not documents:
|
||||
ui.label("No documents available. Create a Document before creating a Job.").classes(
|
||||
"text-body1 text-warning"
|
||||
)
|
||||
with ui.row():
|
||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
|
||||
return
|
||||
|
||||
uploaded_files: list[tuple[str, bytes]] = []
|
||||
|
||||
document_options = {str(document.id): document.name for document in documents}
|
||||
document_select = ui.select(document_options, label="Document").props("outlined")
|
||||
requested_document_id = request.query_params.get("document_id")
|
||||
if requested_document_id in document_options:
|
||||
document_select.value = requested_document_id
|
||||
provider_input = ui.input(label="Provider").props("outlined")
|
||||
model_input = ui.input(label="Model").props("outlined")
|
||||
prompt_input = ui.input(label="Prompt").props("outlined")
|
||||
|
||||
ui.label("Source files").classes("text-subtitle1 text-weight-medium")
|
||||
ui.label(
|
||||
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
|
||||
).classes("text-body2 vibe-text-muted")
|
||||
|
||||
@ui.refreshable
|
||||
def render_upload_list() -> None:
|
||||
if not uploaded_files:
|
||||
ui.label("No files uploaded yet.").classes("text-body2 vibe-text-muted")
|
||||
return
|
||||
|
||||
def remove_file(index: int) -> None:
|
||||
if 0 <= index < len(uploaded_files):
|
||||
removed_name, _ = uploaded_files.pop(index)
|
||||
ui.notify(f"Removed {removed_name}", type="info")
|
||||
render_upload_list.refresh()
|
||||
|
||||
def clear_files() -> None:
|
||||
uploaded_files.clear()
|
||||
ui.notify("Cleared queued files", type="info")
|
||||
render_upload_list.refresh()
|
||||
|
||||
ordered_uploads = sorted(
|
||||
enumerate(uploaded_files),
|
||||
key=lambda item: Path(item[1][0]).name.casefold(),
|
||||
)
|
||||
|
||||
with ui.column().classes("gap-1"):
|
||||
for index, (filename, _) in ordered_uploads:
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.label(Path(filename).name).classes("text-body2")
|
||||
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props("flat round dense")
|
||||
|
||||
with ui.row().classes("w-full justify-end"):
|
||||
ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat")
|
||||
|
||||
async def on_upload(event) -> None:
|
||||
payload = await event.file.read()
|
||||
uploaded_files.append((event.file.name, payload))
|
||||
ui.notify(f"Added {event.file.name}", type="positive")
|
||||
render_upload_list.refresh()
|
||||
|
||||
ui.upload(
|
||||
on_upload=on_upload,
|
||||
auto_upload=True,
|
||||
label="Select source files",
|
||||
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" multiple')
|
||||
|
||||
ui.upload(
|
||||
on_upload=on_upload,
|
||||
auto_upload=True,
|
||||
label="Upload folder",
|
||||
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple')
|
||||
|
||||
render_upload_list()
|
||||
|
||||
async def submit_create() -> None:
|
||||
selected_document = document_select.value
|
||||
if not selected_document:
|
||||
ui.notify("Document is required.", type="warning")
|
||||
return
|
||||
if not uploaded_files:
|
||||
ui.notify("At least one source file is required.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
document_id = UUID(str(selected_document))
|
||||
except ValueError:
|
||||
ui.notify("Selected document id is invalid.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
async with session_scope(session_factory=session_factory) as session:
|
||||
result = await create_job_for_document(
|
||||
document_id=document_id,
|
||||
uploads=uploaded_files,
|
||||
provider=(provider_input.value or None),
|
||||
model=(model_input.value or None),
|
||||
prompt_name=(prompt_input.value or None),
|
||||
session=session,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Create job failed", operation="jobs.create")
|
||||
return
|
||||
|
||||
resolve_worker_notifier(request.app.state).notify()
|
||||
ui.notify(f"Created job {result.job_id}", type="positive")
|
||||
ui.navigate.to(f"/jobs/{result.job_id}")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back")
|
||||
|
||||
@ui.page("/jobs/{job_id}")
|
||||
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
@@ -85,6 +218,20 @@ def register_page() -> None: # noqa: PLR0915
|
||||
case _:
|
||||
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button(
|
||||
"Delete job",
|
||||
on_click=lambda: ui.navigate.to(f"/jobs/{parsed_job_id}/delete"),
|
||||
icon="delete",
|
||||
).props("outline color=negative")
|
||||
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label(f"Provider: {job.provider or 'pending'}").classes("text-body2")
|
||||
ui.label(f"Model: {job.model or 'pending'}").classes("text-body2")
|
||||
ui.label(f"Prompt: {job.prompt_name or 'pending'}").classes("text-body2")
|
||||
ui.label(f"Retry count: {job.retry_count}").classes("text-body2")
|
||||
ui.label(f"Last updated: {job.date_updated.isoformat()}").classes("text-body2")
|
||||
|
||||
render_original_transcription_card(job=job)
|
||||
|
||||
@ui.refreshable
|
||||
@@ -102,6 +249,26 @@ def register_page() -> None: # noqa: PLR0915
|
||||
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
|
||||
editor.classes("w-full")
|
||||
|
||||
async def delete_source() -> None:
|
||||
try:
|
||||
await transcription_service.delete_source_from_job_context(
|
||||
job_id=parsed_job_id,
|
||||
source_id=refreshed_source.id,
|
||||
)
|
||||
except SourceDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Delete source failed", operation="jobs.delete_source")
|
||||
return
|
||||
|
||||
ui.notify("Source deleted", type="positive")
|
||||
ui.navigate.to(f"/jobs/{parsed_job_id}")
|
||||
|
||||
async def confirm_delete_source() -> None:
|
||||
delete_dialog.close()
|
||||
await delete_source()
|
||||
|
||||
async def save_revision() -> None:
|
||||
candidate = (editor.value or "").strip()
|
||||
if not candidate:
|
||||
@@ -120,7 +287,22 @@ def register_page() -> None: # noqa: PLR0915
|
||||
ui.notify("Revision saved", type="positive")
|
||||
await render_revision_panel.refresh()
|
||||
|
||||
with ui.row().classes("w-full justify-end"):
|
||||
with ui.row().classes("w-full justify-end gap-2"):
|
||||
with ui.dialog() as delete_dialog, ui.card().classes("min-w-[22rem]"):
|
||||
ui.label("Delete source").classes("text-subtitle1 text-weight-medium")
|
||||
ui.label("This permanently deletes the source from this job context.").classes("text-body2")
|
||||
ui.label("If related job history exists, deletion may be blocked.").classes(
|
||||
"text-body2 vibe-text-muted"
|
||||
)
|
||||
with ui.row().classes("w-full justify-end gap-2"):
|
||||
ui.button("Cancel", on_click=delete_dialog.close)
|
||||
ui.button(
|
||||
"Delete source",
|
||||
on_click=confirm_delete_source,
|
||||
icon="delete_forever",
|
||||
).props('unelevated color="negative"')
|
||||
|
||||
ui.button("Delete source", on_click=delete_dialog.open, icon="delete").props("outline color=negative")
|
||||
ui.button(
|
||||
"Create revision" if current_revision_text is None else "Update revision",
|
||||
on_click=save_revision,
|
||||
@@ -138,6 +320,61 @@ def register_page() -> None: # noqa: PLR0915
|
||||
|
||||
await render_revision_panel()
|
||||
|
||||
@ui.page("/jobs/{job_id}/delete")
|
||||
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
jobs_service = JobService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/jobs")
|
||||
|
||||
try:
|
||||
parsed_job_id = UUID(job_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid job id").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
try:
|
||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||
except ValueError:
|
||||
ui.label("Job not found").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
ui.label("Delete job").classes("text-h5 text-weight-medium")
|
||||
ui.label(f"Job: {job.id}").classes("text-subtitle1")
|
||||
|
||||
if job.status == JobStatus.PROCESSING:
|
||||
ui.label("Delete is blocked while the job is processing.").classes("text-negative text-weight-medium")
|
||||
ui.label("Wait for processing to complete, then retry delete.").classes("text-body2 vibe-text-muted")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
|
||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history")
|
||||
return
|
||||
|
||||
ui.label("This action permanently deletes the job.").classes("text-negative")
|
||||
if job.job_sources:
|
||||
ui.label("Related JobSource links will be removed as part of delete.").classes("text-body2")
|
||||
|
||||
async def submit_delete() -> None:
|
||||
try:
|
||||
await jobs_service.delete_job_with_guardrails(job_id=job.id)
|
||||
except JobDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
return
|
||||
except ValueError:
|
||||
ui.notify("Job not found.", type="warning")
|
||||
ui.navigate.to("/jobs")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Delete job failed", operation="jobs.delete")
|
||||
return
|
||||
|
||||
ui.notify("Job deleted", type="positive")
|
||||
ui.navigate.to("/jobs")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Delete job permanently", on_click=submit_delete, icon="delete_forever").props(
|
||||
'unelevated color="negative"'
|
||||
)
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back")
|
||||
|
||||
|
||||
def _resolve_primary_source(job: Job) -> Source | None:
|
||||
if not job.job_sources:
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""People list and detail page registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db.models import Person
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.documents import DocumentError
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.documents import PersonDeleteBlockedError
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.error_presenter import show_error
|
||||
|
||||
from ...db.session import SessionFactoryDep
|
||||
|
||||
|
||||
def _parse_optional_date(value: str | None, *, label: str) -> date | None:
|
||||
candidate = (value or "").strip()
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
try:
|
||||
return date.fromisoformat(candidate)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
|
||||
|
||||
|
||||
def register_page() -> None: # noqa: PLR0915
|
||||
"""Register people list and CRUD routes."""
|
||||
|
||||
@ui.page("/people")
|
||||
async def people_page(session_factory: SessionFactoryDep) -> None:
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.label("People").classes("text-h5 text-weight-medium")
|
||||
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
|
||||
try:
|
||||
people = sorted(
|
||||
await people_service.list_people(),
|
||||
key=lambda item: item.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="people.list")
|
||||
return
|
||||
|
||||
if not people:
|
||||
ui.label("No people yet.").classes("text-body1 vibe-text-muted")
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full gap-2"):
|
||||
for person in people:
|
||||
with ui.card().classes("w-full"):
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label(person.full_name).classes("text-subtitle1 text-weight-medium")
|
||||
ui.label(f"Display name: {person.display_name or 'not set'}").classes("text-body2")
|
||||
ui.button(
|
||||
"Open",
|
||||
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
|
||||
icon="open_in_new",
|
||||
).props("flat")
|
||||
|
||||
@ui.page("/people/new")
|
||||
async def person_create_page(session_factory: SessionFactoryDep) -> None:
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
ui.label("Create person").classes("text-h5 text-weight-medium")
|
||||
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
|
||||
|
||||
full_name_input = ui.input(label="Full name").props("outlined")
|
||||
display_name_input = ui.input(label="Display name").props("outlined")
|
||||
maiden_name_input = ui.input(label="Maiden name").props("outlined")
|
||||
|
||||
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props("outlined")
|
||||
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)").props("outlined")
|
||||
birth_place_input = ui.input(label="Birth place").props("outlined")
|
||||
|
||||
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props("outlined")
|
||||
death_date_raw_input = ui.input(label="Death date (approximate/raw)").props("outlined")
|
||||
death_place_input = ui.input(label="Death place").props("outlined")
|
||||
|
||||
biography_input = ui.textarea(label="Biography").props("outlined autogrow")
|
||||
portrait_path_input = ui.input(label="Portrait path").props("outlined")
|
||||
|
||||
async def submit_create() -> None:
|
||||
full_name = (full_name_input.value or "").strip()
|
||||
if not full_name:
|
||||
ui.notify("Full name is required.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
|
||||
death_date = _parse_optional_date(death_date_input.value, label="Death date")
|
||||
except ValueError as exc:
|
||||
ui.notify(str(exc), type="warning")
|
||||
return
|
||||
|
||||
candidate = Person(
|
||||
full_name=full_name,
|
||||
display_name=(display_name_input.value or "").strip() or None,
|
||||
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||
birth_date=birth_date,
|
||||
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
|
||||
birth_place=(birth_place_input.value or "").strip() or None,
|
||||
death_date=death_date,
|
||||
death_date_raw=(death_date_raw_input.value or "").strip() or None,
|
||||
death_place=(death_place_input.value or "").strip() or None,
|
||||
biography=(biography_input.value or "").strip() or None,
|
||||
portrait_path=(portrait_path_input.value or "").strip() or None,
|
||||
)
|
||||
|
||||
try:
|
||||
created = await people_service.create_person(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Create failed", operation="people.create")
|
||||
return
|
||||
|
||||
ui.notify("Person created", type="positive")
|
||||
ui.navigate.to(f"/people/{created.id}")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Save person", on_click=submit_create, icon="save").props('unelevated color="primary"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back")
|
||||
|
||||
@ui.page("/people/{person_id}")
|
||||
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
try:
|
||||
parsed_person_id = UUID(person_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid person id").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
try:
|
||||
person = await people_service.read_person_detail(parsed_person_id)
|
||||
except DocumentError:
|
||||
ui.label("Person not found").classes("text-h6 text-negative")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="people.read")
|
||||
return
|
||||
|
||||
ui.label(person.full_name).classes("text-h5 text-weight-medium")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Edit person", on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), icon="edit").props(
|
||||
'unelevated color="primary"'
|
||||
)
|
||||
ui.button(
|
||||
"Delete person",
|
||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
|
||||
icon="delete",
|
||||
).props("outline color=negative")
|
||||
|
||||
with ui.column().classes("w-full gap-1"):
|
||||
ui.label(f"Display name: {person.display_name or 'not set'}")
|
||||
ui.label(f"Maiden name: {person.maiden_name or 'not set'}")
|
||||
ui.label(f"Birth date: {person.birth_date.isoformat() if person.birth_date else 'not set'}")
|
||||
ui.label(f"Birth date (approximate): {person.birth_date_raw or 'not set'}")
|
||||
ui.label(f"Birth place: {person.birth_place or 'not set'}")
|
||||
ui.label(f"Death date: {person.death_date.isoformat() if person.death_date else 'not set'}")
|
||||
ui.label(f"Death date (approximate): {person.death_date_raw or 'not set'}")
|
||||
ui.label(f"Death place: {person.death_place or 'not set'}")
|
||||
ui.label(f"Biography: {person.biography or 'not set'}")
|
||||
ui.label(f"Portrait path: {person.portrait_path or 'not set'}")
|
||||
ui.label(f"Created at (read-only): {person.created_at.isoformat()}").classes("text-body2")
|
||||
ui.label(f"Updated at (read-only): {person.updated_at.isoformat()}").classes("text-body2")
|
||||
|
||||
ui.separator()
|
||||
ui.label("Linked documents").classes("text-subtitle1 text-weight-medium")
|
||||
|
||||
if not person.document_people:
|
||||
ui.label("No linked documents yet.").classes("text-body2 vibe-text-muted")
|
||||
ui.label("Link this person from a Document workflow.").classes("text-body2 vibe-text-muted")
|
||||
return
|
||||
|
||||
with ui.column().classes("w-full gap-1"):
|
||||
for link in person.document_people:
|
||||
document = link.document
|
||||
if document is None:
|
||||
continue
|
||||
with ui.row().classes("w-full items-center justify-between"):
|
||||
ui.label(f"{document.name} ({link.role.value})").classes("text-body2")
|
||||
ui.button(
|
||||
"Open",
|
||||
on_click=lambda _=None, document_id=document.id: ui.navigate.to(f"/documents/{document_id}"),
|
||||
icon="open_in_new",
|
||||
).props("flat")
|
||||
|
||||
@ui.page("/people/{person_id}/edit")
|
||||
async def person_edit_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
try:
|
||||
parsed_person_id = UUID(person_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid person id").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
try:
|
||||
person = await people_service.read_person_detail(parsed_person_id)
|
||||
except DocumentError:
|
||||
ui.label("Person not found").classes("text-h6 text-negative")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="people.edit.read")
|
||||
return
|
||||
|
||||
ui.label("Edit person").classes("text-h5 text-weight-medium")
|
||||
ui.label("Full name is required.").classes("text-body2 vibe-text-muted")
|
||||
|
||||
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined")
|
||||
display_name_input = ui.input(label="Display name", value=person.display_name or "").props("outlined")
|
||||
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined")
|
||||
|
||||
birth_date_input = ui.input(
|
||||
label="Birth date (YYYY-MM-DD)",
|
||||
value=person.birth_date.isoformat() if person.birth_date else "",
|
||||
).props("outlined")
|
||||
birth_date_raw_input = ui.input(label="Birth date (approximate/raw)", value=person.birth_date_raw or "").props(
|
||||
"outlined"
|
||||
)
|
||||
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined")
|
||||
|
||||
death_date_input = ui.input(
|
||||
label="Death date (YYYY-MM-DD)",
|
||||
value=person.death_date.isoformat() if person.death_date else "",
|
||||
).props("outlined")
|
||||
death_date_raw_input = ui.input(label="Death date (approximate/raw)", value=person.death_date_raw or "").props(
|
||||
"outlined"
|
||||
)
|
||||
death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined")
|
||||
|
||||
biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow")
|
||||
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined")
|
||||
|
||||
async def submit_edit() -> None:
|
||||
full_name = (full_name_input.value or "").strip()
|
||||
if not full_name:
|
||||
ui.notify("Full name is required.", type="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
|
||||
death_date = _parse_optional_date(death_date_input.value, label="Death date")
|
||||
except ValueError as exc:
|
||||
ui.notify(str(exc), type="warning")
|
||||
return
|
||||
|
||||
candidate = Person(
|
||||
id=person.id,
|
||||
full_name=full_name,
|
||||
display_name=(display_name_input.value or "").strip() or None,
|
||||
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||
birth_date=birth_date,
|
||||
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
|
||||
birth_place=(birth_place_input.value or "").strip() or None,
|
||||
death_date=death_date,
|
||||
death_date_raw=(death_date_raw_input.value or "").strip() or None,
|
||||
death_place=(death_place_input.value or "").strip() or None,
|
||||
biography=(biography_input.value or "").strip() or None,
|
||||
portrait_path=(portrait_path_input.value or "").strip() or None,
|
||||
metadata_=person.metadata_,
|
||||
created_at=person.created_at,
|
||||
updated_at=person.updated_at,
|
||||
)
|
||||
|
||||
try:
|
||||
await people_service.update_person(candidate)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Save failed", operation="people.edit.save")
|
||||
return
|
||||
|
||||
ui.notify("Person updated", type="positive")
|
||||
ui.navigate.to(f"/people/{person.id}")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Save changes", on_click=submit_edit, icon="save").props('unelevated color="primary"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
|
||||
|
||||
@ui.page("/people/{person_id}/delete")
|
||||
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
people_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
|
||||
try:
|
||||
parsed_person_id = UUID(person_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid person id").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
try:
|
||||
person = await people_service.read_person_detail(parsed_person_id)
|
||||
except DocumentError:
|
||||
ui.label("Person not found").classes("text-h6 text-negative")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Load failed", operation="people.delete.read")
|
||||
return
|
||||
|
||||
ui.label("Delete person").classes("text-h5 text-weight-medium")
|
||||
ui.label(f"Person: {person.full_name}").classes("text-subtitle1")
|
||||
|
||||
if person.document_people:
|
||||
ui.label("Delete is blocked because linked documents exist.").classes("text-negative text-weight-medium")
|
||||
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-body2")
|
||||
ui.label("Remove document links first, then retry deletion.").classes("text-body2 vibe-text-muted")
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button("Back to Person", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
|
||||
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description")
|
||||
return
|
||||
|
||||
ui.label("This action permanently deletes the person.").classes("text-negative")
|
||||
|
||||
async def submit_delete() -> None:
|
||||
try:
|
||||
await people_service.delete_person(person)
|
||||
except PersonDeleteBlockedError as exc:
|
||||
ui.notify(exc.message, type="warning")
|
||||
ui.navigate.to(f"/people/{person.id}/delete")
|
||||
return
|
||||
except DocumentError as exc:
|
||||
if exc.category == ErrorCategory.NOT_FOUND:
|
||||
ui.notify("Person not found.", type="warning")
|
||||
ui.navigate.to("/people")
|
||||
return
|
||||
show_error(exc, title="Delete failed", operation="people.delete")
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
show_error(exc, title="Delete failed", operation="people.delete")
|
||||
return
|
||||
|
||||
ui.notify("Person deleted", type="positive")
|
||||
ui.navigate.to("/people")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-2"):
|
||||
ui.button(
|
||||
"Delete person permanently",
|
||||
on_click=submit_delete,
|
||||
icon="delete_forever",
|
||||
).props('unelevated color="negative"')
|
||||
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back")
|
||||
@@ -3,29 +3,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from starlette import status
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.db import session_scope
|
||||
from transcription.services.store import create_upload_job
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.upload import render_upload_widget
|
||||
from transcription.worker import resolve_worker_notifier
|
||||
|
||||
|
||||
def register_page() -> None:
|
||||
"""Register the upload page route."""
|
||||
|
||||
@ui.page("/upload", title="Upload Document")
|
||||
def upload_page(request: Request) -> None:
|
||||
def upload_page(request: Request) -> RedirectResponse:
|
||||
_ = request
|
||||
render_navigation_header(current_path="/upload")
|
||||
|
||||
async def submit_upload(filename: str, file_bytes: bytes):
|
||||
async with session_scope() as session:
|
||||
return await create_upload_job(
|
||||
filename=filename,
|
||||
file_bytes=file_bytes,
|
||||
session=session,
|
||||
)
|
||||
|
||||
notify_worker = resolve_worker_notifier(request.app.state)
|
||||
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
|
||||
return RedirectResponse(url="/ui/jobs/new", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||
|
||||
Reference in New Issue
Block a user