generated from john/python-template
Compare commits
3
Commits
8064821503
...
5719debbaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5719debbaa | ||
|
|
e7c7ab71b4 | ||
|
|
ca5c9f787f |
@@ -8,6 +8,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from .api.errors import register_error_handlers
|
from .api.errors import register_error_handlers
|
||||||
from .api.health import router as health_router
|
from .api.health import router as health_router
|
||||||
@@ -25,7 +26,7 @@ from .worker import worker_consumer_lifespan
|
|||||||
async def _lifespan(app: FastAPI):
|
async def _lifespan(app: FastAPI):
|
||||||
configure_logging()
|
configure_logging()
|
||||||
|
|
||||||
settings = get_settings()
|
settings = getattr(app.state, "settings", None) or get_settings()
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.services = ServiceBundle()
|
app.state.services = ServiceBundle()
|
||||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||||
@@ -52,6 +53,13 @@ async def _lifespan(app: FastAPI):
|
|||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create and configure the FastAPI application."""
|
"""Create and configure the FastAPI application."""
|
||||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||||
|
settings = get_settings()
|
||||||
|
app.state.settings = settings
|
||||||
|
app.mount(
|
||||||
|
"/uploads",
|
||||||
|
StaticFiles(directory=settings.upload_dir, check_dir=False),
|
||||||
|
name="uploads",
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False)
|
||||||
async def root_redirect() -> RedirectResponse:
|
async def root_redirect() -> RedirectResponse:
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
|
|
||||||
from transcription.ui.components.app_shell import NAV_ITEMS
|
from transcription.ui.components.app_shell import NAV_ITEMS
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
|
|
||||||
__all__ = ["NAV_ITEMS", "render_navigation_header"]
|
__all__ = ["NAV_ITEMS", "render_document_panzoom", "render_navigation_header"]
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""Panzoom-backed document preview component."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.config import get_settings
|
||||||
|
from transcription.models import Document
|
||||||
|
|
||||||
|
PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.js"
|
||||||
|
UPLOADS_URL_PREFIX = "/uploads"
|
||||||
|
|
||||||
|
|
||||||
|
def _document_url(document: Document) -> str:
|
||||||
|
file_path = Path(document.file_path)
|
||||||
|
upload_dir = get_settings().upload_dir
|
||||||
|
|
||||||
|
relative_path: Path
|
||||||
|
try:
|
||||||
|
relative_path = file_path.resolve().relative_to(upload_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
parts = file_path.parts
|
||||||
|
if "uploads" in parts:
|
||||||
|
uploads_index = parts.index("uploads")
|
||||||
|
relative_path = Path(*parts[uploads_index + 1 :])
|
||||||
|
else:
|
||||||
|
relative_path = Path(file_path.name)
|
||||||
|
|
||||||
|
encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
|
||||||
|
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _document_kind(document: Document) -> str:
|
||||||
|
suffix = Path(document.file_path).suffix.lower()
|
||||||
|
if suffix == ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
return "image"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _register_panzoom_assets() -> None:
|
||||||
|
ui.add_head_html(
|
||||||
|
f'<script src="{PANGOZOOM_CDN_URL}"></script>',
|
||||||
|
shared=True,
|
||||||
|
)
|
||||||
|
ui.add_head_html(
|
||||||
|
"""
|
||||||
|
<style>
|
||||||
|
.document-panzoom-host {
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-panzoom-surface {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-panzoom-media {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-panzoom-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
""",
|
||||||
|
shared=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_panzoom(host_id: str) -> None:
|
||||||
|
ui.run_javascript(
|
||||||
|
f"""
|
||||||
|
(function() {{
|
||||||
|
if (!window.Panzoom) return;
|
||||||
|
window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
|
||||||
|
const host = document.getElementById({host_id!r});
|
||||||
|
if (!host) return;
|
||||||
|
const target = host.querySelector('[data-panzoom-target]');
|
||||||
|
const media = host.querySelector('[data-panzoom-media]');
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const computeFitScale = () => {{
|
||||||
|
const hostRect = host.getBoundingClientRect();
|
||||||
|
if (hostRect.width <= 0 || hostRect.height <= 0) return 1;
|
||||||
|
|
||||||
|
if (media && media.tagName === 'IMG') {{
|
||||||
|
if (media.naturalWidth <= 0 || media.naturalHeight <= 0) return 1;
|
||||||
|
return Math.min(
|
||||||
|
hostRect.width / media.naturalWidth,
|
||||||
|
hostRect.height / media.naturalHeight
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (target.tagName === 'IFRAME') {{
|
||||||
|
const targetRect = target.getBoundingClientRect();
|
||||||
|
if (targetRect.width <= 0 || targetRect.height <= 0) return 1;
|
||||||
|
return hostRect.height / targetRect.height;
|
||||||
|
}}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}};
|
||||||
|
|
||||||
|
const initPanzoom = () => {{
|
||||||
|
if (window.__transcriptionPanzoom[{host_id!r}]) {{
|
||||||
|
window.__transcriptionPanzoom[{host_id!r}].destroy();
|
||||||
|
}}
|
||||||
|
|
||||||
|
if (host.__transcriptionWheelHandler) {{
|
||||||
|
host.removeEventListener('wheel', host.__transcriptionWheelHandler);
|
||||||
|
host.__transcriptionWheelHandler = null;
|
||||||
|
}}
|
||||||
|
|
||||||
|
let fitScale = computeFitScale();
|
||||||
|
|
||||||
|
if (!Number.isFinite(fitScale) || fitScale <= 0) {{
|
||||||
|
fitScale = 1;
|
||||||
|
}}
|
||||||
|
|
||||||
|
const startScale = fitScale;
|
||||||
|
const minScale = 0.01;
|
||||||
|
const instance = Panzoom(target, {{
|
||||||
|
maxScale: 32,
|
||||||
|
minScale: minScale,
|
||||||
|
startScale: startScale,
|
||||||
|
step: 0.18,
|
||||||
|
contain: 'inside',
|
||||||
|
roundPixels: true,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}});
|
||||||
|
|
||||||
|
window.__transcriptionPanzoom[{host_id!r}] = instance;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {{
|
||||||
|
instance.reset({{ animate: false, force: true }});
|
||||||
|
instance.setOptions({{ contain: undefined }});
|
||||||
|
}});
|
||||||
|
|
||||||
|
const wheelHandler = (event) => instance.zoomWithWheel(event);
|
||||||
|
host.__transcriptionWheelHandler = wheelHandler;
|
||||||
|
host.addEventListener('wheel', wheelHandler, {{ passive: false }});
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (media && media.tagName === 'IMG' && !media.complete) {{
|
||||||
|
media.addEventListener('load', initPanzoom, {{ once: true }});
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
if ((media && media.tagName === 'IMG' && (host.clientWidth <= 0 || host.clientHeight <= 0)) ||
|
||||||
|
(target.tagName === 'IFRAME' && (host.clientWidth <= 0 || host.clientHeight <= 0))) {{
|
||||||
|
requestAnimationFrame(initPanzoom);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
initPanzoom();
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_document_panzoom(*, document: Document, height: str = "640px") -> None:
|
||||||
|
"""Render a document preview with pan and zoom interactions."""
|
||||||
|
_register_panzoom_assets()
|
||||||
|
|
||||||
|
host_id = f"document-panzoom-{uuid4().hex}"
|
||||||
|
document_url = _document_url(document)
|
||||||
|
document_kind = _document_kind(document)
|
||||||
|
|
||||||
|
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
|
||||||
|
with ui.row().classes("w-full items-center justify-between q-gutter-sm"):
|
||||||
|
with ui.column().classes("q-gutter-none"):
|
||||||
|
ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label(document.filename).classes("text-caption text-grey-4")
|
||||||
|
|
||||||
|
with ui.button_group().props("flat outline"):
|
||||||
|
ui.button(
|
||||||
|
"Zoom In",
|
||||||
|
icon="add",
|
||||||
|
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.zoomIn()"),
|
||||||
|
)
|
||||||
|
ui.button(
|
||||||
|
"Zoom Out",
|
||||||
|
icon="remove",
|
||||||
|
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.zoomOut()"),
|
||||||
|
)
|
||||||
|
ui.button(
|
||||||
|
"Reset",
|
||||||
|
icon="restart_alt",
|
||||||
|
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.reset()"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ui.label("Scroll, pinch, or use the buttons to inspect the document.").classes(
|
||||||
|
"text-caption text-grey-4 q-mt-sm"
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
ui.element("div")
|
||||||
|
.classes("w-full document-panzoom-host bg-blue-grey-9 rounded-borders q-mt-md")
|
||||||
|
.style(f"height: {height};") as host
|
||||||
|
):
|
||||||
|
host.props(f"id={host_id}")
|
||||||
|
with ui.element("div").classes("document-panzoom-surface"):
|
||||||
|
if document_kind == "pdf":
|
||||||
|
ui.html(
|
||||||
|
f'<iframe class="document-panzoom-iframe" '
|
||||||
|
f'src="{document_url}" title="{document.filename}" '
|
||||||
|
"data-panzoom-target></iframe>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ui.html(
|
||||||
|
f'<img class="document-panzoom-media" '
|
||||||
|
f'src="{document_url}" alt="{document.filename}" '
|
||||||
|
"data-panzoom-target data-panzoom-media />"
|
||||||
|
)
|
||||||
|
|
||||||
|
_attach_panzoom(host_id)
|
||||||
@@ -7,6 +7,7 @@ from nicegui import ui
|
|||||||
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.document_panzoom import render_document_panzoom
|
||||||
|
|
||||||
|
|
||||||
def _status_chip_classes(status: str) -> str:
|
def _status_chip_classes(status: str) -> str:
|
||||||
@@ -35,6 +36,9 @@ def _render_document_section(document: Document) -> None:
|
|||||||
_metadata_row("Filename", document.filename)
|
_metadata_row("Filename", document.filename)
|
||||||
_metadata_row("File path", document.file_path)
|
_metadata_row("File path", document.file_path)
|
||||||
|
|
||||||
|
ui.separator().classes("q-my-md bg-blue-grey-7")
|
||||||
|
render_document_panzoom(document=document)
|
||||||
|
|
||||||
|
|
||||||
def _render_transcript_section(transcript: Transcript | None) -> None:
|
def _render_transcript_section(transcript: Transcript | None) -> None:
|
||||||
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
|
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Shared fixtures for UI integration tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from transcription.app import create_app
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.config import _settings
|
||||||
|
from transcription.db import get_session
|
||||||
|
from transcription.models import Document
|
||||||
|
from transcription.models import Job
|
||||||
|
from transcription.models import JobStatus
|
||||||
|
from transcription.models import Transcript
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_client(tmp_path: Path) -> tuple[FastAPI, TestClient]:
|
||||||
|
"""Provide a real application and test client backed by in-memory SQLite."""
|
||||||
|
settings = Settings(
|
||||||
|
openrouter_api_key="test-key",
|
||||||
|
database_url="sqlite:///:memory:",
|
||||||
|
environment="test",
|
||||||
|
upload_dir=tmp_path / "uploads",
|
||||||
|
prompt_dir=tmp_path / "prompts",
|
||||||
|
)
|
||||||
|
_settings.set(settings)
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield app, client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||||
|
"""Return a helper for inserting a document/job/transcript trio."""
|
||||||
|
app, _ = app_client
|
||||||
|
|
||||||
|
def _seed(
|
||||||
|
*,
|
||||||
|
filename: str = "sample.pdf",
|
||||||
|
status: JobStatus = JobStatus.TRANSCRIBED,
|
||||||
|
transcript_text: str | None = "Sample transcript text",
|
||||||
|
error_detail: str | None = None,
|
||||||
|
) -> UUID:
|
||||||
|
async def _insert() -> UUID:
|
||||||
|
async with get_session(session_factory=app.state.runtime.session_factory) as session:
|
||||||
|
document = Document(filename=filename, file_path=f"uploads/{filename}")
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=status, retry_count=0)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
if transcript_text is not None or error_detail is not None:
|
||||||
|
session.add(
|
||||||
|
Transcript(
|
||||||
|
job_id=job.id,
|
||||||
|
provider="openrouter",
|
||||||
|
prompt_name="transcribe_document",
|
||||||
|
text=transcript_text,
|
||||||
|
error_detail=error_detail,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return job.id
|
||||||
|
|
||||||
|
return asyncio.run(_insert())
|
||||||
|
|
||||||
|
return _seed
|
||||||
Reference in New Issue
Block a user