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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"):
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user