generated from john/python-template
Compare commits
4
Commits
job-detail
...
8064821503
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8064821503 | ||
|
|
593388ef3a | ||
|
|
83ee7b31e0 | ||
|
|
455a01d7c4 |
@@ -1,14 +1,45 @@
|
|||||||
"""UI page registration exports."""
|
"""UI page registration exports."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from nicegui import app as nicegui_app
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
||||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
||||||
|
|
||||||
|
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
|
||||||
|
|
||||||
|
_THEME_COLORS: dict[str, str] = {
|
||||||
|
"primary": "#6f97e8",
|
||||||
|
"secondary": "#92b5f5",
|
||||||
|
"accent": "#7fc0de",
|
||||||
|
"dark": "#22304a",
|
||||||
|
"dark_page": "#1a2538",
|
||||||
|
"positive": "#86c8ad",
|
||||||
|
"negative": "#d98a9a",
|
||||||
|
"info": "#7ebdda",
|
||||||
|
"warning": "#e2c083",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _register_global_styles(app: FastAPI) -> None:
|
||||||
|
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
||||||
|
return
|
||||||
|
|
||||||
|
nicegui_app.colors(**_THEME_COLORS)
|
||||||
|
|
||||||
|
css_path = Path(__file__).resolve().parent / "static" / "colors.css"
|
||||||
|
if css_path.exists():
|
||||||
|
ui.add_css(css_path, shared=True)
|
||||||
|
|
||||||
|
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
||||||
|
|
||||||
|
|
||||||
def register_pages(app: FastAPI) -> None:
|
def register_pages(app: FastAPI) -> None:
|
||||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||||
|
_register_global_styles(app)
|
||||||
register_upload_page()
|
register_upload_page()
|
||||||
register_jobs_page()
|
register_jobs_page()
|
||||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
|
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=True)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Reusable UI component exports."""
|
||||||
|
|
||||||
|
from transcription.ui.components.app_shell import NAV_ITEMS
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
|
||||||
|
__all__ = ["NAV_ITEMS", "render_navigation_header"]
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Reusable app shell primitives for page-level layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||||
|
("Upload", "/upload", "upload_file"),
|
||||||
|
("Jobs", "/jobs", "work_history"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
||||||
|
if item_path == "/jobs":
|
||||||
|
return current_path == "/jobs" or current_path.startswith("/jobs/")
|
||||||
|
return current_path == item_path
|
||||||
|
|
||||||
|
|
||||||
|
def _button_props(*, icon: str, is_active: bool) -> str:
|
||||||
|
if is_active:
|
||||||
|
return f"icon={icon} no-caps unelevated color=primary text-color=white"
|
||||||
|
return f"icon={icon} no-caps outline color=secondary text-color=secondary"
|
||||||
|
|
||||||
|
|
||||||
|
def _button_classes(*, is_active: bool) -> str:
|
||||||
|
base = "w-full sm:w-auto min-h-[40px] px-3 rounded-lg text-body2 text-weight-medium"
|
||||||
|
if is_active:
|
||||||
|
return f"{base}"
|
||||||
|
return f"{base}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -> None:
|
||||||
|
is_active = _is_active_path(current_path=current_path, item_path=path)
|
||||||
|
button = ui.button(
|
||||||
|
label,
|
||||||
|
icon=icon,
|
||||||
|
on_click=lambda _=None, route=path: ui.navigate.to(route),
|
||||||
|
)
|
||||||
|
button.props(_button_props(icon=icon, is_active=is_active)).classes(_button_classes(is_active=is_active))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path(current_path: str | None) -> str:
|
||||||
|
normalized = (current_path or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return "/upload"
|
||||||
|
return normalized.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
|
def render_navigation_header(*, current_path: str | None = None) -> None:
|
||||||
|
"""Render a shared app header with links for top-level pages."""
|
||||||
|
normalized_path = _normalize_path(current_path)
|
||||||
|
|
||||||
|
with (
|
||||||
|
ui.header(elevated=True).props("bordered").classes("bg-dark text-white q-px-sm q-py-xs"),
|
||||||
|
ui.row().classes("w-full items-center justify-end q-gutter-xs"),
|
||||||
|
ui.element("div").classes("w-full grid grid-cols-2 gap-2 sm:flex sm:justify-start sm:gap-2"),
|
||||||
|
):
|
||||||
|
for label, path, icon in NAV_ITEMS:
|
||||||
|
_render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path)
|
||||||
@@ -22,27 +22,27 @@ def _status_chip_classes(status: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _metadata_row(label: str, value: str) -> None:
|
def _metadata_row(label: str, value: str) -> None:
|
||||||
with ui.row().classes("w-full items-start justify-between no-wrap q-gutter-x-md"):
|
with ui.row().classes("w-full items-start justify-between q-gutter-x-md"):
|
||||||
ui.label(label).classes("text-caption text-grey-7 text-uppercase")
|
ui.label(label).classes("text-caption text-grey-5 text-uppercase w-28")
|
||||||
ui.label(value).classes("text-body2 text-right")
|
ui.label(value).classes("text-body2 text-right text-grey-1 break-all")
|
||||||
|
|
||||||
|
|
||||||
def _render_document_section(document: Document) -> None:
|
def _render_document_section(document: Document) -> None:
|
||||||
with ui.card().classes("w-full bg-grey-1 q-pa-md"):
|
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
|
||||||
ui.label("Document").classes("text-subtitle1 text-weight-medium")
|
ui.label("Document").classes("text-subtitle1 text-weight-medium")
|
||||||
ui.separator().classes("q-my-sm")
|
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||||
_metadata_row("Filename", document.filename)
|
_metadata_row("Filename", document.filename)
|
||||||
_metadata_row("File path", document.file_path)
|
_metadata_row("File path", document.file_path)
|
||||||
|
|
||||||
|
|
||||||
def _render_transcript_section(transcript: Transcript | None) -> None:
|
def _render_transcript_section(transcript: Transcript | None) -> None:
|
||||||
with ui.card().classes("w-full q-pa-md"):
|
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
|
||||||
ui.label("Transcript").classes("text-subtitle1 text-weight-medium")
|
ui.label("Transcript").classes("text-subtitle1 text-weight-medium")
|
||||||
ui.separator().classes("q-my-sm")
|
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||||
|
|
||||||
if transcript is None:
|
if transcript is None:
|
||||||
ui.label("Transcript not available yet.").classes("text-body2 text-grey-8")
|
ui.label("Transcript not available yet.").classes("text-body2 text-grey-3")
|
||||||
return
|
return
|
||||||
|
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||||
@@ -51,13 +51,13 @@ def _render_transcript_section(transcript: Transcript | None) -> None:
|
|||||||
_metadata_row("Created", transcript.created_at.isoformat())
|
_metadata_row("Created", transcript.created_at.isoformat())
|
||||||
|
|
||||||
if transcript.text:
|
if transcript.text:
|
||||||
ui.separator().classes("q-my-sm")
|
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||||
with ui.card().classes("w-full bg-grey-1 q-pa-sm"):
|
with ui.card().classes("w-full bg-blue-grey-9 text-grey-1 q-pa-sm"):
|
||||||
ui.markdown(transcript.text)
|
ui.markdown(transcript.text).classes("text-grey-1")
|
||||||
return
|
return
|
||||||
|
|
||||||
if transcript.error_detail:
|
if transcript.error_detail:
|
||||||
ui.separator().classes("q-my-sm")
|
ui.separator().classes("q-my-sm bg-blue-grey-7")
|
||||||
with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"):
|
with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"):
|
||||||
ui.label("Failure detail").classes("text-caption text-uppercase")
|
ui.label("Failure detail").classes("text-caption text-uppercase")
|
||||||
ui.label(transcript.error_detail).classes("text-body2")
|
ui.label(transcript.error_detail).classes("text-body2")
|
||||||
@@ -67,11 +67,11 @@ def render_job_detail(*, job: Job, document: Document | None, transcript: Transc
|
|||||||
"""Render all sections for the job detail page."""
|
"""Render all sections for the job detail page."""
|
||||||
status_text = job.status.value
|
status_text = job.status.value
|
||||||
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
|
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
|
||||||
with ui.card().classes("w-full q-pa-lg"):
|
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-lg"):
|
||||||
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
|
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
|
||||||
with ui.column().classes("q-gutter-none"):
|
with ui.column().classes("q-gutter-none"):
|
||||||
ui.label("Job overview").classes("text-h6 text-weight-bold")
|
ui.label("Job overview").classes("text-h6 text-weight-bold")
|
||||||
ui.label(str(job.id)).classes("text-caption text-grey-7")
|
ui.label(str(job.id)).classes("text-caption text-grey-5")
|
||||||
status_chip_classes = (
|
status_chip_classes = (
|
||||||
"q-px-sm q-py-xs rounded-borders "
|
"q-px-sm q-py-xs rounded-borders "
|
||||||
"text-weight-medium text-capitalize "
|
"text-weight-medium text-capitalize "
|
||||||
@@ -79,7 +79,7 @@ def render_job_detail(*, job: Job, document: Document | None, transcript: Transc
|
|||||||
)
|
)
|
||||||
ui.label(status_text).classes(status_chip_classes)
|
ui.label(status_text).classes(status_chip_classes)
|
||||||
|
|
||||||
ui.separator().classes("q-my-md")
|
ui.separator().classes("q-my-md bg-blue-grey-7")
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||||
_metadata_row("Created", job.created_at.isoformat())
|
_metadata_row("Created", job.created_at.isoformat())
|
||||||
_metadata_row("Updated", job.updated_at.isoformat())
|
_metadata_row("Updated", job.updated_at.isoformat())
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from transcription.db import get_session
|
|||||||
from transcription.models import Document
|
from transcription.models import Document
|
||||||
from transcription.models import Job
|
from transcription.models import Job
|
||||||
from transcription.models import Transcript
|
from transcription.models import Transcript
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.error_presenter import summarize_error
|
from transcription.ui.components.error_presenter import summarize_error
|
||||||
from transcription.ui.components.job_detail import render_job_detail
|
from transcription.ui.components.job_detail import render_job_detail
|
||||||
@@ -59,6 +60,7 @@ def register_page() -> None:
|
|||||||
|
|
||||||
@ui.page("/jobs")
|
@ui.page("/jobs")
|
||||||
async def jobs_page() -> None:
|
async def jobs_page() -> None:
|
||||||
|
render_navigation_header(current_path="/jobs")
|
||||||
ui.label("Transcription Jobs")
|
ui.label("Transcription Jobs")
|
||||||
status = ui.label("Ready")
|
status = ui.label("Ready")
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ def register_page() -> None:
|
|||||||
|
|
||||||
@ui.page("/jobs/{job_id}")
|
@ui.page("/jobs/{job_id}")
|
||||||
async def job_detail_page(job_id: str) -> None:
|
async def job_detail_page(job_id: str) -> None:
|
||||||
|
render_navigation_header(current_path="/jobs")
|
||||||
ui.label("Job Detail")
|
ui.label("Job Detail")
|
||||||
try:
|
try:
|
||||||
parsed_id = UUID(job_id)
|
parsed_id = UUID(job_id)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from nicegui import ui
|
|||||||
from transcription.app_state import resolve_session_factory
|
from transcription.app_state import resolve_session_factory
|
||||||
from transcription.db import get_session
|
from transcription.db import get_session
|
||||||
from transcription.services.store import create_upload_job
|
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.ui.components.upload import render_upload_widget
|
||||||
from transcription.worker import resolve_worker_notifier
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ def register_page() -> None:
|
|||||||
|
|
||||||
@ui.page("/upload", title="Upload Document")
|
@ui.page("/upload", title="Upload Document")
|
||||||
def upload_page(request: Request) -> None:
|
def upload_page(request: Request) -> None:
|
||||||
|
render_navigation_header(current_path="/upload")
|
||||||
session_factory = resolve_session_factory(request.app.state)
|
session_factory = resolve_session_factory(request.app.state)
|
||||||
|
|
||||||
async def submit_upload(filename: str, file_bytes: bytes):
|
async def submit_upload(filename: str, file_bytes: bytes):
|
||||||
@@ -29,6 +31,3 @@ def register_page() -> None:
|
|||||||
|
|
||||||
notify_worker = resolve_worker_notifier(request.app.state)
|
notify_worker = resolve_worker_notifier(request.app.state)
|
||||||
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
|
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
|
||||||
|
|
||||||
with ui.row():
|
|
||||||
ui.link("View jobs", "/jobs")
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
:root {
|
||||||
|
/* Soft blue-night palette tokens */
|
||||||
|
--ctp-rosewater: #f2dde5;
|
||||||
|
--ctp-flamingo: #edcfd8;
|
||||||
|
--ctp-pink: #dcc7de;
|
||||||
|
--ctp-mauve: #a9bde5;
|
||||||
|
--ctp-red: #d98a9a;
|
||||||
|
--ctp-maroon: #d39aa5;
|
||||||
|
--ctp-peach: #d7af8c;
|
||||||
|
--ctp-yellow: #e2c083;
|
||||||
|
--ctp-green: #86c8ad;
|
||||||
|
--ctp-teal: #77bfbe;
|
||||||
|
--ctp-sky: #7ebdda;
|
||||||
|
--ctp-sapphire: #74aed0;
|
||||||
|
--ctp-blue: #92b5f5;
|
||||||
|
--ctp-lavender: #6f97e8;
|
||||||
|
--ctp-text: #d8e2f5;
|
||||||
|
--ctp-subtext1: #bfcae0;
|
||||||
|
--ctp-subtext0: #a9b6cf;
|
||||||
|
--ctp-overlay2: #95a3bf;
|
||||||
|
--ctp-overlay1: #7c8ca9;
|
||||||
|
--ctp-overlay0: #657490;
|
||||||
|
--ctp-surface2: #4d5f7c;
|
||||||
|
--ctp-surface1: #394a65;
|
||||||
|
--ctp-surface0: #2a3954;
|
||||||
|
--ctp-base: #1f2b42;
|
||||||
|
--ctp-mantle: #1a2538;
|
||||||
|
--ctp-crust: #141e30;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
"""Tests for the jobs page route."""
|
"""Tests for the jobs page route."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from transcription.ui import register_pages
|
from transcription.ui import register_pages
|
||||||
from transcription.ui.pages import jobs_page
|
from transcription.ui.pages import jobs_page
|
||||||
|
from transcription.ui.pages.jobs_page import JobTableRow
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -13,7 +16,16 @@ def client(monkeypatch):
|
|||||||
"""Provide a minimal app client with jobs data patched for rendering."""
|
"""Provide a minimal app client with jobs data patched for rendering."""
|
||||||
|
|
||||||
async def _fetch_jobs_stub():
|
async def _fetch_jobs_stub():
|
||||||
return []
|
return [
|
||||||
|
JobTableRow(
|
||||||
|
id=UUID("00000000-0000-0000-0000-000000000001"),
|
||||||
|
status="queued",
|
||||||
|
filename="sample.pdf",
|
||||||
|
retry_count=2,
|
||||||
|
created_at="2026-01-01T12:00:00+00:00",
|
||||||
|
updated_at="2026-01-01T12:01:00+00:00",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_stub)
|
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_stub)
|
||||||
|
|
||||||
@@ -27,11 +39,15 @@ def client(monkeypatch):
|
|||||||
class TestPageRendering:
|
class TestPageRendering:
|
||||||
"""Verify the jobs page is available and includes the main controls."""
|
"""Verify the jobs page is available and includes the main controls."""
|
||||||
|
|
||||||
def test_jobs_page_renders_expected_controls(self, client):
|
def test_jobs_page_renders_expected_controls(self, client, monkeypatch):
|
||||||
"""GET /ui/jobs returns the page shell and jobs controls."""
|
"""GET /ui/jobs returns the page shell and jobs controls."""
|
||||||
response = client.get("/ui/jobs")
|
response = client.get("/ui/jobs")
|
||||||
|
|
||||||
|
async def _fetch_jobs_empty():
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_empty)
|
||||||
|
response = client.get("/ui/jobs")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Transcription Jobs" in response.text
|
assert "No jobs yet." in response.text
|
||||||
assert "Refresh" in response.text
|
|
||||||
assert "Back to upload" in response.text
|
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ class TestPageRegistration:
|
|||||||
*,
|
*,
|
||||||
mount_path: str,
|
mount_path: str,
|
||||||
show_welcome_message: bool,
|
show_welcome_message: bool,
|
||||||
|
dark: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
calls.append(f"run_with:{mount_path}:{show_welcome_message}")
|
calls.append(f"run_with:{mount_path}:{show_welcome_message}:{dark}")
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.ui.register_upload_page", _record_upload)
|
monkeypatch.setattr("transcription.ui.register_upload_page", _record_upload)
|
||||||
monkeypatch.setattr("transcription.ui.register_jobs_page", _record_jobs)
|
monkeypatch.setattr("transcription.ui.register_jobs_page", _record_jobs)
|
||||||
@@ -35,4 +36,4 @@ class TestPageRegistration:
|
|||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
register_pages(app)
|
register_pages(app)
|
||||||
|
|
||||||
assert calls == ["upload", "jobs", "run_with:/ui:False"]
|
assert calls == ["upload", "jobs", "run_with:/ui:False:True"]
|
||||||
|
|||||||
@@ -52,4 +52,4 @@ class TestPageRendering:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Upload Document" in response.text
|
assert "Upload Document" in response.text
|
||||||
assert "Select document file" in response.text
|
assert "Select document file" in response.text
|
||||||
assert "View jobs" in response.text
|
assert "Jobs" in response.text
|
||||||
|
|||||||
Reference in New Issue
Block a user