diff --git a/src/transcription/services/maintenance.py b/src/transcription/services/maintenance.py index a7219d6..1b98c0d 100644 --- a/src/transcription/services/maintenance.py +++ b/src/transcription/services/maintenance.py @@ -10,6 +10,8 @@ from pathlib import Path from uuid import UUID from sqlalchemy import update +from sqlalchemy.exc import SQLAlchemyError +from sqlmodel import SQLModel from sqlmodel import col from sqlmodel import func from sqlmodel import select @@ -48,6 +50,10 @@ class MaintenanceError(AppError): class MaintenanceService(ServiceBase): """Persist and execute background maintenance runs.""" + def __init__(self, session_factory=None, settings=None): + super().__init__(session_factory=session_factory, settings=settings) + self._maintenance_table_ready = False + async def list_runs( self, *, @@ -55,6 +61,7 @@ class MaintenanceService(ServiceBase): session: AsyncSession | None = None, ) -> list[MaintenanceRun]: async with self._session_scope(session) as _session: + await self._ensure_runs_table(session=_session) query = ( select(MaintenanceRun) .order_by(col(MaintenanceRun.created_at).desc(), col(MaintenanceRun.id).desc()) @@ -75,12 +82,14 @@ class MaintenanceService(ServiceBase): triggered_by=triggered_by, ) async with self._session_scope(session) as _session: + await self._ensure_runs_table(session=_session) _session.add(run) await self._finalize(session=_session, caller_session=session, refresh=(run,)) return run async def claim_next_queued_run(self, *, session: AsyncSession | None = None) -> MaintenanceRun | None: async with self._session_scope(session) as _session: + await self._ensure_runs_table(session=_session) now = _utc_now_naive() queued_run_id = ( select(col(MaintenanceRun.id)) @@ -110,6 +119,29 @@ class MaintenanceService(ServiceBase): await self._finalize(session=_session, caller_session=session, refresh=(run,)) return run + async def _ensure_runs_table(self, *, session: AsyncSession) -> None: + if self._maintenance_table_ready: + return + table = SQLModel.metadata.tables.get("maintenance_run") + if table is None: + raise MaintenanceError( + "Maintenance storage is unavailable.", + category=ErrorCategory.INTERNAL_UNEXPECTED, + suggestion="Check database schema registration, then retry.", + detail="maintenance_run table metadata is not registered.", + ) + try: + connection = await session.connection() + await connection.run_sync(lambda sync_connection: table.create(sync_connection, checkfirst=True)) + except SQLAlchemyError as exc: + raise MaintenanceError( + "Maintenance storage is unavailable.", + category=ErrorCategory.INFRA_PERSISTENT, + suggestion="Check database availability and schema permissions, then retry.", + detail=f"Failed to ensure maintenance_run table: {type(exc).__name__}: {exc}", + ) from exc + self._maintenance_table_ready = True + async def process_next_queued_run(self, *, session: AsyncSession | None = None) -> bool: run = await self.claim_next_queued_run(session=session) if run is None: diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index e422517..3525d2c 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -651,7 +651,7 @@ def _render_document_source_navigation(*, document: Document, active_source: Sou def _render_document_detail_revision_zone(*, source: Source | None, sources_service: SourceService) -> None: - with ui.column().classes("col-span-12 lg:col-span-4 gap-4"), archival_card(title="Editable Revision"): + with ui.column().classes("col-span-12 lg:col-span-6 gap-4"), archival_card(title="Editable Revision"): if source is None: render_empty_state("No source pages are linked yet.", italic=True) return @@ -748,7 +748,7 @@ def _detail_document_date(exact: date | None, approximate: str | None) -> str: def _render_bento_relations_zone(document: Document) -> None: - with ui.column().classes("col-span-12 lg:col-span-4 gap-4"): + with ui.column().classes("col-span-12 lg:col-span-2 gap-4"): _render_related_people_card(document) _render_document_processing_card(document) diff --git a/tests/services/test_maintenance_service.py b/tests/services/test_maintenance_service.py index d1c743e..fb69ed0 100644 --- a/tests/services/test_maintenance_service.py +++ b/tests/services/test_maintenance_service.py @@ -5,6 +5,7 @@ from __future__ import annotations from pathlib import Path import pytest +from sqlalchemy import text from transcription.config import Settings from transcription.db.models import MaintenanceJobType @@ -57,3 +58,18 @@ async def test_process_next_queued_run_persists_terminal_result( assert updated.summary == "Synthetic success" assert updated.log_path is not None assert (settings.log_dir / Path(updated.log_path)).is_file() + + +@pytest.mark.asyncio +async def test_list_runs_recreates_missing_table(default_session_factory, default_settings): + service = MaintenanceService(session_factory=default_session_factory, settings=default_settings) + async with default_session_factory() as session: + await session.exec(text("DROP TABLE maintenance_run")) + await session.commit() + + runs = await service.list_runs(limit=10) + assert runs == [] + + created = await service.enqueue_run(job_type=MaintenanceJobType.BACKUP, triggered_by="test") + listed = await service.list_runs(limit=10) + assert any(run.id == created.id for run in listed)