diff --git a/backend/_batch_convert_defs.py b/backend/_batch_convert_defs.py index ab255a19..65736cd1 100644 --- a/backend/_batch_convert_defs.py +++ b/backend/_batch_convert_defs.py @@ -3,15 +3,15 @@ Batch convert legacy [DEF:...] / [/DEF:...] annotations to #region/#endregion format. Handles Python, JS, and Svelte files. Removes [SECTION:...] / [/SECTION] markers. """ -import re from pathlib import Path +import re ROOT = Path(__file__).resolve().parent.parent def process_file(filepath: Path) -> int: """Process a single file. Returns number of DEF blocks converted.""" - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, encoding='utf-8') as f: text = f.read() lines = text.split('\n') diff --git a/backend/_convert_defs.py b/backend/_convert_defs.py index 39695f5f..0b5d6459 100644 --- a/backend/_convert_defs.py +++ b/backend/_convert_defs.py @@ -7,8 +7,8 @@ NEVER touches code, strings, imports, or non-comment lines. Preserves original line endings (CRLF vs LF). """ -import re from pathlib import Path +import re SRC_DIR = Path(__file__).resolve().parent / "src" diff --git a/backend/alembic/env.py b/backend/alembic/env.py index e79cf439..8395e13b 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,10 +1,9 @@ -import os -import sys from logging.config import fileConfig +import os from pathlib import Path +import sys -from sqlalchemy import engine_from_config -from sqlalchemy import pool +from sqlalchemy import engine_from_config, pool from alembic import context @@ -26,8 +25,6 @@ if config.config_file_name is not None: # add your model's MetaData object here # for 'autogenerate' support -from src.models.mapping import Base - # Import ALL model modules so their tables are registered in Base.metadata from src.models import ( # noqa: F401 api_key, @@ -46,8 +43,10 @@ from src.models import ( # noqa: F401 task, translate, ) + # Import config models with explicit alias to avoid name collision with alembic config import src.models.config as models_config # noqa: F401 +from src.models.mapping import Base target_metadata = Base.metadata diff --git a/backend/alembic/versions/543d43d752b8_migrate_old_dictionary_entries_and_.py b/backend/alembic/versions/543d43d752b8_migrate_old_dictionary_entries_and_.py index 112ceb41..4063104e 100644 --- a/backend/alembic/versions/543d43d752b8_migrate_old_dictionary_entries_and_.py +++ b/backend/alembic/versions/543d43d752b8_migrate_old_dictionary_entries_and_.py @@ -5,17 +5,18 @@ Revises: c4a3a2f74bfe Create Date: 2026-05-14 18:00:00.000000 """ -from typing import Sequence, Union +from collections.abc import Sequence +from datetime import UTC + +from sqlalchemy.sql import text from alembic import op -import sqlalchemy as sa -from sqlalchemy.sql import text # revision identifiers, used by Alembic. revision: str = '543d43d752b8' -down_revision: Union[str, Sequence[str], None] = 'c4a3a2f74bfe' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = 'c4a3a2f74bfe' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: @@ -82,8 +83,8 @@ def upgrade() -> None: """) ).fetchall() + from datetime import datetime import uuid - from datetime import datetime, timezone for row in missing: record_id = row[0] @@ -129,7 +130,7 @@ def upgrade() -> None: # Determine final value (prefer user_edit over llm_translation) final_value = resolved_edit if resolved_edit else resolved_value - now = datetime.now(timezone.utc) + now = datetime.now(UTC) lang_id = str(uuid.uuid4()) connection.execute( diff --git a/backend/alembic/versions/86c7b1d6a710_add_is_admin_column_to_roles_table.py b/backend/alembic/versions/86c7b1d6a710_add_is_admin_column_to_roles_table.py index e3257f59..4415a2c2 100644 --- a/backend/alembic/versions/86c7b1d6a710_add_is_admin_column_to_roles_table.py +++ b/backend/alembic/versions/86c7b1d6a710_add_is_admin_column_to_roles_table.py @@ -5,17 +5,17 @@ Revises: b0c1d2e3f4a5 Create Date: 2026-05-26 15:27:06.159151 """ -from typing import Sequence, Union +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = '86c7b1d6a710' -down_revision: Union[str, Sequence[str], None] = 'b0c1d2e3f4a5' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = 'b0c1d2e3f4a5' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/backend/alembic/versions/8dd0a93af539_drop_deprecated_source_language_column_.py b/backend/alembic/versions/8dd0a93af539_drop_deprecated_source_language_column_.py index dae44ad7..c9a9e10e 100644 --- a/backend/alembic/versions/8dd0a93af539_drop_deprecated_source_language_column_.py +++ b/backend/alembic/versions/8dd0a93af539_drop_deprecated_source_language_column_.py @@ -5,17 +5,17 @@ Revises: 543d43d752b8 Create Date: 2026-05-14 23:43:32.216941 """ -from typing import Sequence, Union +from collections.abc import Sequence + +import sqlalchemy as sa from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. revision: str = '8dd0a93af539' -down_revision: Union[str, Sequence[str], None] = '543d43d752b8' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = '543d43d752b8' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/backend/alembic/versions/9f8e7d6c5b4a_add_is_multimodal_to_llm_providers.py b/backend/alembic/versions/9f8e7d6c5b4a_add_is_multimodal_to_llm_providers.py index e4c8afdf..17ffcc4a 100644 --- a/backend/alembic/versions/9f8e7d6c5b4a_add_is_multimodal_to_llm_providers.py +++ b/backend/alembic/versions/9f8e7d6c5b4a_add_is_multimodal_to_llm_providers.py @@ -6,17 +6,17 @@ Create Date: 2026-05-20 14:30:00.000000 """ -from typing import Sequence, Union +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = "9f8e7d6c5b4a" -down_revision: Union[str, Sequence[str], None] = "b1c2d3e4f5a6" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = "b1c2d3e4f5a6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def _is_multimodal_heuristic(model_name: str) -> bool: diff --git a/backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py b/backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py index cfdc55d5..ef84b66e 100644 --- a/backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py +++ b/backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py @@ -5,16 +5,15 @@ Revises: c4a3a2f74bfe Create Date: 2026-05-21 18:55:00.000000 """ -from typing import Sequence, Union +from collections.abc import Sequence from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = 'a5b6c7d8e9f0' -down_revision: Union[str, Sequence[str], None] = 'c4a3a2f74bfe' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = 'c4a3a2f74bfe' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/backend/alembic/versions/a7b1c2d3e4f5_add_provider_id_to_validation_policies.py b/backend/alembic/versions/a7b1c2d3e4f5_add_provider_id_to_validation_policies.py index dd67f8ee..109f693f 100644 --- a/backend/alembic/versions/a7b1c2d3e4f5_add_provider_id_to_validation_policies.py +++ b/backend/alembic/versions/a7b1c2d3e4f5_add_provider_id_to_validation_policies.py @@ -6,17 +6,17 @@ Create Date: 2026-05-21 10:00:00.000000 """ -from typing import Sequence, Union +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = "a7b1c2d3e4f5" -down_revision: Union[str, Sequence[str], None] = "9f8e7d6c5b4a" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = "9f8e7d6c5b4a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py b/backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py index 4f478b2d..9668329c 100644 --- a/backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py +++ b/backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py @@ -5,16 +5,15 @@ Revises: f0e9d8c7b6a5 Create Date: 2026-05-21 19:05:00.000000 """ -from typing import Sequence, Union +from collections.abc import Sequence from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = 'b0c1d2e3f4a5' -down_revision: Union[str, Sequence[str], None] = 'f0e9d8c7b6a5' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = 'f0e9d8c7b6a5' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None # Each entry: (table, constraint_name, column, ref_table, ondelete_rule) FK_FIXES = [ diff --git a/backend/alembic/versions/c4a3a2f74bfe_add_missing_columns_needs_review_.py b/backend/alembic/versions/c4a3a2f74bfe_add_missing_columns_needs_review_.py index 975fed0e..0c3efb08 100644 --- a/backend/alembic/versions/c4a3a2f74bfe_add_missing_columns_needs_review_.py +++ b/backend/alembic/versions/c4a3a2f74bfe_add_missing_columns_needs_review_.py @@ -4,14 +4,16 @@ Revision ID: c4a3a2f74bfe Revises: ed310b33f02c Create Date: 2026-05-14 17:30:36.865269 """ -from typing import Sequence, Union -from alembic import op +from collections.abc import Sequence + import sqlalchemy as sa +from alembic import op + revision: str = 'c4a3a2f74bfe' -down_revision: Union[str, Sequence[str], None] = 'ed310b33f02c' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = 'ed310b33f02c' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/backend/alembic/versions/e5f4d3c2b1a_add_policy_id_to_validation_results.py b/backend/alembic/versions/e5f4d3c2b1a_add_policy_id_to_validation_results.py index 13018359..3b982e34 100644 --- a/backend/alembic/versions/e5f4d3c2b1a_add_policy_id_to_validation_results.py +++ b/backend/alembic/versions/e5f4d3c2b1a_add_policy_id_to_validation_results.py @@ -6,17 +6,17 @@ Create Date: 2026-05-21 12:00:00.000000 """ -from typing import Sequence, Union +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = "e5f4d3c2b1a" -down_revision: Union[str, Sequence[str], None] = "a7b1c2d3e4f5" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = "a7b1c2d3e4f5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/backend/alembic/versions/ed310b33f02c_multi_language_translation_tables.py b/backend/alembic/versions/ed310b33f02c_multi_language_translation_tables.py index 022fcc42..a97fc630 100644 --- a/backend/alembic/versions/ed310b33f02c_multi_language_translation_tables.py +++ b/backend/alembic/versions/ed310b33f02c_multi_language_translation_tables.py @@ -5,17 +5,17 @@ Revises: Create Date: 2026-05-14 15:35:03.167031 """ -from typing import Sequence, Union +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = 'ed310b33f02c' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py b/backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py index b9471fb5..25e9130c 100644 --- a/backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py +++ b/backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py @@ -5,16 +5,15 @@ Revises: a5b6c7d8e9f0, e5f4d3c2b1a Create Date: 2026-05-21 19:00:00.000000 """ -from typing import Sequence, Union +from collections.abc import Sequence from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. revision: str = 'f0e9d8c7b6a5' -down_revision: Union[str, Sequence[str], None] = ('a5b6c7d8e9f0', 'e5f4d3c2b1a') -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = ('a5b6c7d8e9f0', 'e5f4d3c2b1a') +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None FK_DEFS = [ ('resource_mappings', 'resource_mappings_environment_id_fkey', 'environment_id'), diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 390a3789..39834648 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -16,4 +16,4 @@ include = ["src*"] [tool.pytest.ini_options] pythonpath = ["."] -importmode = "importlib" +asyncio_mode = "auto" diff --git a/backend/src/api/auth.py b/backend/src/api/auth.py index 0536c5d0..2381beea 100755 --- a/backend/src/api/auth.py +++ b/backend/src/api/auth.py @@ -173,4 +173,4 @@ async def auth_callback_adfs( # #endregion auth_callback_adfs -# #endregion AuthApi \ No newline at end of file +# #endregion AuthApi diff --git a/backend/src/api/routes/__tests__/conftest.py b/backend/src/api/routes/__tests__/conftest.py index 087b4ffe..094a1e4d 100644 --- a/backend/src/api/routes/__tests__/conftest.py +++ b/backend/src/api/routes/__tests__/conftest.py @@ -3,6 +3,7 @@ # Set required env vars before any app imports import os + os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:test_auth") os.environ.setdefault("DEV_MODE", "true") diff --git a/backend/src/api/routes/__tests__/test_assistant_api.py b/backend/src/api/routes/__tests__/test_assistant_api.py index 18a15174..aa0e2161 100644 --- a/backend/src/api/routes/__tests__/test_assistant_api.py +++ b/backend/src/api/routes/__tests__/test_assistant_api.py @@ -6,7 +6,7 @@ os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w=" # @RELATION DEPENDS_ON -> [AssistantApi] # @INVARIANT Every test clears assistant in-memory state before execution. import asyncio -from datetime import datetime +from datetime import UTC, datetime from unittest.mock import MagicMock import uuid @@ -54,8 +54,8 @@ class _FakeTask: self.params = params or {} self.result = result or {} self.user_id = user_id - self.started_at = datetime.utcnow() - self.finished_at = datetime.utcnow() + self.started_at = datetime.now(UTC) + self.finished_at = datetime.now(UTC) # #endregion _FakeTask # @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks(). # #region _FakeTaskManager [TYPE Class] [C:2] @@ -206,9 +206,9 @@ def _dataset_review_session(): recommended_action=RecommendedAction.APPROVE_MAPPING, status=SessionStatus.ACTIVE, current_phase=SessionPhase.MAPPING_REVIEW, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), - last_activity_at=datetime.utcnow(), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + last_activity_at=datetime.now(UTC), ) session.findings = [] session.previews = [] @@ -226,8 +226,8 @@ def _dataset_review_session(): requires_confirmation=False, recovery_status="recovered", notes=None, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), ) ] session.execution_mappings = [ @@ -245,8 +245,8 @@ def _dataset_review_session(): approval_state=ApprovalState.PENDING, approved_by_user_id=None, approved_at=None, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), ) ] session.semantic_fields = [] diff --git a/backend/src/api/routes/__tests__/test_assistant_authz.py b/backend/src/api/routes/__tests__/test_assistant_authz.py index 9ac53e4f..13dbe80d 100644 --- a/backend/src/api/routes/__tests__/test_assistant_authz.py +++ b/backend/src/api/routes/__tests__/test_assistant_authz.py @@ -7,7 +7,7 @@ os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w=" # @RELATION DEPENDS_ON -> AssistantApi # @INVARIANT Security-sensitive flows fail closed for unauthorized actors. import asyncio -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta import os import pytest from types import SimpleNamespace @@ -249,7 +249,7 @@ def test_expired_confirmation_cannot_be_confirmed(): ) ) assistant_module.CONFIRMATIONS[create.confirmation_id].expires_at = ( - datetime.utcnow() - timedelta(minutes=1) + datetime.now(UTC) - timedelta(minutes=1) ) with pytest.raises(HTTPException) as exc: _run_async( diff --git a/backend/src/api/routes/__tests__/test_reports_api.py b/backend/src/api/routes/__tests__/test_reports_api.py index 6cdec5be..832ba302 100644 --- a/backend/src/api/routes/__tests__/test_reports_api.py +++ b/backend/src/api/routes/__tests__/test_reports_api.py @@ -56,7 +56,7 @@ def _make_task( # @RELATION BINDS_TO -> TestReportsApi # @BRIEF Validate reports list endpoint default pagination and contract keys for mixed task statuses. def test_get_reports_default_pagination_contract(): - now = datetime.utcnow() + now = datetime.now(UTC) tasks = [ _make_task( "t-1", @@ -103,7 +103,7 @@ def test_get_reports_default_pagination_contract(): # @RELATION BINDS_TO -> TestReportsApi # @BRIEF Validate reports list endpoint applies task-type/status filters and pagination boundaries. def test_get_reports_filter_and_pagination(): - now = datetime.utcnow() + now = datetime.now(UTC) tasks = [ _make_task( "t-1", @@ -150,7 +150,7 @@ def test_get_reports_filter_and_pagination(): # @RELATION BINDS_TO -> TestReportsApi # @BRIEF Validate reports sorting remains stable when task timestamps mix naive and timezone-aware datetimes. def test_get_reports_handles_mixed_naive_and_aware_datetimes(): - naive_now = datetime.utcnow() + naive_now = datetime.now(UTC) aware_now = datetime.now(UTC) tasks = [ _make_task( @@ -184,7 +184,7 @@ def test_get_reports_handles_mixed_naive_and_aware_datetimes(): # @RELATION BINDS_TO -> TestReportsApi # @BRIEF Validate reports list endpoint rejects unsupported task type filters with HTTP 400. def test_get_reports_invalid_filter_returns_400(): - now = datetime.utcnow() + now = datetime.now(UTC) tasks = [ _make_task( "t-1", diff --git a/backend/src/api/routes/__tests__/test_reports_detail_api.py b/backend/src/api/routes/__tests__/test_reports_detail_api.py index 59396906..09498108 100644 --- a/backend/src/api/routes/__tests__/test_reports_detail_api.py +++ b/backend/src/api/routes/__tests__/test_reports_detail_api.py @@ -3,7 +3,7 @@ # @BRIEF Contract tests for GET /api/reports/{report_id} detail endpoint behavior. # @LAYER Domain # @INVARIANT Detail endpoint tests must keep deterministic assertions for success and not-found contracts. -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from types import SimpleNamespace from fastapi.testclient import TestClient @@ -35,7 +35,7 @@ def _admin_user(): # @RELATION BINDS_TO -> TestReportsDetailApi # @BRIEF Build deterministic Task payload for reports detail endpoint contract assertions. def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None): - now = datetime.utcnow() + now = datetime.now(UTC) return Task( id=task_id, plugin_id=plugin_id, diff --git a/backend/src/api/routes/__tests__/test_reports_openapi_conformance.py b/backend/src/api/routes/__tests__/test_reports_openapi_conformance.py index 3db1e3ed..4ba1257d 100644 --- a/backend/src/api/routes/__tests__/test_reports_openapi_conformance.py +++ b/backend/src/api/routes/__tests__/test_reports_openapi_conformance.py @@ -3,7 +3,7 @@ # @BRIEF Validate implemented reports payload shape against OpenAPI-required top-level contract fields. # @LAYER Domain # @INVARIANT List and detail payloads include required contract keys. -from datetime import datetime +from datetime import UTC, datetime from types import SimpleNamespace from fastapi.testclient import TestClient @@ -34,7 +34,7 @@ def _admin_user(): # @RELATION BINDS_TO -> TestReportsOpenapiConformance # @BRIEF Construct deterministic task fixture consumed by reports list/detail payload assertions. def _task(task_id: str, plugin_id: str, status: TaskStatus): - now = datetime.utcnow() + now = datetime.now(UTC) return Task( id=task_id, plugin_id=plugin_id, diff --git a/backend/src/api/routes/assistant/_admin_routes.py b/backend/src/api/routes/assistant/_admin_routes.py index 5a2dafa7..9139ce8f 100644 --- a/backend/src/api/routes/assistant/_admin_routes.py +++ b/backend/src/api/routes/assistant/_admin_routes.py @@ -8,7 +8,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from typing import Any from fastapi import Depends, HTTPException, Query @@ -69,7 +69,7 @@ async def list_conversations( conv_id = row.conversation_id if not conv_id: continue - created_at = row.created_at or datetime.utcnow() + created_at = row.created_at or datetime.now(UTC) if conv_id not in summary: summary[conv_id] = { "conversation_id": conv_id, diff --git a/backend/src/api/routes/assistant/_dataset_review_dispatch.py b/backend/src/api/routes/assistant/_dataset_review_dispatch.py index 8f3833db..0c5120e6 100644 --- a/backend/src/api/routes/assistant/_dataset_review_dispatch.py +++ b/backend/src/api/routes/assistant/_dataset_review_dispatch.py @@ -8,7 +8,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from typing import Any from fastapi import HTTPException, status @@ -138,13 +138,13 @@ async def _dispatch_dataset_review_intent( continue mapping.approval_state = ApprovalState.APPROVED mapping.approved_by_user_id = current_user.id - mapping.approved_at = datetime.utcnow() + mapping.approved_at = datetime.now(UTC) updated_count += 1 if updated_count == 0: raise HTTPException( status_code=409, detail="No matching mappings available to approve" ) - session.last_activity_at = datetime.utcnow() + session.last_activity_at = datetime.now(UTC) if session.readiness_state == ReadinessState.MAPPING_REVIEW_NEEDED: session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW repository.bump_session_version(session) @@ -206,7 +206,7 @@ async def _dispatch_dataset_review_intent( raise except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - session.last_activity_at = datetime.utcnow() + session.last_activity_at = datetime.now(UTC) repository.bump_session_version(session) repository.db.commit() repository.db.refresh(session) diff --git a/backend/src/api/routes/assistant/_history.py b/backend/src/api/routes/assistant/_history.py index 89b594c3..06350358 100644 --- a/backend/src/api/routes/assistant/_history.py +++ b/backend/src/api/routes/assistant/_history.py @@ -6,7 +6,7 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Any import uuid @@ -59,7 +59,7 @@ def _append_history( "state": state, "task_id": task_id, "confirmation_id": confirmation_id, - "created_at": datetime.utcnow(), + "created_at": datetime.now(UTC), } ) @@ -120,7 +120,7 @@ def _audit(user_id: str, payload: dict[str, Any]): if user_id not in ASSISTANT_AUDIT: ASSISTANT_AUDIT[user_id] = [] ASSISTANT_AUDIT[user_id].append( - {**payload, "created_at": datetime.utcnow().isoformat()} + {**payload, "created_at": datetime.now(UTC).isoformat()} ) logger.info(f"[assistant.audit] {payload}") @@ -197,7 +197,7 @@ def _update_confirmation_state(db: Session, confirmation_id: str, state: str): return row.state = state if state in {"consumed", "expired", "cancelled"}: - row.consumed_at = datetime.utcnow() + row.consumed_at = datetime.now(UTC) db.commit() except Exception as exc: db.rollback() @@ -301,7 +301,7 @@ def _resolve_or_create_conversation( # @PRE db session is available and user_id references current actor scope. # @POST Messages older than ASSISTANT_MESSAGE_TTL_DAYS are removed from persistence and memory mirrors. def _cleanup_history_ttl(db: Session, user_id: str): - cutoff = datetime.utcnow() - timedelta(days=ASSISTANT_MESSAGE_TTL_DAYS) + cutoff = datetime.now(UTC) - timedelta(days=ASSISTANT_MESSAGE_TTL_DAYS) try: query = db.query(AssistantMessageRecord).filter( AssistantMessageRecord.user_id == user_id, @@ -344,7 +344,7 @@ def _cleanup_history_ttl(db: Session, user_id: str): def _is_conversation_archived(updated_at: datetime | None) -> bool: if not updated_at: return False - cutoff = datetime.utcnow() - timedelta(days=ASSISTANT_ARCHIVE_AFTER_DAYS) + cutoff = datetime.now(UTC) - timedelta(days=ASSISTANT_ARCHIVE_AFTER_DAYS) return updated_at < cutoff diff --git a/backend/src/api/routes/assistant/_routes.py b/backend/src/api/routes/assistant/_routes.py index b0b068bc..05a90e37 100644 --- a/backend/src/api/routes/assistant/_routes.py +++ b/backend/src/api/routes/assistant/_routes.py @@ -12,7 +12,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime import uuid from fastapi import APIRouter, Depends, HTTPException, status @@ -106,13 +106,13 @@ async def send_message(request: AssistantMessageRequest, current_user: User=Depe _audit(user_id, audit_payload) _persist_audit(db, user_id, audit_payload, conversation_id) logger.reflect('Belief protocol postcondition checkpoint for send_message') - return AssistantMessageResponse(conversation_id=conversation_id, response_id=str(uuid.uuid4()), state='needs_clarification', text=text, intent=intent, actions=[AssistantAction(type='rephrase', label='Rephrase command')], created_at=datetime.utcnow()) + return AssistantMessageResponse(conversation_id=conversation_id, response_id=str(uuid.uuid4()), state='needs_clarification', text=text, intent=intent, actions=[AssistantAction(type='rephrase', label='Rephrase command')], created_at=datetime.now(UTC)) try: _authorize_intent(intent, current_user) operation = intent.get('operation') if operation not in _SAFE_OPS: confirmation_id = str(uuid.uuid4()) - confirm = ConfirmationRecord(id=confirmation_id, user_id=user_id, conversation_id=conversation_id, intent=intent, dispatch={'intent': intent}, expires_at=datetime.utcnow() + __import__('datetime').timedelta(minutes=5), created_at=datetime.utcnow()) + confirm = ConfirmationRecord(id=confirmation_id, user_id=user_id, conversation_id=conversation_id, intent=intent, dispatch={'intent': intent}, expires_at=datetime.now(UTC) + __import__('datetime').timedelta(minutes=5), created_at=datetime.now(UTC)) CONFIRMATIONS[confirmation_id] = confirm _persist_confirmation(db, confirm) text = await _async_confirmation_summary(intent, config_manager, db) @@ -122,7 +122,7 @@ async def send_message(request: AssistantMessageRequest, current_user: User=Depe _audit(user_id, audit_payload) _persist_audit(db, user_id, audit_payload, conversation_id) logger.reflect('Belief protocol postcondition checkpoint for send_message') - return AssistantMessageResponse(conversation_id=conversation_id, response_id=str(uuid.uuid4()), state='needs_confirmation', text=text, intent=intent, confirmation_id=confirmation_id, actions=[AssistantAction(type='confirm', label='✅ Подтвердить', target=confirmation_id), AssistantAction(type='cancel', label='❌ Отменить', target=confirmation_id)], created_at=datetime.utcnow()) + return AssistantMessageResponse(conversation_id=conversation_id, response_id=str(uuid.uuid4()), state='needs_confirmation', text=text, intent=intent, confirmation_id=confirmation_id, actions=[AssistantAction(type='confirm', label='✅ Подтвердить', target=confirmation_id), AssistantAction(type='cancel', label='❌ Отменить', target=confirmation_id)], created_at=datetime.now(UTC)) text, task_id, actions = await _dispatch_intent(intent, current_user, task_manager, config_manager, db) state = 'started' if task_id else 'success' _append_history(user_id, conversation_id, 'assistant', text, state=state, task_id=task_id) @@ -131,7 +131,7 @@ async def send_message(request: AssistantMessageRequest, current_user: User=Depe _audit(user_id, audit_payload) _persist_audit(db, user_id, audit_payload, conversation_id) logger.reflect('Belief protocol postcondition checkpoint for send_message') - return AssistantMessageResponse(conversation_id=conversation_id, response_id=str(uuid.uuid4()), state=state, text=text, intent=intent, task_id=task_id, actions=actions, created_at=datetime.utcnow()) + return AssistantMessageResponse(conversation_id=conversation_id, response_id=str(uuid.uuid4()), state=state, text=text, intent=intent, task_id=task_id, actions=actions, created_at=datetime.now(UTC)) except HTTPException as exc: detail_text = str(exc.detail) is_clarification_error = exc.status_code in (400, 422) and (detail_text.lower().startswith('missing') or 'укажите' in detail_text.lower() or 'выберите' in detail_text.lower()) @@ -148,7 +148,7 @@ async def send_message(request: AssistantMessageRequest, current_user: User=Depe _audit(user_id, audit_payload) _persist_audit(db, user_id, audit_payload, conversation_id) logger.reflect('Belief protocol postcondition checkpoint for send_message') - return AssistantMessageResponse(conversation_id=conversation_id, response_id=str(uuid.uuid4()), state=state, text=text, intent=intent, actions=[AssistantAction(type='rephrase', label='Rephrase command')] if state == 'needs_clarification' else [], created_at=datetime.utcnow()) + return AssistantMessageResponse(conversation_id=conversation_id, response_id=str(uuid.uuid4()), state=state, text=text, intent=intent, actions=[AssistantAction(type='rephrase', label='Rephrase command')] if state == 'needs_clarification' else [], created_at=datetime.now(UTC)) # #endregion send_message @@ -187,7 +187,7 @@ async def confirm_operation( status_code=400, detail=f"Confirmation already {record.state}" ) - if datetime.utcnow() > record.expires_at: + if datetime.now(UTC) > record.expires_at: record.state = "expired" _update_confirmation_state(db, confirmation_id, "expired") raise HTTPException(status_code=400, detail="Confirmation expired") @@ -234,10 +234,11 @@ async def confirm_operation( intent=intent, task_id=task_id, actions=actions, - created_at=datetime.utcnow(), + created_at=datetime.now(UTC), ) + # #endregion confirm_operation @@ -309,10 +310,11 @@ async def cancel_operation( intent=record.intent, confirmation_id=confirmation_id, actions=[], - created_at=datetime.utcnow(), + created_at=datetime.now(UTC), ) + # #endregion cancel_operation diff --git a/backend/src/api/routes/dashboards/_schemas.py b/backend/src/api/routes/dashboards/_schemas.py index 87ef07fd..37f6e44e 100644 --- a/backend/src/api/routes/dashboards/_schemas.py +++ b/backend/src/api/routes/dashboards/_schemas.py @@ -5,7 +5,7 @@ from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field # #region GitStatus [C:1] [TYPE DataClass] @@ -96,6 +96,7 @@ class DashboardChartItem(BaseModel): # #region DashboardDatasetItem [C:1] [TYPE DataClass] # @BRIEF DTO for a dataset associated with a dashboard. class DashboardDatasetItem(BaseModel): + model_config = ConfigDict(protected_namespaces=()) id: int table_name: str schema: str | None = None diff --git a/backend/src/api/routes/dataset_review_pkg/_dependencies.py b/backend/src/api/routes/dataset_review_pkg/_dependencies.py index 69e8560e..536aeed9 100644 --- a/backend/src/api/routes/dataset_review_pkg/_dependencies.py +++ b/backend/src/api/routes/dataset_review_pkg/_dependencies.py @@ -4,7 +4,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from typing import Any from fastapi import Depends, Header, HTTPException, status @@ -524,7 +524,7 @@ def _serialize_clarification_question_payload(payload): "topic_ref": payload.topic_ref, "question_text": payload.question_text, "why_it_matters": payload.why_it_matters, "current_guess": payload.current_guess, "priority": payload.priority, "state": payload.state, "options": payload.options, - "answer": None, "created_at": datetime.utcnow(), "updated_at": datetime.utcnow(), + "answer": None, "created_at": datetime.now(UTC), "updated_at": datetime.now(UTC), }) diff --git a/backend/src/api/routes/dataset_review_pkg/_routes.py b/backend/src/api/routes/dataset_review_pkg/_routes.py index 4395c406..a5bae059 100644 --- a/backend/src/api/routes/dataset_review_pkg/_routes.py +++ b/backend/src/api/routes/dataset_review_pkg/_routes.py @@ -4,7 +4,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from typing import Any, Union, cast from fastapi import APIRouter, Depends, HTTPException, Query, Response, status @@ -746,8 +746,8 @@ async def update_execution_mapping( mapping.transformation_note = request.transformation_note mapping.approval_state = ApprovalState.APPROVED mapping.approved_by_user_id = current_user.id - mapping.approved_at = datetime.utcnow() - session.last_activity_at = datetime.utcnow() + mapping.approved_at = datetime.now(UTC) + session.last_activity_at = datetime.now(UTC) session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW if session.readiness_state in { ReadinessState.MAPPING_REVIEW_NEEDED, @@ -801,10 +801,10 @@ async def approve_execution_mapping( mapping = _get_owned_mapping_or_404(session, mapping_id) mapping.approval_state = ApprovalState.APPROVED mapping.approved_by_user_id = current_user.id - mapping.approved_at = datetime.utcnow() + mapping.approved_at = datetime.now(UTC) if request.approval_note: mapping.transformation_note = request.approval_note - session.last_activity_at = datetime.utcnow() + session.last_activity_at = datetime.now(UTC) if session.readiness_state == ReadinessState.MAPPING_REVIEW_NEEDED: session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW sr = cast(Any, session) @@ -850,11 +850,11 @@ async def approve_batch_execution_mappings( mapping = _get_owned_mapping_or_404(session, mid) mapping.approval_state = ApprovalState.APPROVED mapping.approved_by_user_id = current_user.id - mapping.approved_at = datetime.utcnow() + mapping.approved_at = datetime.now(UTC) if request.approval_note: mapping.transformation_note = request.approval_note updated.append(mapping) - session.last_activity_at = datetime.utcnow() + session.last_activity_at = datetime.now(UTC) if session.readiness_state == ReadinessState.MAPPING_REVIEW_NEEDED: session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW sr = cast(Any, session) diff --git a/backend/src/api/routes/datasets.py b/backend/src/api/routes/datasets.py index 80628549..d71eb292 100644 --- a/backend/src/api/routes/datasets.py +++ b/backend/src/api/routes/datasets.py @@ -16,7 +16,7 @@ import re from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from ...core.logger import belief_scope, logger from ...core.superset_client import SupersetClient @@ -49,8 +49,7 @@ class DatasetItem(BaseModel): last_task: LastTask | None = None metric_count: int = 0 - class Config: - allow_population_by_field_name = True + model_config = ConfigDict(validate_by_name=True) # #endregion DatasetItem # #region LinkedDashboard [C:1] [TYPE DataClass] @@ -104,8 +103,7 @@ class DatasetDetailResponse(BaseModel): created_on: str | None = None changed_on: str | None = None - class Config: - allow_population_by_field_name = True + model_config = ConfigDict(validate_by_name=True) # #endregion DatasetDetailResponse # #region StatsCounts [C:1] [TYPE DataClass] @@ -498,7 +496,7 @@ def _strip_html_tags(text: str) -> str: return text if re.search(r"<[^>]*>", text): cleaned = re.sub(r"<[^>]*>", "", text) - logger.warning(f"[_strip_html_tags] HTML tags detected and stripped from description") + logger.warning("[_strip_html_tags] HTML tags detected and stripped from description") return cleaned return text # #endregion _strip_html_tags diff --git a/backend/src/api/routes/git/_merge_routes.py b/backend/src/api/routes/git/_merge_routes.py index 0866f322..41eb9e07 100644 --- a/backend/src/api/routes/git/_merge_routes.py +++ b/backend/src/api/routes/git/_merge_routes.py @@ -97,7 +97,7 @@ async def resolve_merge_conflicts( ) resolved_files = _gs.resolve_merge_conflicts( dashboard_id, - [item.dict() for item in resolve_data.resolutions], + [item.model_dump() for item in resolve_data.resolutions], ) return {"status": "success", "resolved_files": resolved_files} except HTTPException: diff --git a/backend/src/api/routes/git_schemas.py b/backend/src/api/routes/git_schemas.py index fcf34c7f..897208ad 100644 --- a/backend/src/api/routes/git_schemas.py +++ b/backend/src/api/routes/git_schemas.py @@ -9,7 +9,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from src.models.git import GitProvider, GitStatus, SyncStatus @@ -52,8 +52,7 @@ class GitServerConfigSchema(GitServerConfigBase): status: GitStatus last_validated: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion GitServerConfigSchema # #region GitRepositorySchema [TYPE Class] @@ -68,8 +67,7 @@ class GitRepositorySchema(BaseModel): current_branch: str sync_status: SyncStatus - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion GitRepositorySchema # #region BranchSchema [TYPE Class] @@ -171,8 +169,7 @@ class DeploymentEnvironmentSchema(BaseModel): superset_url: str is_active: bool - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion DeploymentEnvironmentSchema # #region DeployRequest [TYPE Class] diff --git a/backend/src/api/routes/maintenance/_routes.py b/backend/src/api/routes/maintenance/_routes.py index adfbf0c2..a5a28b4f 100644 --- a/backend/src/api/routes/maintenance/_routes.py +++ b/backend/src/api/routes/maintenance/_routes.py @@ -8,10 +8,10 @@ # @INVARIANT All mutation endpoints return 202 {task_id} per spec. # @INVARIANT RBAC enforced per FR-015 matrix via has_permission() dependency. -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any, cast -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, status from sqlalchemy.orm import Session from ....core.logger import belief_scope, logger as app_logger @@ -48,7 +48,6 @@ from ._schemas import ( MaintenanceStartResponse, ) - # ── GET /api/maintenance/dashboard-banners ─────────────────── # FR-015: viewer, operator, admin all have READ access @@ -127,7 +126,7 @@ async def list_events( ): with belief_scope("list_events"): # ── Auto-expiry: end events past their end_time ── - now = datetime.now(timezone.utc) + now = datetime.now(UTC) expired = ( db.query(MaintenanceEvent) .filter( @@ -253,11 +252,11 @@ async def start_maintenance( ) # Validate times are within reasonable range (future ±1h grace period) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) grace_threshold = 3600 # 1 hour in seconds if request.start_time: diff = (request.start_time - now).total_seconds() if request.start_time.tzinfo else \ - (request.start_time.replace(tzinfo=timezone.utc) - now).total_seconds() + (request.start_time.replace(tzinfo=UTC) - now).total_seconds() if diff < -grace_threshold: raise HTTPException( status_code=400, @@ -281,7 +280,6 @@ async def start_maintenance( pass # ── Idempotency check ── - from ....services.maintenance import build_idempotency_key sorted_tables = sorted(t.strip().lower() for t in request.tables if t.strip()) idem_tables = frozenset(sorted_tables) diff --git a/backend/src/api/routes/maintenance/_schemas.py b/backend/src/api/routes/maintenance/_schemas.py index 354e2cd0..1b5a01f4 100644 --- a/backend/src/api/routes/maintenance/_schemas.py +++ b/backend/src/api/routes/maintenance/_schemas.py @@ -8,7 +8,6 @@ from datetime import datetime from pydantic import BaseModel, Field - # ── Request Schemas ────────────────────────────────────────── # #region MaintenanceStartRequest [C:1] [TYPE Class] diff --git a/backend/src/api/routes/mappings.py b/backend/src/api/routes/mappings.py index 274612c9..f3ae057f 100644 --- a/backend/src/api/routes/mappings.py +++ b/backend/src/api/routes/mappings.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from sqlalchemy.orm import Session from ...core.database import get_db @@ -42,8 +42,7 @@ class MappingResponse(BaseModel): target_db_name: str engine: str | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion MappingResponse # #region SuggestRequest [TYPE DataClass] @@ -98,7 +97,7 @@ async def create_mapping( db.refresh(existing) return existing - new_mapping = DatabaseMapping(**mapping.dict()) + new_mapping = DatabaseMapping(**mapping.model_dump()) db.add(new_mapping) db.commit() db.refresh(new_mapping) diff --git a/backend/src/api/routes/migration.py b/backend/src/api/routes/migration.py index 543ed12b..363c5c40 100644 --- a/backend/src/api/routes/migration.py +++ b/backend/src/api/routes/migration.py @@ -112,7 +112,7 @@ async def execute_migration( ) # Include replace_db_config and fix_cross_filters in the task parameters - task_params = selection.dict() + task_params = selection.model_dump() task_params["replace_db_config"] = selection.replace_db_config task_params["fix_cross_filters"] = selection.fix_cross_filters diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index 846bc2a7..8c6cfcf8 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -456,12 +456,12 @@ async def get_consolidated_settings( normalized_llm = normalize_llm_settings(config.settings.llm) response_payload = ConsolidatedSettingsResponse( - environments=[env.dict() for env in config.environments], + environments=[env.model_dump() for env in config.environments], connections=config.settings.connections, llm=normalized_llm, llm_providers=llm_providers_list, - logging=config.settings.logging.dict(), - storage=config.settings.storage.dict(), + logging=config.settings.logging.model_dump(), + storage=config.settings.storage.model_dump(), notifications=notifications_payload, features=config.settings.features.model_dump(), app_timezone=config.settings.app_timezone, @@ -531,7 +531,7 @@ async def update_consolidated_settings( # Update app_timezone if provided if "app_timezone" in settings_patch: new_tz = settings_patch["app_timezone"] - from ...core.timezone import validate_timezone, invalidate_timezone_cache + from ...core.timezone import invalidate_timezone_cache, validate_timezone if not validate_timezone(new_tz): raise HTTPException(status_code=422, detail=f"Invalid IANA timezone: {new_tz}") current_settings.app_timezone = new_tz @@ -566,7 +566,7 @@ async def create_validation_policy( _=Depends(has_permission("admin:settings", "WRITE")), ): with belief_scope("create_validation_policy"): - db_policy = ValidationPolicy(**policy.dict()) + db_policy = ValidationPolicy(**policy.model_dump()) db.add(db_policy) db.commit() db.refresh(db_policy) diff --git a/backend/src/api/routes/translate/_job_routes.py b/backend/src/api/routes/translate/_job_routes.py index 89a9f77d..0210b37e 100644 --- a/backend/src/api/routes/translate/_job_routes.py +++ b/backend/src/api/routes/translate/_job_routes.py @@ -13,9 +13,9 @@ from ....dependencies import get_config_manager, get_current_user, has_permissio from ....plugins.translate.service import ( TranslateJobService, get_datasource_columns as fetch_datasource_columns_service, - job_to_response, ) from ....plugins.translate.service_target_schema import validate_target_table_schema +from ....plugins.translate.service_utils import job_to_response from ....schemas.auth import User from ....schemas.translate import ( DatasourceColumnsResponse, diff --git a/backend/src/api/routes/validation.py b/backend/src/api/routes/validation.py index 47fb16e8..848e703f 100644 --- a/backend/src/api/routes/validation.py +++ b/backend/src/api/routes/validation.py @@ -117,7 +117,6 @@ async def get_task( logger.reason(f"get_task — Task: {task_id}, User: {current_user.username}", extra={"src": "validation_routes"}) try: task = task_service.get_task(task_id) - from ...schemas.validation import ValidationRunResponse # Find recent runs for this specific task via task_id recent = run_service.list_runs( task_id=task_id, diff --git a/backend/src/api/routes/validation/__tests__/test_validation_api.py b/backend/src/api/routes/validation/__tests__/test_validation_api.py index a98af35c..b829aa49 100644 --- a/backend/src/api/routes/validation/__tests__/test_validation_api.py +++ b/backend/src/api/routes/validation/__tests__/test_validation_api.py @@ -23,33 +23,33 @@ # Set required env vars before ANY app imports — crash-early guard for AuthConfig() import os + os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:test_auth") os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:test_main") os.environ.setdefault("DEV_MODE", "true") -from datetime import datetime, timezone - +from datetime import UTC, datetime import pytest from unittest.mock import AsyncMock, MagicMock from fastapi.testclient import TestClient +from src.api.routes.validation import _get_run_service, _get_task_service from src.app import app from src.dependencies import ( - get_current_user, get_config_manager, + get_current_user, get_task_manager, ) -from src.api.routes.validation import _get_task_service, _get_run_service +from src.models.auth import User from src.schemas.validation import ( + TriggerRunResponse, + ValidationRunDetailResponse, + ValidationRunListResponse, ValidationTaskListResponse, ValidationTaskResponse, - ValidationRunListResponse, - ValidationRunDetailResponse, - TriggerRunResponse, ) -from src.models.auth import User # ── Shared mocks ─────────────────────────────────────────────────────── @@ -64,7 +64,7 @@ mock_user.roles.append(admin_role) def _make_now(): """Return a timezone-aware UTC datetime for Pydantic v2.""" - return datetime.now(timezone.utc) + return datetime.now(UTC) @pytest.fixture(autouse=True) diff --git a/backend/src/app.py b/backend/src/app.py index 8da8c96b..ccdb0cf9 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -10,13 +10,15 @@ # @DATA_CONTRACT [HTTP Request | WS Message] -> [HTTP Response | JSON Log Stream] # @RATIONALE Duplicate @RELATION and @INVARIANT lines removed from header. Import sorting unified via ruff isort (I) rule across src/ — 139 fixes applied. +import asyncio +from contextlib import asynccontextmanager import os from pathlib import Path # project_root is used for static files mounting project_root = Path(__file__).resolve().parent.parent.parent -import asyncio +from alembic.config import Config as AlembicConfig from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse @@ -24,6 +26,8 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.sessions import SessionMiddleware +from alembic import command as alembic_command + from .api import auth from .api.routes import ( admin, @@ -50,9 +54,6 @@ from .api.routes import ( translate, validation, ) -from alembic.config import Config as AlembicConfig -from alembic import command as alembic_command - from .core.auth.security import get_password_hash from .core.cot_logger import seed_trace_id from .core.database import AuthSessionLocal, init_db @@ -62,6 +63,28 @@ from .core.utils.network import NetworkError from .dependencies import get_scheduler_service, get_task_manager from .models.auth import Role, User + +# #region lifespan [C:3] [TYPE Function] +# @BRIEF Async context manager for FastAPI startup/shutdown lifecycle. +# @RELATION CALLS -> [run_alembic_migrations] +# @RELATION CALLS -> [init_db] +# @RELATION CALLS -> [AppDependencies] +# @POST On startup: schema up-to-date, admin exists, scheduler started. On shutdown: scheduler stopped. +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + seed_trace_id() + with belief_scope("startup_event"): + ensure_encryption_key() + run_alembic_migrations() + init_db() + ensure_initial_admin_user() + scheduler = get_scheduler_service() + scheduler.start() + yield + # Shutdown + scheduler.stop() +# #endregion lifespan # #region FastAPI_App [C:3] [TYPE Global] [SEMANTICS app, fastapi, instance, route-registry] # @BRIEF Canonical FastAPI application instance for route, middleware, and websocket registration. # @RELATION DEPENDS_ON -> [ApiRoutesModule] @@ -70,6 +93,7 @@ app = FastAPI( title="Superset Tools API", description="API for managing Superset automation tools and plugins.", version="1.0.0", + lifespan=lifespan, ) # #endregion FastAPI_App # #region ensure_initial_admin_user [C:3] [TYPE Function] @@ -152,37 +176,7 @@ def run_alembic_migrations() -> None: # #endregion run_alembic_migrations -# #region startup_event [C:3] [TYPE Function] -# @BRIEF Handles application startup: Alembic → create_all → admin bootstrap → scheduler. -# @RELATION CALLS -> [run_alembic_migrations] -# @RELATION CALLS -> [init_db] -# @POST Schema is up-to-date via Alembic, missing tables created, admin exists, scheduler started. -@app.on_event("startup") -async def startup_event(): - seed_trace_id() - with belief_scope("startup_event"): - ensure_encryption_key() - # 1. Alembic — applies ALL pending migrations (add/drop/rename columns, data migrations) - run_alembic_migrations() - # 2. init_db — creates new tables via create_all() (does NOT alter existing ones) - init_db() - ensure_initial_admin_user() - scheduler = get_scheduler_service() - scheduler.start() -# #endregion startup_event -# #region shutdown_event [C:3] [TYPE Function] -# @BRIEF Handles application shutdown tasks, such as stopping the scheduler. -# @RELATION CALLS -> [AppDependencies] -# @PRE None. -# @POST Scheduler is stopped. -# Shutdown event -@app.on_event("shutdown") -async def shutdown_event(): - seed_trace_id() - with belief_scope("shutdown_event"): - scheduler = get_scheduler_service() - scheduler.stop() -# #endregion shutdown_event + # #region app_middleware [TYPE Block] # @BRIEF Configure application-wide middleware (Session, CORS). # @RATIONALE SessionMiddleware uses SESSION_SECRET_KEY independent of JWT SECRET_KEY (see [SEC:H-4]). @@ -349,8 +343,8 @@ async def _authenticate_websocket(websocket: WebSocket, endpoint_name: str) -> b # Try JWT first, fallback to API key try: - from .core.auth.jwt import decode_token from .core.auth.api_key import hash_api_key + from .core.auth.jwt import decode_token from .core.database import SessionLocal # Attempt JWT validation @@ -479,7 +473,7 @@ async def websocket_endpoint( initial_sent = 0 for log_entry in initial_logs: if matches_filters(log_entry): - log_dict = log_entry.dict() + log_dict = log_entry.model_dump() log_dict["timestamp"] = log_dict["timestamp"].isoformat() await websocket.send_json(log_dict) initial_sent += 1 @@ -510,7 +504,7 @@ async def websocket_endpoint( log_entry = await queue.get() if not matches_filters(log_entry): continue - log_dict = log_entry.dict() + log_dict = log_entry.model_dump() log_dict["timestamp"] = log_dict["timestamp"].isoformat() await websocket.send_json(log_dict) logger.reflect( diff --git a/backend/src/core/auth/jwt.py b/backend/src/core/auth/jwt.py index 7a3bc02d..013e7a9c 100644 --- a/backend/src/core/auth/jwt.py +++ b/backend/src/core/auth/jwt.py @@ -12,12 +12,11 @@ # @SIDE_EFFECT None # @DATA_CONTRACT TokenPayload -> JWT string +from datetime import UTC, datetime, timedelta import hashlib import uuid -from datetime import datetime, timedelta, timezone from jose import jwt -from jose.exceptions import JWTError from sqlalchemy.orm import Session from ...models.auth import TokenBlacklist @@ -34,7 +33,7 @@ from .config import auth_config def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: with belief_scope("create_access_token"): to_encode = data.copy() - now = datetime.now(timezone.utc) + now = datetime.now(UTC) if expires_delta: expire = now + expires_delta else: @@ -101,7 +100,7 @@ def _hash_token(token: str) -> str: # @PRE db is a valid SQLAlchemy session. # @POST Expired blacklist rows are removed. def _prune_blacklist(db: Session) -> None: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) db.query(TokenBlacklist).filter(TokenBlacklist.expires_at < now).delete() db.commit() @@ -131,7 +130,7 @@ def blacklist_token(token: str, db: Session) -> None: if isinstance(exp_raw, datetime): expires_at = exp_raw else: - expires_at = datetime.fromtimestamp(exp_raw, tz=timezone.utc) + expires_at = datetime.fromtimestamp(exp_raw, tz=UTC) existing = db.query(TokenBlacklist).filter(TokenBlacklist.jti == jti).first() if existing: diff --git a/backend/src/core/auth/logger.py b/backend/src/core/auth/logger.py index ab6e5d53..a28407eb 100644 --- a/backend/src/core/auth/logger.py +++ b/backend/src/core/auth/logger.py @@ -12,9 +12,8 @@ # @SIDE_EFFECT Writes auth audit log entries # @DATA_CONTRACT AuthEvent -> LogEntry -import copy +from datetime import UTC, datetime import re -from datetime import datetime from typing import Any from ..logger import belief_scope, logger @@ -52,7 +51,7 @@ def _mask_details(details: dict[str, Any] | None) -> dict[str, Any] | None: # dict are masked to prevent credential leakage — see [SEC:L-1]. def log_security_event(event_type: str, username: str, details: dict = None): with belief_scope("log_security_event", f"{event_type}:{username}"): - timestamp = datetime.utcnow().isoformat() + timestamp = datetime.now(UTC).isoformat() logger.info( "[AUDIT][%s][%s] User: %s", timestamp, diff --git a/backend/src/core/logger.py b/backend/src/core/logger.py index 9bc96db6..ff8e4b02 100755 --- a/backend/src/core/logger.py +++ b/backend/src/core/logger.py @@ -124,7 +124,7 @@ class BeliefFormatter(logging.Formatter): # #region LogEntry [C:1] [TYPE Class] [SEMANTICS log,entry,record,pydantic] # @BRIEF A Pydantic model representing a single, structured log entry. class LogEntry(BaseModel): - timestamp: datetime = Field(default_factory=datetime.utcnow) + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) level: str message: str context: dict[str, Any] | None = None diff --git a/backend/src/core/rate_limiter.py b/backend/src/core/rate_limiter.py index 4ff3577c..d3ee28c3 100644 --- a/backend/src/core/rate_limiter.py +++ b/backend/src/core/rate_limiter.py @@ -7,9 +7,9 @@ # Window resets after ATTEMPT_WINDOW seconds of no activity. # @REJECTED Redis-backed limiter — not available in all deployments. # Per-user limiting — user isn't known until after auth. -import time -import threading from collections import defaultdict +import threading +import time # ── Configuration ── MAX_ATTEMPTS: int = 10 # Max failed attempts per window diff --git a/backend/src/core/superset_client/_layout_utils.py b/backend/src/core/superset_client/_layout_utils.py index 64bee8e5..cc43f02e 100644 --- a/backend/src/core/superset_client/_layout_utils.py +++ b/backend/src/core/superset_client/_layout_utils.py @@ -172,8 +172,7 @@ def remove_banner_from_position(position_json: dict, chart_id: int) -> dict: del position_json[md_key] # Remove ROW entry - if row_key in position_json: - del position_json[row_key] + position_json.pop(row_key, None) # Remove ROW from GRID_ID children grid = position_json.get("GRID_ID") diff --git a/backend/src/core/task_manager/lifecycle.py b/backend/src/core/task_manager/lifecycle.py index d4e43b54..7d1f6e89 100644 --- a/backend/src/core/task_manager/lifecycle.py +++ b/backend/src/core/task_manager/lifecycle.py @@ -127,7 +127,7 @@ class JobLifecycle: f"Starting execution of task {task_id} for plugin '{plugin.name}'" ) task.status = TaskStatus.RUNNING - task.started_at = datetime.utcnow() + task.started_at = datetime.now(UTC) self.persistence_service.persist_task(task) if add_log_callback: add_log_callback( @@ -181,7 +181,7 @@ class JobLifecycle: metadata={"error_type": type(e).__name__}, ) finally: - task.finished_at = datetime.utcnow() + task.finished_at = datetime.now(UTC) self.event_bus.flush_task_logs(task_id) self.persistence_service.persist_task(task) logger.info( diff --git a/backend/src/core/task_manager/manager.py b/backend/src/core/task_manager/manager.py index 747daf7e..17c219a5 100644 --- a/backend/src/core/task_manager/manager.py +++ b/backend/src/core/task_manager/manager.py @@ -34,7 +34,6 @@ import asyncio from concurrent.futures import ThreadPoolExecutor -import threading from typing import Any from ..logger import belief_scope, logger diff --git a/backend/src/core/task_manager/models.py b/backend/src/core/task_manager/models.py index c3735eb2..86d9c649 100644 --- a/backend/src/core/task_manager/models.py +++ b/backend/src/core/task_manager/models.py @@ -9,12 +9,12 @@ # @POST Task models exported with immutable IDs # @SIDE_EFFECT Defines task data schema # @DATA_CONTRACT TaskInput -> TaskModel -from datetime import datetime +from datetime import UTC, datetime from enum import Enum from typing import Any import uuid -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field # #region TaskStatus [TYPE Enum] @@ -41,7 +41,7 @@ class LogLevel(str, Enum): # optional_fields: {timestamp: datetime, level: str, source: str, context: dict, metadata: dict} # } class LogEntry(BaseModel): - timestamp: datetime = Field(default_factory=datetime.utcnow) + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) level: str = Field(default="INFO") message: str source: str = Field( @@ -65,8 +65,7 @@ class TaskLog(BaseModel): source: str message: str metadata: dict[str, Any] | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion TaskLog # #region LogFilter [TYPE Class] class LogFilter(BaseModel): diff --git a/backend/src/core/task_manager/persistence.py b/backend/src/core/task_manager/persistence.py index 66a7e110..daad6c57 100644 --- a/backend/src/core/task_manager/persistence.py +++ b/backend/src/core/task_manager/persistence.py @@ -9,7 +9,7 @@ # @RELATION DEPENDS_ON -> [TaskGraph] # @RELATION DEPENDS_ON -> [TasksSessionLocal] # @INVARIANT Database schema must match the TaskRecord model structure. -from datetime import datetime +from datetime import UTC, datetime import json import re @@ -179,7 +179,7 @@ class TaskPersistenceService: # Store logs as JSON, converting datetime to string record.logs = [] for log in task.logs: - log_dict = log.dict() + log_dict = log.model_dump() if isinstance(log_dict.get("timestamp"), datetime): log_dict["timestamp"] = log_dict["timestamp"].isoformat() # Also clean up any datetimes in context @@ -244,7 +244,7 @@ class TaskPersistenceService: log_data = dict(log_data) log_data["timestamp"] = ( self._parse_datetime(log_data.get("timestamp")) - or datetime.utcnow() + or datetime.now(UTC) ) logs.append(LogEntry(**log_data)) started_at = self._parse_datetime(record.started_at) diff --git a/backend/src/core/utils/dataset_mapper.py b/backend/src/core/utils/dataset_mapper.py index f72f2607..85dd7b2f 100644 --- a/backend/src/core/utils/dataset_mapper.py +++ b/backend/src/core/utils/dataset_mapper.py @@ -235,4 +235,4 @@ class DatasetMapper: return # #endregion run_mapping # #endregion DatasetMapper -# #endregion DatasetMapperModule \ No newline at end of file +# #endregion DatasetMapperModule diff --git a/backend/src/core/utils/superset_compilation_adapter.py b/backend/src/core/utils/superset_compilation_adapter.py index 4b9db11b..a5c9e1c9 100644 --- a/backend/src/core/utils/superset_compilation_adapter.py +++ b/backend/src/core/utils/superset_compilation_adapter.py @@ -11,7 +11,7 @@ from __future__ import annotations # #region SupersetCompilationAdapter.imports [TYPE Block] from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from typing import Any from src.core.config_models import Environment @@ -140,7 +140,7 @@ class SupersetCompilationAdapter: compiled_by="superset", error_code=None, error_details=None, - compiled_at=datetime.utcnow(), + compiled_at=datetime.now(UTC), ) logger.reflect( "Superset-generated SQL preview captured successfully", diff --git a/backend/src/core/ws_log_handler.py b/backend/src/core/ws_log_handler.py index f42a1110..6eb73968 100644 --- a/backend/src/core/ws_log_handler.py +++ b/backend/src/core/ws_log_handler.py @@ -3,7 +3,7 @@ # @RELATION DEPENDS_ON -> [LogEntry] # @RELATION DEPENDS_ON -> [CotJsonFormatter] from collections import deque -from datetime import datetime +from datetime import UTC, datetime import logging from typing import Any @@ -12,7 +12,7 @@ from pydantic import BaseModel, Field # #region LogEntry [C:1] [TYPE Class] [SEMANTICS log,entry,record,pydantic] class LogEntry(BaseModel): - timestamp: datetime = Field(default_factory=datetime.utcnow) + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) level: str message: str context: dict[str, Any] | None = None diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py index 2ef0ae98..24eb713c 100755 --- a/backend/src/dependencies.py +++ b/backend/src/dependencies.py @@ -13,12 +13,10 @@ # @RELATION CALLS -> [get_api_key_principal] # @RELATION CALLS -> [info] -import json - from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime +import json from pathlib import Path -from typing import Optional from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer @@ -214,7 +212,7 @@ def get_clean_release_facade(db=Depends(get_db)) -> CleanReleaseFacade: class APIKeyPrincipal: name: str api_key_id: str - environment_id: Optional[str] = None + environment_id: str | None = None permissions: list[str] = field(default_factory=list) # #endregion APIKeyPrincipal @@ -337,8 +335,8 @@ def require_api_key_or_jwt( if api_key.expires_at: expires_at = api_key.expires_at if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - if expires_at < datetime.now(timezone.utc): + expires_at = expires_at.replace(tzinfo=UTC) + if expires_at < datetime.now(UTC): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="API key has expired", @@ -361,7 +359,7 @@ def require_api_key_or_jwt( ) # Update last_used_at (best-effort, non-blocking) - api_key.last_used_at = datetime.now(timezone.utc) + api_key.last_used_at = datetime.now(UTC) db.flush() logger.info(f"API key auth: {api_key.name} ({api_key.prefix}...)") @@ -425,7 +423,7 @@ async def check_api_key_environment_scope(request: Request, db, target_env_id: s async def get_api_key_principal( request: Request, db=Depends(get_db), -) -> Optional[APIKeyPrincipal]: +) -> APIKeyPrincipal | None: """Extract and validate API key from X-API-Key header. If no header is present, returns None (fall through to JWT auth). @@ -460,8 +458,8 @@ async def get_api_key_principal( if api_key.expires_at: expires_at = api_key.expires_at if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - if expires_at < datetime.now(timezone.utc): + expires_at = expires_at.replace(tzinfo=UTC) + if expires_at < datetime.now(UTC): logger.warning(f"API key auth failed: key expired (id: {api_key.id}, name: {api_key.name})") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -469,7 +467,7 @@ async def get_api_key_principal( ) # Update last_used_at (non-blocking, best-effort) - api_key.last_used_at = datetime.now(timezone.utc) + api_key.last_used_at = datetime.now(UTC) db.flush() logger.info(f"API key auth success: {api_key.name} ({api_key.prefix}...)") diff --git a/backend/src/models/__tests__/test_report_models.py b/backend/src/models/__tests__/test_report_models.py index 684d51ea..b02d5457 100644 --- a/backend/src/models/__tests__/test_report_models.py +++ b/backend/src/models/__tests__/test_report_models.py @@ -6,7 +6,7 @@ from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta import pytest @@ -116,12 +116,12 @@ class TestReportQuery: assert q.sort_order == order def test_time_range_validation_valid(self): from src.models.report import ReportQuery - now = datetime.utcnow() + now = datetime.now(UTC) q = ReportQuery(time_from=now - timedelta(days=1), time_to=now) assert q.time_from < q.time_to def test_time_range_validation_invalid(self): from src.models.report import ReportQuery - now = datetime.utcnow() + now = datetime.now(UTC) with pytest.raises(ValueError, match="time_from"): ReportQuery(time_from=now, time_to=now - timedelta(days=1)) def test_page_ge_1(self): @@ -152,7 +152,7 @@ class TestReportCollection: from src.models.report import ReportCollection, ReportQuery, ReportStatus, TaskReport, TaskType report = TaskReport( report_id="r1", task_id="t1", task_type=TaskType.BACKUP, - status=ReportStatus.SUCCESS, updated_at=datetime.utcnow(), + status=ReportStatus.SUCCESS, updated_at=datetime.now(UTC), summary="OK" ) col = ReportCollection( @@ -167,7 +167,7 @@ class TestReportDetailView: from src.models.report import ReportDetailView, ReportStatus, TaskReport, TaskType report = TaskReport( report_id="r1", task_id="t1", task_type=TaskType.BACKUP, - status=ReportStatus.SUCCESS, updated_at=datetime.utcnow(), + status=ReportStatus.SUCCESS, updated_at=datetime.now(UTC), summary="Backup OK" ) detail = ReportDetailView(report=report) @@ -179,7 +179,7 @@ class TestReportDetailView: from src.models.report import ReportDetailView, ReportStatus, TaskReport, TaskType report = TaskReport( report_id="r1", task_id="t1", task_type=TaskType.MIGRATION, - status=ReportStatus.FAILED, updated_at=datetime.utcnow(), + status=ReportStatus.FAILED, updated_at=datetime.now(UTC), summary="Migration failed" ) detail = ReportDetailView( diff --git a/backend/src/models/api_key.py b/backend/src/models/api_key.py index 271e01bc..a8a3b742 100644 --- a/backend/src/models/api_key.py +++ b/backend/src/models/api_key.py @@ -5,10 +5,10 @@ # @INVARIANT key_hash is stored as SHA-256 hex digest (64 chars). prefix stores "ssk_" + first 7 chars. # @INVARIANT Raw key is NEVER stored — shown once at creation and discarded. +from datetime import UTC, datetime import uuid -from datetime import datetime, timezone -from sqlalchemy import Boolean, Column, DateTime, JSON, String +from sqlalchemy import JSON, Boolean, Column, DateTime, String from .mapping import Base @@ -25,7 +25,7 @@ class APIKey(Base): environment_id = Column(String(50), nullable=True) # Scoped to environment; null = all permissions = Column(JSON, nullable=False) # e.g. ["maintenance:start", "maintenance:end"] active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) expires_at = Column(DateTime, nullable=True) last_used_at = Column(DateTime, nullable=True) diff --git a/backend/src/models/assistant.py b/backend/src/models/assistant.py index d4f9eacf..053332fc 100644 --- a/backend/src/models/assistant.py +++ b/backend/src/models/assistant.py @@ -6,7 +6,7 @@ # @SIDE_EFFECT Defines assistant audit/message/confirmation tables # @DATA_CONTRACT AssistantData -> AssistantRecord -from datetime import datetime +from datetime import UTC, datetime from sqlalchemy import JSON, Column, DateTime, String, Text @@ -28,7 +28,7 @@ class AssistantAuditRecord(Base): task_id = Column(String, nullable=True) message = Column(Text, nullable=True) payload = Column(JSON, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) # #endregion AssistantAuditRecord @@ -51,7 +51,7 @@ class AssistantMessageRecord(Base): task_id = Column(String, nullable=True) confirmation_id = Column(String, nullable=True) payload = Column(JSON, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) # #endregion AssistantMessageRecord @@ -72,7 +72,7 @@ class AssistantConfirmationRecord(Base): intent = Column(JSON, nullable=False) dispatch = Column(JSON, nullable=False) expires_at = Column(DateTime, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) consumed_at = Column(DateTime, nullable=True) diff --git a/backend/src/models/auth.py b/backend/src/models/auth.py index fe04b7f4..cfda6fd2 100644 --- a/backend/src/models/auth.py +++ b/backend/src/models/auth.py @@ -9,7 +9,7 @@ # @SIDE_EFFECT Defines auth user tables # @DATA_CONTRACT UserData -> UserRecord -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Table @@ -69,7 +69,7 @@ class User(Base): auth_source = Column(String, default="LOCAL") # LOCAL or ADFS is_active = Column(Boolean, default=True) is_ad_user = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=lambda: datetime.now(UTC)) last_login = Column(DateTime, nullable=True) roles = relationship("Role", secondary=user_roles, back_populates="users") @@ -128,7 +128,7 @@ class TokenBlacklist(Base): jti = Column(String, primary_key=True) # JWT ID from the token token_hash = Column(String(64), nullable=False, index=True) # SHA-256 of token expires_at = Column(DateTime, nullable=False) # Token's original exp claim - revoked_at = Column(DateTime, nullable=False, default=datetime.utcnow) + revoked_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) # #endregion TokenBlacklist diff --git a/backend/src/models/clean_release.py b/backend/src/models/clean_release.py index a39d07c1..31672bcf 100644 --- a/backend/src/models/clean_release.py +++ b/backend/src/models/clean_release.py @@ -8,7 +8,7 @@ # @DATA_CONTRACT Model[ReleaseCandidate, CandidateArtifact, DistributionManifest, ComplianceRun, ComplianceReport] # @INVARIANT Immutable snapshots are never mutated; forbidden lifecycle transitions are rejected. -from datetime import datetime +from datetime import UTC, datetime from enum import Enum from typing import Any @@ -237,7 +237,7 @@ class ReleaseCandidate(Base): version = Column(String, nullable=False) source_snapshot_ref = Column(String, nullable=False) build_id = Column(String, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=lambda: datetime.now(UTC)) created_by = Column(String, nullable=False) status = Column(String, default=CandidateStatus.DRAFT) @@ -333,7 +333,7 @@ class DistributionManifest(Base): manifest_version = Column(Integer, nullable=False) manifest_digest = Column(String, nullable=False) artifacts_digest = Column(String, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=lambda: datetime.now(UTC)) created_by = Column(String, nullable=False) source_snapshot_ref = Column(String, nullable=False) content_json = Column(JSON, nullable=False) @@ -418,7 +418,7 @@ class SourceRegistrySnapshot(Base): id = Column(String, primary_key=True) registry_id = Column(String, nullable=False) registry_version = Column(String, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=lambda: datetime.now(UTC)) allowed_hosts = Column(JSON, nullable=False) # List[str] allowed_schemes = Column(JSON, nullable=False) # List[str] allowed_source_types = Column(JSON, nullable=False) # List[str] @@ -433,7 +433,7 @@ class CleanPolicySnapshot(Base): id = Column(String, primary_key=True) policy_id = Column(String, nullable=False) policy_version = Column(String, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=lambda: datetime.now(UTC)) content_json = Column(JSON, nullable=False) registry_snapshot_id = Column(String, ForeignKey("clean_release_registry_snapshots.id"), nullable=False) immutable = Column(Boolean, default=True) @@ -451,7 +451,7 @@ class ComplianceRun(Base): policy_snapshot_id = Column(String, ForeignKey("clean_release_policy_snapshots.id"), nullable=False) registry_snapshot_id = Column(String, ForeignKey("clean_release_registry_snapshots.id"), nullable=False) requested_by = Column(String, nullable=False) - requested_at = Column(DateTime, default=datetime.utcnow) + requested_at = Column(DateTime, default=lambda: datetime.now(UTC)) started_at = Column(DateTime, nullable=True) finished_at = Column(DateTime, nullable=True) status = Column(String, default=RunStatus.PENDING) @@ -584,7 +584,7 @@ class ComplianceReport(Base): candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False) final_status = Column(String, nullable=False) # ComplianceDecision summary_json = Column(JSON, nullable=False) - generated_at = Column(DateTime, default=datetime.utcnow) + generated_at = Column(DateTime, default=lambda: datetime.now(UTC)) immutable = Column(Boolean, default=True) def __init__(self, **kwargs): @@ -657,7 +657,7 @@ class ApprovalDecision(Base): report_id = Column(String, ForeignKey("clean_release_compliance_reports.id"), nullable=False) decision = Column(String, nullable=False) # ApprovalDecisionType decided_by = Column(String, nullable=False) - decided_at = Column(DateTime, default=datetime.utcnow) + decided_at = Column(DateTime, default=lambda: datetime.now(UTC)) comment = Column(String, nullable=True) # #endregion ApprovalDecision @@ -670,7 +670,7 @@ class PublicationRecord(Base): candidate_id = Column(String, ForeignKey("clean_release_candidates.id"), nullable=False) report_id = Column(String, ForeignKey("clean_release_compliance_reports.id"), nullable=False) published_by = Column(String, nullable=False) - published_at = Column(DateTime, default=datetime.utcnow) + published_at = Column(DateTime, default=lambda: datetime.now(UTC)) target_channel = Column(String, nullable=False) publication_ref = Column(String, nullable=True) status = Column(String, default=PublicationStatus.ACTIVE) @@ -688,7 +688,7 @@ class CleanReleaseAuditLog(Base): candidate_id = Column(String, index=True, nullable=True) action = Column(String, nullable=False) # e.g. "TRANSITION", "APPROVE", "PUBLISH" actor = Column(String, nullable=False) - timestamp = Column(DateTime, default=datetime.utcnow) + timestamp = Column(DateTime, default=lambda: datetime.now(UTC)) details_json = Column(JSON, default=dict) # #endregion CleanReleaseAuditLog diff --git a/backend/src/models/dataset_review_pkg/_clarification_models.py b/backend/src/models/dataset_review_pkg/_clarification_models.py index 54b0b113..2ef7ed76 100644 --- a/backend/src/models/dataset_review_pkg/_clarification_models.py +++ b/backend/src/models/dataset_review_pkg/_clarification_models.py @@ -9,7 +9,7 @@ # @SIDE_EFFECT Defines dataset review clarification tables # @DATA_CONTRACT ClarificationData -> ClarificationRecord -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import ( @@ -45,8 +45,8 @@ class ClarificationSession(Base): resolved_count = Column(Integer, nullable=False, default=0) remaining_count = Column(Integer, nullable=False, default=0) summary_delta = Column(Text, nullable=True) - started_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + started_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) + updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False) completed_at = Column(DateTime, nullable=True) session = relationship("DatasetReviewSession", back_populates="clarification_sessions") @@ -70,8 +70,8 @@ class ClarificationQuestion(Base): current_guess = Column(Text, nullable=True) priority = Column(Integer, nullable=False, default=0) state = Column(SQLEnum(QuestionState), nullable=False, default=QuestionState.OPEN) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) + updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False) clarification_session = relationship("ClarificationSession", back_populates="questions") options = relationship("ClarificationOption", back_populates="question", cascade="all, delete-orphan") @@ -113,7 +113,7 @@ class ClarificationAnswer(Base): answered_by_user_id = Column(String, nullable=False) impact_summary = Column(Text, nullable=True) user_feedback = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) question = relationship("ClarificationQuestion", back_populates="answer") diff --git a/backend/src/models/dataset_review_pkg/_execution_models.py b/backend/src/models/dataset_review_pkg/_execution_models.py index c418d30d..ea1465df 100644 --- a/backend/src/models/dataset_review_pkg/_execution_models.py +++ b/backend/src/models/dataset_review_pkg/_execution_models.py @@ -4,7 +4,7 @@ # @RELATION DEPENDS_ON -> [DatasetReviewEnums] # @RELATION DEPENDS_ON -> [MappingModels] -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import ( @@ -46,7 +46,7 @@ class CompiledPreview(Base): error_code = Column(String, nullable=True) error_details = Column(Text, nullable=True) compiled_at = Column(DateTime, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) session = relationship("DatasetReviewSession", back_populates="previews") @@ -75,7 +75,7 @@ class DatasetRunContext(Base): open_warning_refs = Column(JSON, nullable=False) launch_status = Column(SQLEnum(LaunchStatus), nullable=False) launch_error = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) session = relationship("DatasetReviewSession", back_populates="run_contexts") @@ -101,7 +101,7 @@ class SessionEvent(Base): current_phase = Column(String, nullable=True) readiness_state = Column(String, nullable=True) event_details = Column(JSON, nullable=False, default=dict) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) session = relationship("DatasetReviewSession", back_populates="events") actor = relationship("User") @@ -124,7 +124,7 @@ class ExportArtifact(Base): format = Column(SQLEnum(ArtifactFormat), nullable=False) storage_ref = Column(String, nullable=False) created_by_user_id = Column(String, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) session = relationship("DatasetReviewSession", back_populates="export_artifacts") diff --git a/backend/src/models/dataset_review_pkg/_filter_models.py b/backend/src/models/dataset_review_pkg/_filter_models.py index 5bc46c33..9514fbce 100644 --- a/backend/src/models/dataset_review_pkg/_filter_models.py +++ b/backend/src/models/dataset_review_pkg/_filter_models.py @@ -4,7 +4,7 @@ # @RELATION DEPENDS_ON -> [DatasetReviewEnums] # @RELATION DEPENDS_ON -> [MappingModels] -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import ( @@ -49,9 +49,9 @@ class ImportedFilter(Base): requires_confirmation = Column(Boolean, nullable=False, default=False) recovery_status = Column(SQLEnum(FilterRecoveryStatus), nullable=False) notes = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) updated_at = Column( - DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False ) session = relationship("DatasetReviewSession", back_populates="imported_filters") @@ -78,9 +78,9 @@ class TemplateVariable(Base): mapping_status = Column( SQLEnum(MappingStatus), nullable=False, default=MappingStatus.UNMAPPED ) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) updated_at = Column( - DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False ) session = relationship("DatasetReviewSession", back_populates="template_variables") diff --git a/backend/src/models/dataset_review_pkg/_finding_models.py b/backend/src/models/dataset_review_pkg/_finding_models.py index 34a6b0cd..5095f139 100644 --- a/backend/src/models/dataset_review_pkg/_finding_models.py +++ b/backend/src/models/dataset_review_pkg/_finding_models.py @@ -4,7 +4,7 @@ # @RELATION DEPENDS_ON -> [DatasetReviewEnums] # @RELATION DEPENDS_ON -> [MappingModels] -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import ( @@ -45,7 +45,7 @@ class ValidationFinding(Base): ) resolution_note = Column(Text, nullable=True) caused_by_ref = Column(String, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) resolved_at = Column(DateTime, nullable=True) session = relationship("DatasetReviewSession", back_populates="findings") diff --git a/backend/src/models/dataset_review_pkg/_mapping_models.py b/backend/src/models/dataset_review_pkg/_mapping_models.py index d0ecd5d5..20ee5cda 100644 --- a/backend/src/models/dataset_review_pkg/_mapping_models.py +++ b/backend/src/models/dataset_review_pkg/_mapping_models.py @@ -4,7 +4,7 @@ # @RELATION DEPENDS_ON -> [DatasetReviewEnums] # @RELATION DEPENDS_ON -> [MappingModels] -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import ( @@ -47,8 +47,8 @@ class ExecutionMapping(Base): approval_state = Column(SQLEnum(ApprovalState), nullable=False, default=ApprovalState.NOT_REQUIRED) approved_by_user_id = Column(String, nullable=True) approved_at = Column(DateTime, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) + updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False) session = relationship("DatasetReviewSession", back_populates="execution_mappings") diff --git a/backend/src/models/dataset_review_pkg/_profile_models.py b/backend/src/models/dataset_review_pkg/_profile_models.py index 1e6bf23e..69295949 100644 --- a/backend/src/models/dataset_review_pkg/_profile_models.py +++ b/backend/src/models/dataset_review_pkg/_profile_models.py @@ -4,7 +4,7 @@ # @RELATION DEPENDS_ON -> [DatasetReviewEnums] # @RELATION DEPENDS_ON -> [MappingModels] -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import ( @@ -52,9 +52,9 @@ class DatasetProfile(Base): has_blocking_findings = Column(Boolean, nullable=False, default=False) has_warning_findings = Column(Boolean, nullable=False, default=False) manual_summary_locked = Column(Boolean, nullable=False, default=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) updated_at = Column( - DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False ) session = relationship("DatasetReviewSession", back_populates="profile") diff --git a/backend/src/models/dataset_review_pkg/_semantic_models.py b/backend/src/models/dataset_review_pkg/_semantic_models.py index d6230c87..8ee15780 100644 --- a/backend/src/models/dataset_review_pkg/_semantic_models.py +++ b/backend/src/models/dataset_review_pkg/_semantic_models.py @@ -9,7 +9,7 @@ # @SIDE_EFFECT Defines dataset review semantic tables # @DATA_CONTRACT SemanticData -> SemanticFieldEntry -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import ( @@ -58,7 +58,7 @@ class SemanticSource(Base): nullable=False, default=SemanticSourceStatus.AVAILABLE, ) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) session = relationship("DatasetReviewSession", back_populates="semantic_sources") @@ -94,9 +94,9 @@ class SemanticFieldEntry(Base): needs_review = Column(Boolean, nullable=False, default=True) last_changed_by = Column(String, nullable=False) user_feedback = Column(String, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) updated_at = Column( - DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False ) session = relationship("DatasetReviewSession", back_populates="semantic_fields") @@ -128,7 +128,7 @@ class SemanticCandidate(Base): status = Column( SQLEnum(CandidateStatus), nullable=False, default=CandidateStatus.PROPOSED ) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) field = relationship("SemanticFieldEntry", back_populates="candidates") diff --git a/backend/src/models/dataset_review_pkg/_session_models.py b/backend/src/models/dataset_review_pkg/_session_models.py index 767c4d52..ce361eb3 100644 --- a/backend/src/models/dataset_review_pkg/_session_models.py +++ b/backend/src/models/dataset_review_pkg/_session_models.py @@ -9,7 +9,7 @@ # @SIDE_EFFECT Defines dataset review session tables # @DATA_CONTRACT SessionData -> SessionRecord -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import ( @@ -44,7 +44,7 @@ class SessionCollaborator(Base): ) user_id = Column(String, ForeignKey("users.id"), nullable=False) role = Column(SQLEnum(SessionCollaboratorRole), nullable=False) - added_at = Column(DateTime, default=datetime.utcnow, nullable=False) + added_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) session = relationship("DatasetReviewSession", back_populates="collaborators") user = relationship("User") @@ -99,11 +99,11 @@ class DatasetReviewSession(Base): active_task_id = Column(String, nullable=True) last_preview_id = Column(String, nullable=True) last_run_context_id = Column(String, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) updated_at = Column( - DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False ) - last_activity_at = Column(DateTime, default=datetime.utcnow, nullable=False) + last_activity_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) closed_at = Column(DateTime, nullable=True) owner = relationship("User") diff --git a/backend/src/models/git.py b/backend/src/models/git.py index cd5df342..62a22192 100644 --- a/backend/src/models/git.py +++ b/backend/src/models/git.py @@ -1,6 +1,6 @@ # #region GitModels [TYPE Module] [SEMANTICS sqlalchemy, git, model, config, sync] -from datetime import datetime +from datetime import UTC, datetime import enum import uuid @@ -37,7 +37,7 @@ class GitServerConfig(Base): default_repository = Column(String(255), nullable=True) default_branch = Column(String(255), default="main") status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN) - last_validated = Column(DateTime, default=datetime.utcnow) + last_validated = Column(DateTime, default=lambda: datetime.now(UTC)) # #endregion GitServerConfig # #region GitRepository [C:1] [TYPE Class] diff --git a/backend/src/models/llm.py b/backend/src/models/llm.py index 0e51e215..5962e1fc 100644 --- a/backend/src/models/llm.py +++ b/backend/src/models/llm.py @@ -3,7 +3,7 @@ # @LAYER Domain # @RELATION INHERITS -> [MappingModels] -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import JSON, Boolean, Column, DateTime, String, Text, Time @@ -32,8 +32,8 @@ class ValidationPolicy(Base): notify_owners = Column(Boolean, default=True) custom_channels = Column(JSON, nullable=True) # List of external channels alert_condition = Column(String, default="FAIL_ONLY") # FAIL_ONLY, WARN_AND_FAIL, ALWAYS - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=lambda: datetime.now(UTC)) + updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) # #endregion ValidationPolicy # #region LLMProvider [TYPE Class] @@ -49,7 +49,7 @@ class LLMProvider(Base): default_model = Column(String, nullable=False) is_active = Column(Boolean, default=True) is_multimodal = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=lambda: datetime.now(UTC)) # #endregion LLMProvider # #region ValidationRecord [TYPE Class] @@ -62,7 +62,7 @@ class ValidationRecord(Base): policy_id = Column(String, nullable=True, index=True) # Reference to ValidationPolicy.id dashboard_id = Column(String, nullable=False, index=True) environment_id = Column(String, nullable=True, index=True) - timestamp = Column(DateTime, default=datetime.utcnow) + timestamp = Column(DateTime, default=lambda: datetime.now(UTC)) status = Column(String, nullable=False) # PASS, WARN, FAIL, UNKNOWN screenshot_path = Column(String, nullable=True) issues = Column(JSON, nullable=False) diff --git a/backend/src/models/maintenance.py b/backend/src/models/maintenance.py index a0d0b842..a4b9b5c7 100644 --- a/backend/src/models/maintenance.py +++ b/backend/src/models/maintenance.py @@ -10,6 +10,7 @@ import enum import uuid from sqlalchemy import ( + JSON, CheckConstraint, Column, DateTime, @@ -17,7 +18,6 @@ from sqlalchemy import ( ForeignKey, Index, Integer, - JSON, String, Text, ) diff --git a/backend/src/models/mapping.py b/backend/src/models/mapping.py index 0d8a4b35..0c9961a1 100644 --- a/backend/src/models/mapping.py +++ b/backend/src/models/mapping.py @@ -16,7 +16,7 @@ import enum import uuid from sqlalchemy import Boolean, Column, DateTime, Enum as SQLEnum, ForeignKey, String -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import declarative_base from sqlalchemy.sql import func # #region Base [C:1] [TYPE Class] diff --git a/backend/src/models/profile.py b/backend/src/models/profile.py index 57ad96e6..9b85ef75 100644 --- a/backend/src/models/profile.py +++ b/backend/src/models/profile.py @@ -12,7 +12,7 @@ # @SIDE_EFFECT Defines user profile/preference tables # @DATA_CONTRACT ProfileData -> PreferenceRecord -from datetime import datetime +from datetime import UTC, datetime import uuid from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String @@ -48,12 +48,12 @@ class UserDashboardPreference(Base): email_address = Column(String, nullable=True) notify_on_fail = Column(Boolean, nullable=False, default=True) - created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) updated_at = Column( DateTime, nullable=False, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), ) user = relationship("User") diff --git a/backend/src/models/storage.py b/backend/src/models/storage.py index 7b942c98..6af947f8 100644 --- a/backend/src/models/storage.py +++ b/backend/src/models/storage.py @@ -2,8 +2,10 @@ # @RATIONALE Fixed typo 'repositorys' → 'repositories' in FileCategory.REPOSITORY and StorageConfig.repo_path default. Breaking change documented for existing installations. from datetime import datetime from enum import Enum + from pydantic import BaseModel, Field + # #region FileCategory [C:1] [TYPE Class] # @BRIEF Enumeration of supported file categories in the storage system. class FileCategory(str, Enum): diff --git a/backend/src/plugins/llm_analysis/models.py b/backend/src/plugins/llm_analysis/models.py index 2b57d3e7..1601d96a 100644 --- a/backend/src/plugins/llm_analysis/models.py +++ b/backend/src/plugins/llm_analysis/models.py @@ -3,7 +3,7 @@ # @LAYER Domain # @RELATION DEPENDS_ON -> [EXT:Library:pydantic] -from datetime import datetime +from datetime import UTC, datetime from enum import Enum from pydantic import BaseModel, Field @@ -53,7 +53,7 @@ class DetectedIssue(BaseModel): class ValidationResult(BaseModel): id: str | None = None dashboard_id: str - timestamp: datetime = Field(default_factory=datetime.utcnow) + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) status: ValidationStatus screenshot_path: str | None = None issues: list[DetectedIssue] diff --git a/backend/src/plugins/llm_analysis/plugin.py b/backend/src/plugins/llm_analysis/plugin.py index 4e58783e..71e3a57b 100644 --- a/backend/src/plugins/llm_analysis/plugin.py +++ b/backend/src/plugins/llm_analysis/plugin.py @@ -9,7 +9,7 @@ # @INVARIANT All LLM interactions must be executed as asynchronous tasks. # @DATA_CONTRACT AnalysisRequest -> AnalysisResult -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta import json import os from typing import Any @@ -99,7 +99,7 @@ class DashboardValidationPlugin(PluginBase): # @SIDE_EFFECT Captures a screenshot, calls LLM API, and writes to the database. async def execute(self, params: dict[str, Any], context: TaskContext | None = None): with belief_scope("execute", f"plugin_id={self.id}"): - validation_started_at = datetime.utcnow() + validation_started_at = datetime.now(UTC) # Use TaskContext logger if available, otherwise fall back to app logger log = context.logger if context else logger @@ -165,15 +165,15 @@ class DashboardValidationPlugin(PluginBase): filename = f"{dashboard_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" screenshot_path = os.path.join(screenshots_dir, filename) - screenshot_started_at = datetime.utcnow() + screenshot_started_at = datetime.now(UTC) screenshot_log.info(f"Capturing screenshot for dashboard {dashboard_id}") await screenshot_service.capture_dashboard(dashboard_id, screenshot_path) screenshot_log.debug(f"Screenshot saved to: {screenshot_path}") - screenshot_finished_at = datetime.utcnow() + screenshot_finished_at = datetime.now(UTC) # 4. Fetch Logs (from Environment /api/v1/log/) logs = [] - logs_fetch_started_at = datetime.utcnow() + logs_fetch_started_at = datetime.now(UTC) try: client = SupersetClient(env) @@ -214,7 +214,7 @@ class DashboardValidationPlugin(PluginBase): except Exception as e: superset_log.warning(f"Failed to fetch logs from environment: {e}") logs = [f"Error fetching remote logs: {e!s}"] - logs_fetch_finished_at = datetime.utcnow() + logs_fetch_finished_at = datetime.now(UTC) # 5. Analyze with LLM llm_client = LLMClient( @@ -230,13 +230,13 @@ class DashboardValidationPlugin(PluginBase): "dashboard_validation_prompt", DEFAULT_LLM_PROMPTS["dashboard_validation_prompt"], ) - llm_call_started_at = datetime.utcnow() + llm_call_started_at = datetime.now(UTC) analysis = await llm_client.analyze_dashboard( screenshot_path, logs, prompt_template=dashboard_prompt, ) - llm_call_finished_at = datetime.utcnow() + llm_call_finished_at = datetime.now(UTC) # Log analysis summary to task logs for better visibility llm_log.info(f"[ANALYSIS_SUMMARY] Status: {analysis['status']}") @@ -254,9 +254,9 @@ class DashboardValidationPlugin(PluginBase): screenshot_path=screenshot_path, raw_response=str(analysis) ) - validation_finished_at = datetime.utcnow() + validation_finished_at = datetime.now(UTC) - result_payload = _json_safe_value(validation_result.dict()) + result_payload = _json_safe_value(validation_result.model_dump()) result_payload["screenshot_paths"] = [screenshot_path] result_payload["logs_sent_to_llm"] = logs result_payload["logs_sent_count"] = len(logs) @@ -291,7 +291,7 @@ class DashboardValidationPlugin(PluginBase): environment_id=env_id, status=validation_result.status.value, summary=validation_result.summary, - issues=[issue.dict() for issue in validation_result.issues], + issues=[issue.model_dump() for issue in validation_result.issues], screenshot_path=validation_result.screenshot_path, raw_response=json.dumps(result_payload, ensure_ascii=False) ) diff --git a/backend/src/plugins/llm_analysis/service.py b/backend/src/plugins/llm_analysis/service.py index 4a5e1213..d5cd0811 100644 --- a/backend/src/plugins/llm_analysis/service.py +++ b/backend/src/plugins/llm_analysis/service.py @@ -842,7 +842,7 @@ class ScreenshotService: await asyncio.sleep(0.5) except Exception: pass - except asyncio.TimeoutError: + except TimeoutError: logger.error(f"Screenshot capture timed out after {SCREENSHOT_SERVICE_TIMEOUT_MS}ms") try: await page.screenshot(path=output_path, timeout=PLAYWRIGHT_SHORT_TIMEOUT_MS) diff --git a/backend/src/plugins/translate/__tests__/test_clickhouse_insert_integration.py b/backend/src/plugins/translate/__tests__/test_clickhouse_insert_integration.py index d10a6cfe..951bfe2a 100644 --- a/backend/src/plugins/translate/__tests__/test_clickhouse_insert_integration.py +++ b/backend/src/plugins/translate/__tests__/test_clickhouse_insert_integration.py @@ -12,7 +12,6 @@ # @TEST_EDGE non_timestamp_string -> passed through unchanged import logging - from unittest.mock import MagicMock, patch from src.plugins.translate.sql_generator import ( diff --git a/backend/src/plugins/translate/__tests__/test_dictionary.py b/backend/src/plugins/translate/__tests__/test_dictionary.py index 54f86350..b7aa886e 100644 --- a/backend/src/plugins/translate/__tests__/test_dictionary.py +++ b/backend/src/plugins/translate/__tests__/test_dictionary.py @@ -11,10 +11,10 @@ # @REJECTED Keeping 1199-line test module violates module < 400 lines constraint. # All tests have been migrated to the following modules: -from .test_dictionary_correction import * # noqa: F401, F403 -from .test_dictionary_crud import * # noqa: F401, F403 -from .test_dictionary_filter import * # noqa: F401, F403 -from .test_dictionary_import import * # noqa: F401, F403 -from .test_dictionary_prompt_builder import * # noqa: F401, F403 -from .test_dictionary_utils import * # noqa: F401, F403 +from .test_dictionary_correction import * # noqa: F403 +from .test_dictionary_crud import * # noqa: F403 +from .test_dictionary_filter import * # noqa: F403 +from .test_dictionary_import import * # noqa: F403 +from .test_dictionary_prompt_builder import * # noqa: F403 +from .test_dictionary_utils import * # noqa: F403 # #endregion TestDictionaryLegacyHub diff --git a/backend/src/plugins/translate/__tests__/test_dictionary_correction.py b/backend/src/plugins/translate/__tests__/test_dictionary_correction.py index a05441b8..a23a0b5e 100644 --- a/backend/src/plugins/translate/__tests__/test_dictionary_correction.py +++ b/backend/src/plugins/translate/__tests__/test_dictionary_correction.py @@ -10,7 +10,6 @@ from sqlalchemy.orm import Session, sessionmaker from src.models.translate import ( Base, DictionaryEntry, - TerminologyDictionary, TranslationBatch, TranslationJob, TranslationLanguage, diff --git a/backend/src/plugins/translate/__tests__/test_dictionary_crud.py b/backend/src/plugins/translate/__tests__/test_dictionary_crud.py index a5245fb8..a748d7fb 100644 --- a/backend/src/plugins/translate/__tests__/test_dictionary_crud.py +++ b/backend/src/plugins/translate/__tests__/test_dictionary_crud.py @@ -12,7 +12,7 @@ import pytest from sqlalchemy import create_engine, event from sqlalchemy.orm import Session, sessionmaker -from src.models.translate import Base, DictionaryEntry, TerminologyDictionary, TranslationJob, TranslationJobDictionary +from src.models.translate import Base, TranslationJob, TranslationJobDictionary from src.plugins.translate.dictionary import DictionaryManager diff --git a/backend/src/plugins/translate/__tests__/test_executor.py b/backend/src/plugins/translate/__tests__/test_executor.py index 3dbd8eaa..29324050 100644 --- a/backend/src/plugins/translate/__tests__/test_executor.py +++ b/backend/src/plugins/translate/__tests__/test_executor.py @@ -14,7 +14,6 @@ from src.models.translate import ( TranslationJob, TranslationRun, ) -from src.plugins.translate._batch_sizer import AdaptiveBatchSizer from src.plugins.translate.executor import TranslationExecutor, estimate_row_tokens diff --git a/backend/src/plugins/translate/__tests__/test_inline_correction.py b/backend/src/plugins/translate/__tests__/test_inline_correction.py index 0cd461fb..a15e950b 100644 --- a/backend/src/plugins/translate/__tests__/test_inline_correction.py +++ b/backend/src/plugins/translate/__tests__/test_inline_correction.py @@ -298,9 +298,7 @@ class TestBulkFindReplaceService: from unittest.mock import MagicMock -from src.models.translate import TranslationLanguage, TranslationRecord from src.plugins.translate.prompt_builder import ContextAwarePromptBuilder -from src.plugins.translate.service import InlineCorrectionService # region TestContextCapture [TYPE Class] diff --git a/backend/src/plugins/translate/__tests__/test_orthogonal_fixes.py b/backend/src/plugins/translate/__tests__/test_orthogonal_fixes.py index 607f5a06..7c70f8d5 100644 --- a/backend/src/plugins/translate/__tests__/test_orthogonal_fixes.py +++ b/backend/src/plugins/translate/__tests__/test_orthogonal_fixes.py @@ -16,7 +16,7 @@ from datetime import UTC, datetime import pytest -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import MagicMock, patch from src.models.translate import ( TranslationJob, diff --git a/backend/src/plugins/translate/__tests__/test_target_schema.py b/backend/src/plugins/translate/__tests__/test_target_schema.py index 3210b16d..07fb0a51 100644 --- a/backend/src/plugins/translate/__tests__/test_target_schema.py +++ b/backend/src/plugins/translate/__tests__/test_target_schema.py @@ -18,7 +18,6 @@ from src.plugins.translate.service_target_schema import ( validate_target_table_schema, ) from src.schemas.translate import ( - TargetSchemaColumnInfo, TargetSchemaValidationRequest, ) diff --git a/backend/src/plugins/translate/_batch_insert.py b/backend/src/plugins/translate/_batch_insert.py index c42ce9ff..96fa043a 100644 --- a/backend/src/plugins/translate/_batch_insert.py +++ b/backend/src/plugins/translate/_batch_insert.py @@ -13,7 +13,6 @@ # @REJECTED Inline insert logic in process_batch — made the function 583 lines. import json -from typing import Any from sqlalchemy.orm import Session, joinedload diff --git a/backend/src/plugins/translate/_batch_proc.py b/backend/src/plugins/translate/_batch_proc.py index 13472654..75c48e2f 100644 --- a/backend/src/plugins/translate/_batch_proc.py +++ b/backend/src/plugins/translate/_batch_proc.py @@ -24,7 +24,7 @@ from ...core.logger import belief_scope, logger from ...models.translate import TranslationBatch, TranslationJob, TranslationLanguage, TranslationRecord from ...services.llm_provider import LLMProviderService from ._batch_insert import insert_batch_to_target -from ._lang_detect import batch_detect, get_detector +from ._lang_detect import batch_detect from ._llm_call import LLMTranslationService from ._token_budget import estimate_token_budget from ._utils import _check_translation_cache, _compute_key_hash, _compute_source_hash diff --git a/backend/src/plugins/translate/_batch_sizer.py b/backend/src/plugins/translate/_batch_sizer.py index 4d9b348b..688b89c5 100644 --- a/backend/src/plugins/translate/_batch_sizer.py +++ b/backend/src/plugins/translate/_batch_sizer.py @@ -11,8 +11,6 @@ # @REJECTED Fixed batch_size of 50 — causes truncation on long-content rows. # Single monolithic batch — would lose all progress on any failure. -import time -from typing import Any from sqlalchemy.orm import Session diff --git a/backend/src/plugins/translate/_llm_call.py b/backend/src/plugins/translate/_llm_call.py index 0905e1bb..54783f69 100644 --- a/backend/src/plugins/translate/_llm_call.py +++ b/backend/src/plugins/translate/_llm_call.py @@ -30,7 +30,6 @@ from ...services.llm_prompt_templates import render_prompt from ...services.llm_provider import LLMProviderService from ._llm_http import call_openai_compatible from ._llm_parse import parse_llm_response -from ._token_budget import estimate_token_budget from ._utils import _enforce_dictionary from .preview import DEFAULT_EXECUTION_PROMPT_TEMPLATE from .prompt_builder import ContextAwarePromptBuilder diff --git a/backend/src/plugins/translate/_llm_http.py b/backend/src/plugins/translate/_llm_http.py index 4b13086a..869215da 100644 --- a/backend/src/plugins/translate/_llm_http.py +++ b/backend/src/plugins/translate/_llm_http.py @@ -35,7 +35,6 @@ def call_openai_compatible( if not base_url: raise ValueError("LLM provider has no base_url configured") - import requests as http_requests url = f"{base_url.rstrip('/')}/chat/completions" headers = { diff --git a/backend/src/plugins/translate/_llm_parse.py b/backend/src/plugins/translate/_llm_parse.py index 680afd99..069f4c40 100644 --- a/backend/src/plugins/translate/_llm_parse.py +++ b/backend/src/plugins/translate/_llm_parse.py @@ -8,7 +8,6 @@ import json import re -from typing import Any from ...core.logger import logger diff --git a/backend/src/plugins/translate/_run_service.py b/backend/src/plugins/translate/_run_service.py index 3fa119cd..0cbd37c5 100644 --- a/backend/src/plugins/translate/_run_service.py +++ b/backend/src/plugins/translate/_run_service.py @@ -12,15 +12,13 @@ # @REJECTED Keeping all run orchestration inside TranslationExecutor — caused class to exceed INV_7. from collections.abc import Callable -from datetime import UTC, datetime from typing import Any from sqlalchemy.orm import Session from ...core.config_manager import ConfigManager from ...core.logger import belief_scope, logger -from ...models.translate import TranslationJob, TranslationRecord, TranslationRun, TranslationRunLanguageStats -from ._batch_sizer import AdaptiveBatchSizer +from ...models.translate import TranslationRecord, TranslationRun, TranslationRunLanguageStats from ._run_source import _extract_chart_data_rows, fetch_source_rows @@ -97,7 +95,7 @@ class RunExecutionService: def _load_preview_edits(self, job_id: str) -> None: """Load preview edits for carry-forward during execution.""" - from ...models.translate import TranslationPreviewLanguage, TranslationPreviewRecord, TranslationPreviewSession + from ...models.translate import TranslationPreviewRecord, TranslationPreviewSession with belief_scope("RunExecutionService._load_preview_edits"): session = ( diff --git a/backend/src/plugins/translate/_utils.py b/backend/src/plugins/translate/_utils.py index fd5dc6cb..825c5c4f 100644 --- a/backend/src/plugins/translate/_utils.py +++ b/backend/src/plugins/translate/_utils.py @@ -17,7 +17,7 @@ import unicodedata from sqlalchemy.orm import Session, joinedload from ...core.logger import logger -from ...models.translate import TranslationLanguage, TranslationRecord +from ...models.translate import TranslationRecord # #region _normalize_term [TYPE Function] diff --git a/backend/src/plugins/translate/dictionary.py b/backend/src/plugins/translate/dictionary.py index 5e1e77a0..b6321a1c 100644 --- a/backend/src/plugins/translate/dictionary.py +++ b/backend/src/plugins/translate/dictionary.py @@ -12,18 +12,13 @@ # @PRE Database session is open and valid. # @SIDE_EFFECT Creates, updates, deletes TerminologyDictionary and DictionaryEntry rows; enforces deletion guards. -from typing import Any -from sqlalchemy.orm import Session -from ...core.logger import belief_scope -from ...models.translate import DictionaryEntry, TerminologyDictionary from .dictionary_correction import DictionaryCorrectionService from .dictionary_crud import DictionaryCRUD from .dictionary_entries import DictionaryEntryCRUD from .dictionary_filter import DictionaryBatchFilter from .dictionary_import_export import DictionaryImportExport -from .dictionary_validation import _validate_bcp47 # #region DictionaryManager [C:4] [TYPE Class] diff --git a/backend/src/plugins/translate/executor.py b/backend/src/plugins/translate/executor.py index ca96ef6c..1d51db0a 100644 --- a/backend/src/plugins/translate/executor.py +++ b/backend/src/plugins/translate/executor.py @@ -28,12 +28,14 @@ from ...core.logger import logger from ...models.translate import TranslationJob, TranslationRun, TranslationRunLanguageStats from ...services.llm_provider import LLMProviderService from ._run_service import RunExecutionService -from ._token_budget import estimate_token_budget from ._utils import _check_translation_cache, _compute_source_hash, _enforce_dictionary, estimate_row_tokens __all__ = [ - "TranslationExecutor", "estimate_row_tokens", - "_enforce_dictionary", "_compute_source_hash", "_check_translation_cache", + "TranslationExecutor", + "_check_translation_cache", + "_compute_source_hash", + "_enforce_dictionary", + "estimate_row_tokens", ] diff --git a/backend/src/plugins/translate/metrics.py b/backend/src/plugins/translate/metrics.py index fb2807cc..5a9e5289 100644 --- a/backend/src/plugins/translate/metrics.py +++ b/backend/src/plugins/translate/metrics.py @@ -11,7 +11,6 @@ # @REJECTED Querying all events for all time — would be slow; MetricSnapshot provides historical summary. # @COMPLEXITY 4 -from datetime import UTC, datetime from typing import Any from sqlalchemy import func @@ -20,7 +19,6 @@ from sqlalchemy.orm import Session from ...core.logger import belief_scope from ...models.translate import ( MetricSnapshot, - TranslationEvent, TranslationRun, TranslationRunLanguageStats, TranslationSchedule, diff --git a/backend/src/plugins/translate/orchestrator.py b/backend/src/plugins/translate/orchestrator.py index 29ec10c7..5604bad9 100644 --- a/backend/src/plugins/translate/orchestrator.py +++ b/backend/src/plugins/translate/orchestrator.py @@ -23,12 +23,11 @@ from typing import Any from sqlalchemy.orm import Session from ...core.config_manager import ConfigManager -from ...core.logger import belief_scope, logger +from ...core.logger import belief_scope from ...models.translate import ( TranslationRun, ) from .events import TranslationEventLog -from .executor import TranslationExecutor from .orchestrator_aggregator import TranslationResultAggregator from .orchestrator_planner import TranslationPlanner from .orchestrator_runner import TranslationStageRunner diff --git a/backend/src/plugins/translate/orchestrator_lang_stats.py b/backend/src/plugins/translate/orchestrator_lang_stats.py index 49e5546c..2f74ee9b 100644 --- a/backend/src/plugins/translate/orchestrator_lang_stats.py +++ b/backend/src/plugins/translate/orchestrator_lang_stats.py @@ -6,7 +6,6 @@ # @RELATION DEPENDS_ON -> [TranslationRunLanguageStats] from collections import defaultdict -from typing import Any from sqlalchemy.orm import Session diff --git a/backend/src/plugins/translate/orchestrator_planner.py b/backend/src/plugins/translate/orchestrator_planner.py index 40e059d0..d5ce3188 100644 --- a/backend/src/plugins/translate/orchestrator_planner.py +++ b/backend/src/plugins/translate/orchestrator_planner.py @@ -19,7 +19,6 @@ from sqlalchemy.orm import Session from ...core.logger import belief_scope, logger from ...models.translate import ( TranslationJob, - TranslationPreviewSession, TranslationRun, ) from .events import TranslationEventLog diff --git a/backend/src/plugins/translate/orchestrator_run_completion.py b/backend/src/plugins/translate/orchestrator_run_completion.py index 88adf89a..6f25fe6e 100644 --- a/backend/src/plugins/translate/orchestrator_run_completion.py +++ b/backend/src/plugins/translate/orchestrator_run_completion.py @@ -7,7 +7,6 @@ # @RELATION DEPENDS_ON -> [TranslationEventLog] from datetime import UTC, datetime -from typing import Any from ...core.logger import logger from ...models.translate import TranslationJob, TranslationRun, TranslationRunLanguageStats diff --git a/backend/src/plugins/translate/orchestrator_sql_rows.py b/backend/src/plugins/translate/orchestrator_sql_rows.py index 237ed705..f064daeb 100644 --- a/backend/src/plugins/translate/orchestrator_sql_rows.py +++ b/backend/src/plugins/translate/orchestrator_sql_rows.py @@ -4,7 +4,6 @@ # @RELATION DEPENDS_ON -> [TranslationJob] import json -from typing import Any from ...models.translate import TranslationJob, TranslationRecord from .sql_generator import _normalize_timestamp_value diff --git a/backend/src/plugins/translate/preview.py b/backend/src/plugins/translate/preview.py index f60b2676..3dae82c4 100644 --- a/backend/src/plugins/translate/preview.py +++ b/backend/src/plugins/translate/preview.py @@ -40,7 +40,6 @@ from .preview_response_parser import ( parse_llm_response as _parse_llm_response_module, ) from .preview_review import PreviewSessionManager -from .preview_token_estimator import TokenEstimator # #region TranslationPreview [C:4] [TYPE Class] @@ -272,5 +271,4 @@ class TranslationPreview: # #endregion TranslationPreview # Re-export for backward compatibility -from .preview_token_estimator import TokenEstimator # noqa: E402, F401 # #endregion TranslationPreview diff --git a/backend/src/plugins/translate/preview_prompt_helpers.py b/backend/src/plugins/translate/preview_prompt_helpers.py index 009e72d0..aebe20b3 100644 --- a/backend/src/plugins/translate/preview_prompt_helpers.py +++ b/backend/src/plugins/translate/preview_prompt_helpers.py @@ -5,8 +5,6 @@ from typing import Any -from sqlalchemy.orm import Session - from ...core.logger import logger from ...models.translate import TranslationJob from ._token_budget import DEFAULT_CONTEXT_WINDOW, estimate_token_budget diff --git a/backend/src/plugins/translate/preview_review.py b/backend/src/plugins/translate/preview_review.py index f63558b3..342eb448 100644 --- a/backend/src/plugins/translate/preview_review.py +++ b/backend/src/plugins/translate/preview_review.py @@ -10,9 +10,8 @@ from typing import Any from sqlalchemy.orm import Session -from ...core.logger import belief_scope, logger +from ...core.logger import belief_scope from ...models.translate import ( - TranslationJob, TranslationPreviewLanguage, TranslationPreviewRecord, TranslationPreviewSession, diff --git a/backend/src/plugins/translate/service.py b/backend/src/plugins/translate/service.py index 28aef1ad..6bba804c 100644 --- a/backend/src/plugins/translate/service.py +++ b/backend/src/plugins/translate/service.py @@ -12,7 +12,6 @@ # @REJECTED Monolithic service.py — violated INV_7. Decomposed into service_datasource, service_inline_correction, service_bulk_replace, service_utils. from datetime import UTC, datetime -from typing import Any import uuid from sqlalchemy.orm import Session @@ -21,9 +20,9 @@ from ...core.config_manager import ConfigManager from ...core.logger import logger from ...models.translate import TranslationJob, TranslationJobDictionary from ...schemas.translate import TranslateJobCreate, TranslateJobUpdate -from .dictionary import _validate_bcp47 +from .dictionary_validation import _validate_bcp47 from .service_datasource import fetch_datasource_metadata -from .service_utils import _extract_dialect, job_to_response +from .service_utils import _extract_dialect # #region TranslateJobService [TYPE Class] @@ -267,10 +266,8 @@ class TranslateJobService: from .service_bulk_replace import BulkFindReplaceService # noqa: E402, F401 from .service_datasource import ( # noqa: E402, F401 detect_virtual_columns, - fetch_datasource_metadata, get_datasource_columns, get_dialect_from_database, ) from .service_inline_correction import InlineCorrectionService # noqa: E402, F401 -from .service_utils import _extract_dialect, job_to_response # noqa: E402, F401 # #endregion TranslateJobService diff --git a/backend/src/plugins/translate/service_inline_correction.py b/backend/src/plugins/translate/service_inline_correction.py index a1c13542..f94038c9 100644 --- a/backend/src/plugins/translate/service_inline_correction.py +++ b/backend/src/plugins/translate/service_inline_correction.py @@ -8,7 +8,6 @@ # @RELATION DEPENDS_ON -> [DictionaryEntry] # @RELATION DEPENDS_ON -> [_normalize_term] -from typing import Any from sqlalchemy.orm import Session diff --git a/backend/src/plugins/translate/service_utils.py b/backend/src/plugins/translate/service_utils.py index a944d907..a26972d7 100644 --- a/backend/src/plugins/translate/service_utils.py +++ b/backend/src/plugins/translate/service_utils.py @@ -1,7 +1,6 @@ # #region ServiceUtils [C:1] [TYPE Module] [SEMANTICS utils, dialect, response] # @BRIEF Utility functions for the translate service layer. -from typing import Any from ...models.translate import TranslationJob from ...schemas.translate import TranslateJobResponse diff --git a/backend/src/schemas/auth.py b/backend/src/schemas/auth.py index 23706f55..db08ce0e 100644 --- a/backend/src/schemas/auth.py +++ b/backend/src/schemas/auth.py @@ -7,10 +7,10 @@ # @INVARIANT Sensitive fields like password must not be included in response schemas. # @DATA_CONTRACT AuthPayload -> AuthSchema -import re from datetime import datetime +import re -from pydantic import BaseModel, EmailStr, Field, field_validator +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator # #region Token [C:1] [TYPE Class] @@ -40,8 +40,7 @@ class PermissionSchema(BaseModel): resource: str action: str - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion PermissionSchema @@ -55,8 +54,7 @@ class RoleSchema(BaseModel): description: str | None = None permissions: list[PermissionSchema] = [] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion RoleSchema @@ -91,8 +89,7 @@ class ADGroupMappingSchema(BaseModel): ad_group: str role_id: str - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ADGroupMappingSchema @@ -176,8 +173,7 @@ class User(UserBase): last_login: datetime | None = None roles: list[RoleSchema] = [] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion User diff --git a/backend/src/schemas/dataset_review_pkg/_composites.py b/backend/src/schemas/dataset_review_pkg/_composites.py index 3b335f05..05c5b2a2 100644 --- a/backend/src/schemas/dataset_review_pkg/_composites.py +++ b/backend/src/schemas/dataset_review_pkg/_composites.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from src.models.dataset_review import ( AnswerKind, @@ -41,8 +41,7 @@ class ClarificationOptionDto(BaseModel): is_recommended: bool display_order: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ClarificationOptionDto @@ -60,8 +59,7 @@ class ClarificationAnswerDto(BaseModel): user_feedback: str | None = None created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ClarificationAnswerDto @@ -83,8 +81,7 @@ class ClarificationQuestionDto(BaseModel): options: list[ClarificationOptionDto] = [] answer: ClarificationAnswerDto | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ClarificationQuestionDto @@ -105,8 +102,7 @@ class ClarificationSessionDto(BaseModel): completed_at: datetime | None = None questions: list[ClarificationQuestionDto] = [] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ClarificationSessionDto @@ -127,8 +123,7 @@ class CompiledPreviewDto(BaseModel): compiled_at: datetime | None = None created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion CompiledPreviewDto @@ -153,8 +148,7 @@ class DatasetRunContextDto(BaseModel): launch_error: str | None = None created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion DatasetRunContextDto @@ -180,8 +174,7 @@ class SessionSummary(BaseModel): updated_at: datetime last_activity_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion SessionSummary diff --git a/backend/src/schemas/dataset_review_pkg/_dtos.py b/backend/src/schemas/dataset_review_pkg/_dtos.py index 4e63bd83..6e75012f 100644 --- a/backend/src/schemas/dataset_review_pkg/_dtos.py +++ b/backend/src/schemas/dataset_review_pkg/_dtos.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from src.models.dataset_review import ( ApprovalState, @@ -40,8 +40,7 @@ class SessionCollaboratorDto(BaseModel): role: SessionCollaboratorRole added_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion SessionCollaboratorDto @@ -68,8 +67,7 @@ class DatasetProfileDto(BaseModel): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion DatasetProfileDto @@ -91,8 +89,7 @@ class ValidationFindingDto(BaseModel): created_at: datetime resolved_at: datetime | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ValidationFindingDto @@ -112,8 +109,7 @@ class SemanticSourceDto(BaseModel): status: SemanticSourceStatus created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion SemanticSourceDto @@ -134,8 +130,7 @@ class SemanticCandidateDto(BaseModel): status: CandidateStatus created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion SemanticCandidateDto @@ -165,8 +160,7 @@ class SemanticFieldEntryDto(BaseModel): updated_at: datetime candidates: list[SemanticCandidateDto] = [] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion SemanticFieldEntryDto @@ -190,8 +184,7 @@ class ImportedFilterDto(BaseModel): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ImportedFilterDto @@ -211,8 +204,7 @@ class TemplateVariableDto(BaseModel): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion TemplateVariableDto @@ -238,8 +230,7 @@ class ExecutionMappingDto(BaseModel): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ExecutionMappingDto diff --git a/backend/src/schemas/profile.py b/backend/src/schemas/profile.py index 3ecbffb6..e8fad9d3 100644 --- a/backend/src/schemas/profile.py +++ b/backend/src/schemas/profile.py @@ -10,7 +10,7 @@ from datetime import datetime from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field # #region ProfilePermissionState [C:3] [TYPE Class] @@ -65,8 +65,7 @@ class ProfilePreference(BaseModel): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ProfilePreference diff --git a/backend/src/schemas/settings.py b/backend/src/schemas/settings.py index 1ff6382c..3fb4932c 100644 --- a/backend/src/schemas/settings.py +++ b/backend/src/schemas/settings.py @@ -5,7 +5,7 @@ from datetime import datetime, time -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field # #region NotificationChannel [TYPE Class] @@ -86,8 +86,7 @@ class ValidationPolicyResponse(ValidationPolicyBase): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ValidationPolicyResponse @@ -108,8 +107,7 @@ class TranslationScheduleItem(BaseModel): created_by: str | None = None created_at: datetime | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion TranslationScheduleItem diff --git a/backend/src/schemas/translate.py b/backend/src/schemas/translate.py index 18ff1e42..02335ba6 100644 --- a/backend/src/schemas/translate.py +++ b/backend/src/schemas/translate.py @@ -7,7 +7,7 @@ from datetime import datetime import re from typing import Any -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator def _validate_bcp47_list(v: list[str] | None) -> list[str] | None: @@ -129,8 +129,7 @@ class TranslateJobResponse(BaseModel): environment_id: str | None = None target_database_id: str | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion TranslateJobResponse @@ -154,8 +153,7 @@ class DatasourceColumnsResponse(BaseModel): database_dialect: str columns: list[DatasourceColumnResponse] = [] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion DatasourceColumnsResponse @@ -166,8 +164,7 @@ class DuplicateJobResponse(BaseModel): name: str message: str = "Job duplicated successfully" - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion DuplicateJobResponse @@ -208,8 +205,7 @@ class DictionaryResponse(BaseModel): updated_at: datetime | None = None entry_count: int | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion DictionaryResponse @@ -222,8 +218,7 @@ class DictionaryEntryCreate(BaseModel): source_language: str = Field("und", description="BCP-47 source language code (default: und)") target_language: str = Field("und", description="BCP-47 target language code (default: und)") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion DictionaryEntryCreate @@ -246,8 +241,7 @@ class DictionaryEntryResponse(BaseModel): created_at: datetime updated_at: datetime | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion DictionaryEntryResponse @@ -294,8 +288,7 @@ class PreviewAcceptResponse(BaseModel): records: list['PreviewRow'] = [] target_languages: list[str] = Field(default_factory=list, description="Target languages for this preview") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion PreviewAcceptResponse @@ -385,8 +378,7 @@ class ScheduleResponse(BaseModel): created_at: datetime updated_at: datetime | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ScheduleResponse @@ -424,8 +416,7 @@ class TranslationRunResponse(BaseModel): created_at: datetime language_stats: list['TranslationRunLanguageStatsResponse'] | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion TranslationRunResponse @@ -495,8 +486,7 @@ class TranslationPreviewLanguageResponse(BaseModel): status: str = "pending" needs_review: bool = False - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion TranslationPreviewLanguageResponse @@ -531,8 +521,7 @@ class TranslationPreviewResponse(BaseModel): target_languages: list[str] = Field(default_factory=list, description="Target languages for this preview") cost_estimate: CostEstimate | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion TranslationPreviewResponse @@ -550,8 +539,7 @@ class TranslationBatchResponse(BaseModel): completed_at: datetime | None = None created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion TranslationBatchResponse @@ -571,8 +559,7 @@ class MetricsResponse(BaseModel): p95_duration_ms: int | None = None p99_duration_ms: int | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion MetricsResponse @@ -588,8 +575,7 @@ class TranslationLanguageResponse(BaseModel): needs_review: bool = False language_overridden: bool = False - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion TranslationLanguageResponse @@ -604,8 +590,7 @@ class TranslationRunLanguageStatsResponse(BaseModel): token_count: int = 0 estimated_cost: float = 0.0 - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion TranslationRunLanguageStatsResponse diff --git a/backend/src/schemas/validation.py b/backend/src/schemas/validation.py index 6fecbc94..478cdb7d 100644 --- a/backend/src/schemas/validation.py +++ b/backend/src/schemas/validation.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field # #region ValidationTaskCreate [C:1] [TYPE Class] @@ -63,8 +63,7 @@ class ValidationTaskResponse(BaseModel): last_run_status: str | None = None last_run_at: datetime | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ValidationTaskResponse @@ -92,8 +91,7 @@ class ValidationRunResponse(BaseModel): task_name: str | None = None # resolved from ValidationPolicy at query time issues_count: int = 0 # number of issues found during validation - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ValidationRunResponse @@ -122,8 +120,7 @@ class ValidationRunDetailResponse(BaseModel): raw_response: str | None = None task_name: str | None = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # #endregion ValidationRunDetailResponse diff --git a/backend/src/services/__tests__/test_health_service.py b/backend/src/services/__tests__/test_health_service.py index 211c3e78..ed49aa5d 100644 --- a/backend/src/services/__tests__/test_health_service.py +++ b/backend/src/services/__tests__/test_health_service.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta import pytest from unittest.mock import MagicMock, patch @@ -18,7 +18,7 @@ async def test_get_health_summary_aggregation(): # Setup: Mock DB session db = MagicMock() - now = datetime.utcnow() + now = datetime.now(UTC) # Dashboard 1: Old FAIL, New PASS rec1_old = ValidationRecord( @@ -104,7 +104,7 @@ async def test_get_health_summary_resolves_slug_and_title_from_superset(): dashboard_id="42", environment_id="env_1", status="PASS", - timestamp=datetime.utcnow(), + timestamp=datetime.now(UTC), summary="Healthy", issues=[], ) @@ -137,7 +137,7 @@ async def test_get_health_summary_reuses_dashboard_metadata_cache_across_service dashboard_id="42", environment_id="env_1", status="PASS", - timestamp=datetime.utcnow(), + timestamp=datetime.now(UTC), summary="Healthy", issues=[], ) @@ -177,7 +177,7 @@ def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks(): dashboard_id="42", environment_id="env_1", status="PASS", - timestamp=datetime.utcnow(), + timestamp=datetime.now(UTC), summary="Healthy", issues=[], screenshot_path=None, @@ -188,7 +188,7 @@ def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks(): dashboard_id="42", environment_id="env_1", status="FAIL", - timestamp=datetime.utcnow() - timedelta(hours=1), + timestamp=datetime.now(UTC) - timedelta(hours=1), summary="Older", issues=[], screenshot_path=None, @@ -199,7 +199,7 @@ def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks(): dashboard_id="42", environment_id="env_2", status="WARN", - timestamp=datetime.utcnow(), + timestamp=datetime.now(UTC), summary="Other environment", issues=[], screenshot_path=None, @@ -268,7 +268,7 @@ def test_delete_validation_report_swallows_linked_task_cleanup_failure(): dashboard_id="42", environment_id="env_1", status="PASS", - timestamp=datetime.utcnow(), + timestamp=datetime.now(UTC), summary="Healthy", issues=[], screenshot_path=None, diff --git a/backend/src/services/auth_service.py b/backend/src/services/auth_service.py index 76f2dec0..12abef1f 100644 --- a/backend/src/services/auth_service.py +++ b/backend/src/services/auth_service.py @@ -12,7 +12,7 @@ # @SIDE_EFFECT Writes last login timestamps and JIT-provisions external users. # @DATA_CONTRACT [Credentials | ADFSClaims] -> [UserEntity | SessionToken] -from datetime import datetime +from datetime import UTC, datetime from typing import Any from sqlalchemy.orm import Session @@ -66,7 +66,7 @@ class AuthService: return None # Update last login - user.last_login = datetime.utcnow() + user.last_login = datetime.now(UTC) self.db.commit() self.db.refresh(user) @@ -129,7 +129,7 @@ class AuthService: mapped_roles = self.repo.get_roles_by_ad_groups(groups) user.roles = mapped_roles - user.last_login = datetime.utcnow() + user.last_login = datetime.now(UTC) self.db.commit() self.db.refresh(user) diff --git a/backend/src/services/dataset_review/clarification_engine.py b/backend/src/services/dataset_review/clarification_engine.py index 972da119..15e032ec 100644 --- a/backend/src/services/dataset_review/clarification_engine.py +++ b/backend/src/services/dataset_review/clarification_engine.py @@ -18,7 +18,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime +from datetime import UTC, datetime from src.core.logger import belief_scope, logger from src.models.auth import User @@ -220,19 +220,19 @@ class ClarificationEngine: elif command.answer_kind == AnswerKind.EXPERT_REVIEW: question.state = QuestionState.EXPERT_REVIEW - question.updated_at = datetime.utcnow() + question.updated_at = datetime.now(UTC) self.repository.db.flush() clarification_session.resolved_count = count_resolved_questions(clarification_session) clarification_session.remaining_count = count_remaining_questions(clarification_session) clarification_session.summary_delta = self.summarize_progress(clarification_session) - clarification_session.updated_at = datetime.utcnow() + clarification_session.updated_at = datetime.now(UTC) next_question = select_next_open_question(clarification_session) clarification_session.current_question_id = next_question.question_id if next_question else None clarification_session.status = ClarificationStatus.ACTIVE if next_question else ClarificationStatus.COMPLETED if clarification_session.status == ClarificationStatus.COMPLETED: - clarification_session.completed_at = datetime.utcnow() + clarification_session.completed_at = datetime.now(UTC) session.readiness_state = derive_readiness_state(session, clarification_session) session.recommended_action = derive_recommended_action(session, clarification_session) diff --git a/backend/src/services/dataset_review/clarification_pkg/_helpers.py b/backend/src/services/dataset_review/clarification_pkg/_helpers.py index 64b42a95..fc288ed3 100644 --- a/backend/src/services/dataset_review/clarification_pkg/_helpers.py +++ b/backend/src/services/dataset_review/clarification_pkg/_helpers.py @@ -5,7 +5,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime import uuid from src.models.dataset_review import ( @@ -121,7 +121,7 @@ def upsert_clarification_finding( if answer_kind in {AnswerKind.SELECTED, AnswerKind.CUSTOM}: resolution_state = ResolutionState.RESOLVED - resolved_at = datetime.utcnow() + resolved_at = datetime.now(UTC) message = f"Clarified '{question.topic_ref}' with answer '{answer_value}'." elif answer_kind == AnswerKind.SKIPPED: resolution_state = ResolutionState.SKIPPED @@ -144,7 +144,7 @@ def upsert_clarification_finding( resolution_state=resolution_state, resolution_note=None, caused_by_ref=caused_by_ref, - created_at=datetime.utcnow(), + created_at=datetime.now(UTC), resolved_at=resolved_at, ) db_session.add(existing) diff --git a/backend/src/services/dataset_review/orchestrator.py b/backend/src/services/dataset_review/orchestrator.py index b6c7306f..7fd55723 100644 --- a/backend/src/services/dataset_review/orchestrator.py +++ b/backend/src/services/dataset_review/orchestrator.py @@ -18,7 +18,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from typing import Any, cast from src.core.config_manager import ConfigManager @@ -316,7 +316,7 @@ class DatasetReviewOrchestrator: ) session.current_phase = SessionPhase.PREVIEW - session.last_activity_at = datetime.utcnow() + session.last_activity_at = datetime.now(UTC) if persisted_preview.preview_status == PreviewStatus.READY: launch_blockers = build_launch_blockers(session=session, execution_snapshot=execution_snapshot, preview=persisted_preview) if launch_blockers: @@ -420,7 +420,7 @@ class DatasetReviewOrchestrator: ) session.current_phase = SessionPhase.LAUNCH - session.last_activity_at = datetime.utcnow() + session.last_activity_at = datetime.now(UTC) if launch_status == LaunchStatus.FAILED: session.readiness_state = ReadinessState.COMPILED_PREVIEW_READY session.recommended_action = RecommendedAction.LAUNCH_DATASET diff --git a/backend/src/services/dataset_review/repositories/session_repository.py b/backend/src/services/dataset_review/repositories/session_repository.py index 93517a2b..5693364a 100644 --- a/backend/src/services/dataset_review/repositories/session_repository.py +++ b/backend/src/services/dataset_review/repositories/session_repository.py @@ -14,7 +14,7 @@ # @RATIONALE Original 627-line file exceeded INV_7 (400-line module limit). Extracted mutation operations into _mutations sub-module. # @REJECTED Keeping all repository operations in one file because it exceeded the fractal limit. -from datetime import datetime +from datetime import UTC, datetime from typing import Any, cast from sqlalchemy import or_ @@ -130,7 +130,7 @@ class DatasetReviewSessionRepository: with belief_scope("DatasetReviewSessionRepository.bump_session_version"): next_version = int(getattr(session, "version", 0) or 0) + 1 session.version = next_version - session.last_activity_at = datetime.utcnow() + session.last_activity_at = datetime.now(UTC) logger.reflect("Prepared incremented session version", extra={"session_id": session.session_id, "version": next_version}) return next_version diff --git a/backend/src/services/git/_base.py b/backend/src/services/git/_base.py index a3b537ec..bed67fbb 100644 --- a/backend/src/services/git/_base.py +++ b/backend/src/services/git/_base.py @@ -266,32 +266,31 @@ class GitServiceBase: # @SIDE_EFFECT Clones remote repository; modifies local filesystem; protected by per-dashboard lock. # @RETURN Repo def init_repo(self, dashboard_id: int, remote_url: str, pat: str, repo_key: str | None = None, default_branch: str | None = None) -> Repo: - with self._locked(dashboard_id): - with belief_scope("GitService.init_repo"): - self._ensure_base_path_exists() - repo_path = self._get_repo_path(dashboard_id, repo_key=repo_key or str(dashboard_id)) - Path(repo_path).parent.mkdir(parents=True, exist_ok=True) - if os.path.exists(repo_path): - logger.reason(f"Opening existing repo at {repo_path}", extra={"src": "init_repo"}) - try: - repo = Repo(repo_path) - except (InvalidGitRepositoryError, NoSuchPathError): - logger.reason(f"Existing path is not a Git repository, recreating: {repo_path}", extra={"src": "init_repo"}) - stale_path = Path(repo_path) + with self._locked(dashboard_id), belief_scope("GitService.init_repo"): + self._ensure_base_path_exists() + repo_path = self._get_repo_path(dashboard_id, repo_key=repo_key or str(dashboard_id)) + Path(repo_path).parent.mkdir(parents=True, exist_ok=True) + if os.path.exists(repo_path): + logger.reason(f"Opening existing repo at {repo_path}", extra={"src": "init_repo"}) + try: + repo = Repo(repo_path) + except (InvalidGitRepositoryError, NoSuchPathError): + logger.reason(f"Existing path is not a Git repository, recreating: {repo_path}", extra={"src": "init_repo"}) + stale_path = Path(repo_path) + if stale_path.exists(): + shutil.rmtree(stale_path, ignore_errors=True) if stale_path.exists(): - shutil.rmtree(stale_path, ignore_errors=True) - if stale_path.exists(): - try: - stale_path.unlink() - except Exception: - pass - repo = self._clone_with_auth(remote_url, repo_path, pat) - self._ensure_gitflow_branches(repo, dashboard_id) - return repo - logger.reason(f"Cloning {remote_url} to {repo_path}", extra={"src": "init_repo"}) - repo = self._clone_with_auth(remote_url, repo_path, pat) + try: + stale_path.unlink() + except Exception: + pass + repo = self._clone_with_auth(remote_url, repo_path, pat) self._ensure_gitflow_branches(repo, dashboard_id) return repo + logger.reason(f"Cloning {remote_url} to {repo_path}", extra={"src": "init_repo"}) + repo = self._clone_with_auth(remote_url, repo_path, pat) + self._ensure_gitflow_branches(repo, dashboard_id) + return repo # endregion init_repo # region delete_repo [C:4] [TYPE Function] [SEMANTICS git,delete,lock] @@ -300,42 +299,41 @@ class GitServiceBase: # @POST Local path is deleted when present and GitRepository row is removed. # @SIDE_EFFECT Deletes filesystem directory and DB record; protected by per-dashboard lock. def delete_repo(self, dashboard_id: int) -> None: - with self._locked(dashboard_id): - with belief_scope("GitService.delete_repo"): - repo_path = self._get_repo_path(dashboard_id) - removed_files = False - if os.path.exists(repo_path): - if os.path.isdir(repo_path): - shutil.rmtree(repo_path) - else: - os.remove(repo_path) - removed_files = True - session = SessionLocal() - try: - db_repo = ( - session.query(GitRepository) - .filter(GitRepository.dashboard_id == int(dashboard_id)) - .first() - ) - if db_repo: - session.delete(db_repo) - session.commit() - return - if removed_files: - return - raise HTTPException( - status_code=404, - detail=f"Repository for dashboard {dashboard_id} not found", - ) - except HTTPException: - session.rollback() - raise - except Exception as e: - session.rollback() - logger.error(f"[delete_repo][Coherence:Failed] Failed to delete repository for dashboard {dashboard_id}: {e}") - raise HTTPException(status_code=500, detail=f"Failed to delete repository: {e!s}") - finally: - session.close() + with self._locked(dashboard_id), belief_scope("GitService.delete_repo"): + repo_path = self._get_repo_path(dashboard_id) + removed_files = False + if os.path.exists(repo_path): + if os.path.isdir(repo_path): + shutil.rmtree(repo_path) + else: + os.remove(repo_path) + removed_files = True + session = SessionLocal() + try: + db_repo = ( + session.query(GitRepository) + .filter(GitRepository.dashboard_id == int(dashboard_id)) + .first() + ) + if db_repo: + session.delete(db_repo) + session.commit() + return + if removed_files: + return + raise HTTPException( + status_code=404, + detail=f"Repository for dashboard {dashboard_id} not found", + ) + except HTTPException: + session.rollback() + raise + except Exception as e: + session.rollback() + logger.error(f"[delete_repo][Coherence:Failed] Failed to delete repository for dashboard {dashboard_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete repository: {e!s}") + finally: + session.close() # endregion delete_repo # region get_repo [C:4] [TYPE Function] [SEMANTICS git,open,lock] @@ -344,17 +342,16 @@ class GitServiceBase: # @POST Returns a GitPython Repo instance for the dashboard. # @RETURN Repo def get_repo(self, dashboard_id: int) -> Repo: - with self._locked(dashboard_id): - with belief_scope("GitService.get_repo"): - repo_path = self._get_repo_path(dashboard_id) - if not os.path.exists(repo_path): - logger.error(f"[EXT:method:get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist") - raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found") - try: - return Repo(repo_path) - except Exception as e: - logger.error(f"[EXT:method:get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}") - raise HTTPException(status_code=500, detail="Failed to open local Git repository") + with self._locked(dashboard_id), belief_scope("GitService.get_repo"): + repo_path = self._get_repo_path(dashboard_id) + if not os.path.exists(repo_path): + logger.error(f"[EXT:method:get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist") + raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found") + try: + return Repo(repo_path) + except Exception as e: + logger.error(f"[EXT:method:get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}") + raise HTTPException(status_code=500, detail="Failed to open local Git repository") # endregion get_repo # region configure_identity [C:4] [TYPE Function] [SEMANTICS git,identity,lock] @@ -363,21 +360,20 @@ class GitServiceBase: # @POST Repository config has user.name and user.email when both identity values are provided. # @SIDE_EFFECT Writes to repository git config; protected by per-dashboard lock. def configure_identity(self, dashboard_id: int, git_username: str | None, git_email: str | None) -> None: - with self._locked(dashboard_id): - with belief_scope("GitService.configure_identity"): - normalized_username = str(git_username or "").strip() - normalized_email = str(git_email or "").strip() - if not normalized_username or not normalized_email: - return - repo = self.get_repo(dashboard_id) - try: - with repo.config_writer(config_level="repository") as config_writer: - config_writer.set_value("user", "name", normalized_username) - config_writer.set_value("user", "email", normalized_email) - logger.reason(f"Applied repository-local git identity for dashboard {dashboard_id}", extra={"src": "configure_identity"}) - except Exception as e: - logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}") - raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {e!s}") + with self._locked(dashboard_id), belief_scope("GitService.configure_identity"): + normalized_username = str(git_username or "").strip() + normalized_email = str(git_email or "").strip() + if not normalized_username or not normalized_email: + return + repo = self.get_repo(dashboard_id) + try: + with repo.config_writer(config_level="repository") as config_writer: + config_writer.set_value("user", "name", normalized_username) + config_writer.set_value("user", "email", normalized_email) + logger.reason(f"Applied repository-local git identity for dashboard {dashboard_id}", extra={"src": "configure_identity"}) + except Exception as e: + logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}") + raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {e!s}") # endregion configure_identity # region close [C:2] [TYPE Function] [SEMANTICS http,cleanup,shutdown] diff --git a/backend/src/services/git/_branch.py b/backend/src/services/git/_branch.py index 9b8ef874..e611d82a 100644 --- a/backend/src/services/git/_branch.py +++ b/backend/src/services/git/_branch.py @@ -3,7 +3,7 @@ # @BRIEF Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes (all concurrent-safe via per-dashboard locks). # @RELATION CALLED_BY -> [GitService] -from datetime import datetime +from datetime import UTC, datetime import os from fastapi import HTTPException @@ -99,43 +99,42 @@ class GitServiceBranchMixin: # @POST Returns a list of branch metadata dictionaries (no tag refs). # @RETURN List[dict] def list_branches(self, dashboard_id: int) -> list[dict]: - with self._locked(dashboard_id): - with belief_scope("GitService.list_branches"): - repo = self.get_repo(dashboard_id) - logger.reason(f"Listing branches for {dashboard_id}. Refs: {repo.refs}", extra={"src": "list_branches"}) - branches = [] - for ref in repo.refs: - try: - # Fix 8: Skip tag refs - ref_name = str(ref.name) - if ref_name.startswith('refs/tags/'): - continue - name = ref_name.replace('refs/heads/', '').replace('refs/remotes/origin/', '') - if any(b['name'] == name for b in branches): - continue - branches.append({ - "name": name, - "commit_hash": ref.commit.hexsha if hasattr(ref, 'commit') else "0000000", - "is_remote": ref.is_remote() if hasattr(ref, 'is_remote') else False, - "last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow() - }) - except Exception as e: - logger.reason(f"Skipping ref {ref}: {e}", extra={"src": "list_branches"}) + with self._locked(dashboard_id), belief_scope("GitService.list_branches"): + repo = self.get_repo(dashboard_id) + logger.reason(f"Listing branches for {dashboard_id}. Refs: {repo.refs}", extra={"src": "list_branches"}) + branches = [] + for ref in repo.refs: try: - active_name = repo.active_branch.name - if not any(b['name'] == active_name for b in branches): - branches.append({ - "name": active_name, "commit_hash": "0000000", - "is_remote": False, "last_updated": datetime.utcnow() - }) + # Fix 8: Skip tag refs + ref_name = str(ref.name) + if ref_name.startswith('refs/tags/'): + continue + name = ref_name.replace('refs/heads/', '').replace('refs/remotes/origin/', '') + if any(b['name'] == name for b in branches): + continue + branches.append({ + "name": name, + "commit_hash": ref.commit.hexsha if hasattr(ref, 'commit') else "0000000", + "is_remote": ref.is_remote() if hasattr(ref, 'is_remote') else False, + "last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.now(UTC) + }) except Exception as e: - logger.reason(f"Could not determine active branch: {e}", extra={"src": "list_branches"}) - if not branches: - branches.append({ - "name": "dev", "commit_hash": "0000000", - "is_remote": False, "last_updated": datetime.utcnow() - }) - return branches + logger.reason(f"Skipping ref {ref}: {e}", extra={"src": "list_branches"}) + try: + active_name = repo.active_branch.name + if not any(b['name'] == active_name for b in branches): + branches.append({ + "name": active_name, "commit_hash": "0000000", + "is_remote": False, "last_updated": datetime.now(UTC) + }) + except Exception as e: + logger.reason(f"Could not determine active branch: {e}", extra={"src": "list_branches"}) + if not branches: + branches.append({ + "name": "dev", "commit_hash": "0000000", + "is_remote": False, "last_updated": datetime.now(UTC) + }) + return branches # endregion list_branches # region create_branch [C:4] [TYPE Function] [SEMANTICS git,branch,create,lock] diff --git a/backend/src/services/git/_gitea.py b/backend/src/services/git/_gitea.py index 24c7ea1f..6d61d800 100644 --- a/backend/src/services/git/_gitea.py +++ b/backend/src/services/git/_gitea.py @@ -7,7 +7,6 @@ from typing import Any from urllib.parse import quote from fastapi import HTTPException -import httpx from src.core.logger import belief_scope, logger from src.models.git import GitProvider diff --git a/backend/src/services/git/_merge.py b/backend/src/services/git/_merge.py index 05f86cf7..08d67fc7 100644 --- a/backend/src/services/git/_merge.py +++ b/backend/src/services/git/_merge.py @@ -241,61 +241,60 @@ class GitServiceMergeMixin: # @SIDE_EFFECT Changes local branch state during merge; restores original branch in finally block. # @RETURN Dict[str, Any] def promote_direct_merge(self, dashboard_id: int, from_branch: str, to_branch: str) -> dict[str, Any]: - with self._locked(dashboard_id): - with belief_scope("GitService.promote_direct_merge"): - if not from_branch or not to_branch: - raise HTTPException(status_code=400, detail="from_branch and to_branch are required") - repo = self.get_repo(dashboard_id) - source = from_branch.strip() - target = to_branch.strip() - if source == target: - raise HTTPException(status_code=400, detail="from_branch and to_branch must be different") - try: - origin = repo.remote(name="origin") - except ValueError: - raise HTTPException(status_code=400, detail="Remote 'origin' not configured") + with self._locked(dashboard_id), belief_scope("GitService.promote_direct_merge"): + if not from_branch or not to_branch: + raise HTTPException(status_code=400, detail="from_branch and to_branch are required") + repo = self.get_repo(dashboard_id) + source = from_branch.strip() + target = to_branch.strip() + if source == target: + raise HTTPException(status_code=400, detail="from_branch and to_branch must be different") + try: + origin = repo.remote(name="origin") + except ValueError: + raise HTTPException(status_code=400, detail="Remote 'origin' not configured") - # Remember original branch for restoration in finally + # Remember original branch for restoration in finally + original_branch = None + try: + original_branch = repo.active_branch.name + except Exception: original_branch = None - try: - original_branch = repo.active_branch.name - except Exception: - original_branch = None - try: - origin.fetch() - if source not in [head.name for head in repo.heads]: - if f"origin/{source}" in [ref.name for ref in repo.refs]: - repo.git.checkout("-b", source, f"origin/{source}") - else: - raise HTTPException(status_code=404, detail=f"Source branch '{source}' not found") - if target in [head.name for head in repo.heads]: - repo.git.checkout(target) - elif f"origin/{target}" in [ref.name for ref in repo.refs]: - repo.git.checkout("-b", target, f"origin/{target}") + try: + origin.fetch() + if source not in [head.name for head in repo.heads]: + if f"origin/{source}" in [ref.name for ref in repo.refs]: + repo.git.checkout("-b", source, f"origin/{source}") else: - raise HTTPException(status_code=404, detail=f"Target branch '{target}' not found") + raise HTTPException(status_code=404, detail=f"Source branch '{source}' not found") + if target in [head.name for head in repo.heads]: + repo.git.checkout(target) + elif f"origin/{target}" in [ref.name for ref in repo.refs]: + repo.git.checkout("-b", target, f"origin/{target}") + else: + raise HTTPException(status_code=404, detail=f"Target branch '{target}' not found") + try: + origin.pull(target) + except Exception: + logger.debug("Could not pull target branch %s before direct promote", target) + repo.git.merge(source, "--no-ff", "-m", f"chore(flow): promote {source} -> {target}") + origin.push(refspec=f"{target}:{target}") + except HTTPException: + raise + except Exception as e: + message = str(e) + if "CONFLICT" in message.upper(): + raise HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}") + raise HTTPException(status_code=500, detail=f"Direct promote failed: {message}") + finally: + # Restore original branch even if merge/push partially failed + if original_branch: try: - origin.pull(target) + repo.git.checkout(original_branch) except Exception: - logger.debug("Could not pull target branch %s before direct promote", target) - repo.git.merge(source, "--no-ff", "-m", f"chore(flow): promote {source} -> {target}") - origin.push(refspec=f"{target}:{target}") - except HTTPException: - raise - except Exception as e: - message = str(e) - if "CONFLICT" in message.upper(): - raise HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}") - raise HTTPException(status_code=500, detail=f"Direct promote failed: {message}") - finally: - # Restore original branch even if merge/push partially failed - if original_branch: - try: - repo.git.checkout(original_branch) - except Exception: - logger.debug("Could not restore original branch %s after direct promote", original_branch) - return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"} + logger.debug("Could not restore original branch %s after direct promote", original_branch) + return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"} # endregion promote_direct_merge # #endregion GitServiceMergeMixin # #endregion GitServiceMergeMixin diff --git a/backend/src/services/git/_remote_providers.py b/backend/src/services/git/_remote_providers.py index 4041cf70..82f65867 100644 --- a/backend/src/services/git/_remote_providers.py +++ b/backend/src/services/git/_remote_providers.py @@ -7,7 +7,6 @@ from typing import Any from urllib.parse import quote from fastapi import HTTPException -import httpx from src.core.logger import logger diff --git a/backend/src/services/maintenance/_banner_renderer.py b/backend/src/services/maintenance/_banner_renderer.py index e0ea2cb9..d0a77d4c 100644 --- a/backend/src/services/maintenance/_banner_renderer.py +++ b/backend/src/services/maintenance/_banner_renderer.py @@ -5,9 +5,9 @@ # @RELATION DEPENDS_ON -> [MaintenanceModels] # @RELATION DEPENDS_ON -> [SupersetDashboardsWriteMixin] +from datetime import datetime import html import re as _re -from datetime import datetime from typing import Any from sqlalchemy.orm import Session diff --git a/backend/src/services/maintenance/_chart_manager.py b/backend/src/services/maintenance/_chart_manager.py index afe76ae2..5bd9b909 100644 --- a/backend/src/services/maintenance/_chart_manager.py +++ b/backend/src/services/maintenance/_chart_manager.py @@ -16,9 +16,6 @@ from ...models.maintenance import ( MaintenanceDashboardBannerStatus, MaintenanceDashboardState, MaintenanceDashboardStateStatus, - MaintenanceEvent, - MaintenanceEventStatus, - MaintenanceSettings, ) from ._banner_renderer import _build_banner_text_for_dashboard, rebuild_banner from ._dashboard_scanner import _resolve_dashboard_title diff --git a/backend/src/services/maintenance/_dashboard_scanner.py b/backend/src/services/maintenance/_dashboard_scanner.py index 817b8b46..d8dd57b4 100644 --- a/backend/src/services/maintenance/_dashboard_scanner.py +++ b/backend/src/services/maintenance/_dashboard_scanner.py @@ -6,9 +6,7 @@ # @RELATION DEPENDS_ON -> [SqlTableExtractorModule] # @RELATION DEPENDS_ON -> [MaintenanceModels] -from typing import Any -from sqlalchemy.orm import Session from ...core.logger import belief_scope, logger as app_logger from ...core.superset_client import SupersetClient diff --git a/backend/src/services/maintenance/_orchestrators.py b/backend/src/services/maintenance/_orchestrators.py index c06248cb..0bba1d7d 100644 --- a/backend/src/services/maintenance/_orchestrators.py +++ b/backend/src/services/maintenance/_orchestrators.py @@ -10,7 +10,7 @@ # @INVARIANT C4 methods use belief_scope + REASON/REFLECT/EXPLORE markers. # @INVARIANT Idempotency key: sorted lowercased (tables, start_time, end_time). message NOT in key. -from datetime import datetime, timezone +from datetime import datetime from typing import Any from sqlalchemy.orm import Session diff --git a/backend/src/services/profile_preference_service.py b/backend/src/services/profile_preference_service.py index 95407a13..65e0dc71 100644 --- a/backend/src/services/profile_preference_service.py +++ b/backend/src/services/profile_preference_service.py @@ -16,12 +16,13 @@ # @POST Preference rows are created/updated with normalized values and encrypted tokens. # @SIDE_EFFECT Writes preference records via AuthRepository; encrypts git tokens. -from datetime import datetime +from datetime import UTC, datetime from typing import Any from sqlalchemy.orm import Session from ..core.auth.repository import AuthRepository +from ..core.encryption import EncryptionManager from ..core.logger import belief_scope, logger from ..models.auth import User from ..models.profile import UserDashboardPreference @@ -30,7 +31,6 @@ from ..schemas.profile import ( ProfilePreferenceResponse, ProfilePreferenceUpdateRequest, ) -from ..core.encryption import EncryptionManager from .profile_utils import ( ProfileAuthorizationError, ProfileValidationError, @@ -259,7 +259,7 @@ class ProfilePreferenceService: preference.telegram_id = effective_telegram_id preference.email_address = effective_email_address preference.notify_on_fail = effective_notify_on_fail - preference.updated_at = datetime.utcnow() + preference.updated_at = datetime.now(UTC) persisted_preference = self.auth_repository.save_user_dashboard_preference( preference @@ -297,7 +297,7 @@ class ProfilePreferenceService: except Exception: token_masked = "***" - created_at = getattr(preference, "created_at", None) or datetime.utcnow() + created_at = getattr(preference, "created_at", None) or datetime.now(UTC) updated_at = getattr(preference, "updated_at", None) or created_at return ProfilePreference( diff --git a/backend/src/services/profile_service.py b/backend/src/services/profile_service.py index 974ed1d5..273fc9c4 100644 --- a/backend/src/services/profile_service.py +++ b/backend/src/services/profile_service.py @@ -39,7 +39,6 @@ from typing import Any from sqlalchemy.orm import Session -from ..core.logger import belief_scope, logger from ..models.auth import User from ..schemas.profile import ( ProfilePreferenceResponse, @@ -54,7 +53,6 @@ from .profile_utils import ( ProfileValidationError, normalize_owner_tokens, normalize_username, - sanitize_username, ) from .security_badge_service import SecurityBadgeService from .superset_lookup_service import SupersetLookupService diff --git a/backend/src/services/profile_utils.py b/backend/src/services/profile_utils.py index 20910d63..f8d15e45 100644 --- a/backend/src/services/profile_utils.py +++ b/backend/src/services/profile_utils.py @@ -10,7 +10,7 @@ # service logic. from collections.abc import Iterable, Sequence -from datetime import datetime +from datetime import UTC, datetime from typing import Any @@ -181,7 +181,7 @@ def validate_update_payload( def build_default_preference(user_id: str) -> Any: """Build default ProfilePreference with disabled toggles and empty usernames.""" from ..schemas.profile import ProfilePreference - now = datetime.utcnow() + now = datetime.now(UTC) return ProfilePreference( user_id=str(user_id), superset_username=None, diff --git a/backend/src/services/reports/__tests__/test_report_normalizer.py b/backend/src/services/reports/__tests__/test_report_normalizer.py index 19761c9c..4ad78856 100644 --- a/backend/src/services/reports/__tests__/test_report_normalizer.py +++ b/backend/src/services/reports/__tests__/test_report_normalizer.py @@ -5,7 +5,7 @@ # @LAYER Domain # @INVARIANT Unknown plugin types are mapped to canonical unknown task type. -from datetime import datetime +from datetime import UTC, datetime from src.core.task_manager.models import Task, TaskStatus from src.services.reports.normalizer import normalize_task_report @@ -19,8 +19,8 @@ def test_unknown_type_maps_to_unknown_profile(): id="unknown-1", plugin_id="custom-unmapped-plugin", status=TaskStatus.FAILED, - started_at=datetime.utcnow(), - finished_at=datetime.utcnow(), + started_at=datetime.now(UTC), + finished_at=datetime.now(UTC), params={}, result={"error_message": "Unexpected plugin payload"}, ) @@ -43,8 +43,8 @@ def test_partial_payload_keeps_report_visible_with_placeholders(): id="partial-1", plugin_id="superset-backup", status=TaskStatus.SUCCESS, - started_at=datetime.utcnow(), - finished_at=datetime.utcnow(), + started_at=datetime.now(UTC), + finished_at=datetime.now(UTC), params={}, result=None, ) @@ -67,8 +67,8 @@ def test_clean_release_plugin_maps_to_clean_release_task_type(): id="clean-release-1", plugin_id="clean-release-compliance", status=TaskStatus.SUCCESS, - started_at=datetime.utcnow(), - finished_at=datetime.utcnow(), + started_at=datetime.now(UTC), + finished_at=datetime.now(UTC), params={"run_id": "run-1"}, result={"summary": "Clean release compliance passed", "run_id": "run-1"}, ) diff --git a/backend/src/services/reports/normalizer.py b/backend/src/services/reports/normalizer.py index bfeb52e7..3ea07e6c 100644 --- a/backend/src/services/reports/normalizer.py +++ b/backend/src/services/reports/normalizer.py @@ -10,7 +10,7 @@ # @POST Returns Normalizer output with normalized fields # @SIDE_EFFECT Read-only database operations -from datetime import datetime +from datetime import UTC, datetime from typing import Any from ...core.logger import belief_scope @@ -126,7 +126,7 @@ def normalize_task_report(task: Task) -> TaskReport: started_at = task.started_at if isinstance(task.started_at, datetime) else None updated_at = task.finished_at if isinstance(task.finished_at, datetime) else None if not updated_at: - updated_at = started_at or datetime.utcnow() + updated_at = started_at or datetime.now(UTC) details: dict[str, Any] = { "profile": { diff --git a/backend/src/services/resource_service.py b/backend/src/services/resource_service.py index e978a506..e904101a 100644 --- a/backend/src/services/resource_service.py +++ b/backend/src/services/resource_service.py @@ -317,7 +317,7 @@ class ResourceService: ), timeout=10.0, ) - except (asyncio.TimeoutError, Exception): + except (TimeoutError, Exception): logger.warning( f"[EXT:method:get_datasets_with_status][Warning] " f"Failed to fetch linked dashboard count for dataset {ds_id}" diff --git a/backend/src/services/sql_table_extractor.py b/backend/src/services/sql_table_extractor.py index 9e28b47d..629861b6 100644 --- a/backend/src/services/sql_table_extractor.py +++ b/backend/src/services/sql_table_extractor.py @@ -8,12 +8,12 @@ # @INVARIANT Only exact schema.table matches (case-insensitive); unqualified references are NOT matched. # @INVARIANT Returns a set[str] of fully-qualified table names (lowercased for case-insensitive matching). +from collections.abc import Iterable import re -from typing import Iterable import sqlparse -from sqlparse.sql import Identifier, Parenthesis, Token, TokenList -from sqlparse.tokens import Literal, Name, Punctuation, Whitespace +from sqlparse.sql import Token, TokenList +from sqlparse.tokens import Literal # ── Phase 1: Jinja span detection ─────────────────────────── diff --git a/backend/src/services/validation_service.py b/backend/src/services/validation_service.py index 0300a15a..f4fadf23 100644 --- a/backend/src/services/validation_service.py +++ b/backend/src/services/validation_service.py @@ -187,9 +187,9 @@ class ValidationTaskService: update_data = payload.model_dump(exclude_unset=True) - if "provider_id" in update_data and update_data["provider_id"]: + if update_data.get("provider_id"): self._validate_provider(update_data["provider_id"]) - if "environment_id" in update_data and update_data["environment_id"]: + if update_data.get("environment_id"): self._validate_environment(update_data["environment_id"]) if "window_start" in update_data: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a4434d2b..cdeb67a2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -22,9 +22,10 @@ # Plain SQLite without FK — silently ignores FK violations, masks bugs. # Always-on PostgresContainer — 5-10x slower, Docker dependency. import os +from pathlib import Path import sys import tempfile -from pathlib import Path + from sqlalchemy import create_engine, event sys.path.insert(0, str(Path(__file__).parent.parent / "src")) diff --git a/backend/tests/core/migration/test_archive_parser.py b/backend/tests/core/migration/test_archive_parser.py index 570b1654..3c59e5af 100644 --- a/backend/tests/core/migration/test_archive_parser.py +++ b/backend/tests/core/migration/test_archive_parser.py @@ -5,10 +5,10 @@ # @RELATION DEPENDS_ON -> [MigrationArchiveParserModule] # import os +from pathlib import Path import sys import tempfile import zipfile -from pathlib import Path import yaml diff --git a/backend/tests/core/migration/test_dry_run_orchestrator.py b/backend/tests/core/migration/test_dry_run_orchestrator.py index 8d3e57e0..c82a5852 100644 --- a/backend/tests/core/migration/test_dry_run_orchestrator.py +++ b/backend/tests/core/migration/test_dry_run_orchestrator.py @@ -5,8 +5,8 @@ # @RELATION DEPENDS_ON -> [MigrationDryRunOrchestratorModule] # import json -import sys from pathlib import Path +import sys from unittest.mock import MagicMock, patch from sqlalchemy import create_engine diff --git a/backend/tests/core/test_defensive_guards.py b/backend/tests/core/test_defensive_guards.py index d907411a..75b67462 100644 --- a/backend/tests/core/test_defensive_guards.py +++ b/backend/tests/core/test_defensive_guards.py @@ -1,11 +1,9 @@ +from pathlib import Path +import pytest import shutil import sys -from pathlib import Path from unittest.mock import MagicMock, patch -import pytest -from git.exc import InvalidGitRepositoryError - sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from src.core.config_models import Environment diff --git a/backend/tests/core/test_git_service_gitea_pr.py b/backend/tests/core/test_git_service_gitea_pr.py index d477bea8..f00f670f 100644 --- a/backend/tests/core/test_git_service_gitea_pr.py +++ b/backend/tests/core/test_git_service_gitea_pr.py @@ -6,10 +6,10 @@ # @INVARIANT: A 404 from primary Gitea URL retries once against remote-url host when different. import asyncio -import sys from pathlib import Path - import pytest +import sys + from fastapi import HTTPException sys.path.insert(0, str(Path(__file__).parent.parent.parent)) diff --git a/backend/tests/core/test_mapping_service.py b/backend/tests/core/test_mapping_service.py index 4a548f70..2bed5081 100644 --- a/backend/tests/core/test_mapping_service.py +++ b/backend/tests/core/test_mapping_service.py @@ -4,11 +4,11 @@ # @LAYER Domain # @RELATION BINDS_TO ->[IdMappingService] # -import sys from datetime import UTC, datetime from pathlib import Path - import pytest +import sys + from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker diff --git a/backend/tests/core/test_migration_engine.py b/backend/tests/core/test_migration_engine.py index 054c70ea..45834c59 100644 --- a/backend/tests/core/test_migration_engine.py +++ b/backend/tests/core/test_migration_engine.py @@ -6,12 +6,12 @@ # import json import os +from pathlib import Path +import pytest import sys import tempfile import zipfile -from pathlib import Path -import pytest import yaml backend_dir = str(Path(__file__).parent.parent.parent.resolve()) diff --git a/backend/tests/scripts/test_clean_release_tui.py b/backend/tests/scripts/test_clean_release_tui.py index ec4eec11..3b68e30b 100644 --- a/backend/tests/scripts/test_clean_release_tui.py +++ b/backend/tests/scripts/test_clean_release_tui.py @@ -8,11 +8,10 @@ import curses import json import os +import pytest from unittest import mock from unittest.mock import MagicMock, patch -import pytest - from src.models.clean_release import CheckFinalStatus from src.scripts.clean_release_tui import CleanReleaseTUI, main diff --git a/backend/tests/services/clean_release/test_approval_service.py b/backend/tests/services/clean_release/test_approval_service.py index 038cc7f1..1416bcbc 100644 --- a/backend/tests/services/clean_release/test_approval_service.py +++ b/backend/tests/services/clean_release/test_approval_service.py @@ -8,7 +8,6 @@ from __future__ import annotations from datetime import UTC, datetime - import pytest from src.models.clean_release import ComplianceReport, ReleaseCandidate diff --git a/backend/tests/services/clean_release/test_candidate_manifest_services.py b/backend/tests/services/clean_release/test_candidate_manifest_services.py index ca8a344b..a74c41cf 100644 --- a/backend/tests/services/clean_release/test_candidate_manifest_services.py +++ b/backend/tests/services/clean_release/test_candidate_manifest_services.py @@ -4,8 +4,8 @@ # @LAYER Tests from datetime import UTC, datetime - import pytest + from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker diff --git a/backend/tests/services/clean_release/test_compliance_execution_service.py b/backend/tests/services/clean_release/test_compliance_execution_service.py index 8b29dc0f..0f0a96dc 100644 --- a/backend/tests/services/clean_release/test_compliance_execution_service.py +++ b/backend/tests/services/clean_release/test_compliance_execution_service.py @@ -8,7 +8,6 @@ from __future__ import annotations from datetime import UTC, datetime - import pytest from src.models.clean_release import ( diff --git a/backend/tests/services/clean_release/test_compliance_task_integration.py b/backend/tests/services/clean_release/test_compliance_task_integration.py index 2e2d0eba..69c4c319 100644 --- a/backend/tests/services/clean_release/test_compliance_task_integration.py +++ b/backend/tests/services/clean_release/test_compliance_task_integration.py @@ -9,11 +9,10 @@ from __future__ import annotations import asyncio from datetime import UTC, datetime +import pytest from typing import Any from unittest.mock import MagicMock, patch -import pytest - from src.core.task_manager.manager import TaskManager from src.core.task_manager.models import TaskStatus from src.models.clean_release import ( diff --git a/backend/tests/services/clean_release/test_policy_resolution_service.py b/backend/tests/services/clean_release/test_policy_resolution_service.py index 993c59fb..b4f9ebbc 100644 --- a/backend/tests/services/clean_release/test_policy_resolution_service.py +++ b/backend/tests/services/clean_release/test_policy_resolution_service.py @@ -9,9 +9,8 @@ from __future__ import annotations -from types import SimpleNamespace - import pytest +from types import SimpleNamespace from src.models.clean_release import CleanPolicySnapshot, SourceRegistrySnapshot from src.services.clean_release.exceptions import PolicyResolutionError diff --git a/backend/tests/services/clean_release/test_publication_service.py b/backend/tests/services/clean_release/test_publication_service.py index ee36b2bc..40672d42 100644 --- a/backend/tests/services/clean_release/test_publication_service.py +++ b/backend/tests/services/clean_release/test_publication_service.py @@ -8,7 +8,6 @@ from __future__ import annotations from datetime import UTC, datetime - import pytest from src.models.clean_release import ComplianceReport, ReleaseCandidate diff --git a/backend/tests/services/clean_release/test_report_audit_immutability.py b/backend/tests/services/clean_release/test_report_audit_immutability.py index ccf636c1..d1f4e9bf 100644 --- a/backend/tests/services/clean_release/test_report_audit_immutability.py +++ b/backend/tests/services/clean_release/test_report_audit_immutability.py @@ -8,9 +8,8 @@ from __future__ import annotations from datetime import UTC, datetime -from unittest.mock import patch - import pytest +from unittest.mock import patch from src.models.clean_release import ( ComplianceReport, diff --git a/backend/tests/test_api_key_auth.py b/backend/tests/test_api_key_auth.py index dfb89747..0b7a2c1e 100644 --- a/backend/tests/test_api_key_auth.py +++ b/backend/tests/test_api_key_auth.py @@ -13,13 +13,12 @@ # @TEST_EDGE: missing_permission -> 403 # @TEST_EDGE: environment_scope_mismatch -> 400 # @TEST_EDGE: jwt_precedence -> JWT takes precedence over API key -from datetime import datetime, timezone, timedelta -from unittest.mock import AsyncMock, MagicMock, patch - +from datetime import UTC, datetime, timedelta import pytest -from fastapi import HTTPException, status -from fastapi.testclient import TestClient +from unittest.mock import AsyncMock, MagicMock +from fastapi import HTTPException +from fastapi.testclient import TestClient # ── Fixtures ────────────────────────────────────────────────── @@ -30,6 +29,7 @@ def db_session(): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool + from src.models.mapping import Base engine = create_engine( @@ -77,9 +77,10 @@ def mock_db(): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool - from src.models.mapping import Base - from src.dependencies import get_db + from src.app import app + from src.dependencies import get_db + from src.models.mapping import Base engine = create_engine( "sqlite:///:memory:", @@ -91,7 +92,7 @@ def mock_db(): session = Session() # Add default maintenance settings (needed by /api/maintenance endpoints) - from src.models.maintenance import MaintenanceSettings, DashboardScope + from src.models.maintenance import DashboardScope, MaintenanceSettings settings = MaintenanceSettings( id="default", @@ -121,8 +122,8 @@ def mock_db(): @pytest.fixture def mock_task_manager(): """Patch get_task_manager to return a mock.""" - from src.dependencies import get_task_manager from src.app import app + from src.dependencies import get_task_manager mock_tm = MagicMock() mock_task = MagicMock() @@ -143,9 +144,10 @@ class TestGetApiKeyPrincipal: # @BRIEF No X-API-Key header → returns None. @pytest.mark.asyncio async def test_no_header_returns_none(self): - from src.dependencies import get_api_key_principal from fastapi import Request + from src.dependencies import get_api_key_principal + # Mock a request without the header mock_request = MagicMock(spec=Request) mock_request.headers.get.return_value = None @@ -160,9 +162,10 @@ class TestGetApiKeyPrincipal: # @BRIEF Valid API key → returns APIKeyPrincipal with correct fields. @pytest.mark.asyncio async def test_valid_key_returns_principal(self, db_session, valid_api_key): - from src.dependencies import get_api_key_principal from fastapi import Request + from src.dependencies import get_api_key_principal + raw_key, api_key_row = valid_api_key mock_request = MagicMock(spec=Request) mock_request.headers.get.return_value = raw_key @@ -180,9 +183,10 @@ class TestGetApiKeyPrincipal: # @BRIEF Invalid API key → 401. @pytest.mark.asyncio async def test_invalid_key_raises_401(self, db_session): - from src.dependencies import get_api_key_principal from fastapi import Request + from src.dependencies import get_api_key_principal + mock_request = MagicMock(spec=Request) mock_request.headers.get.return_value = "ssk_invalid_key_that_does_not_exist" @@ -196,10 +200,11 @@ class TestGetApiKeyPrincipal: # @BRIEF Revoked (active=False) API key → 401. @pytest.mark.asyncio async def test_revoked_key_raises_401(self, db_session): + from fastapi import Request + from src.core.auth.api_key import generate_api_key from src.dependencies import get_api_key_principal from src.models.api_key import APIKey - from fastapi import Request raw, prefix, key_hash = generate_api_key() api_key = APIKey( @@ -225,10 +230,11 @@ class TestGetApiKeyPrincipal: # @BRIEF Expired API key → 401. @pytest.mark.asyncio async def test_expired_key_raises_401(self, db_session): + from fastapi import Request + from src.core.auth.api_key import generate_api_key from src.dependencies import get_api_key_principal from src.models.api_key import APIKey - from fastapi import Request raw, prefix, key_hash = generate_api_key() api_key = APIKey( @@ -237,7 +243,7 @@ class TestGetApiKeyPrincipal: name="Expired Key", permissions=["maintenance:start"], active=True, - expires_at=datetime.now(timezone.utc) - timedelta(hours=1), # expired + expires_at=datetime.now(UTC) - timedelta(hours=1), # expired ) db_session.add(api_key) db_session.commit() @@ -263,8 +269,8 @@ class TestRequireApiKeyOrJwt: API_START_URL = "/api/maintenance/start" START_PAYLOAD = { "tables": ["raw.sales"], - "start_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), - "end_time": (datetime.now(timezone.utc) + timedelta(hours=3)).isoformat(), + "start_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), + "end_time": (datetime.now(UTC) + timedelta(hours=3)).isoformat(), "message": "Test maintenance", "environment_id": "ss-dev", } @@ -362,7 +368,7 @@ class TestRequireApiKeyOrJwt: name="Expired Key", permissions=["maintenance:start"], active=True, - expires_at=datetime.now(timezone.utc) - timedelta(hours=1), + expires_at=datetime.now(UTC) - timedelta(hours=1), ) mock_db.add(api_key) mock_db.commit() @@ -440,10 +446,10 @@ class TestRequireApiKeyOrJwt: # #region test_jwt_precedence [C:2] [TYPE Function] # @BRIEF Both valid API key + valid JWT → JWT takes precedence. def test_jwt_precedence(self, client, mock_db, mock_task_manager): - from src.core.auth.jwt import create_access_token - from src.models.auth import User, Role - from src.dependencies import get_auth_db from src.app import app + from src.core.auth.jwt import create_access_token + from src.dependencies import get_auth_db + from src.models.auth import Role, User raw_key = self._create_api_key( mock_db, diff --git a/backend/tests/test_api_key_model.py b/backend/tests/test_api_key_model.py index 97324e35..1a95b62f 100644 --- a/backend/tests/test_api_key_model.py +++ b/backend/tests/test_api_key_model.py @@ -6,7 +6,6 @@ # @TEST_EDGE: prefix is exactly 11 chars ("ssk_" + 7) # @TEST_EDGE: active defaults to True import pytest -from datetime import datetime, timezone # #region clean_db [C:1] [TYPE Fixture] @@ -16,6 +15,7 @@ def clean_db(): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool + from src.models.mapping import Base engine = create_engine( @@ -65,6 +65,7 @@ class TestAPIKeyModel: # @BRIEF key_hash is unique — duplicate raises IntegrityError. def test_key_hash_unique(self, clean_db): from sqlalchemy.exc import IntegrityError + from src.models.api_key import APIKey clean_db.add(APIKey( diff --git a/backend/tests/test_api_key_routes.py b/backend/tests/test_api_key_routes.py index fbd336f2..0e597e70 100644 --- a/backend/tests/test_api_key_routes.py +++ b/backend/tests/test_api_key_routes.py @@ -7,13 +7,10 @@ # @TEST_EDGE: revoked_key_delete -> 404 # @TEST_EDGE: missing_name -> 422 # @TEST_EDGE: missing_permissions -> 422 -import json -import sys -from datetime import datetime, timezone, timedelta -from unittest.mock import MagicMock, patch - import pytest -from fastapi import status +import sys +from unittest.mock import MagicMock + from fastapi.testclient import TestClient # Patch GitService at module level to prevent /app/storage/repositories error @@ -34,8 +31,8 @@ def client(): def _mock_admin_auth(): """Apply dependency overrides for admin authentication.""" - from src.dependencies import get_current_user from src.app import app + from src.dependencies import get_current_user mock_role = MagicMock() mock_role.name = "Admin" @@ -69,9 +66,10 @@ def mock_db(): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool - from src.models.mapping import Base - from src.dependencies import get_db + from src.app import app + from src.dependencies import get_db + from src.models.mapping import Base engine = create_engine( "sqlite:///:memory:", @@ -113,8 +111,8 @@ class TestListApiKeys: # #region test_list_with_keys [C:2] [TYPE Function] # @BRIEF Keys exist → returns list without raw_key or key_hash fields. def test_list_with_keys(self, client, mock_db): - from src.models.api_key import APIKey from src.core.auth.api_key import generate_api_key + from src.models.api_key import APIKey # Create a key raw, prefix, key_hash = generate_api_key() @@ -203,8 +201,8 @@ class TestRevokeApiKey: # #region test_revoke_valid [C:2] [TYPE Function] # @BRIEF Revoke existing active key → 200 with status=revoked. def test_revoke_valid(self, client, mock_db): - from src.models.api_key import APIKey from src.core.auth.api_key import generate_api_key + from src.models.api_key import APIKey raw, prefix, key_hash = generate_api_key() api_key = APIKey( @@ -231,8 +229,8 @@ class TestRevokeApiKey: # #region test_revoke_already_revoked [C:2] [TYPE Function] # @BRIEF Revoke already revoked key → 404. def test_revoke_already_revoked(self, client, mock_db): - from src.models.api_key import APIKey from src.core.auth.api_key import generate_api_key + from src.models.api_key import APIKey raw, prefix, key_hash = generate_api_key() api_key = APIKey( diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 1f937bc3..4480c992 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -6,13 +6,14 @@ # @RELATION BINDS_TO -> create_admin # @RELATION BINDS_TO -> ensure_encryption_key -import sys from pathlib import Path +import sys # Add src to path sys.path.append(str(Path(__file__).parent.parent / "src")) import pytest + from cryptography.fernet import Fernet from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker diff --git a/backend/tests/test_constants_audit_fixes.py b/backend/tests/test_constants_audit_fixes.py index 0b123395..85217038 100644 --- a/backend/tests/test_constants_audit_fixes.py +++ b/backend/tests/test_constants_audit_fixes.py @@ -8,11 +8,9 @@ # @TEST_EDGE: timeout_constants -> Playwright/HTTP timeout constants should be defined (RATIONALE says extracted) # @TEST_EDGE: base_dir_constant -> BASE_DIR equivalent resolves to backend/ root # @TEST_EDGE: git_typo_fix -> services/git/_base.py still has "repositorys" at line 148 (bug) -import os -import sys from pathlib import Path - import pytest +import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) @@ -113,7 +111,7 @@ class TestConstantsAuditFixes: if "repositorys" in stripped and not stripped.startswith("#"): bad_lines.append(f" line {i}: {stripped}") assert not bad_lines, ( - f"Hardcoded 'repositorys' found in executable code — fix not fully applied:\n" + "Hardcoded 'repositorys' found in executable code — fix not fully applied:\n" + "\n".join(bad_lines) ) # #endregion test_git_service_repositorys_typo_regression diff --git a/backend/tests/test_dashboards_api.py b/backend/tests/test_dashboards_api.py index 39907da0..02585031 100644 --- a/backend/tests/test_dashboards_api.py +++ b/backend/tests/test_dashboards_api.py @@ -4,9 +4,9 @@ # @LAYER Tests # @SEMANTICS: tests, dashboards, api, contract, remediation from datetime import UTC, datetime +import pytest from unittest.mock import AsyncMock, MagicMock, patch -import pytest from fastapi.testclient import TestClient from src.api.routes.dashboards import ( diff --git a/backend/tests/test_datasets.py b/backend/tests/test_datasets.py index c4446b0b..5303e84c 100644 --- a/backend/tests/test_datasets.py +++ b/backend/tests/test_datasets.py @@ -3,9 +3,9 @@ # @SEMANTICS: tests, datasets, api, metrics, inline-edit # @PURPOSE: Contract tests for dataset endpoints (list+stats, detail+metrics, inline-edit descriptions). # @LAYER Tests +import pytest from unittest.mock import AsyncMock, MagicMock, patch -import pytest from fastapi.testclient import TestClient from src.app import app @@ -396,7 +396,7 @@ def test_update_column_description_strips_html(mock_deps): assert response.status_code == 200 data = response.json() # HTML stripped - assert "Hello World with alert('xss')" == data["description"] + assert data["description"] == "Hello World with alert('xss')" assert "" not in data["description"] assert "