fix: finalize semantic repair and test updates
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
|
||||
os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w="
|
||||
# [DEF:AssistantApiTests:Module]
|
||||
# @COMPLEXITY: 3
|
||||
@@ -23,7 +24,7 @@ from src.models.assistant import AssistantMessageRecord
|
||||
|
||||
|
||||
# [DEF:_run_async:Function]
|
||||
# @RELATION: BINDS_TO -> AssistantApiTests
|
||||
# @RELATION: BINDS_TO -> [AssistantApiTests]
|
||||
def _run_async(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
@@ -33,6 +34,9 @@ def _run_async(coro):
|
||||
|
||||
# [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,
|
||||
@@ -94,6 +98,9 @@ class _FakeTaskManager:
|
||||
|
||||
# [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):
|
||||
@@ -119,7 +126,9 @@ class _FakeConfigManager:
|
||||
|
||||
|
||||
# [DEF:_admin_user:Function]
|
||||
# @RELATION: BINDS_TO -> AssistantApiTests
|
||||
# @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"
|
||||
@@ -134,7 +143,9 @@ def _admin_user():
|
||||
|
||||
|
||||
# [DEF:_limited_user:Function]
|
||||
# @RELATION: BINDS_TO -> AssistantApiTests
|
||||
# @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"
|
||||
@@ -148,6 +159,9 @@ def _limited_user():
|
||||
|
||||
# [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
|
||||
@@ -183,7 +197,7 @@ class _FakeQuery:
|
||||
# @RELATION: BINDS_TO -> [AssistantApiTests]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Explicit in-memory DB session double limited to assistant message persistence paths.
|
||||
# @INVARIANT: query/add/merge stay deterministic and never emulate unrelated SQLAlchemy behavior.
|
||||
# @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 = []
|
||||
@@ -213,7 +227,7 @@ class _FakeDb:
|
||||
|
||||
|
||||
# [DEF:_clear_assistant_state:Function]
|
||||
# @RELATION: BINDS_TO -> AssistantApiTests
|
||||
# @RELATION: BINDS_TO -> [AssistantApiTests]
|
||||
def _clear_assistant_state():
|
||||
assistant_routes.CONVERSATIONS.clear()
|
||||
assistant_routes.USER_ACTIVE_CONVERSATION.clear()
|
||||
@@ -225,7 +239,7 @@ def _clear_assistant_state():
|
||||
|
||||
|
||||
# [DEF:test_unknown_command_returns_needs_clarification:Function]
|
||||
# @RELATION: BINDS_TO -> AssistantApiTests
|
||||
# @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()
|
||||
@@ -252,7 +266,7 @@ def test_unknown_command_returns_needs_clarification(monkeypatch):
|
||||
|
||||
|
||||
# [DEF:test_capabilities_question_returns_successful_help:Function]
|
||||
# @RELATION: BINDS_TO -> AssistantApiTests
|
||||
# @RELATION: BINDS_TO -> [AssistantApiTests]
|
||||
# @PURPOSE: Capability query should return deterministic help response.
|
||||
def test_capabilities_question_returns_successful_help(monkeypatch):
|
||||
_clear_assistant_state()
|
||||
@@ -274,7 +288,4 @@ def test_capabilities_question_returns_successful_help(monkeypatch):
|
||||
|
||||
# [/DEF:test_capabilities_question_returns_successful_help:Function]
|
||||
|
||||
# ... (rest of file trimmed for length, I've seen it and I'll keep the existing [DEF]s as is but add @RELATION)
|
||||
# Note: I'll actually just provide the full file with all @RELATIONs added to reduce orphan count.
|
||||
|
||||
# [/DEF:AssistantApiTests:Module]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
|
||||
os.environ["ENCRYPTION_KEY"] = "OnrCzomBWbIjTf7Y-fnhL2adlU55bHZQjp8zX5zBC5w="
|
||||
# [DEF:TestAssistantAuthz:Module]
|
||||
# @COMPLEXITY: 3
|
||||
@@ -34,7 +35,7 @@ from src.models.assistant import (
|
||||
|
||||
|
||||
# [DEF:_run_async:Function]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Execute async endpoint handler in synchronous test context.
|
||||
# @PRE: coroutine is awaitable endpoint invocation.
|
||||
@@ -47,9 +48,11 @@ def _run_async(coroutine):
|
||||
|
||||
|
||||
# [DEF:_FakeTask:Class]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @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
|
||||
@@ -59,7 +62,7 @@ class _FakeTask:
|
||||
|
||||
# [/DEF:_FakeTask:Class]
|
||||
# [DEF:_FakeTaskManager:Class]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @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.
|
||||
@@ -85,9 +88,12 @@ class _FakeTaskManager:
|
||||
|
||||
# [/DEF:_FakeTaskManager:Class]
|
||||
# [DEF:_FakeConfigManager:Class]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @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 [
|
||||
@@ -98,7 +104,7 @@ class _FakeConfigManager:
|
||||
|
||||
# [/DEF:_FakeConfigManager:Class]
|
||||
# [DEF:_admin_user:Function]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Build admin principal fixture.
|
||||
# @PRE: Test requires privileged principal for risky operations.
|
||||
@@ -110,7 +116,7 @@ def _admin_user():
|
||||
|
||||
# [/DEF:_admin_user:Function]
|
||||
# [DEF:_other_admin_user:Function]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Build second admin principal fixture for ownership tests.
|
||||
# @PRE: Ownership mismatch scenario needs distinct authenticated actor.
|
||||
@@ -122,7 +128,7 @@ def _other_admin_user():
|
||||
|
||||
# [/DEF:_other_admin_user:Function]
|
||||
# [DEF:_limited_user:Function]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Build limited principal without required assistant execution privileges.
|
||||
# @PRE: Permission denial scenario needs non-admin actor.
|
||||
@@ -136,9 +142,10 @@ def _limited_user():
|
||||
|
||||
|
||||
# [DEF:_FakeQuery:Class]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @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)
|
||||
@@ -169,7 +176,7 @@ class _FakeQuery:
|
||||
|
||||
# [/DEF:_FakeQuery:Class]
|
||||
# [DEF:_FakeDb:Class]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @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.
|
||||
@@ -218,7 +225,7 @@ class _FakeDb:
|
||||
|
||||
# [/DEF:_FakeDb:Class]
|
||||
# [DEF:_clear_assistant_state:Function]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @RELATION: BINDS_TO -> [TestAssistantAuthz]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Reset assistant process-local state between test cases.
|
||||
# @PRE: Assistant globals may contain state from prior tests.
|
||||
@@ -234,7 +241,7 @@ def _clear_assistant_state():
|
||||
|
||||
|
||||
# [DEF:test_confirmation_owner_mismatch_returns_403:Function]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @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.
|
||||
@@ -273,7 +280,7 @@ def test_confirmation_owner_mismatch_returns_403():
|
||||
|
||||
|
||||
# [DEF:test_expired_confirmation_cannot_be_confirmed:Function]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @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.
|
||||
@@ -315,7 +322,7 @@ def test_expired_confirmation_cannot_be_confirmed():
|
||||
|
||||
|
||||
# [DEF:test_limited_user_cannot_launch_restricted_operation:Function]
|
||||
# @RELATION: BINDS_TO -> TestAssistantAuthz
|
||||
# @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.
|
||||
|
||||
@@ -79,6 +79,7 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
|
||||
|
||||
# [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
|
||||
@@ -116,6 +117,7 @@ def test_start_check_and_get_status_contract():
|
||||
|
||||
# [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
|
||||
@@ -132,6 +134,7 @@ def test_get_report_not_found_returns_404():
|
||||
|
||||
# [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(
|
||||
@@ -161,6 +164,7 @@ def test_get_report_success():
|
||||
|
||||
# [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
|
||||
|
||||
@@ -12,7 +12,9 @@ from datetime import datetime, timezone
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///./test_clean_release_legacy_compat.db")
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///./test_clean_release_legacy_auth.db")
|
||||
os.environ.setdefault(
|
||||
"AUTH_DATABASE_URL", "sqlite:///./test_clean_release_legacy_auth.db"
|
||||
)
|
||||
|
||||
from src.app import app
|
||||
from src.dependencies import get_clean_release_repository
|
||||
@@ -103,17 +105,23 @@ def _seed_legacy_repo() -> CleanReleaseRepository:
|
||||
created_at=now,
|
||||
created_by="compat-tester",
|
||||
source_snapshot_ref="git:legacy-001",
|
||||
content_json={"items": [], "summary": {"included_count": 0, "prohibited_detected_count": 0}},
|
||||
content_json={
|
||||
"items": [],
|
||||
"summary": {"included_count": 0, "prohibited_detected_count": 0},
|
||||
},
|
||||
immutable=True,
|
||||
)
|
||||
)
|
||||
|
||||
return repo
|
||||
|
||||
|
||||
# [/DEF:_seed_legacy_repo:Function]
|
||||
|
||||
|
||||
# [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
|
||||
@@ -123,7 +131,9 @@ def test_legacy_prepare_endpoint_still_available() -> None:
|
||||
"/api/clean-release/candidates/prepare",
|
||||
json={
|
||||
"candidate_id": "legacy-rc-001",
|
||||
"artifacts": [{"path": "src/main.py", "category": "core", "reason": "required"}],
|
||||
"artifacts": [
|
||||
{"path": "src/main.py", "category": "core", "reason": "required"}
|
||||
],
|
||||
"sources": ["repo.intra.company.local"],
|
||||
"operator_id": "compat-tester",
|
||||
},
|
||||
@@ -138,8 +148,10 @@ def test_legacy_prepare_endpoint_still_available() -> None:
|
||||
|
||||
# [/DEF:test_legacy_prepare_endpoint_still_available:Function]
|
||||
|
||||
|
||||
# [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
|
||||
@@ -159,7 +171,9 @@ def test_legacy_checks_endpoints_still_available() -> None:
|
||||
assert "check_run_id" in start_payload
|
||||
assert start_payload["candidate_id"] == "legacy-rc-001"
|
||||
|
||||
status_response = client.get(f"/api/clean-release/checks/{start_payload['check_run_id']}")
|
||||
status_response = client.get(
|
||||
f"/api/clean-release/checks/{start_payload['check_run_id']}"
|
||||
)
|
||||
assert status_response.status_code == 200
|
||||
status_payload = status_response.json()
|
||||
assert status_payload["check_run_id"] == start_payload["check_run_id"]
|
||||
@@ -169,4 +183,5 @@ def test_legacy_checks_endpoints_still_available() -> None:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:TestCleanReleaseLegacyCompat:Module]# [/DEF:test_legacy_checks_endpoints_still_available:Function]
|
||||
# [/DEF:test_legacy_checks_endpoints_still_available:Function]
|
||||
# [/DEF:TestCleanReleaseLegacyCompat:Module]
|
||||
|
||||
@@ -24,6 +24,7 @@ from src.services.clean_release.repository import CleanReleaseRepository
|
||||
|
||||
# [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()
|
||||
|
||||
@@ -76,8 +77,10 @@ def _repo_with_seed_data() -> CleanReleaseRepository:
|
||||
|
||||
# [/DEF:_repo_with_seed_data:Function]
|
||||
|
||||
|
||||
# [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
|
||||
@@ -89,7 +92,11 @@ def test_prepare_candidate_blocks_external_source():
|
||||
json={
|
||||
"candidate_id": "2026.03.03-rc1",
|
||||
"artifacts": [
|
||||
{"path": "cfg/system.yaml", "category": "system-init", "reason": "required"}
|
||||
{
|
||||
"path": "cfg/system.yaml",
|
||||
"category": "system-init",
|
||||
"reason": "required",
|
||||
}
|
||||
],
|
||||
"sources": ["repo.intra.company.local", "pypi.org"],
|
||||
"operator_id": "release-manager",
|
||||
@@ -103,4 +110,5 @@ def test_prepare_candidate_blocks_external_source():
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# [/DEF:TestCleanReleaseSourcePolicy:Module]# [/DEF:test_prepare_candidate_blocks_external_source:Function]
|
||||
# [/DEF:test_prepare_candidate_blocks_external_source:Function]
|
||||
# [/DEF:TestCleanReleaseSourcePolicy:Module]
|
||||
|
||||
@@ -27,6 +27,7 @@ client = TestClient(app)
|
||||
# [REASON] Implementing API contract tests for candidate/artifact/manifest endpoints (T012).
|
||||
# [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.
|
||||
@@ -50,6 +51,7 @@ def test_candidate_registration_contract():
|
||||
|
||||
# [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.
|
||||
@@ -84,6 +86,7 @@ def test_artifact_import_contract():
|
||||
|
||||
# [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.
|
||||
|
||||
@@ -25,6 +25,7 @@ client = TestClient(test_app)
|
||||
|
||||
# [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()}"
|
||||
@@ -46,7 +47,11 @@ def _seed_candidate_and_passed_report() -> tuple[str, str]:
|
||||
run_id=f"run-{uuid4()}",
|
||||
candidate_id=candidate_id,
|
||||
final_status=ComplianceDecision.PASSED.value,
|
||||
summary_json={"operator_summary": "ok", "violations_count": 0, "blocking_violations_count": 0},
|
||||
summary_json={
|
||||
"operator_summary": "ok",
|
||||
"violations_count": 0,
|
||||
"blocking_violations_count": 0,
|
||||
},
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
immutable=True,
|
||||
)
|
||||
@@ -56,8 +61,10 @@ def _seed_candidate_and_passed_report() -> tuple[str, str]:
|
||||
|
||||
# [/DEF:_seed_candidate_and_passed_report:Function]
|
||||
|
||||
|
||||
# [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()
|
||||
@@ -98,8 +105,10 @@ def test_release_approve_and_publish_revoke_contract() -> None:
|
||||
|
||||
# [/DEF:test_release_approve_and_publish_revoke_contract:Function]
|
||||
|
||||
|
||||
# [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()
|
||||
@@ -114,4 +123,5 @@ def test_release_reject_contract() -> None:
|
||||
assert payload["decision"] == "REJECTED"
|
||||
|
||||
|
||||
# [/DEF:CleanReleaseV2ReleaseApiTests:Module]# [/DEF:test_release_reject_contract:Function]
|
||||
# [/DEF:test_release_reject_contract:Function]
|
||||
# [/DEF:CleanReleaseV2ReleaseApiTests:Module]
|
||||
|
||||
@@ -41,6 +41,7 @@ def db_session():
|
||||
|
||||
# [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
|
||||
|
||||
@@ -53,8 +54,10 @@ def test_list_connections_bootstraps_missing_table(db_session):
|
||||
|
||||
# [/DEF:test_list_connections_bootstraps_missing_table:Function]
|
||||
|
||||
|
||||
# [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
|
||||
|
||||
@@ -75,5 +78,6 @@ def test_create_connection_bootstraps_missing_table(db_session):
|
||||
assert created.host == "warehouse.internal"
|
||||
assert "connection_configs" in inspector.get_table_names()
|
||||
|
||||
# [/DEF:ConnectionsRoutesTests:Module]
|
||||
|
||||
# [/DEF:test_create_connection_bootstraps_missing_table:Function]
|
||||
# [/DEF:ConnectionsRoutesTests:Module]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# @SEMANTICS: datasets, api, tests, pagination, mapping, docs
|
||||
# @PURPOSE: Unit tests for datasets API endpoints.
|
||||
# @LAYER: API
|
||||
# @RELATION: DEPENDS_ON -> backend.src.api.routes.datasets
|
||||
# @RELATION: DEPENDS_ON -> [src.api.routes.datasets:Module]
|
||||
# @INVARIANT: Endpoint contracts remain stable for success and validation failure paths.
|
||||
|
||||
import pytest
|
||||
@@ -11,7 +11,14 @@ from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from fastapi.testclient import TestClient
|
||||
from src.app import app
|
||||
from src.api.routes.datasets import DatasetsResponse, DatasetDetailResponse
|
||||
from src.dependencies import get_current_user, has_permission, get_config_manager, get_task_manager, get_resource_service, get_mapping_service
|
||||
from src.dependencies import (
|
||||
get_current_user,
|
||||
has_permission,
|
||||
get_config_manager,
|
||||
get_task_manager,
|
||||
get_resource_service,
|
||||
get_mapping_service,
|
||||
)
|
||||
|
||||
# Global mock user for get_current_user dependency overrides
|
||||
mock_user = MagicMock()
|
||||
@@ -21,49 +28,58 @@ admin_role = MagicMock()
|
||||
admin_role.name = "Admin"
|
||||
mock_user.roles.append(admin_role)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_deps():
|
||||
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass
|
||||
config_manager = MagicMock()
|
||||
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass
|
||||
task_manager = MagicMock()
|
||||
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass
|
||||
resource_service = MagicMock()
|
||||
mapping_service = MagicMock()
|
||||
|
||||
|
||||
app.dependency_overrides[get_config_manager] = lambda: config_manager
|
||||
app.dependency_overrides[get_task_manager] = lambda: task_manager
|
||||
app.dependency_overrides[get_resource_service] = lambda: resource_service
|
||||
app.dependency_overrides[get_mapping_service] = lambda: mapping_service
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
|
||||
app.dependency_overrides[has_permission("plugin:migration", "READ")] = lambda: mock_user
|
||||
app.dependency_overrides[has_permission("plugin:migration", "EXECUTE")] = lambda: mock_user
|
||||
app.dependency_overrides[has_permission("plugin:backup", "EXECUTE")] = lambda: mock_user
|
||||
|
||||
app.dependency_overrides[has_permission("plugin:migration", "READ")] = (
|
||||
lambda: mock_user
|
||||
)
|
||||
app.dependency_overrides[has_permission("plugin:migration", "EXECUTE")] = (
|
||||
lambda: mock_user
|
||||
)
|
||||
app.dependency_overrides[has_permission("plugin:backup", "EXECUTE")] = (
|
||||
lambda: mock_user
|
||||
)
|
||||
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: mock_user
|
||||
|
||||
|
||||
yield {
|
||||
"config": config_manager,
|
||||
"task": task_manager,
|
||||
"resource": resource_service,
|
||||
"mapping": mapping_service
|
||||
"mapping": mapping_service,
|
||||
}
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
# [DEF:test_get_datasets_success:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
# @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
|
||||
# [DEF:test_get_datasets_success:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
def test_get_datasets_success(mock_deps):
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
|
||||
|
||||
# Mock resource service response
|
||||
mock_deps["resource"].get_datasets_with_status = AsyncMock(
|
||||
return_value=[
|
||||
@@ -73,13 +89,13 @@ def test_get_datasets_success(mock_deps):
|
||||
"schema": "public",
|
||||
"database": "sales_db",
|
||||
"mapped_fields": {"total": 10, "mapped": 5},
|
||||
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
|
||||
"last_task": {"task_id": "task-1", "status": "SUCCESS"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
response = client.get("/api/datasets?env_id=prod")
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "datasets" in data
|
||||
@@ -92,20 +108,16 @@ def test_get_datasets_success(mock_deps):
|
||||
|
||||
|
||||
# [DEF:test_get_datasets_env_not_found:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
# @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
|
||||
# [/DEF:test_get_datasets_success:Function]
|
||||
|
||||
# [DEF:test_get_datasets_env_not_found:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
def test_get_datasets_env_not_found(mock_deps):
|
||||
mock_deps["config"].get_environments.return_value = []
|
||||
|
||||
response = client.get("/api/datasets?env_id=nonexistent")
|
||||
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
|
||||
@@ -114,15 +126,11 @@ def test_get_datasets_env_not_found(mock_deps):
|
||||
|
||||
|
||||
# [DEF:test_get_datasets_invalid_pagination:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
# @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
|
||||
# [/DEF:test_get_datasets_env_not_found:Function]
|
||||
|
||||
# [DEF:test_get_datasets_invalid_pagination:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
def test_get_datasets_invalid_pagination(mock_deps):
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
@@ -132,7 +140,7 @@ def test_get_datasets_invalid_pagination(mock_deps):
|
||||
response = client.get("/api/datasets?env_id=prod&page=0")
|
||||
assert response.status_code == 400
|
||||
assert "Page must be >= 1" in response.json()["detail"]
|
||||
|
||||
|
||||
# Invalid page_size (too small)
|
||||
response = client.get("/api/datasets?env_id=prod&page_size=0")
|
||||
assert response.status_code == 400
|
||||
@@ -148,21 +156,17 @@ def test_get_datasets_invalid_pagination(mock_deps):
|
||||
|
||||
|
||||
# [DEF:test_map_columns_success:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
# @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
|
||||
# [/DEF:test_get_datasets_invalid_pagination:Function]
|
||||
|
||||
# [DEF:test_map_columns_success:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
def test_map_columns_success(mock_deps):
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
|
||||
|
||||
# Mock task manager
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-123"
|
||||
@@ -170,13 +174,9 @@ def test_map_columns_success(mock_deps):
|
||||
|
||||
response = client.post(
|
||||
"/api/datasets/map-columns",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [1, 2, 3],
|
||||
"source_type": "postgresql"
|
||||
}
|
||||
json={"env_id": "prod", "dataset_ids": [1, 2, 3], "source_type": "postgresql"},
|
||||
)
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
@@ -188,25 +188,17 @@ def test_map_columns_success(mock_deps):
|
||||
|
||||
|
||||
# [DEF:test_map_columns_invalid_source_type:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
# @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
|
||||
# [/DEF:test_map_columns_success:Function]
|
||||
|
||||
# [DEF:test_map_columns_invalid_source_type:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
def test_map_columns_invalid_source_type(mock_deps):
|
||||
response = client.post(
|
||||
"/api/datasets/map-columns",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [1],
|
||||
"source_type": "invalid"
|
||||
}
|
||||
json={"env_id": "prod", "dataset_ids": [1], "source_type": "invalid"},
|
||||
)
|
||||
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Source type must be 'postgresql' or 'xlsx'" in response.json()["detail"]
|
||||
|
||||
@@ -215,21 +207,17 @@ def test_map_columns_invalid_source_type(mock_deps):
|
||||
|
||||
|
||||
# [DEF:test_generate_docs_success:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
# @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.
|
||||
# @POST: Returns task_id
|
||||
# [/DEF:test_map_columns_invalid_source_type:Function]
|
||||
|
||||
# [DEF:test_generate_docs_success:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
def test_generate_docs_success(mock_deps):
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
|
||||
|
||||
# Mock task manager
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-456"
|
||||
@@ -237,13 +225,9 @@ def test_generate_docs_success(mock_deps):
|
||||
|
||||
response = client.post(
|
||||
"/api/datasets/generate-docs",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [1],
|
||||
"llm_provider": "openai"
|
||||
}
|
||||
json={"env_id": "prod", "dataset_ids": [1], "llm_provider": "openai"},
|
||||
)
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
@@ -255,87 +239,68 @@ def test_generate_docs_success(mock_deps):
|
||||
|
||||
|
||||
# [DEF:test_map_columns_empty_ids:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
# @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
|
||||
# [/DEF:test_generate_docs_success:Function]
|
||||
|
||||
# [DEF:test_map_columns_empty_ids:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
def test_map_columns_empty_ids(mock_deps):
|
||||
"""@PRE: dataset_ids must be non-empty."""
|
||||
response = client.post(
|
||||
"/api/datasets/map-columns",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [],
|
||||
"source_type": "postgresql"
|
||||
}
|
||||
json={"env_id": "prod", "dataset_ids": [], "source_type": "postgresql"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "At least one dataset ID must be provided" in response.json()["detail"]
|
||||
|
||||
|
||||
# [/DEF:test_map_columns_empty_ids:Function]
|
||||
|
||||
|
||||
# [DEF:test_generate_docs_empty_ids:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
# @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
|
||||
# [/DEF:test_map_columns_empty_ids:Function]
|
||||
|
||||
# [DEF:test_generate_docs_empty_ids:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
def test_generate_docs_empty_ids(mock_deps):
|
||||
"""@PRE: dataset_ids must be non-empty."""
|
||||
response = client.post(
|
||||
"/api/datasets/generate-docs",
|
||||
json={
|
||||
"env_id": "prod",
|
||||
"dataset_ids": [],
|
||||
"llm_provider": "openai"
|
||||
}
|
||||
json={"env_id": "prod", "dataset_ids": [], "llm_provider": "openai"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "At least one dataset ID must be provided" in response.json()["detail"]
|
||||
|
||||
|
||||
# [/DEF:test_generate_docs_empty_ids:Function]
|
||||
|
||||
|
||||
# [DEF:test_generate_docs_env_not_found:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
# @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.
|
||||
# @POST: Returns 404 error
|
||||
# [/DEF:test_generate_docs_empty_ids:Function]
|
||||
|
||||
# [DEF:test_generate_docs_env_not_found:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
def test_generate_docs_env_not_found(mock_deps):
|
||||
"""@PRE: env_id must be a valid environment."""
|
||||
mock_deps["config"].get_environments.return_value = []
|
||||
response = client.post(
|
||||
"/api/datasets/generate-docs",
|
||||
json={
|
||||
"env_id": "ghost",
|
||||
"dataset_ids": [1],
|
||||
"llm_provider": "openai"
|
||||
}
|
||||
json={"env_id": "ghost", "dataset_ids": [1], "llm_provider": "openai"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
|
||||
|
||||
# [/DEF:test_generate_docs_env_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_datasets_superset_failure:Function]
|
||||
# @RELATION: BINDS_TO -> DatasetsApiTests
|
||||
# @RELATION: BINDS_TO -> [DatasetsApiTests:Module]
|
||||
# @PURPOSE: Validate datasets listing surfaces a 503 contract when Superset access fails.
|
||||
# @TEST_EDGE: external_superset_failure -> {status: 503}
|
||||
# [/DEF:test_generate_docs_env_not_found:Function]
|
||||
|
||||
# @POST: Returns 503 with stable error detail when upstream dataset fetch fails.
|
||||
def test_get_datasets_superset_failure(mock_deps):
|
||||
"""@TEST_EDGE: external_superset_failure -> {status: 503}"""
|
||||
mock_env = MagicMock()
|
||||
@@ -349,7 +314,9 @@ def test_get_datasets_superset_failure(mock_deps):
|
||||
response = client.get("/api/datasets?env_id=bad_conn")
|
||||
assert response.status_code == 503
|
||||
assert "Failed to fetch datasets" in response.json()["detail"]
|
||||
|
||||
|
||||
# [/DEF:test_get_datasets_superset_failure:Function]
|
||||
|
||||
|
||||
# [/DEF:DatasetsApiTests:Module]
|
||||
# [/DEF:DatasetsApiTests:Module]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# [DEF:TestGitApi:Module]
|
||||
# @COMPLEXITY: 3
|
||||
# @RELATION: VERIFIES ->[src.api.routes.git]
|
||||
# @RELATION: VERIFIES -> [GitApi]
|
||||
# @PURPOSE: API tests for Git configurations and repository operations.
|
||||
|
||||
import pytest
|
||||
@@ -12,7 +12,7 @@ from src.models.git import GitServerConfig, GitProvider, GitStatus, GitRepositor
|
||||
|
||||
|
||||
# [DEF:DbMock:Class]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@@ -88,7 +88,8 @@ class DbMock:
|
||||
|
||||
|
||||
# [DEF:test_get_git_configs_masks_pat:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@@ -119,7 +120,8 @@ def test_get_git_configs_masks_pat():
|
||||
|
||||
|
||||
# [DEF:test_create_git_config_persists_config:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@@ -153,7 +155,8 @@ from src.api.routes.git_schemas import GitServerConfigUpdate
|
||||
|
||||
|
||||
# [DEF:test_update_git_config_modifies_record:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@@ -170,6 +173,7 @@ def test_update_git_config_modifies_record():
|
||||
)
|
||||
|
||||
# The monkeypatched query will return existing_config as it's the only one in the list
|
||||
# [DEF:SingleConfigDbMock:Class] @PURPOSE: Fake SQLAlchemy session returning single config row. @INVARIANT: Returns hardcoded single-item list; does not simulate empty or multi-row results.
|
||||
class SingleConfigDbMock:
|
||||
def query(self, *args):
|
||||
return self
|
||||
@@ -206,7 +210,8 @@ def test_update_git_config_modifies_record():
|
||||
|
||||
|
||||
# [DEF:test_update_git_config_raises_404_if_not_found:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@@ -230,7 +235,8 @@ def test_update_git_config_raises_404_if_not_found():
|
||||
|
||||
|
||||
# [DEF:test_delete_git_config_removes_record:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@@ -238,6 +244,7 @@ def test_delete_git_config_removes_record():
|
||||
"""
|
||||
existing_config = GitServerConfig(id="config-1")
|
||||
|
||||
# [DEF:SingleConfigDbMock:Class] @PURPOSE: Fake SQLAlchemy session returning single config row. @INVARIANT: Returns hardcoded single-item list; does not simulate empty or multi-row results.
|
||||
class SingleConfigDbMock:
|
||||
def query(self, *args):
|
||||
return self
|
||||
@@ -266,13 +273,15 @@ def test_delete_git_config_removes_record():
|
||||
|
||||
|
||||
# [DEF:test_test_git_config_validates_connection_successfully:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@POST: Returns success if the connection is validated via GitService.
|
||||
"""
|
||||
|
||||
# [DEF:MockGitService:Class] @PURPOSE: Stub GitService returning controlled responses. @INVARIANT: Returns only the configured response; does not simulate partial failure or exception paths.
|
||||
class MockGitService:
|
||||
async def test_connection(self, provider, url, pat):
|
||||
return True
|
||||
@@ -297,13 +306,15 @@ def test_test_git_config_validates_connection_successfully(monkeypatch):
|
||||
|
||||
|
||||
# [DEF:test_test_git_config_fails_validation:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@THROW: HTTPException 400
|
||||
"""
|
||||
|
||||
# [DEF:MockGitService:Class] @PURPOSE: Stub GitService returning controlled responses. @INVARIANT: Returns only the configured response; does not simulate partial failure or exception paths.
|
||||
class MockGitService:
|
||||
async def test_connection(self, provider, url, pat):
|
||||
return False
|
||||
@@ -330,13 +341,15 @@ def test_test_git_config_fails_validation(monkeypatch):
|
||||
|
||||
|
||||
# [DEF:test_list_gitea_repositories_returns_payload:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@POST: Returns repositories visible to PAT user.
|
||||
"""
|
||||
|
||||
# [DEF:MockGitService:Class] @PURPOSE: Stub GitService returning controlled responses. @INVARIANT: Returns only the configured response; does not simulate partial failure or exception paths.
|
||||
class MockGitService:
|
||||
async def list_gitea_repositories(self, url, pat):
|
||||
return [
|
||||
@@ -366,7 +379,8 @@ def test_list_gitea_repositories_returns_payload(monkeypatch):
|
||||
|
||||
|
||||
# [DEF:test_list_gitea_repositories_rejects_non_gitea:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@@ -392,13 +406,15 @@ def test_list_gitea_repositories_rejects_non_gitea(monkeypatch):
|
||||
|
||||
|
||||
# [DEF:test_create_remote_repository_creates_provider_repo:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@POST: Returns normalized remote repository payload.
|
||||
"""
|
||||
|
||||
# [DEF:MockGitService:Class] @PURPOSE: Stub GitService returning controlled responses. @INVARIANT: Returns only the configured response; does not simulate partial failure or exception paths.
|
||||
class MockGitService:
|
||||
async def create_gitlab_repository(
|
||||
self, server_url, pat, name, private, description, auto_init, default_branch
|
||||
@@ -438,7 +454,8 @@ def test_create_remote_repository_creates_provider_repo(monkeypatch):
|
||||
|
||||
|
||||
# [DEF:test_init_repository_initializes_and_saves_binding:Function]
|
||||
# @RELATION: BINDS_TO ->[TestGitApi]
|
||||
# @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.
|
||||
@@ -446,6 +463,7 @@ def test_init_repository_initializes_and_saves_binding(monkeypatch):
|
||||
"""
|
||||
from src.api.routes.git_schemas import RepoInitRequest
|
||||
|
||||
# [DEF:MockGitService:Class] @PURPOSE: Stub GitService returning controlled responses. @INVARIANT: Returns only the configured response; does not simulate partial failure or exception paths.
|
||||
class MockGitService:
|
||||
def init_repo(self, dashboard_id, remote_url, pat, repo_key, default_branch):
|
||||
self.init_called = True
|
||||
|
||||
@@ -16,6 +16,11 @@ from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
# [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
|
||||
@@ -24,8 +29,12 @@ class _FakeTaskManager:
|
||||
return self._tasks
|
||||
|
||||
|
||||
# [/DEF:_FakeTaskManager:Class]
|
||||
|
||||
|
||||
# [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])
|
||||
@@ -36,6 +45,7 @@ def _admin_user():
|
||||
|
||||
# [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,
|
||||
@@ -60,6 +70,7 @@ def _make_task(
|
||||
|
||||
# [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 = [
|
||||
@@ -113,6 +124,7 @@ def test_get_reports_default_pagination_contract():
|
||||
|
||||
# [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 = [
|
||||
@@ -166,6 +178,7 @@ def test_get_reports_filter_and_pagination():
|
||||
|
||||
# [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)
|
||||
@@ -205,6 +218,7 @@ def test_get_reports_handles_mixed_naive_and_aware_datetimes():
|
||||
|
||||
# [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 = [
|
||||
|
||||
@@ -16,6 +16,11 @@ from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
# [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
|
||||
@@ -24,8 +29,12 @@ class _FakeTaskManager:
|
||||
return self._tasks
|
||||
|
||||
|
||||
# [/DEF:_FakeTaskManager:Class]
|
||||
|
||||
|
||||
# [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])
|
||||
@@ -36,6 +45,7 @@ def _admin_user():
|
||||
|
||||
# [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(
|
||||
@@ -56,6 +66,7 @@ def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None):
|
||||
|
||||
# [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",
|
||||
@@ -91,6 +102,7 @@ def test_get_report_detail_success():
|
||||
|
||||
# [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)
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ from src.core.task_manager.models import Task, TaskStatus
|
||||
from src.dependencies import get_current_user, get_task_manager
|
||||
|
||||
|
||||
# [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
|
||||
@@ -24,8 +29,12 @@ class _FakeTaskManager:
|
||||
return self._tasks
|
||||
|
||||
|
||||
# [/DEF:_FakeTaskManager:Class]
|
||||
|
||||
|
||||
# [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])
|
||||
@@ -36,6 +45,7 @@ def _admin_user():
|
||||
|
||||
# [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(
|
||||
@@ -54,6 +64,7 @@ def _task(task_id: str, plugin_id: str, status: TaskStatus):
|
||||
|
||||
# [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),
|
||||
@@ -86,6 +97,7 @@ def test_reports_list_openapi_required_keys():
|
||||
|
||||
# [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()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# [DEF:__tests__/test_tasks_logs:Module]
|
||||
# @RELATION: VERIFIES -> ../tasks.py
|
||||
# [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.
|
||||
# [/DEF:__tests__/test_tasks_logs:Module]
|
||||
# @LAYER: Domain (Tests)
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
@@ -10,35 +12,39 @@ from unittest.mock import MagicMock
|
||||
from src.dependencies import get_task_manager, has_permission
|
||||
from src.api.routes.tasks import router
|
||||
|
||||
|
||||
# @TEST_FIXTURE: mock_app
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/tasks")
|
||||
|
||||
|
||||
# Mock TaskManager
|
||||
# @INVARIANT: unconstrained mock — no spec= enforced
|
||||
mock_tm = MagicMock()
|
||||
app.dependency_overrides[get_task_manager] = lambda: mock_tm
|
||||
|
||||
|
||||
# Mock permissions (bypass for unit test)
|
||||
app.dependency_overrides[has_permission("tasks", "READ")] = lambda: True
|
||||
|
||||
|
||||
return TestClient(app), mock_tm
|
||||
|
||||
|
||||
# @TEST_CONTRACT: get_task_logs_api -> Invariants
|
||||
# @TEST_FIXTURE: valid_task_logs_request
|
||||
# [DEF:test_get_task_logs_success:Function]
|
||||
# @RELATION: BINDS_TO -> __tests__/test_tasks_logs
|
||||
# @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
|
||||
|
||||
|
||||
# Setup mock task
|
||||
mock_task = MagicMock()
|
||||
tm.get_task.return_value = mock_task
|
||||
tm.get_task_logs.return_value = [{"level": "INFO", "message": "msg1"}]
|
||||
|
||||
|
||||
response = tc.get("/tasks/task-1/logs?level=INFO")
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"level": "INFO", "message": "msg1"}]
|
||||
tm.get_task.assert_called_with("task-1")
|
||||
@@ -47,42 +53,56 @@ def test_get_task_logs_success(client):
|
||||
assert args[0][0] == "task-1"
|
||||
assert args[0][1].level == "INFO"
|
||||
|
||||
|
||||
# @TEST_EDGE: task_not_found
|
||||
# [/DEF:test_get_task_logs_success:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_task_logs_not_found:Function]
|
||||
# @RELATION: BINDS_TO -> __tests__/test_tasks_logs
|
||||
# @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
|
||||
|
||||
|
||||
response = tc.get("/tasks/missing/logs")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Task not found"
|
||||
|
||||
|
||||
# @TEST_EDGE: invalid_limit
|
||||
# [/DEF:test_get_task_logs_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_task_logs_invalid_limit:Function]
|
||||
# @RELATION: BINDS_TO -> __tests__/test_tasks_logs
|
||||
# @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
|
||||
response = tc.get("/tasks/task-1/logs?limit=0")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# @TEST_INVARIANT: response_purity
|
||||
# [/DEF:test_get_task_logs_invalid_limit:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_task_log_stats_success:Function]
|
||||
# @RELATION: BINDS_TO -> __tests__/test_tasks_logs
|
||||
# @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()
|
||||
tm.get_task_log_stats.return_value = {"INFO": 5, "ERROR": 1}
|
||||
|
||||
|
||||
response = tc.get("/tasks/task-1/logs/stats")
|
||||
assert response.status_code == 200
|
||||
# response_model=LogStats might wrap this, but let's check basic structure
|
||||
# assuming tm.get_task_log_stats returns something compatible with LogStats
|
||||
|
||||
|
||||
# response_model=LogStats might wrap this, but let's check basic structure
|
||||
# assuming tm.get_task_log_stats returns something compatible with LogStats
|
||||
|
||||
|
||||
# [/DEF:test_get_task_log_stats_success:Function]
|
||||
# [/DEF:test_tasks_logs_module:Module]
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
# @SEMANTICS: api, admin, users, roles, permissions
|
||||
# @PURPOSE: Admin API endpoints for user and role management.
|
||||
# @LAYER: API
|
||||
# @RELATION: [USES] ->[backend.src.core.auth.repository.AuthRepository]
|
||||
# @RELATION: [USES] ->[backend.src.dependencies.has_permission]
|
||||
# @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.
|
||||
|
||||
@@ -17,9 +18,15 @@ from ...core.database import get_auth_db
|
||||
from ...core.auth.repository import AuthRepository
|
||||
from ...core.auth.security import get_password_hash
|
||||
from ...schemas.auth import (
|
||||
User as UserSchema, UserCreate, UserUpdate,
|
||||
RoleSchema, RoleCreate, RoleUpdate, PermissionSchema,
|
||||
ADGroupMappingSchema, ADGroupMappingCreate
|
||||
User as UserSchema,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
RoleSchema,
|
||||
RoleCreate,
|
||||
RoleUpdate,
|
||||
PermissionSchema,
|
||||
ADGroupMappingSchema,
|
||||
ADGroupMappingCreate,
|
||||
)
|
||||
from ...models.auth import User, Role, ADGroupMapping
|
||||
from ...dependencies import has_permission, get_plugin_loader
|
||||
@@ -36,6 +43,7 @@ from ...services.rbac_permission_catalog import (
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
# [/DEF:router:Variable]
|
||||
|
||||
|
||||
# [DEF:list_users:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Lists all registered users.
|
||||
@@ -46,14 +54,16 @@ router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
# @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"))
|
||||
db: Session = Depends(get_auth_db), _=Depends(has_permission("admin:users", "READ"))
|
||||
):
|
||||
with belief_scope("api.admin.list_users"):
|
||||
users = db.query(User).all()
|
||||
return users
|
||||
|
||||
|
||||
# [/DEF:list_users:Function]
|
||||
|
||||
|
||||
# [DEF:create_user:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Creates a new local user.
|
||||
@@ -62,37 +72,40 @@ async def list_users(
|
||||
# @PARAM: user_in (UserCreate) - New user data.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: UserSchema - The created user.
|
||||
# @RELATION: CALLS -> AuthRepository
|
||||
# @RELATION: [CALLS] ->[AuthRepository:Class]
|
||||
@router.post("/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
user_in: UserCreate,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:users", "WRITE"))
|
||||
_=Depends(has_permission("admin:users", "WRITE")),
|
||||
):
|
||||
with belief_scope("api.admin.create_user"):
|
||||
repo = AuthRepository(db)
|
||||
if repo.get_user_by_username(user_in.username):
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
|
||||
|
||||
new_user = User(
|
||||
username=user_in.username,
|
||||
email=user_in.email,
|
||||
password_hash=get_password_hash(user_in.password),
|
||||
auth_source="LOCAL",
|
||||
is_active=user_in.is_active
|
||||
is_active=user_in.is_active,
|
||||
)
|
||||
|
||||
|
||||
for role_name in user_in.roles:
|
||||
role = repo.get_role_by_name(role_name)
|
||||
if role:
|
||||
new_user.roles.append(role)
|
||||
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
|
||||
|
||||
# [/DEF:create_user:Function]
|
||||
|
||||
|
||||
# [DEF:update_user:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Updates an existing user.
|
||||
@@ -108,33 +121,36 @@ async def update_user(
|
||||
user_id: str,
|
||||
user_in: UserUpdate,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:users", "WRITE"))
|
||||
_=Depends(has_permission("admin:users", "WRITE")),
|
||||
):
|
||||
with belief_scope("api.admin.update_user"):
|
||||
repo = AuthRepository(db)
|
||||
user = repo.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
|
||||
if user_in.email is not None:
|
||||
user.email = user_in.email
|
||||
if user_in.is_active is not None:
|
||||
user.is_active = user_in.is_active
|
||||
if user_in.password is not None:
|
||||
user.password_hash = get_password_hash(user_in.password)
|
||||
|
||||
|
||||
if user_in.roles is not None:
|
||||
user.roles = []
|
||||
for role_name in user_in.roles:
|
||||
role = repo.get_role_by_name(role_name)
|
||||
if role:
|
||||
user.roles.append(role)
|
||||
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
# [/DEF:update_user:Function]
|
||||
|
||||
|
||||
# [DEF:delete_user:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Deletes a user.
|
||||
@@ -148,37 +164,50 @@ async def update_user(
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:users", "WRITE"))
|
||||
_=Depends(has_permission("admin:users", "WRITE")),
|
||||
):
|
||||
with belief_scope("api.admin.delete_user"):
|
||||
logger.info(f"[DEBUG] Attempting to delete user context={{'user_id': '{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:
|
||||
logger.warning(f"[DEBUG] User not found for deletion context={{'user_id': '{user_id}'}}")
|
||||
logger.warning(
|
||||
f"[DEBUG] User not found for deletion context={{'user_id': '{user_id}'}}"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
logger.info(f"[DEBUG] Found user to delete context={{'username': '{user.username}'}}")
|
||||
|
||||
logger.info(
|
||||
f"[DEBUG] Found user to delete context={{'username': '{user.username}'}}"
|
||||
)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
logger.info(f"[DEBUG] Successfully deleted user context={{'user_id': '{user_id}'}}")
|
||||
logger.info(
|
||||
f"[DEBUG] Successfully deleted user context={{'user_id': '{user_id}'}}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# [/DEF:delete_user:Function]
|
||||
|
||||
|
||||
# [DEF:list_roles:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Lists all available roles.
|
||||
# @RETURN: List[RoleSchema] - List of roles.
|
||||
# @RELATION: CALLS -> backend.src.models.auth.Role
|
||||
# @RELATION: [CALLS] ->[Role:Class]
|
||||
@router.get("/roles", response_model=List[RoleSchema])
|
||||
async def list_roles(
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:roles", "READ"))
|
||||
db: Session = Depends(get_auth_db), _=Depends(has_permission("admin:roles", "READ"))
|
||||
):
|
||||
with belief_scope("api.admin.list_roles"):
|
||||
return db.query(Role).all()
|
||||
|
||||
|
||||
# [/DEF:list_roles:Function]
|
||||
|
||||
|
||||
# [DEF:create_role:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Creates a new system role with associated permissions.
|
||||
@@ -188,35 +217,38 @@ async def list_roles(
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: RoleSchema - The created role.
|
||||
# @SIDE_EFFECT: Commits new role and associations to auth.db.
|
||||
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_permission_by_id
|
||||
# @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,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:roles", "WRITE"))
|
||||
_=Depends(has_permission("admin:roles", "WRITE")),
|
||||
):
|
||||
with belief_scope("api.admin.create_role"):
|
||||
if db.query(Role).filter(Role.name == role_in.name).first():
|
||||
raise HTTPException(status_code=400, detail="Role already exists")
|
||||
|
||||
|
||||
new_role = Role(name=role_in.name, description=role_in.description)
|
||||
repo = AuthRepository(db)
|
||||
|
||||
|
||||
for perm_id_or_str in role_in.permissions:
|
||||
perm = repo.get_permission_by_id(perm_id_or_str)
|
||||
if not perm and ":" in perm_id_or_str:
|
||||
res, act = perm_id_or_str.split(":", 1)
|
||||
perm = repo.get_permission_by_resource_action(res, act)
|
||||
|
||||
|
||||
if perm:
|
||||
new_role.permissions.append(perm)
|
||||
|
||||
|
||||
db.add(new_role)
|
||||
db.commit()
|
||||
db.refresh(new_role)
|
||||
return new_role
|
||||
|
||||
|
||||
# [/DEF:create_role:Function]
|
||||
|
||||
|
||||
# [DEF:update_role:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Updates an existing role's metadata and permissions.
|
||||
@@ -227,25 +259,25 @@ async def create_role(
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: RoleSchema - The updated role.
|
||||
# @SIDE_EFFECT: Commits updates to auth.db.
|
||||
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
|
||||
# @RELATION: [CALLS] ->[get_role_by_id:Function]
|
||||
@router.put("/roles/{role_id}", response_model=RoleSchema)
|
||||
async def update_role(
|
||||
role_id: str,
|
||||
role_in: RoleUpdate,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:roles", "WRITE"))
|
||||
_=Depends(has_permission("admin:roles", "WRITE")),
|
||||
):
|
||||
with belief_scope("api.admin.update_role"):
|
||||
repo = AuthRepository(db)
|
||||
role = repo.get_role_by_id(role_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
|
||||
if role_in.name is not None:
|
||||
role.name = role_in.name
|
||||
if role_in.description is not None:
|
||||
role.description = role_in.description
|
||||
|
||||
|
||||
if role_in.permissions is not None:
|
||||
role.permissions = []
|
||||
for perm_id_or_str in role_in.permissions:
|
||||
@@ -253,15 +285,18 @@ async def update_role(
|
||||
if not perm and ":" in perm_id_or_str:
|
||||
res, act = perm_id_or_str.split(":", 1)
|
||||
perm = repo.get_permission_by_resource_action(res, act)
|
||||
|
||||
|
||||
if perm:
|
||||
role.permissions.append(perm)
|
||||
|
||||
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
# [/DEF:update_role:Function]
|
||||
|
||||
|
||||
# [DEF:delete_role:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Removes a role from the system.
|
||||
@@ -271,24 +306,27 @@ async def update_role(
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: None
|
||||
# @SIDE_EFFECT: Deletes record from auth.db and commits.
|
||||
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
|
||||
# @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,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:roles", "WRITE"))
|
||||
_=Depends(has_permission("admin:roles", "WRITE")),
|
||||
):
|
||||
with belief_scope("api.admin.delete_role"):
|
||||
repo = AuthRepository(db)
|
||||
role = repo.get_role_by_id(role_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
|
||||
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# [/DEF:delete_role:Function]
|
||||
|
||||
|
||||
# [DEF:list_permissions:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Lists all available system permissions for assignment.
|
||||
@@ -299,12 +337,16 @@ async def delete_role(
|
||||
@router.get("/permissions", response_model=List[PermissionSchema])
|
||||
async def list_permissions(
|
||||
db: Session = Depends(get_auth_db),
|
||||
plugin_loader = Depends(get_plugin_loader),
|
||||
_ = Depends(has_permission("admin:roles", "READ"))
|
||||
plugin_loader=Depends(get_plugin_loader),
|
||||
_=Depends(has_permission("admin:roles", "READ")),
|
||||
):
|
||||
with belief_scope("api.admin.list_permissions"):
|
||||
declared_permissions = discover_declared_permissions(plugin_loader=plugin_loader)
|
||||
inserted_count = sync_permission_catalog(db=db, declared_permissions=declared_permissions)
|
||||
declared_permissions = discover_declared_permissions(
|
||||
plugin_loader=plugin_loader
|
||||
)
|
||||
inserted_count = sync_permission_catalog(
|
||||
db=db, declared_permissions=declared_permissions
|
||||
)
|
||||
if inserted_count > 0:
|
||||
logger.info(
|
||||
"[api.admin.list_permissions][Action] Synchronized %s missing RBAC permissions into auth catalog",
|
||||
@@ -313,8 +355,11 @@ async def list_permissions(
|
||||
|
||||
repo = AuthRepository(db)
|
||||
return repo.list_permissions()
|
||||
|
||||
|
||||
# [/DEF:list_permissions:Function]
|
||||
|
||||
|
||||
# [DEF:list_ad_mappings:Function]
|
||||
# @COMPLEXITY: 3
|
||||
# @PURPOSE: Lists all AD Group to Role mappings.
|
||||
@@ -322,31 +367,37 @@ async def list_permissions(
|
||||
@router.get("/ad-mappings", response_model=List[ADGroupMappingSchema])
|
||||
async def list_ad_mappings(
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
_=Depends(has_permission("admin:settings", "READ")),
|
||||
):
|
||||
with belief_scope("api.admin.list_ad_mappings"):
|
||||
return db.query(ADGroupMapping).all()
|
||||
|
||||
|
||||
# [/DEF:list_ad_mappings:Function]
|
||||
|
||||
|
||||
# [DEF:create_ad_mapping:Function]
|
||||
# @RELATION: CALLS -> AuthRepository
|
||||
# @RELATION: [DEPENDS_ON] ->[ADGroupMapping:Class]
|
||||
# @RELATION: [DEPENDS_ON] ->[get_auth_db:Function]
|
||||
# @RELATION: [DEPENDS_ON] ->[has_permission:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Creates a new AD Group mapping.
|
||||
@router.post("/ad-mappings", response_model=ADGroupMappingSchema)
|
||||
async def create_ad_mapping(
|
||||
mapping_in: ADGroupMappingCreate,
|
||||
db: Session = Depends(get_auth_db),
|
||||
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||
_=Depends(has_permission("admin:settings", "WRITE")),
|
||||
):
|
||||
with belief_scope("api.admin.create_ad_mapping"):
|
||||
new_mapping = ADGroupMapping(
|
||||
ad_group=mapping_in.ad_group,
|
||||
role_id=mapping_in.role_id
|
||||
ad_group=mapping_in.ad_group, role_id=mapping_in.role_id
|
||||
)
|
||||
db.add(new_mapping)
|
||||
db.commit()
|
||||
db.refresh(new_mapping)
|
||||
return new_mapping
|
||||
|
||||
|
||||
# [/DEF:create_ad_mapping:Function]
|
||||
|
||||
# [/DEF:AdminApi:Module]
|
||||
# [/DEF:AdminApi:Module]
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
# @SEMANTICS: api, assistant, chat, command, confirmation
|
||||
# @PURPOSE: API routes for LLM assistant command parsing and safe execution orchestration.
|
||||
# @LAYER: API
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.core.task_manager.manager.TaskManager]
|
||||
# @RELATION: [DEPENDS_ON] ->[backend.src.models.assistant]
|
||||
# @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.
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -54,8 +56,12 @@ git_service = GitService()
|
||||
# [DEF:AssistantMessageRequest:Class]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Input payload for assistant message endpoint.
|
||||
# @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)
|
||||
@@ -67,8 +73,12 @@ class AssistantMessageRequest(BaseModel):
|
||||
# [DEF:AssistantAction:Class]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: UI action descriptor returned with assistant responses.
|
||||
# @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
|
||||
@@ -81,8 +91,14 @@ class AssistantAction(BaseModel):
|
||||
# [DEF:AssistantMessageResponse:Class]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: Output payload contract for assistant interaction endpoints.
|
||||
# @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
|
||||
@@ -101,8 +117,14 @@ class AssistantMessageResponse(BaseModel):
|
||||
# [DEF:ConfirmationRecord:Class]
|
||||
# @COMPLEXITY: 1
|
||||
# @PURPOSE: In-memory confirmation token model for risky operation dispatch.
|
||||
# @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
|
||||
@@ -143,8 +165,12 @@ INTENT_PERMISSION_CHECKS: Dict[str, List[Tuple[str, str]]] = {
|
||||
# [DEF:_append_history:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Append conversation message to in-memory history buffer.
|
||||
# @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,
|
||||
@@ -177,8 +203,12 @@ def _append_history(
|
||||
# [DEF:_persist_message:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Persist assistant/user message record to database.
|
||||
# @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,
|
||||
@@ -215,8 +245,12 @@ def _persist_message(
|
||||
# [DEF:_audit:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Append in-memory audit record for assistant decision trace.
|
||||
# @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] = []
|
||||
@@ -852,8 +886,13 @@ def _build_task_observability_summary(task: Any, config_manager: ConfigManager)
|
||||
# [DEF:_parse_command:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Deterministically parse RU/EN command text into intent payload.
|
||||
# @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]:
|
||||
text = message.strip()
|
||||
lower = text.lower()
|
||||
@@ -1575,8 +1614,15 @@ def _authorize_intent(intent: Dict[str, Any], current_user: User):
|
||||
# [DEF:_dispatch_intent:Function]
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Execute parsed assistant intent via existing task/plugin/git services.
|
||||
# @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,
|
||||
@@ -1974,9 +2020,18 @@ async def _dispatch_intent(
|
||||
# [DEF:send_message:Function]
|
||||
# @COMPLEXITY: 5
|
||||
# @PURPOSE: Parse assistant command, enforce safety gates, and dispatch executable intent.
|
||||
# @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.
|
||||
# @RETURN: AssistantMessageResponse with operation feedback and optional actions.
|
||||
# @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),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# [DEF:ConnectionsRouter:Module]
|
||||
# @SEMANTICS: api, router, connections, database
|
||||
# @PURPOSE: Defines the FastAPI router for managing external database connections.
|
||||
# @COMPLEXITY: 3
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: Depends on SQLAlchemy session.
|
||||
# @RELATION: DEPENDS_ON -> Session
|
||||
# @CONSTRAINT: Must use belief_scope for logging.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
@@ -21,15 +22,22 @@ router = APIRouter()
|
||||
|
||||
# [DEF:_ensure_connections_schema:Function]
|
||||
# @PURPOSE: Ensures the connection_configs table exists before CRUD access.
|
||||
# @COMPLEXITY: 3
|
||||
# @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())
|
||||
|
||||
|
||||
# [/DEF:_ensure_connections_schema:Function]
|
||||
|
||||
|
||||
# [DEF:ConnectionSchema:Class]
|
||||
# @PURPOSE: Pydantic model for connection response.
|
||||
# @COMPLEXITY: 3
|
||||
# @RELATION: BINDS_TO -> ConnectionConfig
|
||||
class ConnectionSchema(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -42,10 +50,15 @@ class ConnectionSchema(BaseModel):
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
# [/DEF:ConnectionSchema:Class]
|
||||
|
||||
|
||||
# [DEF:ConnectionCreate:Class]
|
||||
# @PURPOSE: Pydantic model for creating a connection.
|
||||
# @COMPLEXITY: 3
|
||||
# @RELATION: BINDS_TO -> ConnectionConfig
|
||||
class ConnectionCreate(BaseModel):
|
||||
name: str
|
||||
type: str
|
||||
@@ -54,60 +67,92 @@ class ConnectionCreate(BaseModel):
|
||||
database: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
# [/DEF:ConnectionCreate:Class]
|
||||
|
||||
|
||||
# [DEF:list_connections:Function]
|
||||
# @PURPOSE: Lists all saved connections.
|
||||
# @COMPLEXITY: 3
|
||||
# @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
|
||||
@router.get("", response_model=List[ConnectionSchema])
|
||||
async def list_connections(db: Session = Depends(get_db)):
|
||||
with belief_scope("ConnectionsRouter.list_connections"):
|
||||
_ensure_connections_schema(db)
|
||||
connections = db.query(ConnectionConfig).all()
|
||||
return connections
|
||||
|
||||
|
||||
# [/DEF:list_connections:Function]
|
||||
|
||||
|
||||
# [DEF:create_connection:Function]
|
||||
# @PURPOSE: Creates a new connection configuration.
|
||||
# @COMPLEXITY: 3
|
||||
# @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
|
||||
@router.post("", response_model=ConnectionSchema, status_code=status.HTTP_201_CREATED)
|
||||
async def create_connection(connection: ConnectionCreate, db: Session = Depends(get_db)):
|
||||
async def create_connection(
|
||||
connection: ConnectionCreate, db: Session = Depends(get_db)
|
||||
):
|
||||
with belief_scope("ConnectionsRouter.create_connection", f"name={connection.name}"):
|
||||
_ensure_connections_schema(db)
|
||||
db_connection = ConnectionConfig(**connection.dict())
|
||||
db.add(db_connection)
|
||||
db.commit()
|
||||
db.refresh(db_connection)
|
||||
logger.info(f"[ConnectionsRouter.create_connection][Success] Created connection {db_connection.id}")
|
||||
logger.info(
|
||||
f"[ConnectionsRouter.create_connection][Success] Created connection {db_connection.id}"
|
||||
)
|
||||
return db_connection
|
||||
|
||||
|
||||
# [/DEF:create_connection:Function]
|
||||
|
||||
|
||||
# [DEF:delete_connection:Function]
|
||||
# @PURPOSE: Deletes a connection configuration.
|
||||
# @COMPLEXITY: 3
|
||||
# @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
|
||||
@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}"):
|
||||
_ensure_connections_schema(db)
|
||||
db_connection = db.query(ConnectionConfig).filter(ConnectionConfig.id == connection_id).first()
|
||||
db_connection = (
|
||||
db.query(ConnectionConfig)
|
||||
.filter(ConnectionConfig.id == connection_id)
|
||||
.first()
|
||||
)
|
||||
if not db_connection:
|
||||
logger.error(f"[ConnectionsRouter.delete_connection][State] Connection {connection_id} not found")
|
||||
logger.error(
|
||||
f"[ConnectionsRouter.delete_connection][State] Connection {connection_id} not found"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Connection not found")
|
||||
db.delete(db_connection)
|
||||
db.commit()
|
||||
logger.info(f"[ConnectionsRouter.delete_connection][Success] Deleted connection {connection_id}")
|
||||
logger.info(
|
||||
f"[ConnectionsRouter.delete_connection][Success] Deleted connection {connection_id}"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
# [/DEF:delete_connection:Function]
|
||||
|
||||
# [/DEF:ConnectionsRouter:Module]
|
||||
|
||||
@@ -25,7 +25,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.core.logger import belief_scope, logger
|
||||
from src.dependencies import get_config_manager, get_current_user, get_task_manager, has_permission
|
||||
from src.dependencies import (
|
||||
get_config_manager,
|
||||
get_current_user,
|
||||
get_task_manager,
|
||||
has_permission,
|
||||
)
|
||||
from src.models.auth import User
|
||||
from src.models.dataset_review import (
|
||||
AnswerKind,
|
||||
@@ -84,6 +89,8 @@ class StartSessionRequest(BaseModel):
|
||||
source_kind: str = Field(..., pattern="^(superset_link|dataset_selection)$")
|
||||
source_input: str = Field(..., min_length=1)
|
||||
environment_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# [/DEF:StartSessionRequest:Class]
|
||||
|
||||
|
||||
@@ -93,6 +100,8 @@ class StartSessionRequest(BaseModel):
|
||||
class UpdateSessionRequest(BaseModel):
|
||||
status: SessionStatus
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
# [/DEF:UpdateSessionRequest:Class]
|
||||
|
||||
|
||||
@@ -105,6 +114,8 @@ class SessionCollectionResponse(BaseModel):
|
||||
page: int
|
||||
page_size: int
|
||||
has_next: bool
|
||||
|
||||
|
||||
# [/DEF:SessionCollectionResponse:Class]
|
||||
|
||||
|
||||
@@ -120,6 +131,8 @@ class ExportArtifactResponse(BaseModel):
|
||||
created_by_user_id: str
|
||||
created_at: Optional[str] = None
|
||||
content: Dict[str, Any]
|
||||
|
||||
|
||||
# [/DEF:ExportArtifactResponse:Class]
|
||||
|
||||
|
||||
@@ -133,6 +146,8 @@ class FieldSemanticUpdateRequest(BaseModel):
|
||||
display_format: Optional[str] = None
|
||||
lock_field: bool = False
|
||||
resolution_note: Optional[str] = None
|
||||
|
||||
|
||||
# [/DEF:FieldSemanticUpdateRequest:Class]
|
||||
|
||||
|
||||
@@ -141,6 +156,8 @@ class FieldSemanticUpdateRequest(BaseModel):
|
||||
# @PURPOSE: Request DTO for thumbs up/down feedback persistence on AI-assisted content.
|
||||
class FeedbackRequest(BaseModel):
|
||||
feedback: str = Field(..., pattern="^(up|down)$")
|
||||
|
||||
|
||||
# [/DEF:FeedbackRequest:Class]
|
||||
|
||||
|
||||
@@ -151,6 +168,8 @@ class ClarificationAnswerRequest(BaseModel):
|
||||
question_id: str = Field(..., min_length=1)
|
||||
answer_kind: AnswerKind
|
||||
answer_value: Optional[str] = None
|
||||
|
||||
|
||||
# [/DEF:ClarificationAnswerRequest:Class]
|
||||
|
||||
|
||||
@@ -165,6 +184,8 @@ class ClarificationSessionSummaryResponse(BaseModel):
|
||||
resolved_count: int
|
||||
remaining_count: int
|
||||
summary_delta: Optional[str] = None
|
||||
|
||||
|
||||
# [/DEF:ClarificationSessionSummaryResponse:Class]
|
||||
|
||||
|
||||
@@ -174,6 +195,8 @@ class ClarificationSessionSummaryResponse(BaseModel):
|
||||
class ClarificationStateResponse(BaseModel):
|
||||
clarification_session: ClarificationSessionSummaryResponse
|
||||
current_question: Optional[ClarificationQuestionDto] = None
|
||||
|
||||
|
||||
# [/DEF:ClarificationStateResponse:Class]
|
||||
|
||||
|
||||
@@ -184,6 +207,8 @@ class ClarificationAnswerResultResponse(BaseModel):
|
||||
clarification_state: ClarificationStateResponse
|
||||
session: SessionSummary
|
||||
changed_findings: List[ValidationFindingDto]
|
||||
|
||||
|
||||
# [/DEF:ClarificationAnswerResultResponse:Class]
|
||||
|
||||
|
||||
@@ -193,6 +218,8 @@ class ClarificationAnswerResultResponse(BaseModel):
|
||||
class FeedbackResponse(BaseModel):
|
||||
target_id: str
|
||||
feedback: str
|
||||
|
||||
|
||||
# [/DEF:FeedbackResponse:Class]
|
||||
|
||||
|
||||
@@ -201,6 +228,8 @@ class FeedbackResponse(BaseModel):
|
||||
# @PURPOSE: Optional request DTO for explicit mapping approval audit notes.
|
||||
class ApproveMappingRequest(BaseModel):
|
||||
approval_note: Optional[str] = None
|
||||
|
||||
|
||||
# [/DEF:ApproveMappingRequest:Class]
|
||||
|
||||
|
||||
@@ -211,6 +240,8 @@ class BatchApproveSemanticItemRequest(BaseModel):
|
||||
field_id: str = Field(..., min_length=1)
|
||||
candidate_id: str = Field(..., min_length=1)
|
||||
lock_field: bool = False
|
||||
|
||||
|
||||
# [/DEF:BatchApproveSemanticItemRequest:Class]
|
||||
|
||||
|
||||
@@ -219,6 +250,8 @@ class BatchApproveSemanticItemRequest(BaseModel):
|
||||
# @PURPOSE: Request DTO for explicit batch semantic approvals inside one owned session scope.
|
||||
class BatchApproveSemanticRequest(BaseModel):
|
||||
items: List[BatchApproveSemanticItemRequest] = Field(..., min_length=1)
|
||||
|
||||
|
||||
# [/DEF:BatchApproveSemanticRequest:Class]
|
||||
|
||||
|
||||
@@ -228,6 +261,8 @@ class BatchApproveSemanticRequest(BaseModel):
|
||||
class BatchApproveMappingRequest(BaseModel):
|
||||
mapping_ids: List[str] = Field(..., min_length=1)
|
||||
approval_note: Optional[str] = None
|
||||
|
||||
|
||||
# [/DEF:BatchApproveMappingRequest:Class]
|
||||
|
||||
|
||||
@@ -238,6 +273,8 @@ class PreviewEnqueueResultResponse(BaseModel):
|
||||
session_id: str
|
||||
preview_status: str
|
||||
task_id: Optional[str] = None
|
||||
|
||||
|
||||
# [/DEF:PreviewEnqueueResultResponse:Class]
|
||||
|
||||
|
||||
@@ -246,6 +283,8 @@ class PreviewEnqueueResultResponse(BaseModel):
|
||||
# @PURPOSE: Contract-compliant wrapper for execution mapping list responses.
|
||||
class MappingCollectionResponse(BaseModel):
|
||||
items: List[ExecutionMappingDto]
|
||||
|
||||
|
||||
# [/DEF:MappingCollectionResponse:Class]
|
||||
|
||||
|
||||
@@ -254,8 +293,13 @@ class MappingCollectionResponse(BaseModel):
|
||||
# @PURPOSE: Request DTO for one manual execution-mapping override update without introducing unrelated bulk mutation semantics.
|
||||
class UpdateExecutionMappingRequest(BaseModel):
|
||||
effective_value: Optional[Any] = None
|
||||
mapping_method: Optional[str] = Field(default=None, pattern="^(manual_override|direct_match|heuristic_match|semantic_match)$")
|
||||
mapping_method: Optional[str] = Field(
|
||||
default=None,
|
||||
pattern="^(manual_override|direct_match|heuristic_match|semantic_match)$",
|
||||
)
|
||||
transformation_note: Optional[str] = None
|
||||
|
||||
|
||||
# [/DEF:UpdateExecutionMappingRequest:Class]
|
||||
|
||||
|
||||
@@ -265,6 +309,8 @@ class UpdateExecutionMappingRequest(BaseModel):
|
||||
class LaunchDatasetResponse(BaseModel):
|
||||
run_context: DatasetRunContextDto
|
||||
redirect_url: str
|
||||
|
||||
|
||||
# [/DEF:LaunchDatasetResponse:Class]
|
||||
|
||||
|
||||
@@ -280,6 +326,8 @@ def _require_auto_review_flag(config_manager=Depends(get_config_manager)) -> boo
|
||||
detail="Dataset auto review feature is disabled",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# [/DEF:_require_auto_review_flag:Function]
|
||||
|
||||
|
||||
@@ -295,6 +343,8 @@ def _require_clarification_flag(config_manager=Depends(get_config_manager)) -> b
|
||||
detail="Dataset clarification feature is disabled",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# [/DEF:_require_clarification_flag:Function]
|
||||
|
||||
|
||||
@@ -310,6 +360,8 @@ def _require_execution_flag(config_manager=Depends(get_config_manager)) -> bool:
|
||||
detail="Dataset execution feature is disabled",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# [/DEF:_require_execution_flag:Function]
|
||||
|
||||
|
||||
@@ -318,6 +370,8 @@ def _require_execution_flag(config_manager=Depends(get_config_manager)) -> bool:
|
||||
# @PURPOSE: Build repository dependency for dataset review session aggregate access.
|
||||
def _get_repository(db: Session = Depends(get_db)) -> DatasetReviewSessionRepository:
|
||||
return DatasetReviewSessionRepository(db)
|
||||
|
||||
|
||||
# [/DEF:_get_repository:Function]
|
||||
|
||||
|
||||
@@ -335,6 +389,8 @@ def _get_orchestrator(
|
||||
config_manager=config_manager,
|
||||
task_manager=task_manager,
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:_get_orchestrator:Function]
|
||||
|
||||
|
||||
@@ -346,15 +402,20 @@ def _get_clarification_engine(
|
||||
repository: DatasetReviewSessionRepository = Depends(_get_repository),
|
||||
) -> ClarificationEngine:
|
||||
return ClarificationEngine(repository=repository)
|
||||
|
||||
|
||||
# [/DEF:_get_clarification_engine:Function]
|
||||
|
||||
|
||||
# [DEF:_serialize_session_summary:Function]
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Map SQLAlchemy session aggregate root into stable API summary DTO.
|
||||
# @RELATION: [DEPENDS_ON] ->[DatasetReviewSession]
|
||||
# @RELATION: [DEPENDS_ON] ->[SessionSummary]
|
||||
def _serialize_session_summary(session: DatasetReviewSession) -> SessionSummary:
|
||||
return SessionSummary.model_validate(session, from_attributes=True)
|
||||
|
||||
|
||||
# [/DEF:_serialize_session_summary:Function]
|
||||
|
||||
|
||||
@@ -364,6 +425,8 @@ def _serialize_session_summary(session: DatasetReviewSession) -> SessionSummary:
|
||||
# @RELATION: [DEPENDS_ON] ->[SessionDetail]
|
||||
def _serialize_session_detail(session: DatasetReviewSession) -> SessionDetail:
|
||||
return SessionDetail.model_validate(session, from_attributes=True)
|
||||
|
||||
|
||||
# [/DEF:_serialize_session_detail:Function]
|
||||
|
||||
|
||||
@@ -373,6 +436,8 @@ def _serialize_session_detail(session: DatasetReviewSession) -> SessionDetail:
|
||||
# @RELATION: [DEPENDS_ON] ->[SemanticFieldEntryDto]
|
||||
def _serialize_semantic_field(field: SemanticFieldEntry) -> SemanticFieldEntryDto:
|
||||
return SemanticFieldEntryDto.model_validate(field, from_attributes=True)
|
||||
|
||||
|
||||
# [/DEF:_serialize_semantic_field:Function]
|
||||
|
||||
|
||||
@@ -401,6 +466,8 @@ def _serialize_clarification_question_payload(
|
||||
"updated_at": datetime.utcnow(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:_serialize_clarification_question_payload:Function]
|
||||
|
||||
|
||||
@@ -421,8 +488,12 @@ def _serialize_clarification_state(
|
||||
remaining_count=state.clarification_session.remaining_count,
|
||||
summary_delta=state.clarification_session.summary_delta,
|
||||
),
|
||||
current_question=_serialize_clarification_question_payload(state.current_question),
|
||||
current_question=_serialize_clarification_question_payload(
|
||||
state.current_question
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:_serialize_clarification_state:Function]
|
||||
|
||||
|
||||
@@ -446,8 +517,12 @@ def _get_owned_session_or_404(
|
||||
"Dataset review session not found in current ownership scope",
|
||||
extra={"session_id": session_id, "user_id": current_user.id},
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
# [/DEF:_get_owned_session_or_404:Function]
|
||||
|
||||
|
||||
@@ -469,6 +544,8 @@ def _require_owner_mutation_scope(
|
||||
detail="Only the owner can mutate dataset review state",
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
# [/DEF:_require_owner_mutation_scope:Function]
|
||||
|
||||
|
||||
@@ -492,6 +569,8 @@ def _record_session_event(
|
||||
event_summary=event_summary,
|
||||
event_details=event_details or {},
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:_record_session_event:Function]
|
||||
|
||||
|
||||
@@ -510,7 +589,11 @@ def _get_owned_mapping_or_404(
|
||||
for mapping in session.execution_mappings:
|
||||
if mapping.mapping_id == mapping_id:
|
||||
return mapping
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Execution mapping not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Execution mapping not found"
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:_get_owned_mapping_or_404:Function]
|
||||
|
||||
|
||||
@@ -529,7 +612,11 @@ def _get_owned_field_or_404(
|
||||
for field in session.semantic_fields:
|
||||
if field.field_id == field_id:
|
||||
return field
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Semantic field not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Semantic field not found"
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:_get_owned_field_or_404:Function]
|
||||
|
||||
|
||||
@@ -541,12 +628,17 @@ def _get_latest_clarification_session_or_404(
|
||||
session: DatasetReviewSession,
|
||||
) -> ClarificationSession:
|
||||
if not session.clarification_sessions:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Clarification session not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Clarification session not found",
|
||||
)
|
||||
return sorted(
|
||||
session.clarification_sessions,
|
||||
key=lambda item: (item.started_at, item.clarification_session_id),
|
||||
reverse=True,
|
||||
)[0]
|
||||
|
||||
|
||||
# [/DEF:_get_latest_clarification_session_or_404:Function]
|
||||
|
||||
|
||||
@@ -561,6 +653,8 @@ def _map_candidate_provenance(candidate: SemanticCandidate) -> FieldProvenance:
|
||||
if str(candidate.match_type.value) == "generated":
|
||||
return FieldProvenance.AI_GENERATED
|
||||
return FieldProvenance.FUZZY_INFERRED
|
||||
|
||||
|
||||
# [/DEF:_map_candidate_provenance:Function]
|
||||
|
||||
|
||||
@@ -569,7 +663,9 @@ def _map_candidate_provenance(candidate: SemanticCandidate) -> FieldProvenance:
|
||||
# @PURPOSE: Resolve the semantic source version for one accepted candidate from the loaded session aggregate.
|
||||
# @RELATION: [DEPENDS_ON] ->[SemanticFieldEntry]
|
||||
# @RELATION: [DEPENDS_ON] ->[SemanticSource]
|
||||
def _resolve_candidate_source_version(field: SemanticFieldEntry, source_id: Optional[str]) -> Optional[str]:
|
||||
def _resolve_candidate_source_version(
|
||||
field: SemanticFieldEntry, source_id: Optional[str]
|
||||
) -> Optional[str]:
|
||||
if not source_id:
|
||||
return None
|
||||
session = getattr(field, "session", None)
|
||||
@@ -579,6 +675,8 @@ def _resolve_candidate_source_version(field: SemanticFieldEntry, source_id: Opti
|
||||
if source.source_id == source_id:
|
||||
return source.source_version
|
||||
return None
|
||||
|
||||
|
||||
# [/DEF:_resolve_candidate_source_version:Function]
|
||||
|
||||
|
||||
@@ -603,11 +701,18 @@ def _update_semantic_field_state(
|
||||
selected_candidate = None
|
||||
if request.candidate_id:
|
||||
selected_candidate = next(
|
||||
(candidate for candidate in field.candidates if candidate.candidate_id == request.candidate_id),
|
||||
(
|
||||
candidate
|
||||
for candidate in field.candidates
|
||||
if candidate.candidate_id == request.candidate_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected_candidate is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Semantic candidate not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Semantic candidate not found",
|
||||
)
|
||||
|
||||
if has_manual_override:
|
||||
field.verbose_name = request.verbose_name
|
||||
@@ -631,7 +736,9 @@ def _update_semantic_field_state(
|
||||
field.display_format = selected_candidate.proposed_display_format
|
||||
field.provenance = _map_candidate_provenance(selected_candidate)
|
||||
field.source_id = selected_candidate.source_id
|
||||
field.source_version = _resolve_candidate_source_version(field, selected_candidate.source_id)
|
||||
field.source_version = _resolve_candidate_source_version(
|
||||
field, selected_candidate.source_id
|
||||
)
|
||||
field.confidence_rank = selected_candidate.candidate_rank
|
||||
field.is_locked = bool(request.lock_field or field.is_locked)
|
||||
field.has_conflict = len(field.candidates) > 1
|
||||
@@ -649,6 +756,8 @@ def _update_semantic_field_state(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Provide candidate_id or at least one manual override field",
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:_update_semantic_field_state:Function]
|
||||
|
||||
|
||||
@@ -658,6 +767,8 @@ def _update_semantic_field_state(
|
||||
# @RELATION: [DEPENDS_ON] ->[ExecutionMappingDto]
|
||||
def _serialize_execution_mapping(mapping: ExecutionMapping) -> ExecutionMappingDto:
|
||||
return ExecutionMappingDto.model_validate(mapping, from_attributes=True)
|
||||
|
||||
|
||||
# [/DEF:_serialize_execution_mapping:Function]
|
||||
|
||||
|
||||
@@ -667,6 +778,8 @@ def _serialize_execution_mapping(mapping: ExecutionMapping) -> ExecutionMappingD
|
||||
# @RELATION: [DEPENDS_ON] ->[DatasetRunContextDto]
|
||||
def _serialize_run_context(run_context) -> DatasetRunContextDto:
|
||||
return DatasetRunContextDto.model_validate(run_context, from_attributes=True)
|
||||
|
||||
|
||||
# [/DEF:_serialize_run_context:Function]
|
||||
|
||||
|
||||
@@ -688,6 +801,8 @@ def _build_sql_lab_redirect_url(environment_url: str, sql_lab_session_ref: str)
|
||||
detail="SQL Lab session reference is missing",
|
||||
)
|
||||
return f"{base_url}/superset/sqllab?queryId={session_ref}"
|
||||
|
||||
|
||||
# [/DEF:_build_sql_lab_redirect_url:Function]
|
||||
|
||||
|
||||
@@ -695,9 +810,13 @@ def _build_sql_lab_redirect_url(environment_url: str, sql_lab_session_ref: str)
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Produce session documentation export content from current persisted review state.
|
||||
# @RELATION: [DEPENDS_ON] ->[DatasetReviewSession]
|
||||
def _build_documentation_export(session: DatasetReviewSession, export_format: ArtifactFormat) -> Dict[str, Any]:
|
||||
def _build_documentation_export(
|
||||
session: DatasetReviewSession, export_format: ArtifactFormat
|
||||
) -> Dict[str, Any]:
|
||||
profile = session.profile
|
||||
findings = sorted(session.findings, key=lambda item: (item.severity.value, item.code))
|
||||
findings = sorted(
|
||||
session.findings, key=lambda item: (item.severity.value, item.code)
|
||||
)
|
||||
if export_format == ArtifactFormat.MARKDOWN:
|
||||
lines = [
|
||||
f"# Dataset Review: {session.dataset_ref}",
|
||||
@@ -724,7 +843,8 @@ def _build_documentation_export(session: DatasetReviewSession, export_format: Ar
|
||||
else:
|
||||
content = {
|
||||
"session": _serialize_session_summary(session).model_dump(mode="json"),
|
||||
"profile": profile and {
|
||||
"profile": profile
|
||||
and {
|
||||
"dataset_name": profile.dataset_name,
|
||||
"business_summary": profile.business_summary,
|
||||
"confidence_state": profile.confidence_state.value,
|
||||
@@ -743,6 +863,8 @@ def _build_documentation_export(session: DatasetReviewSession, export_format: Ar
|
||||
}
|
||||
storage_ref = f"inline://dataset-review/{session.session_id}/documentation.json"
|
||||
return {"storage_ref": storage_ref, "content": content}
|
||||
|
||||
|
||||
# [/DEF:_build_documentation_export:Function]
|
||||
|
||||
|
||||
@@ -750,8 +872,12 @@ def _build_documentation_export(session: DatasetReviewSession, export_format: Ar
|
||||
# @COMPLEXITY: 2
|
||||
# @PURPOSE: Produce validation-focused export content from persisted findings and readiness state.
|
||||
# @RELATION: [DEPENDS_ON] ->[DatasetReviewSession]
|
||||
def _build_validation_export(session: DatasetReviewSession, export_format: ArtifactFormat) -> Dict[str, Any]:
|
||||
findings = sorted(session.findings, key=lambda item: (item.severity.value, item.code))
|
||||
def _build_validation_export(
|
||||
session: DatasetReviewSession, export_format: ArtifactFormat
|
||||
) -> Dict[str, Any]:
|
||||
findings = sorted(
|
||||
session.findings, key=lambda item: (item.severity.value, item.code)
|
||||
)
|
||||
if export_format == ArtifactFormat.MARKDOWN:
|
||||
lines = [
|
||||
f"# Validation Report: {session.dataset_ref}",
|
||||
@@ -790,6 +916,8 @@ def _build_validation_export(session: DatasetReviewSession, export_format: Artif
|
||||
}
|
||||
storage_ref = f"inline://dataset-review/{session.session_id}/validation.json"
|
||||
return {"storage_ref": storage_ref, "content": content}
|
||||
|
||||
|
||||
# [/DEF:_build_validation_export:Function]
|
||||
|
||||
|
||||
@@ -823,13 +951,15 @@ async def list_sessions(
|
||||
page_size=page_size,
|
||||
has_next=end < len(sessions),
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:list_sessions:Function]
|
||||
|
||||
|
||||
# [DEF:start_session:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Start a new dataset review session from a Superset link or dataset selection.
|
||||
# @RELATION: [CALLS] ->[DatasetReviewOrchestrator.start_session]
|
||||
# @RELATION: [CALLS] ->[start_session:Function]
|
||||
# @PRE: feature flag enabled, user authenticated, and request body valid.
|
||||
# @POST: returns persisted session summary scoped to the authenticated user.
|
||||
# @SIDE_EFFECT: persists session/profile/findings and may enqueue recovery task.
|
||||
@@ -864,10 +994,16 @@ async def start_session(
|
||||
extra={"user_id": current_user.id, "error": str(exc)},
|
||||
)
|
||||
detail = str(exc)
|
||||
status_code = status.HTTP_404_NOT_FOUND if detail == "Environment not found" else status.HTTP_400_BAD_REQUEST
|
||||
status_code = (
|
||||
status.HTTP_404_NOT_FOUND
|
||||
if detail == "Environment not found"
|
||||
else status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
|
||||
return _serialize_session_summary(result.session)
|
||||
|
||||
|
||||
# [/DEF:start_session:Function]
|
||||
|
||||
|
||||
@@ -891,6 +1027,8 @@ async def get_session_detail(
|
||||
with belief_scope("dataset_review.get_session_detail"):
|
||||
session = _get_owned_session_or_404(repository, session_id, current_user)
|
||||
return _serialize_session_detail(session)
|
||||
|
||||
|
||||
# [/DEF:get_session_detail:Function]
|
||||
|
||||
|
||||
@@ -923,7 +1061,11 @@ async def update_session(
|
||||
session.status = request.status
|
||||
if request.status == SessionStatus.PAUSED:
|
||||
session.recommended_action = RecommendedAction.RESUME_SESSION
|
||||
elif request.status in {SessionStatus.ARCHIVED, SessionStatus.CANCELLED, SessionStatus.COMPLETED}:
|
||||
elif request.status in {
|
||||
SessionStatus.ARCHIVED,
|
||||
SessionStatus.CANCELLED,
|
||||
SessionStatus.COMPLETED,
|
||||
}:
|
||||
session.active_task_id = None
|
||||
|
||||
repository.db.commit()
|
||||
@@ -937,6 +1079,8 @@ async def update_session(
|
||||
event_details={"status": session.status.value},
|
||||
)
|
||||
return _serialize_session_summary(session)
|
||||
|
||||
|
||||
# [/DEF:update_session:Function]
|
||||
|
||||
|
||||
@@ -992,6 +1136,8 @@ async def delete_session(
|
||||
event_details={"hard_delete": False},
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# [/DEF:delete_session:Function]
|
||||
|
||||
|
||||
@@ -1019,7 +1165,10 @@ async def export_documentation(
|
||||
):
|
||||
with belief_scope("dataset_review.export_documentation"):
|
||||
if format not in {ArtifactFormat.JSON, ArtifactFormat.MARKDOWN}:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only json and markdown exports are supported")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only json and markdown exports are supported",
|
||||
)
|
||||
session = _get_owned_session_or_404(repository, session_id, current_user)
|
||||
export_payload = _build_documentation_export(session, format)
|
||||
return ExportArtifactResponse(
|
||||
@@ -1031,6 +1180,8 @@ async def export_documentation(
|
||||
created_by_user_id=current_user.id,
|
||||
content=export_payload["content"],
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:export_documentation:Function]
|
||||
|
||||
|
||||
@@ -1058,7 +1209,10 @@ async def export_validation(
|
||||
):
|
||||
with belief_scope("dataset_review.export_validation"):
|
||||
if format not in {ArtifactFormat.JSON, ArtifactFormat.MARKDOWN}:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only json and markdown exports are supported")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only json and markdown exports are supported",
|
||||
)
|
||||
session = _get_owned_session_or_404(repository, session_id, current_user)
|
||||
export_payload = _build_validation_export(session, format)
|
||||
return ExportArtifactResponse(
|
||||
@@ -1070,13 +1224,15 @@ async def export_validation(
|
||||
created_by_user_id=current_user.id,
|
||||
content=export_payload["content"],
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:export_validation:Function]
|
||||
|
||||
|
||||
# [DEF:get_clarification_state:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Return the current clarification session summary and one active question payload.
|
||||
# @RELATION: [CALLS] ->[ClarificationEngine.build_question_payload]
|
||||
# @RELATION: [CALLS] ->[build_question_payload:Function]
|
||||
# @PRE: Session is accessible to current user and clarification feature is enabled.
|
||||
# @POST: Returns at most one active clarification question with why_it_matters, current_guess, and ordered options.
|
||||
# @SIDE_EFFECT: May normalize clarification pointer and readiness state in persistence.
|
||||
@@ -1108,13 +1264,15 @@ async def get_clarification_state(
|
||||
changed_findings=[],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:get_clarification_state:Function]
|
||||
|
||||
|
||||
# [DEF:resume_clarification:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Resume clarification mode on the highest-priority unresolved question for an owned session.
|
||||
# @RELATION: [CALLS] ->[ClarificationEngine.build_question_payload]
|
||||
# @RELATION: [CALLS] ->[build_question_payload:Function]
|
||||
# @PRE: Session belongs to the current owner and clarification feature is enabled.
|
||||
# @POST: Clarification session enters active state with one current question or completes deterministically when no unresolved items remain.
|
||||
# @SIDE_EFFECT: Mutates clarification pointer, readiness, and recommended action.
|
||||
@@ -1147,13 +1305,15 @@ async def resume_clarification(
|
||||
changed_findings=[],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:resume_clarification:Function]
|
||||
|
||||
|
||||
# [DEF:record_clarification_answer:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Persist one clarification answer before advancing the active pointer or readiness state.
|
||||
# @RELATION: [CALLS] ->[ClarificationEngine.record_answer]
|
||||
# @RELATION: [CALLS] ->[record_answer:Function]
|
||||
# @PRE: Target question is the session's active clarification question and current user owns the session.
|
||||
# @POST: Answer is persisted, changed findings are returned, and unresolved skipped/expert-review questions remain visible.
|
||||
# @SIDE_EFFECT: Inserts answer row and mutates clarification/session state.
|
||||
@@ -1188,7 +1348,9 @@ async def record_clarification_answer(
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
return ClarificationAnswerResultResponse(
|
||||
clarification_state=_serialize_clarification_state(result),
|
||||
@@ -1198,6 +1360,8 @@ async def record_clarification_answer(
|
||||
for item in result.changed_findings
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:record_clarification_answer:Function]
|
||||
|
||||
|
||||
@@ -1246,6 +1410,8 @@ async def update_field_semantic(
|
||||
},
|
||||
)
|
||||
return _serialize_semantic_field(field)
|
||||
|
||||
|
||||
# [/DEF:update_field_semantic:Function]
|
||||
|
||||
|
||||
@@ -1288,6 +1454,8 @@ async def lock_field_semantic(
|
||||
event_details={"field_id": field.field_id},
|
||||
)
|
||||
return _serialize_semantic_field(field)
|
||||
|
||||
|
||||
# [/DEF:lock_field_semantic:Function]
|
||||
|
||||
|
||||
@@ -1333,6 +1501,8 @@ async def unlock_field_semantic(
|
||||
event_details={"field_id": field.field_id},
|
||||
)
|
||||
return _serialize_semantic_field(field)
|
||||
|
||||
|
||||
# [/DEF:unlock_field_semantic:Function]
|
||||
|
||||
|
||||
@@ -1367,7 +1537,9 @@ async def approve_batch_semantic_fields(
|
||||
field = _get_owned_field_or_404(session, item.field_id)
|
||||
updated_field = _update_semantic_field_state(
|
||||
field,
|
||||
FieldSemanticUpdateRequest(candidate_id=item.candidate_id, lock_field=item.lock_field),
|
||||
FieldSemanticUpdateRequest(
|
||||
candidate_id=item.candidate_id, lock_field=item.lock_field
|
||||
),
|
||||
changed_by="user",
|
||||
)
|
||||
updated_fields.append(updated_field)
|
||||
@@ -1387,6 +1559,8 @@ async def approve_batch_semantic_fields(
|
||||
},
|
||||
)
|
||||
return [_serialize_semantic_field(field) for field in updated_fields]
|
||||
|
||||
|
||||
# [/DEF:approve_batch_semantic_fields:Function]
|
||||
|
||||
|
||||
@@ -1415,8 +1589,13 @@ async def list_execution_mappings(
|
||||
with belief_scope("dataset_review.list_execution_mappings"):
|
||||
session = _get_owned_session_or_404(repository, session_id, current_user)
|
||||
return MappingCollectionResponse(
|
||||
items=[_serialize_execution_mapping(item) for item in session.execution_mappings]
|
||||
items=[
|
||||
_serialize_execution_mapping(item)
|
||||
for item in session.execution_mappings
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:list_execution_mappings:Function]
|
||||
|
||||
|
||||
@@ -1456,7 +1635,9 @@ async def update_execution_mapping(
|
||||
)
|
||||
|
||||
mapping.effective_value = request.effective_value
|
||||
mapping.mapping_method = MappingMethod(request.mapping_method or MappingMethod.MANUAL_OVERRIDE.value)
|
||||
mapping.mapping_method = MappingMethod(
|
||||
request.mapping_method or MappingMethod.MANUAL_OVERRIDE.value
|
||||
)
|
||||
mapping.transformation_note = request.transformation_note
|
||||
mapping.approval_state = ApprovalState.APPROVED
|
||||
mapping.approved_by_user_id = current_user.id
|
||||
@@ -1491,6 +1672,8 @@ async def update_execution_mapping(
|
||||
},
|
||||
)
|
||||
return _serialize_execution_mapping(mapping)
|
||||
|
||||
|
||||
# [/DEF:update_execution_mapping:Function]
|
||||
|
||||
|
||||
@@ -1544,6 +1727,8 @@ async def approve_execution_mapping(
|
||||
},
|
||||
)
|
||||
return _serialize_execution_mapping(mapping)
|
||||
|
||||
|
||||
# [/DEF:approve_execution_mapping:Function]
|
||||
|
||||
|
||||
@@ -1603,13 +1788,15 @@ async def approve_batch_execution_mappings(
|
||||
},
|
||||
)
|
||||
return [_serialize_execution_mapping(mapping) for mapping in updated_mappings]
|
||||
|
||||
|
||||
# [/DEF:approve_batch_execution_mappings:Function]
|
||||
|
||||
|
||||
# [DEF:trigger_preview_generation:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Trigger Superset-side preview compilation for the current owned execution context.
|
||||
# @RELATION: [CALLS] ->[DatasetReviewOrchestrator.prepare_launch_preview]
|
||||
# @RELATION: [CALLS] ->[prepare_launch_preview:Function]
|
||||
# @PRE: Session belongs to the current owner and required mapping inputs are available.
|
||||
# @POST: Returns the compiled preview directly for synchronous success or enqueue-state shape when preview generation remains pending.
|
||||
# @SIDE_EFFECT: Persists preview attempt and updates readiness state.
|
||||
@@ -1640,8 +1827,10 @@ async def trigger_preview_generation(
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
status_code = (
|
||||
status.HTTP_404_NOT_FOUND if detail in {"Session not found", "Environment not found"}
|
||||
else status.HTTP_409_CONFLICT if detail.startswith("Preview blocked:")
|
||||
status.HTTP_404_NOT_FOUND
|
||||
if detail in {"Session not found", "Environment not found"}
|
||||
else status.HTTP_409_CONFLICT
|
||||
if detail.startswith("Preview blocked:")
|
||||
else status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
@@ -1656,13 +1845,15 @@ async def trigger_preview_generation(
|
||||
|
||||
response.status_code = status.HTTP_200_OK
|
||||
return CompiledPreviewDto.model_validate(result.preview, from_attributes=True)
|
||||
|
||||
|
||||
# [/DEF:trigger_preview_generation:Function]
|
||||
|
||||
|
||||
# [DEF:launch_dataset:Function]
|
||||
# @COMPLEXITY: 4
|
||||
# @PURPOSE: Execute the current owned session launch handoff through the orchestrator and return audited SQL Lab run context.
|
||||
# @RELATION: [CALLS] ->[DatasetReviewOrchestrator.launch_dataset]
|
||||
# @RELATION: [CALLS] ->[launch_dataset:Function]
|
||||
# @PRE: Session belongs to the current owner, execution feature is enabled, and launch gates are satisfied or a deterministic conflict is returned.
|
||||
# @POST: Returns persisted run context plus redirect URL when launch handoff is accepted.
|
||||
# @SIDE_EFFECT: Persists launch audit snapshot and may trigger SQL Lab session creation.
|
||||
@@ -1703,7 +1894,9 @@ async def launch_dataset(
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
|
||||
environment = config_manager.get_environment(result.session.environment_id)
|
||||
environment_url = getattr(environment, "url", "") if environment is not None else ""
|
||||
environment_url = (
|
||||
getattr(environment, "url", "") if environment is not None else ""
|
||||
)
|
||||
return LaunchDatasetResponse(
|
||||
run_context=_serialize_run_context(result.run_context),
|
||||
redirect_url=_build_sql_lab_redirect_url(
|
||||
@@ -1711,6 +1904,8 @@ async def launch_dataset(
|
||||
sql_lab_session_ref=result.run_context.sql_lab_session_ref,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:launch_dataset:Function]
|
||||
|
||||
|
||||
@@ -1752,6 +1947,8 @@ async def record_field_feedback(
|
||||
event_details={"field_id": field.field_id, "feedback": request.feedback},
|
||||
)
|
||||
return FeedbackResponse(target_id=field.field_id, feedback=request.feedback)
|
||||
|
||||
|
||||
# [/DEF:record_field_feedback:Function]
|
||||
|
||||
|
||||
@@ -1784,13 +1981,23 @@ async def record_clarification_feedback(
|
||||
_require_owner_mutation_scope(session, current_user)
|
||||
clarification_session = _get_latest_clarification_session_or_404(session)
|
||||
question = next(
|
||||
(item for item in clarification_session.questions if item.question_id == question_id),
|
||||
(
|
||||
item
|
||||
for item in clarification_session.questions
|
||||
if item.question_id == question_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if question is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Clarification question not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Clarification question not found",
|
||||
)
|
||||
if question.answer is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Clarification answer not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Clarification answer not found",
|
||||
)
|
||||
question.answer.user_feedback = request.feedback
|
||||
repository.db.commit()
|
||||
_record_session_event(
|
||||
@@ -1799,9 +2006,16 @@ async def record_clarification_feedback(
|
||||
current_user,
|
||||
event_type="clarification_feedback_recorded",
|
||||
event_summary="Clarification feedback persisted",
|
||||
event_details={"question_id": question.question_id, "feedback": request.feedback},
|
||||
event_details={
|
||||
"question_id": question.question_id,
|
||||
"feedback": request.feedback,
|
||||
},
|
||||
)
|
||||
return FeedbackResponse(target_id=question.question_id, feedback=request.feedback)
|
||||
return FeedbackResponse(
|
||||
target_id=question.question_id, feedback=request.feedback
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:record_clarification_feedback:Function]
|
||||
|
||||
# [/DEF:DatasetReviewApi:Module]
|
||||
# [/DEF:DatasetReviewApi:Module]
|
||||
|
||||
@@ -31,21 +31,25 @@ def _is_valid_runtime_api_key(value: Optional[str]) -> bool:
|
||||
if key in {"********", "EMPTY_OR_NONE"}:
|
||||
return False
|
||||
return len(key) >= 16
|
||||
|
||||
|
||||
# [/DEF:_is_valid_runtime_api_key:Function]
|
||||
|
||||
|
||||
# [DEF:get_providers:Function]
|
||||
# @PURPOSE: Retrieve all LLM provider configurations.
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns list of LLMProviderConfig.
|
||||
@router.get("/providers", response_model=List[LLMProviderConfig])
|
||||
async def get_providers(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all LLM provider configurations.
|
||||
"""
|
||||
logger.info(f"[llm_routes][get_providers][Action] 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()
|
||||
return [
|
||||
@@ -56,9 +60,12 @@ async def get_providers(
|
||||
base_url=p.base_url,
|
||||
api_key="********",
|
||||
default_model=p.default_model,
|
||||
is_active=p.is_active
|
||||
) for p in providers
|
||||
is_active=p.is_active,
|
||||
)
|
||||
for p in providers
|
||||
]
|
||||
|
||||
|
||||
# [/DEF:get_providers:Function]
|
||||
|
||||
|
||||
@@ -68,8 +75,7 @@ async def get_providers(
|
||||
# @POST: configured=true only when an active provider with valid decrypted key exists.
|
||||
@router.get("/status")
|
||||
async def get_llm_status(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db)
|
||||
):
|
||||
service = LLMProviderService(db)
|
||||
providers = service.get_all_providers()
|
||||
@@ -90,17 +96,22 @@ async def get_llm_status(
|
||||
"provider_type": active_provider.provider_type,
|
||||
"default_model": active_provider.default_model,
|
||||
}
|
||||
|
||||
|
||||
# [/DEF:get_llm_status:Function]
|
||||
|
||||
|
||||
# [DEF:create_provider:Function]
|
||||
# @PURPOSE: Create a new LLM provider configuration.
|
||||
# @PRE: User is authenticated and has admin permissions.
|
||||
# @POST: Returns the created LLMProviderConfig.
|
||||
@router.post("/providers", response_model=LLMProviderConfig, status_code=status.HTTP_201_CREATED)
|
||||
@router.post(
|
||||
"/providers", response_model=LLMProviderConfig, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_provider(
|
||||
config: LLMProviderConfig,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create a new LLM provider configuration.
|
||||
@@ -114,10 +125,13 @@ async def create_provider(
|
||||
base_url=provider.base_url,
|
||||
api_key="********",
|
||||
default_model=provider.default_model,
|
||||
is_active=provider.is_active
|
||||
is_active=provider.is_active,
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:create_provider:Function]
|
||||
|
||||
|
||||
# [DEF:update_provider:Function]
|
||||
# @PURPOSE: Update an existing LLM provider configuration.
|
||||
# @PRE: User is authenticated and has admin permissions.
|
||||
@@ -127,7 +141,7 @@ async def update_provider(
|
||||
provider_id: str,
|
||||
config: LLMProviderConfig,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update an existing LLM provider configuration.
|
||||
@@ -136,7 +150,7 @@ async def update_provider(
|
||||
provider = service.update_provider(provider_id, config)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
|
||||
return LLMProviderConfig(
|
||||
id=provider.id,
|
||||
provider_type=LLMProviderType(provider.provider_type),
|
||||
@@ -144,10 +158,13 @@ async def update_provider(
|
||||
base_url=provider.base_url,
|
||||
api_key="********",
|
||||
default_model=provider.default_model,
|
||||
is_active=provider.is_active
|
||||
is_active=provider.is_active,
|
||||
)
|
||||
|
||||
|
||||
# [/DEF:update_provider:Function]
|
||||
|
||||
|
||||
# [DEF:delete_provider:Function]
|
||||
# @PURPOSE: Delete an LLM provider configuration.
|
||||
# @PRE: User is authenticated and has admin permissions.
|
||||
@@ -156,7 +173,7 @@ async def update_provider(
|
||||
async def delete_provider(
|
||||
provider_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Delete an LLM provider configuration.
|
||||
@@ -165,8 +182,11 @@ async def delete_provider(
|
||||
if not service.delete_provider(provider_id):
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
return
|
||||
|
||||
|
||||
# [/DEF:delete_provider:Function]
|
||||
|
||||
|
||||
# [DEF:test_connection:Function]
|
||||
# @PURPOSE: Test connection to an LLM provider.
|
||||
# @PRE: User is authenticated.
|
||||
@@ -175,76 +195,87 @@ async def delete_provider(
|
||||
async def test_connection(
|
||||
provider_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
logger.info(f"[llm_routes][test_connection][Action] 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.
|
||||
"""
|
||||
from ...plugins.llm_analysis.service import LLMClient
|
||||
|
||||
service = LLMProviderService(db)
|
||||
db_provider = service.get_provider(provider_id)
|
||||
if not db_provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
|
||||
api_key = service.get_decrypted_api_key(provider_id)
|
||||
|
||||
|
||||
# Check if API key was successfully decrypted
|
||||
if not api_key:
|
||||
logger.error(f"[llm_routes][test_connection] Failed to decrypt API key for provider {provider_id}")
|
||||
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."
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
client = LLMClient(
|
||||
provider_type=LLMProviderType(db_provider.provider_type),
|
||||
api_key=api_key,
|
||||
base_url=db_provider.base_url,
|
||||
default_model=db_provider.default_model
|
||||
default_model=db_provider.default_model,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
await client.test_runtime_connection()
|
||||
return {"success": True, "message": "Connection successful"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# [/DEF:test_connection:Function]
|
||||
|
||||
|
||||
# [DEF:test_provider_config:Function]
|
||||
# @PURPOSE: Test connection with a provided configuration (not yet saved).
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns success status and message.
|
||||
@router.post("/providers/test")
|
||||
async def test_provider_config(
|
||||
config: LLMProviderConfig,
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
config: LLMProviderConfig, current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""
|
||||
Test connection with a provided configuration.
|
||||
"""
|
||||
from ...plugins.llm_analysis.service import LLMClient
|
||||
logger.info(f"[llm_routes][test_provider_config][Action] 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
|
||||
if not config.api_key or config.api_key == "********":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="API key is required for testing connection"
|
||||
status_code=400, detail="API key is required for testing connection"
|
||||
)
|
||||
|
||||
|
||||
client = LLMClient(
|
||||
provider_type=config.provider_type,
|
||||
api_key=config.api_key,
|
||||
base_url=config.base_url,
|
||||
default_model=config.default_model
|
||||
default_model=config.default_model,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
await client.test_runtime_connection()
|
||||
return {"success": True, "message": "Connection successful"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# [/DEF:test_provider_config:Function]
|
||||
|
||||
# [/DEF:backend/src/api/routes/llm.py]
|
||||
# [/DEF:backend/src/api/routes/llm.py:Module]
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
# @SEMANTICS: api, reports, list, detail, pagination, filters
|
||||
# @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: DEPENDS_ON -> [backend.src.services.reports.report_service.ReportsService]
|
||||
# @RELATION: DEPENDS_ON -> [AppDependencies]
|
||||
# @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.
|
||||
@@ -17,10 +19,20 @@ from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from ...dependencies import get_task_manager, has_permission, get_clean_release_repository
|
||||
from ...dependencies import (
|
||||
get_task_manager,
|
||||
has_permission,
|
||||
get_clean_release_repository,
|
||||
)
|
||||
from ...core.task_manager import TaskManager
|
||||
from ...core.logger import belief_scope
|
||||
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskType
|
||||
from ...models.report import (
|
||||
ReportCollection,
|
||||
ReportDetailView,
|
||||
ReportQuery,
|
||||
ReportStatus,
|
||||
TaskType,
|
||||
)
|
||||
from ...services.clean_release.repository import CleanReleaseRepository
|
||||
from ...services.reports.report_service import ReportsService
|
||||
# [/SECTION]
|
||||
@@ -60,6 +72,8 @@ def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
|
||||
},
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
# [/DEF:_parse_csv_enum_list:Function]
|
||||
|
||||
|
||||
@@ -69,6 +83,9 @@ def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> 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.
|
||||
# @RELATION: [CALLS] ->[_parse_csv_enum_list:Function]
|
||||
# @RELATION: [DEPENDS_ON] ->[ReportQuery:Class]
|
||||
# @RELATION: [DEPENDS_ON] ->[ReportsService:Class]
|
||||
#
|
||||
# @TEST_CONTRACT: ListReportsApi ->
|
||||
# {
|
||||
@@ -95,7 +112,9 @@ async def list_reports(
|
||||
sort_by: str = Query("updated_at"),
|
||||
sort_order: str = Query("desc"),
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
clean_release_repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
clean_release_repository: CleanReleaseRepository = Depends(
|
||||
get_clean_release_repository
|
||||
),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("list_reports"):
|
||||
@@ -125,8 +144,12 @@ async def list_reports(
|
||||
},
|
||||
)
|
||||
|
||||
service = ReportsService(task_manager, clean_release_repository=clean_release_repository)
|
||||
service = ReportsService(
|
||||
task_manager, clean_release_repository=clean_release_repository
|
||||
)
|
||||
return service.list_reports(query)
|
||||
|
||||
|
||||
# [/DEF:list_reports:Function]
|
||||
|
||||
|
||||
@@ -139,11 +162,15 @@ async def list_reports(
|
||||
async def get_report_detail(
|
||||
report_id: str,
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
clean_release_repository: CleanReleaseRepository = Depends(get_clean_release_repository),
|
||||
clean_release_repository: CleanReleaseRepository = Depends(
|
||||
get_clean_release_repository
|
||||
),
|
||||
_=Depends(has_permission("tasks", "READ")),
|
||||
):
|
||||
with belief_scope("get_report_detail", f"report_id={report_id}"):
|
||||
service = ReportsService(task_manager, clean_release_repository=clean_release_repository)
|
||||
service = ReportsService(
|
||||
task_manager, clean_release_repository=clean_release_repository
|
||||
)
|
||||
detail = service.get_report_detail(report_id)
|
||||
if not detail:
|
||||
raise HTTPException(
|
||||
@@ -151,6 +178,8 @@ async def get_report_detail(
|
||||
detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"},
|
||||
)
|
||||
return detail
|
||||
|
||||
|
||||
# [/DEF:get_report_detail:Function]
|
||||
|
||||
# [/DEF:ReportsRouter:Module]
|
||||
# [/DEF:ReportsRouter:Module]
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
# @SEMANTICS: settings, api, router, fastapi
|
||||
# @PURPOSE: Provides API endpoints for managing application settings and Superset environments.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: DEPENDS_ON -> [backend.src.core.config_manager.ConfigManager]
|
||||
# @RELATION: DEPENDS_ON -> [backend.src.core.config_models]
|
||||
# @RELATION: [DEPENDS_ON] ->[ConfigManager]
|
||||
# @RELATION: [DEPENDS_ON] ->[get_config_manager:Function]
|
||||
# @RELATION: [DEPENDS_ON] ->[has_permission:Function]
|
||||
#
|
||||
# @INVARIANT: All settings changes must be persisted via ConfigManager.
|
||||
# @PUBLIC_API: router
|
||||
@@ -413,6 +414,12 @@ class ConsolidatedSettingsResponse(BaseModel):
|
||||
# @PRE: Config manager is available.
|
||||
# @POST: Returns all consolidated settings.
|
||||
# @RETURN: ConsolidatedSettingsResponse - All settings categories.
|
||||
# @RELATION: [DEPENDS_ON] ->[ConfigManager]
|
||||
# @RELATION: [DEPENDS_ON] ->[LLMProviderService]
|
||||
# @RELATION: [DEPENDS_ON] ->[AppConfigRecord]
|
||||
# @RELATION: [DEPENDS_ON] ->[SessionLocal]
|
||||
# @RELATION: [DEPENDS_ON] ->[has_permission:Function]
|
||||
# @RELATION: [DEPENDS_ON] ->[normalize_llm_settings:Function]
|
||||
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
|
||||
async def get_consolidated_settings(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
|
||||
Reference in New Issue
Block a user