generated from john/python-template
Fixed Source Detail page
This commit is contained in:
@@ -48,7 +48,7 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
|
|||||||
"""Render sources table and open detail page when clicking a row."""
|
"""Render sources table and open detail page when clicking a row."""
|
||||||
if not rows:
|
if not rows:
|
||||||
with archival_card(extra_classes="p-8 text-center"):
|
with archival_card(extra_classes="p-8 text-center"):
|
||||||
render_empty_state("No source file records found in repository.")
|
render_empty_state("No source asset records found in repository.")
|
||||||
return
|
return
|
||||||
|
|
||||||
table = build_table(
|
table = build_table(
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ def dark_room_viewer(
|
|||||||
.style("transform-origin: center center;")
|
.style("transform-origin: center center;")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
viewer_key = str(img.id)
|
||||||
|
|
||||||
# Client-side JavaScript state management for smooth panning and scaling
|
# Client-side JavaScript state management for smooth panning and scaling
|
||||||
js_pan_zoom = f"""
|
js_pan_zoom = f"""
|
||||||
const viewport = getElement('{viewport.id}');
|
(() => {{
|
||||||
const img = getElement('{img.id}');
|
const viewport = document.getElementById('{viewport.id}');
|
||||||
|
const img = document.getElementById('{img.id}');
|
||||||
|
if (!viewport || !img) return;
|
||||||
|
|
||||||
let scale = 1;
|
let scale = 1;
|
||||||
let pointX = 0;
|
let pointX = 0;
|
||||||
@@ -46,43 +50,52 @@ def dark_room_viewer(
|
|||||||
img.style.transform = `translate(${{pointX}}px, ${{pointY}}px) scale(${{scale}})`;
|
img.style.transform = `translate(${{pointX}}px, ${{pointY}}px) scale(${{scale}})`;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Mouse Wheel Zooming
|
function zoomIn() {{
|
||||||
viewport.onwheel = function(e) {{
|
scale = Math.min(scale * 1.25, 8);
|
||||||
e.preventDefault();
|
|
||||||
const xs = (e.clientX - pointX) / scale;
|
|
||||||
const ys = (e.clientY - pointY) / scale;
|
|
||||||
|
|
||||||
const delta = -e.deltaY;
|
|
||||||
(delta > 0) ? (scale *= 1.15) : (scale /= 1.15);
|
|
||||||
scale = Math.min(Math.max(0.5, scale), 8); // Constrain zoom level (0.5x to 8x)
|
|
||||||
|
|
||||||
updateTransform();
|
updateTransform();
|
||||||
}};
|
}}
|
||||||
|
|
||||||
// Mouse Drag Panning
|
function zoomOut() {{
|
||||||
viewport.onmousedown = function(e) {{
|
scale = Math.max(scale / 1.25, 0.5);
|
||||||
|
updateTransform();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function resetZoom() {{
|
||||||
|
scale = 1;
|
||||||
|
pointX = 0;
|
||||||
|
pointY = 0;
|
||||||
|
updateTransform();
|
||||||
|
}}
|
||||||
|
|
||||||
|
viewport.addEventListener('wheel', (e) => {{
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = -e.deltaY;
|
||||||
|
if (delta > 0) zoomIn();
|
||||||
|
else zoomOut();
|
||||||
|
}}, {{ passive: false }});
|
||||||
|
|
||||||
|
viewport.addEventListener('mousedown', (e) => {{
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startX = e.clientX - pointX;
|
startX = e.clientX - pointX;
|
||||||
startY = e.clientY - pointY;
|
startY = e.clientY - pointY;
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
}};
|
}});
|
||||||
|
|
||||||
window.onmouseup = function() {{
|
window.addEventListener('mouseup', () => {{
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
}};
|
}});
|
||||||
|
|
||||||
viewport.onmousemove = function(e) {{
|
viewport.addEventListener('mousemove', (e) => {{
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
pointX = e.clientX - startX;
|
pointX = e.clientX - startX;
|
||||||
pointY = e.clientY - startY;
|
pointY = e.clientY - startY;
|
||||||
updateTransform();
|
updateTransform();
|
||||||
}};
|
}});
|
||||||
|
|
||||||
// Global function handles for external control toolbar
|
window.__darkRoomViewer = window.__darkRoomViewer || {{}};
|
||||||
window.resetZoom_{img.id} = function() {{ scale = 1; pointX = 0; pointY = 0; updateTransform(); }};
|
window.__darkRoomViewer['{viewer_key}'] = {{ zoomIn, zoomOut, resetZoom }};
|
||||||
window.zoomIn_{img.id} = function() {{ scale = Math.min(scale * 1.25, 8); updateTransform(); }};
|
}})();
|
||||||
window.zoomOut_{img.id} = function() {{ scale = Math.max(scale / 1.25, 0.5); updateTransform(); }};
|
|
||||||
"""
|
"""
|
||||||
ui.run_javascript(js_pan_zoom)
|
ui.run_javascript(js_pan_zoom)
|
||||||
|
|
||||||
@@ -90,17 +103,17 @@ def dark_room_viewer(
|
|||||||
with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t ui-border-viewer"):
|
with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t ui-border-viewer"):
|
||||||
ui.button(
|
ui.button(
|
||||||
icon="zoom_in",
|
icon="zoom_in",
|
||||||
on_click=lambda: ui.run_javascript(f"window.zoomIn_{img.id}()"),
|
on_click=lambda: ui.run_javascript(f"window.__darkRoomViewer?.['{viewer_key}']?.zoomIn?.()"),
|
||||||
).props("flat round dense color=white text-xs").tooltip("Zoom In")
|
).props("flat round dense color=white text-xs").tooltip("Zoom In")
|
||||||
|
|
||||||
ui.button(
|
ui.button(
|
||||||
icon="zoom_out",
|
icon="zoom_out",
|
||||||
on_click=lambda: ui.run_javascript(f"window.zoomOut_{img.id}()"),
|
on_click=lambda: ui.run_javascript(f"window.__darkRoomViewer?.['{viewer_key}']?.zoomOut?.()"),
|
||||||
).props("flat round dense color=white text-xs").tooltip("Zoom Out")
|
).props("flat round dense color=white text-xs").tooltip("Zoom Out")
|
||||||
|
|
||||||
ui.button(
|
ui.button(
|
||||||
icon="center_focus_strong",
|
icon="center_focus_strong",
|
||||||
on_click=lambda: ui.run_javascript(f"window.resetZoom_{img.id}()"),
|
on_click=lambda: ui.run_javascript(f"window.__darkRoomViewer?.['{viewer_key}']?.resetZoom?.()"),
|
||||||
).props("flat round dense color=white text-xs").tooltip("Reset View")
|
).props("flat round dense color=white text-xs").tooltip("Reset View")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ from transcription.services.documents import DocumentError, DocumentService
|
|||||||
from transcription.services.store import UploadError, store_person_portrait
|
from transcription.services.store import UploadError, store_person_portrait
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
from transcription.ui.components.data_display import archival_badge, metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.primitives import (
|
from transcription.ui.components.primitives import (
|
||||||
destructive_button,
|
destructive_button,
|
||||||
@@ -121,7 +123,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
|
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
@ui.page("/people/{person_id}")
|
@ui.page("/people/{person_id}")
|
||||||
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
async def person_detail_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
people_service = DocumentService(session_factory=session_factory)
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
@@ -157,7 +159,11 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
|
|
||||||
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||||
_render_person_portrait_zone(person)
|
_render_person_portrait_zone(
|
||||||
|
person,
|
||||||
|
settings=_resolve_runtime_settings(request),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
_render_person_biographical_zone(person)
|
_render_person_biographical_zone(person)
|
||||||
_render_person_biography_zone(person)
|
_render_person_biography_zone(person)
|
||||||
|
|
||||||
@@ -338,8 +344,8 @@ def _render_person_form_fields(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _render_person_portrait_zone(person: Person) -> None:
|
def _render_person_portrait_zone(person: Person, *, settings: Settings, request: Request) -> None:
|
||||||
portrait_src = _resolve_portrait_src(person.portrait_path)
|
portrait_src = _resolve_portrait_src(person.portrait_path, settings=settings, request=request)
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4"):
|
with ui.column().classes("col-span-12 lg:col-span-4"):
|
||||||
dark_room_viewer(portrait_src, count_label="Portrait Media")
|
dark_room_viewer(portrait_src, count_label="Portrait Media")
|
||||||
|
|
||||||
@@ -425,7 +431,7 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti
|
|||||||
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-text-muted")
|
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-text-muted")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_portrait_src(path: str | None) -> str | None:
|
def _resolve_portrait_src(path: str | None, *, settings: Settings, request: Request) -> str | None:
|
||||||
candidate = (path or "").strip()
|
candidate = (path or "").strip()
|
||||||
if not candidate:
|
if not candidate:
|
||||||
return None
|
return None
|
||||||
@@ -434,11 +440,52 @@ def _resolve_portrait_src(path: str | None) -> str | None:
|
|||||||
lowered = normalized.casefold()
|
lowered = normalized.casefold()
|
||||||
if lowered.startswith(("http://", "https://", "data:")):
|
if lowered.startswith(("http://", "https://", "data:")):
|
||||||
return normalized
|
return normalized
|
||||||
if normalized.startswith("/"):
|
if normalized.startswith("/uploads/"):
|
||||||
return normalized
|
return _to_absolute_upload_url(normalized, request=request)
|
||||||
|
|
||||||
|
upload_dir = settings.upload_dir.resolve()
|
||||||
|
path_obj = Path(candidate)
|
||||||
|
|
||||||
|
if path_obj.is_absolute():
|
||||||
|
absolute_candidates = [path_obj.resolve()]
|
||||||
|
else:
|
||||||
|
absolute_candidates = [
|
||||||
|
(Path.cwd() / path_obj).resolve(),
|
||||||
|
(upload_dir / path_obj).resolve(),
|
||||||
|
]
|
||||||
|
|
||||||
|
for absolute_candidate in absolute_candidates:
|
||||||
|
try:
|
||||||
|
relative = absolute_candidate.relative_to(upload_dir).as_posix()
|
||||||
|
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
upload_name = upload_dir.name.casefold()
|
||||||
|
normalized_parts = Path(normalized).parts
|
||||||
|
lowered_parts = [part.casefold() for part in normalized_parts]
|
||||||
|
if upload_name in lowered_parts:
|
||||||
|
idx = lowered_parts.index(upload_name)
|
||||||
|
relative = Path(*normalized_parts[idx + 1 :]).as_posix()
|
||||||
|
if relative:
|
||||||
|
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
||||||
|
|
||||||
if lowered.startswith("uploads/"):
|
if lowered.startswith("uploads/"):
|
||||||
return f"/{normalized}"
|
return _to_absolute_upload_url(f"/{normalized}", request=request)
|
||||||
return f"/uploads/{quote(normalized)}"
|
if lowered.startswith("data/"):
|
||||||
|
relative = normalized.split("/", 1)[1] if "/" in normalized else ""
|
||||||
|
if relative:
|
||||||
|
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
||||||
|
if lowered.startswith(("documents/", "persons/")):
|
||||||
|
return _to_absolute_upload_url(f"/uploads/{quote(normalized)}", request=request)
|
||||||
|
|
||||||
|
return _to_absolute_upload_url(f"/uploads/{quote(path_obj.name)}", request=request)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_absolute_upload_url(path: str, *, request: Request) -> str:
|
||||||
|
base = str(request.base_url).rstrip("/")
|
||||||
|
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||||
|
return f"{base}{normalized_path}"
|
||||||
|
|
||||||
|
|
||||||
def _resolve_runtime_settings(request: Request) -> Settings:
|
def _resolve_runtime_settings(request: Request) -> Settings:
|
||||||
|
|||||||
@@ -1,45 +1,51 @@
|
|||||||
"""Sources UI page module for VibeScribe."""
|
"""Sources list and detail page registration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.services.transcription import TranscriptionNotFoundError
|
from transcription.config import Settings, get_settings
|
||||||
from transcription.services.transcription import TranscriptionService
|
from transcription.db.models import JobSource, Source
|
||||||
|
from transcription.services.transcription import (
|
||||||
|
SourceDeleteBlockedError,
|
||||||
|
TranscriptionNotFoundError,
|
||||||
|
TranscriptionService,
|
||||||
|
)
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.data_display import archival_badge, metadata_row
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import destructive_button, render_empty_state, section_header_row
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
|
||||||
|
from transcription.ui.components.viewers import dark_room_viewer
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
|
|
||||||
async def sources_page(
|
def register_page() -> None:
|
||||||
|
"""Register sources list, detail, and deletion routes."""
|
||||||
|
|
||||||
|
@ui.page("/sources")
|
||||||
|
async def sources_page(
|
||||||
session_factory: SessionFactoryDep,
|
session_factory: SessionFactoryDep,
|
||||||
document_id: Optional[str] = None,
|
document_id: str | None = None,
|
||||||
job_id: Optional[str] = None,
|
job_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Render the master or context-filtered sources page."""
|
|
||||||
sources_service = TranscriptionService(session_factory=session_factory)
|
sources_service = TranscriptionService(session_factory=session_factory)
|
||||||
parsed_doc_id: Optional[UUID] = None
|
parsed_doc_id = _parse_uuid(document_id)
|
||||||
parsed_job_id: Optional[UUID] = None
|
parsed_job_id = _parse_uuid(job_id)
|
||||||
|
|
||||||
if document_id:
|
header_title = "Source Asset Records"
|
||||||
try:
|
if parsed_doc_id is not None:
|
||||||
parsed_doc_id = UUID(document_id)
|
header_title = "Sources for Document"
|
||||||
except ValueError:
|
elif parsed_job_id is not None:
|
||||||
pass
|
header_title = "Sources for Job"
|
||||||
|
|
||||||
if job_id:
|
|
||||||
try:
|
|
||||||
parsed_job_id = UUID(job_id)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sources = await sources_service.list_sources_detail(document_id=parsed_doc_id, job_id=parsed_job_id)
|
sources = await sources_service.list_sources_detail(document_id=parsed_doc_id, job_id=parsed_job_id)
|
||||||
@@ -47,108 +53,405 @@ async def sources_page(
|
|||||||
show_error(exc, title="Load failed", operation="sources.list")
|
show_error(exc, title="Load failed", operation="sources.list")
|
||||||
return
|
return
|
||||||
|
|
||||||
header_title = "Source Asset Records"
|
|
||||||
if parsed_doc_id:
|
|
||||||
header_title = "Sources for Document"
|
|
||||||
elif parsed_job_id:
|
|
||||||
header_title = "Sources for Job"
|
|
||||||
|
|
||||||
render_navigation_header(current_path="/sources")
|
render_navigation_header(current_path="/sources")
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
|
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||||
with section_header_row():
|
with section_header_row():
|
||||||
page_header(header_title)
|
page_header(header_title)
|
||||||
|
if parsed_doc_id is not None:
|
||||||
if not sources:
|
ui.button(
|
||||||
with archival_card(extra_classes="p-8 text-center"):
|
"Back to Document",
|
||||||
render_empty_state("No source file records found in repository.")
|
on_click=lambda: ui.navigate.to(f"/documents/{parsed_doc_id}"),
|
||||||
return
|
icon="arrow_back",
|
||||||
|
).props("flat")
|
||||||
columns = [
|
elif parsed_job_id is not None:
|
||||||
{"name": "upload_name", "label": "Upload Name", "field": "upload_name", "align": "left", "sortable": True},
|
ui.button(
|
||||||
{"name": "document_name", "label": "Document Context", "field": "document_name", "align": "left", "sortable": True},
|
"Back to Job",
|
||||||
{"name": "page_number", "label": "Page #", "field": "page_number", "align": "center", "sortable": True},
|
on_click=lambda: ui.navigate.to(f"/jobs/{parsed_job_id}"),
|
||||||
{"name": "status", "label": "Latest Status", "field": "status", "align": "center", "sortable": True},
|
icon="arrow_back",
|
||||||
{"name": "revised", "label": "Human Revised", "field": "revised", "align": "center", "sortable": True},
|
).props("flat")
|
||||||
{"name": "actions", "label": "Actions", "field": "actions", "align": "right"},
|
else:
|
||||||
]
|
ui.button(
|
||||||
|
"Create Job",
|
||||||
|
on_click=lambda: ui.navigate.to("/jobs/new"),
|
||||||
|
icon="add",
|
||||||
|
).classes("ui-btn-primary")
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
{
|
SourceTableRow(
|
||||||
"id": str(source.id),
|
id=source.id,
|
||||||
"upload_name": source.upload_name,
|
page_number=source.page_number,
|
||||||
"document_name": source.document_name or "Unlinked Document",
|
upload_name=source.upload_name,
|
||||||
"page_number": source.page_number,
|
filename=source.filename,
|
||||||
"status": source.latest_status.value if source.latest_status else "Unprocessed",
|
document_id=source.document_id,
|
||||||
"revised": "Yes" if source.revised_text else "No",
|
document_name=source.document_name,
|
||||||
"error_detail": source.latest_error_detail,
|
job_source_status=source.latest_status.value if source.latest_status else "unprocessed",
|
||||||
}
|
job_source_error_detail=source.latest_error_detail,
|
||||||
for source in sources
|
|
||||||
]
|
|
||||||
|
|
||||||
with archival_card(extra_classes="p-0 overflow-hidden"):
|
|
||||||
table = ui.table(columns=columns, rows=rows, row_key="id").classes("w-full ui-table")
|
|
||||||
|
|
||||||
table.add_slot(
|
|
||||||
"body-cell-actions",
|
|
||||||
r"""
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-btn flat round dense icon="visibility" color="primary" @click="() => $parent.$emit('view_source', props.row.id)" />
|
|
||||||
<q-btn flat round dense icon="delete" color="negative" @click="() => $parent.$emit('delete_source', props.row.id)" />
|
|
||||||
</q-td>
|
|
||||||
""",
|
|
||||||
)
|
)
|
||||||
|
for source in sorted(sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||||
|
]
|
||||||
|
render_sources_table(rows)
|
||||||
|
|
||||||
table.on("view_source", lambda e: ui.navigate.to(f"/sources/{e.args}"))
|
if parsed_doc_id is None and parsed_job_id is None:
|
||||||
table.on("delete_source", lambda e: ui.navigate.to(f"/sources/{e.args}/delete"))
|
ui.label("Open a source row to inspect AI output and add human revisions.").classes("text-xs ui-text-muted")
|
||||||
|
|
||||||
|
@ui.page("/sources/{source_id}")
|
||||||
async def source_detail_page(source_id: str, session_factory: SessionFactoryDep) -> None:
|
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
"""Render individual source detail preview and revision workspace."""
|
sources_service = TranscriptionService(session_factory=session_factory)
|
||||||
_ = session_factory
|
parsed_source_id = _parse_uuid(source_id)
|
||||||
|
|
||||||
render_navigation_header(current_path="/sources")
|
render_navigation_header(current_path="/sources")
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
|
if parsed_source_id is None:
|
||||||
with archival_card(title=f"Source Record: {source_id}"):
|
ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4")
|
||||||
ui.label("Source Preview & Revision Workspace").classes("ui-text-muted")
|
return
|
||||||
|
|
||||||
|
|
||||||
async def source_delete_page(source_id: str, session_factory: SessionFactoryDep) -> None:
|
|
||||||
"""Render source deletion confirmation workspace."""
|
|
||||||
sources_service = TranscriptionService(session_factory=session_factory)
|
|
||||||
parsed_source_id: UUID | None = None
|
|
||||||
try:
|
|
||||||
parsed_source_id = UUID(source_id)
|
|
||||||
except ValueError:
|
|
||||||
parsed_source_id = None
|
|
||||||
|
|
||||||
is_linked = False
|
|
||||||
if parsed_source_id is not None:
|
|
||||||
try:
|
try:
|
||||||
source = await sources_service.read_source_detail(parsed_source_id)
|
source = await sources_service.read_source_detail(parsed_source_id)
|
||||||
is_linked = bool(source.job_sources)
|
|
||||||
except TranscriptionNotFoundError:
|
except TranscriptionNotFoundError:
|
||||||
is_linked = False
|
ui.label("Source not found").classes("text-h6 ui-text-danger p-4")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="sources.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
latest_job_source = _latest_job_source(source)
|
||||||
|
original_transcription = _resolve_original_transcription(source=source, latest_job_source=latest_job_source)
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||||
|
with section_header_row():
|
||||||
|
page_header(
|
||||||
|
f"Source Page {source.page_number}: {source.upload_name}",
|
||||||
|
subtitle=f"Source ID: {source.id}",
|
||||||
|
)
|
||||||
|
with ui.row().classes("items-center gap-2"):
|
||||||
|
ui.button(
|
||||||
|
"Back to Sources",
|
||||||
|
on_click=lambda: ui.navigate.to("/sources"),
|
||||||
|
icon="arrow_back",
|
||||||
|
).props("flat")
|
||||||
|
destructive_button(
|
||||||
|
"Delete Source",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/sources/{source.id}/delete"),
|
||||||
|
icon="delete",
|
||||||
|
extra_classes="text-xs",
|
||||||
|
)
|
||||||
|
|
||||||
|
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||||
|
_render_source_viewer_zone(
|
||||||
|
source,
|
||||||
|
settings=_resolve_runtime_settings(request),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
_render_source_transcription_column(
|
||||||
|
source=source,
|
||||||
|
original_transcription=original_transcription,
|
||||||
|
latest_job_source=latest_job_source,
|
||||||
|
sources_service=sources_service,
|
||||||
|
)
|
||||||
|
_render_source_metadata_column(source=source, latest_job_source=latest_job_source)
|
||||||
|
|
||||||
|
@ui.page("/sources/{source_id}/delete")
|
||||||
|
async def source_delete_page(source_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
sources_service = TranscriptionService(session_factory=session_factory)
|
||||||
|
parsed_source_id = _parse_uuid(source_id)
|
||||||
|
|
||||||
|
render_navigation_header(current_path="/sources")
|
||||||
|
|
||||||
|
if parsed_source_id is None:
|
||||||
|
ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
source = await sources_service.read_source_detail(parsed_source_id)
|
||||||
|
except TranscriptionNotFoundError:
|
||||||
|
ui.label("Source not found").classes("text-h6 ui-text-danger p-4")
|
||||||
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Load failed", operation="sources.delete.read")
|
show_error(exc, title="Load failed", operation="sources.delete.read")
|
||||||
return
|
return
|
||||||
|
|
||||||
render_navigation_header(current_path="/sources")
|
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||||
|
page_header("Delete Source Record")
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
|
with archival_card(extra_classes="gap-2"):
|
||||||
with archival_card(title="Delete Source Confirmation"):
|
ui.label(f"Source: {source.upload_name}").classes("text-sm font-semibold ui-text-primary")
|
||||||
if parsed_source_id is None:
|
|
||||||
ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4")
|
if source.job_sources:
|
||||||
|
ui.label("Delete is only available for unlinked sources.").classes("text-xs ui-text-danger font-bold mt-2")
|
||||||
|
ui.label("Open the related job record and remove job links first.").classes("text-xs ui-text-muted italic")
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||||
|
ui.button(
|
||||||
|
"Back to Source",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/sources/{source.id}"),
|
||||||
|
icon="arrow_back",
|
||||||
|
).classes("ui-btn-primary text-xs")
|
||||||
|
ui.button(
|
||||||
|
"Go to Jobs",
|
||||||
|
on_click=lambda: ui.navigate.to("/jobs"),
|
||||||
|
icon="work_history",
|
||||||
|
).props("flat text-xs")
|
||||||
return
|
return
|
||||||
if is_linked:
|
|
||||||
ui.label("Cannot delete source linked to active jobs.").classes("ui-text-danger")
|
ui.label("This action permanently deletes the source record.").classes("text-xs ui-text-danger font-medium")
|
||||||
|
|
||||||
|
async def submit_delete() -> None:
|
||||||
|
try:
|
||||||
|
await sources_service.delete_unlinked_source(source_id=source.id)
|
||||||
|
except SourceDeleteBlockedError as exc:
|
||||||
|
ui.notify(exc.message, type="warning")
|
||||||
|
return
|
||||||
|
except TranscriptionNotFoundError:
|
||||||
|
ui.notify("Source not found.", type="warning")
|
||||||
|
ui.navigate.to("/sources")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Delete failed", operation="sources.delete")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Source deleted", type="positive")
|
||||||
|
ui.navigate.to("/sources")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
destructive_button("Delete source permanently", on_click=submit_delete, icon="delete_forever", variant="solid")
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/sources/{source.id}"), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_source_viewer_zone(source: Source, *, settings: Settings, request: Request) -> None:
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-6"):
|
||||||
|
dark_room_viewer(
|
||||||
|
_resolve_source_media_src(source.file_path, settings=settings, request=request),
|
||||||
|
count_label=f"Page {source.page_number}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_source_transcription_column(
|
||||||
|
*,
|
||||||
|
source: Source,
|
||||||
|
original_transcription: str | None,
|
||||||
|
latest_job_source: JobSource | None,
|
||||||
|
sources_service: TranscriptionService,
|
||||||
|
) -> None:
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
|
||||||
|
_render_source_transcription_zone(
|
||||||
|
source=source,
|
||||||
|
original_transcription=original_transcription,
|
||||||
|
latest_job_source=latest_job_source,
|
||||||
|
sources_service=sources_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_source_metadata_column(*, source: Source, latest_job_source: JobSource | None) -> None:
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
|
||||||
|
_render_source_metadata_zone(source)
|
||||||
|
_render_source_job_metadata_zone(latest_job_source)
|
||||||
|
_render_source_revision_logistics_zone(source)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_source_metadata_zone(source: Source) -> None:
|
||||||
|
with archival_card(title="Source Metadata"):
|
||||||
|
metadata_row("Upload Name:", source.upload_name)
|
||||||
|
metadata_row("Stored Filename:", source.filename)
|
||||||
|
metadata_row("Page Number:", str(source.page_number))
|
||||||
|
metadata_row("Document Name:", source.document_name or "Not set")
|
||||||
|
metadata_row("Document ID:", str(source.document_id))
|
||||||
|
metadata_row("Stored Path:", source.file_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_source_job_metadata_zone(latest_job_source: JobSource | None) -> None:
|
||||||
|
with archival_card(title="SourceJob Metadata"):
|
||||||
|
if latest_job_source is None:
|
||||||
|
render_empty_state("No job execution metadata available yet.", italic=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
status = latest_job_source.status.value
|
||||||
|
with ui.row().classes("w-full justify-between items-center mb-2"):
|
||||||
|
ui.label("Latest Status").classes("text-xs ui-text-muted")
|
||||||
|
archival_badge(status)
|
||||||
|
|
||||||
|
metadata_row("Job ID:", str(latest_job_source.job_id))
|
||||||
|
metadata_row("Executed:", latest_job_source.executed_at.isoformat())
|
||||||
|
metadata_row(
|
||||||
|
"Provider:",
|
||||||
|
latest_job_source.job.provider if latest_job_source.job and latest_job_source.job.provider else "unknown",
|
||||||
|
)
|
||||||
|
metadata_row(
|
||||||
|
"Model:",
|
||||||
|
latest_job_source.job.model if latest_job_source.job and latest_job_source.job.model else "unknown",
|
||||||
|
)
|
||||||
|
metadata_row(
|
||||||
|
"Prompt:",
|
||||||
|
latest_job_source.job.prompt_name if latest_job_source.job and latest_job_source.job.prompt_name else "unknown",
|
||||||
|
)
|
||||||
|
|
||||||
|
if latest_job_source.error_detail:
|
||||||
|
with ui.column().classes("w-full mt-2"):
|
||||||
|
ui.label("Failure Detail:").classes("ui-text-muted text-xs mb-1")
|
||||||
|
ui.label(latest_job_source.error_detail).classes("p-2 ui-note-box text-xs")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_source_revision_logistics_zone(source: Source) -> None:
|
||||||
|
with archival_card(title="Revision Logistics"):
|
||||||
|
metadata_row("Revised:", "Yes" if source.revised_text else "No")
|
||||||
|
metadata_row(
|
||||||
|
"Last Revised:",
|
||||||
|
source.date_revised.isoformat() if source.date_revised is not None else "Not revised",
|
||||||
|
)
|
||||||
|
metadata_row("Uploaded:", source.date_uploaded.isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def _render_source_transcription_zone(
|
||||||
|
*,
|
||||||
|
source: Source,
|
||||||
|
original_transcription: str | None,
|
||||||
|
latest_job_source: JobSource | None,
|
||||||
|
sources_service: TranscriptionService,
|
||||||
|
) -> None:
|
||||||
|
with archival_card(title="Transcription Text"):
|
||||||
|
if original_transcription:
|
||||||
|
ui.label(original_transcription).classes("p-2 ui-note-box text-xs whitespace-pre-wrap")
|
||||||
else:
|
else:
|
||||||
ui.label(f"Are you sure you want to delete source {source_id}?").classes("ui-text-muted")
|
render_empty_state("No transcription text available yet.", italic=True)
|
||||||
|
|
||||||
|
with archival_card(title="Editable Revision"):
|
||||||
|
seed_revision = source.revised_text if source.revised_text is not None else (original_transcription or "")
|
||||||
|
revision_input = ui.textarea(
|
||||||
|
label="Revised transcription",
|
||||||
|
value=seed_revision,
|
||||||
|
).props("outlined autogrow").classes("w-full ui-form-surface")
|
||||||
|
|
||||||
|
save_state = ui.label(
|
||||||
|
f"Last saved: {source.date_revised.isoformat()}"
|
||||||
|
if source.date_revised is not None
|
||||||
|
else "No revision saved yet."
|
||||||
|
).classes("text-xs ui-text-muted")
|
||||||
|
|
||||||
|
async def submit_revision() -> None:
|
||||||
|
revised_text = (revision_input.value or "").strip()
|
||||||
|
if not revised_text:
|
||||||
|
ui.notify("Revised transcription cannot be empty.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated = await sources_service.upsert_revision_for_source(
|
||||||
|
source_id=source.id,
|
||||||
|
text=revised_text,
|
||||||
|
)
|
||||||
|
except TranscriptionNotFoundError:
|
||||||
|
ui.notify("Source not found.", type="warning")
|
||||||
|
ui.navigate.to("/sources")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Save failed", operation="sources.revision.save")
|
||||||
|
return
|
||||||
|
|
||||||
|
source.revised_text = updated.revised_text
|
||||||
|
source.date_revised = updated.date_revised
|
||||||
|
save_state.text = (
|
||||||
|
f"Last saved: {updated.date_revised.isoformat()}" if updated.date_revised is not None else "Revision saved."
|
||||||
|
)
|
||||||
|
ui.notify("Revision saved", type="positive")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
ui.button("Save revision", on_click=submit_revision, icon="save").classes("ui-btn-primary")
|
||||||
|
ui.button("Reset", on_click=lambda: _reset_revision_text(revision_input, source, original_transcription), icon="refresh").props("flat")
|
||||||
|
|
||||||
|
if latest_job_source is not None and latest_job_source.status.value == "failed":
|
||||||
|
ui.label("Source has a failed job execution. Save a human revision to preserve corrected text.").classes(
|
||||||
|
"text-xs ui-text-muted italic"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
def _reset_revision_text(revision_input: ui.textarea, source: Source, original_transcription: str | None) -> None:
|
||||||
"""Register all source-related UI routes with the application router."""
|
fallback_text = source.revised_text if source.revised_text is not None else (original_transcription or "")
|
||||||
ui.page("/sources")(sources_page)
|
revision_input.value = fallback_text
|
||||||
ui.page("/sources/{source_id}")(source_detail_page)
|
|
||||||
ui.page("/sources/{source_id}/delete")(source_delete_page)
|
|
||||||
|
def _latest_job_source(source: Source) -> JobSource | None:
|
||||||
|
if not source.job_sources:
|
||||||
|
return None
|
||||||
|
return max(source.job_sources, key=lambda item: item.executed_at)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_original_transcription(*, source: Source, latest_job_source: JobSource | None) -> str | None:
|
||||||
|
if latest_job_source is not None and latest_job_source.raw_transcription:
|
||||||
|
return latest_job_source.raw_transcription
|
||||||
|
return source.raw_transcription
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_source_media_src(path: str | None, *, settings: Settings, request: Request) -> str | None:
|
||||||
|
candidate = (path or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = candidate.replace("\\", "/")
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
|
||||||
|
if lowered.startswith(("http://", "https://", "data:")):
|
||||||
|
return normalized
|
||||||
|
if normalized.startswith("/uploads/"):
|
||||||
|
return _to_absolute_upload_url(normalized, request=request)
|
||||||
|
|
||||||
|
upload_dir = settings.upload_dir.resolve()
|
||||||
|
path_obj = Path(candidate)
|
||||||
|
|
||||||
|
# Case 1: absolute filesystem path
|
||||||
|
if path_obj.is_absolute():
|
||||||
|
absolute_candidates = [path_obj.resolve()]
|
||||||
|
else:
|
||||||
|
# Case 2: relative path that may already include data root name (e.g. data/documents/...)
|
||||||
|
absolute_candidates = [
|
||||||
|
(Path.cwd() / path_obj).resolve(),
|
||||||
|
(upload_dir / path_obj).resolve(),
|
||||||
|
]
|
||||||
|
|
||||||
|
for absolute_candidate in absolute_candidates:
|
||||||
|
try:
|
||||||
|
relative = absolute_candidate.relative_to(upload_dir).as_posix()
|
||||||
|
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fallback: if path contains upload-dir folder name, strip through that segment.
|
||||||
|
upload_name = upload_dir.name.casefold()
|
||||||
|
normalized_parts = Path(normalized).parts
|
||||||
|
lowered_parts = [part.casefold() for part in normalized_parts]
|
||||||
|
if upload_name in lowered_parts:
|
||||||
|
idx = lowered_parts.index(upload_name)
|
||||||
|
relative = Path(*normalized_parts[idx + 1 :]).as_posix()
|
||||||
|
if relative:
|
||||||
|
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
||||||
|
|
||||||
|
# Final fallback: treat as already relative to upload root.
|
||||||
|
if lowered.startswith("uploads/"):
|
||||||
|
return _to_absolute_upload_url(f"/{normalized}", request=request)
|
||||||
|
if lowered.startswith("data/"):
|
||||||
|
relative = normalized.split("/", 1)[1] if "/" in normalized else ""
|
||||||
|
if relative:
|
||||||
|
return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
|
||||||
|
if lowered.startswith("documents/") or lowered.startswith("persons/"):
|
||||||
|
return _to_absolute_upload_url(f"/uploads/{quote(normalized)}", request=request)
|
||||||
|
|
||||||
|
return _to_absolute_upload_url(f"/uploads/{quote(path_obj.name)}", request=request)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_absolute_upload_url(path: str, *, request: Request) -> str:
|
||||||
|
base = str(request.base_url).rstrip("/")
|
||||||
|
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||||
|
return f"{base}{normalized_path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_runtime_settings(request: Request) -> Settings:
|
||||||
|
app_settings = getattr(request.app.state, "settings", None)
|
||||||
|
if isinstance(app_settings, Settings):
|
||||||
|
return app_settings
|
||||||
|
return get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_uuid(value: str | None) -> UUID | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return UUID(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class TestDocumentsPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Archival Documents" in response.text
|
assert "Archival Documents" in response.text
|
||||||
assert "No documents in repository yet." in response.text
|
assert "No documents found in repository." in response.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_documents_page_lists_seeded_documents(self, app_client):
|
async def test_documents_page_lists_seeded_documents(self, app_client):
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class TestJobsPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Transcription Pipeline Jobs" in response.text
|
assert "Transcription Pipeline Jobs" in response.text
|
||||||
assert "No active or historical processing jobs found." in response.text
|
assert "No job records found in repository." in response.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
async def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ class TestSourcesPageRendering:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Source Asset Records" in response.text
|
assert "Source Asset Records" in response.text
|
||||||
assert "No Source Assets Found".upper() in response.text.upper()
|
|
||||||
assert "No source asset records found in repository." in response.text
|
assert "No source asset records found in repository." in response.text
|
||||||
assert "Upload New Documents" not in response.text
|
assert "Upload New Documents" not in response.text
|
||||||
|
|
||||||
@@ -196,9 +195,14 @@ class TestSourcesPageRendering:
|
|||||||
response = client.get(f"/ui/sources/{source_id}")
|
response = client.get(f"/ui/sources/{source_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "SOURCE RECORD:" in response.text.upper()
|
assert "SOURCE PAGE 1: DETAIL-SOURCE.PNG" in response.text.upper()
|
||||||
assert source_id.upper() in response.text.upper()
|
assert "SOURCE METADATA" in response.text.upper()
|
||||||
assert "SOURCE PREVIEW" in response.text.upper()
|
assert "SOURCEJOB METADATA" in response.text.upper()
|
||||||
|
assert "TRANSCRIPTION TEXT" in response.text.upper()
|
||||||
|
assert "EDITABLE REVISION" in response.text.upper()
|
||||||
|
assert "original transcription text" in response.text
|
||||||
|
assert "human revision text" in response.text
|
||||||
|
assert "Save revision" in response.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_source_delete_page_blocks_when_source_is_job_linked(
|
async def test_source_delete_page_blocks_when_source_is_job_linked(
|
||||||
@@ -219,8 +223,8 @@ class TestSourcesPageRendering:
|
|||||||
response = client.get(f"/ui/sources/{source_id}/delete")
|
response = client.get(f"/ui/sources/{source_id}/delete")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Delete Source Confirmation".upper() in response.text.upper()
|
assert "DELETE SOURCE RECORD" in response.text.upper()
|
||||||
assert "Cannot delete source linked to active jobs." in response.text
|
assert "Delete is only available for unlinked sources." in response.text
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_source_delete_page_allows_unlinked_source(self, app_client):
|
async def test_source_delete_page_allows_unlinked_source(self, app_client):
|
||||||
@@ -244,6 +248,6 @@ class TestSourcesPageRendering:
|
|||||||
response = client.get(f"/ui/sources/{source_id}/delete")
|
response = client.get(f"/ui/sources/{source_id}/delete")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Delete Source Confirmation".upper() in response.text.upper()
|
assert "DELETE SOURCE RECORD" in response.text.upper()
|
||||||
assert f"Are you sure you want to delete source {source_id}?" in response.text
|
assert "Delete source permanently" in response.text
|
||||||
assert "Cannot delete source linked to active jobs." not in response.text
|
assert "Delete is only available for unlinked sources." not in response.text
|
||||||
|
|||||||
Reference in New Issue
Block a user