diff --git a/src/transcription/db/models.py b/src/transcription/db/models.py index 527691a..2299060 100644 --- a/src/transcription/db/models.py +++ b/src/transcription/db/models.py @@ -46,6 +46,11 @@ def _loaded_attribute(instance: object, attribute: str) -> Any | None: return state.dict.get(attribute) +def _utc_now_naive() -> datetime: + """Return a UTC timestamp stored as a naive datetime.""" + return datetime.now(UTC).replace(tzinfo=None) + + class JSONBCompat(TypeDecorator): """JSONB for PostgreSQL and JSON for SQLite/testing backends.""" @@ -87,10 +92,10 @@ class DocumentType(SQLModel, table=True): label: str normalized_label: str = Field(index=True, unique=True) is_active: bool = True - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_at: datetime = Field(default_factory=_utc_now_naive) updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, + default_factory=_utc_now_naive, + sa_column_kwargs={"onupdate": _utc_now_naive}, ) documents: list["Document"] = Relationship( @@ -108,10 +113,10 @@ class PersonRole(SQLModel, table=True): label: str normalized_label: str = Field(index=True, unique=True) is_active: bool = True - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_at: datetime = Field(default_factory=_utc_now_naive) updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, + default_factory=_utc_now_naive, + sa_column_kwargs={"onupdate": _utc_now_naive}, ) document_people: list["DocumentPerson"] = Relationship( @@ -129,10 +134,10 @@ class Tag(SQLModel, table=True): label: str normalized_label: str = Field(index=True, unique=True) is_active: bool = True - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_at: datetime = Field(default_factory=_utc_now_naive) updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, + default_factory=_utc_now_naive, + sa_column_kwargs={"onupdate": _utc_now_naive}, ) document_tags: list["DocumentTag"] = Relationship( @@ -156,10 +161,10 @@ class Document(SQLModel, table=True): location_created: str | None = None notes: str | None = None archive_identifier: str | None = None - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_at: datetime = Field(default_factory=_utc_now_naive) updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, + default_factory=_utc_now_naive, + sa_column_kwargs={"onupdate": _utc_now_naive}, ) jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "raise"}) @@ -194,10 +199,10 @@ class Person(SQLModel, table=True): default=None, sa_column=Column("metadata", JSONBCompat(), nullable=True), ) - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_at: datetime = Field(default_factory=_utc_now_naive) updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, + default_factory=_utc_now_naive, + sa_column_kwargs={"onupdate": _utc_now_naive}, ) document_people: list["DocumentPerson"] = Relationship( @@ -228,10 +233,10 @@ class Photo(SQLModel, table=True): path: str description: str | None = None is_primary: bool = False - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_at: datetime = Field(default_factory=_utc_now_naive) updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, + default_factory=_utc_now_naive, + sa_column_kwargs={"onupdate": _utc_now_naive}, ) person: Optional["Person"] = Relationship( @@ -249,10 +254,10 @@ class DocumentPerson(SQLModel, table=True): document_id: UUID = Field(foreign_key="document.id", index=True) person_id: UUID = Field(foreign_key="person.id", index=True) role_id: UUID = Field(foreign_key="person_role.id", index=True) - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_at: datetime = Field(default_factory=_utc_now_naive) updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, + default_factory=_utc_now_naive, + sa_column_kwargs={"onupdate": _utc_now_naive}, ) __table_args__ = (UniqueConstraint("document_id", "person_id", name="uq_document_person"),) @@ -276,10 +281,10 @@ class DocumentTag(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) document_id: UUID = Field(foreign_key="document.id", index=True) tag_id: UUID = Field(foreign_key="tag.id", index=True) - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_at: datetime = Field(default_factory=_utc_now_naive) updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, + default_factory=_utc_now_naive, + sa_column_kwargs={"onupdate": _utc_now_naive}, ) __table_args__ = (UniqueConstraint("document_id", "tag_id", name="uq_document_tag"),) @@ -302,10 +307,10 @@ class PersonTag(SQLModel, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True) person_id: UUID = Field(foreign_key="person.id", index=True) tag_id: UUID = Field(foreign_key="tag.id", index=True) - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_at: datetime = Field(default_factory=_utc_now_naive) updated_at: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, + default_factory=_utc_now_naive, + sa_column_kwargs={"onupdate": _utc_now_naive}, ) __table_args__ = (UniqueConstraint("person_id", "tag_id", name="uq_person_tag"),) @@ -351,10 +356,10 @@ class Job(SQLModel, table=True): default=JobPurpose.TRANSCRIPTION.value, ), ) - date_created: datetime = Field(default_factory=lambda: datetime.now(UTC)) + date_created: datetime = Field(default_factory=_utc_now_naive) date_updated: datetime = Field( - default_factory=lambda: datetime.now(UTC), - sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)}, + default_factory=_utc_now_naive, + sa_column_kwargs={"onupdate": _utc_now_naive}, ) provider: str | None = None model: str | None = None @@ -413,7 +418,7 @@ class Source(SQLModel, table=True): ), ) revised_text: str | None = None - date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC)) + date_uploaded: datetime = Field(default_factory=_utc_now_naive) date_revised: datetime | None = None document: Optional["Document"] = Relationship( @@ -551,7 +556,7 @@ class ExecutionAttempt(SQLModel, table=True): started_at: datetime finished_at: datetime duration_ms: int = Field(ge=0) - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + created_at: datetime = Field(default_factory=_utc_now_naive) job_source: Optional["JobSource"] = Relationship( back_populates="execution_attempts", sa_relationship_kwargs={"lazy": "raise"} diff --git a/src/transcription/services/sources.py b/src/transcription/services/sources.py index 3b8be54..f79507f 100644 --- a/src/transcription/services/sources.py +++ b/src/transcription/services/sources.py @@ -66,6 +66,18 @@ JSON_OBJECT_ADAPTER = TypeAdapter(dict[str, JsonValue]) MAX_EXECUTION_ATTEMPT_NUMBER_RETRIES = 3 +def _utc_now_naive() -> datetime: + """Return current UTC as naive datetime for DB timestamp columns.""" + return datetime.now(UTC).replace(tzinfo=None) + + +def _as_naive_utc(value: datetime) -> datetime: + """Normalize aware or naive datetimes to naive UTC.""" + if value.tzinfo is None: + return value + return value.astimezone(UTC).replace(tzinfo=None) + + class PromptExecution(BaseModel): """Resolved prompt inputs captured for one page execution.""" @@ -533,8 +545,8 @@ class SourceService(ServiceBase): else: job_source.status = outcome - finish_time = finished_at or datetime.now(UTC) - start_time = started_at or finish_time + finish_time = _as_naive_utc(finished_at) if finished_at is not None else _utc_now_naive() + start_time = _as_naive_utc(started_at) if started_at is not None else finish_time transport = transport_evidence or TransportEvidence(response_received=False) manifest_payload = request_manifest.model_dump(mode="json") if request_manifest is not None else None software_payload = ( @@ -641,7 +653,7 @@ class SourceService(ServiceBase): source = await self._read_source(session=_session, source_id=source_id) source.revised_text = text - source.date_revised = datetime.now(UTC) + source.date_revised = _utc_now_naive() await self._finalize(session=_session, caller_session=session, refresh=(source,)) return source diff --git a/src/transcription/services/workflows.py b/src/transcription/services/workflows.py index 1e68f40..da98c04 100644 --- a/src/transcription/services/workflows.py +++ b/src/transcription/services/workflows.py @@ -48,6 +48,11 @@ _RETRIABLE_FAILED_JOB_ERROR_CATEGORIES = { } +def _utc_now_naive() -> datetime: + """Return current UTC as naive datetime for DB timestamp columns.""" + return datetime.now(UTC).replace(tzinfo=None) + + async def create_document_with_people( *, document: Document, @@ -272,7 +277,7 @@ async def process_queued_job( # noqa: PLR0915 externally_stopped = True break - started_at = datetime.now(UTC) + started_at = _utc_now_naive() # Fallback start for failures raised before the provider call; reset to the # true call boundary immediately before the wait_for below. monotonic_started_at = asyncio.get_running_loop().time() @@ -327,7 +332,7 @@ async def process_queued_job( # noqa: PLR0915 ) _validate_transcription_quality(result=result, settings=runtime_settings) - finished_at = datetime.now(UTC) + finished_at = _utc_now_naive() page_outcome = _SuccessfulPage( source=source, result=result, @@ -347,7 +352,7 @@ async def process_queued_job( # noqa: PLR0915 suggestion="Retry the job. If this repeats, verify provider latency and request payload size.", retriable=True, ) - finished_at = datetime.now(UTC) + finished_at = _utc_now_naive() page_outcome = _FailedPage( source=source, error=error, @@ -382,7 +387,7 @@ async def process_queued_job( # noqa: PLR0915 case _: error = classify_unexpected_error(exc, operation="worker.process_job") - finished_at = datetime.now(UTC) + finished_at = _utc_now_naive() provider_error = _find_provider_error(exc) page_outcome = _FailedPage( source=source, diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index edc6d52..f4f64bc 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -541,11 +541,21 @@ def _render_document_form_fields( def _render_bento_viewer_zone(document: Document, *, base_url: str, settings: Settings) -> None: with ui.column().classes("col-span-12 lg:col-span-4"): - source_path = document.sources[0].file_path if document.sources else None + source_path = _first_source_path(document) source_url = resolve_media_url(source_path, upload_dir=settings.upload_dir, base_url=base_url) dark_room_viewer(source_url, count_label=f"{len(document.sources)} Source(s) Linked") +def _first_source_path(document: Document) -> str | None: + if not document.sources: + return None + first_source = min( + document.sources, + key=lambda source: (source.page_number, source.upload_name.casefold(), source.filename.casefold()), + ) + return first_source.file_path + + def _render_bento_metadata_zone(document: Document) -> None: author_names = _author_names(document) diff --git a/tests/test_models.py b/tests/test_models.py index f8a4297..07a4491 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -115,6 +115,8 @@ class TestDocumentModel: assert isinstance(document.id, UUID) assert document.created_at is not None assert document.updated_at is not None + assert document.created_at.tzinfo is None + assert document.updated_at.tzinfo is None def test_can_reference_document_type_registry(self, session): document_type = _persist_document_type(session, label="Record") @@ -140,6 +142,8 @@ class TestJobModel: assert job.retry_count == 0 assert job.date_created is not None assert job.date_updated is not None + assert job.date_created.tzinfo is None + assert job.date_updated.tzinfo is None def test_transitions_to_transcribed(self, session): document = _persist_document(session) @@ -183,6 +187,7 @@ class TestSourceModel: assert fetched.document_id == document.id assert fetched.page_number == 1 assert fetched.date_uploaded is not None + assert fetched.date_uploaded.tzinfo is None def test_revised_text_is_supported(self, session): document = _persist_document(session) diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index 0486a50..98bc85c 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -17,6 +17,7 @@ from transcription.db.models import Person from transcription.db.models import PersonRole from transcription.db.models import Source from transcription.db.models import Tag +from transcription.ui.pages.documents_page import _first_source_path from transcription.ui.pages.documents_page import _resolve_selected_tag_labels # --- Helper Fixtures --- @@ -107,6 +108,40 @@ class TestDocumentsPageRendering: re.DOTALL, ) + def test_first_source_path_prefers_lowest_page_number(self): + document = Document(name="Ordering Test") + document.sources = [ + Source( + document_id=document.id, + page_number=3, + upload_name="c.jpg", + filename="c.jpg", + file_path="documents/c.jpg", + file_hash="c" * 64, + file_size_bytes=1, + ), + Source( + document_id=document.id, + page_number=1, + upload_name="a.jpg", + filename="a.jpg", + file_path="documents/a.jpg", + file_hash="a" * 64, + file_size_bytes=1, + ), + Source( + document_id=document.id, + page_number=2, + upload_name="b.jpg", + filename="b.jpg", + file_path="documents/b.jpg", + file_hash="b" * 64, + file_size_bytes=1, + ), + ] + + assert _first_source_path(document) == "documents/a.jpg" + def test_document_create_page_renders_form(self, app_client): _, client = app_client