semantics: complete DEF-to-region migration, fix regressions

- Convert legacy [DEF🆔Type] anchors to #region/#endregion across 329 files
- Reinstate _normalize_timestamp_value in sql_generator.py
- Fix MarkerLogger→logger migration in events.py (molecular CoT markers)
- Fix dataset_review orchestrator dependencies (_build_execution_snapshot)
- Fix config_manager stale-record deletion (moved to save path only)
- Add 77 missing [/DEF:] closers in 5 unbalanced test files
- Update assistant_chat.integration.test.js for #region format
- Apply molecular-cot-logging markers (REASON/REFLECT/EXPLORE) via logger.* methods
This commit is contained in:
2026-05-12 23:54:55 +03:00
parent fe8978f716
commit 306c5ae742
331 changed files with 9630 additions and 10312 deletions

View File

@@ -1,14 +1,13 @@
# #region AuthApi [C:3] [TYPE Module] [SEMANTICS api, auth, routes, login, logout]
#
# @BRIEF Authentication API endpoints.
# @LAYER API
# @INVARIANT All auth endpoints must return consistent error codes.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AuthService]
# @RELATION DEPENDS_ON -> [get_auth_db]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [is_adfs_configured]
#
# @INVARIANT: All auth endpoints must return consistent error codes.
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
@@ -20,25 +19,20 @@ from ..core.auth.oauth import oauth, is_adfs_configured
from ..core.auth.logger import log_security_event
from ..core.logger import belief_scope
import starlette.requests
# [/SECTION]
# #region router [C:1] [TYPE Variable]
# @BRIEF APIRouter instance for authentication routes.
# @RELATION DEPENDS_ON -> [fastapi.APIRouter]
# @BRIEF APIRouter instance for authentication routes.
router = APIRouter(prefix="/api/auth", tags=["auth"])
# #endregion router
# #region login_for_access_token [C:3] [TYPE Function]
# @BRIEF Authenticates a user and returns a JWT access token.
# @PRE form_data contains username and password.
# @POST Returns a Token object on success.
# @THROW: HTTPException 401 if authentication fails.
# @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials.
# @PARAM: db (Session) - Auth database session.
# @RETURN: Token - The generated JWT token.
# @RELATION: CALLS -> [AuthService.authenticate_user]
# @RELATION: CALLS -> [AuthService.create_session]
# @PRE: form_data contains username and password.
# @POST: Returns a Token object on success.
# @RELATION CALLS -> [AuthService.authenticate_user]
# @RELATION CALLS -> [AuthService.create_session]
@router.post("/login", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_auth_db)
@@ -64,11 +58,9 @@ async def login_for_access_token(
# #region read_users_me [C:3] [TYPE Function]
# @BRIEF Retrieves the profile of the currently authenticated user.
# @PRE Valid JWT token provided.
# @POST Returns the current user's data.
# @PARAM: current_user (UserSchema) - The user extracted from the token.
# @RETURN: UserSchema - The current user profile.
# @RELATION: DEPENDS_ON -> [get_current_user]
# @PRE: Valid JWT token provided.
# @POST: Returns the current user's data.
# @RELATION DEPENDS_ON -> [get_current_user]
@router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.me"):
@@ -80,10 +72,9 @@ async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
# #region logout [C:3] [TYPE Function]
# @BRIEF Logs out the current user (placeholder for session revocation).
# @PRE Valid JWT token provided.
# @POST Returns success message.
# @PARAM: current_user (UserSchema) - The user extracted from the token.
# @RELATION: DEPENDS_ON -> [get_current_user]
# @PRE: Valid JWT token provided.
# @POST: Returns success message.
# @RELATION DEPENDS_ON -> [get_current_user]
@router.post("/logout")
async def logout(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.logout"):
@@ -98,7 +89,7 @@ async def logout(current_user: UserSchema = Depends(get_current_user)):
# #region login_adfs [C:3] [TYPE Function]
# @BRIEF Initiates the ADFS OIDC login flow.
# @POST Redirects the user to ADFS.
# @POST: Redirects the user to ADFS.
# @RELATION USES -> [is_adfs_configured]
@router.get("/login/adfs")
async def login_adfs(request: starlette.requests.Request):
@@ -117,7 +108,7 @@ async def login_adfs(request: starlette.requests.Request):
# #region auth_callback_adfs [C:3] [TYPE Function]
# @BRIEF Handles the callback from ADFS after successful authentication.
# @POST Provisions user JIT and returns session token.
# @POST: Provisions user JIT and returns session token.
# @RELATION DEPENDS_ON -> [is_adfs_configured]
# @RELATION CALLS -> [AuthService.provision_adfs_user]
# @RELATION CALLS -> [AuthService.create_session]

View File

@@ -1,9 +1,9 @@
# #region ApiRoutesModule [C:3] [TYPE Module] [SEMANTICS routes, lazy-import, module-registry]
# @BRIEF Provide lazy route module loading to avoid heavyweight imports during tests.
# @LAYER API
# @INVARIANT Only names listed in __all__ are importable via __getattr__.
# @LAYER: API
# @RELATION CALLS -> [ApiRoutesGetAttr]
# @RELATION BINDS_TO -> [Route_Group_Contracts]
# @INVARIANT: Only names listed in __all__ are importable via __getattr__.
# #region Route_Group_Contracts [C:3] [TYPE Block]
# @BRIEF Declare the canonical route-module registry used by lazy imports and app router inclusion.
@@ -41,9 +41,9 @@ __all__ = [
# #region ApiRoutesGetAttr [C:3] [TYPE Function]
# @BRIEF Lazily import route module by attribute name.
# @PRE name is module candidate exposed in __all__.
# @POST Returns imported submodule or raises AttributeError.
# @RELATION DEPENDS_ON -> [ApiRoutesModule]
# @PRE: name is module candidate exposed in __all__.
# @POST: Returns imported submodule or raises AttributeError.
def __getattr__(name):
if name in __all__:
import importlib

View File

@@ -1,5 +1,6 @@
# #region RoutesTestsConftest [C:1] [TYPE Module]
# @BRIEF Shared low-fidelity test doubles for API route test modules.
# [DEF:RoutesTestsConftest:Module]
# @COMPLEXITY: 1
# @PURPOSE: Shared low-fidelity test doubles for API route test modules.
class FakeQuery:
@@ -41,4 +42,4 @@ class FakeQuery:
return len(self._rows)
# #endregion RoutesTestsConftest
# [/DEF:RoutesTestsConftest:Module]

View File

@@ -1,10 +1,12 @@
import os
os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w="
# #region AssistantApiTests [C:3] [TYPE Module] [SEMANTICS tests, assistant, api]
# @BRIEF Validate assistant API endpoint logic via direct async handler invocation.
# @INVARIANT Every test clears assistant in-memory state before execution.
# @RELATION DEPENDS_ON -> [AssistantApi]
# [DEF:AssistantApiTests:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, assistant, api
# @PURPOSE: Validate assistant API endpoint logic via direct async handler invocation.
# @RELATION: DEPENDS_ON -> [AssistantApi]
# @INVARIANT: Every test clears assistant in-memory state before execution.
import asyncio
import uuid
@@ -35,19 +37,20 @@ from src.models.dataset_review import (
)
# #region _run_async [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_run_async:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
def _run_async(coro):
return asyncio.run(coro)
# #endregion _run_async
# [/DEF:_run_async:Function]
# #region _FakeTask [C:1] [TYPE Class]
# @BRIEF Lightweight task model stub used as return value from _FakeTaskManager.create_task in assistant route tests.
# @INVARIANT status is a bare string not a TaskStatus enum; callers must not depend on enum semantics.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_FakeTask:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 1
# @PURPOSE: Lightweight task model stub used as return value from _FakeTaskManager.create_task in assistant route tests.
# @INVARIANT: status is a bare string not a TaskStatus enum; callers must not depend on enum semantics.
class _FakeTask:
def __init__(
self,
@@ -68,14 +71,15 @@ class _FakeTask:
self.finished_at = datetime.utcnow()
# #endregion _FakeTask
# [/DEF:_FakeTask:Class]
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
# #region _FakeTaskManager [C:2] [TYPE Class]
# @BRIEF In-memory task manager stub that records created tasks for route-level assertions.
# @INVARIANT create_task stores tasks retrievable by get_task/get_tasks without external side effects.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 2
# @PURPOSE: In-memory task manager stub that records created tasks for route-level assertions.
# @INVARIANT: create_task stores tasks retrievable by get_task/get_tasks without external side effects.
class _FakeTaskManager:
def __init__(self):
self.tasks = {}
@@ -104,13 +108,14 @@ class _FakeTaskManager:
return list(self.tasks.values())
# #endregion _FakeTaskManager
# [/DEF:_FakeTaskManager:Class]
# #region _FakeConfigManager [C:2] [TYPE Class]
# @BRIEF Deterministic config stub providing hardcoded dev/prod environments and minimal settings shape for assistant route tests.
# @INVARIANT get_config() returns anonymous inner classes, not real GlobalSettings; only default_environment_id and llm fields are safe to access.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_FakeConfigManager:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 2
# @PURPOSE: Deterministic config stub providing hardcoded dev/prod environments and minimal settings shape for assistant route tests.
# @INVARIANT: get_config() returns anonymous inner classes, not real GlobalSettings; only default_environment_id and llm fields are safe to access.
class _FakeConfigManager:
class _Env:
def __init__(self, id, name):
@@ -132,12 +137,13 @@ class _FakeConfigManager:
return _Config()
# #endregion _FakeConfigManager
# [/DEF:_FakeConfigManager:Class]
# #region _admin_user [C:1] [TYPE Function]
# @BRIEF Build admin principal with spec=User for assistant route authorization tests.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 1
# @PURPOSE: Build admin principal with spec=User for assistant route authorization tests.
def _admin_user():
user = MagicMock(spec=User)
user.id = "u-admin"
@@ -148,12 +154,13 @@ def _admin_user():
return user
# #endregion _admin_user
# [/DEF:_admin_user:Function]
# #region _limited_user [C:1] [TYPE Function]
# @BRIEF Build limited user principal with empty roles for assistant route denial tests.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_limited_user:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 1
# @PURPOSE: Build limited user principal with empty roles for assistant route denial tests.
def _limited_user():
user = MagicMock(spec=User)
user.id = "u-limited"
@@ -162,13 +169,14 @@ def _limited_user():
return user
# #endregion _limited_user
# [/DEF:_limited_user:Function]
# #region _FakeQuery [C:2] [TYPE Class]
# @BRIEF Chainable SQLAlchemy-like query stub returning fixed item lists for assistant message persistence paths.
# @INVARIANT filter() ignores all predicate arguments and returns self; no predicate-based filtering is emulated.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_FakeQuery:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 2
# @PURPOSE: Chainable SQLAlchemy-like query stub returning fixed item lists for assistant message persistence paths.
# @INVARIANT: filter() ignores all predicate arguments and returns self; no predicate-based filtering is emulated.
class _FakeQuery:
def __init__(self, items):
self.items = items
@@ -204,13 +212,14 @@ class _FakeQuery:
return len(self.items)
# #endregion _FakeQuery
# [/DEF:_FakeQuery:Class]
# #region _FakeDb [C:2] [TYPE Class]
# @BRIEF Explicit in-memory DB session double limited to assistant message persistence paths.
# @INVARIANT query() always returns _FakeQuery with intentionally non-evaluated predicates; add/merge stay deterministic and never emulate unrelated SQLAlchemy behavior.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_FakeDb:Class]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 2
# @PURPOSE: Explicit in-memory DB session double limited to assistant message persistence paths.
# @INVARIANT: query() always returns _FakeQuery with intentionally non-evaluated predicates; add/merge stay deterministic and never emulate unrelated SQLAlchemy behavior.
class _FakeDb:
def __init__(self):
self.added = []
@@ -239,11 +248,11 @@ class _FakeDb:
pass
# #endregion _FakeDb
# [/DEF:_FakeDb:Class]
# #region _clear_assistant_state [TYPE Function]
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_clear_assistant_state:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
def _clear_assistant_state():
assistant_routes.CONVERSATIONS.clear()
assistant_routes.USER_ACTIVE_CONVERSATION.clear()
@@ -251,12 +260,13 @@ def _clear_assistant_state():
assistant_routes.ASSISTANT_AUDIT.clear()
# #endregion _clear_assistant_state
# [/DEF:_clear_assistant_state:Function]
# #region _dataset_review_session [C:1] [TYPE Function]
# @BRIEF Build minimal owned dataset-review session fixture for assistant scoped routing tests.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_dataset_review_session:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 1
# @PURPOSE: Build minimal owned dataset-review session fixture for assistant scoped routing tests.
def _dataset_review_session():
session = DatasetReviewSession(
session_id="sess-1",
@@ -354,22 +364,23 @@ def _dataset_review_session():
return session
# #endregion _dataset_review_session
# [/DEF:_dataset_review_session:Function]
# #region _await_none [C:1] [TYPE Function]
# @BRIEF Async helper returning None for planner fallback tests.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:_await_none:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @COMPLEXITY: 1
# @PURPOSE: Async helper returning None for planner fallback tests.
async def _await_none(*args, **kwargs):
return None
# #endregion _await_none
# [/DEF:_await_none:Function]
# #region test_unknown_command_returns_needs_clarification [TYPE Function]
# @BRIEF Unknown command should return clarification state and unknown intent.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:test_unknown_command_returns_needs_clarification:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Unknown command should return clarification state and unknown intent.
def test_unknown_command_returns_needs_clarification(monkeypatch):
_clear_assistant_state()
req = assistant_routes.AssistantMessageRequest(message="some random gibberish")
@@ -391,12 +402,12 @@ def test_unknown_command_returns_needs_clarification(monkeypatch):
assert "уточните" in resp.text.lower() or "неоднозначна" in resp.text.lower()
# #endregion test_unknown_command_returns_needs_clarification
# [/DEF:test_unknown_command_returns_needs_clarification:Function]
# #region test_capabilities_question_returns_successful_help [TYPE Function]
# @BRIEF Capability query should return deterministic help response.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:test_capabilities_question_returns_successful_help:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Capability query should return deterministic help response.
def test_capabilities_question_returns_successful_help(monkeypatch):
_clear_assistant_state()
req = assistant_routes.AssistantMessageRequest(message="что ты умеешь?")
@@ -415,12 +426,12 @@ def test_capabilities_question_returns_successful_help(monkeypatch):
assert "я могу сделать" in resp.text.lower()
# #endregion test_capabilities_question_returns_successful_help
# [/DEF:test_capabilities_question_returns_successful_help:Function]
# #region test_assistant_message_request_accepts_dataset_review_session_binding [TYPE Function]
# @BRIEF Assistant request schema should accept active dataset review session binding for scoped orchestration.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:test_assistant_message_request_accepts_dataset_review_session_binding:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Assistant request schema should accept active dataset review session binding for scoped orchestration.
def test_assistant_message_request_accepts_dataset_review_session_binding():
request = assistant_routes.AssistantMessageRequest(
message="approve mappings",
@@ -430,12 +441,12 @@ def test_assistant_message_request_accepts_dataset_review_session_binding():
assert request.dataset_review_session_id == "sess-1"
# #endregion test_assistant_message_request_accepts_dataset_review_session_binding
# [/DEF:test_assistant_message_request_accepts_dataset_review_session_binding:Function]
# #region test_dataset_review_scoped_message_uses_masked_filter_context [TYPE Function]
# @BRIEF Session-scoped assistant context should mask imported-filter raw values before assistant-visible metadata is persisted.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:test_dataset_review_scoped_message_uses_masked_filter_context:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Session-scoped assistant context should mask imported-filter raw values before assistant-visible metadata is persisted.
def test_dataset_review_scoped_message_uses_masked_filter_context(monkeypatch):
_clear_assistant_state()
db = _FakeDb()
@@ -478,12 +489,12 @@ def test_dataset_review_scoped_message_uses_masked_filter_context(monkeypatch):
assert imported_filters[0]["raw_value_masked"] is True
# #endregion test_dataset_review_scoped_message_uses_masked_filter_context
# [/DEF:test_dataset_review_scoped_message_uses_masked_filter_context:Function]
# #region test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval [TYPE Function]
# @BRIEF Session-scoped assistant commands should route dataset-review mapping approvals into confirmation workflow with bound session metadata.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Session-scoped assistant commands should route dataset-review mapping approvals into confirmation workflow with bound session metadata.
def test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval():
_clear_assistant_state()
db = _FakeDb()
@@ -511,12 +522,12 @@ def test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval
assert resp.intent["entities"]["mapping_ids"] == ["map-1"]
# #endregion test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval
# [/DEF:test_dataset_review_scoped_command_returns_confirmation_for_mapping_approval:Function]
# #region test_dataset_review_scoped_command_routes_field_semantics_update [TYPE Function]
# @BRIEF Session-scoped assistant commands should route semantic field updates through explicit confirmation metadata.
# @RELATION BINDS_TO -> [AssistantApiTests]
# [DEF:test_dataset_review_scoped_command_routes_field_semantics_update:Function]
# @RELATION: BINDS_TO -> [AssistantApiTests]
# @PURPOSE: Session-scoped assistant commands should route semantic field updates through explicit confirmation metadata.
def test_dataset_review_scoped_command_routes_field_semantics_update():
_clear_assistant_state()
db = _FakeDb()
@@ -545,7 +556,7 @@ def test_dataset_review_scoped_command_routes_field_semantics_update():
assert resp.intent["entities"]["lock_field"] is True
# #endregion test_dataset_review_scoped_command_routes_field_semantics_update
# [/DEF:test_dataset_review_scoped_command_routes_field_semantics_update:Function]
# #endregion AssistantApiTests
# [/DEF:AssistantApiTests:Module]

View File

@@ -1,12 +1,14 @@
import os
os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w="
# #region TestAssistantAuthz [C:3] [TYPE Module] [SEMANTICS tests, assistant, authz, confirmation, rbac]
# @BRIEF Verify assistant confirmation ownership, expiration, and deny behavior for restricted users.
# @LAYER UI (API Tests)
# @INVARIANT Security-sensitive flows fail closed for unauthorized actors.
# @RELATION DEPENDS_ON -> [AssistantApi]
# [DEF:TestAssistantAuthz:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, assistant, authz, confirmation, rbac
# @PURPOSE: Verify assistant confirmation ownership, expiration, and deny behavior for restricted users.
# @LAYER: UI (API Tests)
# @RELATION: DEPENDS_ON -> AssistantApi
# @INVARIANT: Security-sensitive flows fail closed for unauthorized actors.
import os
import asyncio
@@ -33,23 +35,25 @@ from src.models.assistant import (
)
# #region _run_async [C:1] [TYPE Function]
# @BRIEF Execute async endpoint handler in synchronous test context.
# @PRE coroutine is awaitable endpoint invocation.
# @POST Returns coroutine result or raises propagated exception.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [DEF:_run_async:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Execute async endpoint handler in synchronous test context.
# @PRE: coroutine is awaitable endpoint invocation.
# @POST: Returns coroutine result or raises propagated exception.
def _run_async(coroutine):
return asyncio.run(coroutine)
# #endregion _run_async
# [/DEF:_run_async:Function]
# #region _FakeTask [C:1] [TYPE Class]
# @BRIEF Lightweight task model used for assistant authz tests.
# @PRE task_id is non-empty string.
# @POST Returns task with provided id, status, and user_id accessible as attributes.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [DEF:_FakeTask:Class]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Lightweight task model used for assistant authz tests.
# @PRE: task_id is non-empty string.
# @POST: Returns task with provided id, status, and user_id accessible as attributes.
class _FakeTask:
def __init__(self, task_id: str, status: str = "RUNNING", user_id: str = "u-admin"):
self.id = task_id
@@ -57,12 +61,13 @@ class _FakeTask:
self.user_id = user_id
# #endregion _FakeTask
# [/DEF:_FakeTask:Class]
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
# #region _FakeTaskManager [C:2] [TYPE Class]
# @BRIEF In-memory task manager double that records assistant-created tasks deterministically.
# @INVARIANT Only create_task/get_task/get_tasks behavior used by assistant authz routes is emulated.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 2
# @PURPOSE: In-memory task manager double that records assistant-created tasks deterministically.
# @INVARIANT: Only create_task/get_task/get_tasks behavior used by assistant authz routes is emulated.
class _FakeTaskManager:
def __init__(self):
self._created = []
@@ -88,14 +93,15 @@ class _FakeTaskManager:
)
# #endregion _FakeTaskManager
# [/DEF:_FakeTaskManager:Class]
# @CONTRACT: Partial ConfigManager stub for authz tests. Missing: get_config().
# #region _FakeConfigManager [C:1] [TYPE Class]
# @BRIEF Provide deterministic environment aliases required by intent parsing.
# @PRE No external config or DB state is required.
# @POST get_environments() returns two deterministic SimpleNamespace stubs with id/name.
# @INVARIANT get_config() is absent; only get_environments() is emulated. Safe only for routes that do not invoke get_config() on the injected ConfigManager — verify against assistant.py route handler code before adding new test cases that use this fake.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [DEF:_FakeConfigManager:Class]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Provide deterministic environment aliases required by intent parsing.
# @PRE: No external config or DB state is required.
# @POST: get_environments() returns two deterministic SimpleNamespace stubs with id/name.
# @INVARIANT: get_config() is absent; only get_environments() is emulated. Safe only for routes that do not invoke get_config() on the injected ConfigManager — verify against assistant.py route handler code before adding new test cases that use this fake.
class _FakeConfigManager:
def get_environments(self):
return [
@@ -109,46 +115,50 @@ class _FakeConfigManager:
)
# #endregion _FakeConfigManager
# #region _admin_user [C:1] [TYPE Function]
# @BRIEF Build admin principal fixture.
# @PRE Test requires privileged principal for risky operations.
# @POST Returns admin-like user stub with Admin role.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [/DEF:_FakeConfigManager:Class]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Build admin principal fixture.
# @PRE: Test requires privileged principal for risky operations.
# @POST: Returns admin-like user stub with Admin role.
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(id="u-admin", username="admin", roles=[role])
# #endregion _admin_user
# #region _other_admin_user [C:1] [TYPE Function]
# @BRIEF Build second admin principal fixture for ownership tests.
# @PRE Ownership mismatch scenario needs distinct authenticated actor.
# @POST Returns alternate admin-like user stub.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [/DEF:_admin_user:Function]
# [DEF:_other_admin_user:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Build second admin principal fixture for ownership tests.
# @PRE: Ownership mismatch scenario needs distinct authenticated actor.
# @POST: Returns alternate admin-like user stub.
def _other_admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(id="u-admin-2", username="admin2", roles=[role])
# #endregion _other_admin_user
# #region _limited_user [C:1] [TYPE Function]
# @BRIEF Build limited principal without required assistant execution privileges.
# @PRE Permission denial scenario needs non-admin actor.
# @POST Returns restricted user stub.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [/DEF:_other_admin_user:Function]
# [DEF:_limited_user:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Build limited principal without required assistant execution privileges.
# @PRE: Permission denial scenario needs non-admin actor.
# @POST: Returns restricted user stub.
def _limited_user():
role = SimpleNamespace(name="Operator", permissions=[])
return SimpleNamespace(id="u-limited", username="limited", roles=[role])
# #endregion _limited_user
# [/DEF:_limited_user:Function]
# #region _FakeQuery [C:1] [TYPE Class]
# @BRIEF Minimal chainable query object for fake DB interactions.
# @INVARIANT filter() deliberately discards predicate args and returns self; tests must not assume predicate evaluation.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [DEF:_FakeQuery:Class]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Minimal chainable query object for fake DB interactions.
# @INVARIANT: filter() deliberately discards predicate args and returns self; tests must not assume predicate evaluation.
class _FakeQuery:
def __init__(self, rows):
self._rows = list(rows)
@@ -178,11 +188,12 @@ class _FakeQuery:
return len(self._rows)
# #endregion _FakeQuery
# #region _FakeDb [C:2] [TYPE Class]
# @BRIEF In-memory DB session double constrained to assistant message/confirmation/audit persistence paths.
# @INVARIANT query/add/merge are intentionally narrow and must not claim full SQLAlchemy Session semantics.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [/DEF:_FakeQuery:Class]
# [DEF:_FakeDb:Class]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 2
# @PURPOSE: In-memory DB session double constrained to assistant message/confirmation/audit persistence paths.
# @INVARIANT: query/add/merge are intentionally narrow and must not claim full SQLAlchemy Session semantics.
class _FakeDb:
def __init__(self):
self._messages = []
@@ -226,12 +237,13 @@ class _FakeDb:
return None
# #endregion _FakeDb
# #region _clear_assistant_state [C:1] [TYPE Function]
# @BRIEF Reset assistant process-local state between test cases.
# @PRE Assistant globals may contain state from prior tests.
# @POST Assistant in-memory state dictionaries are cleared.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [/DEF:_FakeDb:Class]
# [DEF:_clear_assistant_state:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @COMPLEXITY: 1
# @PURPOSE: Reset assistant process-local state between test cases.
# @PRE: Assistant globals may contain state from prior tests.
# @POST: Assistant in-memory state dictionaries are cleared.
def _clear_assistant_state():
assistant_module.CONVERSATIONS.clear()
assistant_module.USER_ACTIVE_CONVERSATION.clear()
@@ -239,14 +251,14 @@ def _clear_assistant_state():
assistant_module.ASSISTANT_AUDIT.clear()
# #endregion _clear_assistant_state
# [/DEF:_clear_assistant_state:Function]
# #region test_confirmation_owner_mismatch_returns_403 [TYPE Function]
# @BRIEF Confirm endpoint should reject requests from user that does not own the confirmation token.
# @PRE Confirmation token is created by first admin actor.
# @POST Second actor receives 403 on confirm operation.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [DEF:test_confirmation_owner_mismatch_returns_403:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @PURPOSE: Confirm endpoint should reject requests from user that does not own the confirmation token.
# @PRE: Confirmation token is created by first admin actor.
# @POST: Second actor receives 403 on confirm operation.
def test_confirmation_owner_mismatch_returns_403():
_clear_assistant_state()
task_manager = _FakeTaskManager()
@@ -278,14 +290,14 @@ def test_confirmation_owner_mismatch_returns_403():
assert exc.value.status_code == 403
# #endregion test_confirmation_owner_mismatch_returns_403
# [/DEF:test_confirmation_owner_mismatch_returns_403:Function]
# #region test_expired_confirmation_cannot_be_confirmed [TYPE Function]
# @BRIEF Expired confirmation token should be rejected and not create task.
# @PRE Confirmation token exists and is manually expired before confirm request.
# @POST Confirm endpoint raises 400 and no task is created.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [DEF:test_expired_confirmation_cannot_be_confirmed:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @PURPOSE: Expired confirmation token should be rejected and not create task.
# @PRE: Confirmation token exists and is manually expired before confirm request.
# @POST: Confirm endpoint raises 400 and no task is created.
def test_expired_confirmation_cannot_be_confirmed():
_clear_assistant_state()
task_manager = _FakeTaskManager()
@@ -320,14 +332,14 @@ def test_expired_confirmation_cannot_be_confirmed():
assert task_manager.get_tasks(limit=10, offset=0) == []
# #endregion test_expired_confirmation_cannot_be_confirmed
# [/DEF:test_expired_confirmation_cannot_be_confirmed:Function]
# #region test_limited_user_cannot_launch_restricted_operation [TYPE Function]
# @BRIEF Limited user should receive denied state for privileged operation.
# @PRE Restricted user attempts dangerous deploy command.
# @POST Assistant returns denied state and does not execute operation.
# @RELATION BINDS_TO -> [TestAssistantAuthz]
# [DEF:test_limited_user_cannot_launch_restricted_operation:Function]
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
# @PURPOSE: Limited user should receive denied state for privileged operation.
# @PRE: Restricted user attempts dangerous deploy command.
# @POST: Assistant returns denied state and does not execute operation.
def test_limited_user_cannot_launch_restricted_operation():
_clear_assistant_state()
response = _run_async(
@@ -344,5 +356,5 @@ def test_limited_user_cannot_launch_restricted_operation():
assert response.state == "denied"
# #endregion test_limited_user_cannot_launch_restricted_operation
# #endregion TestAssistantAuthz
# [/DEF:test_limited_user_cannot_launch_restricted_operation:Function]
# [/DEF:TestAssistantAuthz:Module]

View File

@@ -1,8 +1,10 @@
# #region TestCleanReleaseApi [C:3] [TYPE Module] [SEMANTICS tests, api, clean-release, checks, reports]
# @BRIEF Contract tests for clean release checks and reports endpoints.
# @LAYER Domain
# @INVARIANT API returns deterministic payload shapes for checks and reports.
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:TestCleanReleaseApi:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, api, clean-release, checks, reports
# @PURPOSE: Contract tests for clean release checks and reports endpoints.
# @LAYER: Domain
# @INVARIANT: API returns deterministic payload shapes for checks and reports.
from datetime import datetime, timezone
@@ -23,8 +25,8 @@ from src.models.clean_release import (
from src.services.clean_release.repository import CleanReleaseRepository
# #region _repo_with_seed_data [TYPE Function]
# @RELATION BINDS_TO -> [TestCleanReleaseApi]
# [DEF:_repo_with_seed_data:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
def _repo_with_seed_data() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
repo.save_candidate(
@@ -72,12 +74,12 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
return repo
# #endregion _repo_with_seed_data
# [/DEF:_repo_with_seed_data:Function]
# #region test_start_check_and_get_status_contract [TYPE Function]
# @BRIEF Validate checks start endpoint returns expected identifiers and status endpoint reflects the same run.
# @RELATION BINDS_TO -> [TestCleanReleaseApi]
# [DEF:test_start_check_and_get_status_contract:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
# @PURPOSE: Validate checks start endpoint returns expected identifiers and status endpoint reflects the same run.
def test_start_check_and_get_status_contract():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -110,12 +112,12 @@ def test_start_check_and_get_status_contract():
app.dependency_overrides.clear()
# #endregion test_start_check_and_get_status_contract
# [/DEF:test_start_check_and_get_status_contract:Function]
# #region test_get_report_not_found_returns_404 [TYPE Function]
# @BRIEF Validate reports endpoint returns 404 for an unknown report identifier.
# @RELATION BINDS_TO -> [TestCleanReleaseApi]
# [DEF:test_get_report_not_found_returns_404:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
# @PURPOSE: Validate reports endpoint returns 404 for an unknown report identifier.
def test_get_report_not_found_returns_404():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -127,12 +129,12 @@ def test_get_report_not_found_returns_404():
app.dependency_overrides.clear()
# #endregion test_get_report_not_found_returns_404
# [/DEF:test_get_report_not_found_returns_404:Function]
# #region test_get_report_success [TYPE Function]
# @BRIEF Validate reports endpoint returns persisted report payload for an existing report identifier.
# @RELATION BINDS_TO -> [TestCleanReleaseApi]
# [DEF:test_get_report_success:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
# @PURPOSE: Validate reports endpoint returns persisted report payload for an existing report identifier.
def test_get_report_success():
repo = _repo_with_seed_data()
report = ComplianceReport(
@@ -157,12 +159,12 @@ def test_get_report_success():
app.dependency_overrides.clear()
# #endregion test_get_report_success
# [/DEF:test_get_report_success:Function]
# #region test_prepare_candidate_api_success [TYPE Function]
# @BRIEF Validate candidate preparation endpoint returns prepared status and manifest identifier on valid input.
# @RELATION BINDS_TO -> [TestCleanReleaseApi]
# [DEF:test_prepare_candidate_api_success:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseApi
# @PURPOSE: Validate candidate preparation endpoint returns prepared status and manifest identifier on valid input.
def test_prepare_candidate_api_success():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -187,5 +189,5 @@ def test_prepare_candidate_api_success():
app.dependency_overrides.clear()
# #endregion test_prepare_candidate_api_success
# #endregion TestCleanReleaseApi
# [/DEF:test_prepare_candidate_api_success:Function]
# [/DEF:TestCleanReleaseApi:Module]

View File

@@ -1,7 +1,8 @@
# #region TestCleanReleaseLegacyCompat [C:3] [TYPE Module]
# @BRIEF Compatibility tests for legacy clean-release API paths retained during v2 migration.
# @LAYER Tests
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:TestCleanReleaseLegacyCompat:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @PURPOSE: Compatibility tests for legacy clean-release API paths retained during v2 migration.
# @LAYER: Tests
from __future__ import annotations
@@ -29,11 +30,11 @@ from src.models.clean_release import (
from src.services.clean_release.repository import CleanReleaseRepository
# #region _seed_legacy_repo [TYPE Function]
# @BRIEF Seed in-memory repository with minimum trusted data for legacy endpoint contracts.
# @PRE Repository is empty.
# @POST Candidate, policy, registry and manifest are available for legacy checks flow.
# @RELATION BINDS_TO -> [TestCleanReleaseLegacyCompat]
# [DEF:_seed_legacy_repo:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseLegacyCompat
# @PURPOSE: Seed in-memory repository with minimum trusted data for legacy endpoint contracts.
# @PRE: Repository is empty.
# @POST: Candidate, policy, registry and manifest are available for legacy checks flow.
def _seed_legacy_repo() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
now = datetime.now(timezone.utc)
@@ -115,12 +116,12 @@ def _seed_legacy_repo() -> CleanReleaseRepository:
return repo
# #endregion _seed_legacy_repo
# [/DEF:_seed_legacy_repo:Function]
# #region test_legacy_prepare_endpoint_still_available [TYPE Function]
# @BRIEF Verify legacy prepare endpoint remains reachable and returns a status payload.
# @RELATION BINDS_TO -> [TestCleanReleaseLegacyCompat]
# [DEF:test_legacy_prepare_endpoint_still_available:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseLegacyCompat
# @PURPOSE: Verify legacy prepare endpoint remains reachable and returns a status payload.
def test_legacy_prepare_endpoint_still_available() -> None:
repo = _seed_legacy_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -145,12 +146,12 @@ def test_legacy_prepare_endpoint_still_available() -> None:
app.dependency_overrides.clear()
# #endregion test_legacy_prepare_endpoint_still_available
# [/DEF:test_legacy_prepare_endpoint_still_available:Function]
# #region test_legacy_checks_endpoints_still_available [TYPE Function]
# @BRIEF Verify legacy checks start/status endpoints remain available during v2 transition.
# @RELATION BINDS_TO -> [TestCleanReleaseLegacyCompat]
# [DEF:test_legacy_checks_endpoints_still_available:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseLegacyCompat
# @PURPOSE: Verify legacy checks start/status endpoints remain available during v2 transition.
def test_legacy_checks_endpoints_still_available() -> None:
repo = _seed_legacy_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -182,5 +183,5 @@ def test_legacy_checks_endpoints_still_available() -> None:
app.dependency_overrides.clear()
# #endregion test_legacy_checks_endpoints_still_available
# #endregion TestCleanReleaseLegacyCompat
# [/DEF:test_legacy_checks_endpoints_still_available:Function]
# [/DEF:TestCleanReleaseLegacyCompat:Module]

View File

@@ -1,8 +1,10 @@
# #region TestCleanReleaseSourcePolicy [C:3] [TYPE Module] [SEMANTICS tests, api, clean-release, source-policy]
# @BRIEF Validate API behavior for source isolation violations in clean release preparation.
# @LAYER Domain
# @INVARIANT External endpoints must produce blocking violation entries.
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:TestCleanReleaseSourcePolicy:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, api, clean-release, source-policy
# @PURPOSE: Validate API behavior for source isolation violations in clean release preparation.
# @LAYER: Domain
# @INVARIANT: External endpoints must produce blocking violation entries.
from datetime import datetime, timezone
from fastapi.testclient import TestClient
@@ -20,9 +22,9 @@ from src.models.clean_release import (
from src.services.clean_release.repository import CleanReleaseRepository
# #region _repo_with_seed_data [TYPE Function]
# @BRIEF Seed repository with candidate, registry, and active policy for source isolation test flow.
# @RELATION BINDS_TO -> [TestCleanReleaseSourcePolicy]
# [DEF:_repo_with_seed_data:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseSourcePolicy
# @PURPOSE: Seed repository with candidate, registry, and active policy for source isolation test flow.
def _repo_with_seed_data() -> CleanReleaseRepository:
repo = CleanReleaseRepository()
@@ -73,12 +75,12 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
return repo
# #endregion _repo_with_seed_data
# [/DEF:_repo_with_seed_data:Function]
# #region test_prepare_candidate_blocks_external_source [TYPE Function]
# @BRIEF Verify candidate preparation is blocked when at least one source host is external to the trusted registry.
# @RELATION BINDS_TO -> [TestCleanReleaseSourcePolicy]
# [DEF:test_prepare_candidate_blocks_external_source:Function]
# @RELATION: BINDS_TO -> TestCleanReleaseSourcePolicy
# @PURPOSE: Verify candidate preparation is blocked when at least one source host is external to the trusted registry.
def test_prepare_candidate_blocks_external_source():
repo = _repo_with_seed_data()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
@@ -108,5 +110,5 @@ def test_prepare_candidate_blocks_external_source():
app.dependency_overrides.clear()
# #endregion test_prepare_candidate_blocks_external_source
# #endregion TestCleanReleaseSourcePolicy
# [/DEF:test_prepare_candidate_blocks_external_source:Function]
# [/DEF:TestCleanReleaseSourcePolicy:Module]

View File

@@ -1,7 +1,8 @@
# #region CleanReleaseV2ApiTests [C:3] [TYPE Module]
# @BRIEF API contract tests for redesigned clean release endpoints.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [CleanReleaseV2Api]
# [DEF:CleanReleaseV2ApiTests:Module]
# @COMPLEXITY: 3
# @PURPOSE: API contract tests for redesigned clean release endpoints.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [CleanReleaseV2Api]
from datetime import datetime, timezone
from types import SimpleNamespace
@@ -24,9 +25,9 @@ client = TestClient(app)
# [REASON] Implementing API contract tests for candidate/artifact/manifest endpoints (T012).
# #region test_candidate_registration_contract [TYPE Function]
# @BRIEF Validate candidate registration endpoint creates a draft candidate with expected identifier contract.
# @RELATION BINDS_TO -> [CleanReleaseV2ApiTests]
# [DEF:test_candidate_registration_contract:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ApiTests
# @PURPOSE: Validate candidate registration endpoint creates a draft candidate with expected identifier contract.
def test_candidate_registration_contract():
"""
@TEST_SCENARIO: candidate_registration -> Should return 201 and candidate DTO.
@@ -45,12 +46,12 @@ def test_candidate_registration_contract():
assert data["status"] == CandidateStatus.DRAFT.value
# #endregion test_candidate_registration_contract
# [/DEF:test_candidate_registration_contract:Function]
# #region test_artifact_import_contract [TYPE Function]
# @BRIEF Validate artifact import endpoint accepts candidate artifacts and returns success status payload.
# @RELATION BINDS_TO -> [CleanReleaseV2ApiTests]
# [DEF:test_artifact_import_contract:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ApiTests
# @PURPOSE: Validate artifact import endpoint accepts candidate artifacts and returns success status payload.
def test_artifact_import_contract():
"""
@TEST_SCENARIO: artifact_import -> Should return 200 and success status.
@@ -80,12 +81,12 @@ def test_artifact_import_contract():
assert response.json()["status"] == "success"
# #endregion test_artifact_import_contract
# [/DEF:test_artifact_import_contract:Function]
# #region test_manifest_build_contract [TYPE Function]
# @BRIEF Validate manifest build endpoint produces manifest payload linked to the target candidate.
# @RELATION BINDS_TO -> [CleanReleaseV2ApiTests]
# [DEF:test_manifest_build_contract:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ApiTests
# @PURPOSE: Validate manifest build endpoint produces manifest payload linked to the target candidate.
def test_manifest_build_contract():
"""
@TEST_SCENARIO: manifest_build -> Should return 201 and manifest DTO.
@@ -110,5 +111,5 @@ def test_manifest_build_contract():
assert data["candidate_id"] == candidate_id
# #endregion test_manifest_build_contract
# #endregion CleanReleaseV2ApiTests
# [/DEF:test_manifest_build_contract:Function]
# [/DEF:CleanReleaseV2ApiTests:Module]

View File

@@ -1,7 +1,8 @@
# #region CleanReleaseV2ReleaseApiTests [C:3] [TYPE Module]
# @BRIEF API contract test scaffolding for clean release approval and publication endpoints.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [CleanReleaseV2Api]
# [DEF:CleanReleaseV2ReleaseApiTests:Module]
# @COMPLEXITY: 3
# @PURPOSE: API contract test scaffolding for clean release approval and publication endpoints.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [CleanReleaseV2Api]
"""Contract tests for redesigned approval/publication API endpoints."""
@@ -22,9 +23,9 @@ test_app.include_router(clean_release_v2_router)
client = TestClient(test_app)
# #region _seed_candidate_and_passed_report [TYPE Function]
# @BRIEF Seed repository with approvable candidate and passed report for release endpoint contracts.
# @RELATION BINDS_TO -> [CleanReleaseV2ReleaseApiTests]
# [DEF:_seed_candidate_and_passed_report:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ReleaseApiTests
# @PURPOSE: Seed repository with approvable candidate and passed report for release endpoint contracts.
def _seed_candidate_and_passed_report() -> tuple[str, str]:
repository = get_clean_release_repository()
candidate_id = f"api-release-candidate-{uuid4()}"
@@ -58,12 +59,12 @@ def _seed_candidate_and_passed_report() -> tuple[str, str]:
return candidate_id, report_id
# #endregion _seed_candidate_and_passed_report
# [/DEF:_seed_candidate_and_passed_report:Function]
# #region test_release_approve_and_publish_revoke_contract [TYPE Function]
# @BRIEF Verify approve, publish, and revoke endpoints preserve expected release lifecycle contract.
# @RELATION BINDS_TO -> [CleanReleaseV2ReleaseApiTests]
# [DEF:test_release_approve_and_publish_revoke_contract:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ReleaseApiTests
# @PURPOSE: Verify approve, publish, and revoke endpoints preserve expected release lifecycle contract.
def test_release_approve_and_publish_revoke_contract() -> None:
"""Contract for approve -> publish -> revoke lifecycle endpoints."""
candidate_id, report_id = _seed_candidate_and_passed_report()
@@ -102,12 +103,12 @@ def test_release_approve_and_publish_revoke_contract() -> None:
assert revoke_payload["publication"]["status"] == "REVOKED"
# #endregion test_release_approve_and_publish_revoke_contract
# [/DEF:test_release_approve_and_publish_revoke_contract:Function]
# #region test_release_reject_contract [TYPE Function]
# @BRIEF Verify reject endpoint returns successful rejection decision payload.
# @RELATION BINDS_TO -> [CleanReleaseV2ReleaseApiTests]
# [DEF:test_release_reject_contract:Function]
# @RELATION: BINDS_TO -> CleanReleaseV2ReleaseApiTests
# @PURPOSE: Verify reject endpoint returns successful rejection decision payload.
def test_release_reject_contract() -> None:
"""Contract for reject endpoint."""
candidate_id, report_id = _seed_candidate_and_passed_report()
@@ -122,5 +123,5 @@ def test_release_reject_contract() -> None:
assert payload["decision"] == "REJECTED"
# #endregion test_release_reject_contract
# #endregion CleanReleaseV2ReleaseApiTests
# [/DEF:test_release_reject_contract:Function]
# [/DEF:CleanReleaseV2ReleaseApiTests:Module]

View File

@@ -1,7 +1,8 @@
# #region ConnectionsRoutesTests [C:3] [TYPE Module]
# @BRIEF Verifies connection routes bootstrap their table before CRUD access.
# @LAYER API
# @RELATION DEPENDS_ON -> [ConnectionsRouter]
# [DEF:ConnectionsRoutesTests:Module]
# @COMPLEXITY: 3
# @PURPOSE: Verifies connection routes bootstrap their table before CRUD access.
# @LAYER: API
# @RELATION: DEPENDS_ON -> ConnectionsRouter
import os
import sys
@@ -42,9 +43,9 @@ def db_session():
session.close()
# #region test_list_connections_bootstraps_missing_table [TYPE Function]
# @BRIEF Ensure listing connections auto-creates missing table and returns empty payload.
# @RELATION BINDS_TO -> [ConnectionsRoutesTests]
# [DEF:test_list_connections_bootstraps_missing_table:Function]
# @RELATION: BINDS_TO -> ConnectionsRoutesTests
# @PURPOSE: Ensure listing connections auto-creates missing table and returns empty payload.
def test_list_connections_bootstraps_missing_table(db_session):
from src.api.routes.connections import list_connections
@@ -55,12 +56,12 @@ def test_list_connections_bootstraps_missing_table(db_session):
assert "connection_configs" in inspector.get_table_names()
# #endregion test_list_connections_bootstraps_missing_table
# [/DEF:test_list_connections_bootstraps_missing_table:Function]
# #region test_create_connection_bootstraps_missing_table [TYPE Function]
# @BRIEF Ensure connection creation bootstraps table and persists returned connection fields.
# @RELATION BINDS_TO -> [ConnectionsRoutesTests]
# [DEF:test_create_connection_bootstraps_missing_table:Function]
# @RELATION: BINDS_TO -> ConnectionsRoutesTests
# @PURPOSE: Ensure connection creation bootstraps table and persists returned connection fields.
def test_create_connection_bootstraps_missing_table(db_session):
from src.api.routes.connections import ConnectionCreate, create_connection
@@ -82,5 +83,5 @@ def test_create_connection_bootstraps_missing_table(db_session):
assert "connection_configs" in inspector.get_table_names()
# #endregion test_create_connection_bootstraps_missing_table
# #endregion ConnectionsRoutesTests
# [/DEF:test_create_connection_bootstraps_missing_table:Function]
# [/DEF:ConnectionsRoutesTests:Module]

View File

@@ -1,7 +1,8 @@
# #region DashboardsApiTests [C:3] [TYPE Module]
# @BRIEF Unit tests for dashboards API endpoints.
# @LAYER API
# @RELATION DEPENDS_ON -> [DashboardsApi]
# [DEF:DashboardsApiTests:Module]
# @COMPLEXITY: 3
# @PURPOSE: Unit tests for dashboards API endpoints.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [DashboardsApi]
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
@@ -70,9 +71,9 @@ def mock_deps():
client = TestClient(app)
# #region test_get_dashboards_success [TYPE Function]
# @BRIEF Validate dashboards listing returns a populated response that satisfies the schema contract.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing returns a populated response that satisfies the schema contract.
# @TEST: GET /api/dashboards returns 200 and valid schema
# @PRE: env_id exists
# @POST: Response matches DashboardsResponse schema
@@ -109,12 +110,12 @@ def test_get_dashboards_success(mock_deps):
DashboardsResponse(**data)
# #endregion test_get_dashboards_success
# [/DEF:test_get_dashboards_success:Function]
# #region test_get_dashboards_with_search [TYPE Function]
# @BRIEF Validate dashboards listing applies the search filter and returns only matching rows.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_with_search:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing applies the search filter and returns only matching rows.
# @TEST: GET /api/dashboards filters by search term
# @PRE: search parameter provided
# @POST: Only matching dashboards returned
@@ -155,12 +156,12 @@ def test_get_dashboards_with_search(mock_deps):
assert data["dashboards"][0]["title"] == "Sales Report"
# #endregion test_get_dashboards_with_search
# [/DEF:test_get_dashboards_with_search:Function]
# #region test_get_dashboards_empty [TYPE Function]
# @BRIEF Validate dashboards listing returns an empty payload for an environment without dashboards.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_empty:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing returns an empty payload for an environment without dashboards.
# @TEST_EDGE: empty_dashboards -> {env_id: 'empty_env', expected_total: 0}
def test_get_dashboards_empty(mock_deps):
"""@TEST_EDGE: empty_dashboards -> {env_id: 'empty_env', expected_total: 0}"""
@@ -179,12 +180,12 @@ def test_get_dashboards_empty(mock_deps):
DashboardsResponse(**data)
# #endregion test_get_dashboards_empty
# [/DEF:test_get_dashboards_empty:Function]
# #region test_get_dashboards_superset_failure [TYPE Function]
# @BRIEF Validate dashboards listing surfaces a 503 contract when Superset access fails.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_superset_failure:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing surfaces a 503 contract when Superset access fails.
# @TEST_EDGE: external_superset_failure -> {env_id: 'bad_conn', status: 503}
def test_get_dashboards_superset_failure(mock_deps):
"""@TEST_EDGE: external_superset_failure -> {env_id: 'bad_conn', status: 503}"""
@@ -201,12 +202,12 @@ def test_get_dashboards_superset_failure(mock_deps):
assert "Failed to fetch dashboards" in response.json()["detail"]
# #endregion test_get_dashboards_superset_failure
# [/DEF:test_get_dashboards_superset_failure:Function]
# #region test_get_dashboards_env_not_found [TYPE Function]
# @BRIEF Validate dashboards listing returns 404 when the requested environment does not exist.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_env_not_found:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing returns 404 when the requested environment does not exist.
# @TEST: GET /api/dashboards returns 404 if env_id missing
# @PRE: env_id does not exist
# @POST: Returns 404 error
@@ -218,12 +219,12 @@ def test_get_dashboards_env_not_found(mock_deps):
assert "Environment not found" in response.json()["detail"]
# #endregion test_get_dashboards_env_not_found
# [/DEF:test_get_dashboards_env_not_found:Function]
# #region test_get_dashboards_invalid_pagination [TYPE Function]
# @BRIEF Validate dashboards listing rejects invalid pagination parameters with 400 responses.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_invalid_pagination:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboards listing rejects invalid pagination parameters with 400 responses.
# @TEST: GET /api/dashboards returns 400 for invalid page/page_size
# @PRE: page < 1 or page_size > 100
# @POST: Returns 400 error
@@ -242,12 +243,12 @@ def test_get_dashboards_invalid_pagination(mock_deps):
assert "Page size must be between 1 and 100" in response.json()["detail"]
# #endregion test_get_dashboards_invalid_pagination
# [/DEF:test_get_dashboards_invalid_pagination:Function]
# #region test_get_dashboard_detail_success [TYPE Function]
# @BRIEF Validate dashboard detail returns charts and datasets for an existing dashboard.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboard_detail_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboard detail returns charts and datasets for an existing dashboard.
# @TEST: GET /api/dashboards/{id} returns dashboard detail with charts and datasets
def test_get_dashboard_detail_success(mock_deps):
with patch("src.api.routes.dashboards._detail_routes.SupersetClient") as mock_client_cls:
@@ -298,12 +299,12 @@ def test_get_dashboard_detail_success(mock_deps):
assert payload["dataset_count"] == 1
# #endregion test_get_dashboard_detail_success
# [/DEF:test_get_dashboard_detail_success:Function]
# #region test_get_dashboard_detail_env_not_found [TYPE Function]
# @BRIEF Validate dashboard detail returns 404 when the requested environment is missing.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboard_detail_env_not_found:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboard detail returns 404 when the requested environment is missing.
# @TEST: GET /api/dashboards/{id} returns 404 for missing environment
def test_get_dashboard_detail_env_not_found(mock_deps):
mock_deps["config"].get_environments.return_value = []
@@ -314,11 +315,11 @@ def test_get_dashboard_detail_env_not_found(mock_deps):
assert "Environment not found" in response.json()["detail"]
# #endregion test_get_dashboard_detail_env_not_found
# [/DEF:test_get_dashboard_detail_env_not_found:Function]
# #region test_migrate_dashboards_success [TYPE Function]
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_migrate_dashboards_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: POST /api/dashboards/migrate creates migration task
# @PRE: Valid source_env_id, target_env_id, dashboard_ids
# @PURPOSE: Validate dashboard migration request creates an async task and returns its identifier.
@@ -351,11 +352,11 @@ def test_migrate_dashboards_success(mock_deps):
mock_deps["task"].create_task.assert_called_once()
# #endregion test_migrate_dashboards_success
# [/DEF:test_migrate_dashboards_success:Function]
# #region test_migrate_dashboards_no_ids [TYPE Function]
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_migrate_dashboards_no_ids:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: POST /api/dashboards/migrate returns 400 for empty dashboard_ids
# @PRE: dashboard_ids is empty
# @PURPOSE: Validate dashboard migration rejects empty dashboard identifier lists.
@@ -374,13 +375,13 @@ def test_migrate_dashboards_no_ids(mock_deps):
assert "At least one dashboard ID must be provided" in response.json()["detail"]
# #endregion test_migrate_dashboards_no_ids
# [/DEF:test_migrate_dashboards_no_ids:Function]
# #region test_migrate_dashboards_env_not_found [TYPE Function]
# @BRIEF Validate migration creation returns 404 when the source environment cannot be resolved.
# @PRE source_env_id and target_env_id are valid environment IDs
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_migrate_dashboards_env_not_found:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate migration creation returns 404 when the source environment cannot be resolved.
# @PRE: source_env_id and target_env_id are valid environment IDs
def test_migrate_dashboards_env_not_found(mock_deps):
"""@PRE: source_env_id and target_env_id are valid environment IDs."""
mock_deps["config"].get_environments.return_value = []
@@ -392,11 +393,11 @@ def test_migrate_dashboards_env_not_found(mock_deps):
assert "Source environment not found" in response.json()["detail"]
# #endregion test_migrate_dashboards_env_not_found
# [/DEF:test_migrate_dashboards_env_not_found:Function]
# #region test_backup_dashboards_success [TYPE Function]
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_backup_dashboards_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: POST /api/dashboards/backup creates backup task
# @PRE: Valid env_id, dashboard_ids
# @PURPOSE: Validate dashboard backup request creates an async backup task and returns its identifier.
@@ -422,13 +423,13 @@ def test_backup_dashboards_success(mock_deps):
mock_deps["task"].create_task.assert_called_once()
# #endregion test_backup_dashboards_success
# [/DEF:test_backup_dashboards_success:Function]
# #region test_backup_dashboards_env_not_found [TYPE Function]
# @BRIEF Validate backup task creation returns 404 when the target environment is missing.
# @PRE env_id is a valid environment ID
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_backup_dashboards_env_not_found:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate backup task creation returns 404 when the target environment is missing.
# @PRE: env_id is a valid environment ID
def test_backup_dashboards_env_not_found(mock_deps):
"""@PRE: env_id is a valid environment ID."""
mock_deps["config"].get_environments.return_value = []
@@ -439,11 +440,11 @@ def test_backup_dashboards_env_not_found(mock_deps):
assert "Environment not found" in response.json()["detail"]
# #endregion test_backup_dashboards_env_not_found
# [/DEF:test_backup_dashboards_env_not_found:Function]
# #region test_get_database_mappings_success [TYPE Function]
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_database_mappings_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards/db-mappings returns mapping suggestions
# @PRE: Valid source_env_id, target_env_id
# @PURPOSE: Validate database mapping suggestions are returned for valid source and target environments.
@@ -478,13 +479,13 @@ def test_get_database_mappings_success(mock_deps):
assert data["mappings"][0]["confidence"] == 0.95
# #endregion test_get_database_mappings_success
# [/DEF:test_get_database_mappings_success:Function]
# #region test_get_database_mappings_env_not_found [TYPE Function]
# @BRIEF Validate database mapping suggestions return 404 when either environment is missing.
# @PRE source_env_id and target_env_id are valid environment IDs
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_database_mappings_env_not_found:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate database mapping suggestions return 404 when either environment is missing.
# @PRE: source_env_id and target_env_id are valid environment IDs
def test_get_database_mappings_env_not_found(mock_deps):
"""@PRE: source_env_id must be a valid environment."""
mock_deps["config"].get_environments.return_value = []
@@ -494,12 +495,12 @@ def test_get_database_mappings_env_not_found(mock_deps):
assert response.status_code == 404
# #endregion test_get_database_mappings_env_not_found
# [/DEF:test_get_database_mappings_env_not_found:Function]
# #region test_get_dashboard_tasks_history_filters_success [TYPE Function]
# @BRIEF Validate dashboard task history returns only related backup and LLM tasks.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboard_tasks_history_filters_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboard task history returns only related backup and LLM tasks.
# @TEST: GET /api/dashboards/{id}/tasks returns backup and llm tasks for dashboard
def test_get_dashboard_tasks_history_filters_success(mock_deps):
now = datetime.now(timezone.utc)
@@ -545,12 +546,12 @@ def test_get_dashboard_tasks_history_filters_success(mock_deps):
}
# #endregion test_get_dashboard_tasks_history_filters_success
# [/DEF:test_get_dashboard_tasks_history_filters_success:Function]
# #region test_get_dashboard_thumbnail_success [TYPE Function]
# @BRIEF Validate dashboard thumbnail endpoint proxies image bytes and content type from Superset.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboard_thumbnail_success:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Validate dashboard thumbnail endpoint proxies image bytes and content type from Superset.
# @TEST: GET /api/dashboards/{id}/thumbnail proxies image bytes from Superset
def test_get_dashboard_thumbnail_success(mock_deps):
with patch("src.api.routes.dashboards._detail_routes.SupersetClient") as mock_client_cls:
@@ -579,14 +580,14 @@ def test_get_dashboard_thumbnail_success(mock_deps):
assert response.headers["content-type"].startswith("image/png")
# #endregion test_get_dashboard_thumbnail_success
# [/DEF:test_get_dashboard_thumbnail_success:Function]
# #region _build_profile_preference_stub [TYPE Function]
# @BRIEF Creates profile preference payload stub for dashboards filter contract tests.
# @PRE username can be empty; enabled indicates profile-default toggle state.
# @POST Returns object compatible with ProfileService.get_my_preference contract.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:_build_profile_preference_stub:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Creates profile preference payload stub for dashboards filter contract tests.
# @PRE: username can be empty; enabled indicates profile-default toggle state.
# @POST: Returns object compatible with ProfileService.get_my_preference contract.
def _build_profile_preference_stub(username: str, enabled: bool):
preference = MagicMock()
preference.superset_username = username
@@ -600,14 +601,14 @@ def _build_profile_preference_stub(username: str, enabled: bool):
return payload
# #endregion _build_profile_preference_stub
# [/DEF:_build_profile_preference_stub:Function]
# #region _matches_actor_case_insensitive [TYPE Function]
# @BRIEF Applies trim + case-insensitive owners OR modified_by matching used by route contract tests.
# @PRE owners can be None or list-like values.
# @POST Returns True when bound username matches any owner or modified_by.
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:_matches_actor_case_insensitive:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @PURPOSE: Applies trim + case-insensitive owners OR modified_by matching used by route contract tests.
# @PRE: owners can be None or list-like values.
# @POST: Returns True when bound username matches any owner or modified_by.
def _matches_actor_case_insensitive(bound_username, owners, modified_by):
normalized_bound = str(bound_username or "").strip().lower()
if not normalized_bound:
@@ -625,11 +626,11 @@ def _matches_actor_case_insensitive(bound_username, owners, modified_by):
)
# #endregion _matches_actor_case_insensitive
# [/DEF:_matches_actor_case_insensitive:Function]
# #region test_get_dashboards_profile_filter_contract_owners_or_modified_by [TYPE Function]
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_profile_filter_contract_owners_or_modified_by:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards applies profile-default filter with owners OR modified_by trim+case-insensitive semantics.
# @PURPOSE: Validate profile-default filtering matches owner and modifier aliases using normalized Superset actor values.
# @PRE: Current user has enabled profile-default preference and bound username.
@@ -692,11 +693,11 @@ def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps)
assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by"
# #endregion test_get_dashboards_profile_filter_contract_owners_or_modified_by
# [/DEF:test_get_dashboards_profile_filter_contract_owners_or_modified_by:Function]
# #region test_get_dashboards_override_show_all_contract [TYPE Function]
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_override_show_all_contract:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards honors override_show_all and disables profile-default filter for current page.
# @PURPOSE: Validate override_show_all bypasses profile-default filtering without changing dashboard list semantics.
# @PRE: Profile-default preference exists but override_show_all=true query is provided.
@@ -753,11 +754,11 @@ def test_get_dashboards_override_show_all_contract(mock_deps):
profile_service.matches_dashboard_actor.assert_not_called()
# #endregion test_get_dashboards_override_show_all_contract
# [/DEF:test_get_dashboards_override_show_all_contract:Function]
# #region test_get_dashboards_profile_filter_no_match_results_contract [TYPE Function]
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_profile_filter_no_match_results_contract:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards returns empty result set when profile-default filter is active and no dashboard actors match.
# @PURPOSE: Validate profile-default filtering returns an empty dashboard page when no actor aliases match the bound user.
# @PRE: Profile-default preference is enabled with bound username and all dashboards are non-matching.
@@ -816,11 +817,11 @@ def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
assert payload["effective_profile_filter"]["match_logic"] == "owners_or_modified_by"
# #endregion test_get_dashboards_profile_filter_no_match_results_contract
# [/DEF:test_get_dashboards_profile_filter_no_match_results_contract:Function]
# #region test_get_dashboards_page_context_other_disables_profile_default [TYPE Function]
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_page_context_other_disables_profile_default:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards does not auto-apply profile-default filter outside dashboards_main page context.
# @PURPOSE: Validate non-dashboard page contexts suppress profile-default filtering and preserve unfiltered results.
# @PRE: Profile-default preference exists but page_context=other query is provided.
@@ -877,11 +878,11 @@ def test_get_dashboards_page_context_other_disables_profile_default(mock_deps):
profile_service.matches_dashboard_actor.assert_not_called()
# #endregion test_get_dashboards_page_context_other_disables_profile_default
# [/DEF:test_get_dashboards_page_context_other_disables_profile_default:Function]
# #region test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout [TYPE Function]
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards resolves Superset display-name alias once and filters without per-dashboard detail calls.
# @PURPOSE: Validate profile-default filtering reuses resolved Superset display aliases without triggering per-dashboard detail fanout.
# @PRE: Profile-default filter is active, bound username is `admin`, dashboard actors contain display labels.
@@ -962,11 +963,11 @@ def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fano
superset_client.get_dashboard.assert_not_called()
# #endregion test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout
# [/DEF:test_get_dashboards_profile_filter_matches_display_alias_without_detail_fanout:Function]
# #region test_get_dashboards_profile_filter_matches_owner_object_payload_contract [TYPE Function]
# @RELATION BINDS_TO -> [DashboardsApiTests]
# [DEF:test_get_dashboards_profile_filter_matches_owner_object_payload_contract:Function]
# @RELATION: BINDS_TO -> DashboardsApiTests
# @TEST: GET /api/dashboards profile-default filter matches Superset owner object payloads.
# @PURPOSE: Validate profile-default filtering accepts owner object payloads once aliases resolve to the bound Superset username.
# @PRE: Profile-default preference is enabled and owners list contains dict payloads.
@@ -1044,7 +1045,7 @@ def test_get_dashboards_profile_filter_matches_owner_object_payload_contract(moc
assert payload["dashboards"][0]["title"] == "Featured Charts"
# #endregion test_get_dashboards_profile_filter_matches_owner_object_payload_contract
# [/DEF:test_get_dashboards_profile_filter_matches_owner_object_payload_contract:Function]
# #endregion DashboardsApiTests
# [/DEF:DashboardsApiTests:Module]

View File

@@ -1,8 +1,10 @@
# #region DatasetReviewApiTests [C:3] [TYPE Module] [SEMANTICS dataset_review, api, tests, lifecycle, exports, orchestration]
# @BRIEF Verify backend US1 dataset review lifecycle, export, parsing, and dictionary-resolution contracts.
# @LAYER API
# @RELATION BINDS_TO -> [DatasetReviewApi]
# @RELATION BINDS_TO -> [DatasetReviewOrchestrator]
# [DEF:DatasetReviewApiTests:Module]
# @COMPLEXITY: 3
# @SEMANTICS: dataset_review, api, tests, lifecycle, exports, orchestration
# @PURPOSE: Verify backend US1 dataset review lifecycle, export, parsing, and dictionary-resolution contracts.
# @LAYER: API
# @RELATION: [BINDS_TO] ->[DatasetReviewApi]
# @RELATION: [BINDS_TO] ->[DatasetReviewOrchestrator]
from datetime import datetime, timezone
import json
@@ -61,6 +63,9 @@ from src.services.dataset_review.orchestrator import (
PreparePreviewResult,
StartSessionCommand,
)
from src.services.dataset_review.orchestrator_pkg._helpers import (
build_execution_snapshot,
)
from src.services.dataset_review.semantic_resolver import SemanticSourceResolver
from src.services.dataset_review.event_logger import SessionEventLogger
from src.services.dataset_review.repositories.session_repository import (
@@ -71,8 +76,8 @@ from src.services.dataset_review.repositories.session_repository import (
client = TestClient(app)
# #region _make_user [TYPE Function]
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:_make_user:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_user():
permissions = [
SimpleNamespace(resource="dataset:session", action="READ"),
@@ -85,11 +90,11 @@ def _make_user():
return SimpleNamespace(id="user-1", username="tester", roles=[dataset_review_role])
# #endregion _make_user
# [/DEF:_make_user:Function]
# #region _make_config_manager [TYPE Function]
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:_make_config_manager:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_config_manager():
env = Environment(
id="env-1",
@@ -107,11 +112,11 @@ def _make_config_manager():
return manager
# #endregion _make_config_manager
# [/DEF:_make_config_manager:Function]
# #region _make_session [TYPE Function]
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:_make_session:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_session():
now = datetime.now(timezone.utc)
return DatasetReviewSession(
@@ -134,11 +139,11 @@ def _make_session():
)
# #endregion _make_session
# [/DEF:_make_session:Function]
# #region _make_us2_session [TYPE Function]
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:_make_us2_session:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_us2_session():
now = datetime.now(timezone.utc)
session = _make_session()
@@ -252,11 +257,11 @@ def _make_us2_session():
return session
# #endregion _make_us2_session
# [/DEF:_make_us2_session:Function]
# #region _make_us3_session [TYPE Function]
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:_make_us3_session:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_us3_session():
"""Fake session factory for US3 flow tests.
@@ -322,11 +327,11 @@ def _make_us3_session():
return session
# #endregion _make_us3_session
# [/DEF:_make_us3_session:Function]
# #region _make_preview_ready_session [TYPE Function]
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:_make_preview_ready_session:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_preview_ready_session():
session = _make_us3_session()
session.readiness_state = ReadinessState.COMPILED_PREVIEW_READY
@@ -335,11 +340,11 @@ def _make_preview_ready_session():
return session
# #endregion _make_preview_ready_session
# [/DEF:_make_preview_ready_session:Function]
# #region dataset_review_api_dependencies [TYPE Function]
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:dataset_review_api_dependencies:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
@pytest.fixture(autouse=True)
def dataset_review_api_dependencies():
mock_user = _make_user()
@@ -358,12 +363,12 @@ def dataset_review_api_dependencies():
app.dependency_overrides.clear()
# #endregion dataset_review_api_dependencies
# [/DEF:dataset_review_api_dependencies:Function]
# #region test_parse_superset_link_dashboard_partial_recovery [TYPE Function]
# @BRIEF Verify dashboard links recover dataset context and preserve explicit partial-recovery markers.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_parse_superset_link_dashboard_partial_recovery:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify dashboard links recover dataset context and preserve explicit partial-recovery markers.
def test_parse_superset_link_dashboard_partial_recovery():
env = Environment(
id="env-1",
@@ -395,12 +400,12 @@ def test_parse_superset_link_dashboard_partial_recovery():
assert result.imported_filters[0]["filter_name"] == "country"
# #endregion test_parse_superset_link_dashboard_partial_recovery
# [/DEF:test_parse_superset_link_dashboard_partial_recovery:Function]
# #region test_parse_superset_link_dashboard_slug_recovery [TYPE Function]
# @BRIEF Verify dashboard slug links resolve through dashboard detail endpoints and recover dataset context.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_parse_superset_link_dashboard_slug_recovery:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify dashboard slug links resolve through dashboard detail endpoints and recover dataset context.
def test_parse_superset_link_dashboard_slug_recovery():
env = Environment(
id="env-1",
@@ -432,12 +437,12 @@ def test_parse_superset_link_dashboard_slug_recovery():
fake_client.get_dashboard_detail.assert_called_once_with("slack")
# #endregion test_parse_superset_link_dashboard_slug_recovery
# [/DEF:test_parse_superset_link_dashboard_slug_recovery:Function]
# #region test_parse_superset_link_dashboard_permalink_partial_recovery [TYPE Function]
# @BRIEF Verify dashboard permalink links no longer fail parsing and preserve permalink filter state for partial recovery.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_parse_superset_link_dashboard_permalink_partial_recovery:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify dashboard permalink links no longer fail parsing and preserve permalink filter state for partial recovery.
def test_parse_superset_link_dashboard_permalink_partial_recovery():
env = Environment(
id="env-1",
@@ -482,9 +487,9 @@ def test_parse_superset_link_dashboard_permalink_partial_recovery():
fake_client.get_dashboard_permalink_state.assert_called_once_with("QabXy6wG30Z")
# #region test_parse_superset_link_dashboard_permalink_recovers_dataset_from_nested_dashboard_state [TYPE Function]
# @BRIEF Verify permalink state with nested dashboard id recovers dataset binding and keeps imported filters.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_parse_superset_link_dashboard_permalink_recovers_dataset_from_nested_dashboard_state:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify permalink state with nested dashboard id recovers dataset binding and keeps imported filters.
def test_parse_superset_link_dashboard_permalink_recovers_dataset_from_nested_dashboard_state():
env = Environment(
id="env-1",
@@ -526,13 +531,13 @@ def test_parse_superset_link_dashboard_permalink_recovers_dataset_from_nested_da
assert result.imported_filters[0]["filter_name"] == "country"
# #endregion test_parse_superset_link_dashboard_permalink_recovers_dataset_from_nested_dashboard_state
# #endregion test_parse_superset_link_dashboard_permalink_partial_recovery
# [/DEF:test_parse_superset_link_dashboard_permalink_recovers_dataset_from_nested_dashboard_state:Function]
# [/DEF:test_parse_superset_link_dashboard_permalink_partial_recovery:Function]
# #region test_resolve_from_dictionary_prefers_exact_match [TYPE Function]
# @BRIEF Verify trusted dictionary exact matches outrank fuzzy candidates and unresolved fields stay explicit.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_resolve_from_dictionary_prefers_exact_match:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify trusted dictionary exact matches outrank fuzzy candidates and unresolved fields stay explicit.
def test_resolve_from_dictionary_prefers_exact_match():
resolver = SemanticSourceResolver()
result = resolver.resolve_from_dictionary(
@@ -572,12 +577,12 @@ def test_resolve_from_dictionary_prefers_exact_match():
assert result.partial_recovery is True
# #endregion test_resolve_from_dictionary_prefers_exact_match
# [/DEF:test_resolve_from_dictionary_prefers_exact_match:Function]
# #region test_orchestrator_start_session_preserves_partial_recovery [TYPE Function]
# @BRIEF Verify session start persists usable recovery-required state when Superset intake is partial.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_orchestrator_start_session_preserves_partial_recovery:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify session start persists usable recovery-required state when Superset intake is partial.
def test_orchestrator_start_session_preserves_partial_recovery(
dataset_review_api_dependencies,
):
@@ -640,12 +645,12 @@ def test_orchestrator_start_session_preserves_partial_recovery(
repository.save_profile_and_findings.assert_called_once()
# #endregion test_orchestrator_start_session_preserves_partial_recovery
# [/DEF:test_orchestrator_start_session_preserves_partial_recovery:Function]
# #region test_orchestrator_start_session_bootstraps_recovery_state [TYPE Function]
# @BRIEF Verify session start persists recovered filters, template variables, and initial execution mappings for review workspace bootstrap.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_orchestrator_start_session_bootstraps_recovery_state:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify session start persists recovered filters, template variables, and initial execution mappings for review workspace bootstrap.
def test_orchestrator_start_session_bootstraps_recovery_state(
dataset_review_api_dependencies,
):
@@ -675,6 +680,7 @@ def test_orchestrator_start_session_bootstraps_recovery_state(
partial_recovery=True,
unresolved_references=["dashboard_dataset_binding_missing"],
imported_filters=[{"filter_name": "country", "raw_value": ["DE"]}],
dataset_payload=None,
)
fake_extractor = MagicMock()
@@ -748,12 +754,12 @@ def test_orchestrator_start_session_bootstraps_recovery_state(
assert saved_mappings[0].raw_input_value == ["DE"]
# #endregion test_orchestrator_start_session_bootstraps_recovery_state
# [/DEF:test_orchestrator_start_session_bootstraps_recovery_state:Function]
# #region test_start_session_endpoint_returns_created_summary [TYPE Function]
# @BRIEF Verify POST session lifecycle endpoint returns a persisted ownership-scoped summary.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_start_session_endpoint_returns_created_summary:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify POST session lifecycle endpoint returns a persisted ownership-scoped summary.
def test_start_session_endpoint_returns_created_summary(
dataset_review_api_dependencies,
):
@@ -781,12 +787,12 @@ def test_start_session_endpoint_returns_created_summary(
assert payload["environment_id"] == "env-1"
# #endregion test_start_session_endpoint_returns_created_summary
# [/DEF:test_start_session_endpoint_returns_created_summary:Function]
# #region test_get_session_detail_export_and_lifecycle_endpoints [TYPE Function]
# @BRIEF Verify lifecycle get/patch/delete plus documentation and validation exports remain ownership-scoped and usable.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_get_session_detail_export_and_lifecycle_endpoints:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Verify lifecycle get/patch/delete plus documentation and validation exports remain ownership-scoped and usable.
def test_get_session_detail_export_and_lifecycle_endpoints(
dataset_review_api_dependencies,
):
@@ -899,11 +905,11 @@ def test_get_session_detail_export_and_lifecycle_endpoints(
assert delete_response.status_code == 204
# #endregion test_get_session_detail_export_and_lifecycle_endpoints
# [/DEF:test_get_session_detail_export_and_lifecycle_endpoints:Function]
# #region test_get_clarification_state_returns_empty_payload_when_session_has_no_record [TYPE Function]
# @BRIEF Clarification state endpoint should return a non-blocking empty payload when the session has no clarification aggregate yet.
# [DEF:test_get_clarification_state_returns_empty_payload_when_session_has_no_record:Function]
# @PURPOSE: Clarification state endpoint should return a non-blocking empty payload when the session has no clarification aggregate yet.
def test_get_clarification_state_returns_empty_payload_when_session_has_no_record(
dataset_review_api_dependencies,
):
@@ -922,12 +928,12 @@ def test_get_clarification_state_returns_empty_payload_when_session_has_no_recor
}
# #endregion test_get_clarification_state_returns_empty_payload_when_session_has_no_record
# [/DEF:test_get_clarification_state_returns_empty_payload_when_session_has_no_record:Function]
# #region test_us2_clarification_endpoints_persist_answer_and_feedback [TYPE Function]
# @BRIEF Clarification endpoints should expose one current question, persist the answer before advancement, and store feedback on the answer audit record.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_us2_clarification_endpoints_persist_answer_and_feedback:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Clarification endpoints should expose one current question, persist the answer before advancement, and store feedback on the answer audit record.
def test_us2_clarification_endpoints_persist_answer_and_feedback(
dataset_review_api_dependencies,
):
@@ -989,12 +995,12 @@ def test_us2_clarification_endpoints_persist_answer_and_feedback(
assert session.clarification_sessions[0].questions[0].answer.user_feedback == "up"
# #endregion test_us2_clarification_endpoints_persist_answer_and_feedback
# [/DEF:test_us2_clarification_endpoints_persist_answer_and_feedback:Function]
# #region test_us2_field_semantic_override_lock_unlock_and_feedback [TYPE Function]
# @BRIEF Semantic field endpoints should apply manual overrides with lock/provenance invariants and persist feedback independently.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_us2_field_semantic_override_lock_unlock_and_feedback:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Semantic field endpoints should apply manual overrides with lock/provenance invariants and persist feedback independently.
def test_us2_field_semantic_override_lock_unlock_and_feedback(
dataset_review_api_dependencies,
):
@@ -1071,12 +1077,12 @@ def test_us2_field_semantic_override_lock_unlock_and_feedback(
assert session.semantic_fields[0].user_feedback == "down"
# #endregion test_us2_field_semantic_override_lock_unlock_and_feedback
# [/DEF:test_us2_field_semantic_override_lock_unlock_and_feedback:Function]
# #region test_us3_mapping_patch_approval_preview_and_launch_endpoints [TYPE Function]
# @BRIEF US3 execution endpoints should persist manual overrides, preserve explicit approval semantics, return contract-shaped preview truth, and expose audited launch handoff.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_us3_mapping_patch_approval_preview_and_launch_endpoints:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: US3 execution endpoints should persist manual overrides, preserve explicit approval semantics, return contract-shaped preview truth, and expose audited launch handoff.
def test_us3_mapping_patch_approval_preview_and_launch_endpoints(
dataset_review_api_dependencies,
):
@@ -1279,12 +1285,12 @@ def test_us3_mapping_patch_approval_preview_and_launch_endpoints(
)
# #endregion test_us3_mapping_patch_approval_preview_and_launch_endpoints
# [/DEF:test_us3_mapping_patch_approval_preview_and_launch_endpoints:Function]
# #region test_us3_preview_response_propagates_refreshed_session_version_for_launch_follow_up [TYPE Function]
# @BRIEF Preview response should expose the refreshed session version so the normal preview-then-launch UI flow can satisfy optimistic locking without a forced full reload.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_us3_preview_response_propagates_refreshed_session_version_for_launch_follow_up:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Preview response should expose the refreshed session version so the normal preview-then-launch UI flow can satisfy optimistic locking without a forced full reload.
def test_us3_preview_response_propagates_refreshed_session_version_for_launch_follow_up(
dataset_review_api_dependencies,
):
@@ -1378,12 +1384,12 @@ def test_us3_preview_response_propagates_refreshed_session_version_for_launch_fo
assert launch_response.json()["run_context"]["run_context_id"] == "run-2"
# #endregion test_us3_preview_response_propagates_refreshed_session_version_for_launch_follow_up
# [/DEF:test_us3_preview_response_propagates_refreshed_session_version_for_launch_follow_up:Function]
# #region test_us3_preview_endpoint_returns_failed_preview_without_false_dashboard_not_found_contract_drift [TYPE Function]
# @BRIEF Preview endpoint should preserve API contract and surface generic upstream preview failures without fabricating dashboard-not-found semantics for non-dashboard 404s.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_us3_preview_endpoint_returns_failed_preview_without_false_dashboard_not_found_contract_drift:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Preview endpoint should preserve API contract and surface generic upstream preview failures without fabricating dashboard-not-found semantics for non-dashboard 404s.
def test_us3_preview_endpoint_returns_failed_preview_without_false_dashboard_not_found_contract_drift(
dataset_review_api_dependencies,
):
@@ -1432,12 +1438,12 @@ def test_us3_preview_endpoint_returns_failed_preview_without_false_dashboard_not
assert "Dashboard not found" not in payload["error_details"]
# #endregion test_us3_preview_endpoint_returns_failed_preview_without_false_dashboard_not_found_contract_drift
# [/DEF:test_us3_preview_endpoint_returns_failed_preview_without_false_dashboard_not_found_contract_drift:Function]
# #region test_mutation_endpoints_surface_session_version_conflict_payload [TYPE Function]
# @BRIEF Dataset review mutation endpoints should return deterministic 409 conflict semantics when optimistic-lock versions are stale.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_mutation_endpoints_surface_session_version_conflict_payload:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Dataset review mutation endpoints should return deterministic 409 conflict semantics when optimistic-lock versions are stale.
def test_mutation_endpoints_surface_session_version_conflict_payload(
dataset_review_api_dependencies,
):
@@ -1472,12 +1478,12 @@ def test_mutation_endpoints_surface_session_version_conflict_payload(
assert payload["actual_version"] == 5
# #endregion test_mutation_endpoints_surface_session_version_conflict_payload
# [/DEF:test_mutation_endpoints_surface_session_version_conflict_payload:Function]
# #region test_update_session_surfaces_commit_time_session_version_conflict_payload [TYPE Function]
# @BRIEF Session lifecycle mutation should return deterministic 409 conflict semantics when commit-time optimistic locking rejects a stale write.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_update_session_surfaces_commit_time_session_version_conflict_payload:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Session lifecycle mutation should return deterministic 409 conflict semantics when commit-time optimistic locking rejects a stale write.
def test_update_session_surfaces_commit_time_session_version_conflict_payload(
dataset_review_api_dependencies,
):
@@ -1510,12 +1516,12 @@ def test_update_session_surfaces_commit_time_session_version_conflict_payload(
assert payload["actual_version"] == 1
# #endregion test_update_session_surfaces_commit_time_session_version_conflict_payload
# [/DEF:test_update_session_surfaces_commit_time_session_version_conflict_payload:Function]
# #region test_execution_snapshot_includes_recovered_imported_filters_without_template_mapping [TYPE Function]
# @BRIEF Recovered imported filters with values should flow into preview filter context even when no template variable mapping exists.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_execution_snapshot_includes_recovered_imported_filters_without_template_mapping:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Recovered imported filters with values should flow into preview filter context even when no template variable mapping exists.
def test_execution_snapshot_includes_recovered_imported_filters_without_template_mapping(
dataset_review_api_dependencies,
):
@@ -1545,7 +1551,7 @@ def test_execution_snapshot_includes_recovered_imported_filters_without_template
session.execution_mappings = []
session.semantic_fields = []
snapshot = orchestrator._build_execution_snapshot(session)
snapshot = build_execution_snapshot(session)
assert snapshot["template_params"] == {}
assert snapshot["preview_blockers"] == []
@@ -1557,7 +1563,7 @@ def test_execution_snapshot_includes_recovered_imported_filters_without_template
"value_origin": "extra_form_data.filters",
}
snapshot = orchestrator._build_execution_snapshot(session)
snapshot = build_execution_snapshot(session)
assert snapshot["template_params"] == {}
assert snapshot["preview_blockers"] == []
@@ -1583,12 +1589,12 @@ def test_execution_snapshot_includes_recovered_imported_filters_without_template
]
# #endregion test_execution_snapshot_includes_recovered_imported_filters_without_template_mapping
# [/DEF:test_execution_snapshot_includes_recovered_imported_filters_without_template_mapping:Function]
# #region test_execution_snapshot_preserves_mapped_template_variables_and_filter_context [TYPE Function]
# @BRIEF Mapped template variables should still populate template params while contributing their effective filter context.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_execution_snapshot_preserves_mapped_template_variables_and_filter_context:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Mapped template variables should still populate template params while contributing their effective filter context.
def test_execution_snapshot_preserves_mapped_template_variables_and_filter_context(
dataset_review_api_dependencies,
):
@@ -1604,7 +1610,7 @@ def test_execution_snapshot_preserves_mapped_template_variables_and_filter_conte
)
session = _make_preview_ready_session()
snapshot = orchestrator._build_execution_snapshot(session)
snapshot = build_execution_snapshot(session)
assert snapshot["template_params"] == {"country": "DE"}
assert snapshot["preview_blockers"] == []
@@ -1622,12 +1628,12 @@ def test_execution_snapshot_preserves_mapped_template_variables_and_filter_conte
assert snapshot["open_warning_refs"] == ["map-1"]
# #endregion test_execution_snapshot_preserves_mapped_template_variables_and_filter_context
# [/DEF:test_execution_snapshot_preserves_mapped_template_variables_and_filter_context:Function]
# #region test_execution_snapshot_skips_partial_imported_filters_without_values [TYPE Function]
# @BRIEF Partial imported filters without raw or normalized values must not emit bogus active preview filters.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_execution_snapshot_skips_partial_imported_filters_without_values:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Partial imported filters without raw or normalized values must not emit bogus active preview filters.
def test_execution_snapshot_skips_partial_imported_filters_without_values(
dataset_review_api_dependencies,
):
@@ -1657,19 +1663,19 @@ def test_execution_snapshot_skips_partial_imported_filters_without_values(
session.execution_mappings = []
session.semantic_fields = []
snapshot = orchestrator._build_execution_snapshot(session)
snapshot = build_execution_snapshot(session)
assert snapshot["template_params"] == {}
assert snapshot["effective_filters"] == []
assert snapshot["preview_blockers"] == []
# #endregion test_execution_snapshot_skips_partial_imported_filters_without_values
# [/DEF:test_execution_snapshot_skips_partial_imported_filters_without_values:Function]
# #region test_us3_launch_endpoint_requires_launch_permission [TYPE Function]
# @BRIEF Launch endpoint should enforce the contract RBAC permission instead of the generic session-manage permission.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_us3_launch_endpoint_requires_launch_permission:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Launch endpoint should enforce the contract RBAC permission instead of the generic session-manage permission.
def test_us3_launch_endpoint_requires_launch_permission(
dataset_review_api_dependencies,
):
@@ -1720,12 +1726,12 @@ def test_us3_launch_endpoint_requires_launch_permission(
)
# #endregion test_us3_launch_endpoint_requires_launch_permission
# [/DEF:test_us3_launch_endpoint_requires_launch_permission:Function]
# #region test_semantic_source_version_propagation_preserves_locked_fields [TYPE Function]
# @BRIEF Updated semantic source versions should mark unlocked fields reviewable while preserving locked manual values.
# @RELATION BINDS_TO -> [DatasetReviewApiTests]
# [DEF:test_semantic_source_version_propagation_preserves_locked_fields:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
# @PURPOSE: Updated semantic source versions should mark unlocked fields reviewable while preserving locked manual values.
def test_semantic_source_version_propagation_preserves_locked_fields():
resolver = SemanticSourceResolver()
source = SimpleNamespace(source_id="src-1", source_version="2026.04")
@@ -1759,6 +1765,6 @@ def test_semantic_source_version_propagation_preserves_locked_fields():
assert locked_field.needs_review is False
# #endregion test_semantic_source_version_propagation_preserves_locked_fields
# [/DEF:test_semantic_source_version_propagation_preserves_locked_fields:Function]
# #endregion DatasetReviewApiTests
# [/DEF:DatasetReviewApiTests:Module]

View File

@@ -1,8 +1,10 @@
# #region DatasetsApiTests [C:3] [TYPE Module] [SEMANTICS datasets, api, tests, pagination, mapping, docs]
# @BRIEF Unit tests for datasets API endpoints.
# @LAYER API
# @INVARIANT Endpoint contracts remain stable for success and validation failure paths.
# @RELATION DEPENDS_ON -> [DatasetsApi]
# [DEF:DatasetsApiTests:Module]
# @COMPLEXITY: 3
# @SEMANTICS: datasets, api, tests, pagination, mapping, docs
# @PURPOSE: Unit tests for datasets API endpoints.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [DatasetsApi]
# @INVARIANT: Endpoint contracts remain stable for success and validation failure paths.
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
@@ -70,9 +72,9 @@ def mock_deps():
client = TestClient(app)
# #region test_get_datasets_success [TYPE Function]
# @BRIEF Validate successful datasets listing contract for an existing environment.
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# [DEF:test_get_datasets_success:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate successful datasets listing contract for an existing environment.
# @TEST: GET /api/datasets returns 200 and valid schema
# @PRE: env_id exists
# @POST: Response matches DatasetsResponse schema
@@ -106,12 +108,12 @@ def test_get_datasets_success(mock_deps):
DatasetsResponse(**data)
# #endregion test_get_datasets_success
# [/DEF:test_get_datasets_success:Function]
# #region test_get_datasets_env_not_found [TYPE Function]
# @BRIEF Validate datasets listing returns 404 when the requested environment does not exist.
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# [DEF:test_get_datasets_env_not_found:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate datasets listing returns 404 when the requested environment does not exist.
# @TEST: GET /api/datasets returns 404 if env_id missing
# @PRE: env_id does not exist
# @POST: Returns 404 error
@@ -124,12 +126,12 @@ def test_get_datasets_env_not_found(mock_deps):
assert "Environment not found" in response.json()["detail"]
# #endregion test_get_datasets_env_not_found
# [/DEF:test_get_datasets_env_not_found:Function]
# #region test_get_datasets_invalid_pagination [TYPE Function]
# @BRIEF Validate datasets listing rejects invalid pagination parameters with 400 responses.
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# [DEF:test_get_datasets_invalid_pagination:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate datasets listing rejects invalid pagination parameters with 400 responses.
# @TEST: GET /api/datasets returns 400 for invalid page/page_size
# @PRE: page < 1 or page_size > 100
# @POST: Returns 400 error
@@ -154,12 +156,12 @@ def test_get_datasets_invalid_pagination(mock_deps):
assert "Page size must be between 1 and 100" in response.json()["detail"]
# #endregion test_get_datasets_invalid_pagination
# [/DEF:test_get_datasets_invalid_pagination:Function]
# #region test_map_columns_success [TYPE Function]
# @BRIEF Validate map-columns request creates an async mapping task and returns its identifier.
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# [DEF:test_map_columns_success:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate map-columns request creates an async mapping task and returns its identifier.
# @TEST: POST /api/datasets/map-columns creates mapping task
# @PRE: Valid env_id, dataset_ids, source_type
# @POST: Returns task_id
@@ -186,12 +188,12 @@ def test_map_columns_success(mock_deps):
mock_deps["task"].create_task.assert_called_once()
# #endregion test_map_columns_success
# [/DEF:test_map_columns_success:Function]
# #region test_map_columns_invalid_source_type [TYPE Function]
# @BRIEF Validate map-columns rejects unsupported source types with a 400 contract response.
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# [DEF:test_map_columns_invalid_source_type:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate map-columns rejects unsupported source types with a 400 contract response.
# @TEST: POST /api/datasets/map-columns returns 400 for invalid source_type
# @PRE: source_type is not 'postgresql' or 'xlsx'
# @POST: Returns 400 error
@@ -205,11 +207,11 @@ def test_map_columns_invalid_source_type(mock_deps):
assert "Source type must be 'postgresql' or 'xlsx'" in response.json()["detail"]
# #endregion test_map_columns_invalid_source_type
# [/DEF:test_map_columns_invalid_source_type:Function]
# #region test_generate_docs_success [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# [DEF:test_generate_docs_success:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @TEST: POST /api/datasets/generate-docs creates doc generation task
# @PRE: Valid env_id, dataset_ids, llm_provider
# @PURPOSE: Validate generate-docs request creates an async documentation task and returns its identifier.
@@ -237,12 +239,12 @@ def test_generate_docs_success(mock_deps):
mock_deps["task"].create_task.assert_called_once()
# #endregion test_generate_docs_success
# [/DEF:test_generate_docs_success:Function]
# #region test_map_columns_empty_ids [TYPE Function]
# @BRIEF Validate map-columns rejects empty dataset identifier lists.
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# [DEF:test_map_columns_empty_ids:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate map-columns rejects empty dataset identifier lists.
# @TEST: POST /api/datasets/map-columns returns 400 for empty dataset_ids
# @PRE: dataset_ids is empty
# @POST: Returns 400 error
@@ -256,12 +258,12 @@ def test_map_columns_empty_ids(mock_deps):
assert "At least one dataset ID must be provided" in response.json()["detail"]
# #endregion test_map_columns_empty_ids
# [/DEF:test_map_columns_empty_ids:Function]
# #region test_generate_docs_empty_ids [TYPE Function]
# @BRIEF Validate generate-docs rejects empty dataset identifier lists.
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# [DEF:test_generate_docs_empty_ids:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate generate-docs rejects empty dataset identifier lists.
# @TEST: POST /api/datasets/generate-docs returns 400 for empty dataset_ids
# @PRE: dataset_ids is empty
# @POST: Returns 400 error
@@ -275,11 +277,11 @@ def test_generate_docs_empty_ids(mock_deps):
assert "At least one dataset ID must be provided" in response.json()["detail"]
# #endregion test_generate_docs_empty_ids
# [/DEF:test_generate_docs_empty_ids:Function]
# #region test_generate_docs_env_not_found [TYPE Function]
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# [DEF:test_generate_docs_env_not_found:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @TEST: POST /api/datasets/generate-docs returns 404 for missing env
# @PRE: env_id does not exist
# @PURPOSE: Validate generate-docs returns 404 when the requested environment cannot be resolved.
@@ -295,12 +297,12 @@ def test_generate_docs_env_not_found(mock_deps):
assert "Environment not found" in response.json()["detail"]
# #endregion test_generate_docs_env_not_found
# [/DEF:test_generate_docs_env_not_found:Function]
# #region test_get_datasets_superset_failure [TYPE Function]
# @BRIEF Validate datasets listing surfaces a 503 contract when Superset access fails.
# @RELATION BINDS_TO -> [DatasetsApiTests:Module]
# [DEF:test_get_datasets_superset_failure:Function]
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
# @PURPOSE: Validate datasets listing surfaces a 503 contract when Superset access fails.
# @TEST_EDGE: external_superset_failure -> {status: 503}
# @POST: Returns 503 with stable error detail when upstream dataset fetch fails.
def test_get_datasets_superset_failure(mock_deps):
@@ -318,7 +320,7 @@ def test_get_datasets_superset_failure(mock_deps):
assert "Failed to fetch datasets" in response.json()["detail"]
# #endregion test_get_datasets_superset_failure
# [/DEF:test_get_datasets_superset_failure:Function]
# #endregion DatasetsApiTests
# [/DEF:DatasetsApiTests:Module]

View File

@@ -1,6 +1,7 @@
# #region TestGitApi [C:3] [TYPE Module]
# @BRIEF API tests for Git configurations and repository operations.
# @RELATION VERIFIES -> [GitApi]
# [DEF:TestGitApi:Module]
# @COMPLEXITY: 3
# @RELATION: VERIFIES -> [GitApi]
# @PURPOSE: API tests for Git configurations and repository operations.
import pytest
import asyncio
@@ -10,10 +11,11 @@ from src.api.routes import git as git_routes
from src.models.git import GitServerConfig, GitProvider, GitStatus, GitRepository
# #region DbMock [C:2] [TYPE Class]
# @BRIEF In-memory session double for git route tests with minimal query/filter persistence semantics.
# @INVARIANT Supports only the SQLAlchemy-like operations exercised by this test module.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:DbMock:Class]
# @RELATION: BINDS_TO -> [TestGitApi]
# @COMPLEXITY: 2
# @PURPOSE: In-memory session double for git route tests with minimal query/filter persistence semantics.
# @INVARIANT: Supports only the SQLAlchemy-like operations exercised by this test module.
class DbMock:
def __init__(self, data=None):
self._data = data or []
@@ -82,12 +84,12 @@ class DbMock:
item.last_validated = "2026-03-08T00:00:00Z"
# #endregion DbMock
# [/DEF:DbMock:Class]
# #region test_get_git_configs_masks_pat [TYPE Function]
# @BRIEF Validate listing git configs masks stored PAT values in API-facing responses.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_get_git_configs_masks_pat:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate listing git configs masks stored PAT values in API-facing responses.
def test_get_git_configs_masks_pat():
"""
@PRE: Database session `db` is available.
@@ -114,12 +116,12 @@ def test_get_git_configs_masks_pat():
assert result[0].name == "Test Server"
# #endregion test_get_git_configs_masks_pat
# [/DEF:test_get_git_configs_masks_pat:Function]
# #region test_create_git_config_persists_config [TYPE Function]
# @BRIEF Validate creating git config persists supplied server attributes in backing session.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_create_git_config_persists_config:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate creating git config persists supplied server attributes in backing session.
def test_create_git_config_persists_config():
"""
@PRE: `config` contains valid GitServerConfigCreate data.
@@ -147,14 +149,14 @@ def test_create_git_config_persists_config():
) # Note: route returns unmasked until serialized by FastAPI usually, but in tests schema might catch it or not.
# #endregion test_create_git_config_persists_config
# [/DEF:test_create_git_config_persists_config:Function]
from src.api.routes.git_schemas import GitServerConfigUpdate
# #region test_update_git_config_modifies_record [TYPE Function]
# @BRIEF Validate updating git config modifies mutable fields while preserving masked PAT semantics.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_update_git_config_modifies_record:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate updating git config modifies mutable fields while preserving masked PAT semantics.
def test_update_git_config_modifies_record():
"""
@PRE: `config_id` corresponds to an existing configuration.
@@ -188,6 +190,8 @@ def test_update_git_config_modifies_record():
def refresh(self, config):
pass
# [/DEF:SingleConfigDbMock:Class]
db = SingleConfigDbMock()
update_data = GitServerConfigUpdate(name="Updated Server", pat="********")
@@ -204,12 +208,12 @@ def test_update_git_config_modifies_record():
assert result.pat == "********"
# #endregion test_update_git_config_modifies_record
# [/DEF:test_update_git_config_modifies_record:Function]
# #region test_update_git_config_raises_404_if_not_found [TYPE Function]
# @BRIEF Validate updating non-existent git config raises HTTP 404 contract response.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_update_git_config_raises_404_if_not_found:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate updating non-existent git config raises HTTP 404 contract response.
def test_update_git_config_raises_404_if_not_found():
"""
@PRE: `config_id` corresponds to a missing configuration.
@@ -229,12 +233,12 @@ def test_update_git_config_raises_404_if_not_found():
assert exc_info.value.detail == "Configuration not found"
# #endregion test_update_git_config_raises_404_if_not_found
# [/DEF:test_update_git_config_raises_404_if_not_found:Function]
# #region test_delete_git_config_removes_record [TYPE Function]
# @BRIEF Validate deleting existing git config removes record and returns success payload.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_delete_git_config_removes_record:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate deleting existing git config removes record and returns success payload.
def test_delete_git_config_removes_record():
"""
@PRE: `config_id` corresponds to an existing configuration.
@@ -259,6 +263,8 @@ def test_delete_git_config_removes_record():
def commit(self):
pass
# [/DEF:SingleConfigDbMock:Class]
db = SingleConfigDbMock()
result = asyncio.run(git_routes.delete_git_config(config_id="config-1", db=db))
@@ -267,12 +273,12 @@ def test_delete_git_config_removes_record():
assert result["status"] == "success"
# #endregion test_delete_git_config_removes_record
# [/DEF:test_delete_git_config_removes_record:Function]
# #region test_test_git_config_validates_connection_successfully [TYPE Function]
# @BRIEF Validate test-connection endpoint returns success when provider connectivity check passes.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_test_git_config_validates_connection_successfully:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate test-connection endpoint returns success when provider connectivity check passes.
def test_test_git_config_validates_connection_successfully(monkeypatch):
"""
@PRE: `config` contains provider, url, and pat.
@@ -284,6 +290,8 @@ def test_test_git_config_validates_connection_successfully(monkeypatch):
async def test_connection(self, provider, url, pat):
return True
# [/DEF:MockGitService:Class]
monkeypatch.setattr(git_routes, "git_service", MockGitService())
from src.api.routes.git_schemas import GitServerConfigCreate
@@ -300,12 +308,12 @@ def test_test_git_config_validates_connection_successfully(monkeypatch):
assert result["status"] == "success"
# #endregion test_test_git_config_validates_connection_successfully
# [/DEF:test_test_git_config_validates_connection_successfully:Function]
# #region test_test_git_config_fails_validation [TYPE Function]
# @BRIEF Validate test-connection endpoint raises HTTP 400 when provider connectivity check fails.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_test_git_config_fails_validation:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate test-connection endpoint raises HTTP 400 when provider connectivity check fails.
def test_test_git_config_fails_validation(monkeypatch):
"""
@PRE: `config` contains provider, url, and pat BUT connection fails.
@@ -317,6 +325,8 @@ def test_test_git_config_fails_validation(monkeypatch):
async def test_connection(self, provider, url, pat):
return False
# [/DEF:MockGitService:Class]
monkeypatch.setattr(git_routes, "git_service", MockGitService())
from src.api.routes.git_schemas import GitServerConfigCreate
@@ -335,12 +345,12 @@ def test_test_git_config_fails_validation(monkeypatch):
assert exc_info.value.detail == "Connection failed"
# #endregion test_test_git_config_fails_validation
# [/DEF:test_test_git_config_fails_validation:Function]
# #region test_list_gitea_repositories_returns_payload [TYPE Function]
# @BRIEF Validate gitea repositories endpoint returns normalized list for GITEA provider configs.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_list_gitea_repositories_returns_payload:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate gitea repositories endpoint returns normalized list for GITEA provider configs.
def test_list_gitea_repositories_returns_payload(monkeypatch):
"""
@PRE: config_id exists and provider is GITEA.
@@ -354,6 +364,8 @@ def test_list_gitea_repositories_returns_payload(monkeypatch):
{"name": "test-repo", "full_name": "owner/test-repo", "private": True}
]
# [/DEF:MockGitService:Class]
monkeypatch.setattr(git_routes, "git_service", MockGitService())
existing_config = GitServerConfig(
id="config-1",
@@ -373,12 +385,12 @@ def test_list_gitea_repositories_returns_payload(monkeypatch):
assert result[0].private is True
# #endregion test_list_gitea_repositories_returns_payload
# [/DEF:test_list_gitea_repositories_returns_payload:Function]
# #region test_list_gitea_repositories_rejects_non_gitea [TYPE Function]
# @BRIEF Validate gitea repositories endpoint rejects non-GITEA providers with HTTP 400.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_list_gitea_repositories_rejects_non_gitea:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate gitea repositories endpoint rejects non-GITEA providers with HTTP 400.
def test_list_gitea_repositories_rejects_non_gitea(monkeypatch):
"""
@PRE: config_id exists and provider is NOT GITEA.
@@ -400,12 +412,12 @@ def test_list_gitea_repositories_rejects_non_gitea(monkeypatch):
assert "GITEA provider only" in exc_info.value.detail
# #endregion test_list_gitea_repositories_rejects_non_gitea
# [/DEF:test_list_gitea_repositories_rejects_non_gitea:Function]
# #region test_create_remote_repository_creates_provider_repo [TYPE Function]
# @BRIEF Validate remote repository creation endpoint maps provider response into normalized payload.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_create_remote_repository_creates_provider_repo:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate remote repository creation endpoint maps provider response into normalized payload.
def test_create_remote_repository_creates_provider_repo(monkeypatch):
"""
@PRE: config_id exists and PAT has creation permissions.
@@ -424,6 +436,8 @@ def test_create_remote_repository_creates_provider_repo(monkeypatch):
"clone_url": f"{server_url}/user/{name}.git",
}
# [/DEF:MockGitService:Class]
monkeypatch.setattr(git_routes, "git_service", MockGitService())
from src.api.routes.git_schemas import RemoteRepoCreateRequest
@@ -448,12 +462,12 @@ def test_create_remote_repository_creates_provider_repo(monkeypatch):
assert result.full_name == "user/new-repo"
# #endregion test_create_remote_repository_creates_provider_repo
# [/DEF:test_create_remote_repository_creates_provider_repo:Function]
# #region test_init_repository_initializes_and_saves_binding [TYPE Function]
# @BRIEF Validate repository initialization endpoint creates local repo and persists dashboard binding.
# @RELATION BINDS_TO -> [TestGitApi]
# [DEF:test_init_repository_initializes_and_saves_binding:Function]
# @RELATION: BINDS_TO -> [TestGitApi]
# @PURPOSE: Validate repository initialization endpoint creates local repo and persists dashboard binding.
def test_init_repository_initializes_and_saves_binding(monkeypatch):
"""
@PRE: `dashboard_ref` exists and `init_data` contains valid config_id and remote_url.
@@ -469,6 +483,8 @@ def test_init_repository_initializes_and_saves_binding(monkeypatch):
def _get_repo_path(self, dashboard_id, repo_key):
return f"/tmp/repos/{repo_key}"
# [/DEF:MockGitService:Class]
git_service_mock = MockGitService()
monkeypatch.setattr(git_routes, "git_service", git_service_mock)
monkeypatch.setattr(
@@ -507,5 +523,5 @@ def test_init_repository_initializes_and_saves_binding(monkeypatch):
assert db._added[0].dashboard_id == 123
# #endregion test_init_repository_initializes_and_saves_binding
# #endregion TestGitApi
# [/DEF:test_init_repository_initializes_and_saves_binding:Function]
# [/DEF:TestGitApi:Module]

View File

@@ -1,7 +1,9 @@
# #region TestGitStatusRoute [C:3] [TYPE Module] [SEMANTICS tests, git, api, status, no_repo]
# @BRIEF Validate status endpoint behavior for missing and error repository states.
# @LAYER Domain (Tests)
# @RELATION VERIFIES -> [GitApi]
# [DEF:TestGitStatusRoute:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, git, api, status, no_repo
# @PURPOSE: Validate status endpoint behavior for missing and error repository states.
# @LAYER: Domain (Tests)
# @RELATION: VERIFIES -> [GitApi]
from fastapi import HTTPException
import pytest
@@ -11,11 +13,11 @@ from unittest.mock import MagicMock
from src.api.routes import git as git_routes
# #region test_get_repository_status_returns_no_repo_payload_for_missing_repo [TYPE Function]
# @BRIEF Ensure missing local repository is represented as NO_REPO payload instead of an API error.
# @PRE GitService.get_status raises HTTPException(404).
# @POST Route returns a deterministic NO_REPO status payload.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_get_repository_status_returns_no_repo_payload_for_missing_repo:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure missing local repository is represented as NO_REPO payload instead of an API error.
# @PRE: GitService.get_status raises HTTPException(404).
# @POST: Route returns a deterministic NO_REPO status payload.
def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypatch):
class MissingRepoGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
@@ -32,14 +34,14 @@ def test_get_repository_status_returns_no_repo_payload_for_missing_repo(monkeypa
assert response["sync_state"] == "NO_REPO"
assert response["has_repo"] is False
assert response["current_branch"] is None
# #endregion test_get_repository_status_returns_no_repo_payload_for_missing_repo
# [/DEF:test_get_repository_status_returns_no_repo_payload_for_missing_repo:Function]
# #region test_get_repository_status_propagates_non_404_http_exception [TYPE Function]
# @BRIEF Ensure HTTP exceptions other than 404 are not masked.
# @PRE GitService.get_status raises HTTPException with non-404 status.
# @POST Raised exception preserves original status and detail.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_get_repository_status_propagates_non_404_http_exception:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure HTTP exceptions other than 404 are not masked.
# @PRE: GitService.get_status raises HTTPException with non-404 status.
# @POST: Raised exception preserves original status and detail.
def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
class ConflictGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
@@ -56,14 +58,14 @@ def test_get_repository_status_propagates_non_404_http_exception(monkeypatch):
assert exc_info.value.status_code == 409
assert exc_info.value.detail == "Conflict"
# #endregion test_get_repository_status_propagates_non_404_http_exception
# [/DEF:test_get_repository_status_propagates_non_404_http_exception:Function]
# #region test_get_repository_diff_propagates_http_exception [TYPE Function]
# @BRIEF Ensure diff endpoint preserves domain HTTP errors from GitService.
# @PRE GitService.get_diff raises HTTPException.
# @POST Endpoint raises same HTTPException values.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_get_repository_diff_propagates_http_exception:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure diff endpoint preserves domain HTTP errors from GitService.
# @PRE: GitService.get_diff raises HTTPException.
# @POST: Endpoint raises same HTTPException values.
def test_get_repository_diff_propagates_http_exception(monkeypatch):
class DiffGitService:
def get_diff(self, dashboard_id: int, file_path=None, staged: bool = False) -> str:
@@ -76,14 +78,14 @@ def test_get_repository_diff_propagates_http_exception(monkeypatch):
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Repository missing"
# #endregion test_get_repository_diff_propagates_http_exception
# [/DEF:test_get_repository_diff_propagates_http_exception:Function]
# #region test_get_history_wraps_unexpected_error_as_500 [TYPE Function]
# @BRIEF Ensure non-HTTP exceptions in history endpoint become deterministic 500 errors.
# @PRE GitService.get_commit_history raises ValueError.
# @POST Endpoint returns HTTPException with status 500 and route context.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_get_history_wraps_unexpected_error_as_500:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure non-HTTP exceptions in history endpoint become deterministic 500 errors.
# @PRE: GitService.get_commit_history raises ValueError.
# @POST: Endpoint returns HTTPException with status 500 and route context.
def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
class HistoryGitService:
def get_commit_history(self, dashboard_id: int, limit: int = 50):
@@ -96,14 +98,14 @@ def test_get_history_wraps_unexpected_error_as_500(monkeypatch):
assert exc_info.value.status_code == 500
assert exc_info.value.detail == "get_history failed: broken parser"
# #endregion test_get_history_wraps_unexpected_error_as_500
# [/DEF:test_get_history_wraps_unexpected_error_as_500:Function]
# #region test_commit_changes_wraps_unexpected_error_as_500 [TYPE Function]
# @BRIEF Ensure commit endpoint does not leak unexpected errors as 400.
# @PRE GitService.commit_changes raises RuntimeError.
# @POST Endpoint raises HTTPException(500) with route context.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_commit_changes_wraps_unexpected_error_as_500:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure commit endpoint does not leak unexpected errors as 400.
# @PRE: GitService.commit_changes raises RuntimeError.
# @POST: Endpoint raises HTTPException(500) with route context.
def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
class CommitGitService:
def commit_changes(self, dashboard_id: int, message: str, files):
@@ -120,14 +122,14 @@ def test_commit_changes_wraps_unexpected_error_as_500(monkeypatch):
assert exc_info.value.status_code == 500
assert exc_info.value.detail == "commit_changes failed: index lock"
# #endregion test_commit_changes_wraps_unexpected_error_as_500
# [/DEF:test_commit_changes_wraps_unexpected_error_as_500:Function]
# #region test_get_repository_status_batch_returns_mixed_statuses [TYPE Function]
# @BRIEF Ensure batch endpoint returns per-dashboard statuses in one response.
# @PRE Some repositories are missing and some are initialized.
# @POST Returned map includes resolved status for each requested dashboard ID.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_get_repository_status_batch_returns_mixed_statuses:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure batch endpoint returns per-dashboard statuses in one response.
# @PRE: Some repositories are missing and some are initialized.
# @POST: Returned map includes resolved status for each requested dashboard ID.
def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
class BatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
@@ -148,14 +150,14 @@ def test_get_repository_status_batch_returns_mixed_statuses(monkeypatch):
assert response.statuses["1"]["sync_status"] == "NO_REPO"
assert response.statuses["2"]["sync_state"] == "SYNCED"
# #endregion test_get_repository_status_batch_returns_mixed_statuses
# [/DEF:test_get_repository_status_batch_returns_mixed_statuses:Function]
# #region test_get_repository_status_batch_marks_item_as_error_on_service_failure [TYPE Function]
# @BRIEF Ensure batch endpoint marks failed items as ERROR without failing entire request.
# @PRE GitService raises non-HTTP exception for one dashboard.
# @POST Failed dashboard status is marked as ERROR.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_get_repository_status_batch_marks_item_as_error_on_service_failure:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure batch endpoint marks failed items as ERROR without failing entire request.
# @PRE: GitService raises non-HTTP exception for one dashboard.
# @POST: Failed dashboard status is marked as ERROR.
def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monkeypatch):
class BatchErrorGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
@@ -174,14 +176,14 @@ def test_get_repository_status_batch_marks_item_as_error_on_service_failure(monk
assert response.statuses["9"]["sync_status"] == "ERROR"
assert response.statuses["9"]["sync_state"] == "ERROR"
# #endregion test_get_repository_status_batch_marks_item_as_error_on_service_failure
# [/DEF:test_get_repository_status_batch_marks_item_as_error_on_service_failure:Function]
# #region test_get_repository_status_batch_deduplicates_and_truncates_ids [TYPE Function]
# @BRIEF Ensure batch endpoint protects server from oversized payloads.
# @PRE request includes duplicate IDs and more than MAX_REPOSITORY_STATUS_BATCH entries.
# @POST Result contains unique IDs up to configured cap.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure batch endpoint protects server from oversized payloads.
# @PRE: request includes duplicate IDs and more than MAX_REPOSITORY_STATUS_BATCH entries.
# @POST: Result contains unique IDs up to configured cap.
def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch):
class SafeBatchGitService:
def _get_repo_path(self, dashboard_id: int) -> str:
@@ -200,14 +202,14 @@ def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch)
assert len(response.statuses) == git_routes.MAX_REPOSITORY_STATUS_BATCH
assert "1" in response.statuses
# #endregion test_get_repository_status_batch_deduplicates_and_truncates_ids
# [/DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function]
# #region test_commit_changes_applies_profile_identity_before_commit [TYPE Function]
# @BRIEF Ensure commit route configures repository identity from profile preferences before commit call.
# @PRE Profile preference contains git_username/git_email for current user.
# @POST git_service.configure_identity receives resolved identity and commit proceeds.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_commit_changes_applies_profile_identity_before_commit:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure commit route configures repository identity from profile preferences before commit call.
# @PRE: Profile preference contains git_username/git_email for current user.
# @POST: git_service.configure_identity receives resolved identity and commit proceeds.
def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
class IdentityGitService:
def __init__(self):
@@ -262,14 +264,14 @@ def test_commit_changes_applies_profile_identity_before_commit(monkeypatch):
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
assert identity_service.commit_payload == (12, "test", ["dashboards/a.yaml"])
# #endregion test_commit_changes_applies_profile_identity_before_commit
# [/DEF:test_commit_changes_applies_profile_identity_before_commit:Function]
# #region test_pull_changes_applies_profile_identity_before_pull [TYPE Function]
# @BRIEF Ensure pull route configures repository identity from profile preferences before pull call.
# @PRE Profile preference contains git_username/git_email for current user.
# @POST git_service.configure_identity receives resolved identity and pull proceeds.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_pull_changes_applies_profile_identity_before_pull:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure pull route configures repository identity from profile preferences before pull call.
# @PRE: Profile preference contains git_username/git_email for current user.
# @POST: git_service.configure_identity receives resolved identity and pull proceeds.
def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
class IdentityGitService:
def __init__(self):
@@ -319,14 +321,14 @@ def test_pull_changes_applies_profile_identity_before_pull(monkeypatch):
assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru")
assert identity_service.pulled_dashboard_id == 12
# #endregion test_pull_changes_applies_profile_identity_before_pull
# [/DEF:test_pull_changes_applies_profile_identity_before_pull:Function]
# #region test_get_merge_status_returns_service_payload [TYPE Function]
# @BRIEF Ensure merge status route returns service payload as-is.
# @PRE git_service.get_merge_status returns unfinished merge payload.
# @POST Route response contains has_unfinished_merge=True.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_get_merge_status_returns_service_payload:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure merge status route returns service payload as-is.
# @PRE: git_service.get_merge_status returns unfinished merge payload.
# @POST: Route response contains has_unfinished_merge=True.
def test_get_merge_status_returns_service_payload(monkeypatch):
class MergeStatusGitService:
def get_merge_status(self, dashboard_id: int) -> dict:
@@ -352,14 +354,14 @@ def test_get_merge_status_returns_service_payload(monkeypatch):
assert response["has_unfinished_merge"] is True
assert response["conflicts_count"] == 2
# #endregion test_get_merge_status_returns_service_payload
# [/DEF:test_get_merge_status_returns_service_payload:Function]
# #region test_resolve_merge_conflicts_passes_resolution_items_to_service [TYPE Function]
# @BRIEF Ensure merge resolve route forwards parsed resolutions to service.
# @PRE resolve_data has one file strategy.
# @POST Service receives normalized list and route returns resolved files.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_resolve_merge_conflicts_passes_resolution_items_to_service:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure merge resolve route forwards parsed resolutions to service.
# @PRE: resolve_data has one file strategy.
# @POST: Service receives normalized list and route returns resolved files.
def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch):
captured = {}
@@ -390,14 +392,14 @@ def test_resolve_merge_conflicts_passes_resolution_items_to_service(monkeypatch)
assert captured["dashboard_id"] == 12
assert captured["resolutions"][0]["resolution"] == "mine"
assert response["resolved_files"] == ["dashboards/a.yaml"]
# #endregion test_resolve_merge_conflicts_passes_resolution_items_to_service
# [/DEF:test_resolve_merge_conflicts_passes_resolution_items_to_service:Function]
# #region test_abort_merge_calls_service_and_returns_result [TYPE Function]
# @BRIEF Ensure abort route delegates to service.
# @PRE Service abort_merge returns aborted status.
# @POST Route returns aborted status.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_abort_merge_calls_service_and_returns_result:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure abort route delegates to service.
# @PRE: Service abort_merge returns aborted status.
# @POST: Route returns aborted status.
def test_abort_merge_calls_service_and_returns_result(monkeypatch):
class AbortGitService:
def abort_merge(self, dashboard_id: int):
@@ -415,14 +417,14 @@ def test_abort_merge_calls_service_and_returns_result(monkeypatch):
)
assert response["status"] == "aborted"
# #endregion test_abort_merge_calls_service_and_returns_result
# [/DEF:test_abort_merge_calls_service_and_returns_result:Function]
# #region test_continue_merge_passes_message_and_returns_commit [TYPE Function]
# @BRIEF Ensure continue route passes commit message to service.
# @PRE continue_data.message is provided.
# @POST Route returns committed status and hash.
# @RELATION BINDS_TO -> [TestGitStatusRoute]
# [DEF:test_continue_merge_passes_message_and_returns_commit:Function]
# @RELATION: BINDS_TO -> TestGitStatusRoute
# @PURPOSE: Ensure continue route passes commit message to service.
# @PRE: continue_data.message is provided.
# @POST: Route returns committed status and hash.
def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
class ContinueGitService:
def continue_merge(self, dashboard_id: int, message: str):
@@ -446,7 +448,7 @@ def test_continue_merge_passes_message_and_returns_commit(monkeypatch):
assert response["status"] == "committed"
assert response["commit_hash"] == "abc123"
# #endregion test_continue_merge_passes_message_and_returns_commit
# [/DEF:test_continue_merge_passes_message_and_returns_commit:Function]
# #endregion TestGitStatusRoute
# [/DEF:TestGitStatusRoute:Module]

View File

@@ -1,8 +1,9 @@
# #region TestMigrationRoutes [C:3] [TYPE Module]
# @BRIEF Unit tests for migration API route handlers.
# @LAYER API
# @RELATION VERIFIES -> [backend.src.api.routes.migration]
# [DEF:TestMigrationRoutes:Module]
#
# @COMPLEXITY: 3
# @PURPOSE: Unit tests for migration API route handlers.
# @LAYER: API
# @RELATION: VERIFIES -> backend.src.api.routes.migration
#
import pytest
import sys
@@ -59,8 +60,8 @@ def db_session():
session.close()
# #region _make_config_manager [TYPE Function]
# @RELATION BINDS_TO -> [TestMigrationRoutes]
# [DEF:_make_config_manager:Function]
# @RELATION: BINDS_TO -> TestMigrationRoutes
def _make_config_manager(cron="0 2 * * *"):
"""Creates a mock config manager with a realistic AppConfig-like object."""
settings = MagicMock()
@@ -75,7 +76,7 @@ def _make_config_manager(cron="0 2 * * *"):
# --- get_migration_settings tests ---
# #endregion _make_config_manager
# [/DEF:_make_config_manager:Function]
@pytest.mark.asyncio
@@ -328,8 +329,8 @@ async def test_get_resource_mappings_filter_by_type(db_session):
@pytest.fixture
# #region _mock_env [TYPE Function]
# @RELATION BINDS_TO -> [TestMigrationRoutes]
# [DEF:_mock_env:Function]
# @RELATION: BINDS_TO -> TestMigrationRoutes
def _mock_env():
"""Creates a mock config environment object."""
env = MagicMock()
@@ -343,11 +344,11 @@ def _mock_env():
return env
# #endregion _mock_env
# [/DEF:_mock_env:Function]
# #region _make_sync_config_manager [TYPE Function]
# @RELATION BINDS_TO -> [TestMigrationRoutes]
# [DEF:_make_sync_config_manager:Function]
# @RELATION: BINDS_TO -> TestMigrationRoutes
def _make_sync_config_manager(environments):
"""Creates a mock config manager with environments list."""
settings = MagicMock()
@@ -361,7 +362,7 @@ def _make_sync_config_manager(environments):
return cm
# #endregion _make_sync_config_manager
# [/DEF:_make_sync_config_manager:Function]
@pytest.mark.asyncio
@@ -652,4 +653,4 @@ async def test_dry_run_migration_rejects_same_environment(db_session):
assert exc.value.status_code == 400
# #endregion TestMigrationRoutes
# [/DEF:TestMigrationRoutes:Module]

View File

@@ -1,7 +1,9 @@
# #region TestProfileApi [C:3] [TYPE Module] [SEMANTICS tests, profile, api, preferences, lookup, contract]
# @BRIEF Verifies profile API route contracts for preference read/update and Superset account lookup.
# @LAYER API
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:TestProfileApi:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, profile, api, preferences, lookup, contract
# @PURPOSE: Verifies profile API route contracts for preference read/update and Superset account lookup.
# @LAYER: API
# [SECTION: IMPORTS]
from datetime import datetime, timezone
@@ -31,11 +33,11 @@ from src.services.profile_service import (
client = TestClient(app)
# #region mock_profile_route_dependencies [TYPE Function]
# @BRIEF Provides deterministic dependency overrides for profile route tests.
# @PRE App instance is initialized.
# @POST Dependencies are overridden for current test and restored afterward.
# @RELATION BINDS_TO -> [TestProfileApi]
# [DEF:mock_profile_route_dependencies:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Provides deterministic dependency overrides for profile route tests.
# @PRE: App instance is initialized.
# @POST: Dependencies are overridden for current test and restored afterward.
def mock_profile_route_dependencies():
mock_user = MagicMock()
mock_user.id = "u-1"
@@ -49,14 +51,14 @@ def mock_profile_route_dependencies():
app.dependency_overrides[get_config_manager] = lambda: mock_config_manager
return mock_user, mock_db, mock_config_manager
# #endregion mock_profile_route_dependencies
# [/DEF:mock_profile_route_dependencies:Function]
# #region profile_route_deps_fixture [TYPE Function]
# @BRIEF Pytest fixture wrapper for profile route dependency overrides.
# @PRE None.
# @POST Yields overridden dependencies and clears overrides after test.
# @RELATION BINDS_TO -> [TestProfileApi]
# [DEF:profile_route_deps_fixture:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Pytest fixture wrapper for profile route dependency overrides.
# @PRE: None.
# @POST: Yields overridden dependencies and clears overrides after test.
import pytest
@@ -65,14 +67,14 @@ def profile_route_deps_fixture():
yielded = mock_profile_route_dependencies()
yield yielded
app.dependency_overrides.clear()
# #endregion profile_route_deps_fixture
# [/DEF:profile_route_deps_fixture:Function]
# #region _build_preference_response [TYPE Function]
# @BRIEF Builds stable profile preference response payload for route tests.
# @PRE user_id is provided.
# @POST Returns ProfilePreferenceResponse object with deterministic timestamps.
# @RELATION BINDS_TO -> [TestProfileApi]
# [DEF:_build_preference_response:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Builds stable profile preference response payload for route tests.
# @PRE: user_id is provided.
# @POST: Returns ProfilePreferenceResponse object with deterministic timestamps.
def _build_preference_response(user_id: str = "u-1") -> ProfilePreferenceResponse:
now = datetime.now(timezone.utc)
return ProfilePreferenceResponse(
@@ -106,14 +108,14 @@ def _build_preference_response(user_id: str = "u-1") -> ProfilePreferenceRespons
],
),
)
# #endregion _build_preference_response
# [/DEF:_build_preference_response:Function]
# #region test_get_profile_preferences_returns_self_payload [TYPE Function]
# @BRIEF Verifies GET /api/profile/preferences returns stable self-scoped payload.
# @PRE Authenticated user context is available.
# @POST Response status is 200 and payload contains current user preference.
# @RELATION BINDS_TO -> [TestProfileApi]
# [DEF:test_get_profile_preferences_returns_self_payload:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies GET /api/profile/preferences returns stable self-scoped payload.
# @PRE: Authenticated user context is available.
# @POST: Response status is 200 and payload contains current user preference.
def test_get_profile_preferences_returns_self_payload(profile_route_deps_fixture):
mock_user, _, _ = profile_route_deps_fixture
service = MagicMock()
@@ -139,14 +141,14 @@ def test_get_profile_preferences_returns_self_payload(profile_route_deps_fixture
assert payload["security"]["current_role"] == "Data Engineer"
assert payload["security"]["permissions"][0]["key"] == "migration:run"
service.get_my_preference.assert_called_once_with(mock_user)
# #endregion test_get_profile_preferences_returns_self_payload
# [/DEF:test_get_profile_preferences_returns_self_payload:Function]
# #region test_patch_profile_preferences_success [TYPE Function]
# @BRIEF Verifies PATCH /api/profile/preferences persists valid payload through route mapping.
# @PRE Valid request payload and authenticated user.
# @POST Response status is 200 with saved preference payload.
# @RELATION BINDS_TO -> [TestProfileApi]
# [DEF:test_patch_profile_preferences_success:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies PATCH /api/profile/preferences persists valid payload through route mapping.
# @PRE: Valid request payload and authenticated user.
# @POST: Response status is 200 with saved preference payload.
def test_patch_profile_preferences_success(profile_route_deps_fixture):
mock_user, _, _ = profile_route_deps_fixture
service = MagicMock()
@@ -190,14 +192,14 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture):
assert called_kwargs["payload"].start_page == "reports-logs"
assert called_kwargs["payload"].auto_open_task_drawer is False
assert called_kwargs["payload"].dashboards_table_density == "free"
# #endregion test_patch_profile_preferences_success
# [/DEF:test_patch_profile_preferences_success:Function]
# #region test_patch_profile_preferences_validation_error [TYPE Function]
# @BRIEF Verifies route maps domain validation failure to HTTP 422 with actionable details.
# @PRE Service raises ProfileValidationError.
# @POST Response status is 422 and includes validation messages.
# @RELATION BINDS_TO -> [TestProfileApi]
# [DEF:test_patch_profile_preferences_validation_error:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies route maps domain validation failure to HTTP 422 with actionable details.
# @PRE: Service raises ProfileValidationError.
# @POST: Response status is 422 and includes validation messages.
def test_patch_profile_preferences_validation_error(profile_route_deps_fixture):
service = MagicMock()
service.update_my_preference.side_effect = ProfileValidationError(
@@ -217,14 +219,14 @@ def test_patch_profile_preferences_validation_error(profile_route_deps_fixture):
payload = response.json()
assert "detail" in payload
assert "Superset username is required when default filter is enabled." in payload["detail"]
# #endregion test_patch_profile_preferences_validation_error
# [/DEF:test_patch_profile_preferences_validation_error:Function]
# #region test_patch_profile_preferences_cross_user_denied [TYPE Function]
# @BRIEF Verifies route maps domain authorization guard failure to HTTP 403.
# @PRE Service raises ProfileAuthorizationError.
# @POST Response status is 403 with denial message.
# @RELATION BINDS_TO -> [TestProfileApi]
# [DEF:test_patch_profile_preferences_cross_user_denied:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies route maps domain authorization guard failure to HTTP 403.
# @PRE: Service raises ProfileAuthorizationError.
# @POST: Response status is 403 with denial message.
def test_patch_profile_preferences_cross_user_denied(profile_route_deps_fixture):
service = MagicMock()
service.update_my_preference.side_effect = ProfileAuthorizationError(
@@ -243,14 +245,14 @@ def test_patch_profile_preferences_cross_user_denied(profile_route_deps_fixture)
assert response.status_code == 403
payload = response.json()
assert payload["detail"] == "Cross-user preference mutation is forbidden"
# #endregion test_patch_profile_preferences_cross_user_denied
# [/DEF:test_patch_profile_preferences_cross_user_denied:Function]
# #region test_lookup_superset_accounts_success [TYPE Function]
# @BRIEF Verifies lookup route returns success payload with normalized candidates.
# @PRE Valid environment_id and service success response.
# @POST Response status is 200 and items list is returned.
# @RELATION BINDS_TO -> [TestProfileApi]
# [DEF:test_lookup_superset_accounts_success:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies lookup route returns success payload with normalized candidates.
# @PRE: Valid environment_id and service success response.
# @POST: Response status is 200 and items list is returned.
def test_lookup_superset_accounts_success(profile_route_deps_fixture):
service = MagicMock()
service.lookup_superset_accounts.return_value = SupersetAccountLookupResponse(
@@ -280,14 +282,14 @@ def test_lookup_superset_accounts_success(profile_route_deps_fixture):
assert payload["environment_id"] == "dev"
assert payload["total"] == 1
assert payload["items"][0]["username"] == "john_doe"
# #endregion test_lookup_superset_accounts_success
# [/DEF:test_lookup_superset_accounts_success:Function]
# #region test_lookup_superset_accounts_env_not_found [TYPE Function]
# @BRIEF Verifies lookup route maps missing environment to HTTP 404.
# @PRE Service raises EnvironmentNotFoundError.
# @POST Response status is 404 with explicit message.
# @RELATION BINDS_TO -> [TestProfileApi]
# [DEF:test_lookup_superset_accounts_env_not_found:Function]
# @RELATION: BINDS_TO -> TestProfileApi
# @PURPOSE: Verifies lookup route maps missing environment to HTTP 404.
# @PRE: Service raises EnvironmentNotFoundError.
# @POST: Response status is 404 with explicit message.
def test_lookup_superset_accounts_env_not_found(profile_route_deps_fixture):
service = MagicMock()
service.lookup_superset_accounts.side_effect = EnvironmentNotFoundError(
@@ -300,6 +302,6 @@ def test_lookup_superset_accounts_env_not_found(profile_route_deps_fixture):
assert response.status_code == 404
payload = response.json()
assert payload["detail"] == "Environment 'missing-env' not found"
# #endregion test_lookup_superset_accounts_env_not_found
# [/DEF:test_lookup_superset_accounts_env_not_found:Function]
# #endregion TestProfileApi
# [/DEF:TestProfileApi:Module]

View File

@@ -1,8 +1,10 @@
# #region TestReportsApi [C:3] [TYPE Module] [SEMANTICS tests, reports, api, contract, pagination, filtering]
# @BRIEF Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
# @LAYER Domain (Tests)
# @INVARIANT API response contract contains {items,total,page,page_size,has_next,applied_filters}.
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:TestReportsApi:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, reports, api, contract, pagination, filtering
# @PURPOSE: Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
# @LAYER: Domain (Tests)
# @INVARIANT: API response contract contains {items,total,page,page_size,has_next,applied_filters}.
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
@@ -15,10 +17,11 @@ from src.dependencies import get_current_user, get_task_manager
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
# #region _FakeTaskManager [C:1] [TYPE Class]
# @BRIEF Minimal task-manager double exposing only get_all_tasks used by reports route tests.
# @INVARIANT Returns pre-seeded tasks without mutation or side effects.
# @RELATION BINDS_TO -> [TestReportsApi]
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [TestReportsApi]
# @COMPLEXITY: 1
# @PURPOSE: Minimal task-manager double exposing only get_all_tasks used by reports route tests.
# @INVARIANT: Returns pre-seeded tasks without mutation or side effects.
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
@@ -27,23 +30,23 @@ class _FakeTaskManager:
return self._tasks
# #endregion _FakeTaskManager
# [/DEF:_FakeTaskManager:Class]
# #region _admin_user [TYPE Function]
# @BRIEF Build deterministic admin principal accepted by reports authorization guard.
# @RELATION BINDS_TO -> [TestReportsApi]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> TestReportsApi
# @PURPOSE: Build deterministic admin principal accepted by reports authorization guard.
def _admin_user():
admin_role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[admin_role])
# #endregion _admin_user
# [/DEF:_admin_user:Function]
# #region _make_task [TYPE Function]
# @BRIEF Build Task fixture with controlled timestamps/status for reports list/detail normalization.
# @RELATION BINDS_TO -> [TestReportsApi]
# [DEF:_make_task:Function]
# @RELATION: BINDS_TO -> TestReportsApi
# @PURPOSE: Build Task fixture with controlled timestamps/status for reports list/detail normalization.
def _make_task(
task_id: str,
plugin_id: str,
@@ -63,12 +66,12 @@ def _make_task(
)
# #endregion _make_task
# [/DEF:_make_task:Function]
# #region test_get_reports_default_pagination_contract [TYPE Function]
# @BRIEF Validate reports list endpoint default pagination and contract keys for mixed task statuses.
# @RELATION BINDS_TO -> [TestReportsApi]
# [DEF:test_get_reports_default_pagination_contract:Function]
# @RELATION: BINDS_TO -> TestReportsApi
# @PURPOSE: Validate reports list endpoint default pagination and contract keys for mixed task statuses.
def test_get_reports_default_pagination_contract():
now = datetime.utcnow()
tasks = [
@@ -117,12 +120,12 @@ def test_get_reports_default_pagination_contract():
app.dependency_overrides.clear()
# #endregion test_get_reports_default_pagination_contract
# [/DEF:test_get_reports_default_pagination_contract:Function]
# #region test_get_reports_filter_and_pagination [TYPE Function]
# @BRIEF Validate reports list endpoint applies task-type/status filters and pagination boundaries.
# @RELATION BINDS_TO -> [TestReportsApi]
# [DEF:test_get_reports_filter_and_pagination:Function]
# @RELATION: BINDS_TO -> TestReportsApi
# @PURPOSE: Validate reports list endpoint applies task-type/status filters and pagination boundaries.
def test_get_reports_filter_and_pagination():
now = datetime.utcnow()
tasks = [
@@ -171,12 +174,12 @@ def test_get_reports_filter_and_pagination():
app.dependency_overrides.clear()
# #endregion test_get_reports_filter_and_pagination
# [/DEF:test_get_reports_filter_and_pagination:Function]
# #region test_get_reports_handles_mixed_naive_and_aware_datetimes [TYPE Function]
# @BRIEF Validate reports sorting remains stable when task timestamps mix naive and timezone-aware datetimes.
# @RELATION BINDS_TO -> [TestReportsApi]
# [DEF:test_get_reports_handles_mixed_naive_and_aware_datetimes:Function]
# @RELATION: BINDS_TO -> TestReportsApi
# @PURPOSE: 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()
aware_now = datetime.now(timezone.utc)
@@ -211,12 +214,12 @@ def test_get_reports_handles_mixed_naive_and_aware_datetimes():
app.dependency_overrides.clear()
# #endregion test_get_reports_handles_mixed_naive_and_aware_datetimes
# [/DEF:test_get_reports_handles_mixed_naive_and_aware_datetimes:Function]
# #region test_get_reports_invalid_filter_returns_400 [TYPE Function]
# @BRIEF Validate reports list endpoint rejects unsupported task type filters with HTTP 400.
# @RELATION BINDS_TO -> [TestReportsApi]
# [DEF:test_get_reports_invalid_filter_returns_400:Function]
# @RELATION: BINDS_TO -> TestReportsApi
# @PURPOSE: Validate reports list endpoint rejects unsupported task type filters with HTTP 400.
def test_get_reports_invalid_filter_returns_400():
now = datetime.utcnow()
tasks = [
@@ -242,5 +245,5 @@ def test_get_reports_invalid_filter_returns_400():
app.dependency_overrides.clear()
# #endregion test_get_reports_invalid_filter_returns_400
# #endregion TestReportsApi
# [/DEF:test_get_reports_invalid_filter_returns_400:Function]
# [/DEF:TestReportsApi:Module]

View File

@@ -1,8 +1,10 @@
# #region TestReportsDetailApi [C:3] [TYPE Module] [SEMANTICS tests, reports, api, detail, diagnostics]
# @BRIEF Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
# @LAYER Domain (Tests)
# @INVARIANT Detail endpoint tests must keep deterministic assertions for success and not-found contracts.
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:TestReportsDetailApi:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, reports, api, detail, diagnostics
# @PURPOSE: Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
# @LAYER: Domain (Tests)
# @INVARIANT: Detail endpoint tests must keep deterministic assertions for success and not-found contracts.
from datetime import datetime, timedelta
from types import SimpleNamespace
@@ -15,10 +17,11 @@ from src.dependencies import get_current_user, get_task_manager
# @DEBT: Divergent _FakeTaskManager definition. Canonical version should be in conftest.py. Authz variant is missing get_all_tasks().
# #region _FakeTaskManager [C:1] [TYPE Class]
# @BRIEF Minimal task-manager double exposing pre-seeded tasks to detail endpoint under test.
# @INVARIANT get_all_tasks returns exactly seeded tasks list.
# @RELATION BINDS_TO -> [TestReportsDetailApi]
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [TestReportsDetailApi]
# @COMPLEXITY: 1
# @PURPOSE: Minimal task-manager double exposing pre-seeded tasks to detail endpoint under test.
# @INVARIANT: get_all_tasks returns exactly seeded tasks list.
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
@@ -27,23 +30,23 @@ class _FakeTaskManager:
return self._tasks
# #endregion _FakeTaskManager
# [/DEF:_FakeTaskManager:Class]
# #region _admin_user [TYPE Function]
# @BRIEF Provide admin principal fixture accepted by reports detail authorization policy.
# @RELATION BINDS_TO -> [TestReportsDetailApi]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> TestReportsDetailApi
# @PURPOSE: Provide admin principal fixture accepted by reports detail authorization policy.
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[role])
# #endregion _admin_user
# [/DEF:_admin_user:Function]
# #region _make_task [TYPE Function]
# @BRIEF Build deterministic Task payload for reports detail endpoint contract assertions.
# @RELATION BINDS_TO -> [TestReportsDetailApi]
# [DEF:_make_task:Function]
# @RELATION: BINDS_TO -> TestReportsDetailApi
# @PURPOSE: 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()
return Task(
@@ -59,12 +62,12 @@ def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None):
)
# #endregion _make_task
# [/DEF:_make_task:Function]
# #region test_get_report_detail_success [TYPE Function]
# @BRIEF Validate report detail endpoint returns report body with diagnostics and next actions for existing task.
# @RELATION BINDS_TO -> [TestReportsDetailApi]
# [DEF:test_get_report_detail_success:Function]
# @RELATION: BINDS_TO -> TestReportsDetailApi
# @PURPOSE: Validate report detail endpoint returns report body with diagnostics and next actions for existing task.
def test_get_report_detail_success():
task = _make_task(
"detail-1",
@@ -95,12 +98,12 @@ def test_get_report_detail_success():
app.dependency_overrides.clear()
# #endregion test_get_report_detail_success
# [/DEF:test_get_report_detail_success:Function]
# #region test_get_report_detail_not_found [TYPE Function]
# @BRIEF Validate report detail endpoint returns 404 when requested report identifier is absent.
# @RELATION BINDS_TO -> [TestReportsDetailApi]
# [DEF:test_get_report_detail_not_found:Function]
# @RELATION: BINDS_TO -> TestReportsDetailApi
# @PURPOSE: Validate report detail endpoint returns 404 when requested report identifier is absent.
def test_get_report_detail_not_found():
task = _make_task("detail-2", "superset-backup", TaskStatus.SUCCESS)
@@ -115,5 +118,5 @@ def test_get_report_detail_not_found():
app.dependency_overrides.clear()
# #endregion test_get_report_detail_not_found
# #endregion TestReportsDetailApi
# [/DEF:test_get_report_detail_not_found:Function]
# [/DEF:TestReportsDetailApi:Module]

View File

@@ -1,8 +1,10 @@
# #region TestReportsOpenapiConformance [C:3] [TYPE Module] [SEMANTICS tests, reports, openapi, conformance]
# @BRIEF Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
# @LAYER Domain (Tests)
# @INVARIANT List and detail payloads include required contract keys.
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:TestReportsOpenapiConformance:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, reports, openapi, conformance
# @PURPOSE: Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
# @LAYER: Domain (Tests)
# @INVARIANT: List and detail payloads include required contract keys.
from datetime import datetime
from types import SimpleNamespace
@@ -14,10 +16,11 @@ from src.core.task_manager.models import Task, TaskStatus
from src.dependencies import get_current_user, get_task_manager
# #region _FakeTaskManager [C:1] [TYPE Class]
# @BRIEF Minimal task-manager fake exposing static task list for OpenAPI conformance checks.
# @INVARIANT get_all_tasks returns seeded tasks unchanged.
# @RELATION BINDS_TO -> [TestReportsOpenapiConformance]
# [DEF:_FakeTaskManager:Class]
# @RELATION: BINDS_TO -> [TestReportsOpenapiConformance]
# @COMPLEXITY: 1
# @PURPOSE: Minimal task-manager fake exposing static task list for OpenAPI conformance checks.
# @INVARIANT: get_all_tasks returns seeded tasks unchanged.
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
@@ -26,23 +29,23 @@ class _FakeTaskManager:
return self._tasks
# #endregion _FakeTaskManager
# [/DEF:_FakeTaskManager:Class]
# #region _admin_user [TYPE Function]
# @BRIEF Provide admin principal fixture required by reports routes in conformance tests.
# @RELATION BINDS_TO -> [TestReportsOpenapiConformance]
# [DEF:_admin_user:Function]
# @RELATION: BINDS_TO -> TestReportsOpenapiConformance
# @PURPOSE: Provide admin principal fixture required by reports routes in conformance tests.
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[role])
# #endregion _admin_user
# [/DEF:_admin_user:Function]
# #region _task [TYPE Function]
# @BRIEF Construct deterministic task fixture consumed by reports list/detail payload assertions.
# @RELATION BINDS_TO -> [TestReportsOpenapiConformance]
# [DEF:_task:Function]
# @RELATION: BINDS_TO -> TestReportsOpenapiConformance
# @PURPOSE: Construct deterministic task fixture consumed by reports list/detail payload assertions.
def _task(task_id: str, plugin_id: str, status: TaskStatus):
now = datetime.utcnow()
return Task(
@@ -56,12 +59,12 @@ def _task(task_id: str, plugin_id: str, status: TaskStatus):
)
# #endregion _task
# [/DEF:_task:Function]
# #region test_reports_list_openapi_required_keys [TYPE Function]
# @BRIEF Verify reports list endpoint includes all required OpenAPI top-level keys.
# @RELATION BINDS_TO -> [TestReportsOpenapiConformance]
# [DEF:test_reports_list_openapi_required_keys:Function]
# @RELATION: BINDS_TO -> TestReportsOpenapiConformance
# @PURPOSE: Verify reports list endpoint includes all required OpenAPI top-level keys.
def test_reports_list_openapi_required_keys():
tasks = [
_task("r-1", "superset-backup", TaskStatus.SUCCESS),
@@ -89,12 +92,12 @@ def test_reports_list_openapi_required_keys():
app.dependency_overrides.clear()
# #endregion test_reports_list_openapi_required_keys
# [/DEF:test_reports_list_openapi_required_keys:Function]
# #region test_reports_detail_openapi_required_keys [TYPE Function]
# @BRIEF Verify reports detail endpoint returns payload containing the report object key.
# @RELATION BINDS_TO -> [TestReportsOpenapiConformance]
# [DEF:test_reports_detail_openapi_required_keys:Function]
# @RELATION: BINDS_TO -> TestReportsOpenapiConformance
# @PURPOSE: Verify reports detail endpoint returns payload containing the report object key.
def test_reports_detail_openapi_required_keys():
tasks = [_task("r-3", "llm_dashboard_validation", TaskStatus.SUCCESS)]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
@@ -111,5 +114,5 @@ def test_reports_detail_openapi_required_keys():
app.dependency_overrides.clear()
# #endregion test_reports_detail_openapi_required_keys
# #endregion TestReportsOpenapiConformance
# [/DEF:test_reports_detail_openapi_required_keys:Function]
# [/DEF:TestReportsOpenapiConformance:Module]

View File

@@ -1,7 +1,9 @@
# #region test_tasks_logs_module [C:2] [TYPE Module] [SEMANTICS tests, tasks, logs, api, contract, validation]
# @BRIEF Contract testing for task logs API endpoints.
# @LAYER Domain (Tests)
# @RELATION VERIFIES -> [src.api.routes.tasks:Module]
# [DEF:test_tasks_logs_module:Module]
# @RELATION: VERIFIES -> [src.api.routes.tasks:Module]
# @COMPLEXITY: 2
# @SEMANTICS: tests, tasks, logs, api, contract, validation
# @PURPOSE: Contract testing for task logs API endpoints.
# @LAYER: Domain (Tests)
import pytest
from fastapi import FastAPI
@@ -30,9 +32,9 @@ def client():
# @TEST_CONTRACT: get_task_logs_api -> Invariants
# @TEST_FIXTURE: valid_task_logs_request
# #region test_get_task_logs_success [TYPE Function]
# @BRIEF Validate task logs endpoint returns filtered logs for an existing task.
# @RELATION BINDS_TO -> [test_tasks_logs_module]
# [DEF:test_get_task_logs_success:Function]
# @RELATION: BINDS_TO -> test_tasks_logs_module
# @PURPOSE: Validate task logs endpoint returns filtered logs for an existing task.
def test_get_task_logs_success(client):
tc, tm = client
@@ -53,12 +55,12 @@ def test_get_task_logs_success(client):
# @TEST_EDGE: task_not_found
# #endregion test_get_task_logs_success
# [/DEF:test_get_task_logs_success:Function]
# #region test_get_task_logs_not_found [TYPE Function]
# @BRIEF Validate task logs endpoint returns 404 when the task identifier is missing.
# @RELATION BINDS_TO -> [test_tasks_logs_module]
# [DEF:test_get_task_logs_not_found:Function]
# @RELATION: BINDS_TO -> test_tasks_logs_module
# @PURPOSE: Validate task logs endpoint returns 404 when the task identifier is missing.
def test_get_task_logs_not_found(client):
tc, tm = client
tm.get_task.return_value = None
@@ -69,12 +71,12 @@ def test_get_task_logs_not_found(client):
# @TEST_EDGE: invalid_limit
# #endregion test_get_task_logs_not_found
# [/DEF:test_get_task_logs_not_found:Function]
# #region test_get_task_logs_invalid_limit [TYPE Function]
# @BRIEF Validate task logs endpoint enforces query validation for limit lower bound.
# @RELATION BINDS_TO -> [test_tasks_logs_module]
# [DEF:test_get_task_logs_invalid_limit:Function]
# @RELATION: BINDS_TO -> test_tasks_logs_module
# @PURPOSE: Validate task logs endpoint enforces query validation for limit lower bound.
def test_get_task_logs_invalid_limit(client):
tc, tm = client
# limit=0 is ge=1 in Query
@@ -83,12 +85,12 @@ def test_get_task_logs_invalid_limit(client):
# @TEST_INVARIANT: response_purity
# #endregion test_get_task_logs_invalid_limit
# [/DEF:test_get_task_logs_invalid_limit:Function]
# #region test_get_task_log_stats_success [TYPE Function]
# @BRIEF Validate log stats endpoint returns success payload for an existing task.
# @RELATION BINDS_TO -> [test_tasks_logs_module]
# [DEF:test_get_task_log_stats_success:Function]
# @RELATION: BINDS_TO -> test_tasks_logs_module
# @PURPOSE: Validate log stats endpoint returns success payload for an existing task.
def test_get_task_log_stats_success(client):
tc, tm = client
tm.get_task.return_value = MagicMock()
@@ -102,5 +104,5 @@ def test_get_task_log_stats_success(client):
# assuming tm.get_task_log_stats returns something compatible with LogStats
# #endregion test_get_task_log_stats_success
# #endregion test_tasks_logs_module
# [/DEF:test_get_task_log_stats_success:Function]
# [/DEF:test_tasks_logs_module:Module]

View File

@@ -1,14 +1,13 @@
# #region AdminApi [C:3] [TYPE Module] [SEMANTICS api, admin, users, roles, permissions]
#
# @BRIEF Admin API endpoints for user and role management.
# @LAYER API
# @INVARIANT All endpoints in this module require 'Admin' role or 'admin' scope.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AuthRepository:Class]
# @RELATION DEPENDS_ON -> [get_auth_db:Function]
# @RELATION DEPENDS_ON -> [has_permission:Function]
#
#
# @INVARIANT: All endpoints in this module require 'Admin' role or 'admin' scope.
# [SECTION: IMPORTS]
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
@@ -28,30 +27,24 @@ from ...schemas.auth import (
)
from ...models.auth import User, Role, ADGroupMapping
from ...dependencies import has_permission, get_plugin_loader
from ...core.cot_logger import MarkerLogger
from ...core.logger import logger, belief_scope
log = MarkerLogger("AdminApi")
from ...services.rbac_permission_catalog import (
discover_declared_permissions,
sync_permission_catalog,
)
# [/SECTION]
# #region router [TYPE Variable]
# @RELATION DEPENDS_ON -> fastapi.APIRouter
# @BRIEF APIRouter instance for admin routes.
# @RELATION DEPENDS_ON -> [fastapi.APIRouter]
router = APIRouter(prefix="/api/admin", tags=["admin"])
# #endregion router
# #region list_users [C:3] [TYPE Function]
# @BRIEF Lists all registered users.
# @PRE Current user has 'Admin' role.
# @POST Returns a list of UserSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[UserSchema] - List of users.
# @RELATION: CALLS -> User
# @PRE: Current user has 'Admin' role.
# @POST: Returns a list of UserSchema objects.
# @RELATION CALLS -> User
@router.get("/users", response_model=List[UserSchema])
async def list_users(
db: Session = Depends(get_auth_db), _=Depends(has_permission("admin:users", "READ"))
@@ -66,12 +59,9 @@ async def list_users(
# #region create_user [C:3] [TYPE Function]
# @BRIEF Creates a new local user.
# @PRE Current user has 'Admin' role.
# @POST New user is created in the database.
# @PARAM: user_in (UserCreate) - New user data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: UserSchema - The created user.
# @RELATION: [CALLS] ->[AuthRepository:Class]
# @PRE: Current user has 'Admin' role.
# @POST: New user is created in the database.
# @RELATION CALLS -> [AuthRepository:Class]
@router.post("/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_user(
user_in: UserCreate,
@@ -107,13 +97,9 @@ async def create_user(
# #region update_user [C:3] [TYPE Function]
# @BRIEF Updates an existing user.
# @PRE Current user has 'Admin' role.
# @POST User record is updated in the database.
# @PARAM: user_id (str) - Target user UUID.
# @PARAM: user_in (UserUpdate) - Updated user data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: UserSchema - The updated user profile.
# @RELATION: CALLS -> AuthRepository
# @PRE: Current user has 'Admin' role.
# @POST: User record is updated in the database.
# @RELATION CALLS -> AuthRepository
@router.put("/users/{user_id}", response_model=UserSchema)
async def update_user(
user_id: str,
@@ -151,12 +137,9 @@ async def update_user(
# #region delete_user [C:3] [TYPE Function]
# @BRIEF Deletes a user.
# @PRE Current user has 'Admin' role.
# @POST User record is removed from the database.
# @PARAM: user_id (str) - Target user UUID.
# @PARAM: db (Session) - Auth database session.
# @RETURN: None
# @RELATION: CALLS -> AuthRepository
# @PRE: Current user has 'Admin' role.
# @POST: User record is removed from the database.
# @RELATION CALLS -> AuthRepository
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
@@ -164,17 +147,25 @@ async def delete_user(
_=Depends(has_permission("admin:users", "WRITE")),
):
with belief_scope("api.admin.delete_user"):
log.reason(f"Attempting to delete user {user_id}")
logger.info(
f"[DEBUG] Attempting to delete user context={{'user_id': '{user_id}'}}"
)
repo = AuthRepository(db)
user = repo.get_user_by_id(user_id)
if not user:
log.explore(f"User not found for deletion: {user_id}", error="User not found")
logger.warning(
f"[DEBUG] User not found for deletion context={{'user_id': '{user_id}'}}"
)
raise HTTPException(status_code=404, detail="User not found")
log.reason(f"Found user to delete: {user.username}")
logger.info(
f"[DEBUG] Found user to delete context={{'username': '{user.username}'}}"
)
db.delete(user)
db.commit()
log.reflect(f"Successfully deleted user {user_id}")
logger.info(
f"[DEBUG] Successfully deleted user context={{'user_id': '{user_id}'}}"
)
return None
@@ -183,7 +174,6 @@ async def delete_user(
# #region list_roles [C:3] [TYPE Function]
# @BRIEF Lists all available roles.
# @RETURN List[RoleSchema] - List of roles.
# @RELATION CALLS -> [Role:Class]
@router.get("/roles", response_model=List[RoleSchema])
async def list_roles(
@@ -198,13 +188,10 @@ async def list_roles(
# #region create_role [C:3] [TYPE Function]
# @BRIEF Creates a new system role with associated permissions.
# @PRE Role name must be unique.
# @POST New Role record is created in auth.db.
# @PARAM: role_in (RoleCreate) - New role data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: RoleSchema - The created role.
# @PRE: Role name must be unique.
# @POST: New Role record is created in auth.db.
# @SIDE_EFFECT: Commits new role and associations to auth.db.
# @RELATION: [CALLS] ->[get_permission_by_id:Function]
# @RELATION CALLS -> [get_permission_by_id:Function]
@router.post("/roles", response_model=RoleSchema, status_code=status.HTTP_201_CREATED)
async def create_role(
role_in: RoleCreate,
@@ -238,14 +225,10 @@ async def create_role(
# #region update_role [C:3] [TYPE Function]
# @BRIEF Updates an existing role's metadata and permissions.
# @PRE role_id must be a valid existing role UUID.
# @POST Role record is updated in auth.db.
# @PARAM: role_id (str) - Target role identifier.
# @PARAM: role_in (RoleUpdate) - Updated role data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: RoleSchema - The updated role.
# @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is updated in auth.db.
# @SIDE_EFFECT: Commits updates to auth.db.
# @RELATION: [CALLS] ->[get_role_by_id:Function]
# @RELATION CALLS -> [get_role_by_id:Function]
@router.put("/roles/{role_id}", response_model=RoleSchema)
async def update_role(
role_id: str,
@@ -285,13 +268,10 @@ async def update_role(
# #region delete_role [C:3] [TYPE Function]
# @BRIEF Removes a role from the system.
# @PRE role_id must be a valid existing role UUID.
# @POST Role record is removed from auth.db.
# @PARAM: role_id (str) - Target role identifier.
# @PARAM: db (Session) - Auth database session.
# @RETURN: None
# @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is removed from auth.db.
# @SIDE_EFFECT: Deletes record from auth.db and commits.
# @RELATION: [CALLS] ->[get_role_by_id:Function]
# @RELATION CALLS -> [get_role_by_id:Function]
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role(
role_id: str,
@@ -314,10 +294,8 @@ async def delete_role(
# #region list_permissions [C:3] [TYPE Function]
# @BRIEF Lists all available system permissions for assignment.
# @POST Returns a list of all PermissionSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[PermissionSchema] - List of permissions.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.list_permissions
# @POST: Returns a list of all PermissionSchema objects.
# @RELATION CALLS -> backend.src.core.auth.repository.AuthRepository.list_permissions
@router.get("/permissions", response_model=List[PermissionSchema])
async def list_permissions(
db: Session = Depends(get_auth_db),
@@ -332,7 +310,10 @@ async def list_permissions(
db=db, declared_permissions=declared_permissions
)
if inserted_count > 0:
log.reason(f"Synchronized {inserted_count} missing RBAC permissions into auth catalog")
logger.info(
"[api.admin.list_permissions][Action] Synchronized %s missing RBAC permissions into auth catalog",
inserted_count,
)
repo = AuthRepository(db)
return repo.list_permissions()
@@ -343,7 +324,7 @@ async def list_permissions(
# #region list_ad_mappings [C:3] [TYPE Function]
# @BRIEF Lists all AD Group to Role mappings.
# @RELATION CALLS -> [ADGroupMapping]
# @RELATION CALLS -> ADGroupMapping
@router.get("/ad-mappings", response_model=List[ADGroupMappingSchema])
async def list_ad_mappings(
db: Session = Depends(get_auth_db),
@@ -357,10 +338,10 @@ async def list_ad_mappings(
# #region create_ad_mapping [C:2] [TYPE Function]
# @BRIEF Creates a new AD Group mapping.
# @RELATION DEPENDS_ON -> [ADGroupMapping:Class]
# @RELATION DEPENDS_ON -> [get_auth_db:Function]
# @RELATION DEPENDS_ON -> [has_permission:Function]
# @BRIEF Creates a new AD Group mapping.
@router.post("/ad-mappings", response_model=ADGroupMappingSchema)
async def create_ad_mapping(
mapping_in: ADGroupMappingCreate,

View File

@@ -1,11 +1,11 @@
# #region AssistantApi [C:5] [TYPE Module] [SEMANTICS api, assistant, chat, command, confirmation]
# @BRIEF API routes for LLM assistant command parsing and safe execution orchestration.
# @LAYER API
# @INVARIANT Risky operations are never executed without valid confirmation token.
# @LAYER: API
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DEPENDS_ON -> [AssistantMessageRecord]
# @RELATION DEPENDS_ON -> [AssistantConfirmationRecord]
# @RELATION DEPENDS_ON -> [AssistantAuditRecord]
# @INVARIANT: Risky operations are never executed without valid confirmation token.
# Re-export public API for backward compatibility.
from ._routes import router, send_message, confirm_operation, cancel_operation

View File

@@ -1,10 +1,10 @@
# #region AssistantAdminRoutes [C:5] [TYPE Module]
# @BRIEF FastAPI route handlers for assistant admin operations — conversation listing, deletion, history, audit.
# @LAYER API
# @INVARIANT Audit endpoint requires tasks:READ permission.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantRoutes]
# @RELATION DEPENDS_ON -> [AssistantSchemas]
# @RELATION DEPENDS_ON -> [AssistantHistory]
# @INVARIANT: Audit endpoint requires tasks:READ permission.
from __future__ import annotations
@@ -42,9 +42,8 @@ from ._routes import router
# #region list_conversations [C:2] [TYPE Function]
# @BRIEF Return paginated conversation list for current user with archived flag and last message preview.
# @PRE Authenticated user context and valid pagination params.
# @POST Conversations are grouped by conversation_id sorted by latest activity descending.
# @RETURN Dict with items, paging metadata, and archive segmentation counts.
# @PRE: Authenticated user context and valid pagination params.
# @POST: Conversations are grouped by conversation_id sorted by latest activity descending.
@router.get("/conversations")
async def list_conversations(
page: int = Query(1, ge=1),
@@ -139,8 +138,8 @@ async def list_conversations(
# #region delete_conversation [C:2] [TYPE Function]
# @BRIEF Soft-delete or hard-delete a conversation and clear its in-memory trace.
# @PRE conversation_id belongs to current_user.
# @POST Conversation records are removed from DB and CONVERSATIONS cache.
# @PRE: conversation_id belongs to current_user.
# @POST: Conversation records are removed from DB and CONVERSATIONS cache.
@router.delete("/conversations/{conversation_id}")
async def delete_conversation(
conversation_id: str,
@@ -185,9 +184,8 @@ async def delete_conversation(
@router.get("/history")
# #region get_history [TYPE Function]
# @BRIEF Retrieve paginated assistant conversation history for current user.
# @PRE Authenticated user is available and page params are valid.
# @POST Returns persistent messages and mirrored in-memory snapshot for diagnostics.
# @RETURN Dict with items, paging metadata, and resolved conversation_id.
# @PRE: Authenticated user is available and page params are valid.
# @POST: Returns persistent messages and mirrored in-memory snapshot for diagnostics.
async def get_history(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
@@ -257,9 +255,8 @@ async def get_history(
@router.get("/audit")
# #region get_assistant_audit [TYPE Function]
# @BRIEF Return assistant audit decisions for current user from persistent and in-memory stores.
# @PRE User has tasks:READ permission.
# @POST Audit payload is returned in reverse chronological order from DB.
# @RETURN Dict with persistent and memory audit slices.
# @PRE: User has tasks:READ permission.
# @POST: Audit payload is returned in reverse chronological order from DB.
async def get_assistant_audit(
limit: int = Query(50, ge=1, le=500),
current_user: User = Depends(get_current_user),

View File

@@ -1,8 +1,8 @@
# #region AssistantCommandParser [C:4] [TYPE Module]
# @BRIEF Deterministic RU/EN command text parser that converts user messages into intent payloads.
# @LAYER API
# @INVARIANT Every return path includes domain, operation, entities, confidence, risk_level, requires_confirmation.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantResolvers]
# @INVARIANT: Every return path includes domain, operation, entities, confidence, risk_level, requires_confirmation.
from __future__ import annotations
@@ -16,13 +16,13 @@ from ._resolvers import _extract_id, _is_production_env
# #region _parse_command [C:4] [TYPE Function]
# @BRIEF Deterministically parse RU/EN command text into intent payload.
# @PRE message contains raw user text and config manager resolves environments.
# @POST Returns intent dict with domain/operation/entities/confidence/risk fields.
# @SIDE_EFFECT None (pure parsing logic).
# @DATA_CONTRACT Input[message:str, config_manager:ConfigManager] -> Output[Dict[str,Any]{domain,operation,entities,confidence,risk_level,requires_confirmation}]
# @INVARIANT every return path includes domain, operation, entities, confidence, risk_level, requires_confirmation.
# @DATA_CONTRACT: Input[message:str, config_manager:ConfigManager] -> Output[Dict[str,Any]{domain,operation,entities,confidence,risk_level,requires_confirmation}]
# @RELATION DEPENDS_ON -> [_extract_id]
# @RELATION DEPENDS_ON -> [_is_production_env]
# @SIDE_EFFECT: None (pure parsing logic).
# @PRE: message contains raw user text and config manager resolves environments.
# @POST: Returns intent dict with domain/operation/entities/confidence/risk fields.
# @INVARIANT: every return path includes domain, operation, entities, confidence, risk_level, requires_confirmation.
def _parse_command(message: str, config_manager: ConfigManager) -> Dict[str, Any]:
with belief_scope('_parse_command'):
logger.reason('Belief protocol reasoning checkpoint for _parse_command')

View File

@@ -1,10 +1,10 @@
# #region AssistantDatasetReview [C:4] [TYPE Module]
# @BRIEF Dataset review context loading and intent planning for the assistant API.
# @LAYER API
# @INVARIANT Dataset review operations are always scoped to the owner's session.
# @LAYER: API
# @RELATION DEPENDS_ON -> [DatasetReviewOrchestrator]
# @RELATION DEPENDS_ON -> [AssistantSchemas]
# @RELATION DISPATCHES -> [AssistantDatasetReviewDispatch]
# @INVARIANT: Dataset review operations are always scoped to the owner's session.
from __future__ import annotations
@@ -37,10 +37,10 @@ from ._schemas import (
# #region _serialize_dataset_review_context [C:4] [TYPE Function]
# @BRIEF Build assistant-safe dataset-review context snapshot with masked imported-filter payloads for session-scoped assistant routing.
# @PRE session_id is a valid active review session identifier.
# @POST Returns a serializable dictionary containing the complete review context.
# @SIDE_EFFECT Reads session data from the database.
# @RELATION DEPENDS_ON -> [DatasetReviewSession]
# @PRE: session_id is a valid active review session identifier.
# @POST: Returns a serializable dictionary containing the complete review context.
# @SIDE_EFFECT: Reads session data from the database.
def _serialize_dataset_review_context(session: DatasetReviewSession) -> Dict[str, Any]:
with belief_scope('_serialize_dataset_review_context'):
logger.reason('Belief protocol reasoning checkpoint for _serialize_dataset_review_context')
@@ -57,10 +57,10 @@ def _serialize_dataset_review_context(session: DatasetReviewSession) -> Dict[str
# #region _load_dataset_review_context [C:4] [TYPE Function]
# @BRIEF Load owner-scoped dataset-review context for assistant planning and grounded response generation.
# @PRE session_id is a valid active review session identifier.
# @POST Returns a loaded context object with session data and findings.
# @SIDE_EFFECT Reads session data from the database.
# @RELATION DEPENDS_ON -> [DatasetReviewSessionRepository]
# @PRE: session_id is a valid active review session identifier.
# @POST: Returns a loaded context object with session data and findings.
# @SIDE_EFFECT: Reads session data from the database.
def _load_dataset_review_context(dataset_review_session_id: Optional[str], current_user: User, db: Session) -> Optional[Dict[str, Any]]:
with belief_scope('_load_dataset_review_context'):
if not dataset_review_session_id:
@@ -136,7 +136,7 @@ def _extract_quoted_segment(message: str, label: str) -> Optional[str]:
# #region _plan_dataset_review_intent [C:3] [TYPE Function]
# @BRIEF Parse session-scoped dataset-review assistant commands before falling back to generic assistant tool routing.
# @RELATION CALLS -> [DatasetReviewOrchestrator]
# @RELATION CALLS -> DatasetReviewOrchestrator
def _plan_dataset_review_intent(
message: str,
dataset_context: Dict[str, Any],

View File

@@ -1,10 +1,10 @@
# #region AssistantDatasetReviewDispatch [C:4] [TYPE Module]
# @BRIEF Dispatch and confirmation handling for dataset-review assistant intents.
# @LAYER API
# @INVARIANT Dataset review dispatch requires valid session version for write operations.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantDatasetReview]
# @RELATION DEPENDS_ON -> [DatasetReviewOrchestrator]
# @RELATION DEPENDS_ON -> [AssistantSchemas]
# @INVARIANT: Dataset review dispatch requires valid session version for write operations.
from __future__ import annotations
@@ -58,10 +58,10 @@ def _dataset_review_conflict_http_exception(
# #region _dispatch_dataset_review_intent [C:4] [TYPE Function]
# @BRIEF Route confirmed dataset-review assistant intents through existing backend dataset-review APIs and orchestration boundaries.
# @PRE context contains valid session data and user intent.
# @POST Returns a structured response with planned actions and confirmations.
# @SIDE_EFFECT May update session state and enqueue tasks.
# @RELATION CALLS -> [DatasetReviewOrchestrator]
# @RELATION CALLS -> DatasetReviewOrchestrator
# @PRE: context contains valid session data and user intent.
# @POST: Returns a structured response with planned actions and confirmations.
# @SIDE_EFFECT: May update session state and enqueue tasks.
async def _dispatch_dataset_review_intent(
intent: Dict[str, Any],
current_user: User,

View File

@@ -1,11 +1,11 @@
# #region AssistantDispatch [C:5] [TYPE Module]
# @BRIEF Intent dispatch engine, confirmation summary, and clarification text for the assistant API.
# @LAYER API
# @INVARIANT Unsupported operations are rejected via HTTPException(400).
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantSchemas]
# @RELATION DEPENDS_ON -> [AssistantResolvers]
# @RELATION DEPENDS_ON -> [AssistantLlmPlanner]
# @RELATION DEPENDS_ON -> [AssistantDatasetReview]
# @INVARIANT: Unsupported operations are rejected via HTTPException(400).
from __future__ import annotations
@@ -42,8 +42,8 @@ git_service = GitService()
# #region _clarification_text_for_intent [C:2] [TYPE Function]
# @BRIEF Convert technical missing-parameter errors into user-facing clarification prompts.
# @PRE state was classified as needs_clarification for current intent/error combination.
# @POST Returned text is human-readable and actionable for target operation.
# @PRE: state was classified as needs_clarification for current intent/error combination.
# @POST: Returned text is human-readable and actionable for target operation.
def _clarification_text_for_intent(
intent: Optional[Dict[str, Any]], detail_text: str
) -> str:
@@ -69,9 +69,9 @@ def _clarification_text_for_intent(
# #region _async_confirmation_summary [C:4] [TYPE Function]
# @BRIEF Build human-readable confirmation prompt for an intent before execution.
# @PRE actions is a non-empty list of planned review actions.
# @POST Returns a formatted summary string suitable for display to the user.
# @SIDE_EFFECT None - pure formatting function.
# @PRE: actions is a non-empty list of planned review actions.
# @POST: Returns a formatted summary string suitable for display to the user.
# @SIDE_EFFECT: None - pure formatting function.
async def _async_confirmation_summary(intent: Dict[str, Any], config_manager: ConfigManager, db: Session) -> str:
with belief_scope('_confirmation_summary'):
logger.reason('Belief protocol reasoning checkpoint for _confirmation_summary')
@@ -139,15 +139,15 @@ async def _async_confirmation_summary(intent: Dict[str, Any], config_manager: Co
# #region _dispatch_intent [C:5] [TYPE Function]
# @BRIEF Execute parsed assistant intent via existing task/plugin/git services.
# @PRE intent operation is known and actor permissions are validated per operation.
# @POST Returns response text, optional task id, and UI actions for follow-up.
# @SIDE_EFFECT May enqueue tasks, invoke git operations, and query/update external service state.
# @DATA_CONTRACT Input[intent,current_user,task_manager,config_manager,db] -> Output[Tuple[text:str,task_id:Optional[str],actions:List[AssistantAction]]]
# @INVARIANT unsupported operations are rejected via HTTPException(400).
# @DATA_CONTRACT: Input[intent,current_user,task_manager,config_manager,db] -> Output[Tuple[text:str,task_id:Optional[str],actions:List[AssistantAction]]]
# @RELATION DEPENDS_ON -> [_check_any_permission]
# @RELATION DEPENDS_ON -> [_resolve_dashboard_id_entity]
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DEPENDS_ON -> [GitService]
# @SIDE_EFFECT: May enqueue tasks, invoke git operations, and query/update external service state.
# @PRE: intent operation is known and actor permissions are validated per operation.
# @POST: Returns response text, optional task id, and UI actions for follow-up.
# @INVARIANT: unsupported operations are rejected via HTTPException(400).
async def _dispatch_intent(intent: Dict[str, Any], current_user: User, task_manager: TaskManager, config_manager: ConfigManager, db: Session) -> Tuple[str, Optional[str], List[AssistantAction]]:
with belief_scope('_dispatch_intent'):
logger.reason('Belief protocol reasoning checkpoint for _dispatch_intent')

View File

@@ -1,8 +1,8 @@
# #region AssistantHistory [C:2] [TYPE Module]
# @BRIEF Conversation history, audit trail, and confirmation persistence helpers for the assistant API.
# @LAYER API
# @INVARIANT Failed persistence attempts always rollback before returning.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantSchemas]
# @INVARIANT: Failed persistence attempts always rollback before returning.
from __future__ import annotations
@@ -32,12 +32,12 @@ logger = logger
# #region _append_history [C:2] [TYPE Function]
# @BRIEF Append conversation message to in-memory history buffer.
# @PRE user_id and conversation_id identify target conversation bucket.
# @POST Message entry is appended to CONVERSATIONS key list.
# @SIDE_EFFECT Mutates in-memory CONVERSATIONS store for user conversation history.
# @DATA_CONTRACT Input[user_id,conversation_id,role,text,state?,task_id?,confirmation_id?] -> Output[None]
# @INVARIANT every appended entry includes generated message_id and created_at timestamp.
# @DATA_CONTRACT: Input[user_id,conversation_id,role,text,state?,task_id?,confirmation_id?] -> Output[None]
# @RELATION UPDATES -> [CONVERSATIONS]
# @SIDE_EFFECT: Mutates in-memory CONVERSATIONS store for user conversation history.
# @PRE: user_id and conversation_id identify target conversation bucket.
# @POST: Message entry is appended to CONVERSATIONS key list.
# @INVARIANT: every appended entry includes generated message_id and created_at timestamp.
def _append_history(
user_id: str,
conversation_id: str,
@@ -69,12 +69,12 @@ def _append_history(
# #region _persist_message [C:2] [TYPE Function]
# @BRIEF Persist assistant/user message record to database.
# @PRE db session is writable and message payload is serializable.
# @POST Message row is committed or persistence failure is logged.
# @SIDE_EFFECT Writes AssistantMessageRecord rows and commits or rollbacks the DB session.
# @DATA_CONTRACT Input[Session,user_id,conversation_id,role,text,state?,task_id?,confirmation_id?,metadata?] -> Output[None]
# @INVARIANT failed persistence attempts always rollback before returning.
# @DATA_CONTRACT: Input[Session,user_id,conversation_id,role,text,state?,task_id?,confirmation_id?,metadata?] -> Output[None]
# @RELATION DEPENDS_ON -> [AssistantMessageRecord]
# @SIDE_EFFECT: Writes AssistantMessageRecord rows and commits or rollbacks the DB session.
# @PRE: db session is writable and message payload is serializable.
# @POST: Message row is committed or persistence failure is logged.
# @INVARIANT: failed persistence attempts always rollback before returning.
def _persist_message(
db: Session,
user_id: str,
@@ -110,12 +110,12 @@ def _persist_message(
# #region _audit [C:2] [TYPE Function]
# @BRIEF Append in-memory audit record for assistant decision trace.
# @PRE payload describes decision/outcome fields.
# @POST ASSISTANT_AUDIT list for user contains new timestamped entry.
# @SIDE_EFFECT Mutates in-memory ASSISTANT_AUDIT store and emits structured log event.
# @DATA_CONTRACT Input[user_id,payload:Dict[str,Any]] -> Output[None]
# @INVARIANT persisted in-memory audit entry always contains created_at in ISO format.
# @DATA_CONTRACT: Input[user_id,payload:Dict[str,Any]] -> Output[None]
# @RELATION UPDATES -> [ASSISTANT_AUDIT]
# @SIDE_EFFECT: Mutates in-memory ASSISTANT_AUDIT store and emits structured log event.
# @PRE: payload describes decision/outcome fields.
# @POST: ASSISTANT_AUDIT list for user contains new timestamped entry.
# @INVARIANT: persisted in-memory audit entry always contains created_at in ISO format.
def _audit(user_id: str, payload: Dict[str, Any]):
if user_id not in ASSISTANT_AUDIT:
ASSISTANT_AUDIT[user_id] = []
@@ -130,8 +130,8 @@ def _audit(user_id: str, payload: Dict[str, Any]):
# #region _persist_audit [C:2] [TYPE Function]
# @BRIEF Persist structured assistant audit payload in database.
# @PRE db session is writable and payload is JSON-serializable.
# @POST Audit row is committed or failure is logged with rollback.
# @PRE: db session is writable and payload is JSON-serializable.
# @POST: Audit row is committed or failure is logged with rollback.
def _persist_audit(
db: Session, user_id: str, payload: Dict[str, Any], conversation_id: Optional[str]
):
@@ -157,8 +157,8 @@ def _persist_audit(
# #region _persist_confirmation [C:2] [TYPE Function]
# @BRIEF Persist confirmation token record to database.
# @PRE record contains id/user/intent/dispatch/expiry fields.
# @POST Confirmation row exists in persistent storage.
# @PRE: record contains id/user/intent/dispatch/expiry fields.
# @POST: Confirmation row exists in persistent storage.
def _persist_confirmation(db: Session, record: ConfirmationRecord):
try:
row = AssistantConfirmationRecord(
@@ -184,8 +184,8 @@ def _persist_confirmation(db: Session, record: ConfirmationRecord):
# #region _update_confirmation_state [C:2] [TYPE Function]
# @BRIEF Update persistent confirmation token lifecycle state.
# @PRE confirmation_id references existing row.
# @POST State and consumed_at fields are updated when applicable.
# @PRE: confirmation_id references existing row.
# @POST: State and consumed_at fields are updated when applicable.
def _update_confirmation_state(db: Session, confirmation_id: str, state: str):
try:
row = (
@@ -209,8 +209,8 @@ def _update_confirmation_state(db: Session, confirmation_id: str, state: str):
# #region _load_confirmation_from_db [C:2] [TYPE Function]
# @BRIEF Load confirmation token from database into in-memory model.
# @PRE confirmation_id may or may not exist in storage.
# @POST Returns ConfirmationRecord when found, otherwise None.
# @PRE: confirmation_id may or may not exist in storage.
# @POST: Returns ConfirmationRecord when found, otherwise None.
def _load_confirmation_from_db(
db: Session, confirmation_id: str
) -> Optional[ConfirmationRecord]:
@@ -238,8 +238,8 @@ def _load_confirmation_from_db(
# #region _ensure_conversation [C:2] [TYPE Function]
# @BRIEF Resolve active conversation id in memory or create a new one.
# @PRE user_id identifies current actor.
# @POST Returns stable conversation id and updates USER_ACTIVE_CONVERSATION.
# @PRE: user_id identifies current actor.
# @POST: Returns stable conversation id and updates USER_ACTIVE_CONVERSATION.
def _ensure_conversation(user_id: str, conversation_id: Optional[str]) -> str:
if conversation_id:
from ._schemas import USER_ACTIVE_CONVERSATION
@@ -261,8 +261,8 @@ def _ensure_conversation(user_id: str, conversation_id: Optional[str]) -> str:
# #region _resolve_or_create_conversation [C:2] [TYPE Function]
# @BRIEF Resolve active conversation using explicit id, memory cache, or persisted history.
# @PRE user_id and db session are available.
# @POST Returns conversation id and updates USER_ACTIVE_CONVERSATION cache.
# @PRE: user_id and db session are available.
# @POST: Returns conversation id and updates USER_ACTIVE_CONVERSATION cache.
def _resolve_or_create_conversation(
user_id: str, conversation_id: Optional[str], db: Session
) -> str:
@@ -298,8 +298,8 @@ def _resolve_or_create_conversation(
# #region _cleanup_history_ttl [C:2] [TYPE Function]
# @BRIEF Enforce assistant message retention window by deleting expired rows and in-memory records.
# @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.
# @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)
try:
@@ -339,8 +339,8 @@ def _cleanup_history_ttl(db: Session, user_id: str):
# #region _is_conversation_archived [C:2] [TYPE Function]
# @BRIEF Determine archived state for a conversation based on last update timestamp.
# @PRE updated_at can be null for empty conversations.
# @POST Returns True when conversation inactivity exceeds archive threshold.
# @PRE: updated_at can be null for empty conversations.
# @POST: Returns True when conversation inactivity exceeds archive threshold.
def _is_conversation_archived(updated_at: Optional[datetime]) -> bool:
if not updated_at:
return False
@@ -353,8 +353,8 @@ def _is_conversation_archived(updated_at: Optional[datetime]) -> bool:
# #region _coerce_query_bool [C:2] [TYPE Function]
# @BRIEF Normalize bool-like query values for compatibility in direct handler invocations/tests.
# @PRE value may be bool, string, or FastAPI Query metadata object.
# @POST Returns deterministic boolean flag.
# @PRE: value may be bool, string, or FastAPI Query metadata object.
# @POST: Returns deterministic boolean flag.
def _coerce_query_bool(value: Any) -> bool:
if isinstance(value, bool):
return value

View File

@@ -1,10 +1,10 @@
# #region AssistantLlmPlanner [C:3] [TYPE Module]
# @BRIEF LLM-based intent planning, tool catalog construction, and authorization for the assistant API.
# @LAYER API
# @INVARIANT Tool catalog is filtered by user permissions before being sent to LLM.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantSchemas]
# @RELATION DEPENDS_ON -> [AssistantResolvers]
# @RELATION DISPATCHES -> [AssistantLlmPlannerIntent]
# @INVARIANT: Tool catalog is filtered by user permissions before being sent to LLM.
from __future__ import annotations
@@ -30,8 +30,8 @@ from ._resolvers import (
# #region _check_any_permission [C:2] [TYPE Function]
# @BRIEF Validate user against alternative permission checks (logical OR).
# @PRE checks list contains resource-action tuples.
# @POST Returns on first successful permission; raises 403-like HTTPException otherwise.
# @PRE: checks list contains resource-action tuples.
# @POST: Returns on first successful permission; raises 403-like HTTPException otherwise.
def _check_any_permission(current_user: User, checks: List[Tuple[str, str]]):
errors: List[HTTPException] = []
for resource, action in checks:
@@ -53,8 +53,8 @@ def _check_any_permission(current_user: User, checks: List[Tuple[str, str]]):
# #region _has_any_permission [C:2] [TYPE Function]
# @BRIEF Check whether user has at least one permission tuple from the provided list.
# @PRE current_user and checks list are valid.
# @POST Returns True when at least one permission check passes.
# @PRE: current_user and checks list are valid.
# @POST: Returns True when at least one permission check passes.
def _has_any_permission(current_user: User, checks: List[Tuple[str, str]]) -> bool:
try:
_check_any_permission(current_user, checks)
@@ -68,9 +68,9 @@ def _has_any_permission(current_user: User, checks: List[Tuple[str, str]]) -> bo
# #region _build_tool_catalog [C:3] [TYPE Function]
# @BRIEF Build current-user tool catalog for LLM planner with operation contracts and defaults.
# @PRE current_user is authenticated; config/db are available.
# @POST Returns list of executable tools filtered by permission and runtime availability.
# @RELATION CALLS -> [LLMProviderService]
# @PRE: current_user is authenticated; config/db are available.
# @POST: Returns list of executable tools filtered by permission and runtime availability.
# @RELATION CALLS -> LLMProviderService
def _build_tool_catalog(
current_user: User,
config_manager: ConfigManager,
@@ -270,8 +270,8 @@ def _build_tool_catalog(
# #region _coerce_intent_entities [C:2] [TYPE Function]
# @BRIEF Normalize intent entity value types from LLM output to route-compatible values.
# @PRE intent contains entities dict or missing entities.
# @POST Returned intent has numeric ids coerced where possible and string values stripped.
# @PRE: intent contains entities dict or missing entities.
# @POST: Returned intent has numeric ids coerced where possible and string values stripped.
def _coerce_intent_entities(intent: Dict[str, Any]) -> Dict[str, Any]:
entities = intent.get("entities")
if not isinstance(entities, dict):

View File

@@ -1,9 +1,9 @@
# #region AssistantLlmPlannerIntent [C:3] [TYPE Module]
# @BRIEF LLM-based intent planning and authorization for the assistant API — separated from tool catalog.
# @LAYER API
# @INVARIANT Production deployments always require confirmation.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantLlmPlanner]
# @RELATION DEPENDS_ON -> [AssistantResolvers]
# @INVARIANT: Production deployments always require confirmation.
from __future__ import annotations
@@ -35,8 +35,8 @@ from ._llm_planner import (
# #region _plan_intent_with_llm [C:2] [TYPE Function]
# @BRIEF Use active LLM provider to select best tool/operation from dynamic catalog.
# @PRE tools list contains allowed operations for current user.
# @POST Returns normalized intent dict when planning succeeds; otherwise None.
# @PRE: tools list contains allowed operations for current user.
# @POST: Returns normalized intent dict when planning succeeds; otherwise None.
async def _plan_intent_with_llm(
message: str,
tools: List[Dict[str, Any]],
@@ -156,8 +156,8 @@ async def _plan_intent_with_llm(
# #region _authorize_intent [C:2] [TYPE Function]
# @BRIEF Validate user permissions for parsed intent before confirmation/dispatch.
# @PRE intent.operation is present for known assistant command domains.
# @POST Returns if authorized; raises HTTPException(403) when denied.
# @PRE: intent.operation is present for known assistant command domains.
# @POST: Returns if authorized; raises HTTPException(403) when denied.
def _authorize_intent(intent: Dict[str, Any], current_user: User):
operation = intent.get("operation")
if operation in INTENT_PERMISSION_CHECKS:

View File

@@ -1,9 +1,9 @@
# #region AssistantResolvers [C:2] [TYPE Module]
# @BRIEF Environment, dashboard, provider, and task resolution utilities for the assistant API.
# @LAYER API
# @INVARIANT Resolution functions never raise; they return None on failure.
# @LAYER: API
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @INVARIANT: Resolution functions never raise; they return None on failure.
from __future__ import annotations
@@ -24,8 +24,8 @@ logger = cast(Any, logger)
# #region _extract_id [C:2] [TYPE Function]
# @BRIEF Extract first regex match group from text by ordered pattern list.
# @PRE patterns contain at least one capture group.
# @POST Returns first matched token or None.
# @PRE: patterns contain at least one capture group.
# @POST: Returns first matched token or None.
def _extract_id(text: str, patterns: List[str]) -> Optional[str]:
for p in patterns:
m = re.search(p, text, flags=re.IGNORECASE)
@@ -38,8 +38,8 @@ def _extract_id(text: str, patterns: List[str]) -> Optional[str]:
# #region _resolve_env_id [C:2] [TYPE Function]
# @BRIEF Resolve environment identifier/name token to canonical environment id.
# @PRE config_manager provides environment list.
# @POST Returns matched environment id or None.
# @PRE: config_manager provides environment list.
# @POST: Returns matched environment id or None.
def _resolve_env_id(
token: Optional[str], config_manager: ConfigManager
) -> Optional[str]:
@@ -58,8 +58,8 @@ def _resolve_env_id(
# #region _is_production_env [C:2] [TYPE Function]
# @BRIEF Determine whether environment token resolves to production-like target.
# @PRE config_manager provides environments or token text is provided.
# @POST Returns True for production/prod synonyms, else False.
# @PRE: config_manager provides environments or token text is provided.
# @POST: Returns True for production/prod synonyms, else False.
def _is_production_env(token: Optional[str], config_manager: ConfigManager) -> bool:
env_id = _resolve_env_id(token, config_manager)
if not env_id:
@@ -76,8 +76,8 @@ def _is_production_env(token: Optional[str], config_manager: ConfigManager) -> b
# #region _resolve_provider_id [C:2] [TYPE Function]
# @BRIEF Resolve provider token to provider id with active/default fallback.
# @PRE db session can load provider list through LLMProviderService.
# @POST Returns provider id or None when no providers configured.
# @PRE: db session can load provider list through LLMProviderService.
# @POST: Returns provider id or None when no providers configured.
def _resolve_provider_id(
provider_token: Optional[str],
db: Session,
@@ -112,8 +112,8 @@ def _resolve_provider_id(
# #region _get_default_environment_id [C:2] [TYPE Function]
# @BRIEF Resolve default environment id from settings or first configured environment.
# @PRE config_manager returns environments list.
# @POST Returns default environment id or None when environment list is empty.
# @PRE: config_manager returns environments list.
# @POST: Returns default environment id or None when environment list is empty.
def _get_default_environment_id(config_manager: ConfigManager) -> Optional[str]:
configured = config_manager.get_environments()
if not configured:
@@ -136,8 +136,8 @@ def _get_default_environment_id(config_manager: ConfigManager) -> Optional[str]:
# #region _resolve_dashboard_id_by_ref [C:2] [TYPE Function]
# @BRIEF Resolve dashboard id by title or slug reference in selected environment.
# @PRE dashboard_ref is a non-empty string-like token.
# @POST Returns dashboard id when uniquely matched, otherwise None.
# @PRE: dashboard_ref is a non-empty string-like token.
# @POST: Returns dashboard id when uniquely matched, otherwise None.
def _resolve_dashboard_id_by_ref(
dashboard_ref: Optional[str],
env_id: Optional[str],
@@ -188,8 +188,8 @@ def _resolve_dashboard_id_by_ref(
# #region _resolve_dashboard_id_entity [C:2] [TYPE Function]
# @BRIEF Resolve dashboard id from intent entities using numeric id or dashboard_ref fallback.
# @PRE entities may contain dashboard_id as int/str and optional dashboard_ref.
# @POST Returns resolved dashboard id or None when ambiguous/unresolvable.
# @PRE: entities may contain dashboard_id as int/str and optional dashboard_ref.
# @POST: Returns resolved dashboard id or None when ambiguous/unresolvable.
def _resolve_dashboard_id_entity(
entities: Dict[str, Any],
config_manager: ConfigManager,
@@ -229,8 +229,8 @@ def _resolve_dashboard_id_entity(
# #region _get_environment_name_by_id [C:2] [TYPE Function]
# @BRIEF Resolve human-readable environment name by id.
# @PRE environment id may be None.
# @POST Returns matching environment name or fallback id.
# @PRE: environment id may be None.
# @POST: Returns matching environment name or fallback id.
def _get_environment_name_by_id(
env_id: Optional[str], config_manager: ConfigManager
) -> str:
@@ -246,8 +246,8 @@ def _get_environment_name_by_id(
# #region _extract_result_deep_links [C:2] [TYPE Function]
# @BRIEF Build deep-link actions to verify task result from assistant chat.
# @PRE task object is available.
# @POST Returns zero or more assistant actions for dashboard open/diff.
# @PRE: task object is available.
# @POST: Returns zero or more assistant actions for dashboard open/diff.
def _extract_result_deep_links(
task: Any, config_manager: ConfigManager
) -> List:
@@ -319,8 +319,8 @@ def _extract_result_deep_links(
# #region _build_task_observability_summary [C:2] [TYPE Function]
# @BRIEF Build compact textual summary for completed tasks to reduce "black box" effect.
# @PRE task may contain plugin-specific result payload.
# @POST Returns non-empty summary line for known task types or empty string fallback.
# @PRE: task may contain plugin-specific result payload.
# @POST: Returns non-empty summary line for known task types or empty string fallback.
def _build_task_observability_summary(task: Any, config_manager: ConfigManager) -> str:
plugin_id = getattr(task, "plugin_id", None)
status = str(getattr(task, "status", "")).upper()

View File

@@ -1,7 +1,6 @@
# #region AssistantRoutes [C:5] [TYPE Module]
# @BRIEF FastAPI route handlers for the assistant API — message sending, confirmation, conversation management.
# @LAYER API
# @INVARIANT Risky operations are never executed without valid confirmation token.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AssistantSchemas]
# @RELATION DEPENDS_ON -> [AssistantHistory]
# @RELATION DEPENDS_ON -> [AssistantCommandParser]
@@ -9,6 +8,7 @@
# @RELATION DEPENDS_ON -> [AssistantDatasetReview]
# @RELATION DEPENDS_ON -> [AssistantDispatch]
# @RELATION DISPATCHES -> [AssistantAdminRoutes]
# @INVARIANT: Risky operations are never executed without valid confirmation token.
from __future__ import annotations
@@ -78,18 +78,17 @@ router = APIRouter(tags=["Assistant"])
@router.post("/messages", response_model=AssistantMessageResponse)
# #region send_message [C:5] [TYPE Function]
# @BRIEF Parse assistant command, enforce safety gates, and dispatch executable intent.
# @PRE Authenticated user is available and message text is non-empty.
# @POST Response state is one of clarification/confirmation/started/success/denied/failed.
# @SIDE_EFFECT Persists chat/audit state, mutates in-memory conversation and confirmation stores, and may create confirmation records.
# @DATA_CONTRACT Input[AssistantMessageRequest,User,TaskManager,ConfigManager,Session] -> Output[AssistantMessageResponse]
# @INVARIANT non-safe operations are gated with confirmation before execution from this endpoint.
# @RETURN AssistantMessageResponse with operation feedback and optional actions.
# @DATA_CONTRACT: Input[AssistantMessageRequest,User,TaskManager,ConfigManager,Session] -> Output[AssistantMessageResponse]
# @RELATION DEPENDS_ON -> [_plan_intent_with_llm]
# @RELATION DEPENDS_ON -> [_parse_command]
# @RELATION DEPENDS_ON -> [_dispatch_intent]
# @RELATION DEPENDS_ON -> [_append_history]
# @RELATION DEPENDS_ON -> [_persist_message]
# @RELATION DEPENDS_ON -> [_audit]
# @SIDE_EFFECT: Persists chat/audit state, mutates in-memory conversation and confirmation stores, and may create confirmation records.
# @PRE: Authenticated user is available and message text is non-empty.
# @POST: Response state is one of clarification/confirmation/started/success/denied/failed.
# @INVARIANT: non-safe operations are gated with confirmation before execution from this endpoint.
async def send_message(request: AssistantMessageRequest, current_user: User=Depends(get_current_user), task_manager: TaskManager=Depends(get_task_manager), config_manager: ConfigManager=Depends(get_config_manager), db: Session=Depends(get_db)):
with belief_scope('send_message'):
logger.reason('Belief protocol reasoning checkpoint for send_message')
@@ -172,9 +171,8 @@ async def send_message(request: AssistantMessageRequest, current_user: User=Depe
)
# #region confirm_operation [C:2] [TYPE Function]
# @BRIEF Execute previously requested risky operation after explicit user confirmation.
# @PRE confirmation_id exists, belongs to current user, is pending, and not expired.
# @POST Confirmation state becomes consumed and operation result is persisted in history.
# @RETURN AssistantMessageResponse with task details when async execution starts.
# @PRE: confirmation_id exists, belongs to current user, is pending, and not expired.
# @POST: Confirmation state becomes consumed and operation result is persisted in history.
async def confirm_operation(
confirmation_id: str,
current_user: User = Depends(get_current_user),
@@ -260,9 +258,8 @@ async def confirm_operation(
)
# #region cancel_operation [C:2] [TYPE Function]
# @BRIEF Cancel pending risky operation and mark confirmation token as cancelled.
# @PRE confirmation_id exists, belongs to current user, and is still pending.
# @POST Confirmation becomes cancelled and cannot be executed anymore.
# @RETURN AssistantMessageResponse confirming cancellation.
# @PRE: confirmation_id exists, belongs to current user, and is still pending.
# @POST: Confirmation becomes cancelled and cannot be executed anymore.
async def cancel_operation(
confirmation_id: str,
current_user: User = Depends(get_current_user),

View File

@@ -1,9 +1,9 @@
# #region AssistantSchemas [C:2] [TYPE Module]
# @BRIEF Pydantic models, in-memory stores, and permission mappings for the assistant API.
# @LAYER API
# @INVARIANT In-memory stores are module-level singletons shared across the assistant package.
# @LAYER: API
# @RELATION USED_BY -> [AssistantRoutes]
# @RELATION USED_BY -> [AssistantHistory]
# @INVARIANT: In-memory stores are module-level singletons shared across the assistant package.
from __future__ import annotations
@@ -17,12 +17,12 @@ from src.schemas.auth import User
# #region AssistantMessageRequest [C:1] [TYPE Class]
# @BRIEF Input payload for assistant message endpoint.
# @PRE message length is within accepted bounds.
# @POST Request object provides message text and optional conversation binding.
# @SIDE_EFFECT None (schema declaration only).
# @DATA_CONTRACT Input[conversation_id?:str, message:str(1..4000)] -> Output[AssistantMessageRequest]
# @INVARIANT message is always non-empty and no longer than 4000 characters.
# @DATA_CONTRACT: Input[conversation_id?:str, message:str(1..4000)] -> Output[AssistantMessageRequest]
# @RELATION USED_BY -> [send_message]
# @SIDE_EFFECT: None (schema declaration only).
# @PRE: message length is within accepted bounds.
# @POST: Request object provides message text and optional conversation binding.
# @INVARIANT: message is always non-empty and no longer than 4000 characters.
class AssistantMessageRequest(BaseModel):
conversation_id: Optional[str] = None
message: str = Field(..., min_length=1, max_length=4000)
@@ -34,12 +34,12 @@ class AssistantMessageRequest(BaseModel):
# #region AssistantAction [C:1] [TYPE Class]
# @BRIEF UI action descriptor returned with assistant responses.
# @PRE type and label are provided by orchestration logic.
# @POST Action can be rendered as button on frontend.
# @SIDE_EFFECT None (schema declaration only).
# @DATA_CONTRACT Input[type:str, label:str, target?:str] -> Output[AssistantAction]
# @INVARIANT type and label are required for every UI action.
# @DATA_CONTRACT: Input[type:str, label:str, target?:str] -> Output[AssistantAction]
# @RELATION USED_BY -> [AssistantMessageResponse]
# @SIDE_EFFECT: None (schema declaration only).
# @PRE: type and label are provided by orchestration logic.
# @POST: Action can be rendered as button on frontend.
# @INVARIANT: type and label are required for every UI action.
class AssistantAction(BaseModel):
type: str
label: str
@@ -51,14 +51,14 @@ class AssistantAction(BaseModel):
# #region AssistantMessageResponse [C:1] [TYPE Class]
# @BRIEF Output payload contract for assistant interaction endpoints.
# @PRE Response includes deterministic state and text.
# @POST Payload may include task_id/confirmation_id/actions for UI follow-up.
# @SIDE_EFFECT None (schema declaration only).
# @DATA_CONTRACT Input[conversation_id,response_id,state,text,intent?,confirmation_id?,task_id?,actions[],created_at] -> Output[AssistantMessageResponse]
# @INVARIANT created_at and state are always present in endpoint responses.
# @DATA_CONTRACT: Input[conversation_id,response_id,state,text,intent?,confirmation_id?,task_id?,actions[],created_at] -> Output[AssistantMessageResponse]
# @RELATION RETURNED_BY -> [send_message]
# @RELATION RETURNED_BY -> [confirm_operation]
# @RELATION RETURNED_BY -> [cancel_operation]
# @SIDE_EFFECT: None (schema declaration only).
# @PRE: Response includes deterministic state and text.
# @POST: Payload may include task_id/confirmation_id/actions for UI follow-up.
# @INVARIANT: created_at and state are always present in endpoint responses.
class AssistantMessageResponse(BaseModel):
conversation_id: str
response_id: str
@@ -76,14 +76,14 @@ class AssistantMessageResponse(BaseModel):
# #region ConfirmationRecord [C:1] [TYPE Class]
# @BRIEF In-memory confirmation token model for risky operation dispatch.
# @PRE intent/dispatch/user_id are populated at confirmation request time.
# @POST Record tracks lifecycle state and expiry timestamp.
# @SIDE_EFFECT None (schema declaration only).
# @DATA_CONTRACT Input[id,user_id,conversation_id,intent,dispatch,expires_at,state?,created_at] -> Output[ConfirmationRecord]
# @INVARIANT state defaults to "pending" and expires_at bounds confirmation validity.
# @DATA_CONTRACT: Input[id,user_id,conversation_id,intent,dispatch,expires_at,state?,created_at] -> Output[ConfirmationRecord]
# @RELATION USED_BY -> [send_message]
# @RELATION USED_BY -> [confirm_operation]
# @RELATION USED_BY -> [cancel_operation]
# @SIDE_EFFECT: None (schema declaration only).
# @PRE: intent/dispatch/user_id are populated at confirmation request time.
# @POST: Record tracks lifecycle state and expiry timestamp.
# @INVARIANT: state defaults to "pending" and expires_at bounds confirmation validity.
class ConfirmationRecord(BaseModel):
id: str
user_id: str

View File

@@ -1,12 +1,12 @@
# #region CleanReleaseApi [C:4] [TYPE Module] [SEMANTICS api, clean-release, candidate-preparation, compliance]
# @BRIEF Expose clean release endpoints for candidate preparation and subsequent compliance flow.
# @LAYER API
# @PRE Clean release repository and preparation service dependencies are configured for the current request scope.
# @POST Candidate preparation, manifest, and compliance routes expose deterministic API payloads without reporting prepared state on failed preparation.
# @SIDE_EFFECT Persists candidate/compliance lifecycle state and triggers clean-release orchestration services.
# @INVARIANT API never reports prepared status if preparation errors are present.
# @LAYER: API
# @RELATION DEPENDS_ON -> [get_clean_release_repository]
# @RELATION DEPENDS_ON -> [PreparationService]
# @PRE: Clean release repository and preparation service dependencies are configured for the current request scope.
# @POST: Candidate preparation, manifest, and compliance routes expose deterministic API payloads without reporting prepared state on failed preparation.
# @SIDE_EFFECT: Persists candidate/compliance lifecycle state and triggers clean-release orchestration services.
# @INVARIANT: API never reports prepared status if preparation errors are present.
from __future__ import annotations
@@ -119,8 +119,8 @@ class CreateComplianceRunRequest(BaseModel):
# #region register_candidate_v2_endpoint [TYPE Function]
# @BRIEF Register a clean-release candidate for headless lifecycle.
# @PRE Candidate identifier is unique.
# @POST Candidate is persisted in DRAFT status.
# @PRE: Candidate identifier is unique.
# @POST: Candidate is persisted in DRAFT status.
@router.post(
"/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED
)
@@ -160,8 +160,8 @@ async def register_candidate_v2_endpoint(
# #region import_candidate_artifacts_v2_endpoint [TYPE Function]
# @BRIEF Import candidate artifacts in headless flow.
# @PRE Candidate exists and artifacts array is non-empty.
# @POST Artifacts are persisted and candidate advances to PREPARED if it was DRAFT.
# @PRE: Candidate exists and artifacts array is non-empty.
# @POST: Artifacts are persisted and candidate advances to PREPARED if it was DRAFT.
@router.post("/candidates/{candidate_id}/artifacts")
async def import_candidate_artifacts_v2_endpoint(
candidate_id: str,
@@ -218,8 +218,8 @@ async def import_candidate_artifacts_v2_endpoint(
# #region build_candidate_manifest_v2_endpoint [TYPE Function]
# @BRIEF Build immutable manifest snapshot for prepared candidate.
# @PRE Candidate exists and has imported artifacts.
# @POST Returns created ManifestDTO with incremented version.
# @PRE: Candidate exists and has imported artifacts.
# @POST: Returns created ManifestDTO with incremented version.
@router.post(
"/candidates/{candidate_id}/manifests",
response_model=ManifestDTO,
@@ -262,8 +262,8 @@ async def build_candidate_manifest_v2_endpoint(
# #region get_candidate_overview_v2_endpoint [TYPE Function]
# @BRIEF Return expanded candidate overview DTO for headless lifecycle visibility.
# @PRE Candidate exists.
# @POST Returns CandidateOverviewDTO built from the same repository state used by headless US1 endpoints.
# @PRE: Candidate exists.
# @POST: Returns CandidateOverviewDTO built from the same repository state used by headless US1 endpoints.
@router.get("/candidates/{candidate_id}/overview", response_model=CandidateOverviewDTO)
async def get_candidate_overview_v2_endpoint(
candidate_id: str,
@@ -378,8 +378,8 @@ async def get_candidate_overview_v2_endpoint(
# #region prepare_candidate_endpoint [TYPE Function]
# @BRIEF Prepare candidate with policy evaluation and deterministic manifest generation.
# @PRE Candidate and active policy exist in repository.
# @POST Returns preparation result including manifest reference and violations.
# @PRE: Candidate and active policy exist in repository.
# @POST: Returns preparation result including manifest reference and violations.
@router.post("/candidates/prepare")
async def prepare_candidate_endpoint(
payload: PrepareCandidateRequest,
@@ -412,8 +412,8 @@ async def prepare_candidate_endpoint(
# #region start_check [TYPE Function]
# @BRIEF Start and finalize a clean compliance check run and persist report artifacts.
# @PRE Active policy and candidate exist.
# @POST Returns accepted payload with check_run_id and started_at.
# @PRE: Active policy and candidate exist.
# @POST: Returns accepted payload with check_run_id and started_at.
@router.post("/checks", status_code=status.HTTP_202_ACCEPTED)
async def start_check(
payload: StartCheckRequest,
@@ -548,8 +548,8 @@ async def start_check(
# #region get_check_status [TYPE Function]
# @BRIEF Return terminal/intermediate status payload for a check run.
# @PRE check_run_id references an existing run.
# @POST Deterministic payload shape includes checks and violations arrays.
# @PRE: check_run_id references an existing run.
# @POST: Deterministic payload shape includes checks and violations arrays.
@router.get("/checks/{check_run_id}")
async def get_check_status(
check_run_id: str,
@@ -600,8 +600,8 @@ async def get_check_status(
# #region get_report [TYPE Function]
# @BRIEF Return persisted compliance report by report_id.
# @PRE report_id references an existing report.
# @POST Returns serialized report object.
# @PRE: report_id references an existing report.
# @POST: Returns serialized report object.
@router.get("/reports/{report_id}")
async def get_report(
report_id: str,

View File

@@ -1,12 +1,12 @@
# #region CleanReleaseV2Api [C:4] [TYPE Module]
# @BRIEF Redesigned clean release API for headless candidate lifecycle.
# @LAYER UI (API)
# @PRE Clean release repository dependency is available for candidate lifecycle endpoints.
# @POST Candidate registration, approval, publication, and revocation routes are registered without behavior changes.
# @SIDE_EFFECT Persists candidate lifecycle state through clean release services and repository adapters.
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
# @RELATION CALLS -> [approve_candidate]
# @RELATION CALLS -> [publish_candidate]
# @PRE: Clean release repository dependency is available for candidate lifecycle endpoints.
# @POST: Candidate registration, approval, publication, and revocation routes are registered without behavior changes.
# @SIDE_EFFECT: Persists candidate lifecycle state through clean release services and repository adapters.
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Dict, Any
@@ -61,9 +61,8 @@ class RevokeRequest(dict):
# #region register_candidate [C:3] [TYPE Function]
# @BRIEF Register a new release candidate.
# @PRE Payload contains required fields (id, version, source_snapshot_ref, created_by).
# @POST Candidate is saved in repository.
# @RETURN CandidateDTO
# @PRE: Payload contains required fields (id, version, source_snapshot_ref, created_by).
# @POST: Candidate is saved in repository.
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
# @RELATION DEPENDS_ON -> [clean_release_dto]
@router.post(
@@ -97,8 +96,8 @@ async def register_candidate(
# #region import_artifacts [C:3] [TYPE Function]
# @BRIEF Associate artifacts with a release candidate.
# @PRE Candidate exists.
# @POST Artifacts are processed (placeholder).
# @PRE: Candidate exists.
# @POST: Artifacts are processed (placeholder).
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
@router.post("/candidates/{candidate_id}/artifacts")
async def import_artifacts(
@@ -130,9 +129,8 @@ async def import_artifacts(
# #region build_manifest [C:3] [TYPE Function]
# @BRIEF Generate distribution manifest for a candidate.
# @PRE Candidate exists.
# @POST Manifest is created and saved.
# @RETURN ManifestDTO
# @PRE: Candidate exists.
# @POST: Manifest is created and saved.
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
@router.post(
"/candidates/{candidate_id}/manifests",

View File

@@ -1,10 +1,9 @@
# #region ConnectionsRouter [C:3] [TYPE Module] [SEMANTICS api, router, connections, database]
# @BRIEF Defines the FastAPI router for managing external database connections.
# @LAYER UI (API)
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [get_db]
# @RELATION DEPENDS_ON -> [ConnectionConfig]
# [SECTION: IMPORTS]
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
@@ -13,16 +12,15 @@ from ...models.connection import ConnectionConfig
from pydantic import BaseModel
from datetime import datetime
from ...core.logger import logger, belief_scope
# [/SECTION]
router = APIRouter()
# #region _ensure_connections_schema [C:3] [TYPE Function]
# @BRIEF Ensures the connection_configs table exists before CRUD access.
# @PRE db is an active SQLAlchemy session.
# @POST The current bind can safely query ConnectionConfig.
# @RELATION CALLS -> [ensure_connection_configs_table]
# @PRE: db is an active SQLAlchemy session.
# @POST: The current bind can safely query ConnectionConfig.
# @RELATION CALLS -> ensure_connection_configs_table
def _ensure_connections_schema(db: Session):
with belief_scope("ConnectionsRouter.ensure_schema"):
ensure_connection_configs_table(db.get_bind())
@@ -33,7 +31,7 @@ def _ensure_connections_schema(db: Session):
# #region ConnectionSchema [C:3] [TYPE Class]
# @BRIEF Pydantic model for connection response.
# @RELATION BINDS_TO -> [ConnectionConfig]
# @RELATION BINDS_TO -> ConnectionConfig
class ConnectionSchema(BaseModel):
id: str
name: str
@@ -53,7 +51,7 @@ class ConnectionSchema(BaseModel):
# #region ConnectionCreate [C:3] [TYPE Class]
# @BRIEF Pydantic model for creating a connection.
# @RELATION BINDS_TO -> [ConnectionConfig]
# @RELATION BINDS_TO -> ConnectionConfig
class ConnectionCreate(BaseModel):
name: str
type: str
@@ -69,12 +67,10 @@ class ConnectionCreate(BaseModel):
# #region list_connections [C:3] [TYPE Function]
# @BRIEF Lists all saved connections.
# @PRE Database session is active.
# @POST Returns list of connection configs.
# @PARAM: db (Session) - Database session.
# @RETURN: List[ConnectionSchema] - List of connections.
# @RELATION: CALLS -> _ensure_connections_schema
# @RELATION: DEPENDS_ON -> ConnectionConfig
# @PRE: Database session is active.
# @POST: Returns list of connection configs.
# @RELATION CALLS -> _ensure_connections_schema
# @RELATION DEPENDS_ON -> ConnectionConfig
@router.get("", response_model=List[ConnectionSchema])
async def list_connections(db: Session = Depends(get_db)):
with belief_scope("ConnectionsRouter.list_connections"):
@@ -88,13 +84,10 @@ async def list_connections(db: Session = Depends(get_db)):
# #region create_connection [C:3] [TYPE Function]
# @BRIEF Creates a new connection configuration.
# @PRE Connection name is unique.
# @POST Connection is saved to DB.
# @PARAM: connection (ConnectionCreate) - Config data.
# @PARAM: db (Session) - Database session.
# @RETURN: ConnectionSchema - Created connection.
# @RELATION: CALLS -> _ensure_connections_schema
# @RELATION: DEPENDS_ON -> ConnectionConfig
# @PRE: Connection name is unique.
# @POST: Connection is saved to DB.
# @RELATION CALLS -> _ensure_connections_schema
# @RELATION DEPENDS_ON -> ConnectionConfig
@router.post("", response_model=ConnectionSchema, status_code=status.HTTP_201_CREATED)
async def create_connection(
connection: ConnectionCreate, db: Session = Depends(get_db)
@@ -116,13 +109,10 @@ async def create_connection(
# #region delete_connection [C:3] [TYPE Function]
# @BRIEF Deletes a connection configuration.
# @PRE Connection ID exists.
# @POST Connection is removed from DB.
# @PARAM: connection_id (str) - ID to delete.
# @PARAM: db (Session) - Database session.
# @RETURN: None.
# @RELATION: CALLS -> _ensure_connections_schema
# @RELATION: DEPENDS_ON -> ConnectionConfig
# @PRE: Connection ID exists.
# @POST: Connection is removed from DB.
# @RELATION CALLS -> _ensure_connections_schema
# @RELATION DEPENDS_ON -> ConnectionConfig
@router.delete("/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_connection(connection_id: str, db: Session = Depends(get_db)):
with belief_scope("ConnectionsRouter.delete_connection", f"id={connection_id}"):

View File

@@ -1,17 +1,17 @@
# #region DashboardsApi [C:5] [TYPE Module] [SEMANTICS api, dashboards, resources, hub]
#
# @BRIEF API endpoints for the Dashboard Hub - listing dashboards with Git and task status
# @LAYER API
# @PRE Valid environment configurations exist in ConfigManager.
# @POST Dashboard responses are projected into DashboardsResponse DTO.
# @SIDE_EFFECT Performs external calls to Superset API and potentially Git providers.
# @DATA_CONTRACT Input(env_id, filters) -> Output(DashboardsResponse)
# @INVARIANT All dashboard responses include git_status and last_task metadata
# @LAYER: API
# @RELATION DEPENDS_ON -> [AppDependencies]
# @RELATION DEPENDS_ON -> [ResourceService]
# @RELATION DEPENDS_ON -> [SupersetClient]
#
# @INVARIANT: All dashboard responses include git_status and last_task metadata
#
#
# @PRE: Valid environment configurations exist in ConfigManager.
# @POST: Dashboard responses are projected into DashboardsResponse DTO.
# @SIDE_EFFECT: Performs external calls to Superset API and potentially Git providers.
# @DATA_CONTRACT: Input(env_id, filters) -> Output(DashboardsResponse)
#
# @TEST_CONTRACT: DashboardsAPI -> {
# required_fields: {env_id: string, page: integer, page_size: integer},

View File

@@ -1,10 +1,9 @@
# #region DashboardActionRoutes [C:2] [TYPE Module] [SEMANTICS api, dashboards, actions, routes]
# @BRIEF Dashboard action route handlers — migrate, backup.
# @LAYER API
# @LAYER: API
# @RELATION DEPENDS_ON -> [DashboardRouter]
# @RELATION DEPENDS_ON -> [DashboardSchemas]
# [SECTION: IMPORTS]
from fastapi import Depends, HTTPException
from src.dependencies import (
@@ -19,20 +18,17 @@ from ._schemas import (
TaskResponse,
)
from ._router import router
# [/SECTION]
# #region migrate_dashboards [C:2] [TYPE Function]
# @BRIEF Trigger bulk migration of dashboards from source to target environment
# @PRE User has permission plugin:migration:execute
# @PRE source_env_id and target_env_id are valid environment IDs
# @PRE dashboard_ids is a non-empty list
# @POST Returns task_id for tracking migration progress
# @POST Task is created and queued for execution
# @PARAM: request (MigrateRequest) - Migration request with source, target, and dashboard IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES ->[MigrationPlugin:execute]
# @RELATION: CALLS ->[TaskManager]
# @PRE: User has permission plugin:migration:execute
# @PRE: source_env_id and target_env_id are valid environment IDs
# @PRE: dashboard_ids is a non-empty list
# @POST: Returns task_id for tracking migration progress
# @POST: Task is created and queued for execution
# @RELATION DISPATCHES -> [MigrationPlugin:execute]
# @RELATION CALLS -> [TaskManager]
@router.post("/migrate", response_model=TaskResponse)
async def migrate_dashboards(
request: MigrateRequest,
@@ -104,16 +100,14 @@ async def migrate_dashboards(
# #region backup_dashboards [C:2] [TYPE Function]
# @BRIEF Trigger bulk backup of dashboards with optional cron schedule
# @PRE User has permission plugin:backup:execute
# @PRE env_id is a valid environment ID
# @PRE dashboard_ids is a non-empty list
# @POST Returns task_id for tracking backup progress
# @POST Task is created and queued for execution
# @POST If schedule is provided, a scheduled task is created
# @PARAM: request (BackupRequest) - Backup request with environment and dashboard IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES ->[BackupPlugin:execute]
# @RELATION: CALLS ->[TaskManager]
# @PRE: User has permission plugin:backup:execute
# @PRE: env_id is a valid environment ID
# @PRE: dashboard_ids is a non-empty list
# @POST: Returns task_id for tracking backup progress
# @POST: Task is created and queued for execution
# @POST: If schedule is provided, a scheduled task is created
# @RELATION DISPATCHES -> [BackupPlugin:execute]
# @RELATION CALLS -> [TaskManager]
@router.post("/backup", response_model=TaskResponse)
async def backup_dashboards(
request: BackupRequest,

View File

@@ -1,12 +1,11 @@
# #region DashboardDetailRoutes [C:3] [TYPE Module] [SEMANTICS api, dashboards, detail, routes]
# @BRIEF Dashboard detail, db-mappings, task history, thumbnail route handlers.
# @LAYER API
# @LAYER: API
# @RELATION DEPENDS_ON -> [DashboardRouter]
# @RELATION DEPENDS_ON -> [DashboardSchemas]
# @RELATION DEPENDS_ON -> [DashboardHelpers]
# @RELATION DEPENDS_ON -> [DashboardProjection]
# [SECTION: IMPORTS]
from typing import Optional, List, Dict, Any
import re
from urllib.parse import urlparse
@@ -39,18 +38,14 @@ from ._schemas import (
DatabaseMappingsResponse,
)
from ._router import router
# [/SECTION]
# #region get_database_mappings [C:2] [TYPE Function]
# @BRIEF Get database mapping suggestions between source and target environments
# @PRE User has permission plugin:migration:read
# @PRE source_env_id and target_env_id are valid environment IDs
# @POST Returns list of suggested database mappings with confidence scores
# @PARAM: source_env_id (str) - Source environment ID
# @PARAM: target_env_id (str) - Target environment ID
# @RETURN: DatabaseMappingsResponse - List of suggested mappings
# @RELATION: CALLS ->[MappingService:get_suggestions]
# @PRE: User has permission plugin:migration:read
# @PRE: source_env_id and target_env_id are valid environment IDs
# @POST: Returns list of suggested database mappings with confidence scores
# @RELATION CALLS -> [MappingService:get_suggestions]
@router.get("/db-mappings", response_model=DatabaseMappingsResponse)
async def get_database_mappings(
source_env_id: str,
@@ -113,8 +108,8 @@ async def get_database_mappings(
# #region get_dashboard_detail [C:2] [TYPE Function]
# @BRIEF Fetch detailed dashboard info with related charts and datasets
# @PRE env_id must be valid and dashboard ref (slug or id) must exist
# @POST Returns dashboard detail payload for overview page
# @PRE: env_id must be valid and dashboard ref (slug or id) must exist
# @POST: Returns dashboard detail payload for overview page
# @RELATION CALLS -> [AsyncSupersetClient]
@router.get("/{dashboard_ref}", response_model=DashboardDetailResponse)
async def get_dashboard_detail(
@@ -158,8 +153,8 @@ async def get_dashboard_detail(
# #region get_dashboard_tasks_history [C:2] [TYPE Function]
# @BRIEF Returns history of backup and LLM validation tasks for a dashboard.
# @PRE dashboard ref (slug or id) is valid.
# @POST Response contains sorted task history (newest first).
# @PRE: dashboard ref (slug or id) is valid.
# @POST: Response contains sorted task history (newest first).
@router.get("/{dashboard_ref}/tasks", response_model=DashboardTaskHistoryResponse)
async def get_dashboard_tasks_history(
dashboard_ref: str,
@@ -260,9 +255,9 @@ async def get_dashboard_tasks_history(
# #region get_dashboard_thumbnail [C:3] [TYPE Function]
# @BRIEF Proxies Superset dashboard thumbnail with cache support.
# @PRE env_id must exist.
# @POST Returns image bytes or 202 when thumbnail is being prepared by Superset.
# @RELATION CALLS -> [AsyncSupersetClient]
# @PRE: env_id must exist.
# @POST: Returns image bytes or 202 when thumbnail is being prepared by Superset.
@router.get("/{dashboard_ref}/thumbnail")
async def get_dashboard_thumbnail(
dashboard_ref: str,

View File

@@ -1,6 +1,6 @@
# #region DashboardHelpers [C:2] [TYPE Module] [SEMANTICS api, dashboards, helpers, resolution]
# @BRIEF Basic helper functions for dashboard route handlers — slug resolution, filter normalization.
# @LAYER Infra
# @LAYER: Infra
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [AsyncSupersetClient]
@@ -13,8 +13,8 @@ from src.core.logger import logger
# #region _find_dashboard_id_by_slug [C:2] [TYPE Function]
# @BRIEF Resolve dashboard numeric ID by slug using Superset list endpoint.
# @PRE `dashboard_slug` is non-empty.
# @POST Returns dashboard ID when found, otherwise None.
# @PRE: `dashboard_slug` is non-empty.
# @POST: Returns dashboard ID when found, otherwise None.
def _find_dashboard_id_by_slug(
client: SupersetClient,
dashboard_slug: str,
@@ -50,8 +50,8 @@ def _find_dashboard_id_by_slug(
# #region _resolve_dashboard_id_from_ref [C:2] [TYPE Function]
# @BRIEF Resolve dashboard ID from slug-first reference with numeric fallback.
# @PRE `dashboard_ref` is provided in route path.
# @POST Returns a valid dashboard ID or raises HTTPException(404).
# @PRE: `dashboard_ref` is provided in route path.
# @POST: Returns a valid dashboard ID or raises HTTPException(404).
def _resolve_dashboard_id_from_ref(
dashboard_ref: str,
client: SupersetClient,
@@ -76,8 +76,8 @@ def _resolve_dashboard_id_from_ref(
# #region _find_dashboard_id_by_slug_async [C:2] [TYPE Function]
# @BRIEF Resolve dashboard numeric ID by slug using async Superset list endpoint.
# @PRE dashboard_slug is non-empty.
# @POST Returns dashboard ID when found, otherwise None.
# @PRE: dashboard_slug is non-empty.
# @POST: Returns dashboard ID when found, otherwise None.
async def _find_dashboard_id_by_slug_async(
client: AsyncSupersetClient,
dashboard_slug: str,
@@ -113,8 +113,8 @@ async def _find_dashboard_id_by_slug_async(
# #region _resolve_dashboard_id_from_ref_async [C:2] [TYPE Function]
# @BRIEF Resolve dashboard ID from slug-first reference using async Superset client.
# @PRE dashboard_ref is provided in route path.
# @POST Returns valid dashboard ID or raises HTTPException(404).
# @PRE: dashboard_ref is provided in route path.
# @POST: Returns valid dashboard ID or raises HTTPException(404).
async def _resolve_dashboard_id_from_ref_async(
dashboard_ref: str,
client: AsyncSupersetClient,
@@ -138,8 +138,8 @@ async def _resolve_dashboard_id_from_ref_async(
# #region _normalize_filter_values [C:2] [TYPE Function]
# @BRIEF Normalize query filter values to lower-cased non-empty tokens.
# @PRE values may be None or list of strings.
# @POST Returns trimmed normalized list preserving input order.
# @PRE: values may be None or list of strings.
# @POST: Returns trimmed normalized list preserving input order.
def _normalize_filter_values(values: Optional[List[str]]) -> List[str]:
if not values:
return []
@@ -156,8 +156,8 @@ def _normalize_filter_values(values: Optional[List[str]]) -> List[str]:
# #region _dashboard_git_filter_value [C:2] [TYPE Function]
# @BRIEF Build comparable git status token for dashboards filtering.
# @PRE dashboard payload may contain git_status or None.
# @POST Returns one of ok|diff|no_repo|error|pending.
# @PRE: dashboard payload may contain git_status or None.
# @POST: Returns one of ok|diff|no_repo|error|pending.
def _dashboard_git_filter_value(dashboard: Dict[str, Any]) -> str:
git_status = dashboard.get("git_status") or {}
sync_status = str(git_status.get("sync_status") or "").strip().upper()

View File

@@ -1,12 +1,11 @@
# #region DashboardListingRoutes [C:4] [TYPE Module] [SEMANTICS api, dashboards, listing, routes]
# @BRIEF Dashboard listing route handler for Dashboard Hub.
# @LAYER API
# @LAYER: API
# @RELATION DEPENDS_ON -> [DashboardRouter]
# @RELATION DEPENDS_ON -> [DashboardSchemas]
# @RELATION DEPENDS_ON -> [DashboardHelpers]
# @RELATION DEPENDS_ON -> [DashboardProjection]
# [SECTION: IMPORTS]
import os
from typing import List, Optional, Dict, Any, Literal
from fastapi import Depends, HTTPException, Query
@@ -16,10 +15,7 @@ from src.dependencies import (
get_current_user, has_permission,
)
from src.core.database import get_db
from src.core.cot_logger import MarkerLogger
from src.core.logger import logger, belief_scope
log = MarkerLogger("DashboardListing")
from src.models.auth import User
from src.services.profile_service import ProfileService
from ._helpers import _normalize_filter_values, _dashboard_git_filter_value
@@ -29,23 +25,17 @@ from ._projection import (
)
from ._schemas import EffectiveProfileFilter, DashboardsResponse
from ._router import router
# [/SECTION]
# #region get_dashboards [C:3] [TYPE Function]
# @BRIEF Fetch list of dashboards from a specific environment with Git status and last task status
# @PRE env_id must be a valid environment ID
# @PRE page must be >= 1 if provided
# @PRE page_size must be between 1 and 100 if provided
# @POST Returns a list of dashboards with enhanced metadata and pagination info
# @POST Response includes pagination metadata (page, page_size, total, total_pages)
# @POST Response includes effective profile filter metadata for main dashboards page context
# @PARAM: env_id (str) - The environment ID to fetch dashboards from
# @PARAM: search (Optional[str]) - Filter by title/slug
# @PARAM: page (Optional[int]) - Page number (default: 1)
# @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100)
# @RETURN: DashboardsResponse - List of dashboards with status metadata
# @RELATION: CALLS ->[get_dashboards_with_status]
# @PRE: env_id must be a valid environment ID
# @PRE: page must be >= 1 if provided
# @PRE: page_size must be between 1 and 100 if provided
# @POST: Returns a list of dashboards with enhanced metadata and pagination info
# @POST: Response includes pagination metadata (page, page_size, total, total_pages)
# @POST: Response includes effective profile filter metadata for main dashboards page context
# @RELATION CALLS -> [get_dashboards_with_status]
@router.get("", response_model=DashboardsResponse)
async def get_dashboards(
env_id: str,
@@ -73,16 +63,16 @@ async def get_dashboards(
f"page_context={page_context}, applyp={apply_profile_default}, overhide={override_show_all}",
):
if page < 1:
log.explore(f"Invalid page: {page}", error="Page must be >= 1")
logger.error(f"[get_dashboards][Coherence:Failed] Invalid page: {page}")
raise HTTPException(status_code=400, detail="Page must be >= 1")
if page_size < 1 or page_size > 100:
log.explore(f"Invalid page_size: {page_size}", error="Page size must be between 1 and 100")
logger.error(f"[get_dashboards][Coherence:Failed] Invalid page_size: {page_size}")
raise HTTPException(status_code=400, detail="Page size must be between 1 and 100")
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
log.explore(f"Environment not found: {env_id}", error="Environment not found")
logger.error(f"[get_dashboards][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
bound_username: Optional[str] = None
@@ -151,8 +141,7 @@ async def get_dashboards(
)
except Exception as profile_error:
logger.explore(
f"Profile preference unavailable; continuing without profile-default filter",
error=str(profile_error),
f"[EXPLORE] Profile preference unavailable; continuing without profile-default filter: {profile_error}"
)
try:
@@ -194,7 +183,10 @@ async def get_dashboards(
total = page_payload["total"]
total_pages = page_payload["total_pages"]
except Exception as page_error:
log.explore(f"Page-based fetch failed; using compatibility fallback", error=str(page_error))
logger.warning(
"[get_dashboards][Action] Page-based fetch failed; using compatibility fallback: %s",
page_error,
)
if can_apply_slug_filter:
dashboards = await resource_service.get_dashboards_with_status(
env,
@@ -249,7 +241,7 @@ async def get_dashboards(
if not actor_aliases:
actor_aliases = [bound_username]
logger.reason(
f"Applying profile actor filter "
"[REASON] Applying profile actor filter "
f"(env={env_id}, bound_username={bound_username}, actor_aliases={actor_aliases!r}, "
f"dashboards_before={len(dashboards)})"
)
@@ -267,7 +259,7 @@ async def get_dashboards(
)
if index < max_actor_samples:
logger.reflect(
f"Profile actor filter sample "
"[REFLECT] Profile actor filter sample "
f"(env={env_id}, dashboard_id={dashboard.get('id')}, "
f"bound_username={bound_username!r}, actor_aliases={actor_aliases!r}, "
f"owners={owners_value!r}, created_by={created_by_value!r}, "
@@ -277,7 +269,7 @@ async def get_dashboards(
filtered_dashboards.append(dashboard)
logger.reflect(
f"Profile actor filter summary "
"[REFLECT] Profile actor filter summary "
f"(env={env_id}, bound_username={bound_username!r}, "
f"dashboards_before={len(dashboards)}, dashboards_after={len(filtered_dashboards)})"
)
@@ -363,7 +355,10 @@ async def get_dashboards(
end_idx = start_idx + page_size
paginated_dashboards = dashboards[start_idx:end_idx]
log.reflect(f"Returning {len(paginated_dashboards)} dashboards (page {page}/{total_pages}, total: {total}, profile_filter_applied={effective_profile_filter.applied})")
logger.info(
f"[get_dashboards][Coherence:OK] Returning {len(paginated_dashboards)} dashboards "
f"(page {page}/{total_pages}, total: {total}, profile_filter_applied={effective_profile_filter.applied})"
)
response_dashboards = _project_dashboard_response_items(
paginated_dashboards
@@ -381,7 +376,9 @@ async def get_dashboards(
except HTTPException:
raise
except Exception as e:
log.explore("Failed to fetch dashboards", error=str(e))
logger.error(
f"[get_dashboards][Coherence:Failed] Failed to fetch dashboards: {e}"
)
raise HTTPException(
status_code=503, detail=f"Failed to fetch dashboards: {str(e)}"
)

View File

@@ -1,6 +1,6 @@
# #region DashboardProjection [C:2] [TYPE Module] [SEMANTICS api, dashboards, projection, profile, filtering]
# @BRIEF Dashboard response projection and profile-filter helpers for Dashboard Hub routes.
# @LAYER Infra
# @LAYER: Infra
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [ProfileService]
@@ -142,7 +142,7 @@ def _get_profile_filter_binding(
# #region _resolve_profile_actor_aliases [C:2] [TYPE Function]
# @BRIEF Resolve stable actor aliases for profile filtering without per-dashboard detail fan-out.
# @SIDE_EFFECT Performs at most one Superset users-lookup request.
# @SIDE_EFFECT: Performs at most one Superset users-lookup request.
def _resolve_profile_actor_aliases(env: Any, bound_username: str) -> List[str]:
normalized_bound = _normalize_actor_alias_token(bound_username)
if not normalized_bound:

View File

@@ -1,6 +1,6 @@
# #region DashboardSchemas [C:1] [TYPE Module] [SEMANTICS api, dashboards, schemas, dto]
# @BRIEF DTO classes for the Dashboard Hub API.
# @LAYER Infra
# @LAYER: Infra
# @RELATION DEPENDS_ON -> [Pydantic]
from typing import List, Optional, Dict, Any, Literal

View File

@@ -1,8 +1,8 @@
# #region DatasetReviewApi [C:3] [TYPE Module] [SEMANTICS dataset_review, api, session_lifecycle, exports, rbac, feature_flags]
# @BRIEF Thin facade re-exporting router and public symbols from decomposed dataset review API sub-modules.
# @LAYER API
# @RATIONALE Original 2484-line monolith violated INV_7 (400-line module limit) by 6x. Decomposed into _dependencies (DTOs/guards/serializers) and _routes (handlers).
# @REJECTED Keeping all routes in one file because it exceeded the fractal limit by 6x and accumulated severe structural erosion risk.
# @LAYER: API
# @RATIONALE: Original 2484-line monolith violated INV_7 (400-line module limit) by 6x. Decomposed into _dependencies (DTOs/guards/serializers) and _routes (handlers).
# @REJECTED: Keeping all routes in one file because it exceeded the fractal limit by 6x and accumulated severe structural erosion risk.
from src.api.routes.dataset_review_pkg._dependencies import ( # noqa: F401
StartSessionRequest,

View File

@@ -1,6 +1,6 @@
# #region DatasetReviewDependencies [C:2] [TYPE Module]
# @BRIEF Dependency injection, serialization helpers, and feature-flag guards for dataset review endpoints.
# @LAYER API
# @LAYER: API
from __future__ import annotations
@@ -12,9 +12,8 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response,
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from src.core.cot_logger import MarkerLogger
from src.core.database import get_db
from src.core.logger import belief_scope
from src.core.logger import belief_scope, logger
from src.dependencies import (
get_config_manager,
get_current_user,
@@ -69,8 +68,6 @@ from src.services.dataset_review.repositories.session_repository import (
DatasetReviewSessionVersionConflictError,
)
log = MarkerLogger("DatasetReviewDeps")
# #region StartSessionRequest [C:1] [TYPE Class]
# @BRIEF Request DTO for starting one dataset review session.
@@ -417,7 +414,7 @@ def _enforce_session_version(repository, session, expected_version):
try:
repository.require_session_version(session, expected_version)
except DatasetReviewSessionVersionConflictError as exc:
log.explore("Dataset review optimistic-lock conflict detected", payload={"session_id": exc.session_id, "expected_version": exc.expected_version, "actual_version": exc.actual_version}, error="Optimistic lock conflict")
logger.explore("Dataset review optimistic-lock conflict detected", extra={"session_id": exc.session_id, "expected_version": exc.expected_version, "actual_version": exc.actual_version})
raise _build_session_version_conflict_http_exception(exc) from exc
return session
@@ -653,7 +650,7 @@ def _resolve_candidate_source_version(field, source_id):
# #region _update_semantic_field_state [C:3] [TYPE Function]
# @BRIEF Apply field-level semantic manual override or candidate acceptance.
# @POST Manual overrides always set manual provenance plus lock.
# @POST: Manual overrides always set manual provenance plus lock.
def _update_semantic_field_state(field, request, changed_by):
has_manual_override = any(v is not None for v in [request.verbose_name, request.description, request.display_format])
selected_candidate = None

View File

@@ -9,8 +9,7 @@ from typing import Any, List, Optional, Union, cast
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
from src.core.logger import belief_scope, logger
from src.dependencies import get_config_manager, get_current_user, has_permission
from src.models.auth import User
from src.models.dataset_review import (
@@ -89,8 +88,6 @@ from src.api.routes.dataset_review_pkg._dependencies import (
_build_session_version_conflict_http_exception,
)
log = MarkerLogger("DatasetReviewRoutes")
router = APIRouter(prefix="/api/dataset-orchestration", tags=["Dataset Orchestration"])
@@ -111,17 +108,17 @@ async def list_sessions(
current_user: User = Depends(get_current_user),
):
with belief_scope("dataset_review.list_sessions"):
log.reason(
logger.reason(
"Listing dataset review sessions",
payload={"user_id": current_user.id, "page": page, "page_size": page_size},
extra={"user_id": current_user.id, "page": page, "page_size": page_size},
)
sessions = repository.list_sessions_for_user(current_user.id)
start = (page - 1) * page_size
end = start + page_size
items = [_serialize_session_summary(s) for s in sessions[start:end]]
log.reflect(
logger.reflect(
"Session page assembled",
payload={
extra={
"user_id": current_user.id,
"returned": len(items),
"total": len(sessions),
@@ -156,9 +153,9 @@ async def start_session(
current_user: User = Depends(get_current_user),
):
with belief_scope("start_session"):
log.reason(
logger.reason(
"Starting dataset review session",
payload={
extra={
"user_id": current_user.id,
"environment_id": request.environment_id,
},
@@ -173,7 +170,10 @@ async def start_session(
)
)
except ValueError as exc:
log.explore("Session start rejected", error="Session start rejected by orchestrator", payload={"user_id": current_user.id, "error": str(exc)})
logger.explore(
"Session start rejected",
extra={"user_id": current_user.id, "error": str(exc)},
)
detail = str(exc)
sc = (
status.HTTP_404_NOT_FOUND
@@ -181,8 +181,8 @@ async def start_session(
else status.HTTP_400_BAD_REQUEST
)
raise HTTPException(status_code=sc, detail=detail) from exc
log.reflect(
"Session started", payload={"session_id": result.session.session_id}
logger.reflect(
"Session started", extra={"session_id": result.session.session_id}
)
return _serialize_session_summary(result.session)

View File

@@ -1,21 +1,19 @@
# #region DatasetsApi [C:3] [TYPE Module] [SEMANTICS api, datasets, resources, hub]
#
# @BRIEF API endpoints for the Dataset Hub - listing datasets with mapping progress
# @LAYER API
# @INVARIANT All dataset responses include last_task metadata
# @LAYER: API
# @RELATION DEPENDS_ON -> [AppDependencies]
# @RELATION DEPENDS_ON -> [ResourceService]
# @RELATION DEPENDS_ON -> [SupersetClient]
#
#
# @INVARIANT: All dataset responses include last_task metadata
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from pydantic import BaseModel, Field
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
# [/SECTION]
router = APIRouter(prefix="/api/datasets", tags=["Datasets"])
@@ -105,12 +103,9 @@ class TaskResponse(BaseModel):
# #region get_dataset_ids [C:3] [TYPE Function]
# @BRIEF Fetch list of all dataset IDs from a specific environment (without pagination)
# @PRE env_id must be a valid environment ID
# @POST Returns a list of all dataset IDs
# @PARAM: env_id (str) - The environment ID to fetch datasets from
# @PARAM: search (Optional[str]) - Filter by table name
# @RETURN: List[int] - List of dataset IDs
# @RELATION: CALLS ->[get_datasets_with_status]
# @PRE: env_id must be a valid environment ID
# @POST: Returns a list of all dataset IDs
# @RELATION CALLS -> [get_datasets_with_status]
@router.get("/ids")
async def get_dataset_ids(
env_id: str,
@@ -156,17 +151,12 @@ async def get_dataset_ids(
# #region get_datasets [C:3] [TYPE Function]
# @BRIEF Fetch list of datasets from a specific environment with mapping progress
# @PRE env_id must be a valid environment ID
# @PRE page must be >= 1 if provided
# @PRE page_size must be between 1 and 100 if provided
# @POST Returns a list of datasets with enhanced metadata and pagination info
# @POST Response includes pagination metadata (page, page_size, total, total_pages)
# @PARAM: env_id (str) - The environment ID to fetch datasets from
# @PARAM: search (Optional[str]) - Filter by table name
# @PARAM: page (Optional[int]) - Page number (default: 1)
# @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100)
# @RETURN: DatasetsResponse - List of datasets with status metadata
# @RELATION: CALLS ->[get_datasets_with_status]
# @PRE: env_id must be a valid environment ID
# @PRE: page must be >= 1 if provided
# @PRE: page_size must be between 1 and 100 if provided
# @POST: Returns a list of datasets with enhanced metadata and pagination info
# @POST: Response includes pagination metadata (page, page_size, total, total_pages)
# @RELATION CALLS -> [get_datasets_with_status]
@router.get("", response_model=DatasetsResponse)
async def get_datasets(
env_id: str,
@@ -245,15 +235,13 @@ class MapColumnsRequest(BaseModel):
# #region map_columns [C:3] [TYPE Function]
# @BRIEF Trigger bulk column mapping for datasets
# @PRE User has permission plugin:mapper:execute
# @PRE env_id is a valid environment ID
# @PRE dataset_ids is a non-empty list
# @POST Returns task_id for tracking mapping progress
# @POST Task is created and queued for execution
# @PARAM: request (MapColumnsRequest) - Mapping request with environment and dataset IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES ->[MapperPlugin]
# @RELATION: CALLS ->[create_task]
# @PRE: User has permission plugin:mapper:execute
# @PRE: env_id is a valid environment ID
# @PRE: dataset_ids is a non-empty list
# @POST: Returns task_id for tracking mapping progress
# @POST: Task is created and queued for execution
# @RELATION DISPATCHES -> [MapperPlugin]
# @RELATION CALLS -> [create_task]
@router.post("/map-columns", response_model=TaskResponse)
async def map_columns(
request: MapColumnsRequest,
@@ -315,15 +303,13 @@ class GenerateDocsRequest(BaseModel):
# #region generate_docs [C:3] [TYPE Function]
# @BRIEF Trigger bulk documentation generation for datasets
# @PRE User has permission plugin:llm_analysis:execute
# @PRE env_id is a valid environment ID
# @PRE dataset_ids is a non-empty list
# @POST Returns task_id for tracking documentation generation progress
# @POST Task is created and queued for execution
# @PARAM: request (GenerateDocsRequest) - Documentation generation request
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES ->[DocumentationPlugin]
# @RELATION: CALLS ->[create_task]
# @PRE: User has permission plugin:llm_analysis:execute
# @PRE: env_id is a valid environment ID
# @PRE: dataset_ids is a non-empty list
# @POST: Returns task_id for tracking documentation generation progress
# @POST: Task is created and queued for execution
# @RELATION DISPATCHES -> [DocumentationPlugin]
# @RELATION CALLS -> [create_task]
@router.post("/generate-docs", response_model=TaskResponse)
async def generate_docs(
request: GenerateDocsRequest,
@@ -370,13 +356,10 @@ async def generate_docs(
# #region get_dataset_detail [C:3] [TYPE Function]
# @BRIEF Get detailed dataset information including columns and linked dashboards
# @PRE env_id is a valid environment ID
# @PRE dataset_id is a valid dataset ID
# @POST Returns detailed dataset info with columns and linked dashboards
# @PARAM: env_id (str) - The environment ID
# @PARAM: dataset_id (int) - The dataset ID
# @RETURN: DatasetDetailResponse - Detailed dataset information
# @RELATION: CALLS ->[SupersetClientGetDatasetDetail]
# @PRE: env_id is a valid environment ID
# @PRE: dataset_id is a valid dataset ID
# @POST: Returns detailed dataset info with columns and linked dashboards
# @RELATION CALLS -> [SupersetClientGetDatasetDetail]
@router.get("/{dataset_id}", response_model=DatasetDetailResponse)
async def get_dataset_detail(
env_id: str,

View File

@@ -1,28 +1,26 @@
# #region EnvironmentsApi [C:3] [TYPE Module] [SEMANTICS api, environments, superset, databases]
#
# @BRIEF API endpoints for listing environments and their databases.
# @LAYER API
# @INVARIANT Environment IDs must exist in the configuration.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AppDependencies]
# @RELATION DEPENDS_ON -> [SupersetClient]
#
#
# @INVARIANT: Environment IDs must exist in the configuration.
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Optional
from ...dependencies import get_config_manager, get_scheduler_service, has_permission
from ...core.superset_client import SupersetClient
from pydantic import BaseModel, Field
from ...core.logger import belief_scope
# [/SECTION]
router = APIRouter(prefix="/api/environments", tags=["Environments"])
# #region _normalize_superset_env_url [TYPE Function]
# @BRIEF Canonicalize Superset environment URL to base host/path without trailing /api/v1.
# @PRE raw_url can be empty.
# @POST Returns normalized base URL.
# @PRE: raw_url can be empty.
# @POST: Returns normalized base URL.
def _normalize_superset_env_url(raw_url: str) -> str:
normalized = str(raw_url or "").strip().rstrip("/")
if normalized.lower().endswith("/api/v1"):
@@ -55,10 +53,9 @@ class DatabaseResponse(BaseModel):
# #region get_environments [TYPE Function] [SEMANTICS list, environments, config]
# @BRIEF List all configured environments.
# @LAYER API
# @PRE config_manager is injected via Depends.
# @POST Returns a list of EnvironmentResponse objects.
# @RETURN List[EnvironmentResponse]
# @LAYER: API
# @PRE: config_manager is injected via Depends.
# @POST: Returns a list of EnvironmentResponse objects.
@router.get("", response_model=List[EnvironmentResponse])
async def get_environments(
config_manager=Depends(get_config_manager),
@@ -93,11 +90,9 @@ async def get_environments(
# #region update_environment_schedule [TYPE Function] [SEMANTICS update, schedule, backup, environment]
# @BRIEF Update backup schedule for an environment.
# @LAYER API
# @PRE Environment id exists, schedule is valid ScheduleSchema.
# @POST Backup schedule updated and scheduler reloaded.
# @PARAM: id (str) - The environment ID.
# @PARAM: schedule (ScheduleSchema) - The new schedule.
# @LAYER: API
# @PRE: Environment id exists, schedule is valid ScheduleSchema.
# @POST: Backup schedule updated and scheduler reloaded.
@router.put("/{id}/schedule")
async def update_environment_schedule(
id: str,
@@ -126,11 +121,9 @@ async def update_environment_schedule(
# #region get_environment_databases [TYPE Function] [SEMANTICS fetch, databases, superset, environment]
# @BRIEF Fetch the list of databases from a specific environment.
# @LAYER API
# @PRE Environment id exists.
# @POST Returns a list of database summaries from the environment.
# @PARAM: id (str) - The environment ID.
# @RETURN: List[Dict] - List of databases.
# @LAYER: API
# @PRE: Environment id exists.
# @POST: Returns a list of database summaries from the environment.
@router.get("/{id}/databases")
async def get_environment_databases(
id: str,

View File

@@ -1,6 +1,6 @@
# #region GitPackage [C:3] [TYPE Module] [SEMANTICS git, package, re-exports]
# @BRIEF Package root for decomposed git routes. Re-exports all public symbols from submodules.
# @LAYER API
# @LAYER: API
# @RELATION USES -> [GitRouter, GitDeps, GitHelpers, GitConfigRoutes, GitGiteaRoutes,
# GitRepoRoutes, GitRepoOperationsRoutes, GitRepoLifecycleRoutes,
# GitMergeRoutes, GitEnvironmentRoutes]

View File

@@ -1,6 +1,6 @@
# #region GitConfigRoutes [C:2] [TYPE Module] [SEMANTICS git, config, routes, crud]
# @BRIEF FastAPI endpoints for Git server configuration CRUD and connection testing.
# @LAYER API
# @LAYER: API
from typing import List

View File

@@ -1,7 +1,7 @@
# #region GitDeps [C:1] [TYPE Module] [SEMANTICS git, deps, service-locator]
# @BRIEF Shared dependency wiring for monkeypatch-safe git_service access and constants.
# @LAYER API
# @INVARIANT get_git_service() resolves from sys.modules at call time so test monkeypatching
# @LAYER: API
# @INVARIANT: get_git_service() resolves from sys.modules at call time so test monkeypatching
# of git_routes.git_service (the __init__ attribute) takes effect across all submodules.
import sys

View File

@@ -1,6 +1,6 @@
# #region GitEnvironmentRoutes [C:2] [TYPE Module] [SEMANTICS git, environments, routes]
# @BRIEF FastAPI endpoint for listing deployment environments.
# @LAYER API
# @LAYER: API
from typing import List

View File

@@ -1,6 +1,6 @@
# #region GitGiteaRoutes [C:2] [TYPE Module] [SEMANTICS git, gitea, routes, repositories]
# @BRIEF FastAPI endpoints for Gitea-specific repository operations.
# @LAYER API
# @LAYER: API
from typing import List

View File

@@ -1,6 +1,6 @@
# #region GitHelpers [C:3] [TYPE Module] [SEMANTICS git, helpers, resolution, identity]
# @BRIEF Shared helper functions for Git route modules.
# @LAYER API
# @LAYER: API
# @RELATION USES -> [GitDeps]
# @RELATION USES -> [SupersetClient]
# @RELATION USES -> [UserDashboardPreference]
@@ -11,9 +11,7 @@ from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from src.core.cot_logger import MarkerLogger
log = MarkerLogger("GitHelpers")
from src.core.logger import logger
from src.core.superset_client import SupersetClient
from src.dependencies import get_config_manager
from src.models.auth import User
@@ -25,7 +23,7 @@ from ._deps import get_git_service, MAX_REPOSITORY_STATUS_BATCH
# #region _build_no_repo_status_payload [C:1] [TYPE Function]
# @BRIEF Build a consistent status payload for dashboards without initialized repositories.
# @POST Returns a stable payload compatible with frontend repository status parsing.
# @POST: Returns a stable payload compatible with frontend repository status parsing.
def _build_no_repo_status_payload() -> dict:
return {
"is_dirty": False,
@@ -47,24 +45,24 @@ def _build_no_repo_status_payload() -> dict:
# #region _handle_unexpected_git_route_error [C:1] [TYPE Function]
# @BRIEF Convert unexpected route-level exceptions to stable 500 API responses.
# @PRE `error` is a non-HTTPException instance.
# @POST Raises HTTPException(500) with route-specific context.
# @PRE: `error` is a non-HTTPException instance.
# @POST: Raises HTTPException(500) with route-specific context.
def _handle_unexpected_git_route_error(route_name: str, error: Exception) -> None:
log.explore(f"{route_name} failed: {error}", error=str(error))
logger.error(f"[{route_name}][Coherence:Failed] {error}")
raise HTTPException(status_code=500, detail=f"{route_name} failed: {str(error)}")
# #endregion _handle_unexpected_git_route_error
# #region _resolve_repository_status [C:2] [TYPE Function]
# @BRIEF Resolve repository status for one dashboard with graceful NO_REPO semantics.
# @PRE `dashboard_id` is a valid integer.
# @POST Returns standard status payload or `NO_REPO` payload when repository path is absent.
# @PRE: `dashboard_id` is a valid integer.
# @POST: Returns standard status payload or `NO_REPO` payload when repository path is absent.
def _resolve_repository_status(dashboard_id: int) -> dict:
git_service = get_git_service()
repo_path = git_service._get_repo_path(dashboard_id)
if not os.path.exists(repo_path):
log.reason(
f"Repository is not initialized for dashboard {dashboard_id}"
logger.debug(
f"[get_repository_status][Action] Repository is not initialized for dashboard {dashboard_id}"
)
return _build_no_repo_status_payload()
@@ -72,8 +70,8 @@ def _resolve_repository_status(dashboard_id: int) -> dict:
return git_service.get_status(dashboard_id)
except HTTPException as e:
if e.status_code == 404:
log.reason(
f"Repository is not initialized for dashboard {dashboard_id}"
logger.debug(
f"[get_repository_status][Action] Repository is not initialized for dashboard {dashboard_id}"
)
return _build_no_repo_status_payload()
raise
@@ -289,7 +287,11 @@ def _resolve_current_user_git_identity(
.first()
)
except Exception as resolve_error:
log.explore(f"Failed to load profile preference for user {user_id}: {resolve_error}", error=str(resolve_error))
logger.warning(
"[_resolve_current_user_git_identity][Action] Failed to load profile preference for user %s: %s",
user_id,
resolve_error,
)
return None
if not preference:

View File

@@ -1,6 +1,6 @@
# #region GitMergeRoutes [C:3] [TYPE Module] [SEMANTICS git, merge, routes, conflicts]
# @BRIEF FastAPI endpoints for merge operations (status, conflicts, resolve, abort, continue).
# @LAYER API
# @LAYER: API
from typing import List, Optional

View File

@@ -1,6 +1,6 @@
# #region GitRepoLifecycleRoutes [C:3] [TYPE Module] [SEMANTICS git, lifecycle, sync, promote, deploy]
# @BRIEF FastAPI endpoints for Git lifecycle operations (sync, promote, deploy).
# @LAYER API
# @LAYER: API
import typing
from typing import Optional

View File

@@ -1,6 +1,6 @@
# #region GitRepoOperationsRoutes [C:3] [TYPE Module] [SEMANTICS git, repository, operations, commit, push, pull, status]
# @BRIEF FastAPI endpoints for Git repository operations (commit, push, pull, status, diff, history).
# @LAYER API
# @LAYER: API
from typing import List, Optional
@@ -8,10 +8,7 @@ from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from src.core.database import get_db
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
log = MarkerLogger("GitRepoOps")
from src.core.logger import belief_scope, logger
from src.dependencies import get_config_manager, get_current_user, has_permission
from src.models.auth import User
from src.models.git import GitRepository, GitServerConfig
@@ -131,13 +128,23 @@ async def pull_changes(
config_url = config_row.url
config_provider = config_row.provider
except Exception as diagnostics_error:
log.explore(f"Failed to load repository binding diagnostics for dashboard {dashboard_id}: {diagnostics_error}", error=str(diagnostics_error))
log.reason(
f"Route diagnostics dashboard_ref={dashboard_ref} env_id={env_id} resolved_dashboard_id={dashboard_id} "
f"binding_exists={bool(db_repo)} binding_local_path={(db_repo.local_path if db_repo else None)} "
f"binding_remote_url={(db_repo.remote_url if db_repo else None)} "
f"binding_config_id={(db_repo.config_id if db_repo else None)} "
f"config_provider={config_provider} config_url={config_url}"
logger.warning(
"[pull_changes][Action] Failed to load repository binding diagnostics for dashboard %s: %s",
dashboard_id,
diagnostics_error,
)
logger.info(
"[pull_changes][Action] Route diagnostics dashboard_ref=%s env_id=%s resolved_dashboard_id=%s "
"binding_exists=%s binding_local_path=%s binding_remote_url=%s binding_config_id=%s config_provider=%s config_url=%s",
dashboard_ref,
env_id,
dashboard_id,
bool(db_repo),
(db_repo.local_path if db_repo else None),
(db_repo.remote_url if db_repo else None),
(db_repo.config_id if db_repo else None),
config_provider,
config_url,
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
_gs.pull_changes(dashboard_id)
@@ -183,7 +190,11 @@ async def get_repository_status_batch(
with belief_scope("get_repository_status_batch"):
dashboard_ids = list(dict.fromkeys(request.dashboard_ids))
if len(dashboard_ids) > MAX_REPOSITORY_STATUS_BATCH:
log.explore(f"Batch size {len(dashboard_ids)} exceeds limit {MAX_REPOSITORY_STATUS_BATCH}. Truncating request.", error="Batch size exceeds limit")
logger.warning(
"[get_repository_status_batch][Action] Batch size %s exceeds limit %s. Truncating request.",
len(dashboard_ids),
MAX_REPOSITORY_STATUS_BATCH,
)
dashboard_ids = dashboard_ids[:MAX_REPOSITORY_STATUS_BATCH]
statuses = {}
@@ -197,7 +208,9 @@ async def get_repository_status_batch(
"sync_status": "ERROR",
}
except Exception as e:
log.explore(f"Failed for dashboard {dashboard_id}: {e}", error=str(e))
logger.error(
f"[get_repository_status_batch][Coherence:Failed] Failed for dashboard {dashboard_id}: {e}"
)
statuses[str(dashboard_id)] = {
**_build_no_repo_status_payload(),
"sync_state": "ERROR",

View File

@@ -1,6 +1,6 @@
# #region GitRepoRoutes [C:3] [TYPE Module] [SEMANTICS git, repository, routes, init, branches]
# @BRIEF FastAPI endpoints for core Git repository operations (init, binding, branches, checkout).
# @LAYER API
# @LAYER: API
from typing import List, Optional
@@ -8,10 +8,7 @@ from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from src.core.database import get_db
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
log = MarkerLogger("GitRepoRoutes")
from src.core.logger import belief_scope, logger
from src.dependencies import get_config_manager, get_current_user, has_permission
from src.models.auth import User
from src.models.git import GitRepository, GitServerConfig
@@ -64,7 +61,9 @@ async def init_repository(
raise HTTPException(status_code=404, detail="Git configuration not found")
try:
log.reason(f"Initializing repo for dashboard {dashboard_id}")
logger.info(
f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}"
)
_gs.init_repo(
dashboard_id,
init_data.remote_url,
@@ -95,10 +94,15 @@ async def init_repository(
db_repo.current_branch = "dev"
db.commit()
logger.info(
f"[init_repository][Coherence:OK] Repository initialized for dashboard {dashboard_id}"
)
return {"status": "success", "message": "Repository initialized"}
except Exception as e:
db.rollback()
log.explore("Failed to init repository", error=str(e))
logger.error(
f"[init_repository][Coherence:Failed] Failed to init repository: {e}"
)
if isinstance(e, HTTPException):
raise
_handle_unexpected_git_route_error("init_repository", e)

View File

@@ -1,6 +1,6 @@
# #region GitRouter [C:1] [TYPE Module] [SEMANTICS git, routes, fastapi, router]
# @BRIEF Shared APIRouter for all Git route modules.
# @LAYER API
# @LAYER: API
from fastapi import APIRouter

View File

@@ -1,10 +1,10 @@
# #region GitSchemas [C:1] [TYPE Module] [SEMANTICS git, schemas, pydantic, api, contracts]
#
# @BRIEF Defines Pydantic models for the Git integration API layer.
# @LAYER API
# @INVARIANT All schemas must be compatible with the FastAPI router.
# @RELATION DEPENDS_ON -> [backend.src.models.git]
#
# @LAYER: API
# @RELATION DEPENDS_ON -> backend.src.models.git
#
# @INVARIANT: All schemas must be compatible with the FastAPI router.
from pydantic import BaseModel, Field
from typing import Any, Dict, List, Optional

View File

@@ -1,7 +1,7 @@
# #region health_router [C:3] [TYPE Module] [SEMANTICS health, monitoring, dashboards]
# @BRIEF API endpoints for dashboard health monitoring and status aggregation.
# @LAYER UI/API
# @RELATION DEPENDS_ON -> [health_service]
# @LAYER: UI/API
# @RELATION DEPENDS_ON -> health_service
from fastapi import APIRouter, Depends, Query, HTTPException, status
from typing import List, Optional
@@ -15,9 +15,9 @@ router = APIRouter(prefix="/api/health", tags=["Health"])
# #region get_health_summary [TYPE Function]
# @BRIEF Get aggregated health status for all dashboards.
# @PRE Caller has read permission for dashboard health view.
# @POST Returns HealthSummaryResponse.
# @RELATION CALLS -> [backend.src.services.health_service.HealthService]
# @PRE: Caller has read permission for dashboard health view.
# @POST: Returns HealthSummaryResponse.
# @RELATION CALLS -> backend.src.services.health_service.HealthService
@router.get("/summary", response_model=HealthSummaryResponse)
async def get_health_summary(
environment_id: Optional[str] = Query(None),
@@ -38,9 +38,9 @@ async def get_health_summary(
# #region delete_health_report [TYPE Function]
# @BRIEF Delete one persisted dashboard validation report from health summary.
# @PRE Caller has write permission for tasks/report maintenance.
# @POST Validation record is removed; linked task/logs are cleaned when available.
# @RELATION CALLS -> [backend.src.services.health_service.HealthService]
# @PRE: Caller has write permission for tasks/report maintenance.
# @POST: Validation record is removed; linked task/logs are cleaned when available.
# @RELATION CALLS -> backend.src.services.health_service.HealthService
@router.delete("/summary/{record_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_health_report(
record_id: str,

View File

@@ -1,6 +1,6 @@
# #region LlmRoutes [C:3] [TYPE Module] [SEMANTICS api, routes, llm]
# @BRIEF API routes for LLM provider configuration and management.
# @LAYER UI (API)
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
# @RELATION DEPENDS_ON -> [get_current_user]
@@ -8,18 +8,10 @@
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Optional
import httpx
from ...core.cot_logger import MarkerLogger
log = MarkerLogger("LlmRoutes")
from ...core.logger import logger
from ...schemas.auth import User
from ...dependencies import get_current_user as get_current_active_user
from ...plugins.llm_analysis.models import (
LLMProviderConfig,
LLMProviderType,
FetchModelsRequest,
FetchModelsResponse,
)
from ...plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
from ...services.llm_provider import LLMProviderService
from ...core.database import get_db
from sqlalchemy.orm import Session
@@ -32,8 +24,8 @@ router = APIRouter(tags=["LLM"])
# #region _is_valid_runtime_api_key [TYPE Function]
# @BRIEF Validate decrypted runtime API key presence/shape.
# @PRE value can be None.
# @POST Returns True only for non-placeholder key.
# @PRE: value can be None.
# @POST: Returns True only for non-placeholder key.
# @RELATION BINDS_TO -> [LlmRoutes]
def _is_valid_runtime_api_key(value: Optional[str]) -> bool:
key = (value or "").strip()
@@ -47,30 +39,10 @@ def _is_valid_runtime_api_key(value: Optional[str]) -> bool:
# #endregion _is_valid_runtime_api_key
# #region _build_provider_auth_headers [TYPE Function]
# @BRIEF Build auth headers for provider model listing based on provider type.
# @PRE provider_type is a valid LLMProviderType.
# @POST Returns dict of HTTP headers for the provider's model list API.
def _build_provider_auth_headers(api_key: Optional[str], provider_type: LLMProviderType) -> dict:
headers = {}
if not api_key:
return headers
if provider_type == LLMProviderType.KILO:
headers["Authentication"] = f"Bearer {api_key}"
headers["X-API-Key"] = api_key
else:
# OpenAI, OpenRouter — standard Bearer token
headers["Authorization"] = f"Bearer {api_key}"
return headers
# #endregion _build_provider_auth_headers
# #region get_providers [TYPE Function]
# @BRIEF Retrieve all LLM provider configurations.
# @PRE User is authenticated.
# @POST Returns list of LLMProviderConfig.
# @PRE: User is authenticated.
# @POST: Returns list of LLMProviderConfig.
# @RELATION CALLS -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
@router.get("/providers", response_model=List[LLMProviderConfig])
@@ -80,8 +52,8 @@ async def get_providers(
"""
Get all LLM provider configurations.
"""
log.reason(
f"Fetching providers for user: {current_user.username}"
logger.info(
f"[llm_routes][get_providers][Action] Fetching providers for user: {current_user.username}"
)
service = LLMProviderService(db)
providers = service.get_all_providers()
@@ -104,8 +76,8 @@ async def get_providers(
# #region get_llm_status [TYPE Function]
# @BRIEF Returns whether LLM runtime is configured for dashboard validation.
# @PRE User is authenticated.
# @POST configured=true only when an active provider with valid decrypted key exists.
# @PRE: User is authenticated.
# @POST: configured=true only when an active provider with valid decrypted key exists.
# @RELATION CALLS -> [LLMProviderService]
# @RELATION CALLS -> [_is_valid_runtime_api_key]
@router.get("/status")
@@ -175,8 +147,8 @@ async def get_llm_status(
# #region create_provider [TYPE Function]
# @BRIEF Create a new LLM provider configuration.
# @PRE User is authenticated and has admin permissions.
# @POST Returns the created LLMProviderConfig.
# @PRE: User is authenticated and has admin permissions.
# @POST: Returns the created LLMProviderConfig.
# @RELATION CALLS -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
@router.post(
@@ -208,8 +180,8 @@ async def create_provider(
# #region update_provider [TYPE Function]
# @BRIEF Update an existing LLM provider configuration.
# @PRE User is authenticated and has admin permissions.
# @POST Returns the updated LLMProviderConfig.
# @PRE: User is authenticated and has admin permissions.
# @POST: Returns the updated LLMProviderConfig.
# @RELATION CALLS -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
@router.put("/providers/{provider_id}", response_model=LLMProviderConfig)
@@ -243,8 +215,8 @@ async def update_provider(
# #region delete_provider [TYPE Function]
# @BRIEF Delete an LLM provider configuration.
# @PRE User is authenticated and has admin permissions.
# @POST Returns success status.
# @PRE: User is authenticated and has admin permissions.
# @POST: Returns success status.
# @RELATION CALLS -> [LLMProviderService]
@router.delete("/providers/{provider_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_provider(
@@ -264,129 +236,10 @@ async def delete_provider(
# #endregion delete_provider
# #region fetch_models_for_provider [TYPE Function]
# @BRIEF Fetch available models from a provider's API endpoint.
# @PRE valid base_url and optional api_key/provider_id.
# @POST Returns list of model IDs sorted alphabetically.
# @SIDE_EFFECT Makes HTTP GET to multiple possible model endpoints.
@router.post("/providers/fetch-models", response_model=FetchModelsResponse)
async def fetch_models_for_provider(
request: FetchModelsRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db),
):
"""
Fetch available models from a provider's models endpoint.
Resolves API key in priority order:
1. Direct api_key from request body
2. Decrypted api_key from DB via provider_id (for existing providers)
3. No auth (for local providers without auth)
Tries multiple paths in order:
1. {base_url}/models (OpenAI-compatible, stripped of /v1)
2. {base_url}/v1/models (OpenAI-compatible, with /v1)
3. {base_url}/api/tags (Ollama format)
Returns empty list if none respond — user can enter model name manually.
"""
log.reason(
f"Fetching models for {request.base_url}"
)
# Resolve API key: prefer direct, fall back to stored key via provider_id
api_key = request.api_key
if not api_key and request.provider_id:
try:
svc = LLMProviderService(db)
api_key = svc.get_decrypted_api_key(request.provider_id)
except Exception:
log.explore(f"Could not resolve API key for provider {request.provider_id}", error="API key resolution failed")
base = request.base_url.rstrip("/")
# Collect possible model list endpoints
candidates = []
# If base ends with /v1, try both /models (after stripping /v1) and /v1/models
if base.endswith("/v1"):
candidates.append(base[:-3] + "/models") # base without /v1 + /models
candidates.append(base + "/models") # base with /v1 + /models (e.g. /v1/models)
else:
candidates.append(base + "/models") # /models
candidates.append(base + "/v1/models") # /v1/models
candidates.append(base + "/api/tags") # Ollama format
headers = _build_provider_auth_headers(api_key, request.provider_type)
last_error = None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
for url in candidates:
try:
response = await client.get(url, headers=headers)
if response.status_code != 200:
if response.status_code != 404:
last_error = f"HTTP {response.status_code} from {url}: {response.text[:200]}"
continue
body = response.json()
raw = body.get("data") if isinstance(body, dict) else body
# Try OpenAI format: [{"id": "model-name", ...}, ...] or list of strings
if isinstance(raw, list):
models = sorted(
m.get("id", str(m)) if isinstance(m, dict) else str(m)
for m in raw
)
if models:
log.reason(
f"Found {len(models)} models at {url}"
)
return FetchModelsResponse(
models=models, total=len(models)
)
# Try Ollama format: {"models": [{"name": "model-name", ...}, ...]}
if isinstance(body, dict):
models_list = body.get("models", [])
if isinstance(models_list, list):
models = sorted(
m.get("name", str(m)) if isinstance(m, dict) else str(m)
for m in models_list
)
if models:
log.reason(
f"Found {len(models)} Ollama models at {url}"
)
return FetchModelsResponse(
models=models, total=len(models)
)
except httpx.TimeoutException:
last_error = f"Timeout at {url}"
except httpx.ConnectError:
last_error = f"Cannot connect at {url}"
except Exception as e:
last_error = f"Error at {url}: {str(e)[:100]}"
# All endpoints failed — return empty list with no error (user can type manually)
log.explore(f"No model endpoint responded for {request.base_url}. Last error: {last_error}", error=f"No model endpoint responded for {request.base_url}")
return FetchModelsResponse(models=[], total=0)
except Exception as e:
log.explore(f"Unexpected error for {request.base_url}: {e}", error=str(e))
return FetchModelsResponse(models=[], total=0)
# #endregion fetch_models_for_provider
# #region test_connection [TYPE Function]
# @BRIEF Test connection to an LLM provider.
# @PRE User is authenticated.
# @POST Returns success status and message.
# @PRE: User is authenticated.
# @POST: Returns success status and message.
# @RELATION CALLS -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [LLMClient]
@router.post("/providers/{provider_id}/test")
@@ -395,8 +248,8 @@ async def test_connection(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db),
):
log.reason(
f"Testing connection for provider_id: {provider_id}"
logger.info(
f"[llm_routes][test_connection][Action] Testing connection for provider_id: {provider_id}"
)
"""
Test connection to an LLM provider.
@@ -412,7 +265,9 @@ async def test_connection(
# Check if API key was successfully decrypted
if not api_key:
log.explore(f"Failed to decrypt API key for provider {provider_id}", error="API key decryption failed")
logger.error(
f"[llm_routes][test_connection] Failed to decrypt API key for provider {provider_id}"
)
raise HTTPException(
status_code=500,
detail="Failed to decrypt API key. The provider may have been encrypted with a different encryption key. Please update the provider with a new API key.",
@@ -437,8 +292,8 @@ async def test_connection(
# #region test_provider_config [TYPE Function]
# @BRIEF Test connection with a provided configuration (not yet saved).
# @PRE User is authenticated.
# @POST Returns success status and message.
# @PRE: User is authenticated.
# @POST: Returns success status and message.
# @RELATION DEPENDS_ON -> [LLMClient]
# @RELATION DEPENDS_ON -> [LLMProviderConfig]
@router.post("/providers/test")
@@ -450,8 +305,8 @@ async def test_provider_config(
"""
from ...plugins.llm_analysis.service import LLMClient
log.reason(
f"Testing config for {config.name}"
logger.info(
f"[llm_routes][test_provider_config][Action] Testing config for {config.name}"
)
# Check if API key is provided

View File

@@ -1,14 +1,13 @@
# #region MappingsApi [C:3] [TYPE Module] [SEMANTICS api, mappings, database, fuzzy-matching]
#
# @BRIEF API endpoints for managing database mappings and getting suggestions.
# @LAYER API
# @INVARIANT Mappings are persisted in the SQLite database.
# @LAYER: API
# @RELATION DEPENDS_ON -> [AppDependencies]
# @RELATION DEPENDS_ON -> [DatabaseModule]
# @RELATION DEPENDS_ON -> [mapping_service]
#
#
# @INVARIANT: Mappings are persisted in the SQLite database.
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
@@ -17,7 +16,6 @@ from ...dependencies import get_config_manager, has_permission
from ...core.database import get_db
from ...models.mapping import DatabaseMapping
from pydantic import BaseModel
# [/SECTION]
router = APIRouter(tags=["mappings"])
@@ -55,8 +53,8 @@ class SuggestRequest(BaseModel):
# #region get_mappings [TYPE Function]
# @BRIEF List all saved database mappings.
# @PRE db session is injected.
# @POST Returns filtered list of DatabaseMapping records.
# @PRE: db session is injected.
# @POST: Returns filtered list of DatabaseMapping records.
@router.get("", response_model=List[MappingResponse])
async def get_mappings(
source_env_id: Optional[str] = None,
@@ -75,8 +73,8 @@ async def get_mappings(
# #region create_mapping [TYPE Function]
# @BRIEF Create or update a database mapping.
# @PRE mapping is valid MappingCreate, db session is injected.
# @POST DatabaseMapping created or updated in database.
# @PRE: mapping is valid MappingCreate, db session is injected.
# @POST: DatabaseMapping created or updated in database.
@router.post("", response_model=MappingResponse)
async def create_mapping(
mapping: MappingCreate,
@@ -108,8 +106,8 @@ async def create_mapping(
# #region suggest_mappings_api [TYPE Function]
# @BRIEF Get suggested mappings based on fuzzy matching.
# @PRE request is valid SuggestRequest, config_manager is injected.
# @POST Returns mapping suggestions.
# @PRE: request is valid SuggestRequest, config_manager is injected.
# @POST: Returns mapping suggestions.
@router.post("/suggest")
async def suggest_mappings_api(
request: SuggestRequest,

View File

@@ -1,11 +1,6 @@
# #region MigrationApi [C:5] [TYPE Module] [SEMANTICS api, migration, dashboards, sync, dry-run]
# @BRIEF HTTP contract layer for migration orchestration, settings, dry-run, and mapping sync endpoints.
# @LAYER Infra
# @PRE Backend core services initialized and Database session available.
# @POST Migration tasks are enqueued or dry-run results are computed and returned.
# @SIDE_EFFECT Enqueues long-running tasks, potentially mutates ResourceMapping table, and performs remote Superset API calls.
# @DATA_CONTRACT [DashboardSelection | QueryParams] -> [TaskResponse | DryRunResult | MappingSummary]
# @INVARIANT Migration endpoints never execute with invalid environment references and always return explicit HTTP errors on guard failures.
# @LAYER: Infra
# @RELATION DEPENDS_ON -> [AppDependencies]
# @RELATION DEPENDS_ON -> [DatabaseModule]
# @RELATION DEPENDS_ON -> [DashboardSelection]
@@ -13,6 +8,11 @@
# @RELATION DEPENDS_ON -> [MigrationDryRunService]
# @RELATION DEPENDS_ON -> [IdMappingService]
# @RELATION DEPENDS_ON -> [ResourceMapping]
# @INVARIANT: Migration endpoints never execute with invalid environment references and always return explicit HTTP errors on guard failures.
# @PRE: Backend core services initialized and Database session available.
# @POST: Migration tasks are enqueued or dry-run results are computed and returned.
# @SIDE_EFFECT: Enqueues long-running tasks, potentially mutates ResourceMapping table, and performs remote Superset API calls.
# @DATA_CONTRACT: [DashboardSelection | QueryParams] -> [TaskResponse | DryRunResult | MappingSummary]
# @TEST_CONTRACT: [DashboardSelection + configured envs] -> [task_id | dry-run result | sync summary]
# @TEST_SCENARIO: [invalid_environment] -> [HTTP_400_or_404]
# @TEST_SCENARIO: [valid_execution] -> [success_payload_with_required_fields]
@@ -40,10 +40,10 @@ router = APIRouter(prefix="/api", tags=["migration"])
# #region get_dashboards [C:3] [TYPE Function]
# @BRIEF Fetch dashboard metadata from a requested environment for migration selection UI.
# @PRE env_id is provided and exists in configured environments.
# @POST Returns List[DashboardMetadata] for the resolved environment; emits HTTP_404 when environment is absent.
# @SIDE_EFFECT Reads environment configuration and performs remote Superset metadata retrieval over network.
# @DATA_CONTRACT Input[str env_id] -> Output[List[DashboardMetadata]]
# @PRE: env_id is provided and exists in configured environments.
# @POST: Returns List[DashboardMetadata] for the resolved environment; emits HTTP_404 when environment is absent.
# @SIDE_EFFECT: Reads environment configuration and performs remote Superset metadata retrieval over network.
# @DATA_CONTRACT: Input[str env_id] -> Output[List[DashboardMetadata]]
# @RELATION CALLS -> [SupersetClient.get_dashboards_summary]
@router.get("/environments/{env_id}/dashboards", response_model=List[DashboardMetadata])
async def get_dashboards(
@@ -71,13 +71,13 @@ async def get_dashboards(
# #region execute_migration [C:5] [TYPE Function]
# @BRIEF Validate migration selection and enqueue asynchronous migration task execution.
# @PRE DashboardSelection payload is valid and both source/target environments exist.
# @POST Returns {"task_id": str, "message": str} when task creation succeeds; emits HTTP_400/HTTP_500 on failure.
# @SIDE_EFFECT Reads configuration, writes task record through task manager, and writes operational logs.
# @DATA_CONTRACT Input[DashboardSelection] -> Output[Dict[str, str]]
# @INVARIANT Migration task dispatch never occurs before source and target environment ids pass guard validation.
# @PRE: DashboardSelection payload is valid and both source/target environments exist.
# @POST: Returns {"task_id": str, "message": str} when task creation succeeds; emits HTTP_400/HTTP_500 on failure.
# @SIDE_EFFECT: Reads configuration, writes task record through task manager, and writes operational logs.
# @DATA_CONTRACT: Input[DashboardSelection] -> Output[Dict[str, str]]
# @RELATION CALLS -> [create_task]
# @RELATION DEPENDS_ON -> [DashboardSelection]
# @INVARIANT: Migration task dispatch never occurs before source and target environment ids pass guard validation.
@router.post("/migration/execute")
async def execute_migration(
selection: DashboardSelection,
@@ -134,13 +134,13 @@ async def execute_migration(
# #region dry_run_migration [C:5] [TYPE Function]
# @BRIEF Build pre-flight migration diff and risk summary without mutating target systems.
# @PRE DashboardSelection is valid, source and target environments exist, differ, and selected_ids is non-empty.
# @POST Returns deterministic dry-run payload; emits HTTP_400 for guard violations and HTTP_500 for orchestrator value errors.
# @SIDE_EFFECT Reads local mappings from DB and fetches source/target metadata via Superset API.
# @DATA_CONTRACT Input[DashboardSelection] -> Output[Dict[str, Any]]
# @INVARIANT Dry-run flow remains read-only and rejects identical source/target environments before service execution.
# @PRE: DashboardSelection is valid, source and target environments exist, differ, and selected_ids is non-empty.
# @POST: Returns deterministic dry-run payload; emits HTTP_400 for guard violations and HTTP_500 for orchestrator value errors.
# @SIDE_EFFECT: Reads local mappings from DB and fetches source/target metadata via Superset API.
# @DATA_CONTRACT: Input[DashboardSelection] -> Output[Dict[str, Any]]
# @RELATION DEPENDS_ON -> [DashboardSelection]
# @RELATION DEPENDS_ON -> [MigrationDryRunService]
# @INVARIANT: Dry-run flow remains read-only and rejects identical source/target environments before service execution.
@router.post("/migration/dry-run", response_model=Dict[str, Any])
async def dry_run_migration(
selection: DashboardSelection,
@@ -200,10 +200,10 @@ async def dry_run_migration(
# #region get_migration_settings [C:3] [TYPE Function]
# @BRIEF Read and return configured migration synchronization cron expression.
# @PRE Configuration store is available and requester has READ permission.
# @POST Returns {"cron": str} reflecting current persisted settings value.
# @SIDE_EFFECT Reads configuration from config manager.
# @DATA_CONTRACT Input[None] -> Output[Dict[str, str]]
# @PRE: Configuration store is available and requester has READ permission.
# @POST: Returns {"cron": str} reflecting current persisted settings value.
# @SIDE_EFFECT: Reads configuration from config manager.
# @DATA_CONTRACT: Input[None] -> Output[Dict[str, str]]
# @RELATION DEPENDS_ON -> [AppDependencies]
@router.get("/migration/settings", response_model=Dict[str, str])
async def get_migration_settings(
@@ -221,10 +221,10 @@ async def get_migration_settings(
# #region update_migration_settings [C:3] [TYPE Function]
# @BRIEF Validate and persist migration synchronization cron expression update.
# @PRE Payload includes "cron" key and requester has WRITE permission.
# @POST Returns {"cron": str, "status": "updated"} and persists updated cron value.
# @SIDE_EFFECT Mutates configuration and writes persisted config through config manager.
# @DATA_CONTRACT Input[Dict[str, str]] -> Output[Dict[str, str]]
# @PRE: Payload includes "cron" key and requester has WRITE permission.
# @POST: Returns {"cron": str, "status": "updated"} and persists updated cron value.
# @SIDE_EFFECT: Mutates configuration and writes persisted config through config manager.
# @DATA_CONTRACT: Input[Dict[str, str]] -> Output[Dict[str, str]]
# @RELATION DEPENDS_ON -> [AppDependencies]
@router.put("/migration/settings", response_model=Dict[str, str])
async def update_migration_settings(
@@ -252,10 +252,10 @@ async def update_migration_settings(
# #region get_resource_mappings [C:3] [TYPE Function]
# @BRIEF Fetch synchronized resource mappings with optional filters and pagination for migration mappings view.
# @PRE skip>=0, 1<=limit<=500, DB session is active, requester has READ permission.
# @POST Returns {"items": [...], "total": int} where items reflect applied filters and pagination.
# @SIDE_EFFECT Executes database read queries against ResourceMapping table.
# @DATA_CONTRACT Input[QueryParams] -> Output[Dict[str, Any]]
# @PRE: skip>=0, 1<=limit<=500, DB session is active, requester has READ permission.
# @POST: Returns {"items": [...], "total": int} where items reflect applied filters and pagination.
# @SIDE_EFFECT: Executes database read queries against ResourceMapping table.
# @DATA_CONTRACT: Input[QueryParams] -> Output[Dict[str, Any]]
# @RELATION DEPENDS_ON -> [ResourceMapping]
@router.get("/migration/mappings-data", response_model=Dict[str, Any])
async def get_resource_mappings(
@@ -324,10 +324,10 @@ async def get_resource_mappings(
# #region trigger_sync_now [C:3] [TYPE Function]
# @BRIEF Trigger immediate ID synchronization for every configured environment.
# @PRE At least one environment is configured and requester has EXECUTE permission.
# @POST Returns sync summary with synced/failed counts after attempting all environments.
# @SIDE_EFFECT Upserts Environment rows, commits DB transaction, performs network sync calls, and writes logs.
# @DATA_CONTRACT Input[None] -> Output[Dict[str, Any]]
# @PRE: At least one environment is configured and requester has EXECUTE permission.
# @POST: Returns sync summary with synced/failed counts after attempting all environments.
# @SIDE_EFFECT: Upserts Environment rows, commits DB transaction, performs network sync calls, and writes logs.
# @DATA_CONTRACT: Input[None] -> Output[Dict[str, Any]]
# @RELATION DEPENDS_ON -> [IdMappingService]
# @RELATION CALLS -> [sync_environment]
@router.post("/migration/sync-now", response_model=Dict[str, Any])
@@ -337,6 +337,7 @@ async def trigger_sync_now(
_=Depends(has_permission("plugin:migration", "EXECUTE")),
):
with belief_scope("trigger_sync_now"):
from ...core.logger import logger
from ...models.mapping import Environment as EnvironmentModel
config = config_manager.get_config()
@@ -356,8 +357,8 @@ async def trigger_sync_now(
credentials_id=env.id, # Use env.id as credentials reference
)
db.add(db_env)
logger.reason(
f"Created environment row for {env.id}"
logger.info(
f"[trigger_sync_now][Action] Created environment row for {env.id}"
)
else:
existing.name = env.name
@@ -372,10 +373,10 @@ async def trigger_sync_now(
client = SupersetClient(env)
service.sync_environment(env.id, client)
results["synced"].append(env.id)
logger.reason(f"Synced environment {env.id}")
logger.info(f"[trigger_sync_now][Action] Synced environment {env.id}")
except Exception as e:
results["failed"].append({"env_id": env.id, "error": str(e)})
logger.explore(f"Failed to sync {env.id}", error=str(e))
logger.error(f"[trigger_sync_now][Error] Failed to sync {env.id}: {e}")
return {
"status": "completed",

View File

@@ -1,6 +1,6 @@
# #region PluginsRouter [C:3] [TYPE Module] [SEMANTICS api, router, plugins, list]
# @BRIEF Defines the FastAPI router for plugin-related endpoints, allowing clients to list available plugins.
# @LAYER UI (API)
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [PluginConfig]
# @RELATION DEPENDS_ON -> [get_plugin_loader]
# @RELATION BINDS_TO -> [API_Routes]
@@ -16,9 +16,8 @@ router = APIRouter()
# #region list_plugins [TYPE Function]
# @BRIEF Retrieve a list of all available plugins.
# @PRE plugin_loader is injected via Depends.
# @POST Returns a list of PluginConfig objects.
# @RETURN List[PluginConfig] - List of registered plugins.
# @PRE: plugin_loader is injected via Depends.
# @POST: Returns a list of PluginConfig objects.
# @RELATION CALLS -> [get_plugin_loader]
# @RELATION DEPENDS_ON -> [PluginConfig]
@router.get("", response_model=List[PluginConfig])

View File

@@ -1,27 +1,25 @@
# #region ProfileApiModule [C:3] [TYPE Module] [SEMANTICS api, profile, preferences, self-service, account-lookup]
#
# @BRIEF Exposes self-scoped profile preference endpoints and environment-based Superset account lookup.
# @LAYER API
# @INVARIANT Endpoints are self-scoped and never mutate another user preference.
# @LAYER: API
# @RELATION DEPENDS_ON -> [ProfileService]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
#
#
# @INVARIANT: Endpoints are self-scoped and never mutate another user preference.
# @UX_STATE: ProfileLoad -> Returns stable ProfilePreferenceResponse for authenticated user.
# @UX_STATE: Saving -> Validation errors map to actionable 422 details.
# @UX_STATE: LookupLoading -> Returns success/degraded Superset lookup payload.
# @UX_FEEDBACK: Stable status/message/warning payloads support profile page feedback.
# @UX_RECOVERY: Lookup degradation keeps manual username save path available.
# [SECTION: IMPORTS]
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.logger import belief_scope
from ...core.cot_logger import MarkerLogger
from ...core.logger import logger, belief_scope
from ...dependencies import (
get_config_manager,
get_current_user,
@@ -40,18 +38,15 @@ from ...services.profile_service import (
ProfileService,
ProfileValidationError,
)
# [/SECTION]
log = MarkerLogger("Profile")
router = APIRouter(prefix="/api/profile", tags=["profile"])
# #region _get_profile_service [TYPE Function]
# @RELATION CALLS -> ProfileService
# @BRIEF Build profile service for current request scope.
# @PRE db session and config manager are available.
# @POST Returns a ready ProfileService instance.
# @RELATION CALLS -> [ProfileService]
# @PRE: db session and config manager are available.
# @POST: Returns a ready ProfileService instance.
def _get_profile_service(db: Session, config_manager, plugin_loader=None) -> ProfileService:
return ProfileService(
db=db,
@@ -62,10 +57,10 @@ def _get_profile_service(db: Session, config_manager, plugin_loader=None) -> Pro
# #region get_preferences [TYPE Function]
# @RELATION CALLS -> ProfileService
# @BRIEF Get authenticated user's dashboard filter preference.
# @PRE Valid JWT and authenticated user context.
# @POST Returns preference payload for current user only.
# @RELATION CALLS -> [ProfileService]
# @PRE: Valid JWT and authenticated user context.
# @POST: Returns preference payload for current user only.
@router.get("/preferences", response_model=ProfilePreferenceResponse)
async def get_preferences(
current_user: User = Depends(get_current_user),
@@ -74,17 +69,17 @@ async def get_preferences(
plugin_loader=Depends(get_plugin_loader),
):
with belief_scope("profile.get_preferences", f"user_id={current_user.id}"):
log.reason("Resolving current user preference")
logger.reason("[REASON] Resolving current user preference")
service = _get_profile_service(db, config_manager, plugin_loader)
return service.get_my_preference(current_user)
# #endregion get_preferences
# #region update_preferences [TYPE Function]
# @RELATION CALLS -> ProfileService
# @BRIEF Update authenticated user's dashboard filter preference.
# @PRE Valid JWT and valid request payload.
# @POST Persists normalized preference for current user or raises validation/authorization errors.
# @RELATION CALLS -> [ProfileService]
# @PRE: Valid JWT and valid request payload.
# @POST: Persists normalized preference for current user or raises validation/authorization errors.
@router.patch("/preferences", response_model=ProfilePreferenceResponse)
async def update_preferences(
payload: ProfilePreferenceUpdateRequest,
@@ -96,22 +91,22 @@ async def update_preferences(
with belief_scope("profile.update_preferences", f"user_id={current_user.id}"):
service = _get_profile_service(db, config_manager, plugin_loader)
try:
log.reason("Attempting preference save")
logger.reason("[REASON] Attempting preference save")
return service.update_my_preference(current_user=current_user, payload=payload)
except ProfileValidationError as exc:
log.reflect("Preference validation failed")
logger.reflect("[REFLECT] Preference validation failed")
raise HTTPException(status_code=422, detail=exc.errors) from exc
except ProfileAuthorizationError as exc:
log.explore("Cross-user mutation guard blocked request", error="User attempted to mutate another user's resource")
logger.explore("[EXPLORE] Cross-user mutation guard blocked request")
raise HTTPException(status_code=403, detail=str(exc)) from exc
# #endregion update_preferences
# #region lookup_superset_accounts [TYPE Function]
# @RELATION CALLS -> ProfileService
# @BRIEF Lookup Superset account candidates in selected environment.
# @PRE Valid JWT, authenticated context, and environment_id query parameter.
# @POST Returns success or degraded lookup payload with stable shape.
# @RELATION CALLS -> [ProfileService]
# @PRE: Valid JWT, authenticated context, and environment_id query parameter.
# @POST: Returns success or degraded lookup payload with stable shape.
@router.get("/superset-accounts", response_model=SupersetAccountLookupResponse)
async def lookup_superset_accounts(
environment_id: str = Query(...),
@@ -139,14 +134,14 @@ async def lookup_superset_accounts(
sort_order=sort_order,
)
try:
log.reason("Executing Superset account lookup")
logger.reason("[REASON] Executing Superset account lookup")
return service.lookup_superset_accounts(
current_user=current_user,
request=lookup_request,
)
except EnvironmentNotFoundError as exc:
log.explore("Lookup request references unknown environment", error="EnvironmentNotFoundError raised during Superset account lookup")
logger.explore("[EXPLORE] Lookup request references unknown environment")
raise HTTPException(status_code=404, detail=str(exc)) from exc
# #endregion lookup_superset_accounts
# #endregion ProfileApiModule
# #endregion ProfileApiModule

View File

@@ -1,17 +1,16 @@
# #region ReportsRouter [C:5] [TYPE Module] [SEMANTICS api, reports, list, detail, pagination, filters]
# @BRIEF FastAPI router for unified task report list and detail retrieval endpoints.
# @LAYER UI (API)
# @PRE Reports service and dependencies are initialized.
# @POST Router is configured and endpoints are ready for registration.
# @SIDE_EFFECT None
# @DATA_CONTRACT [ReportQuery] -> [ReportCollection | ReportDetailView]
# @INVARIANT Endpoints are read-only and do not trigger long-running tasks.
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [ReportsService:Class]
# @RELATION DEPENDS_ON -> [get_task_manager:Function]
# @RELATION DEPENDS_ON -> [get_clean_release_repository:Function]
# @RELATION DEPENDS_ON -> [has_permission:Function]
# @INVARIANT: Endpoints are read-only and do not trigger long-running tasks.
# @PRE: Reports service and dependencies are initialized.
# @POST: Router is configured and endpoints are ready for registration.
# @SIDE_EFFECT: None
# @DATA_CONTRACT: [ReportQuery] -> [ReportCollection | ReportDetailView]
# [SECTION: IMPORTS]
from datetime import datetime
from typing import List, Optional
@@ -33,20 +32,15 @@ from ...models.report import (
)
from ...services.clean_release.repository import CleanReleaseRepository
from ...services.reports.report_service import ReportsService
# [/SECTION]
router = APIRouter(prefix="/api/reports", tags=["Reports"])
# #region _parse_csv_enum_list [C:1] [TYPE Function]
# @BRIEF Parse comma-separated query value into enum list.
# @PRE raw may be None/empty or comma-separated values.
# @POST Returns enum list or raises HTTP 400 with deterministic machine-readable payload.
# @PARAM: raw (Optional[str]) - Comma-separated enum values.
# @PARAM: enum_cls (type) - Enum class for validation.
# @PARAM: field_name (str) - Query field name for diagnostics.
# @RETURN: List - Parsed enum values.
# @RELATION: BINDS_TO -> [ReportsRouter]
# @PRE: raw may be None/empty or comma-separated values.
# @POST: Returns enum list or raises HTTP 400 with deterministic machine-readable payload.
# @RELATION BINDS_TO -> [ReportsRouter]
def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
with belief_scope("_parse_csv_enum_list"):
if raw is None or not raw.strip():
@@ -77,9 +71,9 @@ def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
# #region list_reports [C:2] [TYPE Function]
# @BRIEF Return paginated unified reports list.
# @PRE authenticated/authorized request and validated query params.
# @POST returns {items,total,page,page_size,has_next,applied_filters}.
# @POST deterministic error payload for invalid filters.
# @PRE: authenticated/authorized request and validated query params.
# @POST: returns {items,total,page,page_size,has_next,applied_filters}.
# @POST: deterministic error payload for invalid filters.
# @RELATION CALLS -> [_parse_csv_enum_list:Function]
# @RELATION DEPENDS_ON -> [ReportQuery:Class]
# @RELATION DEPENDS_ON -> [ReportsService:Class]
@@ -152,8 +146,8 @@ async def list_reports(
# #region get_report_detail [C:2] [TYPE Function]
# @BRIEF Return one normalized report detail with diagnostics and next actions.
# @PRE authenticated/authorized request and existing report_id.
# @POST returns normalized detail envelope or 404 when report is not found.
# @PRE: authenticated/authorized request and existing report_id.
# @POST: returns normalized detail envelope or 404 when report is not found.
# @RELATION CALLS -> [ReportsService:Class]
@router.get("/{report_id}", response_model=ReportDetailView)
async def get_report_detail(

View File

@@ -1,15 +1,14 @@
# #region SettingsRouter [C:3] [TYPE Module] [SEMANTICS settings, api, router, fastapi]
#
# @BRIEF Provides API endpoints for managing application settings and Superset environments.
# @LAYER API
# @INVARIANT All settings changes must be persisted via ConfigManager.
# @LAYER: API
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [get_config_manager]
# @RELATION DEPENDS_ON -> [has_permission]
#
#
# @INVARIANT: All settings changes must be persisted via ConfigManager.
# @PUBLIC_API: router
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from pydantic import BaseModel
@@ -17,10 +16,7 @@ from ...core.config_models import AppConfig, Environment, GlobalSettings, Loggin
from ...models.storage import StorageConfig
from ...dependencies import get_config_manager, has_permission
from ...core.config_manager import ConfigManager
from ...core.logger import belief_scope
from ...core.cot_logger import MarkerLogger
log = MarkerLogger("Settings")
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
from ...services.llm_prompt_templates import normalize_llm_settings
from ...models.llm import ValidationPolicy
@@ -32,7 +28,6 @@ from ...schemas.settings import (
)
from ...core.database import get_db
from sqlalchemy.orm import Session
# [/SECTION]
# #region LoggingConfigResponse [C:1] [TYPE Class] [SEMANTICS logging, config, response]
@@ -50,8 +45,8 @@ router = APIRouter()
# #region _normalize_superset_env_url [C:1] [TYPE Function]
# @BRIEF Canonicalize Superset environment URL to base host/path without trailing /api/v1.
# @PRE raw_url can be empty.
# @POST Returns normalized base URL.
# @PRE: raw_url can be empty.
# @POST: Returns normalized base URL.
def _normalize_superset_env_url(raw_url: str) -> str:
normalized = str(raw_url or "").strip().rstrip("/")
if normalized.lower().endswith("/api/v1"):
@@ -64,8 +59,8 @@ def _normalize_superset_env_url(raw_url: str) -> str:
# #region _validate_superset_connection_fast [C:2] [TYPE Function]
# @BRIEF Run lightweight Superset connectivity validation without full pagination scan.
# @PRE env contains valid URL and credentials.
# @POST Raises on auth/API failures; returns None on success.
# @PRE: env contains valid URL and credentials.
# @POST: Raises on auth/API failures; returns None on success.
def _validate_superset_connection_fast(env: Environment) -> None:
client = SupersetClient(env)
# 1) Explicit auth check
@@ -85,16 +80,15 @@ def _validate_superset_connection_fast(env: Environment) -> None:
# #region get_settings [C:2] [TYPE Function]
# @BRIEF Retrieves all application settings.
# @PRE Config manager is available.
# @POST Returns masked AppConfig.
# @RETURN AppConfig - The current configuration.
# @PRE: Config manager is available.
# @POST: Returns masked AppConfig.
@router.get("", response_model=AppConfig)
async def get_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_=Depends(has_permission("admin:settings", "READ")),
):
with belief_scope("get_settings"):
log.reason("Fetching all settings")
logger.info("[get_settings][Entry] Fetching all settings")
config = config_manager.get_config().copy(deep=True)
config.settings.llm = normalize_llm_settings(config.settings.llm)
# Mask passwords
@@ -109,9 +103,9 @@ async def get_settings(
# #region get_features [C:1] [TYPE Function]
# @BRIEF Public endpoint returning feature flags for frontend sidebar filtering.
# @PRE Config manager is available.
# @POST Returns dict with dataset_review and health_monitor booleans.
# @RATIONALE Unauthenticated because sidebar filtering must work for all users, not just admins.
# @RATIONALE: Unauthenticated because sidebar filtering must work for all users, not just admins.
# @PRE: Config manager is available.
# @POST: Returns dict with dataset_review and health_monitor booleans.
@router.get("/features")
async def get_features(
config_manager: ConfigManager = Depends(get_config_manager),
@@ -124,10 +118,8 @@ async def get_features(
# #region update_global_settings [C:2] [TYPE Function]
# @BRIEF Updates global application settings.
# @PRE New settings are provided.
# @POST Global settings are updated.
# @PARAM: settings (GlobalSettings) - The new global settings.
# @RETURN: GlobalSettings - The updated settings.
# @PRE: New settings are provided.
# @POST: Global settings are updated.
@router.patch("/global", response_model=GlobalSettings)
async def update_global_settings(
settings: GlobalSettings,
@@ -135,7 +127,7 @@ async def update_global_settings(
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_global_settings"):
log.reason("Updating global settings")
logger.info("[update_global_settings][Entry] Updating global settings")
config_manager.update_global_settings(settings)
return settings
@@ -146,7 +138,6 @@ async def update_global_settings(
# #region get_storage_settings [C:2] [TYPE Function]
# @BRIEF Retrieves storage-specific settings.
# @RETURN StorageConfig - The storage configuration.
@router.get("/storage", response_model=StorageConfig)
async def get_storage_settings(
config_manager: ConfigManager = Depends(get_config_manager),
@@ -161,9 +152,7 @@ async def get_storage_settings(
# #region update_storage_settings [C:2] [TYPE Function]
# @BRIEF Updates storage-specific settings.
# @PARAM: storage (StorageConfig) - The new storage settings.
# @POST: Storage settings are updated and saved.
# @RETURN: StorageConfig - The updated storage settings.
@router.put("/storage", response_model=StorageConfig)
async def update_storage_settings(
storage: StorageConfig,
@@ -186,16 +175,15 @@ async def update_storage_settings(
# #region get_environments [C:2] [TYPE Function]
# @BRIEF Lists all configured Superset environments.
# @PRE Config manager is available.
# @POST Returns list of environments.
# @RETURN List[Environment] - List of environments.
# @PRE: Config manager is available.
# @POST: Returns list of environments.
@router.get("/environments", response_model=List[Environment])
async def get_environments(
config_manager: ConfigManager = Depends(get_config_manager),
_=Depends(has_permission("admin:settings", "READ")),
):
with belief_scope("get_environments"):
log.reason("Fetching environments")
logger.info("[get_environments][Entry] Fetching environments")
environments = config_manager.get_environments()
return [
env.copy(update={"url": _normalize_superset_env_url(env.url)})
@@ -208,10 +196,8 @@ async def get_environments(
# #region add_environment [C:2] [TYPE Function]
# @BRIEF Adds a new Superset environment.
# @PRE Environment data is valid and reachable.
# @POST Environment is added to config.
# @PARAM: env (Environment) - The environment to add.
# @RETURN: Environment - The added environment.
# @PRE: Environment data is valid and reachable.
# @POST: Environment is added to config.
@router.post("/environments", response_model=Environment)
async def add_environment(
env: Environment,
@@ -219,14 +205,16 @@ async def add_environment(
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("add_environment"):
log.reason("Adding environment", payload={"env_id": env.id})
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
# Validate connection before adding (fast path)
try:
_validate_superset_connection_fast(env)
except Exception as e:
log.explore("Connection validation failed", payload={"env_id": env.id}, error=str(e))
logger.error(
f"[add_environment][Coherence:Failed] Connection validation failed: {e}"
)
raise HTTPException(
status_code=400, detail=f"Connection validation failed: {e}"
)
@@ -240,11 +228,8 @@ async def add_environment(
# #region update_environment [C:2] [TYPE Function]
# @BRIEF Updates an existing Superset environment.
# @PRE ID and valid environment data are provided.
# @POST Environment is updated in config.
# @PARAM: id (str) - The ID of the environment to update.
# @PARAM: env (Environment) - The updated environment data.
# @RETURN: Environment - The updated environment.
# @PRE: ID and valid environment data are provided.
# @POST: Environment is updated in config.
@router.put("/environments/{id}", response_model=Environment)
async def update_environment(
id: str,
@@ -252,7 +237,7 @@ async def update_environment(
config_manager: ConfigManager = Depends(get_config_manager),
):
with belief_scope("update_environment"):
log.reason("Updating environment", payload={"id": id})
logger.info(f"[update_environment][Entry] Updating environment {id}")
env = env.copy(update={"url": _normalize_superset_env_url(env.url)})
@@ -269,7 +254,9 @@ async def update_environment(
try:
_validate_superset_connection_fast(env_to_validate)
except Exception as e:
log.explore("Connection validation failed", payload={"id": id}, error=str(e))
logger.error(
f"[update_environment][Coherence:Failed] Connection validation failed: {e}"
)
raise HTTPException(
status_code=400, detail=f"Connection validation failed: {e}"
)
@@ -284,15 +271,14 @@ async def update_environment(
# #region delete_environment [C:2] [TYPE Function]
# @BRIEF Deletes a Superset environment.
# @PRE ID is provided.
# @POST Environment is removed from config.
# @PARAM: id (str) - The ID of the environment to delete.
# @PRE: ID is provided.
# @POST: Environment is removed from config.
@router.delete("/environments/{id}")
async def delete_environment(
id: str, config_manager: ConfigManager = Depends(get_config_manager)
):
with belief_scope("delete_environment"):
log.reason("Deleting environment", payload={"id": id})
logger.info(f"[delete_environment][Entry] Deleting environment {id}")
config_manager.delete_environment(id)
return {"message": f"Environment {id} deleted"}
@@ -302,16 +288,14 @@ async def delete_environment(
# #region test_environment_connection [C:2] [TYPE Function]
# @BRIEF Tests the connection to a Superset environment.
# @PRE ID is provided.
# @POST Returns success or error status.
# @PARAM: id (str) - The ID of the environment to test.
# @RETURN: dict - Success message or error.
# @PRE: ID is provided.
# @POST: Returns success or error status.
@router.post("/environments/{id}/test")
async def test_environment_connection(
id: str, config_manager: ConfigManager = Depends(get_config_manager)
):
with belief_scope("test_environment_connection"):
log.reason("Testing environment connection", payload={"id": id})
logger.info(f"[test_environment_connection][Entry] Testing environment {id}")
# Find environment
env = next((e for e in config_manager.get_environments() if e.id == id), None)
@@ -321,10 +305,14 @@ async def test_environment_connection(
try:
_validate_superset_connection_fast(env)
log.reflect("Connection successful", payload={"id": id})
logger.info(
f"[test_environment_connection][Coherence:OK] Connection successful for {id}"
)
return {"status": "success", "message": "Connection successful"}
except Exception as e:
log.explore("Connection failed", payload={"id": id}, error=str(e))
logger.error(
f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}"
)
return {"status": "error", "message": str(e)}
@@ -333,9 +321,8 @@ async def test_environment_connection(
# #region get_logging_config [C:2] [TYPE Function]
# @BRIEF Retrieves current logging configuration.
# @PRE Config manager is available.
# @POST Returns logging configuration.
# @RETURN LoggingConfigResponse - The current logging config.
# @PRE: Config manager is available.
# @POST: Returns logging configuration.
@router.get("/logging", response_model=LoggingConfigResponse)
async def get_logging_config(
config_manager: ConfigManager = Depends(get_config_manager),
@@ -355,10 +342,8 @@ async def get_logging_config(
# #region update_logging_config [C:2] [TYPE Function]
# @BRIEF Updates logging configuration.
# @PRE New logging config is provided.
# @POST Logging configuration is updated and saved.
# @PARAM: config (LoggingConfig) - The new logging configuration.
# @RETURN: LoggingConfigResponse - The updated logging config.
# @PRE: New logging config is provided.
# @POST: Logging configuration is updated and saved.
@router.patch("/logging", response_model=LoggingConfigResponse)
async def update_logging_config(
config: LoggingConfig,
@@ -366,7 +351,9 @@ async def update_logging_config(
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_logging_config"):
log.reason("Updating logging config", payload={"level": config.level, "task_log_level": config.task_log_level})
logger.info(
f"[update_logging_config][Entry] Updating logging config: level={config.level}, task_log_level={config.task_log_level}"
)
# Get current settings and update logging config
settings = config_manager.get_config().settings
@@ -401,10 +388,10 @@ class ConsolidatedSettingsResponse(BaseModel):
# #region get_consolidated_settings [C:4] [TYPE Function]
# @BRIEF Retrieves all settings categories in a single call.
# @PRE Config manager is available and the caller holds admin settings read permission.
# @POST Returns consolidated settings, provider metadata, and persisted notification payload in one stable response.
# @SIDE_EFFECT Opens one database session to read LLM providers and config-backed notification payload, then closes it.
# @DATA_CONTRACT Input[ConfigManager] -> Output[ConsolidatedSettingsResponse]
# @PRE: Config manager is available and the caller holds admin settings read permission.
# @POST: Returns consolidated settings, provider metadata, and persisted notification payload in one stable response.
# @SIDE_EFFECT: Opens one database session to read LLM providers and config-backed notification payload, then closes it.
# @DATA_CONTRACT: Input[ConfigManager] -> Output[ConsolidatedSettingsResponse]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [AppConfigRecord]
@@ -417,7 +404,7 @@ async def get_consolidated_settings(
_=Depends(has_permission("admin:settings", "READ")),
):
with belief_scope("get_consolidated_settings"):
log.reason("Fetching consolidated settings payload")
logger.reason("Fetching consolidated settings payload")
config = config_manager.get_config()
@@ -464,9 +451,9 @@ async def get_consolidated_settings(
notifications=notifications_payload,
features=config.settings.features.model_dump(),
)
log.reflect(
logger.reflect(
"Consolidated settings payload assembled",
payload={
extra={
"environment_count": len(response_payload.environments),
"provider_count": len(response_payload.llm_providers),
},
@@ -479,8 +466,8 @@ async def get_consolidated_settings(
# #region update_consolidated_settings [C:2] [TYPE Function]
# @BRIEF Bulk update application settings from the consolidated view.
# @PRE User has admin permissions, config is valid.
# @POST Settings are updated and saved via ConfigManager.
# @PRE: User has admin permissions, config is valid.
# @POST: Settings are updated and saved via ConfigManager.
@router.patch("/consolidated")
async def update_consolidated_settings(
settings_patch: dict,
@@ -488,7 +475,9 @@ async def update_consolidated_settings(
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_consolidated_settings"):
log.reason("Applying consolidated settings patch")
logger.info(
"[update_consolidated_settings][Entry] Applying consolidated settings patch"
)
current_config = config_manager.get_config()
current_settings = current_config.settings
@@ -533,7 +522,6 @@ async def update_consolidated_settings(
# #region get_validation_policies [C:2] [TYPE Function]
# @BRIEF Lists all validation policies.
# @RETURN List[ValidationPolicyResponse] - List of policies.
@router.get("/automation/policies", response_model=List[ValidationPolicyResponse])
async def get_validation_policies(
db: Session = Depends(get_db), _=Depends(has_permission("admin:settings", "READ"))
@@ -547,8 +535,6 @@ async def get_validation_policies(
# #region create_validation_policy [C:2] [TYPE Function]
# @BRIEF Creates a new validation policy.
# @PARAM: policy (ValidationPolicyCreate) - The policy data.
# @RETURN: ValidationPolicyResponse - The created policy.
@router.post("/automation/policies", response_model=ValidationPolicyResponse)
async def create_validation_policy(
policy: ValidationPolicyCreate,
@@ -568,9 +554,6 @@ async def create_validation_policy(
# #region update_validation_policy [C:2] [TYPE Function]
# @BRIEF Updates an existing validation policy.
# @PARAM: id (str) - The ID of the policy to update.
# @PARAM: policy (ValidationPolicyUpdate) - The updated policy data.
# @RETURN: ValidationPolicyResponse - The updated policy.
@router.patch("/automation/policies/{id}", response_model=ValidationPolicyResponse)
async def update_validation_policy(
id: str,
@@ -597,7 +580,6 @@ async def update_validation_policy(
# #region delete_validation_policy [C:2] [TYPE Function]
# @BRIEF Deletes a validation policy.
# @PARAM: id (str) - The ID of the policy to delete.
@router.delete("/automation/policies/{id}")
async def delete_validation_policy(
id: str,

View File

@@ -1,13 +1,12 @@
# #region storage_routes [C:3] [TYPE Module] [SEMANTICS storage, files, upload, download, backup, repository]
#
# @BRIEF API endpoints for file storage management (backups and repositories).
# @LAYER API
# @INVARIANT All paths must be validated against path traversal.
# @LAYER: API
# @RELATION DEPENDS_ON -> [StorageModels]
# @RELATION DEPENDS_ON -> [StoragePlugin]
#
#
# @INVARIANT: All paths must be validated against path traversal.
# [SECTION: IMPORTS]
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse
@@ -16,20 +15,16 @@ from ...models.storage import StoredFile, FileCategory
from ...dependencies import get_plugin_loader, has_permission
from ...plugins.storage.plugin import StoragePlugin
from ...core.logger import belief_scope
# [/SECTION]
router = APIRouter(tags=["storage"])
# #region list_files [C:3] [TYPE Function]
# @BRIEF List all files and directories in the storage system.
# @PRE None.
# @POST Returns a list of StoredFile objects.
#
# @PRE: None.
# @POST: Returns a list of StoredFile objects.
#
# @PARAM: category (Optional[FileCategory]) - Filter by category.
# @PARAM: path (Optional[str]) - Subpath within the category.
# @RETURN: List[StoredFile] - List of files/directories.
# @RELATION: DEPENDS_ON -> [StoragePlugin]
# @RELATION DEPENDS_ON -> [StoragePlugin]
@router.get("/files", response_model=List[StoredFile])
async def list_files(
category: Optional[FileCategory] = None,
@@ -47,19 +42,15 @@ async def list_files(
# #region upload_file [C:3] [TYPE Function]
# @BRIEF Upload a file to the storage system.
# @PRE category must be a valid FileCategory.
# @PRE file must be a valid UploadFile.
# @POST Returns the StoredFile object of the uploaded file.
#
# @PRE: category must be a valid FileCategory.
# @PRE: file must be a valid UploadFile.
# @POST: Returns the StoredFile object of the uploaded file.
#
# @PARAM: category (FileCategory) - Target category.
# @PARAM: path (Optional[str]) - Target subpath.
# @PARAM: file (UploadFile) - The file content.
# @RETURN: StoredFile - Metadata of the uploaded file.
#
# @SIDE_EFFECT: Writes file to the filesystem.
#
# @RELATION: DEPENDS_ON -> [StoragePlugin]
# @RELATION DEPENDS_ON -> [StoragePlugin]
@router.post("/upload", response_model=StoredFile, status_code=201)
async def upload_file(
category: FileCategory = Form(...),
@@ -80,17 +71,14 @@ async def upload_file(
# #region delete_file [C:3] [TYPE Function]
# @BRIEF Delete a specific file or directory.
# @PRE category must be a valid FileCategory.
# @POST Item is removed from storage.
#
# @PRE: category must be a valid FileCategory.
# @POST: Item is removed from storage.
#
# @PARAM: category (FileCategory) - File category.
# @PARAM: path (str) - Relative path of the item.
# @RETURN: None
#
# @SIDE_EFFECT: Deletes item from the filesystem.
#
# @RELATION: DEPENDS_ON -> [StoragePlugin]
# @RELATION DEPENDS_ON -> [StoragePlugin]
@router.delete("/files/{category}/{path:path}", status_code=204)
async def delete_file(
category: FileCategory,
@@ -112,15 +100,12 @@ async def delete_file(
# #region download_file [C:3] [TYPE Function]
# @BRIEF Retrieve a file for download.
# @PRE category must be a valid FileCategory.
# @POST Returns a FileResponse.
#
# @PRE: category must be a valid FileCategory.
# @POST: Returns a FileResponse.
#
#
# @PARAM: category (FileCategory) - File category.
# @PARAM: path (str) - Relative path of the file.
# @RETURN: FileResponse - The file content.
#
# @RELATION: DEPENDS_ON -> [StoragePlugin]
# @RELATION DEPENDS_ON -> [StoragePlugin]
@router.get("/download/{category}/{path:path}")
async def download_file(
category: FileCategory,
@@ -144,14 +129,12 @@ async def download_file(
# #region get_file_by_path [C:3] [TYPE Function]
# @BRIEF Retrieve a file by validated absolute/relative path under storage root.
# @PRE path must resolve under configured storage root.
# @POST Returns a FileResponse for existing files.
#
# @PRE: path must resolve under configured storage root.
# @POST: Returns a FileResponse for existing files.
#
#
# @PARAM: path (str) - Absolute or storage-root-relative file path.
# @RETURN: FileResponse - The file content.
#
# @RELATION: DEPENDS_ON -> [StoragePlugin]
# @RELATION DEPENDS_ON -> [StoragePlugin]
@router.get("/file")
async def get_file_by_path(
path: str,

View File

@@ -1,11 +1,10 @@
# #region TasksRouter [C:3] [TYPE Module] [SEMANTICS api, router, tasks, create, list, get, logs]
# @BRIEF Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
# @LAYER UI (API)
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [LLMProviderService]
# [SECTION: IMPORTS]
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel
@@ -25,7 +24,6 @@ from ...services.llm_prompt_templates import (
normalize_llm_settings,
resolve_bound_provider_id,
)
# [/SECTION]
router = APIRouter()
@@ -51,14 +49,11 @@ class ResumeTaskRequest(BaseModel):
# #region create_task [C:3] [TYPE Function]
# @BRIEF Create and start a new task for a given plugin.
# @PARAM: request (CreateTaskRequest) - The request body containing plugin_id and params.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: plugin_id must exist and params must be valid for that plugin.
# @POST: A new task is created and started.
# @RETURN: Task - The created task instance.
# @RELATION: CALLS -> [TaskManager]
# @RELATION: DEPENDS_ON -> [ConfigManager]
# @RELATION: DEPENDS_ON -> [LLMProviderService]
# @RELATION CALLS -> [TaskManager]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [LLMProviderService]
@router.post("", response_model=Task, status_code=status.HTTP_201_CREATED)
async def create_task(
request: CreateTaskRequest,
@@ -133,15 +128,10 @@ async def create_task(
# #region list_tasks [C:2] [TYPE Function]
# @BRIEF Retrieve a list of tasks with pagination and optional status filter.
# @PARAM: limit (int) - Maximum number of tasks to return.
# @PARAM: offset (int) - Number of tasks to skip.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_manager must be available.
# @POST: Returns a list of tasks.
# @RETURN: List[Task] - List of tasks.
# @RELATION: CALLS -> [TaskManager]
# @RELATION: BINDS_TO -> [TASK_TYPE_PLUGIN_MAP]
# @RELATION CALLS -> [TaskManager]
# @RELATION BINDS_TO -> [TASK_TYPE_PLUGIN_MAP]
@router.get("", response_model=List[Task])
async def list_tasks(
limit: int = 10,
@@ -183,12 +173,9 @@ async def list_tasks(
# #region get_task [C:2] [TYPE Function]
# @BRIEF Retrieve the details of a specific task.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_id must exist.
# @POST: Returns task details or raises 404.
# @RETURN: Task - The task details.
# @RELATION: CALLS -> [TaskManager]
# @RELATION CALLS -> [TaskManager]
@router.get("/{task_id}", response_model=Task)
async def get_task(
task_id: str,
@@ -209,18 +196,10 @@ async def get_task(
# #region get_task_logs [C:5] [TYPE Function]
# @BRIEF Retrieve logs for a specific task with optional filtering.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: level (Optional[str]) - Filter by log level (DEBUG, INFO, WARNING, ERROR).
# @PARAM: source (Optional[str]) - Filter by source component.
# @PARAM: search (Optional[str]) - Text search in message.
# @PARAM: offset (int) - Number of logs to skip.
# @PARAM: limit (int) - Maximum number of logs to return.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_id must exist.
# @POST: Returns a list of log entries or raises 404.
# @RETURN: List[LogEntry] - List of log entries.
# @RELATION: CALLS -> [TaskManager]
# @RELATION: DEPENDS_ON -> [LogFilter]
# @RELATION CALLS -> [TaskManager]
# @RELATION DEPENDS_ON -> [LogFilter]
# @TEST_CONTRACT: TaskLogQueryInput -> List[LogEntry]
# @TEST_SCENARIO: existing_task_logs_filtered -> Returns filtered logs by level/source/search with pagination.
# @TEST_FIXTURE: valid_task_with_mixed_logs -> backend/tests/fixtures/task_logs/valid_task_with_mixed_logs.json
@@ -264,13 +243,10 @@ async def get_task_logs(
# #region get_task_log_stats [C:2] [TYPE Function]
# @BRIEF Get statistics about logs for a task (counts by level and source).
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_id must exist.
# @POST: Returns log statistics or raises 404.
# @RETURN: LogStats - Statistics about task logs.
# @RELATION: CALLS -> [TaskManager]
# @RELATION: DEPENDS_ON -> [LogStats]
# @RELATION CALLS -> [TaskManager]
# @RELATION DEPENDS_ON -> [LogStats]
@router.get("/{task_id}/logs/stats", response_model=LogStats)
async def get_task_log_stats(
task_id: str,
@@ -312,12 +288,9 @@ async def get_task_log_stats(
# #region get_task_log_sources [C:2] [TYPE Function]
# @BRIEF Get unique sources for a task's logs.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_id must exist.
# @POST: Returns list of unique source names or raises 404.
# @RETURN: List[str] - Unique source names.
# @RELATION: CALLS -> [TaskManager]
# @RELATION CALLS -> [TaskManager]
@router.get("/{task_id}/logs/sources", response_model=List[str])
async def get_task_log_sources(
task_id: str,
@@ -337,13 +310,9 @@ async def get_task_log_sources(
# #region resolve_task [C:2] [TYPE Function]
# @BRIEF Resolve a task that is awaiting mapping.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: request (ResolveTaskRequest) - The resolution parameters.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task must be in AWAITING_MAPPING status.
# @POST: Task is resolved and resumes execution.
# @RETURN: Task - The updated task object.
# @RELATION: CALLS -> [TaskManager]
# @RELATION CALLS -> [TaskManager]
@router.post("/{task_id}/resolve", response_model=Task)
async def resolve_task(
task_id: str,
@@ -364,13 +333,9 @@ async def resolve_task(
# #region resume_task [C:2] [TYPE Function]
# @BRIEF Resume a task that is awaiting input (e.g., passwords).
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: request (ResumeTaskRequest) - The input (passwords).
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task must be in AWAITING_INPUT status.
# @POST: Task resumes execution with provided input.
# @RETURN: Task - The updated task object.
# @RELATION: CALLS -> [TaskManager]
# @RELATION CALLS -> [TaskManager]
@router.post("/{task_id}/resume", response_model=Task)
async def resume_task(
task_id: str,
@@ -391,11 +356,9 @@ async def resume_task(
# #region clear_tasks [C:2] [TYPE Function]
# @BRIEF Clear tasks matching the status filter.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_manager is available.
# @POST: Tasks are removed from memory/persistence.
# @RELATION: CALLS -> [TaskManager]
# @RELATION CALLS -> [TaskManager]
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
async def clear_tasks(
status: Optional[TaskStatus] = None,

View File

@@ -1,16 +1,15 @@
# #region TranslateRoutes [C:4] [TYPE Module] [SEMANTICS api,routes,translate]
# #region TranslateRoutes [C:4] [TYPE Module] [SEMANTICS api, routes, translate]
# @BRIEF API routes for LLM-based SQL/dashboard translation management including terminology dictionary CRUD and import.
# @LAYER UI
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [TranslateJobResponse]
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @RELATION DEPENDS_ON -> [DictionaryManager:Class]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @RELATION DEPENDS_ON -> [has_permission]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @PRE All sub-modules importable. Router instance available from ._router.
# @POST Package imports all route modules and re-exports router. Sub-routes registered on shared router.
# @SIDE_EFFECT Registers route handlers on shared APIRouter at import time.
# @RATIONALE: Snapshot isolation — in-progress runs use config snapshot; config edits affect future runs only.
# @REJECTED: Invalidating in-progress runs on config edit would break scheduled run continuity.
"""
Translate routes package.

View File

@@ -1,19 +1,13 @@
# #region TranslateCorrectionRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,corrections]
# @BRIEF Term correction submission endpoints for applying user feedback to dictionary entries.
# @LAYER UI
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE DB session initialized. User authenticated with translate.dictionary.edit permissions.
# @POST Term corrections submitted and applied to dictionary with conflict detection.
# @SIDE_EFFECT Creates/updates DictionaryEntry records in DB based on correction submissions.
# #region TranslateCorrectionRoutesModule [C:2] [TYPE Module] [SEMANTICS api, routes, translate, corrections]
# @BRIEF Term correction submission endpoints.
# @LAYER: API
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.cot_logger import MarkerLogger
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission
from ....plugins.translate.dictionary import DictionaryManager
@@ -25,18 +19,15 @@ from ....schemas.translate import (
from ._router import router
log = MarkerLogger("TranslateCorrectionRoutes")
# ============================================================
# Corrections
# ============================================================
# #region submit_correction [C:4] [TYPE Function] [SEMANTICS api,translate,corrections]
# @BRIEF Submit a single term correction from a run result review.
# @PRE User has translate.dictionary.edit permission. Dictionary ID is provided.
# @POST Correction applied with conflict detection. Result returned.
# @SIDE_EFFECT Creates/updates DictionaryEntry record in DB; logs correction origin.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# #region submit_correction [TYPE Function]
# @BRIEF Submit a term correction from a run result review.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Correction applied with conflict detection.
@router.post("/corrections")
async def submit_correction(
payload: TermCorrectionSubmit,
@@ -45,7 +36,7 @@ async def submit_correction(
db: Session = Depends(get_db),
):
"""Submit a term correction from a run result review."""
log.reason("Correction request", payload={"user": current_user.username})
logger.info(f"[translate_routes][submit_correction] User: {current_user.username}")
if not payload.dictionary_id:
raise HTTPException(status_code=422, detail="dictionary_id is required")
try:
@@ -65,12 +56,10 @@ async def submit_correction(
# #endregion submit_correction
# #region submit_bulk_corrections [C:4] [TYPE Function] [SEMANTICS api,translate,corrections]
# @BRIEF Submit multiple term corrections atomically with full conflict reporting.
# @PRE User has translate.dictionary.edit permission. Dictionary ID is provided.
# @POST All corrections applied or none with conflict list returned.
# @SIDE_EFFECT Creates/updates multiple DictionaryEntry records in DB atomically.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# #region submit_bulk_corrections [TYPE Function]
# @BRIEF Submit multiple term corrections atomically.
# @PRE: User has translate.dictionary.edit permission.
# @POST: All corrections applied or none with conflict list.
@router.post("/corrections/bulk")
async def submit_bulk_corrections(
payload: TermCorrectionBulkSubmit,
@@ -79,7 +68,7 @@ async def submit_bulk_corrections(
db: Session = Depends(get_db),
):
"""Submit multiple term corrections atomically."""
log.reason("Bulk correction request", payload={"user": current_user.username})
logger.info(f"[translate_routes][submit_bulk_corrections] User: {current_user.username}")
try:
corr_list = []
for c in payload.corrections:

View File

@@ -1,23 +1,14 @@
# #region TranslateDictionaryRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,dictionaries]
# #region TranslateDictionaryRoutesModule [C:3] [TYPE Module] [SEMANTICS api, routes, translate, dictionaries]
# @BRIEF Terminology Dictionary CRUD, entries management, and import routes.
# @LAYER UI
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE DB session initialized. User authenticated with translate.dictionary permissions.
# @POST Dictionaries and entries CRUD completed. Import executed with conflict resolution.
# @SIDE_EFFECT Reads/writes TerminologyDictionary and DictionaryEntry records to DB.
# @LAYER: API
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import belief_scope
from ....core.cot_logger import MarkerLogger
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
log = MarkerLogger("TranslateDictRoutes")
from ....dependencies import get_current_user, has_permission
from ....plugins.translate.dictionary import DictionaryManager
from ....schemas.translate import (
@@ -36,12 +27,10 @@ from ._helpers import _dict_to_response, _get_dictionary_entry_counts
# Terminology Dictionaries
# ============================================================
# #region list_dictionaries [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries]
# @BRIEF List all terminology dictionaries with pagination and entry counts.
# @PRE User has translate.dictionary.view permission.
# @POST Returns paginated list of dictionaries with total count.
# @SIDE_EFFECT Reads TerminologyDictionary and DictionaryEntry records from DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# #region list_dictionaries [TYPE Function]
# @BRIEF List all terminology dictionaries.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns list of dictionaries with total count.
@router.get("/dictionaries")
async def list_dictionaries(
page: int = Query(1, ge=1),
@@ -52,22 +41,20 @@ async def list_dictionaries(
):
"""List all terminology dictionaries."""
with belief_scope("list_dictionaries"):
log.reason("List dictionaries", payload={"user": current_user.username})
logger.reason(f"User: {current_user.username}")
dicts, total = DictionaryManager.list_dictionaries(db, page=page, page_size=page_size)
dict_ids = [d.id for d in dicts]
counts = _get_dictionary_entry_counts(db, dict_ids) if dict_ids else {}
items = [_dict_to_response(d, counts.get(d.id, 0)) for d in dicts]
log.reflect("Dictionaries listed", payload={"total": total, "returned": len(items)})
logger.reflect("Dictionaries listed", {"total": total, "returned": len(items)})
return {"items": items, "total": total, "page": page, "page_size": page_size}
# #endregion list_dictionaries
# #region get_dictionary [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries]
# @BRIEF Get a single terminology dictionary by ID with entry count.
# @PRE User has translate.dictionary.view permission.
# @POST Returns the dictionary with entry_count or 404.
# @SIDE_EFFECT Reads TerminologyDictionary and DictionaryEntry records from DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# #region get_dictionary [TYPE Function]
# @BRIEF Get a single terminology dictionary by ID.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns the dictionary with entry_count.
@router.get("/dictionaries/{dictionary_id}")
async def get_dictionary(
dictionary_id: str,
@@ -77,24 +64,22 @@ async def get_dictionary(
):
"""Get a terminology dictionary by ID."""
with belief_scope("get_dictionary"):
log.reason("Get dictionary", payload={"dict_id": dictionary_id, "user": current_user.username})
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
d = DictionaryManager.get_dictionary(db, dictionary_id)
from ....models.translate import DictionaryEntry
from ...models.translate import DictionaryEntry
entry_count = db.query(DictionaryEntry.id).filter(DictionaryEntry.dictionary_id == dictionary_id).count()
log.reflect("Dictionary found", payload={"id": dictionary_id})
logger.reflect("Dictionary found", {"id": dictionary_id})
return _dict_to_response(d, entry_count)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion get_dictionary
# #region create_dictionary [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries]
# #region create_dictionary [TYPE Function]
# @BRIEF Create a new terminology dictionary.
# @PRE User has translate.dictionary.create permission. Payload validated.
# @POST Dictionary created and returned with zero entries.
# @SIDE_EFFECT Creates TerminologyDictionary record in DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @PRE: User has translate.dictionary.create permission.
# @POST: Returns the created dictionary.
@router.post("/dictionaries", status_code=status.HTTP_201_CREATED)
async def create_dictionary(
payload: DictionaryCreate,
@@ -104,7 +89,7 @@ async def create_dictionary(
):
"""Create a new terminology dictionary."""
with belief_scope("create_dictionary"):
log.reason("Create dictionary", payload={"user": current_user.username})
logger.reason(f"User: {current_user.username}")
d = DictionaryManager.create_dictionary(
db,
name=payload.name,
@@ -114,17 +99,15 @@ async def create_dictionary(
description=payload.description,
is_active=payload.is_active,
)
log.reflect("Dictionary created", payload={"id": d.id})
logger.reflect("Dictionary created", {"id": d.id})
return _dict_to_response(d, 0)
# #endregion create_dictionary
# #region update_dictionary [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries]
# #region update_dictionary [TYPE Function]
# @BRIEF Update an existing terminology dictionary.
# @PRE User has translate.dictionary.edit permission. Dictionary exists.
# @POST Dictionary updated and returned with current entry count.
# @SIDE_EFFECT Updates TerminologyDictionary record in DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @PRE: User has translate.dictionary.edit permission.
# @POST: Returns the updated dictionary.
@router.put("/dictionaries/{dictionary_id}")
async def update_dictionary(
dictionary_id: str,
@@ -135,7 +118,7 @@ async def update_dictionary(
):
"""Update a terminology dictionary."""
with belief_scope("update_dictionary"):
log.reason("Update dictionary", payload={"dict_id": dictionary_id, "user": current_user.username})
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
d = DictionaryManager.update_dictionary(
db,
@@ -146,21 +129,19 @@ async def update_dictionary(
target_dialect=payload.target_dialect,
is_active=payload.is_active,
)
from ....models.translate import DictionaryEntry
from ...models.translate import DictionaryEntry
entry_count = db.query(DictionaryEntry.id).filter(DictionaryEntry.dictionary_id == dictionary_id).count()
log.reflect("Dictionary updated", payload={"id": dictionary_id})
logger.reflect("Dictionary updated", {"id": dictionary_id})
return _dict_to_response(d, entry_count)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion update_dictionary
# #region delete_dictionary [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries]
# #region delete_dictionary [TYPE Function]
# @BRIEF Delete a terminology dictionary, blocked if attached to active/scheduled jobs.
# @PRE User has translate.dictionary.delete permission. Dictionary exists and is not in use.
# @POST Dictionary is deleted or 409 if attached to active jobs.
# @SIDE_EFFECT Deletes TerminologyDictionary record from DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @PRE: User has translate.dictionary.delete permission.
# @POST: Dictionary is deleted.
@router.delete("/dictionaries/{dictionary_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dictionary(
dictionary_id: str,
@@ -170,10 +151,10 @@ async def delete_dictionary(
):
"""Delete a terminology dictionary."""
with belief_scope("delete_dictionary"):
log.reason("Delete dictionary", payload={"dict_id": dictionary_id, "user": current_user.username})
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
DictionaryManager.delete_dictionary(db, dictionary_id)
log.reflect("Dictionary deleted", payload={"id": dictionary_id})
logger.reflect("Dictionary deleted", {"id": dictionary_id})
return None
except ValueError as e:
if "active/scheduled" in str(e):
@@ -186,12 +167,10 @@ async def delete_dictionary(
# Dictionary Entries CRUD
# ============================================================
# #region list_dictionary_entries [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries,entries]
# @BRIEF List entries for a dictionary with pagination.
# @PRE User has translate.dictionary.view permission. Dictionary exists.
# @POST Returns paginated list of entries.
# @SIDE_EFFECT Reads DictionaryEntry records from DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# #region list_dictionary_entries [TYPE Function]
# @BRIEF List entries for a dictionary.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns paginated list of entries.
@router.get("/dictionaries/{dictionary_id}/entries")
async def list_dictionary_entries(
dictionary_id: str,
@@ -203,7 +182,7 @@ async def list_dictionary_entries(
):
"""List entries for a dictionary."""
with belief_scope("list_dictionary_entries"):
log.reason("List dictionary entries", payload={"dict_id": dictionary_id, "user": current_user.username})
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
entries, total = DictionaryManager.list_entries(db, dictionary_id, page=page, page_size=page_size)
items = [
@@ -219,19 +198,17 @@ async def list_dictionary_entries(
}
for e in entries
]
log.reflect("Entries listed", payload={"dict_id": dictionary_id, "total": total})
logger.reflect("Entries listed", {"dict_id": dictionary_id, "total": total})
return {"items": items, "total": total, "page": page, "page_size": page_size}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion list_dictionary_entries
# #region add_dictionary_entry [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries,entries]
# #region add_dictionary_entry [TYPE Function]
# @BRIEF Add a new entry to a dictionary.
# @PRE User has translate.dictionary.edit permission. Dictionary exists.
# @POST Entry created and returned.
# @SIDE_EFFECT Creates DictionaryEntry record in DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is created.
@router.post("/dictionaries/{dictionary_id}/entries", status_code=status.HTTP_201_CREATED)
async def add_dictionary_entry(
dictionary_id: str,
@@ -242,7 +219,7 @@ async def add_dictionary_entry(
):
"""Add a new entry to a dictionary."""
with belief_scope("add_dictionary_entry"):
log.reason("Add dictionary entry", payload={"dict_id": dictionary_id, "user": current_user.username})
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
entry = DictionaryManager.add_entry(
db, dictionary_id,
@@ -250,7 +227,7 @@ async def add_dictionary_entry(
target_term=payload.target_term,
context_notes=payload.context_notes,
)
log.reflect("Entry added", payload={"entry_id": entry.id})
logger.reflect("Entry added", {"entry_id": entry.id})
return {
"id": entry.id,
"dictionary_id": entry.dictionary_id,
@@ -268,12 +245,10 @@ async def add_dictionary_entry(
# #endregion add_dictionary_entry
# #region edit_dictionary_entry [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries,entries]
# #region edit_dictionary_entry [TYPE Function]
# @BRIEF Update an existing dictionary entry.
# @PRE User has translate.dictionary.edit permission. Entry exists.
# @POST Entry updated and returned.
# @SIDE_EFFECT Updates DictionaryEntry record in DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is updated.
@router.put("/dictionaries/{dictionary_id}/entries/{entry_id}")
async def edit_dictionary_entry(
dictionary_id: str,
@@ -285,7 +260,7 @@ async def edit_dictionary_entry(
):
"""Update an existing dictionary entry."""
with belief_scope("edit_dictionary_entry"):
log.reason("Edit dictionary entry", payload={"entry_id": entry_id, "user": current_user.username})
logger.reason(f"Entry: {entry_id}, User: {current_user.username}")
try:
entry = DictionaryManager.edit_entry(
db, entry_id,
@@ -293,7 +268,7 @@ async def edit_dictionary_entry(
target_term=payload.target_term,
context_notes=payload.context_notes,
)
log.reflect("Entry updated", payload={"entry_id": entry_id})
logger.reflect("Entry updated", {"entry_id": entry_id})
return {
"id": entry.id,
"dictionary_id": entry.dictionary_id,
@@ -311,12 +286,10 @@ async def edit_dictionary_entry(
# #endregion edit_dictionary_entry
# #region delete_dictionary_entry [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries,entries]
# #region delete_dictionary_entry [TYPE Function]
# @BRIEF Delete a dictionary entry.
# @PRE User has translate.dictionary.edit permission. Entry exists.
# @POST Entry is deleted.
# @SIDE_EFFECT Deletes DictionaryEntry record from DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is deleted.
@router.delete("/dictionaries/{dictionary_id}/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dictionary_entry(
dictionary_id: str,
@@ -327,22 +300,20 @@ async def delete_dictionary_entry(
):
"""Delete a dictionary entry."""
with belief_scope("delete_dictionary_entry"):
log.reason("Delete dictionary entry", payload={"entry_id": entry_id, "user": current_user.username})
logger.reason(f"Entry: {entry_id}, User: {current_user.username}")
try:
DictionaryManager.delete_entry(db, entry_id)
log.reflect("Entry deleted", payload={"entry_id": entry_id})
logger.reflect("Entry deleted", {"entry_id": entry_id})
return None
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# #endregion delete_dictionary_entry
# #region import_dictionary_entries [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries,import]
# #region import_dictionary_entries [TYPE Function]
# @BRIEF Import entries into a terminology dictionary from CSV/TSV content.
# @PRE User has translate.dictionary.edit permission. Dictionary exists. Content is valid CSV/TSV.
# @POST Entries imported per conflict mode (overwrite/keep_existing/cancel). Import result returned.
# @SIDE_EFFECT Creates/updates DictionaryEntry records in DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entries are imported per conflict mode.
@router.post("/dictionaries/{dictionary_id}/import")
async def import_dictionary_entries(
dictionary_id: str,
@@ -353,7 +324,7 @@ async def import_dictionary_entries(
):
"""Import entries into a terminology dictionary from CSV/TSV content."""
with belief_scope("import_dictionary_entries"):
log.reason("Import dictionary entries", payload={"dict_id": dictionary_id, "user": current_user.username})
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
if payload.on_conflict not in ("overwrite", "keep_existing", "cancel"):
raise HTTPException(
@@ -370,7 +341,7 @@ async def import_dictionary_entries(
on_conflict=payload.on_conflict,
preview_only=payload.preview_only,
)
log.reflect("Import completed", payload={
logger.reflect("Import completed", {
"dict_id": dictionary_id,
"total": result["total"],
"errors": len(result["errors"]),

View File

@@ -1,12 +1,13 @@
# #region TranslateHelpersModule [C:2] [TYPE Module] [SEMANTICS api,routes,translate,helpers]
# #region TranslateHelpersModule [C:2] [TYPE Module] [SEMANTICS api, routes, translate, helpers]
# @BRIEF Shared helper functions for translate route handlers.
# @LAYER UI
# @LAYER: API
# @RELATION DEPENDS_ON -> [models.translate]
from typing import Any, Dict, List
from sqlalchemy.orm import Session
# #region _run_to_response [C:2] [TYPE Function] [SEMANTICS helpers,translate]
# #region _run_to_response [C:2] [TYPE Function]
# @BRIEF Convert TranslationRun ORM to response dict.
def _run_to_response(run: Any) -> dict:
return {
@@ -33,7 +34,7 @@ def _run_to_response(run: Any) -> dict:
# #endregion _run_to_response
# #region _dict_to_response [C:2] [TYPE Function] [SEMANTICS helpers,translate]
# #region _dict_to_response [C:2] [TYPE Function]
# @BRIEF Convert a TerminologyDictionary ORM model to a response dict with computed entry_count.
@staticmethod
def _dict_to_response(d: Any, entry_count: int = 0) -> dict:
@@ -52,11 +53,11 @@ def _dict_to_response(d: Any, entry_count: int = 0) -> dict:
# #endregion _dict_to_response
# #region _get_dictionary_entry_counts [C:2] [TYPE Function] [SEMANTICS helpers,translate]
# #region _get_dictionary_entry_counts [C:2] [TYPE Function]
# @BRIEF Get entry counts for a list of dictionary IDs.
def _get_dictionary_entry_counts(db: Session, dict_ids: List[str]) -> Dict[str, int]:
from sqlalchemy import func
from ....models.translate import DictionaryEntry
from ...models.translate import DictionaryEntry
counts = (
db.query(DictionaryEntry.dictionary_id, func.count(DictionaryEntry.id))
.filter(DictionaryEntry.dictionary_id.in_(dict_ids))

View File

@@ -1,25 +1,14 @@
# #region TranslateJobRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,jobs]
# #region TranslateJobRoutesModule [C:3] [TYPE Module] [SEMANTICS api, routes, translate, jobs]
# @BRIEF Translation Job CRUD and datasource column routes.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslateJobService]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE ConfigManager and DB session initialized. User authenticated with translate.job permissions.
# @POST Translation job CRUD completed or datasource columns fetched.
# @SIDE_EFFECT Reads/writes TranslationJob records to DB; fetches datasource metadata from Superset API.
# @LAYER: API
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import belief_scope
from ....core.cot_logger import MarkerLogger
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
log = MarkerLogger("TranslateJobRoutes")
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.service import (
@@ -42,9 +31,10 @@ from ._router import router
# Translation Job CRUD
# ============================================================
# #region list_jobs [C:3] [TYPE Function] [SEMANTICS api,translate,jobs]
# @BRIEF List all translation jobs with pagination and optional status filter.
# @RELATION DEPENDS_ON -> [TranslateJobService]
# #region list_jobs [TYPE Function]
# @BRIEF List all translation jobs.
# @PRE: User has translate.job.view permission.
# @POST: Returns list of translation jobs.
@router.get("/jobs", response_model=List[TranslateJobResponse])
async def list_jobs(
page: int = Query(1, ge=1),
@@ -56,7 +46,7 @@ async def list_jobs(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""List all translation jobs."""
log.reason("Listing jobs", payload={"user": current_user.username})
logger.info(f"[translate_routes][list_jobs] User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
total, jobs = service.list_jobs(
@@ -74,9 +64,10 @@ async def list_jobs(
# #endregion list_jobs
# #region get_job [C:3] [TYPE Function] [SEMANTICS api,translate,jobs]
# #region get_job [TYPE Function]
# @BRIEF Get a single translation job by ID.
# @RELATION DEPENDS_ON -> [TranslateJobService]
# @PRE: User has translate.job.view permission.
# @POST: Returns the translation job.
@router.get("/jobs/{job_id}", response_model=TranslateJobResponse)
async def get_job(
job_id: str,
@@ -86,7 +77,7 @@ async def get_job(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get a translation job by ID."""
log.reason("Getting job", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][get_job] Job: {job_id}, User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
job = service.get_job(job_id)
@@ -97,12 +88,11 @@ async def get_job(
# #endregion get_job
# #region create_job [C:4] [TYPE Function] [SEMANTICS api,translate,jobs]
# #region create_job [TYPE Function]
# @BRIEF Create a new translation job.
# @PRE User has translate.job.create permission. Payload validated.
# @POST Translation job created and returned.
# @SIDE_EFFECT Creates TranslationJob record in DB; validates columns via SupersetClient; caches database_dialect.
# @RELATION DEPENDS_ON -> [TranslateJobService]
# @PRE: User has translate.job.create permission.
# @POST: Returns the created translation job.
# @SIDE_EFFECT: Validates columns via SupersetClient; caches database_dialect.
@router.post("/jobs", response_model=TranslateJobResponse, status_code=status.HTTP_201_CREATED)
async def create_job(
payload: TranslateJobCreate,
@@ -112,7 +102,7 @@ async def create_job(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Create a new translation job."""
log.reason("Creating job", payload={"user": current_user.username})
logger.info(f"[translate_routes][create_job] User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
job = service.create_job(payload)
@@ -123,12 +113,11 @@ async def create_job(
# #endregion create_job
# #region update_job [C:4] [TYPE Function] [SEMANTICS api,translate,jobs]
# #region update_job [TYPE Function]
# @BRIEF Update an existing translation job.
# @PRE User has translate.job.edit permission. Job exists.
# @POST Translation job updated and returned.
# @SIDE_EFFECT Updates TranslationJob record in DB; re-detects database_dialect if datasource changed.
# @RELATION DEPENDS_ON -> [TranslateJobService]
# @PRE: User has translate.job.edit permission.
# @POST: Returns the updated translation job.
# @SIDE_EFFECT: Re-detects database_dialect if datasource changed.
@router.put("/jobs/{job_id}", response_model=TranslateJobResponse)
async def update_job(
job_id: str,
@@ -139,7 +128,7 @@ async def update_job(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Update a translation job."""
log.reason("Updating job", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][update_job] Job: {job_id}, User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
job = service.update_job(job_id, payload)
@@ -150,12 +139,10 @@ async def update_job(
# #endregion update_job
# #region delete_job [C:4] [TYPE Function] [SEMANTICS api,translate,jobs]
# #region delete_job [TYPE Function]
# @BRIEF Delete a translation job.
# @PRE User has translate.job.delete permission. Job exists.
# @POST Translation job is deleted from DB.
# @SIDE_EFFECT Deletes TranslationJob and associated records from DB.
# @RELATION DEPENDS_ON -> [TranslateJobService]
# @PRE: User has translate.job.delete permission.
# @POST: Job is deleted.
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_job(
job_id: str,
@@ -165,7 +152,7 @@ async def delete_job(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Delete a translation job."""
log.reason("Deleting job", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][delete_job] Job: {job_id}, User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
service.delete_job(job_id)
@@ -174,12 +161,10 @@ async def delete_job(
# #endregion delete_job
# #region duplicate_job [C:4] [TYPE Function] [SEMANTICS api,translate,jobs]
# #region duplicate_job [TYPE Function]
# @BRIEF Duplicate a translation job.
# @PRE User has translate.job.create permission. Source job exists.
# @POST Duplicated job created with status DRAFT and returned.
# @SIDE_EFFECT Creates a new TranslationJob record in DB as a copy of the source.
# @RELATION DEPENDS_ON -> [TranslateJobService]
# @PRE: User has translate.job.create permission.
# @POST: Returns the duplicated job with status DRAFT.
@router.post("/jobs/{job_id}/duplicate", response_model=DuplicateJobResponse, status_code=status.HTTP_201_CREATED)
async def duplicate_job(
job_id: str,
@@ -189,7 +174,7 @@ async def duplicate_job(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Duplicate a translation job."""
log.reason("Duplicating job", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][duplicate_job] Job: {job_id}, User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
new_job = service.duplicate_job(job_id)
@@ -203,13 +188,14 @@ async def duplicate_job(
# #endregion duplicate_job
# #region list_datasources [C:4] [TYPE Function] [SEMANTICS api,translate,datasources]
# @BRIEF List all Superset datasets available for translation jobs.
# @PRE User has translate.job.view permission.
# @POST Returns list of datasets with metadata.
# @SIDE_EFFECT Queries Superset API for available datasets.
# @RELATION DEPENDS_ON -> [TranslateJobService]
# @RELATION DEPENDS_ON -> [SupersetClient]
# #region get_datasource_columns [TYPE Function]
# @BRIEF Get column metadata and database dialect for a Superset datasource.
# @PRE: User has translate.job.view permission.
# @POST: Returns column list with metadata and database_dialect.
# @SIDE_EFFECT: Queries Superset API for dataset detail and database info.
# @RATIONALE: Dialect detection from Superset connection cached on TranslationJob at save time.
# @REJECTED: Hardcoding dialect list per database — would drift from actual Superset config.
@router.get("/datasources")
async def list_datasources(
env_id: str = Query(..., description="Superset environment ID"),
@@ -226,18 +212,9 @@ async def list_datasources(
datasets = service.fetch_available_datasources(env_id, search)
return datasets
except Exception as e:
log.explore("List datasources failed", error=str(e))
logger.error(f"[translate_routes][list_datasources] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
# #endregion list_datasources
# #region get_datasource_columns [C:4] [TYPE Function] [SEMANTICS api,translate,datasources]
# @BRIEF Get column metadata and database dialect for a Superset datasource.
# @PRE User has translate.job.view permission. Datasource exists in Superset.
# @POST Returns column list with metadata and database_dialect.
# @SIDE_EFFECT Queries Superset API for dataset detail and database info.
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [ConfigManager]
@router.get("/datasources/{datasource_id}/columns", response_model=DatasourceColumnsResponse)
async def get_datasource_columns(
datasource_id: int,
@@ -247,11 +224,10 @@ async def get_datasource_columns(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get column metadata and database dialect for a Superset datasource."""
log.reason("Getting datasource columns", payload={
"datasource_id": datasource_id,
"env_id": env_id,
"user": current_user.username,
})
logger.info(
f"[translate_routes][get_datasource_columns] "
f"Datasource: {datasource_id}, Env: {env_id}, User: {current_user.username}"
)
try:
result = fetch_datasource_columns_service(
datasource_id=datasource_id,
@@ -262,7 +238,7 @@ async def get_datasource_columns(
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
log.explore("Get datasource columns failed", error=str(e))
logger.error(f"[translate_routes][get_datasource_columns] Error: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to fetch datasource columns from Superset: {e}",

View File

@@ -1,31 +1,28 @@
# #region TranslateMetricsRoutesModule [C:3] [TYPE Module] [SEMANTICS api,routes,translate,metrics]
# @BRIEF Translation Metrics endpoints providing aggregated stats on runs and corrections.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslationMetrics]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# #region TranslateMetricsRoutesModule [C:2] [TYPE Module] [SEMANTICS api, routes, translate, metrics]
# @BRIEF Translation Metrics endpoints.
# @LAYER: API
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.cot_logger import MarkerLogger
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission
from ....plugins.translate.metrics import TranslationMetrics
from ._router import router
log = MarkerLogger("TranslateMetricsRoutes")
# ============================================================
# Metrics
# ============================================================
# #region get_metrics [C:3] [TYPE Function] [SEMANTICS api,translate,metrics]
# @BRIEF Get translation metrics across all jobs, optionally filtered by job.
# @RELATION DEPENDS_ON -> [TranslationMetrics]
# #region get_metrics [TYPE Function]
# @BRIEF Get translation metrics, optionally filtered by job.
# @PRE: User has translate.metrics.view permission.
# @POST: Returns metrics data.
@router.get("/metrics")
async def get_metrics(
job_id: Optional[str] = Query(None),
@@ -34,7 +31,7 @@ async def get_metrics(
db: Session = Depends(get_db),
):
"""Get translation metrics."""
log.reason("Metrics request", payload={"user": current_user.username})
logger.info(f"[translate_routes][get_metrics] User: {current_user.username}")
try:
metrics_svc = TranslationMetrics(db)
if job_id:
@@ -45,9 +42,10 @@ async def get_metrics(
# #endregion get_metrics
# #region get_job_metrics [C:3] [TYPE Function] [SEMANTICS api,translate,metrics]
# @BRIEF Get aggregated metrics for a specific translation job.
# @RELATION DEPENDS_ON -> [TranslationMetrics]
# #region get_job_metrics [TYPE Function]
# @BRIEF Get aggregated metrics for a specific job.
# @PRE: User has translate.metrics.view permission.
# @POST: Returns metrics dict.
@router.get("/jobs/{job_id}/metrics")
async def get_job_metrics(
job_id: str,
@@ -56,7 +54,7 @@ async def get_job_metrics(
db: Session = Depends(get_db),
):
"""Get aggregated metrics for a specific job."""
log.reason("Job metrics request", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][get_job_metrics] Job: {job_id}, User: {current_user.username}")
try:
metrics_svc = TranslationMetrics(db)
return metrics_svc.get_job_metrics(job_id)

View File

@@ -1,23 +1,14 @@
# #region TranslatePreviewRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,preview]
# #region TranslatePreviewRoutesModule [C:3] [TYPE Module] [SEMANTICS api, routes, translate, preview]
# @BRIEF Translation Preview session management routes.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslationPreview]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE ConfigManager and DB session initialized. User authenticated with translate permissions.
# @POST Preview sessions created, rows reviewed/accepted, records queried.
# @SIDE_EFFECT Creates/updates preview sessions and rows in DB; fetches sample data from Superset; calls LLM provider.
# @LAYER: API
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.cot_logger import MarkerLogger
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
log = MarkerLogger("TranslatePreviewRoutes")
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.preview import TranslationPreview
@@ -35,13 +26,11 @@ from ._router import router
# Preview
# ============================================================
# #region preview_translation [C:4] [TYPE Function] [SEMANTICS api,translate,preview]
# #region preview_translation [TYPE Function]
# @BRIEF Preview a translation before applying it.
# @PRE User has translate.job.execute permission. Job exists.
# @POST Preview session created with sample rows and cost estimation.
# @SIDE_EFFECT Fetches sample data from Superset; calls LLM provider; creates PreviewSession and PreviewRecord rows in DB.
# @RELATION DEPENDS_ON -> [TranslationPreview]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @PRE: User has translate.job.execute permission.
# @POST: Returns a preview session with records and cost estimation.
# @SIDE_EFFECT: Fetches sample data from Superset; calls LLM provider; creates DB rows.
@router.post("/jobs/{job_id}/preview", status_code=status.HTTP_201_CREATED)
async def preview_translation(
job_id: str,
@@ -52,7 +41,7 @@ async def preview_translation(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Preview a translation before applying it."""
log.reason("Preview translation", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][preview_translation] Job: {job_id}, User: {current_user.username}")
if payload is None:
payload = PreviewRequest()
try:
@@ -67,17 +56,15 @@ async def preview_translation(
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
log.explore("Preview translation error", error=str(e))
logger.error(f"[translate_routes][preview_translation] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Preview failed: {e}")
# #endregion preview_translation
# #region update_preview_row [C:4] [TYPE Function] [SEMANTICS api,translate,preview]
# #region update_preview_row [TYPE Function]
# @BRIEF Approve, edit, or reject a preview row.
# @PRE User has translate.job.execute permission. Preview row exists.
# @POST Preview row status updated to APPROVED/REJECTED/EDITED.
# @SIDE_EFFECT Updates PreviewRecord status in DB.
# @RELATION DEPENDS_ON -> [TranslationPreview]
# @PRE: User has translate.job.execute permission.
# @POST: Preview row status is updated.
@router.put("/jobs/{job_id}/preview/rows/{row_key}")
async def update_preview_row(
job_id: str,
@@ -89,7 +76,7 @@ async def update_preview_row(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Approve, edit, or reject a preview row."""
log.reason("Update preview row", payload={"job_id": job_id, "row": row_key, "action": payload.action})
logger.info(f"[translate_routes][update_preview_row] Job: {job_id}, Row: {row_key}, Action: {payload.action}")
try:
preview_service = TranslationPreview(db, config_manager, current_user.username)
result = preview_service.update_preview_row(
@@ -105,12 +92,10 @@ async def update_preview_row(
# #endregion update_preview_row
# #region accept_preview_session [C:4] [TYPE Function] [SEMANTICS api,translate,preview]
# #region accept_preview_session [TYPE Function]
# @BRIEF Accept a preview session, marking it as the quality gate for full execution.
# @PRE User has translate.job.execute permission. Job has an ACTIVE preview session.
# @POST Preview session status set to APPLIED. Full execution can proceed.
# @SIDE_EFFECT Updates PreviewSession status to APPLIED in DB.
# @RELATION DEPENDS_ON -> [TranslationPreview]
# @PRE: User has translate.job.execute permission. Job has an ACTIVE preview session.
# @POST: Preview session is marked as APPLIED; full execution can proceed.
@router.post("/jobs/{job_id}/preview/accept")
async def accept_preview_session(
job_id: str,
@@ -120,7 +105,7 @@ async def accept_preview_session(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Accept a preview session, enabling full translation execution."""
log.reason("Accept preview session", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][accept_preview_session] Job: {job_id}, User: {current_user.username}")
try:
preview_service = TranslationPreview(db, config_manager, current_user.username)
result = preview_service.accept_preview_session(job_id=job_id)
@@ -130,12 +115,10 @@ async def accept_preview_session(
# #endregion accept_preview_session
# #region apply_preview [C:4] [TYPE Function] [SEMANTICS api,translate,preview]
# #region apply_preview [TYPE Function]
# @BRIEF Apply a preview session (alias for accept when accepting at session level).
# @PRE User has translate.job.execute permission. Session exists.
# @POST Preview session applied and marked as quality gate.
# @SIDE_EFFECT Updates PreviewSession status to APPLIED in DB.
# @RELATION DEPENDS_ON -> [TranslationPreview]
# @PRE: User has translate.job.execute permission.
# @POST: Preview is applied.
@router.post("/preview/{session_id}/apply")
async def apply_preview(
session_id: str,
@@ -145,9 +128,9 @@ async def apply_preview(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Apply a preview session by session ID."""
log.reason("Apply preview", payload={"session_id": session_id, "user": current_user.username})
logger.info(f"[translate_routes][apply_preview] Session: {session_id}, User: {current_user.username}")
# Find job_id from session
from ....models.translate import TranslationPreviewSession
from ...models.translate import TranslationPreviewSession
session = db.query(TranslationPreviewSession).filter(TranslationPreviewSession.id == session_id).first()
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Preview session '{session_id}' not found")
@@ -164,10 +147,10 @@ async def apply_preview(
# Preview Records
# ============================================================
# #region get_preview_records [C:3] [TYPE Function] [SEMANTICS api,translate,preview]
# #region get_preview_records [TYPE Function]
# @BRIEF Get records for a preview session.
# @RELATION DEPENDS_ON -> [TranslationPreviewSession]
# @RELATION DEPENDS_ON -> [TranslationPreviewRecord]
# @PRE: User has translate.job.view permission.
# @POST: Returns list of preview records.
@router.get("/preview/{session_id}/records", response_model=List[PreviewRow])
async def get_preview_records(
session_id: str,
@@ -176,9 +159,9 @@ async def get_preview_records(
db: Session = Depends(get_db),
):
"""Get records for a preview session."""
log.reason("Get preview records", payload={"session_id": session_id, "user": current_user.username})
logger.info(f"[translate_routes][get_preview_records] Session: {session_id}, User: {current_user.username}")
try:
from ....models.translate import TranslationPreviewSession, TranslationPreviewRecord
from ...models.translate import TranslationPreviewSession, TranslationPreviewRecord
session = db.query(TranslationPreviewSession).filter(TranslationPreviewSession.id == session_id).first()
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Preview session '{session_id}' not found")
@@ -203,7 +186,7 @@ async def get_preview_records(
except HTTPException:
raise
except Exception as e:
log.explore("Get preview records error", error=str(e))
logger.error(f"[translate_routes][get_preview_records] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion get_preview_records

View File

@@ -1,9 +1,11 @@
# #region TranslateRouterModule [C:1] [TYPE Module] [SEMANTICS api,routes,translate,router]
# @LAYER UI
# #region TranslateRouterModule [C:1] [TYPE Module] [SEMANTICS api, routes, translate, router]
# @BRIEF APIRouter instance for translate routes.
# @LAYER: API
from fastapi import APIRouter
# #region translate_router [C:1] [TYPE Global]
# #region translate_router [TYPE Global]
# @BRIEF APIRouter instance for all translate sub-routes.
router = APIRouter(prefix="/api/translate", tags=["translate"])
# #endregion translate_router

View File

@@ -1,20 +1,14 @@
# #region TranslateRunListRoutesModule [C:3] [TYPE Module] [SEMANTICS api,routes,translate,runs,list,detail]
# #region TranslateRunListRoutesModule [C:3] [TYPE Module] [SEMANTICS api, routes, translate, runs, list, detail]
# @BRIEF Translation Run listing, detail, and CSV download routes (cross-job).
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @RELATION DEPENDS_ON -> [TranslationRun]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @LAYER: API
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.cot_logger import MarkerLogger
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
log = MarkerLogger("TranslateRunListRoutes")
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.orchestrator import TranslationOrchestrator
@@ -27,13 +21,14 @@ from ._router import router
# List Runs (cross-job)
# ============================================================
# #region list_runs [C:3] [TYPE Function] [SEMANTICS api,translate,runs,list]
# #region list_runs [TYPE Function]
# @BRIEF List all runs with cross-job filtering and pagination.
# @RELATION DEPENDS_ON -> [TranslationRun]
# @PRE: User has translate.history.view permission.
# @POST: Returns paginated list of runs.
@router.get("/runs")
async def list_runs(
job_id: Optional[str] = Query(None),
run_status: Optional[str] = Query(None, alias="status"),
run_status: Optional[str] = Query(None),
trigger_type: Optional[str] = Query(None),
created_by: Optional[str] = Query(None),
date_from: Optional[str] = Query(None),
@@ -45,9 +40,9 @@ async def list_runs(
db: Session = Depends(get_db),
):
"""List all runs with cross-job filtering and pagination."""
log.reason("Listing runs", payload={"user": current_user.username})
logger.info(f"[translate_routes][list_runs] User: {current_user.username}")
try:
from ....models.translate import TranslationRun
from ...models.translate import TranslationRun
query = db.query(TranslationRun)
if job_id:
@@ -105,7 +100,7 @@ async def list_runs(
return {"items": items, "total": total, "page": page, "page_size": page_size}
except Exception as e:
log.explore("List runs error", error=str(e))
logger.error(f"[translate_routes][list_runs] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion list_runs
@@ -114,11 +109,10 @@ async def list_runs(
# Run Detail
# ============================================================
# #region get_run_detail [C:3] [TYPE Function] [SEMANTICS api,translate,runs,detail]
# #region get_run_detail [TYPE Function]
# @BRIEF Get detailed run info with config_snapshot, records, events.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @RELATION DEPENDS_ON -> [TranslationEventLog]
# @RELATION DEPENDS_ON -> [TranslationRun]
# @PRE: User has translate.history.view permission.
# @POST: Returns run detail with records and events.
@router.get("/runs/{run_id}/detail")
async def get_run_detail(
run_id: str,
@@ -128,7 +122,7 @@ async def get_run_detail(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get detailed run info with config_snapshot, records, events."""
log.reason("Get run detail", payload={"run_id": run_id, "user": current_user.username})
logger.info(f"[translate_routes][get_run_detail] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
status_info = orch.get_run_status(run_id)
@@ -137,7 +131,7 @@ async def get_run_detail(
event_summary = TranslationEventLog(db).get_run_event_summary(run_id)
# Get config snapshot from run
from ....models.translate import TranslationRun
from ...models.translate import TranslationRun
run = db.query(TranslationRun).filter(TranslationRun.id == run_id).first()
config_snapshot = run.config_snapshot if run else None
@@ -158,9 +152,10 @@ async def get_run_detail(
# Download Skipped CSV
# ============================================================
# #region download_skipped_csv [C:3] [TYPE Function] [SEMANTICS api,translate,runs,csv]
# #region download_skipped_csv [TYPE Function]
# @BRIEF Download a CSV of skipped translation records from a run.
# @RELATION DEPENDS_ON -> [TranslationRecord]
# @PRE: User has translate.job.view permission.
# @POST: Returns CSV file of skipped records.
@router.get("/runs/{run_id}/skipped.csv")
async def download_skipped_csv(
run_id: str,
@@ -169,9 +164,9 @@ async def download_skipped_csv(
db: Session = Depends(get_db),
):
"""Download skipped translation records as CSV."""
log.reason("Download skipped CSV", payload={"run_id": run_id, "user": current_user.username})
logger.info(f"[translate_routes][download_skipped_csv] Run: {run_id}, User: {current_user.username}")
try:
from ....models.translate import TranslationRecord
from ...models.translate import TranslationRecord
from fastapi.responses import StreamingResponse
import csv
import io
@@ -202,7 +197,7 @@ async def download_skipped_csv(
headers={"Content-Disposition": f"attachment; filename=skipped_{run_id}.csv"},
)
except Exception as e:
log.explore("Download skipped CSV error", error=str(e))
logger.error(f"[translate_routes][download_skipped_csv] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion download_skipped_csv

View File

@@ -1,28 +1,18 @@
# #region TranslateRunRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,runs]
# #region TranslateRunRoutesModule [C:3] [TYPE Module] [SEMANTICS api, routes, translate, runs]
# @BRIEF Translation Run execution, history, status, records and batches routes.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE ConfigManager and DB session initialized. User authenticated with translate permissions.
# @POST Translation run executed, manipulated, or queried. Results returned to caller.
# @SIDE_EFFECT Creates/updates TranslationRun records in DB; spawns background threads for execution; queries Superset API.
# @LAYER: API
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db, SessionLocal
from ....core.cot_logger import MarkerLogger
from ....core.database import get_db
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
log = MarkerLogger("TranslateRunRoutes")
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.orchestrator import TranslationOrchestrator
from ....plugins.translate.events import TranslationEventLog
from ....models.translate import TranslationRun
from ._router import router
from ._helpers import _run_to_response
@@ -32,64 +22,43 @@ from ._helpers import _run_to_response
# Translation Run / Execute
# ============================================================
# #region run_translation [C:4] [TYPE Function] [SEMANTICS api,translate,runs]
# #region run_translation [TYPE Function]
# @BRIEF Execute a translation job (trigger a run).
# @PRE User has translate.job.execute permission. Job exists and preview is accepted.
# @POST Translation run created and started in background. Run object returned.
# @SIDE_EFFECT Creates TranslationRun record in DB; spawns background thread for translation execution.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @PRE: User has translate.job.execute permission.
# @POST: Returns the created translation run.
@router.post("/jobs/{job_id}/run", status_code=status.HTTP_201_CREATED)
async def run_translation(
job_id: str,
full_translation: bool = Query(False, description="If True, fetch ALL rows from Superset dataset instead of preview-only rows"),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Execute a translation job (trigger a run).
By default runs translation on preview-approved rows only.
Set full_translation=true to fetch ALL rows from the Superset source dataset.
"""
log.reason("Run translation", payload={"job_id": job_id, "user": current_user.username, "full_translation": full_translation})
"""Execute a translation job (trigger a run)."""
logger.info(f"[translate_routes][run_translation] Job: {job_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
run = orch.start_run(job_id=job_id, is_scheduled=False)
# Execute asynchronously in background with a FRESH session.
# The request-scoped session from Depends(get_db) is closed after the handler returns,
# so the background thread must create its own session to avoid "This transaction is closed".
# Execute asynchronously in background
import threading
def _execute_background():
thread_db = SessionLocal()
try:
thread_orch = TranslationOrchestrator(thread_db, config_manager, current_user.username)
# Reload run from DB with the new session (the passed run object is detached)
thread_run = thread_db.query(TranslationRun).filter(TranslationRun.id == run.id).first()
if thread_run:
thread_orch.execute_run(thread_run, full_translation=full_translation)
except Exception as e:
log.explore("Background execution error", error=str(e))
finally:
thread_db.close()
threading.Thread(target=_execute_background, daemon=True).start()
threading.Thread(
target=orch.execute_run,
args=(run,),
daemon=True,
).start()
return _run_to_response(run)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
log.explore("Run translation error", error=str(e))
logger.error(f"[translate_routes][run_translation] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Run failed: {e}")
# #endregion run_translation
# #region retry_run [C:4] [TYPE Function] [SEMANTICS api,translate,runs]
# #region retry_run [TYPE Function]
# @BRIEF Retry failed batches in a translation run.
# @PRE User has translate.job.execute permission. Run exists and has failed batches.
# @POST Failed batches re-executed. Updated run returned.
# @SIDE_EFFECT Updates TranslationRun and TranslationBatch records in DB.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @PRE: User has translate.job.execute permission.
# @POST: Returns the updated translation run.
@router.post("/runs/{run_id}/retry")
async def retry_run(
run_id: str,
@@ -99,7 +68,7 @@ async def retry_run(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Retry failed batches in a translation run."""
log.reason("Retry run", payload={"run_id": run_id, "user": current_user.username})
logger.info(f"[translate_routes][retry_run] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
run = orch.retry_failed_batches(run_id)
@@ -107,17 +76,15 @@ async def retry_run(
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
log.explore("Retry run error", error=str(e))
logger.error(f"[translate_routes][retry_run] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Retry failed: {e}")
# #endregion retry_run
# #region retry_insert [C:4] [TYPE Function] [SEMANTICS api,translate,runs]
# #region retry_insert [TYPE Function]
# @BRIEF Retry the SQL insert phase for a completed run.
# @PRE User has translate.job.execute permission. Run completed with insert_status FAILED.
# @POST SQL insert phase re-executed. Updated run returned.
# @SIDE_EFFECT Re-executes SQL insert into Superset; updates TranslationRun insert_status.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @PRE: User has translate.job.execute permission.
# @POST: Returns the updated run.
@router.post("/runs/{run_id}/retry-insert")
async def retry_insert(
run_id: str,
@@ -127,7 +94,7 @@ async def retry_insert(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Retry the SQL insert phase for a completed run."""
log.reason("Retry insert", payload={"run_id": run_id, "user": current_user.username})
logger.info(f"[translate_routes][retry_insert] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
run = orch.retry_insert(run_id)
@@ -135,17 +102,15 @@ async def retry_insert(
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
log.explore("Retry insert error", error=str(e))
logger.error(f"[translate_routes][retry_insert] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Retry insert failed: {e}")
# #endregion retry_insert
# #region cancel_run [C:4] [TYPE Function] [SEMANTICS api,translate,runs]
# #region cancel_run [TYPE Function]
# @BRIEF Cancel a running translation.
# @PRE User has translate.job.execute permission. Run is in RUNNING state.
# @POST Run is cancelled. Updated run returned.
# @SIDE_EFFECT Updates TranslationRun status to CANCELLED in DB.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @PRE: User has translate.job.execute permission.
# @POST: Run is cancelled.
@router.post("/runs/{run_id}/cancel")
async def cancel_run(
run_id: str,
@@ -155,7 +120,7 @@ async def cancel_run(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Cancel a running translation."""
log.reason("Cancel run", payload={"run_id": run_id, "user": current_user.username})
logger.info(f"[translate_routes][cancel_run] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
run = orch.cancel_run(run_id)
@@ -165,9 +130,10 @@ async def cancel_run(
# #endregion cancel_run
# #region get_run_history [C:3] [TYPE Function] [SEMANTICS api,translate,runs]
# #region get_run_history [TYPE Function]
# @BRIEF Get run history for a translation job.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @PRE: User has translate.history.view permission.
# @POST: Returns list of runs.
@router.get("/jobs/{job_id}/runs")
async def get_run_history(
job_id: str,
@@ -179,7 +145,7 @@ async def get_run_history(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get run history for a translation job."""
log.reason("Get run history", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][get_run_history] Job: {job_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
total, runs = orch.get_run_history(job_id, page=page, page_size=page_size)
@@ -189,9 +155,10 @@ async def get_run_history(
# #endregion get_run_history
# #region get_run_status [C:3] [TYPE Function] [SEMANTICS api,translate,runs]
# #region get_run_status [TYPE Function]
# @BRIEF Get status and statistics for a translation run.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @PRE: User has translate.history.view permission.
# @POST: Returns run details with statistics.
@router.get("/runs/{run_id}")
async def get_run_status(
run_id: str,
@@ -201,7 +168,7 @@ async def get_run_status(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get status and statistics for a translation run."""
log.reason("Get run status", payload={"run_id": run_id, "user": current_user.username})
logger.info(f"[translate_routes][get_run_status] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
return orch.get_run_status(run_id)
@@ -210,9 +177,10 @@ async def get_run_status(
# #endregion get_run_status
# #region get_run_records [C:3] [TYPE Function] [SEMANTICS api,translate,runs]
# #region get_run_records [TYPE Function]
# @BRIEF Get paginated records for a translation run.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @PRE: User has translate.history.view permission.
# @POST: Returns paginated records.
@router.get("/runs/{run_id}/records")
async def get_run_records(
run_id: str,
@@ -225,7 +193,7 @@ async def get_run_records(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get paginated records for a translation run."""
log.reason("Get run records", payload={"run_id": run_id, "user": current_user.username})
logger.info(f"[translate_routes][get_run_records] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
return orch.get_run_records(run_id, page=page, page_size=page_size, status_filter=status)
@@ -238,9 +206,10 @@ async def get_run_records(
# Batches
# ============================================================
# #region get_batches [C:3] [TYPE Function] [SEMANTICS api,translate,runs]
# #region get_batches [TYPE Function]
# @BRIEF Get batches for a translation run.
# @RELATION DEPENDS_ON -> [TranslationBatch]
# @PRE: User has translate.job.view permission.
# @POST: Returns list of batches.
@router.get("/runs/{run_id}/batches")
async def get_batches(
run_id: str,
@@ -249,9 +218,9 @@ async def get_batches(
db: Session = Depends(get_db),
):
"""Get batches for a translation run."""
log.reason("Get batches", payload={"run_id": run_id, "user": current_user.username})
logger.info(f"[translate_routes][get_batches] Run: {run_id}, User: {current_user.username}")
try:
from ....models.translate import TranslationBatch
from ...models.translate import TranslationBatch
batches = (
db.query(TranslationBatch)
.filter(TranslationBatch.run_id == run_id)
@@ -274,7 +243,7 @@ async def get_batches(
for b in batches
]
except Exception as e:
log.explore("Get batches error", error=str(e))
logger.error(f"[translate_routes][get_batches] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion get_batches

View File

@@ -1,23 +1,14 @@
# #region TranslateScheduleRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,schedule]
# #region TranslateScheduleRoutesModule [C:3] [TYPE Module] [SEMANTICS api, routes, translate, schedule]
# @BRIEF Translation Schedule management routes.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslationScheduler]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE ConfigManager and DB session initialized. User authenticated with translate.schedule permissions.
# @POST Schedule CRUD operations completed. APScheduler jobs registered/unregistered.
# @SIDE_EFFECT Creates/updates/deletes TranslationSchedule records in DB; registers/removes jobs in APScheduler.
# @LAYER: API
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.cot_logger import MarkerLogger
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
log = MarkerLogger("TranslateScheduleRoutes")
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.scheduler import TranslationScheduler
@@ -30,9 +21,10 @@ from ._router import router
# Schedule
# ============================================================
# #region get_schedule [C:3] [TYPE Function] [SEMANTICS api,translate,schedule]
# @BRIEF Get the schedule configuration for a translation job.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
# #region get_schedule [TYPE Function]
# @BRIEF Get the schedule for a translation job.
# @PRE: User has translate.schedule.view permission.
# @POST: Returns the schedule configuration.
@router.get("/jobs/{job_id}/schedule")
async def get_schedule(
job_id: str,
@@ -42,7 +34,7 @@ async def get_schedule(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get schedule for a translation job."""
log.reason("Get schedule", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][get_schedule] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
schedule = scheduler.get_schedule(job_id)
@@ -63,12 +55,10 @@ async def get_schedule(
# #endregion get_schedule
# #region set_schedule [C:4] [TYPE Function] [SEMANTICS api,translate,schedule]
# #region set_schedule [TYPE Function]
# @BRIEF Set or update the schedule for a translation job.
# @PRE User has translate.schedule.manage permission. Job exists.
# @POST Schedule created or updated. APScheduler job registered.
# @SIDE_EFFECT Creates/updates TranslationSchedule record in DB; registers job in APScheduler.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is created or updated.
@router.put("/jobs/{job_id}/schedule")
async def set_schedule(
job_id: str,
@@ -79,11 +69,11 @@ async def set_schedule(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Set or update schedule for a translation job."""
log.reason("Set schedule", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][set_schedule] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
# Check if schedule already exists
from ....models.translate import TranslationSchedule
from ...models.translate import TranslationSchedule
existing = db.query(TranslationSchedule).filter(
TranslationSchedule.job_id == job_id
).first()
@@ -102,7 +92,7 @@ async def set_schedule(
is_active=payload.is_active,
)
# Register with APScheduler via SchedulerService
from ....dependencies import get_scheduler_service
from ...dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.add_translation_job(
schedule_id=schedule.id,
@@ -126,12 +116,10 @@ async def set_schedule(
# #endregion set_schedule
# #region enable_schedule [C:4] [TYPE Function] [SEMANTICS api,translate,schedule]
# #region enable_schedule [TYPE Function]
# @BRIEF Enable a schedule for a translation job.
# @PRE User has translate.schedule.manage permission. Schedule exists.
# @POST Schedule is enabled. APScheduler job registered.
# @SIDE_EFFECT Updates TranslationSchedule is_active to True in DB; registers job in APScheduler.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is enabled.
@router.post("/jobs/{job_id}/schedule/enable")
async def enable_schedule(
job_id: str,
@@ -141,11 +129,11 @@ async def enable_schedule(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Enable a schedule for a translation job."""
log.reason("Enable schedule", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][enable_schedule] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
schedule = scheduler.set_schedule_active(job_id, is_active=True)
from ....dependencies import get_scheduler_service
from ...dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.add_translation_job(
schedule_id=schedule.id,
@@ -159,12 +147,10 @@ async def enable_schedule(
# #endregion enable_schedule
# #region disable_schedule [C:4] [TYPE Function] [SEMANTICS api,translate,schedule]
# #region disable_schedule [TYPE Function]
# @BRIEF Disable a schedule for a translation job.
# @PRE User has translate.schedule.manage permission. Schedule exists.
# @POST Schedule is disabled. APScheduler job removed.
# @SIDE_EFFECT Updates TranslationSchedule is_active to False in DB; removes job from APScheduler.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is disabled.
@router.post("/jobs/{job_id}/schedule/disable")
async def disable_schedule(
job_id: str,
@@ -174,11 +160,11 @@ async def disable_schedule(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Disable a schedule for a translation job."""
log.reason("Disable schedule", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][disable_schedule] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
scheduler.set_schedule_active(job_id, is_active=False)
from ....dependencies import get_scheduler_service
from ...dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.remove_translation_job(schedule_id=job_id)
return {"status": "disabled", "job_id": job_id}
@@ -187,12 +173,10 @@ async def disable_schedule(
# #endregion disable_schedule
# #region delete_schedule [C:4] [TYPE Function] [SEMANTICS api,translate,schedule]
# #region delete_schedule [TYPE Function]
# @BRIEF Delete the schedule for a translation job.
# @PRE User has translate.schedule.manage permission. Schedule exists.
# @POST Schedule is removed. APScheduler job removed.
# @SIDE_EFFECT Deletes TranslationSchedule record from DB; removes job from APScheduler.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is removed.
@router.delete("/jobs/{job_id}/schedule", status_code=status.HTTP_204_NO_CONTENT)
async def delete_schedule(
job_id: str,
@@ -202,11 +186,11 @@ async def delete_schedule(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Delete schedule for a translation job."""
log.reason(f"Delete schedule", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][delete_schedule] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
scheduler.delete_schedule(job_id)
from ....dependencies import get_scheduler_service
from ...dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.remove_translation_job(schedule_id=job_id)
return None
@@ -215,9 +199,10 @@ async def delete_schedule(
# #endregion delete_schedule
# #region get_next_executions [C:3] [TYPE Function] [SEMANTICS api,translate,schedule]
# @BRIEF Preview next N execution times for a job's schedule.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
# #region get_next_executions [TYPE Function]
# @BRIEF Preview next N executions for a job's schedule.
# @PRE: User has translate.schedule.view permission.
# @POST: Returns next execution times.
@router.get("/jobs/{job_id}/schedule/next-executions")
async def get_next_executions(
job_id: str,
@@ -228,7 +213,7 @@ async def get_next_executions(
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Preview next N executions for a job's schedule."""
log.reason(f"Get next executions", payload={"job_id": job_id, "user": current_user.username})
logger.info(f"[translate_routes][get_next_executions] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
schedule = scheduler.get_schedule(job_id)

View File

@@ -1,14 +1,14 @@
# #region AppModule [C:5] [TYPE Module] [SEMANTICS app, main, entrypoint, fastapi]
# @BRIEF The main entry point for the FastAPI application. It initializes the app, configures CORS, sets up dependencies, includes API routers, and defines the WebSocket endpoint for log streaming.
# @LAYER UI (API)
# @PRE Python environment and dependencies installed; configuration database available.
# @POST FastAPI app instance is created, middleware configured, and routes registered.
# @SIDE_EFFECT Starts background scheduler and binds network ports for HTTP/WS traffic.
# @DATA_CONTRACT [HTTP Request | WS Message] -> [HTTP Response | JSON Log Stream]
# @INVARIANT Only one FastAPI app instance exists per process.
# @INVARIANT All WebSocket connections must be properly cleaned up on disconnect.
# @LAYER: UI (API)
# @RELATION DEPENDS_ON -> [AppDependencies]
# @RELATION DEPENDS_ON -> [ApiRoutesModule]
# @INVARIANT: Only one FastAPI app instance exists per process.
# @INVARIANT: All WebSocket connections must be properly cleaned up on disconnect.
# @PRE: Python environment and dependencies installed; configuration database available.
# @POST: FastAPI app instance is created, middleware configured, and routes registered.
# @SIDE_EFFECT: Starts background scheduler and binds network ports for HTTP/WS traffic.
# @DATA_CONTRACT: [HTTP Request | WS Message] -> [HTTP Response | JSON Log Stream]
import os
from pathlib import Path
@@ -27,9 +27,6 @@ from .dependencies import get_task_manager, get_scheduler_service
from .core.encryption_key import ensure_encryption_key
from .core.utils.network import NetworkError
from .core.logger import logger, belief_scope
from .core.cot_logger import MarkerLogger
startup_log = MarkerLogger("Startup")
from .core.database import AuthSessionLocal
from .core.auth.security import get_password_hash
from .models.auth import User, Role
@@ -72,7 +69,7 @@ app = FastAPI(
# #region ensure_initial_admin_user [C:3] [TYPE Function]
# @BRIEF Ensures initial admin user exists when bootstrap env flags are enabled.
# @RELATION DEPENDS_ON -> [AuthRepository]
# @RELATION DEPENDS_ON -> AuthRepository
def ensure_initial_admin_user() -> None:
raw_flag = os.getenv("INITIAL_ADMIN_CREATE", "false").strip().lower()
if raw_flag not in {"1", "true", "yes", "on"}:
@@ -127,17 +124,16 @@ def ensure_initial_admin_user() -> None:
# #region startup_event [C:3] [TYPE Function]
# @BRIEF Handles application startup tasks, such as starting the scheduler.
# @PRE None.
# @POST Scheduler is started.
# @RELATION CALLS -> [AppDependencies]
# @PRE: None.
# @POST: Scheduler is started.
# Startup event
@app.on_event("startup")
async def startup_event():
startup_log.reason("Enter startup event")
ensure_encryption_key()
ensure_initial_admin_user()
scheduler = get_scheduler_service()
startup_log.reflect("Application startup completed")
with belief_scope("startup_event"):
ensure_encryption_key()
ensure_initial_admin_user()
scheduler = get_scheduler_service()
scheduler.start()
@@ -146,9 +142,9 @@ async def startup_event():
# #region shutdown_event [C:3] [TYPE Function]
# @BRIEF Handles application shutdown tasks, such as stopping the scheduler.
# @PRE None.
# @POST Scheduler is stopped.
# @RELATION CALLS -> [AppDependencies]
# @PRE: None.
# @POST: Scheduler is stopped.
# Shutdown event
@app.on_event("shutdown")
async def shutdown_event():
@@ -160,7 +156,7 @@ async def shutdown_event():
# #endregion shutdown_event
# #region app_middleware [TYPE Block]
# @BRIEF Configure application-wide middleware (Session, CORS, TraceContext).
# @BRIEF Configure application-wide middleware (Session, CORS).
# Configure Session Middleware (required by Authlib for OAuth2 flow)
from .core.auth.config import auth_config
@@ -174,27 +170,17 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
# Configure Trace Context Middleware (seeds trace_id per request)
from .core.middleware.trace import TraceContextMiddleware
app.add_middleware(TraceContextMiddleware)
# #endregion app_middleware
# #region network_error_handler [C:1] [TYPE Function]
# @BRIEF Global exception handler for NetworkError.
# @PRE request is a FastAPI Request object.
# @POST Returns 503 HTTP Exception.
# @PARAM: request (Request) - The incoming request object.
# @PARAM: exc (NetworkError) - The exception instance.
eh_log = MarkerLogger("ExceptionHandler")
# @PRE: request is a FastAPI Request object.
# @POST: Returns 503 HTTP Exception.
@app.exception_handler(NetworkError)
async def network_error_handler(request: Request, exc: NetworkError):
with belief_scope("network_error_handler"):
eh_log.explore("Unhandled NetworkError in request", error=str(exc), payload={"url": str(request.url)})
logger.error(f"Network error: {exc}")
return HTTPException(
status_code=503,
detail="Environment unavailable. Please check if the Superset instance is running.",
@@ -206,11 +192,9 @@ async def network_error_handler(request: Request, exc: NetworkError):
# #region log_requests [C:3] [TYPE Function]
# @BRIEF Middleware to log incoming HTTP requests and their response status.
# @PRE request is a FastAPI Request object.
# @POST Logs request and response details.
# @RELATION DEPENDS_ON -> [LoggerModule]
# @PARAM: request (Request) - The incoming request object.
# @PARAM: call_next (Callable) - The next middleware or route handler.
# @PRE: request is a FastAPI Request object.
# @POST: Logs request and response details.
@app.middleware("http")
async def log_requests(request: Request, call_next):
with belief_scope("log_requests"):
@@ -280,19 +264,19 @@ app.include_router(translate.router)
# #region api.include_routers [C:1] [TYPE Action] [SEMANTICS routes, registration, api]
# @BRIEF Registers all API routers with the FastAPI application.
# @LAYER API
# @LAYER: API
# #endregion api.include_routers
# #region websocket_endpoint [C:5] [TYPE Function]
# @BRIEF Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering.
# @PRE task_id must be a valid task ID.
# @POST WebSocket connection is managed and logs are streamed until disconnect.
# @SIDE_EFFECT Subscribes to TaskManager log queue and broadcasts messages over network.
# @DATA_CONTRACT [task_id: str, source: str, level: str] -> [JSON log entry objects]
# @INVARIANT Every accepted WebSocket subscription is unsubscribed exactly once even when streaming fails or the client disconnects.
# @RELATION CALLS -> [TaskManagerPackage]
# @RELATION DEPENDS_ON -> [LoggerModule]
# @PRE: task_id must be a valid task ID.
# @POST: WebSocket connection is managed and logs are streamed until disconnect.
# @SIDE_EFFECT: Subscribes to TaskManager log queue and broadcasts messages over network.
# @DATA_CONTRACT: [task_id: str, source: str, level: str] -> [JSON log entry objects]
# @INVARIANT: Every accepted WebSocket subscription is unsubscribed exactly once even when streaming fails or the client disconnects.
# @UX_STATE: Connecting -> Streaming -> (Disconnected)
#
# @TEST_CONTRACT: WebSocketLogStreamApi ->
@@ -454,10 +438,11 @@ if frontend_path.exists():
"/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static"
)
# #region serve_spa [C:1] [TYPE Function]
# @BRIEF Serves the SPA frontend for any path not matched by API routes.
# @PRE frontend_path exists.
# @POST Returns the requested file or index.html.
# [DEF:serve_spa:Function]
# @COMPLEXITY: 1
# @PURPOSE: Serves the SPA frontend for any path not matched by API routes.
# @PRE: frontend_path exists.
# @POST: Returns the requested file or index.html.
@app.get("/{file_path:path}", include_in_schema=False)
async def serve_spa(file_path: str):
with belief_scope("serve_spa"):
@@ -479,12 +464,13 @@ if frontend_path.exists():
return FileResponse(str(full_path))
return FileResponse(str(frontend_path / "index.html"))
# #endregion serve_spa
# [/DEF:serve_spa:Function]
else:
# #region read_root [C:1] [TYPE Function]
# @BRIEF A simple root endpoint to confirm that the API is running when frontend is missing.
# @PRE None.
# @POST Returns a JSON message indicating API status.
# [DEF:read_root:Function]
# @COMPLEXITY: 1
# @PURPOSE: A simple root endpoint to confirm that the API is running when frontend is missing.
# @PRE: None.
# @POST: Returns a JSON message indicating API status.
@app.get("/")
async def read_root():
with belief_scope("read_root"):
@@ -492,5 +478,6 @@ else:
"message": "Superset Tools API is running (Frontend build not found)"
}
# #endregion read_root
# [/DEF:read_root:Function]
# #endregion StaticFiles
# #endregion AppModule

View File

@@ -1,7 +1,9 @@
# #region TestConfigManagerCompat [C:3] [TYPE Module] [SEMANTICS config-manager, compatibility, payload, tests]
# @BRIEF Verifies ConfigManager compatibility wrappers preserve legacy payload sections.
# @LAYER Domain
# @RELATION VERIFIES -> [ConfigManager]
# [DEF:TestConfigManagerCompat:Module]
# @COMPLEXITY: 3
# @SEMANTICS: config-manager, compatibility, payload, tests
# @PURPOSE: Verifies ConfigManager compatibility wrappers preserve legacy payload sections.
# @LAYER: Domain
# @RELATION: VERIFIES -> ConfigManager
from types import SimpleNamespace
@@ -9,9 +11,9 @@ from src.core.config_manager import ConfigManager
from src.core.config_models import AppConfig, Environment, GlobalSettings
# #region test_get_payload_preserves_legacy_sections [TYPE Function]
# @BRIEF Ensure get_payload merges typed config into raw payload without dropping legacy sections.
# @RELATION BINDS_TO -> [TestConfigManagerCompat]
# [DEF:test_get_payload_preserves_legacy_sections:Function]
# @RELATION: BINDS_TO -> TestConfigManagerCompat
# @PURPOSE: Ensure get_payload merges typed config into raw payload without dropping legacy sections.
def test_get_payload_preserves_legacy_sections():
manager = ConfigManager.__new__(ConfigManager)
manager.raw_payload = {"notifications": {"smtp": {"host": "mail.local"}}}
@@ -23,7 +25,7 @@ def test_get_payload_preserves_legacy_sections():
assert payload["notifications"]["smtp"]["host"] == "mail.local"
# #endregion test_get_payload_preserves_legacy_sections
# [/DEF:test_get_payload_preserves_legacy_sections:Function]
# [DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function]
@@ -54,9 +56,13 @@ def test_save_config_accepts_raw_payload_and_keeps_extras(monkeypatch):
assert persisted["payload"]["notifications"]["telegram"]["bot_token"] == "secret"
# #region test_save_config_syncs_environment_records_for_fk_backed_flows [TYPE Function]
# @BRIEF Ensure saving config mirrors typed environments into relational records required by FK-backed session persistence.
# @RELATION BINDS_TO -> [TestConfigManagerCompat]
# [/DEF:test_save_config_accepts_raw_payload_and_keeps_extras:Function]
# [DEF:test_save_config_syncs_environment_records_for_fk_backed_flows:Function]
# @RELATION: BINDS_TO -> TestConfigManagerCompat
# @PURPOSE: Ensure saving config mirrors typed environments into relational records required by FK-backed session persistence.
# Deletion of stale records is tested separately in test_save_config_syncs_deletions_to_persistence.
def test_save_config_syncs_environment_records_for_fk_backed_flows():
manager = ConfigManager.__new__(ConfigManager)
manager.raw_payload = {}
@@ -71,20 +77,22 @@ def test_save_config_syncs_environment_records_for_fk_backed_flows():
credentials_id="legacy-user",
)
# #region _FakeQuery [C:1] [TYPE Class]
# @BRIEF Minimal query stub returning hardcoded existing environment record list for sync tests.
# @INVARIANT all() always returns [existing_record]; no parameterization or filtering.
# @RELATION BINDS_TO -> [test_save_config_syncs_environment_records_for_fk_backed_flows]
# [DEF:_FakeQuery:Class]
# @RELATION: BINDS_TO -> [test_save_config_syncs_environment_records_for_fk_backed_flows]
# @COMPLEXITY: 1
# @PURPOSE: Minimal query stub returning hardcoded existing environment record list for sync tests.
# @INVARIANT: all() always returns [existing_record]; no parameterization or filtering.
class _FakeQuery:
def all(self):
return [existing_record]
# #endregion _FakeQuery
# [/DEF:_FakeQuery:Class]
# #region _FakeSession [C:1] [TYPE Class]
# @BRIEF Minimal SQLAlchemy session stub that captures add/delete calls for environment sync assertions.
# @INVARIANT query() always returns _FakeQuery; no real DB interaction.
# @RELATION BINDS_TO -> [test_save_config_syncs_environment_records_for_fk_backed_flows]
# [DEF:_FakeSession:Class]
# @RELATION: BINDS_TO -> [test_save_config_syncs_environment_records_for_fk_backed_flows]
# @COMPLEXITY: 1
# @PURPOSE: Minimal SQLAlchemy session stub that captures add/delete calls for environment sync assertions.
# @INVARIANT: query() always returns _FakeQuery; no real DB interaction.
class _FakeSession:
def query(self, model):
return _FakeQuery()
@@ -95,7 +103,7 @@ def test_save_config_syncs_environment_records_for_fk_backed_flows():
def delete(self, value):
deleted_records.append(value)
# #endregion _FakeSession
# [/DEF:_FakeSession:Class]
session = _FakeSession()
config = AppConfig(
@@ -118,13 +126,91 @@ def test_save_config_syncs_environment_records_for_fk_backed_flows():
assert added_records[0].name == "DEV"
assert added_records[0].url == "http://superset.local"
assert added_records[0].credentials_id == "demo"
assert deleted_records == [existing_record]
# Sync does NOT delete; deletion is in _delete_stale_environment_records
assert len(deleted_records) == 0
# #endregion test_save_config_syncs_environment_records_for_fk_backed_flows
# #region test_load_config_syncs_environment_records_from_existing_db_payload [TYPE Function]
# @BRIEF Ensure loading an existing DB-backed config also mirrors environment rows required by FK-backed runtime flows.
# @RELATION BINDS_TO -> [TestConfigManagerCompat]
# [/DEF:test_save_config_syncs_environment_records_for_fk_backed_flows:Function]
# [DEF:test_save_config_syncs_deletions_to_persistence:Function]
# @RELATION: BINDS_TO -> TestConfigManagerCompat
# @PURPOSE: Ensure stale environment records are deleted during save (in _delete_stale_environment_records)
# and NOT during _load_config → _sync_environment_records (which would violate FK constraints
# from task_records, database_mappings, etc.).
def test_save_config_syncs_deletions_to_persistence():
manager = ConfigManager.__new__(ConfigManager)
manager.raw_payload = {}
manager.config = AppConfig(environments=[], settings=GlobalSettings())
added_records = []
deleted_records = []
existing_record = SimpleNamespace(
id="legacy-env",
name="Legacy",
url="http://legacy.local",
credentials_id="legacy-user",
)
# [DEF:_FakeQueryDel:Class]
# @RELATION: BINDS_TO -> [test_save_config_syncs_deletions_to_persistence]
# @COMPLEXITY: 1
# @PURPOSE: Minimal query stub for deletion test.
class _FakeQuery:
def all(self):
return [existing_record]
# [/DEF:_FakeQueryDel:Class]
# [DEF:_FakeSessionDel:Class]
# @RELATION: BINDS_TO -> [test_save_config_syncs_deletions_to_persistence]
# @COMPLEXITY: 1
# @PURPOSE: Minimal session stub that captures add/delete for deletion assertions.
class _FakeSession:
def query(self, model):
return _FakeQuery()
def add(self, value):
added_records.append(value)
def delete(self, value):
deleted_records.append(value)
# [/DEF:_FakeSessionDel:Class]
session = _FakeSession()
config = AppConfig(
environments=[
Environment(
id="dev",
name="DEV",
url="http://superset.local",
username="demo",
password="secret",
)
],
settings=GlobalSettings(),
)
# Step 1: sync creates/updates (no deletion)
manager._sync_environment_records(session, config)
assert len(added_records) == 1
assert added_records[0].id == "dev"
assert len(deleted_records) == 0
# Step 2: stale deletion removes envs not in the configured set
manager._delete_stale_environment_records(session, config)
assert len(deleted_records) == 1
assert deleted_records[0] == existing_record
assert deleted_records[0].id == "legacy-env"
# [/DEF:test_save_config_syncs_deletions_to_persistence:Function]
# [DEF:test_load_config_syncs_environment_records_from_existing_db_payload:Function]
# @RELATION: BINDS_TO -> TestConfigManagerCompat
# @PURPOSE: Ensure loading an existing DB-backed config also mirrors environment rows required by FK-backed runtime flows.
def test_load_config_syncs_environment_records_from_existing_db_payload(monkeypatch):
manager = ConfigManager.__new__(ConfigManager)
manager.config_path = None
@@ -135,10 +221,11 @@ def test_load_config_syncs_environment_records_from_existing_db_payload(monkeypa
closed = {"value": False}
committed = {"value": False}
# #region _FakeSession [C:1] [TYPE Class]
# @BRIEF Minimal session stub tracking commit/close signals for config load lifecycle assertions.
# @INVARIANT No query or add semantics; only lifecycle signal tracking.
# @RELATION BINDS_TO -> [test_load_config_syncs_environment_records_from_existing_db_payload]
# [DEF:_FakeSession:Class]
# @RELATION: BINDS_TO -> [test_load_config_syncs_environment_records_from_existing_db_payload]
# @COMPLEXITY: 1
# @PURPOSE: Minimal session stub tracking commit/close signals for config load lifecycle assertions.
# @INVARIANT: No query or add semantics; only lifecycle signal tracking.
class _FakeSession:
def commit(self):
committed["value"] = True
@@ -146,7 +233,7 @@ def test_load_config_syncs_environment_records_from_existing_db_payload(monkeypa
def close(self):
closed["value"] = True
# #endregion _FakeSession
# [/DEF:_FakeSession:Class]
fake_session = _FakeSession()
fake_record = SimpleNamespace(
@@ -183,5 +270,6 @@ def test_load_config_syncs_environment_records_from_existing_db_payload(monkeypa
assert closed["value"] is True
# #endregion test_load_config_syncs_environment_records_from_existing_db_payload
# #endregion TestConfigManagerCompat
# [/DEF:test_load_config_syncs_environment_records_from_existing_db_payload:Function]
# [/DEF:TestConfigManagerCompat:Module]

View File

@@ -1,9 +1,11 @@
# #region NativeFilterExtractionTests [C:3] [TYPE Module] [SEMANTICS tests, superset, native, filters, permalink, filter_state]
# @BRIEF Verify native filter extraction from permalinks and native_filters_key URLs.
# @LAYER Domain
# @RELATION BINDS_TO -> [SupersetClient]
# @RELATION BINDS_TO -> [AsyncSupersetClient]
# @RELATION BINDS_TO -> [FilterState, ParsedNativeFilters, ExtraFormDataMerge]
# [DEF:NativeFilterExtractionTests:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, superset, native, filters, permalink, filter_state
# @PURPOSE: Verify native filter extraction from permalinks and native_filters_key URLs.
# @LAYER: Domain
# @RELATION: [BINDS_TO] ->[SupersetClient]
# @RELATION: [BINDS_TO] ->[AsyncSupersetClient]
# @RELATION: [BINDS_TO] ->[FilterState, ParsedNativeFilters, ExtraFormDataMerge]
import json
from unittest.mock import MagicMock
@@ -26,8 +28,8 @@ from src.models.filter_state import (
)
# #region _make_environment [TYPE Function]
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:_make_environment:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
def _make_environment() -> Environment:
return Environment(
id="env-1",
@@ -38,12 +40,12 @@ def _make_environment() -> Environment:
)
# #endregion _make_environment
# [/DEF:_make_environment:Function]
# #region test_extract_native_filters_from_permalink [TYPE Function]
# @BRIEF Extract native filters from a permalink key.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_extract_native_filters_from_permalink:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Extract native filters from a permalink key.
def test_extract_native_filters_from_permalink():
client = SupersetClient(_make_environment())
client.get_dashboard_permalink_state = MagicMock(
@@ -87,12 +89,12 @@ def test_extract_native_filters_from_permalink():
assert result["anchor"] == "SECTION1"
# #endregion test_extract_native_filters_from_permalink
# [/DEF:test_extract_native_filters_from_permalink]
# #region test_extract_native_filters_from_permalink_direct_response [TYPE Function]
# @BRIEF Handle permalink response without result wrapper.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_extract_native_filters_from_permalink_direct_response:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Handle permalink response without result wrapper.
def test_extract_native_filters_from_permalink_direct_response():
client = SupersetClient(_make_environment())
client.get_dashboard_permalink_state = MagicMock(
@@ -115,12 +117,12 @@ def test_extract_native_filters_from_permalink_direct_response():
assert "filter_1" in result["dataMask"]
# #endregion test_extract_native_filters_from_permalink_direct_response
# [/DEF:test_extract_native_filters_from_permalink_direct_response]
# #region test_extract_native_filters_from_key [TYPE Function]
# @BRIEF Extract native filters from a native_filters_key.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_extract_native_filters_from_key:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Extract native filters from a native_filters_key.
def test_extract_native_filters_from_key():
client = SupersetClient(_make_environment())
client.get_native_filter_state = MagicMock(
@@ -154,12 +156,12 @@ def test_extract_native_filters_from_key():
] == ["EMEA"]
# #endregion test_extract_native_filters_from_key
# [/DEF:test_extract_native_filters_from_key]
# #region test_extract_native_filters_from_key_single_filter [TYPE Function]
# @BRIEF Handle single filter format in native filter state.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_extract_native_filters_from_key_single_filter:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Handle single filter format in native filter state.
def test_extract_native_filters_from_key_single_filter():
client = SupersetClient(_make_environment())
client.get_native_filter_state = MagicMock(
@@ -188,12 +190,12 @@ def test_extract_native_filters_from_key_single_filter():
)
# #endregion test_extract_native_filters_from_key_single_filter
# [/DEF:test_extract_native_filters_from_key_single_filter]
# #region test_extract_native_filters_from_key_dict_value [TYPE Function]
# @BRIEF Handle filter state value as dict instead of JSON string.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_extract_native_filters_from_key_dict_value:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Handle filter state value as dict instead of JSON string.
def test_extract_native_filters_from_key_dict_value():
client = SupersetClient(_make_environment())
client.get_native_filter_state = MagicMock(
@@ -215,12 +217,12 @@ def test_extract_native_filters_from_key_dict_value():
assert "filter_id" in result["dataMask"]
# #endregion test_extract_native_filters_from_key_dict_value
# [/DEF:test_extract_native_filters_from_key_dict_value]
# #region test_parse_dashboard_url_for_filters_permalink [TYPE Function]
# @BRIEF Parse permalink URL format.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_parse_dashboard_url_for_filters_permalink:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Parse permalink URL format.
def test_parse_dashboard_url_for_filters_permalink():
client = SupersetClient(_make_environment())
client.extract_native_filters_from_permalink = MagicMock(
@@ -235,12 +237,12 @@ def test_parse_dashboard_url_for_filters_permalink():
assert result["filters"]["dataMask"]["f1"] == {}
# #endregion test_parse_dashboard_url_for_filters_permalink
# [/DEF:test_parse_dashboard_url_for_filters_permalink]
# #region test_parse_dashboard_url_for_filters_native_key [TYPE Function]
# @BRIEF Parse native_filters_key URL format with numeric dashboard ID.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_parse_dashboard_url_for_filters_native_key:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Parse native_filters_key URL format with numeric dashboard ID.
def test_parse_dashboard_url_for_filters_native_key():
client = SupersetClient(_make_environment())
client.extract_native_filters_from_key = MagicMock(
@@ -260,12 +262,12 @@ def test_parse_dashboard_url_for_filters_native_key():
assert result["filters"]["dataMask"]["f2"] == {}
# #endregion test_parse_dashboard_url_for_filters_native_key
# [/DEF:test_parse_dashboard_url_for_filters_native_key]
# #region test_parse_dashboard_url_for_filters_native_key_slug [TYPE Function]
# @BRIEF Parse native_filters_key URL format when dashboard reference is a slug, not a numeric ID.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_parse_dashboard_url_for_filters_native_key_slug:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Parse native_filters_key URL format when dashboard reference is a slug, not a numeric ID.
def test_parse_dashboard_url_for_filters_native_key_slug():
client = SupersetClient(_make_environment())
# Simulate slug resolution: get_dashboard returns the dashboard with numeric ID
@@ -293,12 +295,12 @@ def test_parse_dashboard_url_for_filters_native_key_slug():
client.extract_native_filters_from_key.assert_called_once_with(99, "abc123")
# #endregion test_parse_dashboard_url_for_filters_native_key_slug
# [/DEF:test_parse_dashboard_url_for_filters_native_key_slug]
# #region test_parse_dashboard_url_for_filters_native_key_slug_resolution_fails [TYPE Function]
# @BRIEF Gracefully handle slug resolution failure for native_filters_key URL.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_parse_dashboard_url_for_filters_native_key_slug_resolution_fails:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Gracefully handle slug resolution failure for native_filters_key URL.
def test_parse_dashboard_url_for_filters_native_key_slug_resolution_fails():
client = SupersetClient(_make_environment())
client.get_dashboard = MagicMock(side_effect=Exception("Not found"))
@@ -311,12 +313,12 @@ def test_parse_dashboard_url_for_filters_native_key_slug_resolution_fails():
assert result["dashboard_id"] is None
# #endregion test_parse_dashboard_url_for_filters_native_key_slug_resolution_fails
# [/DEF:test_parse_dashboard_url_for_filters_native_key_slug_resolution_fails]
# #region test_parse_dashboard_url_for_filters_native_filters_direct [TYPE Function]
# @BRIEF Parse native_filters direct query param.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_parse_dashboard_url_for_filters_native_filters_direct:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Parse native_filters direct query param.
def test_parse_dashboard_url_for_filters_native_filters_direct():
client = SupersetClient(_make_environment())
@@ -329,12 +331,12 @@ def test_parse_dashboard_url_for_filters_native_filters_direct():
assert "dataMask" in result["filters"]
# #endregion test_parse_dashboard_url_for_filters_native_filters_direct
# [/DEF:test_parse_dashboard_url_for_filters_native_filters_direct]
# #region test_parse_dashboard_url_for_filters_no_filters [TYPE Function]
# @BRIEF Return empty result when no filters present.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_parse_dashboard_url_for_filters_no_filters:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Return empty result when no filters present.
def test_parse_dashboard_url_for_filters_no_filters():
client = SupersetClient(_make_environment())
@@ -346,12 +348,12 @@ def test_parse_dashboard_url_for_filters_no_filters():
assert result["filters"] == {}
# #endregion test_parse_dashboard_url_for_filters_no_filters
# [/DEF:test_parse_dashboard_url_for_filters_no_filters]
# #region test_extra_form_data_merge [TYPE Function]
# @BRIEF Test ExtraFormDataMerge correctly merges dictionaries.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_extra_form_data_merge:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Test ExtraFormDataMerge correctly merges dictionaries.
def test_extra_form_data_merge():
merger = ExtraFormDataMerge()
@@ -384,12 +386,12 @@ def test_extra_form_data_merge():
assert result["columns"] == ["col1", "col2"]
# #endregion test_extra_form_data_merge
# [/DEF:test_extra_form_data_merge]
# #region test_filter_state_model [TYPE Function]
# @BRIEF Test FilterState Pydantic model.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_filter_state_model:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Test FilterState Pydantic model.
def test_filter_state_model():
state = FilterState(
extraFormData={"filters": [{"col": "x", "op": "==", "val": "y"}]},
@@ -402,12 +404,12 @@ def test_filter_state_model():
assert state.ownState["selectedValues"] == ["y"]
# #endregion test_filter_state_model
# [/DEF:test_filter_state_model]
# #region test_parsed_native_filters_model [TYPE Function]
# @BRIEF Test ParsedNativeFilters Pydantic model.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_parsed_native_filters_model:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Test ParsedNativeFilters Pydantic model.
def test_parsed_native_filters_model():
filters = ParsedNativeFilters(
dataMask={"f1": {"extraFormData": {}, "filterState": {}}},
@@ -421,12 +423,12 @@ def test_parsed_native_filters_model():
assert filters.filter_type == "permalink"
# #endregion test_parsed_native_filters_model
# [/DEF:test_parsed_native_filters_model]
# #region test_parsed_native_filters_empty [TYPE Function]
# @BRIEF Test ParsedNativeFilters with no filters.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_parsed_native_filters_empty:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Test ParsedNativeFilters with no filters.
def test_parsed_native_filters_empty():
filters = ParsedNativeFilters()
@@ -434,12 +436,12 @@ def test_parsed_native_filters_empty():
assert filters.get_filter_count() == 0
# #endregion test_parsed_native_filters_empty
# [/DEF:test_parsed_native_filters_empty]
# #region test_native_filter_data_mask_model [TYPE Function]
# @BRIEF Test NativeFilterDataMask model.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_native_filter_data_mask_model:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Test NativeFilterDataMask model.
def test_native_filter_data_mask_model():
data_mask = NativeFilterDataMask(
filters={
@@ -455,12 +457,12 @@ def test_native_filter_data_mask_model():
assert data_mask.get_extra_form_data("nonexistent") == {}
# #endregion test_native_filter_data_mask_model
# [/DEF:test_native_filter_data_mask_model]
# #region test_recover_imported_filters_reconciles_raw_native_filter_ids_to_metadata_names [TYPE Function]
# @BRIEF Reconcile raw native filter ids from state to canonical metadata filter names.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_recover_imported_filters_reconciles_raw_native_filter_ids_to_metadata_names:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Reconcile raw native filter ids from state to canonical metadata filter names.
def test_recover_imported_filters_reconciles_raw_native_filter_ids_to_metadata_names():
client = MagicMock()
client.get_dashboard.return_value = {
@@ -522,12 +524,12 @@ def test_recover_imported_filters_reconciles_raw_native_filter_ids_to_metadata_n
}
# #endregion test_recover_imported_filters_reconciles_raw_native_filter_ids_to_metadata_names
# [/DEF:test_recover_imported_filters_reconciles_raw_native_filter_ids_to_metadata_names:Function]
# #region test_recover_imported_filters_collapses_state_and_metadata_duplicates_into_one_canonical_filter [TYPE Function]
# @BRIEF Collapse raw-id state entries and metadata entries into one canonical filter.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_recover_imported_filters_collapses_state_and_metadata_duplicates_into_one_canonical_filter:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Collapse raw-id state entries and metadata entries into one canonical filter.
def test_recover_imported_filters_collapses_state_and_metadata_duplicates_into_one_canonical_filter():
client = MagicMock()
client.get_dashboard.return_value = {
@@ -580,12 +582,12 @@ def test_recover_imported_filters_collapses_state_and_metadata_duplicates_into_o
assert region_filter["recovery_status"] == "partial"
# #endregion test_recover_imported_filters_collapses_state_and_metadata_duplicates_into_one_canonical_filter
# [/DEF:test_recover_imported_filters_collapses_state_and_metadata_duplicates_into_one_canonical_filter:Function]
# #region test_recover_imported_filters_preserves_unmatched_raw_native_filter_ids [TYPE Function]
# @BRIEF Preserve unmatched raw native filter ids as fallback diagnostics when metadata mapping is unavailable.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_recover_imported_filters_preserves_unmatched_raw_native_filter_ids:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Preserve unmatched raw native filter ids as fallback diagnostics when metadata mapping is unavailable.
def test_recover_imported_filters_preserves_unmatched_raw_native_filter_ids():
client = MagicMock()
client.get_dashboard.return_value = {
@@ -637,12 +639,12 @@ def test_recover_imported_filters_preserves_unmatched_raw_native_filter_ids():
)
# #endregion test_recover_imported_filters_preserves_unmatched_raw_native_filter_ids
# [/DEF:test_recover_imported_filters_preserves_unmatched_raw_native_filter_ids:Function]
# #region test_extract_imported_filters_preserves_clause_level_native_filter_payload_for_preview [TYPE Function]
# @BRIEF Recovered native filter state should preserve exact Superset clause payload and time extras for preview compilation.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_extract_imported_filters_preserves_clause_level_native_filter_payload_for_preview:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Recovered native filter state should preserve exact Superset clause payload and time extras for preview compilation.
def test_extract_imported_filters_preserves_clause_level_native_filter_payload_for_preview():
extractor = SupersetContextExtractor(_make_environment(), client=MagicMock())
@@ -682,12 +684,12 @@ def test_extract_imported_filters_preserves_clause_level_native_filter_payload_f
]
# #endregion test_extract_imported_filters_preserves_clause_level_native_filter_payload_for_preview
# [/DEF:test_extract_imported_filters_preserves_clause_level_native_filter_payload_for_preview:Function]
# #region test_sanitize_imported_filter_for_assistant_masks_sensitive_raw_values [TYPE Function]
# @BRIEF Assistant-facing imported-filter payload should redact likely PII while preserving structure metadata.
# @RELATION BINDS_TO -> [NativeFilterExtractionTests]
# [DEF:test_sanitize_imported_filter_for_assistant_masks_sensitive_raw_values:Function]
# @RELATION: BINDS_TO -> NativeFilterExtractionTests
# @PURPOSE: Assistant-facing imported-filter payload should redact likely PII while preserving structure metadata.
def test_sanitize_imported_filter_for_assistant_masks_sensitive_raw_values():
result = sanitize_imported_filter_for_assistant(
{
@@ -703,7 +705,7 @@ def test_sanitize_imported_filter_for_assistant_masks_sensitive_raw_values():
assert result["normalized_value"] == {"filter_clauses": []}
# #endregion test_sanitize_imported_filter_for_assistant_masks_sensitive_raw_values
# [/DEF:test_sanitize_imported_filter_for_assistant_masks_sensitive_raw_values:Function]
# #endregion NativeFilterExtractionTests
# [/DEF:NativeFilterExtractionTests:Module]

View File

@@ -1,7 +1,9 @@
# #region SupersetPreviewPipelineTests [C:3] [TYPE Module] [SEMANTICS tests, superset, preview, chart_data, network, 404-mapping]
# @BRIEF Verify explicit chart-data preview compilation and ensure non-dashboard 404 errors remain generic across sync and async clients.
# @LAYER Domain
# @RELATION BINDS_TO -> [AsyncNetworkModule]
# [DEF:SupersetPreviewPipelineTests:Module]
# @COMPLEXITY: 3
# @SEMANTICS: tests, superset, preview, chart_data, network, 404-mapping
# @PURPOSE: Verify explicit chart-data preview compilation and ensure non-dashboard 404 errors remain generic across sync and async clients.
# @LAYER: Domain
# @RELATION: [BINDS_TO] ->[AsyncNetworkModule]
import json
from unittest.mock import MagicMock
@@ -16,8 +18,8 @@ from src.core.utils.async_network import AsyncAPIClient
from src.core.utils.network import APIClient, DashboardNotFoundError, SupersetAPIError
# #region _make_environment [TYPE Function]
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:_make_environment:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
def _make_environment() -> Environment:
return Environment(
id="env-1",
@@ -28,11 +30,11 @@ def _make_environment() -> Environment:
)
# #endregion _make_environment
# [/DEF:_make_environment:Function]
# #region _make_requests_http_error [TYPE Function]
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:_make_requests_http_error:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
def _make_requests_http_error(
status_code: int, url: str
) -> requests.exceptions.HTTPError:
@@ -45,11 +47,11 @@ def _make_requests_http_error(
return requests.exceptions.HTTPError(response=response, request=request)
# #endregion _make_requests_http_error
# [/DEF:_make_requests_http_error:Function]
# #region _make_httpx_status_error [TYPE Function]
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:_make_httpx_status_error:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
def _make_httpx_status_error(status_code: int, url: str) -> httpx.HTTPStatusError:
request = httpx.Request("GET", url)
response = httpx.Response(
@@ -58,12 +60,12 @@ def _make_httpx_status_error(status_code: int, url: str) -> httpx.HTTPStatusErro
return httpx.HTTPStatusError("upstream error", request=request, response=response)
# #endregion _make_httpx_status_error
# [/DEF:_make_httpx_status_error:Function]
# #region test_compile_dataset_preview_prefers_legacy_explore_form_data_strategy [TYPE Function]
# @BRIEF Superset preview compilation should prefer the legacy form_data transport inferred from browser traffic before falling back to chart-data.
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:test_compile_dataset_preview_prefers_legacy_explore_form_data_strategy:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
# @PURPOSE: Superset preview compilation should prefer the legacy form_data transport inferred from browser traffic before falling back to chart-data.
def test_compile_dataset_preview_prefers_legacy_explore_form_data_strategy():
client = SupersetClient(_make_environment())
client.get_dataset = MagicMock(
@@ -144,12 +146,12 @@ def test_compile_dataset_preview_prefers_legacy_explore_form_data_strategy():
]
# #endregion test_compile_dataset_preview_prefers_legacy_explore_form_data_strategy
# [/DEF:test_compile_dataset_preview_prefers_legacy_explore_form_data_strategy:Function]
# #region test_compile_dataset_preview_falls_back_to_chart_data_after_legacy_failures [TYPE Function]
# @BRIEF Superset preview compilation should fall back to chart-data when legacy form_data strategies are rejected.
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:test_compile_dataset_preview_falls_back_to_chart_data_after_legacy_failures:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
# @PURPOSE: Superset preview compilation should fall back to chart-data when legacy form_data strategies are rejected.
def test_compile_dataset_preview_falls_back_to_chart_data_after_legacy_failures():
client = SupersetClient(_make_environment())
client.get_dataset = MagicMock(
@@ -241,12 +243,12 @@ def test_compile_dataset_preview_falls_back_to_chart_data_after_legacy_failures(
}
# #endregion test_compile_dataset_preview_falls_back_to_chart_data_after_legacy_failures
# [/DEF:test_compile_dataset_preview_falls_back_to_chart_data_after_legacy_failures:Function]
# #region test_build_dataset_preview_query_context_places_recovered_filters_in_chart_style_form_data [TYPE Function]
# @BRIEF Preview query context should mirror chart-style filter transport so recovered native filters reach Superset compilation.
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:test_build_dataset_preview_query_context_places_recovered_filters_in_chart_style_form_data:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
# @PURPOSE: Preview query context should mirror chart-style filter transport so recovered native filters reach Superset compilation.
def test_build_dataset_preview_query_context_places_recovered_filters_in_chart_style_form_data():
client = SupersetClient(_make_environment())
@@ -304,12 +306,12 @@ def test_build_dataset_preview_query_context_places_recovered_filters_in_chart_s
assert query_context["form_data"]["url_params"] == {"country": "DE"}
# #endregion test_build_dataset_preview_query_context_places_recovered_filters_in_chart_style_form_data
# [/DEF:test_build_dataset_preview_query_context_places_recovered_filters_in_chart_style_form_data:Function]
# #region test_build_dataset_preview_query_context_merges_dataset_template_params_and_preserves_user_values [TYPE Function]
# @BRIEF Preview query context should merge dataset template params for parity with real dataset definitions while preserving explicit session overrides.
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:test_build_dataset_preview_query_context_merges_dataset_template_params_and_preserves_user_values:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
# @PURPOSE: Preview query context should merge dataset template params for parity with real dataset definitions while preserving explicit session overrides.
def test_build_dataset_preview_query_context_merges_dataset_template_params_and_preserves_user_values():
client = SupersetClient(_make_environment())
@@ -335,12 +337,12 @@ def test_build_dataset_preview_query_context_merges_dataset_template_params_and_
}
# #endregion test_build_dataset_preview_query_context_merges_dataset_template_params_and_preserves_user_values
# [/DEF:test_build_dataset_preview_query_context_merges_dataset_template_params_and_preserves_user_values:Function]
# #region test_build_dataset_preview_query_context_preserves_time_range_from_native_filter_payload [TYPE Function]
# @BRIEF Preview query context should preserve time-range native filter extras even when dataset defaults differ.
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:test_build_dataset_preview_query_context_preserves_time_range_from_native_filter_payload:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
# @PURPOSE: Preview query context should preserve time-range native filter extras even when dataset defaults differ.
def test_build_dataset_preview_query_context_preserves_time_range_from_native_filter_payload():
client = SupersetClient(_make_environment())
@@ -374,12 +376,12 @@ def test_build_dataset_preview_query_context_preserves_time_range_from_native_fi
assert query_context["queries"][0]["filters"] == []
# #endregion test_build_dataset_preview_query_context_preserves_time_range_from_native_filter_payload
# [/DEF:test_build_dataset_preview_query_context_preserves_time_range_from_native_filter_payload:Function]
# #region test_build_dataset_preview_legacy_form_data_preserves_native_filter_clauses [TYPE Function]
# @BRIEF Legacy preview form_data should preserve recovered native filter clauses in browser-style fields without duplicating datasource for QueryObjectFactory.
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:test_build_dataset_preview_legacy_form_data_preserves_native_filter_clauses:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
# @PURPOSE: Legacy preview form_data should preserve recovered native filter clauses in browser-style fields without duplicating datasource for QueryObjectFactory.
def test_build_dataset_preview_legacy_form_data_preserves_native_filter_clauses():
client = SupersetClient(_make_environment())
@@ -428,12 +430,12 @@ def test_build_dataset_preview_legacy_form_data_preserves_native_filter_clauses(
assert legacy_form_data["result_type"] == "query"
# #endregion test_build_dataset_preview_legacy_form_data_preserves_native_filter_clauses
# [/DEF:test_build_dataset_preview_legacy_form_data_preserves_native_filter_clauses:Function]
# #region test_sync_network_404_mapping_keeps_non_dashboard_endpoints_generic [TYPE Function]
# @BRIEF Sync network client should reserve dashboard-not-found translation for dashboard endpoints only.
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:test_sync_network_404_mapping_keeps_non_dashboard_endpoints_generic:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
# @PURPOSE: Sync network client should reserve dashboard-not-found translation for dashboard endpoints only.
def test_sync_network_404_mapping_keeps_non_dashboard_endpoints_generic():
client = APIClient(
config={
@@ -452,12 +454,12 @@ def test_sync_network_404_mapping_keeps_non_dashboard_endpoints_generic():
assert "API resource not found at endpoint '/chart/data'" in str(exc_info.value)
# #endregion test_sync_network_404_mapping_keeps_non_dashboard_endpoints_generic
# [/DEF:test_sync_network_404_mapping_keeps_non_dashboard_endpoints_generic:Function]
# #region test_sync_network_404_mapping_translates_dashboard_endpoints [TYPE Function]
# @BRIEF Sync network client should still translate dashboard endpoint 404 responses into dashboard-not-found errors.
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:test_sync_network_404_mapping_translates_dashboard_endpoints:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
# @PURPOSE: Sync network client should still translate dashboard endpoint 404 responses into dashboard-not-found errors.
def test_sync_network_404_mapping_translates_dashboard_endpoints():
client = APIClient(
config={
@@ -475,12 +477,12 @@ def test_sync_network_404_mapping_translates_dashboard_endpoints():
assert "Dashboard '/dashboard/10' Dashboard not found" in str(exc_info.value)
# #endregion test_sync_network_404_mapping_translates_dashboard_endpoints
# [/DEF:test_sync_network_404_mapping_translates_dashboard_endpoints:Function]
# #region test_async_network_404_mapping_keeps_non_dashboard_endpoints_generic [TYPE Function]
# @BRIEF Async network client should reserve dashboard-not-found translation for dashboard endpoints only.
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:test_async_network_404_mapping_keeps_non_dashboard_endpoints_generic:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
# @PURPOSE: Async network client should reserve dashboard-not-found translation for dashboard endpoints only.
@pytest.mark.asyncio
async def test_async_network_404_mapping_keeps_non_dashboard_endpoints_generic():
client = AsyncAPIClient(
@@ -505,12 +507,12 @@ async def test_async_network_404_mapping_keeps_non_dashboard_endpoints_generic()
await client.aclose()
# #endregion test_async_network_404_mapping_keeps_non_dashboard_endpoints_generic
# [/DEF:test_async_network_404_mapping_keeps_non_dashboard_endpoints_generic:Function]
# #region test_async_network_404_mapping_translates_dashboard_endpoints [TYPE Function]
# @BRIEF Async network client should still translate dashboard endpoint 404 responses into dashboard-not-found errors.
# @RELATION BINDS_TO -> [SupersetPreviewPipelineTests]
# [DEF:test_async_network_404_mapping_translates_dashboard_endpoints:Function]
# @RELATION: BINDS_TO -> SupersetPreviewPipelineTests
# @PURPOSE: Async network client should still translate dashboard endpoint 404 responses into dashboard-not-found errors.
@pytest.mark.asyncio
async def test_async_network_404_mapping_translates_dashboard_endpoints():
client = AsyncAPIClient(
@@ -534,7 +536,7 @@ async def test_async_network_404_mapping_translates_dashboard_endpoints():
await client.aclose()
# #endregion test_async_network_404_mapping_translates_dashboard_endpoints
# [/DEF:test_async_network_404_mapping_translates_dashboard_endpoints:Function]
# #endregion SupersetPreviewPipelineTests
# [/DEF:SupersetPreviewPipelineTests:Module]

View File

@@ -1,7 +1,9 @@
# #region TestSupersetProfileLookup [C:3] [TYPE Module] [SEMANTICS tests, superset, profile, lookup, fallback, sorting]
# @BRIEF Verifies Superset profile lookup adapter payload normalization and fallback error precedence.
# @LAYER Domain
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:TestSupersetProfileLookup:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @SEMANTICS: tests, superset, profile, lookup, fallback, sorting
# @PURPOSE: Verifies Superset profile lookup adapter payload normalization and fallback error precedence.
# @LAYER: Domain
# [SECTION: IMPORTS]
import json
@@ -20,25 +22,26 @@ from src.core.utils.network import AuthenticationError, SupersetAPIError
# [/SECTION]
# #region _RecordingNetworkClient [C:2] [TYPE Class]
# @BRIEF Records request payloads and returns scripted responses for deterministic adapter tests.
# @INVARIANT Each request consumes one scripted response in call order and persists call metadata.
# @RELATION BINDS_TO -> [TestSupersetProfileLookup]
# [DEF:_RecordingNetworkClient:Class]
# @RELATION: BINDS_TO -> TestSupersetProfileLookup
# @COMPLEXITY: 2
# @PURPOSE: Records request payloads and returns scripted responses for deterministic adapter tests.
# @INVARIANT: Each request consumes one scripted response in call order and persists call metadata.
class _RecordingNetworkClient:
# #region __init__ [TYPE Function]
# @BRIEF Initializes scripted network responses.
# @PRE scripted_responses is ordered per expected request sequence.
# @POST Instance stores response script and captures subsequent request calls.
# [DEF:__init__:Function]
# @PURPOSE: Initializes scripted network responses.
# @PRE: scripted_responses is ordered per expected request sequence.
# @POST: Instance stores response script and captures subsequent request calls.
def __init__(self, scripted_responses: List[Any]):
self._scripted_responses = scripted_responses
self.calls: List[Dict[str, Any]] = []
# #endregion __init__
# [/DEF:__init__:Function]
# #region request [TYPE Function]
# @BRIEF Mimics APIClient.request while capturing call arguments.
# @PRE method and endpoint are provided.
# @POST Returns scripted response or raises scripted exception.
# [DEF:request:Function]
# @PURPOSE: Mimics APIClient.request while capturing call arguments.
# @PRE: method and endpoint are provided.
# @POST: Returns scripted response or raises scripted exception.
def request(
self,
method: str,
@@ -59,17 +62,17 @@ class _RecordingNetworkClient:
raise response
return response
# #endregion request
# [/DEF:request:Function]
# #endregion _RecordingNetworkClient
# [/DEF:_RecordingNetworkClient:Class]
# #region test_get_users_page_sends_lowercase_order_direction [TYPE Function]
# @BRIEF Ensures adapter sends lowercase order_direction compatible with Superset rison schema.
# @PRE Adapter is initialized with recording network client.
# @POST First request query payload contains order_direction='asc' for asc sort.
# @RELATION BINDS_TO -> [TestSupersetProfileLookup]
# [DEF:test_get_users_page_sends_lowercase_order_direction:Function]
# @RELATION: BINDS_TO -> TestSupersetProfileLookup
# @PURPOSE: Ensures adapter sends lowercase order_direction compatible with Superset rison schema.
# @PRE: Adapter is initialized with recording network client.
# @POST: First request query payload contains order_direction='asc' for asc sort.
def test_get_users_page_sends_lowercase_order_direction():
client = _RecordingNetworkClient(
scripted_responses=[{"result": [{"username": "admin"}], "count": 1}]
@@ -90,14 +93,14 @@ def test_get_users_page_sends_lowercase_order_direction():
assert sent_query["order_direction"] == "asc"
# #endregion test_get_users_page_sends_lowercase_order_direction
# [/DEF:test_get_users_page_sends_lowercase_order_direction:Function]
# #region test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error [TYPE Function]
# @BRIEF Ensures fallback auth error does not mask primary schema/query failure.
# @PRE Primary endpoint fails with SupersetAPIError and fallback fails with AuthenticationError.
# @POST Raised exception remains primary SupersetAPIError (non-auth) to preserve root cause.
# @RELATION BINDS_TO -> [TestSupersetProfileLookup]
# [DEF:test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error:Function]
# @RELATION: BINDS_TO -> TestSupersetProfileLookup
# @PURPOSE: Ensures fallback auth error does not mask primary schema/query failure.
# @PRE: Primary endpoint fails with SupersetAPIError and fallback fails with AuthenticationError.
# @POST: Raised exception remains primary SupersetAPIError (non-auth) to preserve root cause.
def test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error():
client = _RecordingNetworkClient(
scripted_responses=[
@@ -116,14 +119,14 @@ def test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error(
assert not isinstance(exc_info.value, AuthenticationError)
# #endregion test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error
# [/DEF:test_get_users_page_preserves_primary_schema_error_over_fallback_auth_error:Function]
# #region test_get_users_page_uses_fallback_endpoint_when_primary_fails [TYPE Function]
# @BRIEF Verifies adapter retries second users endpoint and succeeds when fallback is healthy.
# @PRE Primary endpoint fails; fallback returns valid users payload.
# @POST Result status is success and both endpoints were attempted in order.
# @RELATION BINDS_TO -> [TestSupersetProfileLookup]
# [DEF:test_get_users_page_uses_fallback_endpoint_when_primary_fails:Function]
# @RELATION: BINDS_TO -> TestSupersetProfileLookup
# @PURPOSE: Verifies adapter retries second users endpoint and succeeds when fallback is healthy.
# @PRE: Primary endpoint fails; fallback returns valid users payload.
# @POST: Result status is success and both endpoints were attempted in order.
def test_get_users_page_uses_fallback_endpoint_when_primary_fails():
client = _RecordingNetworkClient(
scripted_responses=[
@@ -144,7 +147,7 @@ def test_get_users_page_uses_fallback_endpoint_when_primary_fails():
]
# #endregion test_get_users_page_uses_fallback_endpoint_when_primary_fails
# [/DEF:test_get_users_page_uses_fallback_endpoint_when_primary_fails:Function]
# #endregion TestSupersetProfileLookup
# [/DEF:TestSupersetProfileLookup:Module]

View File

@@ -2,14 +2,15 @@ import pytest
from datetime import time, date, datetime, timedelta
from src.core.scheduler import ThrottledSchedulerConfigurator
# #region test_throttled_scheduler [C:3] [TYPE Module]
# @BRIEF Unit tests for ThrottledSchedulerConfigurator distribution logic.
# @RELATION BELONGS_TO -> [SrcRoot]
# [DEF:test_throttled_scheduler:Module]
# @RELATION: BELONGS_TO -> SrcRoot
# @COMPLEXITY: 3
# @PURPOSE: Unit tests for ThrottledSchedulerConfigurator distribution logic.
# #region test_calculate_schedule_even_distribution [TYPE Function]
# @BRIEF Validate even spacing across a two-hour scheduling window for three tasks.
# @RELATION BINDS_TO -> [test_throttled_scheduler]
# [DEF:test_calculate_schedule_even_distribution:Function]
# @RELATION: BINDS_TO -> test_throttled_scheduler
# @PURPOSE: Validate even spacing across a two-hour scheduling window for three tasks.
def test_calculate_schedule_even_distribution():
"""
@TEST_SCENARIO: 3 tasks in a 2-hour window should be spaced 1 hour apart.
@@ -29,12 +30,12 @@ def test_calculate_schedule_even_distribution():
assert schedule[2] == datetime(2024, 1, 1, 3, 0)
# #endregion test_calculate_schedule_even_distribution
# [/DEF:test_calculate_schedule_even_distribution:Function]
# #region test_calculate_schedule_midnight_crossing [TYPE Function]
# @BRIEF Validate scheduler correctly rolls timestamps into the next day across midnight.
# @RELATION BINDS_TO -> [test_throttled_scheduler]
# [DEF:test_calculate_schedule_midnight_crossing:Function]
# @RELATION: BINDS_TO -> test_throttled_scheduler
# @PURPOSE: Validate scheduler correctly rolls timestamps into the next day across midnight.
def test_calculate_schedule_midnight_crossing():
"""
@TEST_SCENARIO: Window from 23:00 to 01:00 (next day).
@@ -54,12 +55,12 @@ def test_calculate_schedule_midnight_crossing():
assert schedule[2] == datetime(2024, 1, 2, 1, 0)
# #endregion test_calculate_schedule_midnight_crossing
# [/DEF:test_calculate_schedule_midnight_crossing:Function]
# #region test_calculate_schedule_single_task [TYPE Function]
# @BRIEF Validate single-task schedule returns only the window start timestamp.
# @RELATION BINDS_TO -> [test_throttled_scheduler]
# [DEF:test_calculate_schedule_single_task:Function]
# @RELATION: BINDS_TO -> test_throttled_scheduler
# @PURPOSE: Validate single-task schedule returns only the window start timestamp.
def test_calculate_schedule_single_task():
"""
@TEST_SCENARIO: Single task should be scheduled at start time.
@@ -77,12 +78,12 @@ def test_calculate_schedule_single_task():
assert schedule[0] == datetime(2024, 1, 1, 1, 0)
# #endregion test_calculate_schedule_single_task
# [/DEF:test_calculate_schedule_single_task:Function]
# #region test_calculate_schedule_empty_list [TYPE Function]
# @BRIEF Validate empty dashboard list produces an empty schedule.
# @RELATION BINDS_TO -> [test_throttled_scheduler]
# [DEF:test_calculate_schedule_empty_list:Function]
# @RELATION: BINDS_TO -> test_throttled_scheduler
# @PURPOSE: Validate empty dashboard list produces an empty schedule.
def test_calculate_schedule_empty_list():
"""
@TEST_SCENARIO: Empty dashboard list returns empty schedule.
@@ -99,12 +100,12 @@ def test_calculate_schedule_empty_list():
assert schedule == []
# #endregion test_calculate_schedule_empty_list
# [/DEF:test_calculate_schedule_empty_list:Function]
# #region test_calculate_schedule_zero_window [TYPE Function]
# @BRIEF Validate zero-length window schedules all tasks at identical start timestamp.
# @RELATION BINDS_TO -> [test_throttled_scheduler]
# [DEF:test_calculate_schedule_zero_window:Function]
# @RELATION: BINDS_TO -> test_throttled_scheduler
# @PURPOSE: Validate zero-length window schedules all tasks at identical start timestamp.
def test_calculate_schedule_zero_window():
"""
@TEST_SCENARIO: Window start == end. All tasks at start time.
@@ -123,12 +124,12 @@ def test_calculate_schedule_zero_window():
assert schedule[1] == datetime(2024, 1, 1, 1, 0)
# #endregion test_calculate_schedule_zero_window
# [/DEF:test_calculate_schedule_zero_window:Function]
# #region test_calculate_schedule_very_small_window [TYPE Function]
# @BRIEF Validate sub-second interpolation when task count exceeds near-zero window granularity.
# @RELATION BINDS_TO -> [test_throttled_scheduler]
# [DEF:test_calculate_schedule_very_small_window:Function]
# @RELATION: BINDS_TO -> test_throttled_scheduler
# @PURPOSE: Validate sub-second interpolation when task count exceeds near-zero window granularity.
def test_calculate_schedule_very_small_window():
"""
@TEST_SCENARIO: Window smaller than number of tasks (in seconds).
@@ -148,5 +149,5 @@ def test_calculate_schedule_very_small_window():
assert schedule[2] == datetime(2024, 1, 1, 1, 0, 1)
# #endregion test_calculate_schedule_very_small_window
# #endregion test_throttled_scheduler
# [/DEF:test_calculate_schedule_very_small_window:Function]
# [/DEF:test_throttled_scheduler:Module]

View File

@@ -1,9 +1,8 @@
# #region AsyncSupersetClientModule [C:3] [TYPE Module] [SEMANTICS superset, async, client, httpx, dashboards, datasets]
# @BRIEF Parse a Superset dashboard URL and extract native filter state asynchronously.
# @LAYER Core
# @LAYER: Core
# [SECTION: IMPORTS]
import asyncio
import json
import re
@@ -13,7 +12,6 @@ from .config_models import Environment
from .logger import logger as app_logger, belief_scope
from .superset_client import SupersetClient
from .utils.async_network import AsyncAPIClient
# [/SECTION]
# #region AsyncSupersetClient [C:3] [TYPE Class]
@@ -22,12 +20,13 @@ from .utils.async_network import AsyncAPIClient
# @RELATION DEPENDS_ON -> [AsyncAPIClient]
# @RELATION CALLS -> [AsyncAPIClient.request]
class AsyncSupersetClient(SupersetClient):
# #region AsyncSupersetClientInit [C:3] [TYPE Function]
# @BRIEF Initialize async Superset client with AsyncAPIClient transport.
# @PRE env is valid Environment instance.
# @POST Client uses async network transport and inherited projection helpers.
# @DATA_CONTRACT Input[Environment] -> self.network[AsyncAPIClient]
# @RELATION DEPENDS_ON -> [AsyncAPIClient]
# [DEF:AsyncSupersetClientInit:Function]
# @COMPLEXITY: 3
# @PURPOSE: Initialize async Superset client with AsyncAPIClient transport.
# @PRE: env is valid Environment instance.
# @POST: Client uses async network transport and inherited projection helpers.
# @DATA_CONTRACT: Input[Environment] -> self.network[AsyncAPIClient]
# @RELATION: [DEPENDS_ON] ->[AsyncAPIClient]
def __init__(self, env: Environment):
self.env = env
auth_payload = {
@@ -43,23 +42,25 @@ class AsyncSupersetClient(SupersetClient):
)
self.delete_before_reimport = False
# #endregion AsyncSupersetClientInit
# [/DEF:AsyncSupersetClientInit:Function]
# #region AsyncSupersetClientClose [C:3] [TYPE Function]
# @BRIEF Close async transport resources.
# @POST Underlying AsyncAPIClient is closed.
# @SIDE_EFFECT Closes network sockets.
# @RELATION CALLS -> [AsyncAPIClient.aclose]
# [DEF:AsyncSupersetClientClose:Function]
# @COMPLEXITY: 3
# @PURPOSE: Close async transport resources.
# @POST: Underlying AsyncAPIClient is closed.
# @SIDE_EFFECT: Closes network sockets.
# @RELATION: [CALLS] ->[AsyncAPIClient.aclose]
async def aclose(self) -> None:
await self.network.aclose()
# #endregion AsyncSupersetClientClose
# [/DEF:AsyncSupersetClientClose:Function]
# #region get_dashboards_page_async [C:3] [TYPE Function]
# @BRIEF Fetch one dashboards page asynchronously.
# @POST Returns total count and page result list.
# @DATA_CONTRACT Input[query: Optional[Dict]] -> Output[Tuple[int, List[Dict]]]
# @RELATION CALLS -> [AsyncAPIClient.request]
# [DEF:get_dashboards_page_async:Function]
# @COMPLEXITY: 3
# @PURPOSE: Fetch one dashboards page asynchronously.
# @POST: Returns total count and page result list.
# @DATA_CONTRACT: Input[query: Optional[Dict]] -> Output[Tuple[int, List[Dict]]]
# @RELATION: [CALLS] -> [AsyncAPIClient.request]
async def get_dashboards_page_async(
self, query: Optional[Dict] = None
) -> Tuple[int, List[Dict]]:
@@ -91,13 +92,14 @@ class AsyncSupersetClient(SupersetClient):
total_count = response_json.get("count", len(result))
return total_count, result
# #endregion get_dashboards_page_async
# [/DEF:get_dashboards_page_async:Function]
# #region get_dashboard_async [C:3] [TYPE Function]
# @BRIEF Fetch one dashboard payload asynchronously.
# @POST Returns raw dashboard payload from Superset API.
# @DATA_CONTRACT Input[dashboard_id: int] -> Output[Dict]
# @RELATION CALLS -> [AsyncAPIClient.request]
# [DEF:get_dashboard_async:Function]
# @COMPLEXITY: 3
# @PURPOSE: Fetch one dashboard payload asynchronously.
# @POST: Returns raw dashboard payload from Superset API.
# @DATA_CONTRACT: Input[dashboard_id: int] -> Output[Dict]
# @RELATION: [CALLS] ->[AsyncAPIClient.request]
async def get_dashboard_async(self, dashboard_id: int) -> Dict:
with belief_scope(
"AsyncSupersetClient.get_dashboard_async", f"id={dashboard_id}"
@@ -107,13 +109,14 @@ class AsyncSupersetClient(SupersetClient):
)
return cast(Dict, response)
# #endregion get_dashboard_async
# [/DEF:get_dashboard_async:Function]
# #region get_chart_async [C:3] [TYPE Function]
# @BRIEF Fetch one chart payload asynchronously.
# @POST Returns raw chart payload from Superset API.
# @DATA_CONTRACT Input[chart_id: int] -> Output[Dict]
# @RELATION CALLS -> [AsyncAPIClient.request]
# [DEF:get_chart_async:Function]
# @COMPLEXITY: 3
# @PURPOSE: Fetch one chart payload asynchronously.
# @POST: Returns raw chart payload from Superset API.
# @DATA_CONTRACT: Input[chart_id: int] -> Output[Dict]
# @RELATION: [CALLS] ->[AsyncAPIClient.request]
async def get_chart_async(self, chart_id: int) -> Dict:
with belief_scope("AsyncSupersetClient.get_chart_async", f"id={chart_id}"):
response = await self.network.request(
@@ -121,14 +124,15 @@ class AsyncSupersetClient(SupersetClient):
)
return cast(Dict, response)
# #endregion get_chart_async
# [/DEF:get_chart_async:Function]
# #region get_dashboard_detail_async [C:3] [TYPE Function]
# @BRIEF Fetch dashboard detail asynchronously with concurrent charts/datasets requests.
# @POST Returns dashboard detail payload for overview page.
# @DATA_CONTRACT Input[dashboard_id: int] -> Output[Dict]
# @RELATION CALLS -> [get_dashboard_async]
# @RELATION CALLS -> [get_chart_async]
# [DEF:get_dashboard_detail_async:Function]
# @COMPLEXITY: 3
# @PURPOSE: Fetch dashboard detail asynchronously with concurrent charts/datasets requests.
# @POST: Returns dashboard detail payload for overview page.
# @DATA_CONTRACT: Input[dashboard_id: int] -> Output[Dict]
# @RELATION: [CALLS] ->[get_dashboard_async]
# @RELATION: [CALLS] ->[get_chart_async]
async def get_dashboard_detail_async(self, dashboard_id: int) -> Dict:
with belief_scope(
"AsyncSupersetClient.get_dashboard_detail_async", f"id={dashboard_id}"
@@ -418,12 +422,13 @@ class AsyncSupersetClient(SupersetClient):
"dataset_count": len(datasets),
}
# #endregion get_dashboard_detail_async
# [/DEF:get_dashboard_detail_async:Function]
# #region get_dashboard_permalink_state_async [C:2] [TYPE Function]
# @BRIEF Fetch stored dashboard permalink state asynchronously.
# @POST Returns dashboard permalink state payload from Superset API.
# @DATA_CONTRACT Input[permalink_key: str] -> Output[Dict]
# [DEF:get_dashboard_permalink_state_async:Function]
# @COMPLEXITY: 2
# @PURPOSE: Fetch stored dashboard permalink state asynchronously.
# @POST: Returns dashboard permalink state payload from Superset API.
# @DATA_CONTRACT: Input[permalink_key: str] -> Output[Dict]
async def get_dashboard_permalink_state_async(self, permalink_key: str) -> Dict:
with belief_scope(
"AsyncSupersetClient.get_dashboard_permalink_state_async",
@@ -434,12 +439,13 @@ class AsyncSupersetClient(SupersetClient):
)
return cast(Dict, response)
# #endregion get_dashboard_permalink_state_async
# [/DEF:get_dashboard_permalink_state_async:Function]
# #region get_native_filter_state_async [C:2] [TYPE Function]
# @BRIEF Fetch stored native filter state asynchronously.
# @POST Returns native filter state payload from Superset API.
# @DATA_CONTRACT Input[dashboard_id: Union[int, str], filter_state_key: str] -> Output[Dict]
# [DEF:get_native_filter_state_async:Function]
# @COMPLEXITY: 2
# @PURPOSE: Fetch stored native filter state asynchronously.
# @POST: Returns native filter state payload from Superset API.
# @DATA_CONTRACT: Input[dashboard_id: Union[int, str], filter_state_key: str] -> Output[Dict]
async def get_native_filter_state_async(
self, dashboard_id: int, filter_state_key: str
) -> Dict:
@@ -453,13 +459,14 @@ class AsyncSupersetClient(SupersetClient):
)
return cast(Dict, response)
# #endregion get_native_filter_state_async
# [/DEF:get_native_filter_state_async:Function]
# #region extract_native_filters_from_permalink_async [C:3] [TYPE Function]
# @BRIEF Extract native filters dataMask from a permalink key asynchronously.
# @POST Returns extracted dataMask with filter states.
# @DATA_CONTRACT Input[permalink_key: str] -> Output[Dict]
# @RELATION CALLS -> [get_dashboard_permalink_state_async]
# [DEF:extract_native_filters_from_permalink_async:Function]
# @COMPLEXITY: 3
# @PURPOSE: Extract native filters dataMask from a permalink key asynchronously.
# @POST: Returns extracted dataMask with filter states.
# @DATA_CONTRACT: Input[permalink_key: str] -> Output[Dict]
# @RELATION: [CALLS] ->[get_dashboard_permalink_state_async]
async def extract_native_filters_from_permalink_async(
self, permalink_key: str
) -> Dict:
@@ -493,13 +500,14 @@ class AsyncSupersetClient(SupersetClient):
"permalink_key": permalink_key,
}
# #endregion extract_native_filters_from_permalink_async
# [/DEF:extract_native_filters_from_permalink_async:Function]
# #region extract_native_filters_from_key_async [C:3] [TYPE Function]
# @BRIEF Extract native filters from a native_filters_key URL parameter asynchronously.
# @POST Returns extracted filter state with extraFormData.
# @DATA_CONTRACT Input[dashboard_id: Union[int, str], filter_state_key: str] -> Output[Dict]
# @RELATION CALLS -> [get_native_filter_state_async]
# [DEF:extract_native_filters_from_key_async:Function]
# @COMPLEXITY: 3
# @PURPOSE: Extract native filters from a native_filters_key URL parameter asynchronously.
# @POST: Returns extracted filter state with extraFormData.
# @DATA_CONTRACT: Input[dashboard_id: Union[int, str], filter_state_key: str] -> Output[Dict]
# @RELATION: [CALLS] ->[get_native_filter_state_async]
async def extract_native_filters_from_key_async(
self, dashboard_id: int, filter_state_key: str
) -> Dict:
@@ -553,14 +561,15 @@ class AsyncSupersetClient(SupersetClient):
"filter_state_key": filter_state_key,
}
# #endregion extract_native_filters_from_key_async
# [/DEF:extract_native_filters_from_key_async:Function]
# #region parse_dashboard_url_for_filters_async [C:3] [TYPE Function]
# @BRIEF Parse a Superset dashboard URL and extract native filter state asynchronously.
# @POST Returns extracted filter state or empty dict if no filters found.
# @DATA_CONTRACT Input[url: str] -> Output[Dict]
# @RELATION CALLS -> [extract_native_filters_from_permalink_async]
# @RELATION CALLS -> [extract_native_filters_from_key_async]
# [DEF:parse_dashboard_url_for_filters_async:Function]
# @COMPLEXITY: 3
# @PURPOSE: Parse a Superset dashboard URL and extract native filter state asynchronously.
# @POST: Returns extracted filter state or empty dict if no filters found.
# @DATA_CONTRACT: Input[url: str] -> Output[Dict]
# @RELATION: [CALLS] ->[extract_native_filters_from_permalink_async]
# @RELATION: [CALLS] ->[extract_native_filters_from_key_async]
async def parse_dashboard_url_for_filters_async(self, url: str) -> Dict:
with belief_scope(
"AsyncSupersetClient.parse_dashboard_url_for_filters_async", f"url={url}"
@@ -662,7 +671,9 @@ class AsyncSupersetClient(SupersetClient):
return result
# #endregion parse_dashboard_url_for_filters_async
# [/DEF:parse_dashboard_url_for_filters_async:Function]
# #endregion AsyncSupersetClient
# #endregion AsyncSupersetClientModule

View File

@@ -1,7 +1,8 @@
# #region test_auth [C:3] [TYPE Module]
# @BRIEF Unit tests for authentication module
# @LAYER Domain
# @RELATION VERIFIES -> [AuthPackage]
# [DEF:test_auth:Module]
# @COMPLEXITY: 3
# @PURPOSE: Unit tests for authentication module
# @LAYER: Domain
# @RELATION: VERIFIES -> AuthPackage
import sys
from pathlib import Path
@@ -57,9 +58,9 @@ def auth_repo(db_session):
return AuthRepository(db_session)
# #region test_create_user [TYPE Function]
# @BRIEF Verifies that a persisted user can be retrieved with intact credential hash.
# @RELATION BINDS_TO -> [test_auth]
# [DEF:test_create_user:Function]
# @PURPOSE: Verifies that a persisted user can be retrieved with intact credential hash.
# @RELATION: BINDS_TO -> test_auth
def test_create_user(auth_repo):
"""Test user creation"""
user = User(
@@ -79,12 +80,12 @@ def test_create_user(auth_repo):
assert verify_password("testpassword123", retrieved_user.password_hash)
# #endregion test_create_user
# [/DEF:test_create_user:Function]
# #region test_authenticate_user [TYPE Function]
# @BRIEF Validates authentication outcomes for valid, wrong-password, and unknown-user cases.
# @RELATION BINDS_TO -> [test_auth]
# [DEF:test_authenticate_user:Function]
# @PURPOSE: Validates authentication outcomes for valid, wrong-password, and unknown-user cases.
# @RELATION: BINDS_TO -> test_auth
def test_authenticate_user(auth_service, auth_repo):
"""Test user authentication with valid and invalid credentials"""
user = User(
@@ -111,12 +112,12 @@ def test_authenticate_user(auth_service, auth_repo):
assert invalid_user is None
# #endregion test_authenticate_user
# [/DEF:test_authenticate_user:Function]
# #region test_create_session [TYPE Function]
# @BRIEF Ensures session creation returns bearer token payload fields.
# @RELATION BINDS_TO -> [test_auth]
# [DEF:test_create_session:Function]
# @PURPOSE: Ensures session creation returns bearer token payload fields.
# @RELATION: BINDS_TO -> test_auth
def test_create_session(auth_service, auth_repo):
"""Test session token creation"""
user = User(
@@ -136,12 +137,12 @@ def test_create_session(auth_service, auth_repo):
assert len(session["access_token"]) > 0
# #endregion test_create_session
# [/DEF:test_create_session:Function]
# #region test_role_permission_association [TYPE Function]
# @BRIEF Confirms role-permission many-to-many assignments persist and reload correctly.
# @RELATION BINDS_TO -> [test_auth]
# [DEF:test_role_permission_association:Function]
# @PURPOSE: Confirms role-permission many-to-many assignments persist and reload correctly.
# @RELATION: BINDS_TO -> test_auth
def test_role_permission_association(auth_repo):
"""Test role and permission association"""
role = Role(name="Admin", description="System administrator")
@@ -162,12 +163,12 @@ def test_role_permission_association(auth_repo):
assert "admin:users:WRITE" in permissions
# #endregion test_role_permission_association
# [/DEF:test_role_permission_association:Function]
# #region test_user_role_association [TYPE Function]
# @BRIEF Confirms user-role assignment persists and is queryable from repository reads.
# @RELATION BINDS_TO -> [test_auth]
# [DEF:test_user_role_association:Function]
# @PURPOSE: Confirms user-role assignment persists and is queryable from repository reads.
# @RELATION: BINDS_TO -> test_auth
def test_user_role_association(auth_repo):
"""Test user and role association"""
role = Role(name="Admin", description="System administrator")
@@ -190,12 +191,12 @@ def test_user_role_association(auth_repo):
assert retrieved_user.roles[0].name == "Admin"
# #endregion test_user_role_association
# [/DEF:test_user_role_association:Function]
# #region test_ad_group_mapping [TYPE Function]
# @BRIEF Verifies AD group mapping rows persist and reference the expected role.
# @RELATION BINDS_TO -> [test_auth]
# [DEF:test_ad_group_mapping:Function]
# @PURPOSE: Verifies AD group mapping rows persist and reference the expected role.
# @RELATION: BINDS_TO -> test_auth
def test_ad_group_mapping(auth_repo):
"""Test AD group mapping"""
role = Role(name="ADFS_Admin", description="ADFS administrators")
@@ -217,12 +218,12 @@ def test_ad_group_mapping(auth_repo):
assert retrieved_mapping.role_id == role.id
# #endregion test_ad_group_mapping
# [/DEF:test_ad_group_mapping:Function]
# #region test_authenticate_user_updates_last_login [TYPE Function]
# @BRIEF Verifies successful authentication updates last_login audit field.
# @RELATION BINDS_TO -> [test_auth]
# [DEF:test_authenticate_user_updates_last_login:Function]
# @PURPOSE: Verifies successful authentication updates last_login audit field.
# @RELATION: BINDS_TO -> test_auth
def test_authenticate_user_updates_last_login(auth_service, auth_repo):
"""@SIDE_EFFECT: authenticate_user updates last_login timestamp on success."""
user = User(
@@ -241,12 +242,12 @@ def test_authenticate_user_updates_last_login(auth_service, auth_repo):
assert authenticated.last_login is not None
# #endregion test_authenticate_user_updates_last_login
# [/DEF:test_authenticate_user_updates_last_login:Function]
# #region test_authenticate_inactive_user [TYPE Function]
# @BRIEF Verifies inactive accounts are rejected during password authentication.
# @RELATION BINDS_TO -> [test_auth]
# [DEF:test_authenticate_inactive_user:Function]
# @PURPOSE: Verifies inactive accounts are rejected during password authentication.
# @RELATION: BINDS_TO -> test_auth
def test_authenticate_inactive_user(auth_service, auth_repo):
"""@PRE: User with is_active=False should not authenticate."""
user = User(
@@ -263,24 +264,24 @@ def test_authenticate_inactive_user(auth_service, auth_repo):
assert result is None
# #endregion test_authenticate_inactive_user
# [/DEF:test_authenticate_inactive_user:Function]
# #region test_verify_password_empty_hash [TYPE Function]
# @BRIEF Verifies password verification safely rejects empty or null password hashes.
# @RELATION BINDS_TO -> [test_auth]
# [DEF:test_verify_password_empty_hash:Function]
# @PURPOSE: Verifies password verification safely rejects empty or null password hashes.
# @RELATION: BINDS_TO -> test_auth
def test_verify_password_empty_hash():
"""@PRE: verify_password with empty/None hash returns False."""
assert verify_password("anypassword", "") is False
assert verify_password("anypassword", None) is False
# #endregion test_verify_password_empty_hash
# [/DEF:test_verify_password_empty_hash:Function]
# #region test_provision_adfs_user_new [TYPE Function]
# @BRIEF Verifies JIT provisioning creates a new ADFS user and maps group-derived roles.
# @RELATION BINDS_TO -> [test_auth]
# [DEF:test_provision_adfs_user_new:Function]
# @PURPOSE: Verifies JIT provisioning creates a new ADFS user and maps group-derived roles.
# @RELATION: BINDS_TO -> test_auth
def test_provision_adfs_user_new(auth_service, auth_repo):
"""@POST: provision_adfs_user creates a new ADFS user with correct roles."""
# Set up a role and AD group mapping
@@ -307,7 +308,7 @@ def test_provision_adfs_user_new(auth_service, auth_repo):
assert user.roles[0].name == "ADFS_Viewer"
# #endregion test_provision_adfs_user_new
# [/DEF:test_provision_adfs_user_new:Function]
# [DEF:test_provision_adfs_user_existing:Function]
@@ -337,5 +338,5 @@ def test_provision_adfs_user_existing(auth_service, auth_repo):
assert len(user.roles) == 0 # No matching group mappings
# #endregion test_auth
# #endregion test_provision_adfs_user_existing
# [/DEF:test_auth:Module]
# [/DEF:test_provision_adfs_user_existing:Function]

View File

@@ -1,21 +1,19 @@
# #region AuthConfigModule [C:2] [TYPE Module] [SEMANTICS auth, config, settings, jwt, adfs]
#
# @BRIEF Centralized configuration for authentication and authorization.
# @LAYER Core
# @INVARIANT All sensitive configuration must have defaults or be loaded from environment.
# @RELATION DEPENDS_ON -> [pydantic]
#
# @LAYER: Core
# @RELATION DEPENDS_ON -> pydantic
#
# @INVARIANT: All sensitive configuration must have defaults or be loaded from environment.
# [SECTION: IMPORTS]
from pydantic import Field
from pydantic_settings import BaseSettings
# [/SECTION]
# #region AuthConfig [TYPE Class]
# @BRIEF Holds authentication-related settings.
# @PRE Environment variables may be provided via .env file.
# @POST Returns a configuration object with validated settings.
# @RELATION INHERITS -> [pydantic_settings.BaseSettings]
# @PRE: Environment variables may be provided via .env file.
# @POST: Returns a configuration object with validated settings.
# @RELATION INHERITS -> pydantic_settings.BaseSettings
class AuthConfig(BaseSettings):
# JWT Settings
SECRET_KEY: str = Field(default="super-secret-key-change-in-production", env="AUTH_SECRET_KEY")
@@ -41,7 +39,7 @@ class AuthConfig(BaseSettings):
# #region auth_config [TYPE Variable]
# @BRIEF Singleton instance of AuthConfig.
# @RELATION DEPENDS_ON -> [AuthConfig]
# @RELATION DEPENDS_ON -> AuthConfig
auth_config = AuthConfig()
# #endregion auth_config

View File

@@ -1,30 +1,25 @@
# #region AuthJwtModule [C:3] [TYPE Module] [SEMANTICS jwt, token, session, auth]
#
# @BRIEF JWT token generation and validation logic.
# @LAYER Core
# @INVARIANT Tokens must include expiration time and user identifier.
# @LAYER: Core
# @RELATION DEPENDS_ON -> [auth_config]
# @RELATION USES -> [auth_config]
#
#
# @INVARIANT: Tokens must include expiration time and user identifier.
# [SECTION: IMPORTS]
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt
from .config import auth_config
from ..logger import belief_scope
# [/SECTION]
# #region create_access_token [TYPE Function]
# @BRIEF Generates a new JWT access token.
# @PRE data dict contains 'sub' (user_id) and optional 'scopes' (roles).
# @POST Returns a signed JWT string.
# @PRE: data dict contains 'sub' (user_id) and optional 'scopes' (roles).
# @POST: Returns a signed JWT string.
# @RELATION DEPENDS_ON -> [auth_config]
#
# @PARAM: data (dict) - Payload data for the token.
# @PARAM: expires_delta (Optional[timedelta]) - Custom expiration time.
# @RETURN: str - The encoded JWT.
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
with belief_scope("create_access_token"):
to_encode = data.copy()
@@ -47,13 +42,10 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
# #region decode_token [TYPE Function]
# @BRIEF Decodes and validates a JWT token.
# @PRE token is a signed JWT string.
# @POST Returns the decoded payload if valid.
# @PRE: token is a signed JWT string.
# @POST: Returns the decoded payload if valid.
# @RELATION DEPENDS_ON -> [auth_config]
#
# @PARAM: token (str) - The JWT to decode.
# @RETURN: dict - The decoded payload.
# @THROW: jose.JWTError - If token is invalid or expired.
def decode_token(token: str) -> dict:
with belief_scope("decode_token"):
payload = jwt.decode(

View File

@@ -1,24 +1,19 @@
# #region AuthLoggerModule [C:3] [TYPE Module] [SEMANTICS auth, logger, audit, security]
#
# @BRIEF Audit logging for security-related events.
# @LAYER Core
# @INVARIANT Must not log sensitive data like passwords or full tokens.
# @RELATION USES -> [belief_scope]
#
# @LAYER: Core
# @RELATION USES -> belief_scope
#
# @INVARIANT: Must not log sensitive data like passwords or full tokens.
# [SECTION: IMPORTS]
from ..logger import logger, belief_scope
from datetime import datetime
# [/SECTION]
# #region log_security_event [TYPE Function]
# @BRIEF Logs a security-related event for audit trails.
# @PRE event_type and username are strings.
# @POST Security event is written to the application log.
# @RELATION USES -> [logger]
# @PARAM: event_type (str) - Type of event (e.g., LOGIN_SUCCESS, PERMISSION_DENIED).
# @PARAM: username (str) - The user involved in the event.
# @PARAM: details (dict) - Additional non-sensitive metadata.
# @PRE: event_type and username are strings.
# @POST: Security event is written to the application log.
# @RELATION USES -> logger
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()
@@ -28,4 +23,4 @@ def log_security_event(event_type: str, username: str, details: dict = None):
logger.info(msg)
# #endregion log_security_event
# #endregion AuthLoggerModule
# #endregion AuthLoggerModule

View File

@@ -1,29 +1,27 @@
# #region AuthOauthModule [C:2] [TYPE Module] [SEMANTICS auth, oauth, oidc, adfs]
#
# @BRIEF ADFS OIDC configuration and client using Authlib.
# @LAYER Core
# @INVARIANT Must use secure OIDC flows.
# @RELATION DEPENDS_ON -> [authlib]
# @RELATION USES -> [auth_config]
#
# @LAYER: Core
# @RELATION DEPENDS_ON -> authlib
# @RELATION USES -> auth_config
#
# @INVARIANT: Must use secure OIDC flows.
# [SECTION: IMPORTS]
from authlib.integrations.starlette_client import OAuth
from .config import auth_config
# [/SECTION]
# #region oauth [TYPE Variable]
# @BRIEF Global Authlib OAuth registry.
# @RELATION DEPENDS_ON -> [OAuth]
# @RELATION DEPENDS_ON -> OAuth
oauth = OAuth()
# #endregion oauth
# #region register_adfs [TYPE Function]
# @BRIEF Registers the ADFS OIDC client.
# @PRE ADFS configuration is provided in auth_config.
# @POST ADFS client is registered in oauth registry.
# @RELATION USES -> [oauth]
# @RELATION USES -> [auth_config]
# @PRE: ADFS configuration is provided in auth_config.
# @POST: ADFS client is registered in oauth registry.
# @RELATION USES -> oauth
# @RELATION USES -> auth_config
def register_adfs():
if auth_config.ADFS_CLIENT_ID:
oauth.register(
@@ -39,10 +37,9 @@ def register_adfs():
# #region is_adfs_configured [TYPE Function]
# @BRIEF Checks if ADFS is properly configured.
# @PRE None.
# @POST Returns True if ADFS client is registered, False otherwise.
# @RETURN bool - Configuration status.
# @RELATION USES -> [oauth]
# @PRE: None.
# @POST: Returns True if ADFS client is registered, False otherwise.
# @RELATION USES -> oauth
def is_adfs_configured() -> bool:
"""Check if ADFS OAuth client is registered."""
return 'adfs' in oauth._registry
@@ -51,4 +48,4 @@ def is_adfs_configured() -> bool:
# Initial registration
register_adfs()
# #endregion AuthOauthModule
# #endregion AuthOauthModule

View File

@@ -1,39 +1,37 @@
# #region AuthRepositoryModule [C:5] [TYPE Module] [SEMANTICS auth, repository, database, user, role, permission]
# @BRIEF Data access layer for authentication and user preference entities.
# @LAYER Domain
# @PRE Database connection is active.
# @POST Provides valid access to identity data.
# @SIDE_EFFECT Executes database read queries through the injected SQLAlchemy session boundary.
# @DATA_CONTRACT Input[sqlalchemy.orm.Session] -> Output[User|Role|Permission|UserDashboardPreference access]
# @INVARIANT All database read/write operations must execute via the injected SQLAlchemy session boundary.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [AuthModels]
# @RELATION DEPENDS_ON -> [ProfileModels]
# @RELATION DEPENDS_ON -> [belief_scope]
# @INVARIANT: All database read/write operations must execute via the injected SQLAlchemy session boundary.
# @DATA_CONTRACT: Input[sqlalchemy.orm.Session] -> Output[User|Role|Permission|UserDashboardPreference access]
# @PRE: Database connection is active.
# @POST: Provides valid access to identity data.
# @SIDE_EFFECT: Executes database read queries through the injected SQLAlchemy session boundary.
# [SECTION: IMPORTS]
from typing import List, Optional
from sqlalchemy.orm import Session, selectinload
from ...models.auth import Permission, Role, User, ADGroupMapping
from ...models.profile import UserDashboardPreference
from ..logger import belief_scope, logger
# [/SECTION]
# #region AuthRepository [TYPE Class]
# @BRIEF Provides low-level CRUD operations for identity and authorization records.
# @PRE Database session is bound.
# @POST Entity instances returned safely.
# @SIDE_EFFECT Performs database reads.
# @PRE: Database session is bound.
# @POST: Entity instances returned safely.
# @SIDE_EFFECT: Performs database reads.
# @RELATION DEPENDS_ON -> [AuthModels]
class AuthRepository:
def __init__(self, db: Session):
self.db = db
# #region get_user_by_id [TYPE Function]
# @BRIEF Retrieve user by UUID.
# @PRE user_id is a valid UUID string.
# @POST Returns User object if found, else None.
# @RELATION DEPENDS_ON -> [User]
# [DEF:get_user_by_id:Function]
# @PURPOSE: Retrieve user by UUID.
# @PRE: user_id is a valid UUID string.
# @POST: Returns User object if found, else None.
# @RELATION: DEPENDS_ON -> [User]
def get_user_by_id(self, user_id: str) -> Optional[User]:
with belief_scope("AuthRepository.get_user_by_id"):
logger.reason(f"Fetching user by id: {user_id}")
@@ -41,13 +39,13 @@ class AuthRepository:
logger.reflect(f"User found: {result is not None}")
return result
# #endregion get_user_by_id
# [/DEF:get_user_by_id:Function]
# #region get_user_by_username [TYPE Function]
# @BRIEF Retrieve user by username.
# @PRE username is a non-empty string.
# @POST Returns User object if found, else None.
# @RELATION DEPENDS_ON -> [User]
# [DEF:get_user_by_username:Function]
# @PURPOSE: Retrieve user by username.
# @PRE: username is a non-empty string.
# @POST: Returns User object if found, else None.
# @RELATION: DEPENDS_ON -> [User]
def get_user_by_username(self, username: str) -> Optional[User]:
with belief_scope("AuthRepository.get_user_by_username"):
logger.reason(f"Fetching user by username: {username}")
@@ -55,12 +53,12 @@ class AuthRepository:
logger.reflect(f"User found: {result is not None}")
return result
# #endregion get_user_by_username
# [/DEF:get_user_by_username:Function]
# #region get_role_by_id [TYPE Function]
# @BRIEF Retrieve role by UUID with permissions preloaded.
# @RELATION DEPENDS_ON -> [Role]
# @RELATION DEPENDS_ON -> [Permission]
# [DEF:get_role_by_id:Function]
# @PURPOSE: Retrieve role by UUID with permissions preloaded.
# @RELATION: DEPENDS_ON -> [Role]
# @RELATION: DEPENDS_ON -> [Permission]
def get_role_by_id(self, role_id: str) -> Optional[Role]:
with belief_scope("AuthRepository.get_role_by_id"):
return (
@@ -70,31 +68,31 @@ class AuthRepository:
.first()
)
# #endregion get_role_by_id
# [/DEF:get_role_by_id:Function]
# #region get_role_by_name [TYPE Function]
# @BRIEF Retrieve role by unique name.
# @RELATION DEPENDS_ON -> [Role]
# [DEF:get_role_by_name:Function]
# @PURPOSE: Retrieve role by unique name.
# @RELATION: DEPENDS_ON -> [Role]
def get_role_by_name(self, name: str) -> Optional[Role]:
with belief_scope("AuthRepository.get_role_by_name"):
return self.db.query(Role).filter(Role.name == name).first()
# #endregion get_role_by_name
# [/DEF:get_role_by_name:Function]
# #region get_permission_by_id [TYPE Function]
# @BRIEF Retrieve permission by UUID.
# @RELATION DEPENDS_ON -> [Permission]
# [DEF:get_permission_by_id:Function]
# @PURPOSE: Retrieve permission by UUID.
# @RELATION: DEPENDS_ON -> [Permission]
def get_permission_by_id(self, permission_id: str) -> Optional[Permission]:
with belief_scope("AuthRepository.get_permission_by_id"):
return (
self.db.query(Permission).filter(Permission.id == permission_id).first()
)
# #endregion get_permission_by_id
# [/DEF:get_permission_by_id:Function]
# #region get_permission_by_resource_action [TYPE Function]
# @BRIEF Retrieve permission by resource and action tuple.
# @RELATION DEPENDS_ON -> [Permission]
# [DEF:get_permission_by_resource_action:Function]
# @PURPOSE: Retrieve permission by resource and action tuple.
# @RELATION: DEPENDS_ON -> [Permission]
def get_permission_by_resource_action(
self, resource: str, action: str
) -> Optional[Permission]:
@@ -105,20 +103,20 @@ class AuthRepository:
.first()
)
# #endregion get_permission_by_resource_action
# [/DEF:get_permission_by_resource_action:Function]
# #region list_permissions [TYPE Function]
# @BRIEF List all system permissions.
# @RELATION DEPENDS_ON -> [Permission]
# [DEF:list_permissions:Function]
# @PURPOSE: List all system permissions.
# @RELATION: DEPENDS_ON -> [Permission]
def list_permissions(self) -> List[Permission]:
with belief_scope("AuthRepository.list_permissions"):
return self.db.query(Permission).all()
# #endregion list_permissions
# [/DEF:list_permissions:Function]
# #region get_user_dashboard_preference [TYPE Function]
# @BRIEF Retrieve dashboard filters/preferences for a user.
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
# [DEF:get_user_dashboard_preference:Function]
# @PURPOSE: Retrieve dashboard filters/preferences for a user.
# @RELATION: DEPENDS_ON -> [UserDashboardPreference]
def get_user_dashboard_preference(
self, user_id: str
) -> Optional[UserDashboardPreference]:
@@ -129,14 +127,14 @@ class AuthRepository:
.first()
)
# #endregion get_user_dashboard_preference
# [/DEF:get_user_dashboard_preference:Function]
# #region get_roles_by_ad_groups [TYPE Function]
# @BRIEF Retrieve roles that match a list of AD group names.
# @PRE groups is a list of strings representing AD group identifiers.
# @POST Returns a list of Role objects mapped to the provided AD groups.
# @RELATION DEPENDS_ON -> [Role]
# @RELATION DEPENDS_ON -> [ADGroupMapping]
# [DEF:get_roles_by_ad_groups:Function]
# @PURPOSE: Retrieve roles that match a list of AD group names.
# @PRE: groups is a list of strings representing AD group identifiers.
# @POST: Returns a list of Role objects mapped to the provided AD groups.
# @RELATION: DEPENDS_ON -> [Role]
# @RELATION: DEPENDS_ON -> [ADGroupMapping]
def get_roles_by_ad_groups(self, groups: List[str]) -> List[Role]:
with belief_scope("AuthRepository.get_roles_by_ad_groups"):
logger.reason(f"Fetching roles for AD groups: {groups}")
@@ -149,7 +147,7 @@ class AuthRepository:
.all()
)
# #endregion get_roles_by_ad_groups
# [/DEF:get_roles_by_ad_groups:Function]
# #endregion AuthRepository

View File

@@ -1,24 +1,19 @@
# #region AuthSecurityModule [C:2] [TYPE Module] [SEMANTICS security, password, hashing, bcrypt]
#
# @BRIEF Utility for password hashing and verification using Passlib.
# @LAYER Core
# @INVARIANT Uses bcrypt for hashing with standard work factor.
# @RELATION DEPENDS_ON -> [bcrypt]
#
# @LAYER: Core
# @RELATION DEPENDS_ON -> bcrypt
#
# @INVARIANT: Uses bcrypt for hashing with standard work factor.
# [SECTION: IMPORTS]
import bcrypt
# [/SECTION]
# #region verify_password [TYPE Function]
# @BRIEF Verifies a plain password against a hashed password.
# @PRE plain_password is a string, hashed_password is a bcrypt hash.
# @POST Returns True if password matches, False otherwise.
# @RELATION DEPENDS_ON -> [bcrypt]
# @PRE: plain_password is a string, hashed_password is a bcrypt hash.
# @POST: Returns True if password matches, False otherwise.
# @RELATION DEPENDS_ON -> bcrypt
#
# @PARAM: plain_password (str) - The unhashed password.
# @PARAM: hashed_password (str) - The stored hash.
# @RETURN: bool - Verification result.
def verify_password(plain_password: str, hashed_password: str) -> bool:
if not hashed_password:
return False
@@ -33,12 +28,10 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
# #region get_password_hash [TYPE Function]
# @BRIEF Generates a bcrypt hash for a plain password.
# @PRE password is a string.
# @POST Returns a secure bcrypt hash string.
# @RELATION DEPENDS_ON -> [bcrypt]
# @PRE: password is a string.
# @POST: Returns a secure bcrypt hash string.
# @RELATION DEPENDS_ON -> bcrypt
#
# @PARAM: password (str) - The password to hash.
# @RETURN: str - The generated hash.
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
# #endregion get_password_hash

View File

@@ -1,18 +1,18 @@
# #region ConfigManager [C:5] [TYPE Module] [SEMANTICS config, manager, persistence, migration, postgresql]
#
# @BRIEF Manages application configuration persistence in DB with one-time migration from legacy JSON.
# @LAYER Domain
# @PRE Database schema for AppConfigRecord must be initialized.
# @POST Configuration is loaded into memory and logger is configured.
# @SIDE_EFFECT Performs DB I/O and may update global logging level.
# @DATA_CONTRACT Input[json, record] -> Model[AppConfig]
# @INVARIANT Configuration must always be representable by AppConfig and persisted under global record id.
# @LAYER: Domain
# @PRE: Database schema for AppConfigRecord must be initialized.
# @POST: Configuration is loaded into memory and logger is configured.
# @SIDE_EFFECT: Performs DB I/O and may update global logging level.
# @DATA_CONTRACT: Input[json, record] -> Model[AppConfig]
# @INVARIANT: Configuration must always be representable by AppConfig and persisted under global record id.
# @RELATION DEPENDS_ON -> [AppConfig]
# @RELATION DEPENDS_ON -> [SessionLocal]
# @RELATION DEPENDS_ON -> [AppConfigRecord]
# @RELATION CALLS -> [logger]
# @RELATION CALLS -> [configure_logger]
#
#
import json
import os
from pathlib import Path
@@ -24,30 +24,30 @@ from .config_models import AppConfig, Environment, GlobalSettings
from .database import SessionLocal
from ..models.config import AppConfigRecord
from ..models.mapping import Environment as EnvironmentRecord
from .logger import configure_logger, belief_scope
from .cot_logger import MarkerLogger
from .logger import logger, configure_logger, belief_scope
log = MarkerLogger("ConfigManager")
# #region ConfigManager [C:5] [TYPE Class]
# @BRIEF Handles application configuration load, validation, mutation, and persistence lifecycle.
# @PRE Database is accessible and AppConfigRecord schema is loaded.
# @POST Configuration state is synchronized between memory and database.
# @SIDE_EFFECT Performs DB I/O, OS path validation, and logger reconfiguration.
# @PRE: Database is accessible and AppConfigRecord schema is loaded.
# @POST: Configuration state is synchronized between memory and database.
# @SIDE_EFFECT: Performs DB I/O, OS path validation, and logger reconfiguration.
class ConfigManager:
# #region __init__ [TYPE Function]
# @BRIEF Initialize manager state from persisted or migrated configuration.
# @PRE config_path is a non-empty string path.
# @POST self.config is initialized as AppConfig and logger is configured.
# @SIDE_EFFECT Reads config sources and updates logging configuration.
# @DATA_CONTRACT Input(str config_path) -> Output(None; self.config: AppConfig)
# [DEF:__init__:Function]
# @PURPOSE: Initialize manager state from persisted or migrated configuration.
# @PRE: config_path is a non-empty string path.
# @POST: self.config is initialized as AppConfig and logger is configured.
# @SIDE_EFFECT: Reads config sources and updates logging configuration.
# @DATA_CONTRACT: Input(str config_path) -> Output(None; self.config: AppConfig)
def __init__(self, config_path: str = "config.json"):
with belief_scope("ConfigManager.__init__"):
if not isinstance(config_path, str) or not config_path:
log.explore("Invalid config_path provided", error="config_path must be a non-empty string", payload={"path": config_path})
logger.explore(
"Invalid config_path provided", extra={"path": config_path}
)
raise ValueError("config_path must be a non-empty string")
log.reason(f"Initializing ConfigManager with legacy path: {config_path}")
logger.reason(f"Initializing ConfigManager with legacy path: {config_path}")
self.config_path = Path(config_path)
self.raw_payload: dict[str, Any] = {}
@@ -56,17 +56,20 @@ class ConfigManager:
configure_logger(self.config.settings.logging)
if not isinstance(self.config, AppConfig):
log.explore("Config loading resulted in invalid type", error="Loaded config is not an AppConfig instance", payload={"type": type(self.config)})
logger.explore(
"Config loading resulted in invalid type",
extra={"type": type(self.config)},
)
raise TypeError("self.config must be an instance of AppConfig")
log.reflect("ConfigManager initialization complete")
logger.reflect("ConfigManager initialization complete")
# #endregion __init__
# [/DEF:__init__:Function]
# #region _apply_features_from_env [TYPE Function]
# @BRIEF Read FEATURES__* env vars and apply them to a GlobalSettings features config.
# @SIDE_EFFECT Reads os.environ; mutates settings.features in-place.
# @RATIONALE Env vars seed the initial defaults. After first bootstrap, DB is source of truth.
# [DEF:_apply_features_from_env:Function]
# @PURPOSE: Read FEATURES__* env vars and apply them to a GlobalSettings features config.
# @SIDE_EFFECT: Reads os.environ; mutates settings.features in-place.
# @RATIONALE: Env vars seed the initial defaults. After first bootstrap, DB is source of truth.
@staticmethod
def _apply_features_from_env(settings: GlobalSettings) -> None:
with belief_scope("ConfigManager._apply_features_from_env"):
@@ -74,35 +77,35 @@ class ConfigManager:
if dataset_review_env is not None:
parsed = dataset_review_env.strip().lower() in ("true", "1", "yes")
settings.features.dataset_review = parsed
log.reason(
logger.reason(
"Applied FEATURES__DATASET_REVIEW from env",
payload={"value": parsed, "raw": dataset_review_env},
extra={"value": parsed, "raw": dataset_review_env},
)
health_monitor_env = os.getenv("FEATURES__HEALTH_MONITOR")
if health_monitor_env is not None:
parsed = health_monitor_env.strip().lower() in ("true", "1", "yes")
settings.features.health_monitor = parsed
log.reason(
logger.reason(
"Applied FEATURES__HEALTH_MONITOR from env",
payload={"value": parsed, "raw": health_monitor_env},
extra={"value": parsed, "raw": health_monitor_env},
)
# #endregion _apply_features_from_env
# [/DEF:_apply_features_from_env:Function]
# #region _default_config [TYPE Function]
# @BRIEF Build default application configuration fallback.
# [DEF:_default_config:Function]
# @PURPOSE: Build default application configuration fallback.
def _default_config(self) -> AppConfig:
with belief_scope("ConfigManager._default_config"):
log.reason("Building default AppConfig fallback")
logger.reason("Building default AppConfig fallback")
config = AppConfig(environments=[], settings=GlobalSettings())
self._apply_features_from_env(config.settings)
return config
# #endregion _default_config
# [/DEF:_default_config:Function]
# #region _sync_raw_payload_from_config [TYPE Function]
# @BRIEF Merge typed AppConfig state into raw payload while preserving unsupported legacy sections.
# [DEF:_sync_raw_payload_from_config:Function]
# @PURPOSE: Merge typed AppConfig state into raw payload while preserving unsupported legacy sections.
def _sync_raw_payload_from_config(self) -> dict[str, Any]:
with belief_scope("ConfigManager._sync_raw_payload_from_config"):
typed_payload = self.config.model_dump()
@@ -110,9 +113,9 @@ class ConfigManager:
merged_payload["environments"] = typed_payload.get("environments", [])
merged_payload["settings"] = typed_payload.get("settings", {})
self.raw_payload = merged_payload
log.reason(
logger.reason(
"Synchronized raw payload from typed config",
payload={
extra={
"environments_count": len(
merged_payload.get("environments", []) or []
),
@@ -126,42 +129,45 @@ class ConfigManager:
)
return merged_payload
# #endregion _sync_raw_payload_from_config
# [/DEF:_sync_raw_payload_from_config:Function]
# #region _load_from_legacy_file [TYPE Function]
# @BRIEF Load legacy JSON configuration for migration fallback path.
# [DEF:_load_from_legacy_file:Function]
# @PURPOSE: Load legacy JSON configuration for migration fallback path.
def _load_from_legacy_file(self) -> dict[str, Any]:
with belief_scope("ConfigManager._load_from_legacy_file"):
if not self.config_path.exists():
log.reason(
logger.reason(
"Legacy config file not found; using default payload",
payload={"path": str(self.config_path)},
extra={"path": str(self.config_path)},
)
return {}
log.reason(
"Loading legacy config file", payload={"path": str(self.config_path)}
logger.reason(
"Loading legacy config file", extra={"path": str(self.config_path)}
)
with self.config_path.open("r", encoding="utf-8") as fh:
payload = json.load(fh)
if not isinstance(payload, dict):
log.explore("Legacy config payload is not a JSON object", error=f"Expected dict, got {type(payload).__name__}", payload={
"path": str(self.config_path),
"type": type(payload).__name__,
})
logger.explore(
"Legacy config payload is not a JSON object",
extra={
"path": str(self.config_path),
"type": type(payload).__name__,
},
)
raise ValueError("Legacy config payload must be a JSON object")
log.reason(
logger.reason(
"Legacy config file loaded successfully",
payload={"path": str(self.config_path), "keys": sorted(payload.keys())},
extra={"path": str(self.config_path), "keys": sorted(payload.keys())},
)
return payload
# #endregion _load_from_legacy_file
# [/DEF:_load_from_legacy_file:Function]
# #region _get_record [TYPE Function]
# @BRIEF Resolve global configuration record from DB.
# [DEF:_get_record:Function]
# @PURPOSE: Resolve global configuration record from DB.
def _get_record(self, session: Session) -> Optional[AppConfigRecord]:
with belief_scope("ConfigManager._get_record"):
record = (
@@ -169,24 +175,24 @@ class ConfigManager:
.filter(AppConfigRecord.id == "global")
.first()
)
log.reason(
"Resolved app config record", payload={"exists": record is not None}
logger.reason(
"Resolved app config record", extra={"exists": record is not None}
)
return record
# #endregion _get_record
# [/DEF:_get_record:Function]
# #region _load_config [TYPE Function]
# @BRIEF Load configuration from DB or perform one-time migration from legacy JSON.
# [DEF:_load_config:Function]
# @PURPOSE: Load configuration from DB or perform one-time migration from legacy JSON.
def _load_config(self) -> AppConfig:
with belief_scope("ConfigManager._load_config"):
session = SessionLocal()
try:
record = self._get_record(session)
if record and isinstance(record.payload, dict):
log.reason(
logger.reason(
"Loading configuration from database",
payload={"record_id": record.id},
extra={"record_id": record.id},
)
self.raw_payload = dict(record.payload)
config = AppConfig.model_validate(
@@ -197,18 +203,18 @@ class ConfigManager:
)
self._sync_environment_records(session, config)
session.commit()
log.reason(
logger.reason(
"Database configuration validated successfully",
payload={
extra={
"environments_count": len(config.environments),
"payload_keys": sorted(self.raw_payload.keys()),
},
)
return config
log.reason(
logger.reason(
"Database configuration record missing; attempting legacy file migration",
payload={"legacy_path": str(self.config_path)},
extra={"legacy_path": str(self.config_path)},
)
legacy_payload = self._load_from_legacy_file()
@@ -221,9 +227,9 @@ class ConfigManager:
}
)
self._apply_features_from_env(config.settings)
log.reason(
logger.reason(
"Legacy payload validated; persisting migrated configuration to database",
payload={
extra={
"environments_count": len(config.environments),
"payload_keys": sorted(self.raw_payload.keys()),
},
@@ -231,7 +237,7 @@ class ConfigManager:
self._save_config_to_db(config, session=session)
return config
log.reason(
logger.reason(
"No persisted config found; falling back to default configuration"
)
config = self._default_config()
@@ -239,20 +245,26 @@ class ConfigManager:
self._save_config_to_db(config, session=session)
return config
except (json.JSONDecodeError, TypeError, ValueError) as exc:
log.explore("Recoverable config load failure; falling back to default configuration", error=str(exc), payload={"legacy_path": str(self.config_path)})
logger.explore(
"Recoverable config load failure; falling back to default configuration",
extra={"error": str(exc), "legacy_path": str(self.config_path)},
)
config = self._default_config()
self.raw_payload = config.model_dump()
return config
except Exception as exc:
log.explore("Critical config load failure; re-raising persistence or validation error", error=str(exc))
logger.explore(
"Critical config load failure; re-raising persistence or validation error",
extra={"error": str(exc)},
)
raise
finally:
session.close()
# #endregion _load_config
# [/DEF:_load_config:Function]
# #region _sync_environment_records [TYPE Function]
# @BRIEF Mirror configured environments into the relational environments table used by FK-backed domain models.
# [DEF:_sync_environment_records:Function]
# @PURPOSE: Mirror configured environments into the relational environments table used by FK-backed domain models.
def _sync_environment_records(self, session: Session, config: AppConfig) -> None:
with belief_scope("ConfigManager._sync_environment_records"):
configured_envs = list(config.environments or [])
@@ -276,9 +288,9 @@ class ConfigManager:
record = persisted_by_id.get(normalized_id)
if record is None:
log.reason(
logger.reason(
"Creating relational environment record from typed config",
payload={
extra={
"environment_id": normalized_id,
"environment_name": display_name,
},
@@ -297,10 +309,37 @@ class ConfigManager:
record.url = normalized_url
record.credentials_id = credentials_id
# #endregion _sync_environment_records
# [/DEF:_sync_environment_records:Function]
# #region _save_config_to_db [TYPE Function]
# @BRIEF Persist provided AppConfig into the global DB configuration record.
# [DEF:_delete_stale_environment_records:Function]
# @PURPOSE: Remove persisted environment records that are no longer in the configured environments.
# @PRE: _sync_environment_records must already have run so the query returns a current view.
# @POST: Stale Environment rows are deleted via the session (caller must commit).
# @SIDE_EFFECT: Only safe to call during explicit save (_save_config_to_db), NOT during _load_config,
# because FK-dependent rows (task_records, database_mappings, etc.) may reference deleted rows.
def _delete_stale_environment_records(
self, session: Session, config: AppConfig
) -> None:
with belief_scope("ConfigManager._delete_stale_environment_records"):
persisted_records = session.query(EnvironmentRecord).all()
configured_ids = {
str(env.id or "").strip()
for env in (config.environments or [])
if str(env.id or "").strip()
}
for record in persisted_records:
record_id = str(record.id or "").strip()
if record_id and record_id not in configured_ids:
logger.reason(
"Deleting stale environment record",
extra={"environment_id": record_id},
)
session.delete(record)
# [/DEF:_delete_stale_environment_records:Function]
# [DEF:_save_config_to_db:Function]
# @PURPOSE: Persist provided AppConfig into the global DB configuration record.
def _save_config_to_db(
self, config: AppConfig, session: Optional[Session] = None
) -> None:
@@ -312,22 +351,23 @@ class ConfigManager:
payload = self._sync_raw_payload_from_config()
record = self._get_record(db)
if record is None:
log.reason("Creating new global app config record")
logger.reason("Creating new global app config record")
record = AppConfigRecord(id="global", payload=payload)
db.add(record)
else:
log.reason(
logger.reason(
"Updating existing global app config record",
payload={"record_id": record.id},
extra={"record_id": record.id},
)
record.payload = payload
self._sync_environment_records(db, config)
self._delete_stale_environment_records(db, config)
db.commit()
log.reason(
logger.reason(
"Configuration persisted to database",
payload={
extra={
"environments_count": len(
payload.get("environments", []) or []
),
@@ -336,54 +376,54 @@ class ConfigManager:
)
except Exception:
db.rollback()
log.explore("Database save failed; transaction rolled back", error="Database transaction rolled back")
logger.explore("Database save failed; transaction rolled back")
raise
finally:
if owns_session:
db.close()
# #endregion _save_config_to_db
# [/DEF:_save_config_to_db:Function]
# #region save [TYPE Function]
# @BRIEF Persist current in-memory configuration state.
# [DEF:save:Function]
# @PURPOSE: Persist current in-memory configuration state.
def save(self) -> None:
with belief_scope("ConfigManager.save"):
log.reason("Persisting current in-memory configuration")
logger.reason("Persisting current in-memory configuration")
self._save_config_to_db(self.config)
# #endregion save
# [/DEF:save:Function]
# #region get_config [TYPE Function]
# @BRIEF Return current in-memory configuration snapshot.
# [DEF:get_config:Function]
# @PURPOSE: Return current in-memory configuration snapshot.
def get_config(self) -> AppConfig:
with belief_scope("ConfigManager.get_config"):
return self.config
# #endregion get_config
# [/DEF:get_config:Function]
# #region get_payload [TYPE Function]
# @BRIEF Return full persisted payload including sections outside typed AppConfig schema.
# [DEF:get_payload:Function]
# @PURPOSE: Return full persisted payload including sections outside typed AppConfig schema.
def get_payload(self) -> dict[str, Any]:
with belief_scope("ConfigManager.get_payload"):
return self._sync_raw_payload_from_config()
# #endregion get_payload
# [/DEF:get_payload:Function]
# #region save_config [TYPE Function]
# @BRIEF Persist configuration provided either as typed AppConfig or raw payload dict.
# [DEF:save_config:Function]
# @PURPOSE: Persist configuration provided either as typed AppConfig or raw payload dict.
def save_config(self, config: Any) -> AppConfig:
with belief_scope("ConfigManager.save_config"):
if isinstance(config, AppConfig):
log.reason("Saving typed AppConfig payload")
logger.reason("Saving typed AppConfig payload")
self.config = config
self.raw_payload = config.model_dump()
self._save_config_to_db(config)
return self.config
if isinstance(config, dict):
log.reason(
logger.reason(
"Saving raw config payload",
payload={"keys": sorted(config.keys())},
extra={"keys": sorted(config.keys())},
)
self.raw_payload = dict(config)
typed_config = AppConfig.model_validate(
@@ -396,24 +436,27 @@ class ConfigManager:
self._save_config_to_db(typed_config)
return self.config
log.explore("Unsupported config type supplied to save_config", error=f"Expected AppConfig or dict, got {type(config).__name__}", payload={"type": type(config).__name__})
logger.explore(
"Unsupported config type supplied to save_config",
extra={"type": type(config).__name__},
)
raise TypeError("config must be AppConfig or dict")
# #endregion save_config
# [/DEF:save_config:Function]
# #region update_global_settings [TYPE Function]
# @BRIEF Replace global settings and persist the resulting configuration.
# [DEF:update_global_settings:Function]
# @PURPOSE: Replace global settings and persist the resulting configuration.
def update_global_settings(self, settings: GlobalSettings) -> AppConfig:
with belief_scope("ConfigManager.update_global_settings"):
log.reason("Updating global settings")
logger.reason("Updating global settings")
self.config.settings = settings
self.save()
return self.config
# #endregion update_global_settings
# [/DEF:update_global_settings:Function]
# #region validate_path [TYPE Function]
# @BRIEF Validate that path exists and is writable, creating it when absent.
# [DEF:validate_path:Function]
# @PURPOSE: Validate that path exists and is writable, creating it when absent.
def validate_path(self, path: str) -> tuple[bool, str]:
with belief_scope("ConfigManager.validate_path", f"path={path}"):
try:
@@ -431,32 +474,34 @@ class ConfigManager:
fh.write("ok")
test_file.unlink(missing_ok=True)
log.reason("Path validation succeeded", payload={"path": str(target)})
logger.reason("Path validation succeeded", extra={"path": str(target)})
return True, "OK"
except Exception as exc:
log.explore("Path validation failed", error=str(exc), payload={"path": path})
logger.explore(
"Path validation failed", extra={"path": path, "error": str(exc)}
)
return False, str(exc)
# #endregion validate_path
# [/DEF:validate_path:Function]
# #region get_environments [TYPE Function]
# @BRIEF Return all configured environments.
# [DEF:get_environments:Function]
# @PURPOSE: Return all configured environments.
def get_environments(self) -> List[Environment]:
with belief_scope("ConfigManager.get_environments"):
return list(self.config.environments)
# #endregion get_environments
# [/DEF:get_environments:Function]
# #region has_environments [TYPE Function]
# @BRIEF Check whether at least one environment exists in configuration.
# [DEF:has_environments:Function]
# @PURPOSE: Check whether at least one environment exists in configuration.
def has_environments(self) -> bool:
with belief_scope("ConfigManager.has_environments"):
return len(self.config.environments) > 0
# #endregion has_environments
# [/DEF:has_environments:Function]
# #region get_environment [TYPE Function]
# @BRIEF Resolve a configured environment by identifier.
# [DEF:get_environment:Function]
# @PURPOSE: Resolve a configured environment by identifier.
def get_environment(self, env_id: str) -> Optional[Environment]:
with belief_scope("ConfigManager.get_environment", f"env_id={env_id}"):
normalized = str(env_id or "").strip()
@@ -468,10 +513,10 @@ class ConfigManager:
return env
return None
# #endregion get_environment
# [/DEF:get_environment:Function]
# #region add_environment [TYPE Function]
# @BRIEF Upsert environment by id into configuration and persist.
# [DEF:add_environment:Function]
# @PURPOSE: Upsert environment by id into configuration and persist.
def add_environment(self, env: Environment) -> AppConfig:
with belief_scope("ConfigManager.add_environment", f"env_id={env.id}"):
existing_index = next(
@@ -487,12 +532,12 @@ class ConfigManager:
item.is_default = False
if existing_index is None:
log.reason("Appending new environment", payload={"env_id": env.id})
logger.reason("Appending new environment", extra={"env_id": env.id})
self.config.environments.append(env)
else:
log.reason(
logger.reason(
"Replacing existing environment during add",
payload={"env_id": env.id},
extra={"env_id": env.id},
)
self.config.environments[existing_index] = env
@@ -504,10 +549,10 @@ class ConfigManager:
self.save()
return self.config
# #endregion add_environment
# [/DEF:add_environment:Function]
# #region update_environment [TYPE Function]
# @BRIEF Update existing environment by id and preserve masked password placeholder behavior.
# [DEF:update_environment:Function]
# @PURPOSE: Update existing environment by id and preserve masked password placeholder behavior.
def update_environment(self, env_id: str, env: Environment) -> bool:
with belief_scope("ConfigManager.update_environment", f"env_id={env_id}"):
for index, existing in enumerate(self.config.environments):
@@ -527,17 +572,19 @@ class ConfigManager:
updated.is_default = True
self.config.environments[index] = updated
log.reason("Environment updated", payload={"env_id": env_id})
logger.reason("Environment updated", extra={"env_id": env_id})
self.save()
return True
log.explore("Environment update skipped; env not found", error=f"No matching environment found for id: {env_id}", payload={"env_id": env_id})
logger.explore(
"Environment update skipped; env not found", extra={"env_id": env_id}
)
return False
# #endregion update_environment
# [/DEF:update_environment:Function]
# #region delete_environment [TYPE Function]
# @BRIEF Delete environment by id and persist when deletion occurs.
# [DEF:delete_environment:Function]
# @PURPOSE: Delete environment by id and persist when deletion occurs.
def delete_environment(self, env_id: str) -> bool:
with belief_scope("ConfigManager.delete_environment", f"env_id={env_id}"):
before = len(self.config.environments)
@@ -547,7 +594,10 @@ class ConfigManager:
]
if len(self.config.environments) == before:
log.explore("Environment delete skipped; env not found", error=f"No matching environment found for id: {env_id}", payload={"env_id": env_id})
logger.explore(
"Environment delete skipped; env not found",
extra={"env_id": env_id},
)
return False
if removed and removed[0].is_default and self.config.environments:
@@ -559,14 +609,14 @@ class ConfigManager:
)
self.config.settings.default_environment_id = replacement
log.reason(
logger.reason(
"Environment deleted",
payload={"env_id": env_id, "remaining": len(self.config.environments)},
extra={"env_id": env_id, "remaining": len(self.config.environments)},
)
self.save()
return True
# #endregion delete_environment
# [/DEF:delete_environment:Function]
# #endregion ConfigManager

View File

@@ -1,6 +1,6 @@
# #region ConfigModels [C:3] [TYPE Module] [SEMANTICS config, models, pydantic]
# @BRIEF Defines the data models for application configuration using Pydantic.
# @LAYER Core
# @LAYER: Core
# @RELATION IMPLEMENTS -> [CoreContracts]
# @RELATION IMPLEMENTS -> [ConnectionContracts]
@@ -71,7 +71,7 @@ class CleanReleaseConfig(BaseModel):
# #region FeaturesConfig [C:1] [TYPE DataClass]
# @BRIEF Top-level feature flags that toggle entire project features on/off.
# @RATIONALE Features are read from environment variables on bootstrap and persisted in DB.
# @RATIONALE: Features are read from environment variables on bootstrap and persisted in DB.
# DB is source of truth after initial bootstrap; env vars only seed defaults.
class FeaturesConfig(BaseModel):
dataset_review: bool = True

View File

@@ -3,8 +3,8 @@
# Uses ContextVar for trace_id and span_id propagation across async contexts.
# Provides log(), MarkerLogger, seed_trace_id(), push_span(), pop_span().
# @LAYER: Core
# @RELATION: [CALLED_BY] -> [TraceContextMiddleware]
# @RELATION: [CALLED_BY] -> [All C4+ service and route modules]
# @RELATION CALLED_BY -> [TraceContextMiddleware]
# @RELATION CALLED_BY -> [All C4+ service and route modules]
# @PRE: Python 3.7+ (ContextVar available).
# @POST: JSON log records written to the 'cot' logger at appropriate levels.
# @SIDE_EFFECT: Writes structured JSON to the 'cot' Python logger.

Some files were not shown because too many files have changed in this diff Show More