refactor: decompose 6 monolithic modules (>1000 LOC) into domain packages

Decompose 6 large modules into packages with <400-line modules to satisfy INV_7:

- superset_client.py (2145->0) -> superset_client/ package (12 modules)
- assistant.py (2683->0) -> assistant/ package (12 modules)
- git_service.py (2101->11 shim) -> services/git/ package (9 modules)
- git.py (1757->0) -> routes/git/ package (11 modules)
- dashboards.py (1619->0) -> routes/dashboards/ package (8 modules)
- translate.py (1587->0) -> routes/translate/ package (11 modules)
- superset_context_extractor.py (1397->0) -> utils/superset_context_extractor/ (7 modules)

All modules <400 lines (INV_7). All 80 tests pass.
All external imports preserved via __init__.py re-exports or shim.
Semantic [DEF] contracts with @LAYER/@SEMANTICS added to all Module types.
This commit is contained in:
2026-05-09 10:13:07 +03:00
parent 344b47ca23
commit 201886eeb0
76 changed files with 9831 additions and 12576 deletions

View File

@@ -37,6 +37,7 @@ __all__ = [
"dashboards",
"datasets",
"health",
"translate",
]
# [/DEF:Route_Group_Contracts:Block]

View File

@@ -251,7 +251,7 @@ def test_get_dashboards_invalid_pagination(mock_deps):
# @PURPOSE: Validate dashboard detail returns charts and datasets for an existing dashboard.
# @TEST: GET /api/dashboards/{id} returns dashboard detail with charts and datasets
def test_get_dashboard_detail_success(mock_deps):
with patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
with patch("src.api.routes.dashboards._detail_routes.SupersetClient") as mock_client_cls:
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
@@ -554,7 +554,7 @@ def test_get_dashboard_tasks_history_filters_success(mock_deps):
# @PURPOSE: Validate dashboard thumbnail endpoint proxies image bytes and content type from Superset.
# @TEST: GET /api/dashboards/{id}/thumbnail proxies image bytes from Superset
def test_get_dashboard_thumbnail_success(mock_deps):
with patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
with patch("src.api.routes.dashboards._detail_routes.SupersetClient") as mock_client_cls:
mock_env = MagicMock()
mock_env.id = "prod"
mock_deps["config"].get_environments.return_value = [mock_env]
@@ -666,7 +666,7 @@ def test_get_dashboards_profile_filter_contract_owners_or_modified_by(mock_deps)
]
)
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
with patch("src.api.routes.dashboards._listing_routes.ProfileService") as profile_service_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
username=" JOHN_DOE ",
@@ -787,7 +787,7 @@ def test_get_dashboards_profile_filter_no_match_results_contract(mock_deps):
]
)
with patch("src.api.routes.dashboards.ProfileService") as profile_service_cls:
with patch("src.api.routes.dashboards._listing_routes.ProfileService") as profile_service_cls:
profile_service = MagicMock()
profile_service.get_my_preference.return_value = _build_profile_preference_stub(
username="john_doe",
@@ -916,10 +916,10 @@ def test_get_dashboards_profile_filter_matches_display_alias_without_detail_fano
)
with (
patch("src.api.routes.dashboards.ProfileService") as profile_service_cls,
patch("src.api.routes.dashboards.SupersetClient") as superset_client_cls,
patch("src.api.routes.dashboards._listing_routes.ProfileService") as profile_service_cls,
patch("src.api.routes.dashboards._projection.SupersetClient") as superset_client_cls,
patch(
"src.api.routes.dashboards.SupersetAccountLookupAdapter"
"src.api.routes.dashboards._projection.SupersetAccountLookupAdapter"
) as lookup_adapter_cls,
):
profile_service = MagicMock()
@@ -1013,9 +1013,9 @@ def test_get_dashboards_profile_filter_matches_owner_object_payload_contract(moc
)
with (
patch("src.api.routes.dashboards.ProfileService") as profile_service_cls,
patch("src.api.routes.dashboards._listing_routes.ProfileService") as profile_service_cls,
patch(
"src.api.routes.dashboards._resolve_profile_actor_aliases",
"src.api.routes.dashboards._projection._resolve_profile_actor_aliases",
return_value=["user_1"],
),
):

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
# Re-export public API for backward compatibility.
from ._routes import router, send_message, confirm_operation, cancel_operation
from ._routes import list_conversations, delete_conversation, get_history, get_assistant_audit
from ._admin_routes import list_conversations, delete_conversation, get_history, get_assistant_audit
from ._schemas import (
AssistantMessageRequest,
AssistantMessageResponse,
@@ -26,13 +26,14 @@ from ._schemas import (
_DATASET_REVIEW_OPS,
)
from ._command_parser import _parse_command
from ._llm_planner import _plan_intent_with_llm, _build_tool_catalog, _authorize_intent
from ._llm_planner import _build_tool_catalog
from ._llm_planner_intent import _plan_intent_with_llm, _authorize_intent
from ._dispatch import _dispatch_intent, _async_confirmation_summary, _clarification_text_for_intent
from ._dataset_review import (
_load_dataset_review_context,
_plan_dataset_review_intent,
_dispatch_dataset_review_intent,
)
from ._dataset_review_dispatch import _dispatch_dataset_review_intent
from ._history import (
_append_history,
_persist_message,

View File

@@ -0,0 +1,305 @@
# [DEF:AssistantAdminRoutes:Module]
# @COMPLEXITY: 5
# @PURPOSE: FastAPI route handlers for assistant admin operations — conversation listing, deletion, history, audit.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [AssistantRoutes]
# @RELATION: DEPENDS_ON -> [AssistantSchemas]
# @RELATION: DEPENDS_ON -> [AssistantHistory]
# @INVARIANT: Audit endpoint requires tasks:READ permission.
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Dict, Optional
from fastapi import Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc
from src.core.logger import belief_scope, logger
from src.dependencies import get_current_user, has_permission
from src.core.database import get_db
from src.models.assistant import (
AssistantAuditRecord,
AssistantMessageRecord,
)
from src.schemas.auth import User
from ._schemas import (
ASSISTANT_AUDIT,
CONVERSATIONS,
AssistantAction,
AssistantMessageResponse,
)
from ._history import (
_cleanup_history_ttl,
_coerce_query_bool,
_is_conversation_archived,
_persist_audit,
_resolve_or_create_conversation,
)
from ._routes import router
# [DEF:list_conversations:Function]
# @COMPLEXITY: 2
# @PURPOSE: Return paginated conversation list for current user with archived flag and last message preview.
# @PRE: Authenticated user context and valid pagination params.
# @POST: Conversations are grouped by conversation_id sorted by latest activity descending.
# @RETURN: Dict with items, paging metadata, and archive segmentation counts.
@router.get("/conversations")
async def list_conversations(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
include_archived: bool = Query(False),
archived_only: bool = Query(False),
search: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
with belief_scope("assistant.conversations"):
user_id = current_user.id
include_archived = _coerce_query_bool(include_archived)
archived_only = _coerce_query_bool(archived_only)
_cleanup_history_ttl(db, user_id)
rows = (
db.query(AssistantMessageRecord)
.filter(AssistantMessageRecord.user_id == user_id)
.order_by(desc(AssistantMessageRecord.created_at))
.all()
)
summary: Dict[str, Dict[str, Any]] = {}
for row in rows:
conv_id = row.conversation_id
if not conv_id:
continue
created_at = row.created_at or datetime.utcnow()
if conv_id not in summary:
summary[conv_id] = {
"conversation_id": conv_id,
"title": "",
"updated_at": created_at,
"last_message": row.text,
"last_role": row.role,
"last_state": row.state,
"last_task_id": row.task_id,
"message_count": 0,
}
item = summary[conv_id]
item["message_count"] += 1
if row.role == "user" and row.text and not item["title"]:
item["title"] = row.text.strip()[:80]
items = []
search_term = search.lower().strip() if search else ""
archived_total = sum(
1
for c in summary.values()
if _is_conversation_archived(c.get("updated_at"))
)
active_total = len(summary) - archived_total
for conv in summary.values():
conv["archived"] = _is_conversation_archived(conv.get("updated_at"))
if not conv.get("title"):
conv["title"] = f"Conversation {conv['conversation_id'][:8]}"
if search_term:
haystack = (
f"{conv.get('title', '')} {conv.get('last_message', '')}".lower()
)
if search_term not in haystack:
continue
if archived_only and not conv["archived"]:
continue
if not archived_only and not include_archived and conv["archived"]:
continue
updated = conv.get("updated_at")
conv["updated_at"] = (
updated.isoformat() if isinstance(updated, datetime) else None
)
items.append(conv)
items.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
total = len(items)
start = (page - 1) * page_size
page_items = items[start : start + page_size]
return {
"items": page_items,
"total": total,
"page": page,
"page_size": page_size,
"has_next": start + page_size < total,
"active_total": active_total,
"archived_total": archived_total,
}
# [/DEF:list_conversations:Function]
# [DEF:delete_conversation:Function]
# @COMPLEXITY: 2
# @PURPOSE: Soft-delete or hard-delete a conversation and clear its in-memory trace.
# @PRE: conversation_id belongs to current_user.
# @POST: Conversation records are removed from DB and CONVERSATIONS cache.
@router.delete("/conversations/{conversation_id}")
async def delete_conversation(
conversation_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
with belief_scope("assistant.conversations.delete"):
user_id = current_user.id
# 1. Remove from in-memory cache
key = (user_id, conversation_id)
if key in CONVERSATIONS:
del CONVERSATIONS[key]
# 2. Delete from database
deleted_count = (
db.query(AssistantMessageRecord)
.filter(
AssistantMessageRecord.user_id == user_id,
AssistantMessageRecord.conversation_id == conversation_id,
)
.delete()
)
db.commit()
if deleted_count == 0:
raise HTTPException(
status_code=404, detail="Conversation not found or already deleted"
)
return {
"status": "success",
"deleted": deleted_count,
"conversation_id": conversation_id,
}
# [/DEF:delete_conversation:Function]
@router.get("/history")
# [DEF:get_history:Function]
# @PURPOSE: Retrieve paginated assistant conversation history for current user.
# @PRE: Authenticated user is available and page params are valid.
# @POST: Returns persistent messages and mirrored in-memory snapshot for diagnostics.
# @RETURN: Dict with items, paging metadata, and resolved conversation_id.
async def get_history(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
conversation_id: Optional[str] = Query(None),
from_latest: bool = Query(False),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
with belief_scope("assistant.history"):
user_id = current_user.id
_cleanup_history_ttl(db, user_id)
conv_id = _resolve_or_create_conversation(user_id, conversation_id, db)
base_query = db.query(AssistantMessageRecord).filter(
AssistantMessageRecord.user_id == user_id,
AssistantMessageRecord.conversation_id == conv_id,
)
total = base_query.count()
start = (page - 1) * page_size
if from_latest:
rows = (
base_query.order_by(desc(AssistantMessageRecord.created_at))
.offset(start)
.limit(page_size)
.all()
)
rows = list(reversed(rows))
else:
rows = (
base_query.order_by(AssistantMessageRecord.created_at.asc())
.offset(start)
.limit(page_size)
.all()
)
persistent_items = [
{
"message_id": row.id,
"conversation_id": row.conversation_id,
"role": row.role,
"text": row.text,
"state": row.state,
"task_id": row.task_id,
"confirmation_id": row.confirmation_id,
"created_at": row.created_at.isoformat() if row.created_at else None,
"metadata": row.payload,
}
for row in rows
]
memory_items = CONVERSATIONS.get((user_id, conv_id), [])
return {
"items": persistent_items,
"memory_items": memory_items,
"total": total,
"page": page,
"page_size": page_size,
"has_next": start + page_size < total,
"from_latest": from_latest,
"conversation_id": conv_id,
}
# [/DEF:get_history:Function]
@router.get("/audit")
# [DEF:get_assistant_audit:Function]
# @PURPOSE: Return assistant audit decisions for current user from persistent and in-memory stores.
# @PRE: User has tasks:READ permission.
# @POST: Audit payload is returned in reverse chronological order from DB.
# @RETURN: Dict with persistent and memory audit slices.
async def get_assistant_audit(
limit: int = Query(50, ge=1, le=500),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope("assistant.audit"):
memory_rows = ASSISTANT_AUDIT.get(current_user.id, [])
db_rows = (
db.query(AssistantAuditRecord)
.filter(AssistantAuditRecord.user_id == current_user.id)
.order_by(AssistantAuditRecord.created_at.desc())
.limit(limit)
.all()
)
persistent = [
{
"id": row.id,
"user_id": row.user_id,
"conversation_id": row.conversation_id,
"decision": row.decision,
"task_id": row.task_id,
"message": row.message,
"payload": row.payload,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
for row in db_rows
]
return {
"items": persistent,
"memory_items": memory_rows[-limit:],
"total": len(persistent),
"memory_total": len(memory_rows),
}
# [/DEF:get_assistant_audit:Function]
# [/DEF:AssistantAdminRoutes:Module]

View File

@@ -1,9 +1,10 @@
# [DEF:AssistantDatasetReview:Module]
# @COMPLEXITY: 4
# @PURPOSE: Dataset review context loading, intent planning, and dispatch for the assistant API.
# @PURPOSE: Dataset review context loading and intent planning for the assistant API.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [DatasetReviewOrchestrator]
# @RELATION: DEPENDS_ON -> [AssistantSchemas]
# @RELATION: DISPATCHES -> [AssistantDatasetReviewDispatch]
# @INVARIANT: Dataset review operations are always scoped to the owner's session.
from __future__ import annotations
@@ -16,7 +17,6 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from src.core.logger import belief_scope, logger
from src.core.config_manager import ConfigManager
from src.core.utils.superset_context_extractor import (
sanitize_imported_filter_for_assistant,
)
@@ -26,16 +26,11 @@ from src.models.dataset_review import (
ReadinessState,
RecommendedAction,
)
from src.services.dataset_review.orchestrator import (
DatasetReviewOrchestrator,
PreparePreviewCommand,
)
from src.services.dataset_review.repositories.session_repository import (
DatasetReviewSessionRepository,
DatasetReviewSessionVersionConflictError,
)
from src.schemas.auth import User
from src.api.routes.dataset_review import FieldSemanticUpdateRequest, _update_semantic_field_state
from ._schemas import (
AssistantAction,
)
@@ -56,7 +51,7 @@ def _serialize_dataset_review_context(session: DatasetReviewSession) -> Dict[str
if previews:
latest_preview = previews[-1]
logger.reflect('Belief protocol postcondition checkpoint for _serialize_dataset_review_context')
return {'session_id': session.session_id, 'version': int(getattr(session, 'version', 0) or 0), 'dataset_ref': session.dataset_ref, 'dataset_id': session.dataset_id, 'environment_id': session.environment_id, 'readiness_state': session.readiness_state.value, 'recommended_action': session.recommended_action.value, 'status': session.status.value, 'current_phase': session.current_phase.value, 'findings': [{'finding_id': item.finding_id, 'code': item.code, 'severity': item.severity.value, 'message': item.message, 'resolution_state': item.resolution_state.value} for item in getattr(session, 'findings', [])], 'imported_filters': [sanitize_imported_filter_for_assistant({'filter_id': item.filter_id, 'filter_name': item.filter_name, 'display_name': item.display_name, 'raw_value': item.raw_value, 'raw_value_masked': bool(getattr(item, 'raw_value_masked', False)), 'normalized_value': item.normalized_value, 'source': getattr(item.source, 'value', item.source), 'confidence_state': getattr(item.confidence_state, 'value', item.confidence_state), 'requires_confirmation': bool(item.requires_confirmation), 'recovery_status': getattr(item.recovery_status, 'value', item.recovery_status), 'notes': item.notes}) for item in getattr(session, 'imported_filters', [])], 'mappings': [{'mapping_id': item.mapping_id, 'filter_id': item.filter_id, 'variable_id': item.variable_id, 'mapping_method': getattr(item.mapping_method, 'value', item.mapping_method), 'effective_value': item.effective_value, 'approval_state': getattr(item.approval_state, 'value', item.approval_state), 'requires_explicit_approval': bool(item.requires_explicit_approval)} for item in getattr(session, 'execution_mappings', [])], 'semantic_fields': [{'field_id': item.field_id, 'field_name': item.field_name, 'verbose_name': item.verbose_name, 'description': item.description, 'display_format': item.display_format, 'provenance': getattr(item.provenance, 'value', item.provenance), 'is_locked': bool(item.is_locked), 'needs_review': bool(item.needs_review), 'candidates': [{'candidate_id': c.candidate_id, 'semantic_type': getattr(c.semantic_type, 'value', c.semantic_type), 'confidence': float(c.confidence or 0), 'reasoning': c.reasoning or ''} for c in (getattr(item, 'candidates', None) or [])]} for item in getattr(session, 'semantic_fields', [])], 'preview': {'preview_status': getattr(latest_preview, 'preview_status', None), 'compiled_sql': getattr(latest_preview, 'compiled_sql', None)} if latest_preview else None}
return {'session_id': session.session_id, 'version': int(getattr(session, 'version', 0) or 0), 'dataset_ref': session.dataset_ref, 'dataset_id': session.dataset_id, 'environment_id': session.environment_id, 'readiness_state': session.readiness_state.value, 'recommended_action': session.recommended_action.value, 'status': session.status.value, 'current_phase': session.current_phase.value, 'findings': [{'finding_id': item.finding_id, 'code': item.code, 'severity': item.severity.value, 'message': item.message, 'resolution_state': item.resolution_state.value} for item in getattr(session, 'findings', [])], 'imported_filters': [sanitize_imported_filter_for_assistant({'filter_id': item.filter_id, 'filter_name': item.filter_name, 'display_name': item.display_name, 'raw_value': item.raw_value, 'raw_value_masked': bool(getattr(item, 'raw_value_masked', False)), 'normalized_value': item.normalized_value, 'source': getattr(item.source, 'value', item.source), 'confidence_state': getattr(item.confidence_state, 'value', item.confidence_state), 'requires_confirmation': bool(item.requires_confirmation), 'recovery_status': getattr(item.recovery_status, 'value', item.recovery_status), 'notes': item.notes}) for item in getattr(session, 'imported_filters', [])], 'mappings': [{'mapping_id': item.mapping_id, 'filter_id': item.filter_id, 'variable_id': item.variable_id, 'mapping_method': getattr(item.mapping_method, 'value', item.mapping_method), 'effective_value': item.effective_value, 'approval_state': getattr(item.approval_state, 'value', item.approval_state), 'requires_explicit_approval': bool(item.requires_explicit_approval)} for item in getattr(session, 'execution_mappings', [])], 'semantic_fields': [{'field_id': item.field_id, 'field_name': item.field_name, 'verbose_name': item.verbose_name, 'description': item.description, 'display_format': item.display_format, 'provenance': getattr(item.provenance, 'value', item.provenance), 'is_locked': bool(item.is_locked), 'needs_review': bool(getattr(item, 'needs_review', False)), 'candidates': [{'candidate_id': c.candidate_id, 'field_id': c.field_id, 'verbose_name': c.proposed_verbose_name, 'description': c.proposed_description, 'display_format': c.proposed_display_format, 'status': getattr(c.status, 'value', c.status), 'source': c.source_id, 'score': c.confidence_score, 'created_at': c.created_at.isoformat() if c.created_at else None} for c in getattr(item, 'candidates', [])]} for item in getattr(session, 'semantic_fields', [])]}
# [/DEF:_serialize_dataset_review_context:Function]
@@ -145,27 +140,6 @@ def _extract_quoted_segment(message: str, label: str) -> Optional[str]:
# [/DEF:_extract_quoted_segment:Function]
# [DEF:_dataset_review_conflict_http_exception:Function]
# @COMPLEXITY: 2
# @PURPOSE: Convert dataset-review optimistic-lock conflicts into shared 409 assistant semantics.
def _dataset_review_conflict_http_exception(
exc: DatasetReviewSessionVersionConflictError,
) -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error_code": "session_version_conflict",
"message": str(exc),
"session_id": exc.session_id,
"expected_version": exc.expected_version,
"actual_version": exc.actual_version,
},
)
# [/DEF:_dataset_review_conflict_http_exception:Function]
# [DEF:_plan_dataset_review_intent:Function]
# @COMPLEXITY: 3
# @PURPOSE: Parse session-scoped dataset-review assistant commands before falling back to generic assistant tool routing.
@@ -320,233 +294,4 @@ def _plan_dataset_review_intent(
# [/DEF:_plan_dataset_review_intent:Function]
# [DEF:_dispatch_dataset_review_intent:Function]
# @COMPLEXITY: 4
# @PURPOSE: Route confirmed dataset-review assistant intents through existing backend dataset-review APIs and orchestration boundaries.
# @RELATION: CALLS -> DatasetReviewOrchestrator
# @PRE: context contains valid session data and user intent.
# @POST: Returns a structured response with planned actions and confirmations.
# @SIDE_EFFECT: May update session state and enqueue tasks.
async def _dispatch_dataset_review_intent(
intent: Dict[str, Any],
current_user: User,
config_manager: ConfigManager,
db: Session,
) -> Tuple[str, Optional[str], List[AssistantAction]]:
with belief_scope("_dispatch_dataset_review_intent"):
logger.reason(
"Dispatching assistant dataset-review intent",
extra={"operation": intent.get("operation")},
)
entities = intent.get("entities", {})
session_id = entities.get("dataset_review_session_id")
session_version = entities.get("session_version")
if not session_id or session_version is None:
raise HTTPException(
status_code=422,
detail="Missing dataset_review_session_id/session_version",
)
operation = str(intent.get("operation") or "")
repository = DatasetReviewSessionRepository(db)
if operation == "dataset_review_answer_context":
summary = str(entities.get("summary") or "")
logger.reflect(
"Returned assistant-safe dataset review context summary",
extra={"session_id": session_id, "operation": operation},
)
return summary, None, []
session = repository.load_session_detail(session_id, current_user.id)
if session is None or session.user_id != current_user.id:
logger.explore(
"Assistant dataset-review intent rejected because session was not found",
extra={"session_id": session_id, "user_id": current_user.id},
)
raise HTTPException(
status_code=404, detail="Dataset review session not found"
)
try:
repository.require_session_version(session, int(session_version))
except DatasetReviewSessionVersionConflictError as exc:
logger.explore(
"Assistant dataset-review intent rejected due to stale session version",
extra={
"session_id": exc.session_id,
"expected_version": exc.expected_version,
"actual_version": exc.actual_version,
"operation": operation,
},
)
raise _dataset_review_conflict_http_exception(exc) from exc
logger.reason(
"Dispatching confirmed assistant dataset-review intent",
extra={
"session_id": session_id,
"session_version": session_version,
"operation": operation,
},
)
if operation == "dataset_review_approve_mappings":
mapping_ids = list(dict.fromkeys(entities.get("mapping_ids") or []))
if not mapping_ids:
raise HTTPException(
status_code=409, detail="No pending mappings to approve"
)
updated_count = 0
for mapping in session.execution_mappings:
if mapping.mapping_id not in mapping_ids:
continue
mapping.approval_state = ApprovalState.APPROVED
mapping.approved_by_user_id = current_user.id
mapping.approved_at = datetime.utcnow()
updated_count += 1
if updated_count == 0:
raise HTTPException(
status_code=409, detail="No matching mappings available to approve"
)
session.last_activity_at = datetime.utcnow()
if session.readiness_state == ReadinessState.MAPPING_REVIEW_NEEDED:
session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW
repository.bump_session_version(session)
repository.db.commit()
repository.db.refresh(session)
repository.event_logger.log_for_session(
session,
actor_user_id=current_user.id,
event_type="assistant_mapping_approval",
event_summary="Assistant-approved warning-sensitive mappings persisted",
event_details={
"mapping_ids": mapping_ids,
"count": updated_count,
"version": int(getattr(session, "version", 0) or 0),
},
)
logger.reflect(
"Assistant mapping approval persisted within optimistic-lock boundary",
extra={
"session_id": session_id,
"updated_count": updated_count,
"version": int(getattr(session, "version", 0) or 0),
},
)
return (
f"Approved {updated_count} mapping(s) for dataset review session {session_id}.",
None,
[
AssistantAction(
type="focus_target",
label="Open mapping review",
target="mapping",
)
],
)
if operation == "dataset_review_set_field_semantics":
field_id = str(entities.get("field_id") or "").strip()
if not field_id:
raise HTTPException(status_code=422, detail="Missing field_id")
field = next(
(item for item in session.semantic_fields if item.field_id == field_id),
None,
)
if field is None:
raise HTTPException(status_code=404, detail="Semantic field not found")
update_request = FieldSemanticUpdateRequest(
candidate_id=entities.get("candidate_id"),
verbose_name=entities.get("verbose_name"),
description=entities.get("description"),
display_format=entities.get("display_format"),
lock_field=bool(entities.get("lock_field", False)),
)
try:
_update_semantic_field_state(
field, update_request, changed_by="assistant"
)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
session.last_activity_at = datetime.utcnow()
repository.bump_session_version(session)
repository.db.commit()
repository.db.refresh(session)
repository.db.refresh(field)
repository.event_logger.log_for_session(
session,
actor_user_id=current_user.id,
event_type="assistant_field_semantics_updated",
event_summary="Assistant semantic field update persisted",
event_details={
"field_id": field.field_id,
"candidate_id": entities.get("candidate_id"),
"lock_field": bool(entities.get("lock_field", False)),
"version": int(getattr(session, "version", 0) or 0),
},
)
logger.reflect(
"Assistant semantic field update committed safely",
extra={
"session_id": session_id,
"field_id": field_id,
"version": int(getattr(session, "version", 0) or 0),
},
)
return (
f"Updated semantic field {field.field_name} for dataset review session {session_id}.",
None,
[
AssistantAction(
type="focus_target",
label="Open semantic review",
target=f"field:{field.field_id}",
)
],
)
if operation == "dataset_review_generate_sql_preview":
orchestrator = DatasetReviewOrchestrator(
repository=repository,
config_manager=config_manager,
)
result = orchestrator.prepare_launch_preview(
PreparePreviewCommand(
user=current_user,
session_id=session_id,
expected_version=int(session_version),
)
)
preview_status = getattr(
result.preview.preview_status, "value", result.preview.preview_status
)
logger.reflect(
"Assistant-triggered Superset preview generation completed",
extra={
"session_id": session_id,
"preview_status": preview_status,
},
)
return (
f"SQL preview {preview_status} for dataset review session {session_id}.",
None,
[
AssistantAction(
type="focus_target",
label="Open SQL preview",
target="sql-preview",
)
],
)
raise HTTPException(
status_code=400, detail="Unsupported dataset review operation"
)
# [/DEF:_dispatch_dataset_review_intent:Function]
# [/DEF:AssistantDatasetReview:Module]

View File

@@ -0,0 +1,290 @@
# [DEF:AssistantDatasetReviewDispatch:Module]
# @COMPLEXITY: 4
# @PURPOSE: Dispatch and confirmation handling for dataset-review assistant intents.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [AssistantDatasetReview]
# @RELATION: DEPENDS_ON -> [DatasetReviewOrchestrator]
# @RELATION: DEPENDS_ON -> [AssistantSchemas]
# @INVARIANT: Dataset review dispatch requires valid session version for write operations.
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from src.core.logger import belief_scope, logger
from src.core.config_manager import ConfigManager
from src.models.dataset_review import (
ApprovalState,
ReadinessState,
RecommendedAction,
)
from src.services.dataset_review.orchestrator import (
DatasetReviewOrchestrator,
PreparePreviewCommand,
)
from src.services.dataset_review.repositories.session_repository import (
DatasetReviewSessionRepository,
DatasetReviewSessionVersionConflictError,
)
from src.schemas.auth import User
from src.api.routes.dataset_review import FieldSemanticUpdateRequest, _update_semantic_field_state
from ._schemas import (
AssistantAction,
)
# [DEF:_dataset_review_conflict_http_exception:Function]
# @COMPLEXITY: 2
# @PURPOSE: Convert dataset-review optimistic-lock conflicts into shared 409 assistant semantics.
def _dataset_review_conflict_http_exception(
exc: DatasetReviewSessionVersionConflictError,
) -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error_code": "session_version_conflict",
"message": str(exc),
"session_id": exc.session_id,
"expected_version": exc.expected_version,
"actual_version": exc.actual_version,
},
)
# [/DEF:_dataset_review_conflict_http_exception:Function]
# [DEF:_dispatch_dataset_review_intent:Function]
# @COMPLEXITY: 4
# @PURPOSE: Route confirmed dataset-review assistant intents through existing backend dataset-review APIs and orchestration boundaries.
# @RELATION: CALLS -> DatasetReviewOrchestrator
# @PRE: context contains valid session data and user intent.
# @POST: Returns a structured response with planned actions and confirmations.
# @SIDE_EFFECT: May update session state and enqueue tasks.
async def _dispatch_dataset_review_intent(
intent: Dict[str, Any],
current_user: User,
config_manager: ConfigManager,
db: Session,
) -> Tuple[str, Optional[str], List[AssistantAction]]:
with belief_scope("_dispatch_dataset_review_intent"):
logger.reason(
"Dispatching assistant dataset-review intent",
extra={"operation": intent.get("operation")},
)
entities = intent.get("entities", {})
session_id = entities.get("dataset_review_session_id")
session_version = entities.get("session_version")
if not session_id or session_version is None:
raise HTTPException(
status_code=422,
detail="Missing dataset_review_session_id/session_version",
)
operation = str(intent.get("operation") or "")
repository = DatasetReviewSessionRepository(db)
if operation == "dataset_review_answer_context":
summary = str(entities.get("summary") or "")
logger.reflect(
"Returned assistant-safe dataset review context summary",
extra={"session_id": session_id, "operation": operation},
)
return summary, None, []
session = repository.load_session_detail(session_id, current_user.id)
if session is None or session.user_id != current_user.id:
logger.explore(
"Assistant dataset-review intent rejected because session was not found",
extra={"session_id": session_id, "user_id": current_user.id},
)
raise HTTPException(
status_code=404, detail="Dataset review session not found"
)
try:
repository.require_session_version(session, int(session_version))
except DatasetReviewSessionVersionConflictError as exc:
logger.explore(
"Assistant dataset-review intent rejected due to stale session version",
extra={
"session_id": exc.session_id,
"expected_version": exc.expected_version,
"actual_version": exc.actual_version,
"operation": operation,
},
)
raise _dataset_review_conflict_http_exception(exc) from exc
logger.reason(
"Dispatching confirmed assistant dataset-review intent",
extra={
"session_id": session_id,
"session_version": session_version,
"operation": operation,
},
)
if operation == "dataset_review_approve_mappings":
mapping_ids = list(dict.fromkeys(entities.get("mapping_ids") or []))
if not mapping_ids:
raise HTTPException(
status_code=409, detail="No pending mappings to approve"
)
updated_count = 0
for mapping in session.execution_mappings:
if mapping.mapping_id not in mapping_ids:
continue
mapping.approval_state = ApprovalState.APPROVED
mapping.approved_by_user_id = current_user.id
mapping.approved_at = datetime.utcnow()
updated_count += 1
if updated_count == 0:
raise HTTPException(
status_code=409, detail="No matching mappings available to approve"
)
session.last_activity_at = datetime.utcnow()
if session.readiness_state == ReadinessState.MAPPING_REVIEW_NEEDED:
session.recommended_action = RecommendedAction.GENERATE_SQL_PREVIEW
repository.bump_session_version(session)
repository.db.commit()
repository.db.refresh(session)
repository.event_logger.log_for_session(
session,
actor_user_id=current_user.id,
event_type="assistant_mapping_approval",
event_summary="Assistant-approved warning-sensitive mappings persisted",
event_details={
"mapping_ids": mapping_ids,
"count": updated_count,
"version": int(getattr(session, "version", 0) or 0),
},
)
logger.reflect(
"Assistant mapping approval persisted within optimistic-lock boundary",
extra={
"session_id": session_id,
"updated_count": updated_count,
"version": int(getattr(session, "version", 0) or 0),
},
)
return (
f"Approved {updated_count} mapping(s) for dataset review session {session_id}.",
None,
[
AssistantAction(
type="focus_target",
label="Open mapping review",
target="mapping",
)
],
)
if operation == "dataset_review_set_field_semantics":
field_id = str(entities.get("field_id") or "").strip()
if not field_id:
raise HTTPException(status_code=422, detail="Missing field_id")
field = next(
(item for item in session.semantic_fields if item.field_id == field_id),
None,
)
if field is None:
raise HTTPException(status_code=404, detail="Semantic field not found")
update_request = FieldSemanticUpdateRequest(
candidate_id=entities.get("candidate_id"),
verbose_name=entities.get("verbose_name"),
description=entities.get("description"),
display_format=entities.get("display_format"),
lock_field=bool(entities.get("lock_field", False)),
)
try:
_update_semantic_field_state(
field, update_request, changed_by="assistant"
)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
session.last_activity_at = datetime.utcnow()
repository.bump_session_version(session)
repository.db.commit()
repository.db.refresh(session)
repository.db.refresh(field)
repository.event_logger.log_for_session(
session,
actor_user_id=current_user.id,
event_type="assistant_field_semantics_updated",
event_summary="Assistant semantic field update persisted",
event_details={
"field_id": field.field_id,
"candidate_id": entities.get("candidate_id"),
"lock_field": bool(entities.get("lock_field", False)),
"version": int(getattr(session, "version", 0) or 0),
},
)
logger.reflect(
"Assistant semantic field update committed safely",
extra={
"session_id": session_id,
"field_id": field_id,
"version": int(getattr(session, "version", 0) or 0),
},
)
return (
f"Updated semantic field {field.field_name} for dataset review session {session_id}.",
None,
[
AssistantAction(
type="focus_target",
label="Open semantic review",
target=f"field:{field.field_id}",
)
],
)
if operation == "dataset_review_generate_sql_preview":
orchestrator = DatasetReviewOrchestrator(
repository=repository,
config_manager=config_manager,
)
result = orchestrator.prepare_launch_preview(
PreparePreviewCommand(
user=current_user,
session_id=session_id,
expected_version=int(session_version),
)
)
preview_status = getattr(
result.preview.preview_status, "value", result.preview.preview_status
)
logger.reflect(
"Assistant-triggered Superset preview generation completed",
extra={
"session_id": session_id,
"preview_status": preview_status,
},
)
return (
f"SQL preview {preview_status} for dataset review session {session_id}.",
None,
[
AssistantAction(
type="focus_target",
label="Open SQL preview",
target="sql-preview",
)
],
)
raise HTTPException(
status_code=400, detail="Unsupported dataset review operation"
)
# [/DEF:_dispatch_dataset_review_intent:Function]
# [/DEF:AssistantDatasetReviewDispatch:Module]

View File

@@ -36,7 +36,7 @@ from ._resolvers import (
)
from ._history import _coerce_query_bool
from ._llm_planner import _check_any_permission
from ._dataset_review import _dispatch_dataset_review_intent
from ._dataset_review_dispatch import _dispatch_dataset_review_intent
git_service = GitService()

View File

@@ -4,11 +4,11 @@
# @LAYER: API
# @RELATION: DEPENDS_ON -> [AssistantSchemas]
# @RELATION: DEPENDS_ON -> [AssistantResolvers]
# @RELATION: DISPATCHES -> [AssistantLlmPlannerIntent]
# @INVARIANT: Tool catalog is filtered by user permissions before being sent to LLM.
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
@@ -19,20 +19,13 @@ from src.core.config_manager import ConfigManager
from src.dependencies import has_permission
from src.schemas.auth import User
from src.services.llm_provider import LLMProviderService
from src.services.llm_prompt_templates import (
normalize_llm_settings,
resolve_bound_provider_id,
)
from src.plugins.llm_analysis.service import LLMClient
from src.plugins.llm_analysis.models import LLMProviderType
from src.services.llm_prompt_templates import resolve_bound_provider_id
from ._schemas import (
INTENT_PERMISSION_CHECKS,
AssistantAction,
)
from ._resolvers import (
_get_default_environment_id,
_is_production_env,
_resolve_provider_id,
)
@@ -302,140 +295,4 @@ def _coerce_intent_entities(intent: Dict[str, Any]) -> Dict[str, Any]:
# [/DEF:_coerce_intent_entities:Function]
# [DEF:_plan_intent_with_llm:Function]
# @COMPLEXITY: 2
# @PURPOSE: Use active LLM provider to select best tool/operation from dynamic catalog.
# @PRE: tools list contains allowed operations for current user.
# @POST: Returns normalized intent dict when planning succeeds; otherwise None.
async def _plan_intent_with_llm(
message: str,
tools: List[Dict[str, Any]],
db: Session,
config_manager: ConfigManager,
) -> Optional[Dict[str, Any]]:
if not tools:
return None
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
planner_provider_token = llm_settings.get("assistant_planner_provider")
planner_model_override = llm_settings.get("assistant_planner_model")
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
provider_id = _resolve_provider_id(planner_provider_token, db)
provider = next((p for p in providers if p.id == provider_id), None)
if not provider:
return None
api_key = llm_service.get_decrypted_api_key(provider.id)
if not api_key:
return None
planner = LLMClient(
provider_type=LLMProviderType(provider.provider_type),
api_key=api_key,
base_url=provider.base_url,
default_model=planner_model_override or provider.default_model,
)
system_instruction = (
"You are a deterministic intent planner for backend tools.\n"
"Choose exactly one operation from available_tools or return clarify.\n"
"Output strict JSON object:\n"
"{"
'"domain": string, '
'"operation": string, '
'"entities": object, '
'"confidence": number, '
'"risk_level": "safe"|"guarded"|"dangerous", '
'"requires_confirmation": boolean'
"}\n"
"Rules:\n"
"- Use only operation names from available_tools.\n"
'- If input is ambiguous, operation must be "clarify" with low confidence.\n'
"- If dashboard is provided as name/slug (e.g., COVID), put it into entities.dashboard_ref.\n"
"- Keep entities minimal and factual.\n"
)
payload = {
"available_tools": tools,
"user_message": message,
"known_environments": [
{"id": e.id, "name": e.name} for e in config_manager.get_environments()
],
}
try:
response = await planner.get_json_completion(
[
{"role": "system", "content": system_instruction},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
]
)
except Exception as exc:
import traceback
logger.warning(
f"[assistant.planner][fallback] LLM planner unavailable: {exc}\n{traceback.format_exc()}"
)
return None
if not isinstance(response, dict):
return None
operation = response.get("operation")
valid_ops = {tool["operation"] for tool in tools}
if operation == "clarify":
return {
"domain": "unknown",
"operation": "clarify",
"entities": {},
"confidence": float(response.get("confidence", 0.3)),
"risk_level": "safe",
"requires_confirmation": False,
}
if operation not in valid_ops:
return None
by_operation = {tool["operation"]: tool for tool in tools}
selected = by_operation[operation]
intent = {
"domain": response.get("domain") or selected["domain"],
"operation": operation,
"entities": response.get("entities", {}),
"confidence": float(response.get("confidence", 0.75)),
"risk_level": response.get("risk_level") or selected["risk_level"],
"requires_confirmation": bool(
response.get("requires_confirmation", selected["requires_confirmation"])
),
}
intent = _coerce_intent_entities(intent)
defaults = selected.get("defaults") or {}
for key, value in defaults.items():
if value and not intent["entities"].get(key):
intent["entities"][key] = value
if operation in {"deploy_dashboard", "execute_migration"}:
env_token = intent["entities"].get("environment") or intent["entities"].get(
"target_env"
)
if _is_production_env(env_token, config_manager):
intent["risk_level"] = "dangerous"
intent["requires_confirmation"] = True
return intent
# [/DEF:_plan_intent_with_llm:Function]
# [DEF:_authorize_intent:Function]
# @COMPLEXITY: 2
# @PURPOSE: Validate user permissions for parsed intent before confirmation/dispatch.
# @PRE: intent.operation is present for known assistant command domains.
# @POST: Returns if authorized; raises HTTPException(403) when denied.
def _authorize_intent(intent: Dict[str, Any], current_user: User):
operation = intent.get("operation")
if operation in INTENT_PERMISSION_CHECKS:
_check_any_permission(current_user, INTENT_PERMISSION_CHECKS[operation])
# [/DEF:_authorize_intent:Function]
# [/DEF:AssistantLlmPlanner:Module]

View File

@@ -0,0 +1,173 @@
# [DEF:AssistantLlmPlannerIntent:Module]
# @COMPLEXITY: 3
# @PURPOSE: LLM-based intent planning and authorization for the assistant API — separated from tool catalog.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [AssistantLlmPlanner]
# @RELATION: DEPENDS_ON -> [AssistantResolvers]
# @INVARIANT: Production deployments always require confirmation.
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from src.core.logger import belief_scope, logger
from src.core.config_manager import ConfigManager
from src.schemas.auth import User
from src.services.llm_provider import LLMProviderService
from src.services.llm_prompt_templates import (
normalize_llm_settings,
resolve_bound_provider_id,
)
from src.plugins.llm_analysis.service import LLMClient
from src.plugins.llm_analysis.models import LLMProviderType
from ._schemas import INTENT_PERMISSION_CHECKS
from ._resolvers import (
_is_production_env,
_resolve_provider_id,
)
from ._llm_planner import (
_check_any_permission,
_coerce_intent_entities,
)
# [DEF:_plan_intent_with_llm:Function]
# @COMPLEXITY: 2
# @PURPOSE: Use active LLM provider to select best tool/operation from dynamic catalog.
# @PRE: tools list contains allowed operations for current user.
# @POST: Returns normalized intent dict when planning succeeds; otherwise None.
async def _plan_intent_with_llm(
message: str,
tools: List[Dict[str, Any]],
db: Session,
config_manager: ConfigManager,
) -> Optional[Dict[str, Any]]:
if not tools:
return None
llm_settings = normalize_llm_settings(config_manager.get_config().settings.llm)
planner_provider_token = llm_settings.get("assistant_planner_provider")
planner_model_override = llm_settings.get("assistant_planner_model")
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
provider_id = _resolve_provider_id(planner_provider_token, db)
provider = next((p for p in providers if p.id == provider_id), None)
if not provider:
return None
api_key = llm_service.get_decrypted_api_key(provider.id)
if not api_key:
return None
planner = LLMClient(
provider_type=LLMProviderType(provider.provider_type),
api_key=api_key,
base_url=provider.base_url,
default_model=planner_model_override or provider.default_model,
)
system_instruction = (
"You are a deterministic intent planner for backend tools.\n"
"Choose exactly one operation from available_tools or return clarify.\n"
"Output strict JSON object:\n"
"{"
'"domain": string, '
'"operation": string, '
'"entities": object, '
'"confidence": number, '
'"risk_level": "safe"|"guarded"|"dangerous", '
'"requires_confirmation": boolean'
"}\n"
"Rules:\n"
"- Use only operation names from available_tools.\n"
'- If input is ambiguous, operation must be "clarify" with low confidence.\n'
"- If dashboard is provided as name/slug (e.g., COVID), put it into entities.dashboard_ref.\n"
"- Keep entities minimal and factual.\n"
)
payload = {
"available_tools": tools,
"user_message": message,
"known_environments": [
{"id": e.id, "name": e.name} for e in config_manager.get_environments()
],
}
try:
response = await planner.get_json_completion(
[
{"role": "system", "content": system_instruction},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
]
)
except Exception as exc:
import traceback
logger.warning(
f"[assistant.planner][fallback] LLM planner unavailable: {exc}\n{traceback.format_exc()}"
)
return None
if not isinstance(response, dict):
return None
operation = response.get("operation")
valid_ops = {tool["operation"] for tool in tools}
if operation == "clarify":
return {
"domain": "unknown",
"operation": "clarify",
"entities": {},
"confidence": float(response.get("confidence", 0.3)),
"risk_level": "safe",
"requires_confirmation": False,
}
if operation not in valid_ops:
return None
by_operation = {tool["operation"]: tool for tool in tools}
selected = by_operation[operation]
intent = {
"domain": response.get("domain") or selected["domain"],
"operation": operation,
"entities": response.get("entities", {}),
"confidence": float(response.get("confidence", 0.75)),
"risk_level": response.get("risk_level") or selected["risk_level"],
"requires_confirmation": bool(
response.get("requires_confirmation", selected["requires_confirmation"])
),
}
intent = _coerce_intent_entities(intent)
defaults = selected.get("defaults") or {}
for key, value in defaults.items():
if value and not intent["entities"].get(key):
intent["entities"][key] = value
if operation in {"deploy_dashboard", "execute_migration"}:
env_token = intent["entities"].get("environment") or intent["entities"].get(
"target_env"
)
if _is_production_env(env_token, config_manager):
intent["risk_level"] = "dangerous"
intent["requires_confirmation"] = True
return intent
# [/DEF:_plan_intent_with_llm:Function]
# [DEF:_authorize_intent:Function]
# @COMPLEXITY: 2
# @PURPOSE: Validate user permissions for parsed intent before confirmation/dispatch.
# @PRE: intent.operation is present for known assistant command domains.
# @POST: Returns if authorized; raises HTTPException(403) when denied.
def _authorize_intent(intent: Dict[str, Any], current_user: User):
operation = intent.get("operation")
if operation in INTENT_PERMISSION_CHECKS:
_check_any_permission(current_user, INTENT_PERMISSION_CHECKS[operation])
# [/DEF:_authorize_intent:Function]
# [/DEF:AssistantLlmPlannerIntent:Module]

View File

@@ -38,7 +38,6 @@ def _extract_id(text: str, patterns: List[str]) -> Optional[str]:
# [/DEF:_extract_id:Function]
# [DEF:_resolve_env_id:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve environment identifier/name token to canonical environment id.
@@ -60,7 +59,6 @@ def _resolve_env_id(
# [/DEF:_resolve_env_id:Function]
# [DEF:_is_production_env:Function]
# @COMPLEXITY: 2
# @PURPOSE: Determine whether environment token resolves to production-like target.
@@ -80,7 +78,6 @@ def _is_production_env(token: Optional[str], config_manager: ConfigManager) -> b
# [/DEF:_is_production_env:Function]
# [DEF:_resolve_provider_id:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve provider token to provider id with active/default fallback.
@@ -118,7 +115,6 @@ def _resolve_provider_id(
# [/DEF:_resolve_provider_id:Function]
# [DEF:_get_default_environment_id:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve default environment id from settings or first configured environment.
@@ -144,7 +140,6 @@ def _get_default_environment_id(config_manager: ConfigManager) -> Optional[str]:
# [/DEF:_get_default_environment_id:Function]
# [DEF:_resolve_dashboard_id_by_ref:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard id by title or slug reference in selected environment.
@@ -198,7 +193,6 @@ def _resolve_dashboard_id_by_ref(
# [/DEF:_resolve_dashboard_id_by_ref:Function]
# [DEF:_resolve_dashboard_id_entity:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard id from intent entities using numeric id or dashboard_ref fallback.
@@ -241,7 +235,6 @@ def _resolve_dashboard_id_entity(
# [/DEF:_resolve_dashboard_id_entity:Function]
# [DEF:_get_environment_name_by_id:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve human-readable environment name by id.
@@ -260,7 +253,6 @@ def _get_environment_name_by_id(
# [/DEF:_get_environment_name_by_id:Function]
# [DEF:_extract_result_deep_links:Function]
# @COMPLEXITY: 2
# @PURPOSE: Build deep-link actions to verify task result from assistant chat.
@@ -335,7 +327,6 @@ def _extract_result_deep_links(
# [/DEF:_extract_result_deep_links:Function]
# [DEF:_build_task_observability_summary:Function]
# @COMPLEXITY: 2
# @PURPOSE: Build compact textual summary for completed tasks to reduce "black box" effect.
@@ -403,5 +394,4 @@ def _build_task_observability_summary(task: Any, config_manager: ConfigManager)
# [/DEF:_build_task_observability_summary:Function]
# [/DEF:AssistantResolvers:Module]

View File

@@ -8,6 +8,7 @@
# @RELATION: DEPENDS_ON -> [AssistantLlmPlanner]
# @RELATION: DEPENDS_ON -> [AssistantDatasetReview]
# @RELATION: DEPENDS_ON -> [AssistantDispatch]
# @RELATION: DISPATCHES -> [AssistantAdminRoutes]
# @INVARIANT: Risky operations are never executed without valid confirmation token.
from __future__ import annotations
@@ -60,11 +61,8 @@ from ._history import (
_update_confirmation_state,
)
from ._command_parser import _parse_command
from ._llm_planner import (
_authorize_intent,
_build_tool_catalog,
_plan_intent_with_llm,
)
from ._llm_planner import _build_tool_catalog
from ._llm_planner_intent import _authorize_intent, _plan_intent_with_llm
from ._dataset_review import (
_load_dataset_review_context,
_plan_dataset_review_intent,
@@ -337,265 +335,4 @@ async def cancel_operation(
# [/DEF:cancel_operation:Function]
# [DEF:list_conversations:Function]
# @COMPLEXITY: 2
# @PURPOSE: Return paginated conversation list for current user with archived flag and last message preview.
# @PRE: Authenticated user context and valid pagination params.
# @POST: Conversations are grouped by conversation_id sorted by latest activity descending.
# @RETURN: Dict with items, paging metadata, and archive segmentation counts.
@router.get("/conversations")
async def list_conversations(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
include_archived: bool = Query(False),
archived_only: bool = Query(False),
search: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
with belief_scope("assistant.conversations"):
user_id = current_user.id
include_archived = _coerce_query_bool(include_archived)
archived_only = _coerce_query_bool(archived_only)
_cleanup_history_ttl(db, user_id)
rows = (
db.query(AssistantMessageRecord)
.filter(AssistantMessageRecord.user_id == user_id)
.order_by(desc(AssistantMessageRecord.created_at))
.all()
)
summary: Dict[str, Dict[str, Any]] = {}
for row in rows:
conv_id = row.conversation_id
if not conv_id:
continue
created_at = row.created_at or datetime.utcnow()
if conv_id not in summary:
summary[conv_id] = {
"conversation_id": conv_id,
"title": "",
"updated_at": created_at,
"last_message": row.text,
"last_role": row.role,
"last_state": row.state,
"last_task_id": row.task_id,
"message_count": 0,
}
item = summary[conv_id]
item["message_count"] += 1
if row.role == "user" and row.text and not item["title"]:
item["title"] = row.text.strip()[:80]
items = []
search_term = search.lower().strip() if search else ""
archived_total = sum(
1
for c in summary.values()
if _is_conversation_archived(c.get("updated_at"))
)
active_total = len(summary) - archived_total
for conv in summary.values():
conv["archived"] = _is_conversation_archived(conv.get("updated_at"))
if not conv.get("title"):
conv["title"] = f"Conversation {conv['conversation_id'][:8]}"
if search_term:
haystack = (
f"{conv.get('title', '')} {conv.get('last_message', '')}".lower()
)
if search_term not in haystack:
continue
if archived_only and not conv["archived"]:
continue
if not archived_only and not include_archived and conv["archived"]:
continue
updated = conv.get("updated_at")
conv["updated_at"] = (
updated.isoformat() if isinstance(updated, datetime) else None
)
items.append(conv)
items.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
total = len(items)
start = (page - 1) * page_size
page_items = items[start : start + page_size]
return {
"items": page_items,
"total": total,
"page": page,
"page_size": page_size,
"has_next": start + page_size < total,
"active_total": active_total,
"archived_total": archived_total,
}
# [/DEF:list_conversations:Function]
# [DEF:delete_conversation:Function]
# @COMPLEXITY: 2
# @PURPOSE: Soft-delete or hard-delete a conversation and clear its in-memory trace.
# @PRE: conversation_id belongs to current_user.
# @POST: Conversation records are removed from DB and CONVERSATIONS cache.
@router.delete("/conversations/{conversation_id}")
async def delete_conversation(
conversation_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
with belief_scope("assistant.conversations.delete"):
user_id = current_user.id
# 1. Remove from in-memory cache
key = (user_id, conversation_id)
if key in CONVERSATIONS:
del CONVERSATIONS[key]
# 2. Delete from database
deleted_count = (
db.query(AssistantMessageRecord)
.filter(
AssistantMessageRecord.user_id == user_id,
AssistantMessageRecord.conversation_id == conversation_id,
)
.delete()
)
db.commit()
if deleted_count == 0:
raise HTTPException(
status_code=404, detail="Conversation not found or already deleted"
)
return {
"status": "success",
"deleted": deleted_count,
"conversation_id": conversation_id,
}
# [/DEF:delete_conversation:Function]
@router.get("/history")
# [DEF:get_history:Function]
# @PURPOSE: Retrieve paginated assistant conversation history for current user.
# @PRE: Authenticated user is available and page params are valid.
# @POST: Returns persistent messages and mirrored in-memory snapshot for diagnostics.
# @RETURN: Dict with items, paging metadata, and resolved conversation_id.
async def get_history(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
conversation_id: Optional[str] = Query(None),
from_latest: bool = Query(False),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
with belief_scope("assistant.history"):
user_id = current_user.id
_cleanup_history_ttl(db, user_id)
conv_id = _resolve_or_create_conversation(user_id, conversation_id, db)
base_query = db.query(AssistantMessageRecord).filter(
AssistantMessageRecord.user_id == user_id,
AssistantMessageRecord.conversation_id == conv_id,
)
total = base_query.count()
start = (page - 1) * page_size
if from_latest:
rows = (
base_query.order_by(desc(AssistantMessageRecord.created_at))
.offset(start)
.limit(page_size)
.all()
)
rows = list(reversed(rows))
else:
rows = (
base_query.order_by(AssistantMessageRecord.created_at.asc())
.offset(start)
.limit(page_size)
.all()
)
persistent_items = [
{
"message_id": row.id,
"conversation_id": row.conversation_id,
"role": row.role,
"text": row.text,
"state": row.state,
"task_id": row.task_id,
"confirmation_id": row.confirmation_id,
"created_at": row.created_at.isoformat() if row.created_at else None,
"metadata": row.payload,
}
for row in rows
]
memory_items = CONVERSATIONS.get((user_id, conv_id), [])
return {
"items": persistent_items,
"memory_items": memory_items,
"total": total,
"page": page,
"page_size": page_size,
"has_next": start + page_size < total,
"from_latest": from_latest,
"conversation_id": conv_id,
}
# [/DEF:get_history:Function]
@router.get("/audit")
# [DEF:get_assistant_audit:Function]
# @PURPOSE: Return assistant audit decisions for current user from persistent and in-memory stores.
# @PRE: User has tasks:READ permission.
# @POST: Audit payload is returned in reverse chronological order from DB.
# @RETURN: Dict with persistent and memory audit slices.
async def get_assistant_audit(
limit: int = Query(50, ge=1, le=500),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope("assistant.audit"):
memory_rows = ASSISTANT_AUDIT.get(current_user.id, [])
db_rows = (
db.query(AssistantAuditRecord)
.filter(AssistantAuditRecord.user_id == current_user.id)
.order_by(AssistantAuditRecord.created_at.desc())
.limit(limit)
.all()
)
persistent = [
{
"id": row.id,
"user_id": row.user_id,
"conversation_id": row.conversation_id,
"decision": row.decision,
"task_id": row.task_id,
"message": row.message,
"payload": row.payload,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
for row in db_rows
]
return {
"items": persistent,
"memory_items": memory_rows[-limit:],
"total": len(persistent),
"memory_total": len(memory_rows),
}
# [/DEF:get_assistant_audit:Function]
# [/DEF:AssistantRoutes:Module]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
# [DEF:DashboardsApi:Module]
#
# @COMPLEXITY: 5
# @SEMANTICS: api, dashboards, resources, hub
# @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status
# @LAYER: API
# @RELATION: DEPENDS_ON ->[AppDependencies]
# @RELATION: DEPENDS_ON ->[ResourceService]
# @RELATION: DEPENDS_ON ->[SupersetClient]
#
# @INVARIANT: All dashboard responses include git_status and last_task metadata
#
# @PRE: Valid environment configurations exist in ConfigManager.
# @POST: Dashboard responses are projected into DashboardsResponse DTO.
# @SIDE_EFFECT: Performs external calls to Superset API and potentially Git providers.
# @DATA_CONTRACT: Input(env_id, filters) -> Output(DashboardsResponse)
#
# @TEST_CONTRACT: DashboardsAPI -> {
# required_fields: {env_id: string, page: integer, page_size: integer},
# optional_fields: {search: string},
# invariants: ["Pagination must be valid", "Environment must exist"]
# }
#
# @TEST_FIXTURE: dashboard_list_happy -> {
# "env_id": "prod",
# "expected_count": 1,
# "dashboards": [{"id": 1, "title": "Main Revenue"}]
# }
#
# @TEST_EDGE: pagination_zero_page -> {"env_id": "prod", "page": 0, "status": 400}
# @TEST_EDGE: pagination_oversize -> {"env_id": "prod", "page_size": 101, "status": 400}
# @TEST_EDGE: missing_env -> {"env_id": "ghost", "status": 404}
# @TEST_EDGE: empty_dashboards -> {"env_id": "empty_env", "expected_total": 0}
# @TEST_EDGE: external_superset_failure -> {"env_id": "bad_conn", "status": 503}
#
# @TEST_INVARIANT: metadata_consistency -> verifies: [dashboard_list_happy, empty_dashboards]
from ._router import router
from ._schemas import *
from ._listing_routes import *
from ._detail_routes import *
from ._action_routes import *
# [/DEF:DashboardsApi:Module]

View File

@@ -0,0 +1,176 @@
# [DEF:DashboardActionRoutes:Module]
# @COMPLEXITY: 2
# @SEMANTICS: api, dashboards, actions, routes
# @PURPOSE: Dashboard action route handlers — migrate, backup.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [DashboardRouter]
# @RELATION: DEPENDS_ON -> [DashboardSchemas]
# [SECTION: IMPORTS]
from fastapi import Depends, HTTPException
from src.dependencies import (
get_config_manager,
get_task_manager,
has_permission,
)
from src.core.logger import logger, belief_scope
from ._schemas import (
MigrateRequest,
BackupRequest,
TaskResponse,
)
from ._router import router
# [/SECTION]
# [DEF:migrate_dashboards:Function]
# @COMPLEXITY: 2
# @PURPOSE: Trigger bulk migration of dashboards from source to target environment
# @PRE: User has permission plugin:migration:execute
# @PRE: source_env_id and target_env_id are valid environment IDs
# @PRE: dashboard_ids is a non-empty list
# @POST: Returns task_id for tracking migration progress
# @POST: Task is created and queued for execution
# @PARAM: request (MigrateRequest) - Migration request with source, target, and dashboard IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES ->[MigrationPlugin:execute]
# @RELATION: CALLS ->[TaskManager]
@router.post("/migrate", response_model=TaskResponse)
async def migrate_dashboards(
request: MigrateRequest,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_=Depends(has_permission("plugin:migration", "EXECUTE")),
):
with belief_scope(
"migrate_dashboards",
f"source={request.source_env_id}, target={request.target_env_id}, count={len(request.dashboard_ids)}",
):
if not request.dashboard_ids:
logger.error(
"[migrate_dashboards][Coherence:Failed] No dashboard IDs provided"
)
raise HTTPException(
status_code=400, detail="At least one dashboard ID must be provided"
)
environments = config_manager.get_environments()
source_env = next(
(e for e in environments if e.id == request.source_env_id), None
)
target_env = next(
(e for e in environments if e.id == request.target_env_id), None
)
if not source_env:
logger.error(
f"[migrate_dashboards][Coherence:Failed] Source environment not found: {request.source_env_id}"
)
raise HTTPException(status_code=404, detail="Source environment not found")
if not target_env:
logger.error(
f"[migrate_dashboards][Coherence:Failed] Target environment not found: {request.target_env_id}"
)
raise HTTPException(status_code=404, detail="Target environment not found")
try:
task_params = {
"source_env_id": request.source_env_id,
"target_env_id": request.target_env_id,
"selected_ids": request.dashboard_ids,
"replace_db_config": request.replace_db_config,
"db_mappings": request.db_mappings or {},
}
task_obj = await task_manager.create_task(
plugin_id="superset-migration", params=task_params
)
logger.info(
f"[migrate_dashboards][Coherence:OK] Migration task created: {task_obj.id} for {len(request.dashboard_ids)} dashboards"
)
return TaskResponse(task_id=str(task_obj.id))
except Exception as e:
logger.error(
f"[migrate_dashboards][Coherence:Failed] Failed to create migration task: {e}"
)
raise HTTPException(
status_code=503, detail=f"Failed to create migration task: {str(e)}"
)
# [/DEF:migrate_dashboards:Function]
# [DEF:backup_dashboards:Function]
# @COMPLEXITY: 2
# @PURPOSE: Trigger bulk backup of dashboards with optional cron schedule
# @PRE: User has permission plugin:backup:execute
# @PRE: env_id is a valid environment ID
# @PRE: dashboard_ids is a non-empty list
# @POST: Returns task_id for tracking backup progress
# @POST: Task is created and queued for execution
# @POST: If schedule is provided, a scheduled task is created
# @PARAM: request (BackupRequest) - Backup request with environment and dashboard IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES ->[BackupPlugin:execute]
# @RELATION: CALLS ->[TaskManager]
@router.post("/backup", response_model=TaskResponse)
async def backup_dashboards(
request: BackupRequest,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_=Depends(has_permission("plugin:backup", "EXECUTE")),
):
with belief_scope(
"backup_dashboards",
f"env={request.env_id}, count={len(request.dashboard_ids)}, schedule={request.schedule}",
):
if not request.dashboard_ids:
logger.error(
"[backup_dashboards][Coherence:Failed] No dashboard IDs provided"
)
raise HTTPException(
status_code=400, detail="At least one dashboard ID must be provided"
)
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == request.env_id), None)
if not env:
logger.error(
f"[backup_dashboards][Coherence:Failed] Environment not found: {request.env_id}"
)
raise HTTPException(status_code=404, detail="Environment not found")
try:
task_params = {
"env": request.env_id,
"dashboards": request.dashboard_ids,
"schedule": request.schedule,
}
task_obj = await task_manager.create_task(
plugin_id="superset-backup", params=task_params
)
logger.info(
f"[backup_dashboards][Coherence:OK] Backup task created: {task_obj.id} for {len(request.dashboard_ids)} dashboards"
)
return TaskResponse(task_id=str(task_obj.id))
except Exception as e:
logger.error(
f"[backup_dashboards][Coherence:Failed] Failed to create backup task: {e}"
)
raise HTTPException(
status_code=503, detail=f"Failed to create backup task: {str(e)}"
)
# [/DEF:backup_dashboards:Function]
# [/DEF:DashboardActionRoutes:Module]

View File

@@ -0,0 +1,392 @@
# [DEF:DashboardDetailRoutes:Module]
# @COMPLEXITY: 3
# @SEMANTICS: api, dashboards, detail, routes
# @PURPOSE: Dashboard detail, db-mappings, task history, thumbnail route handlers.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [DashboardRouter]
# @RELATION: DEPENDS_ON -> [DashboardSchemas]
# @RELATION: DEPENDS_ON -> [DashboardHelpers]
# @RELATION: DEPENDS_ON -> [DashboardProjection]
# [SECTION: IMPORTS]
from typing import Optional, List, Dict, Any
import re
from urllib.parse import urlparse
from fastapi import Depends, HTTPException, Query, Response
from fastapi.responses import JSONResponse
from src.dependencies import (
get_config_manager,
get_task_manager,
get_mapping_service,
has_permission,
)
from src.core.async_superset_client import AsyncSupersetClient
from src.core.superset_client import SupersetClient
from src.core.logger import logger, belief_scope
from src.core.utils.network import DashboardNotFoundError
from ._helpers import (
_resolve_dashboard_id_from_ref,
_resolve_dashboard_id_from_ref_async,
)
from ._projection import (
_task_matches_dashboard,
)
from ._schemas import (
DashboardDetailResponse,
DashboardTaskHistoryItem,
DashboardTaskHistoryResponse,
DatabaseMapping,
DatabaseMappingsResponse,
)
from ._router import router
# [/SECTION]
# [DEF:get_database_mappings:Function]
# @COMPLEXITY: 2
# @PURPOSE: Get database mapping suggestions between source and target environments
# @PRE: User has permission plugin:migration:read
# @PRE: source_env_id and target_env_id are valid environment IDs
# @POST: Returns list of suggested database mappings with confidence scores
# @PARAM: source_env_id (str) - Source environment ID
# @PARAM: target_env_id (str) - Target environment ID
# @RETURN: DatabaseMappingsResponse - List of suggested mappings
# @RELATION: CALLS ->[MappingService:get_suggestions]
@router.get("/db-mappings", response_model=DatabaseMappingsResponse)
async def get_database_mappings(
source_env_id: str,
target_env_id: str,
config_manager=Depends(get_config_manager),
mapping_service=Depends(get_mapping_service),
_=Depends(has_permission("plugin:migration", "READ")),
):
with belief_scope(
"get_database_mappings", f"source={source_env_id}, target={target_env_id}"
):
environments = config_manager.get_environments()
source_env = next((e for e in environments if e.id == source_env_id), None)
target_env = next((e for e in environments if e.id == target_env_id), None)
if not source_env:
logger.error(
f"[get_database_mappings][Coherence:Failed] Source environment not found: {source_env_id}"
)
raise HTTPException(status_code=404, detail="Source environment not found")
if not target_env:
logger.error(
f"[get_database_mappings][Coherence:Failed] Target environment not found: {target_env_id}"
)
raise HTTPException(status_code=404, detail="Target environment not found")
try:
suggestions = await mapping_service.get_suggestions(
source_env_id, target_env_id
)
mappings = [
DatabaseMapping(
source_db=s.get("source_db", ""),
target_db=s.get("target_db", ""),
source_db_uuid=s.get("source_db_uuid"),
target_db_uuid=s.get("target_db_uuid"),
confidence=s.get("confidence", 0.0),
)
for s in suggestions
]
logger.info(
f"[get_database_mappings][Coherence:OK] Returning {len(mappings)} database mapping suggestions"
)
return DatabaseMappingsResponse(mappings=mappings)
except Exception as e:
logger.error(
f"[get_database_mappings][Coherence:Failed] Failed to get database mappings: {e}"
)
raise HTTPException(
status_code=503, detail=f"Failed to get database mappings: {str(e)}"
)
# [/DEF:get_database_mappings:Function]
# [DEF:get_dashboard_detail:Function]
# @COMPLEXITY: 2
# @PURPOSE: Fetch detailed dashboard info with related charts and datasets
# @PRE: env_id must be valid and dashboard ref (slug or id) must exist
# @POST: Returns dashboard detail payload for overview page
# @RELATION: CALLS ->[AsyncSupersetClient]
@router.get("/{dashboard_ref}", response_model=DashboardDetailResponse)
async def get_dashboard_detail(
dashboard_ref: str,
env_id: str,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:migration", "READ")),
):
with belief_scope(
"get_dashboard_detail", f"dashboard_ref={dashboard_ref}, env_id={env_id}"
):
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(
f"[get_dashboard_detail][Coherence:Failed] Environment not found: {env_id}"
)
raise HTTPException(status_code=404, detail="Environment not found")
try:
sync_client = SupersetClient(env)
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, sync_client)
detail = sync_client.get_dashboard_detail(dashboard_id)
logger.info(
f"[get_dashboard_detail][Coherence:OK] Dashboard ref={dashboard_ref} resolved_id={dashboard_id}: {detail.get('chart_count', 0)} charts, {detail.get('dataset_count', 0)} datasets"
)
return DashboardDetailResponse(**detail)
except HTTPException:
raise
except Exception as e:
logger.error(
f"[get_dashboard_detail][Coherence:Failed] Failed to fetch dashboard detail: {e}"
)
raise HTTPException(
status_code=503, detail=f"Failed to fetch dashboard detail: {str(e)}"
)
# [/DEF:get_dashboard_detail:Function]
# [DEF:get_dashboard_tasks_history:Function]
# @COMPLEXITY: 2
# @PURPOSE: Returns history of backup and LLM validation tasks for a dashboard.
# @PRE: dashboard ref (slug or id) is valid.
# @POST: Response contains sorted task history (newest first).
@router.get("/{dashboard_ref}/tasks", response_model=DashboardTaskHistoryResponse)
async def get_dashboard_tasks_history(
dashboard_ref: str,
env_id: Optional[str] = None,
limit: int = Query(20, ge=1, le=100),
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope(
"get_dashboard_tasks_history",
f"dashboard_ref={dashboard_ref}, env_id={env_id}, limit={limit}",
):
dashboard_id: Optional[int] = None
client: Optional[AsyncSupersetClient] = None
try:
if dashboard_ref.isdigit():
dashboard_id = int(dashboard_ref)
elif env_id:
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(
f"[get_dashboard_tasks_history][Coherence:Failed] Environment not found: {env_id}"
)
raise HTTPException(status_code=404, detail="Environment not found")
client = AsyncSupersetClient(env)
dashboard_id = await _resolve_dashboard_id_from_ref_async(
dashboard_ref, client
)
else:
logger.error(
"[get_dashboard_tasks_history][Coherence:Failed] Non-numeric dashboard ref requires env_id"
)
raise HTTPException(
status_code=400,
detail="env_id is required when dashboard reference is a slug",
)
matching_tasks = []
for task in task_manager.get_all_tasks():
if _task_matches_dashboard(task, dashboard_id, env_id):
matching_tasks.append(task)
def _sort_key(task_obj: Any) -> str:
return str(getattr(task_obj, "started_at", "") or "") or str(
getattr(task_obj, "finished_at", "") or ""
)
matching_tasks.sort(key=_sort_key, reverse=True)
selected = matching_tasks[:limit]
items = []
for task in selected:
result = getattr(task, "result", None)
summary = None
validation_status = None
if isinstance(result, dict):
raw_validation_status = result.get("status")
if raw_validation_status is not None:
validation_status = str(raw_validation_status)
summary = (
result.get("summary")
or result.get("status")
or result.get("message")
)
params = getattr(task, "params", {}) or {}
items.append(
DashboardTaskHistoryItem(
id=str(getattr(task, "id", "")),
plugin_id=str(getattr(task, "plugin_id", "")),
status=str(getattr(task, "status", "")),
validation_status=validation_status,
started_at=getattr(task, "started_at", None).isoformat()
if getattr(task, "started_at", None)
else None,
finished_at=getattr(task, "finished_at", None).isoformat()
if getattr(task, "finished_at", None)
else None,
env_id=str(params.get("environment_id") or params.get("env"))
if (params.get("environment_id") or params.get("env"))
else None,
summary=summary,
)
)
logger.info(
f"[get_dashboard_tasks_history][Coherence:OK] Found {len(items)} tasks for dashboard_ref={dashboard_ref}, dashboard_id={dashboard_id}"
)
return DashboardTaskHistoryResponse(dashboard_id=dashboard_id, items=items)
finally:
if client is not None:
await client.aclose()
# [/DEF:get_dashboard_tasks_history:Function]
# [DEF:get_dashboard_thumbnail:Function]
# @COMPLEXITY: 3
# @PURPOSE: Proxies Superset dashboard thumbnail with cache support.
# @RELATION: CALLS ->[AsyncSupersetClient]
# @PRE: env_id must exist.
# @POST: Returns image bytes or 202 when thumbnail is being prepared by Superset.
@router.get("/{dashboard_ref}/thumbnail")
async def get_dashboard_thumbnail(
dashboard_ref: str,
env_id: str,
force: bool = Query(False),
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:migration", "READ")),
):
with belief_scope(
"get_dashboard_thumbnail",
f"dashboard_ref={dashboard_ref}, env_id={env_id}, force={force}",
):
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(
f"[get_dashboard_thumbnail][Coherence:Failed] Environment not found: {env_id}"
)
raise HTTPException(status_code=404, detail="Environment not found")
try:
client = SupersetClient(env)
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, client)
digest = None
thumb_endpoint = None
try:
screenshot_payload = client.network.request(
method="POST",
endpoint=f"/dashboard/{dashboard_id}/cache_dashboard_screenshot/",
json={"force": force},
)
payload = (
screenshot_payload.get("result", screenshot_payload)
if isinstance(screenshot_payload, dict)
else {}
)
image_url = (
payload.get("image_url", "") if isinstance(payload, dict) else ""
)
if isinstance(image_url, str) and image_url:
matched = re.search(
r"/dashboard/\d+/(?:thumbnail|screenshot)/([^/]+)/?$",
image_url,
)
if matched:
digest = matched.group(1)
except DashboardNotFoundError:
logger.warning(
"[get_dashboard_thumbnail][Fallback] cache_dashboard_screenshot endpoint unavailable, fallback to dashboard.thumbnail_url"
)
if not digest:
dashboard_payload = client.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id}",
)
dashboard_data = (
dashboard_payload.get("result", dashboard_payload)
if isinstance(dashboard_payload, dict)
else {}
)
thumbnail_url = (
dashboard_data.get("thumbnail_url", "")
if isinstance(dashboard_data, dict)
else ""
)
if isinstance(thumbnail_url, str) and thumbnail_url:
parsed = urlparse(thumbnail_url)
parsed_path = parsed.path or thumbnail_url
if parsed_path.startswith("/api/v1/"):
parsed_path = parsed_path[len("/api/v1") :]
thumb_endpoint = parsed_path
matched = re.search(
r"/dashboard/\d+/(?:thumbnail|screenshot)/([^/]+)/?$",
parsed_path,
)
if matched:
digest = matched.group(1)
if not thumb_endpoint:
thumb_endpoint = (
f"/dashboard/{dashboard_id}/thumbnail/{digest or 'latest'}/"
)
thumb_response = client.network.request(
method="GET",
endpoint=thumb_endpoint,
raw_response=True,
allow_redirects=True,
)
if thumb_response.status_code == 202:
payload_202: Dict[str, Any] = {}
try:
payload_202 = thumb_response.json()
except Exception:
payload_202 = {"message": "Thumbnail is being generated"}
return JSONResponse(status_code=202, content=payload_202)
content_type = thumb_response.headers.get("Content-Type", "image/png")
return Response(content=thumb_response.content, media_type=content_type)
except DashboardNotFoundError as e:
logger.error(
f"[get_dashboard_thumbnail][Coherence:Failed] Dashboard not found for thumbnail: {e}"
)
raise HTTPException(status_code=404, detail="Dashboard thumbnail not found")
except HTTPException:
raise
except Exception as e:
logger.error(
f"[get_dashboard_thumbnail][Coherence:Failed] Failed to fetch dashboard thumbnail: {e}"
)
raise HTTPException(
status_code=503,
detail=f"Failed to fetch dashboard thumbnail: {str(e)}",
)
# [/DEF:get_dashboard_thumbnail:Function]
# [/DEF:DashboardDetailRoutes:Module]

View File

@@ -0,0 +1,187 @@
# [DEF:DashboardHelpers:Module]
# @COMPLEXITY: 2
# @SEMANTICS: api, dashboards, helpers, resolution
# @PURPOSE: Basic helper functions for dashboard route handlers — slug resolution, filter normalization.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> [SupersetClient]
# @RELATION: DEPENDS_ON -> [AsyncSupersetClient]
from typing import List, Optional, Dict, Any
from fastapi import HTTPException
from src.core.superset_client import SupersetClient
from src.core.async_superset_client import AsyncSupersetClient
from src.core.logger import logger
# [DEF:_find_dashboard_id_by_slug:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard numeric ID by slug using Superset list endpoint.
# @PRE: `dashboard_slug` is non-empty.
# @POST: Returns dashboard ID when found, otherwise None.
def _find_dashboard_id_by_slug(
client: SupersetClient,
dashboard_slug: str,
) -> Optional[int]:
query_variants = [
{
"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
{
"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
]
for query in query_variants:
try:
_count, dashboards = client.get_dashboards_page(query=query)
if dashboards:
resolved_id = dashboards[0].get("id")
if resolved_id is not None:
return int(resolved_id)
except Exception:
continue
return None
# [/DEF:_find_dashboard_id_by_slug:Function]
# [DEF:_resolve_dashboard_id_from_ref:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard ID from slug-first reference with numeric fallback.
# @PRE: `dashboard_ref` is provided in route path.
# @POST: Returns a valid dashboard ID or raises HTTPException(404).
def _resolve_dashboard_id_from_ref(
dashboard_ref: str,
client: SupersetClient,
) -> int:
normalized_ref = str(dashboard_ref or "").strip()
if not normalized_ref:
raise HTTPException(status_code=404, detail="Dashboard not found")
# Slug-first: even if ref looks numeric, try slug first.
slug_match_id = _find_dashboard_id_by_slug(client, normalized_ref)
if slug_match_id is not None:
return slug_match_id
if normalized_ref.isdigit():
return int(normalized_ref)
raise HTTPException(status_code=404, detail="Dashboard not found")
# [/DEF:_resolve_dashboard_id_from_ref:Function]
# [DEF:_find_dashboard_id_by_slug_async:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard numeric ID by slug using async Superset list endpoint.
# @PRE: dashboard_slug is non-empty.
# @POST: Returns dashboard ID when found, otherwise None.
async def _find_dashboard_id_by_slug_async(
client: AsyncSupersetClient,
dashboard_slug: str,
) -> Optional[int]:
query_variants = [
{
"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
{
"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
]
for query in query_variants:
try:
_count, dashboards = await client.get_dashboards_page_async(query=query)
if dashboards:
resolved_id = dashboards[0].get("id")
if resolved_id is not None:
return int(resolved_id)
except Exception:
continue
return None
# [/DEF:_find_dashboard_id_by_slug_async:Function]
# [DEF:_resolve_dashboard_id_from_ref_async:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard ID from slug-first reference using async Superset client.
# @PRE: dashboard_ref is provided in route path.
# @POST: Returns valid dashboard ID or raises HTTPException(404).
async def _resolve_dashboard_id_from_ref_async(
dashboard_ref: str,
client: AsyncSupersetClient,
) -> int:
normalized_ref = str(dashboard_ref or "").strip()
if not normalized_ref:
raise HTTPException(status_code=404, detail="Dashboard not found")
slug_match_id = await _find_dashboard_id_by_slug_async(client, normalized_ref)
if slug_match_id is not None:
return slug_match_id
if normalized_ref.isdigit():
return int(normalized_ref)
raise HTTPException(status_code=404, detail="Dashboard not found")
# [/DEF:_resolve_dashboard_id_from_ref_async:Function]
# [DEF:_normalize_filter_values:Function]
# @COMPLEXITY: 2
# @PURPOSE: Normalize query filter values to lower-cased non-empty tokens.
# @PRE: values may be None or list of strings.
# @POST: Returns trimmed normalized list preserving input order.
def _normalize_filter_values(values: Optional[List[str]]) -> List[str]:
if not values:
return []
normalized: List[str] = []
for value in values:
token = str(value or "").strip().lower()
if token:
normalized.append(token)
return normalized
# [/DEF:_normalize_filter_values:Function]
# [DEF:_dashboard_git_filter_value:Function]
# @COMPLEXITY: 2
# @PURPOSE: Build comparable git status token for dashboards filtering.
# @PRE: dashboard payload may contain git_status or None.
# @POST: Returns one of ok|diff|no_repo|error|pending.
def _dashboard_git_filter_value(dashboard: Dict[str, Any]) -> str:
git_status = dashboard.get("git_status") or {}
sync_status = str(git_status.get("sync_status") or "").strip().upper()
has_repo = git_status.get("has_repo")
if has_repo is False or sync_status == "NO_REPO":
return "no_repo"
if sync_status == "DIFF":
return "diff"
if sync_status == "OK":
return "ok"
if sync_status == "ERROR":
return "error"
return "pending"
# [/DEF:_dashboard_git_filter_value:Function]
# [/DEF:DashboardHelpers:Module]

View File

@@ -0,0 +1,398 @@
# [DEF:DashboardListingRoutes:Module]
# @COMPLEXITY: 4
# @SEMANTICS: api, dashboards, listing, routes
# @PURPOSE: Dashboard listing route handler for Dashboard Hub.
# @LAYER: API
# @RELATION: DEPENDS_ON -> [DashboardRouter]
# @RELATION: DEPENDS_ON -> [DashboardSchemas]
# @RELATION: DEPENDS_ON -> [DashboardHelpers]
# @RELATION: DEPENDS_ON -> [DashboardProjection]
# [SECTION: IMPORTS]
import os
from typing import List, Optional, Dict, Any, Literal
from fastapi import Depends, HTTPException, Query
from sqlalchemy.orm import Session
from src.dependencies import (
get_config_manager, get_task_manager, get_resource_service,
get_current_user, has_permission,
)
from src.core.database import get_db
from src.core.logger import logger, belief_scope
from src.models.auth import User
from src.services.profile_service import ProfileService
from ._helpers import _normalize_filter_values, _dashboard_git_filter_value
from ._projection import (
_project_dashboard_response_items, _get_profile_filter_binding,
_resolve_profile_actor_aliases, _matches_dashboard_actor_aliases,
)
from ._schemas import EffectiveProfileFilter, DashboardsResponse
from ._router import router
# [/SECTION]
# [DEF:get_dashboards:Function]
# @COMPLEXITY: 3
# @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status
# @PRE: env_id must be a valid environment ID
# @PRE: page must be >= 1 if provided
# @PRE: page_size must be between 1 and 100 if provided
# @POST: Returns a list of dashboards with enhanced metadata and pagination info
# @POST: Response includes pagination metadata (page, page_size, total, total_pages)
# @POST: Response includes effective profile filter metadata for main dashboards page context
# @PARAM: env_id (str) - The environment ID to fetch dashboards from
# @PARAM: search (Optional[str]) - Filter by title/slug
# @PARAM: page (Optional[int]) - Page number (default: 1)
# @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100)
# @RETURN: DashboardsResponse - List of dashboards with status metadata
# @RELATION: CALLS ->[get_dashboards_with_status]
@router.get("", response_model=DashboardsResponse)
async def get_dashboards(
env_id: str,
search: Optional[str] = None,
page: int = 1,
page_size: int = 10,
page_context: Literal["dashboards_main", "other"] = Query("dashboards_main"),
apply_profile_default: bool = Query(default=True),
override_show_all: bool = Query(default=False),
filter_title: Optional[List[str]] = Query(default=None),
filter_git_status: Optional[List[str]] = Query(default=None),
filter_llm_status: Optional[List[str]] = Query(default=None),
filter_changed_on: Optional[List[str]] = Query(default=None),
filter_actor: Optional[List[str]] = Query(default=None),
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
resource_service=Depends(get_resource_service),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
_=Depends(has_permission("plugin:migration", "READ")),
):
with belief_scope(
"get_dashboards",
f"env_id={env_id}, search={search}, page={page}, page_size={page_size}, "
f"page_context={page_context}, applyp={apply_profile_default}, overhide={override_show_all}",
):
if page < 1:
logger.error(f"[get_dashboards][Coherence:Failed] Invalid page: {page}")
raise HTTPException(status_code=400, detail="Page must be >= 1")
if page_size < 1 or page_size > 100:
logger.error(f"[get_dashboards][Coherence:Failed] Invalid page_size: {page_size}")
raise HTTPException(status_code=400, detail="Page size must be between 1 and 100")
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dashboards][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
bound_username: Optional[str] = None
can_apply_profile_filter = False
can_apply_slug_filter = False
effective_profile_filter = EffectiveProfileFilter(
applied=False,
source_page=page_context,
override_show_all=bool(override_show_all),
username=None,
match_logic=None,
)
profile_service: Optional[ProfileService] = None
try:
profile_service_module = getattr(ProfileService, "__module__", "")
is_mock_db = db.__class__.__module__.startswith("unittest.mock")
use_profile_service = (not is_mock_db) or profile_service_module.startswith(
"unittest.mock"
)
if use_profile_service:
profile_service = ProfileService(db=db, config_manager=config_manager)
profile_preference = _get_profile_filter_binding(
profile_service, current_user
)
normalized_username = (
str(profile_preference.get("superset_username_normalized") or "")
.strip()
.lower()
)
raw_username = (
str(profile_preference.get("superset_username") or "")
.strip()
.lower()
)
bound_username = normalized_username or raw_username or None
can_apply_profile_filter = (
page_context == "dashboards_main"
and bool(apply_profile_default)
and not bool(override_show_all)
and bool(profile_preference.get("show_only_my_dashboards", False))
and bool(bound_username)
)
can_apply_slug_filter = (
page_context == "dashboards_main"
and bool(apply_profile_default)
and not bool(override_show_all)
and bool(profile_preference.get("show_only_slug_dashboards", True))
)
profile_match_logic = None
if can_apply_profile_filter and can_apply_slug_filter:
profile_match_logic = "owners_or_modified_by+slug_only"
elif can_apply_profile_filter:
profile_match_logic = "owners_or_modified_by"
elif can_apply_slug_filter:
profile_match_logic = "slug_only"
effective_profile_filter = EffectiveProfileFilter(
applied=bool(can_apply_profile_filter or can_apply_slug_filter),
source_page=page_context,
override_show_all=bool(override_show_all),
username=bound_username if can_apply_profile_filter else None,
match_logic=profile_match_logic,
)
except Exception as profile_error:
logger.explore(
f"[EXPLORE] Profile preference unavailable; continuing without profile-default filter: {profile_error}"
)
try:
all_tasks = task_manager.get_all_tasks()
title_filters = _normalize_filter_values(filter_title)
git_filters = _normalize_filter_values(filter_git_status)
llm_filters = _normalize_filter_values(filter_llm_status)
changed_on_filters = _normalize_filter_values(filter_changed_on)
actor_filters = _normalize_filter_values(filter_actor)
has_column_filters = any(
(
title_filters,
git_filters,
llm_filters,
changed_on_filters,
actor_filters,
)
)
needs_full_scan = (
has_column_filters
or bool(can_apply_profile_filter)
or bool(can_apply_slug_filter)
)
if not needs_full_scan:
try:
page_payload = (
await resource_service.get_dashboards_page_with_status(
env,
all_tasks,
page=page,
page_size=page_size,
search=search,
include_git_status=False,
require_slug=bool(can_apply_slug_filter),
)
)
paginated_dashboards = page_payload["dashboards"]
total = page_payload["total"]
total_pages = page_payload["total_pages"]
except Exception as page_error:
logger.warning(
"[get_dashboards][Action] Page-based fetch failed; using compatibility fallback: %s",
page_error,
)
if can_apply_slug_filter:
dashboards = await resource_service.get_dashboards_with_status(
env,
all_tasks,
include_git_status=False,
require_slug=True,
)
else:
dashboards = await resource_service.get_dashboards_with_status(
env,
all_tasks,
include_git_status=False,
)
if search:
search_lower = search.lower()
dashboards = [
d
for d in dashboards
if search_lower in d.get("title", "").lower()
or search_lower in d.get("slug", "").lower()
]
total = len(dashboards)
total_pages = (
(total + page_size - 1) // page_size if total > 0 else 1
)
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_dashboards = dashboards[start_idx:end_idx]
else:
if can_apply_slug_filter:
dashboards = await resource_service.get_dashboards_with_status(
env,
all_tasks,
include_git_status=bool(git_filters),
require_slug=True,
)
else:
dashboards = await resource_service.get_dashboards_with_status(
env,
all_tasks,
include_git_status=bool(git_filters),
)
if (
can_apply_profile_filter
and bound_username
and profile_service is not None
):
actor_aliases = _resolve_profile_actor_aliases(env, bound_username)
if not actor_aliases:
actor_aliases = [bound_username]
logger.reason(
"[REASON] Applying profile actor filter "
f"(env={env_id}, bound_username={bound_username}, actor_aliases={actor_aliases!r}, "
f"dashboards_before={len(dashboards)})"
)
filtered_dashboards: List[Dict[str, Any]] = []
max_actor_samples = 15
for index, dashboard in enumerate(dashboards):
owners_value = dashboard.get("owners")
created_by_value = dashboard.get("created_by")
modified_by_value = dashboard.get("modified_by")
matches_actor = _matches_dashboard_actor_aliases(
profile_service=profile_service,
actor_aliases=actor_aliases,
owners=owners_value,
modified_by=modified_by_value,
)
if index < max_actor_samples:
logger.reflect(
"[REFLECT] Profile actor filter sample "
f"(env={env_id}, dashboard_id={dashboard.get('id')}, "
f"bound_username={bound_username!r}, actor_aliases={actor_aliases!r}, "
f"owners={owners_value!r}, created_by={created_by_value!r}, "
f"modified_by={modified_by_value!r}, matches={matches_actor})"
)
if matches_actor:
filtered_dashboards.append(dashboard)
logger.reflect(
"[REFLECT] Profile actor filter summary "
f"(env={env_id}, bound_username={bound_username!r}, "
f"dashboards_before={len(dashboards)}, dashboards_after={len(filtered_dashboards)})"
)
dashboards = filtered_dashboards
if can_apply_slug_filter:
dashboards = [
dashboard
for dashboard in dashboards
if str(dashboard.get("slug") or "").strip()
]
if search:
search_lower = search.lower()
dashboards = [
d
for d in dashboards
if search_lower in d.get("title", "").lower()
or search_lower in d.get("slug", "").lower()
]
def _matches_dashboard_filters(dashboard: Dict[str, Any]) -> bool:
title_value = str(dashboard.get("title") or "").strip().lower()
if title_filters and title_value not in title_filters:
return False
if git_filters:
git_value = _dashboard_git_filter_value(dashboard)
if git_value not in git_filters:
return False
llm_value = (
str(
(
(dashboard.get("last_task") or {}).get(
"validation_status"
)
)
or "UNKNOWN"
)
.strip()
.lower()
)
if llm_filters and llm_value not in llm_filters:
return False
changed_on_raw = (
str(dashboard.get("last_modified") or "").strip().lower()
)
changed_on_prefix = (
changed_on_raw[:10]
if len(changed_on_raw) >= 10
else changed_on_raw
)
if (
changed_on_filters
and changed_on_raw not in changed_on_filters
and changed_on_prefix not in changed_on_filters
):
return False
owners = dashboard.get("owners") or []
if isinstance(owners, list):
actor_value = ", ".join(
str(item).strip() for item in owners if str(item).strip()
).lower()
else:
actor_value = str(owners).strip().lower()
if not actor_value:
actor_value = "-"
if actor_filters and actor_value not in actor_filters:
return False
return True
if has_column_filters:
dashboards = [
d for d in dashboards if _matches_dashboard_filters(d)
]
total = len(dashboards)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_dashboards = dashboards[start_idx:end_idx]
logger.info(
f"[get_dashboards][Coherence:OK] Returning {len(paginated_dashboards)} dashboards "
f"(page {page}/{total_pages}, total: {total}, profile_filter_applied={effective_profile_filter.applied})"
)
response_dashboards = _project_dashboard_response_items(
paginated_dashboards
)
return DashboardsResponse(
dashboards=response_dashboards,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
effective_profile_filter=effective_profile_filter,
)
except HTTPException:
raise
except Exception as e:
logger.error(
f"[get_dashboards][Coherence:Failed] Failed to fetch dashboards: {e}"
)
raise HTTPException(
status_code=503, detail=f"Failed to fetch dashboards: {str(e)}"
)
# [/DEF:get_dashboards:Function]
# [/DEF:DashboardListingRoutes:Module]

View File

@@ -0,0 +1,260 @@
# [DEF:DashboardProjection:Module]
# @COMPLEXITY: 2
# @SEMANTICS: api, dashboards, projection, profile, filtering
# @PURPOSE: Dashboard response projection and profile-filter helpers for Dashboard Hub routes.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> [SupersetClient]
# @RELATION: DEPENDS_ON -> [ProfileService]
from typing import Any, Dict, List, Optional
from src.core.logger import logger, belief_scope
from src.core.superset_client import SupersetClient
from src.core.superset_profile_lookup import SupersetAccountLookupAdapter
from src.models.auth import User
from src.services.profile_service import ProfileService
# [DEF:_normalize_actor_alias_token:Function]
# @COMPLEXITY: 2
# @PURPOSE: Normalize actor alias token to comparable trim+lower text.
def _normalize_actor_alias_token(value: Any) -> Optional[str]:
if value is None:
return None
normalized = str(value).strip().lower()
return normalized if normalized else None
# [/DEF:_normalize_actor_alias_token:Function]
# [DEF:_normalize_owner_display_token:Function]
# @COMPLEXITY: 2
# @PURPOSE: Project owner payload value into stable display string for API response contracts.
def _normalize_owner_display_token(owner: Any) -> Optional[str]:
if owner is None:
return None
if isinstance(owner, dict):
for key in ("username", "full_name", "first_name", "email"):
candidate = owner.get(key)
if isinstance(candidate, str) and candidate.strip():
return candidate.strip()
return None
if isinstance(owner, str):
return owner.strip() or None
return None
# [/DEF:_normalize_owner_display_token:Function]
# [DEF:_normalize_dashboard_owner_values:Function]
# @COMPLEXITY: 2
# @PURPOSE: Normalize dashboard owners payload to optional list of display strings.
def _normalize_dashboard_owner_values(owners: Any) -> Optional[List[str]]:
if owners is None:
return None
raw_items: List[Any]
if isinstance(owners, list):
raw_items = owners
else:
raw_items = [owners]
normalized: List[str] = []
for owner in raw_items:
token = _normalize_owner_display_token(owner)
if token and token not in normalized:
normalized.append(token)
return normalized
# [/DEF:_normalize_dashboard_owner_values:Function]
# [DEF:_project_dashboard_response_items:Function]
# @COMPLEXITY: 2
# @PURPOSE: Project dashboard payloads to response-contract-safe shape.
def _project_dashboard_response_items(
dashboards: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
projected: List[Dict[str, Any]] = []
for dashboard in dashboards:
projected_dashboard = dict(dashboard)
projected_dashboard["owners"] = _normalize_dashboard_owner_values(
projected_dashboard.get("owners")
)
projected.append(projected_dashboard)
return projected
# [/DEF:_project_dashboard_response_items:Function]
# [DEF:_get_profile_filter_binding:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard profile-filter binding through current or legacy profile service contracts.
def _get_profile_filter_binding(
profile_service: Any, current_user: User
) -> Dict[str, Any]:
def _read_optional_string(value: Any) -> Optional[str]:
return value if isinstance(value, str) else None
def _read_bool(value: Any, default: bool) -> bool:
return value if isinstance(value, bool) else default
if hasattr(profile_service, "get_dashboard_filter_binding"):
binding = profile_service.get_dashboard_filter_binding(current_user)
if isinstance(binding, dict):
return {
"superset_username": _read_optional_string(
binding.get("superset_username")
),
"superset_username_normalized": _read_optional_string(
binding.get("superset_username_normalized")
),
"show_only_my_dashboards": _read_bool(
binding.get("show_only_my_dashboards"), False
),
"show_only_slug_dashboards": _read_bool(
binding.get("show_only_slug_dashboards"), False
),
}
if hasattr(profile_service, "get_my_preference"):
response = profile_service.get_my_preference(current_user)
preference = getattr(response, "preference", None)
return {
"superset_username": _read_optional_string(
getattr(preference, "superset_username", None)
),
"superset_username_normalized": _read_optional_string(
getattr(preference, "superset_username_normalized", None)
),
"show_only_my_dashboards": _read_bool(
getattr(preference, "show_only_my_dashboards", False), False
),
"show_only_slug_dashboards": _read_bool(
getattr(preference, "show_only_slug_dashboards", False), False
),
}
return {
"superset_username": None,
"superset_username_normalized": None,
"show_only_my_dashboards": False,
"show_only_slug_dashboards": False,
}
# [/DEF:_get_profile_filter_binding:Function]
# [DEF:_resolve_profile_actor_aliases:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve stable actor aliases for profile filtering without per-dashboard detail fan-out.
# @SIDE_EFFECT: Performs at most one Superset users-lookup request.
def _resolve_profile_actor_aliases(env: Any, bound_username: str) -> List[str]:
normalized_bound = _normalize_actor_alias_token(bound_username)
if not normalized_bound:
return []
aliases: List[str] = [normalized_bound]
try:
client = SupersetClient(env)
adapter = SupersetAccountLookupAdapter(
network_client=client.network,
environment_id=str(getattr(env, "id", "")),
)
lookup_payload = adapter.get_users_page(
search=normalized_bound,
page_index=0,
page_size=20,
sort_column="username",
sort_order="asc",
)
lookup_items = (
lookup_payload.get("items", []) if isinstance(lookup_payload, dict) else []
)
matched_item: Optional[Dict[str, Any]] = None
for item in lookup_items:
if not isinstance(item, dict):
continue
if _normalize_actor_alias_token(item.get("username")) == normalized_bound:
matched_item = item
break
if matched_item is None:
for item in lookup_items:
if isinstance(item, dict):
matched_item = item
break
display_alias = _normalize_actor_alias_token(
(matched_item or {}).get("display_name")
)
if display_alias and display_alias not in aliases:
aliases.append(display_alias)
logger.reflect(
"[REFLECT] Resolved profile actor aliases "
f"(env={getattr(env, 'id', None)}, bound_username={normalized_bound!r}, "
f"lookup_items={len(lookup_items)}, aliases={aliases!r})"
)
except Exception as alias_error:
logger.explore(
"[EXPLORE] Failed to resolve profile actor aliases via Superset users lookup "
f"(env={getattr(env, 'id', None)}, bound_username={normalized_bound!r}): {alias_error}"
)
return aliases
# [/DEF:_resolve_profile_actor_aliases:Function]
# [DEF:_matches_dashboard_actor_aliases:Function]
# @COMPLEXITY: 2
# @PURPOSE: Apply profile actor matching against multiple aliases (username + optional display name).
def _matches_dashboard_actor_aliases(
profile_service: ProfileService,
actor_aliases: List[str],
owners: Optional[Any],
modified_by: Optional[str],
) -> bool:
for actor_alias in actor_aliases:
if profile_service.matches_dashboard_actor(
bound_username=actor_alias,
owners=owners,
modified_by=modified_by,
):
return True
return False
# [/DEF:_matches_dashboard_actor_aliases:Function]
# [DEF:_task_matches_dashboard:Function]
# @COMPLEXITY: 2
# @PURPOSE: Checks whether task params are tied to a specific dashboard and environment.
def _task_matches_dashboard(
task: Any, dashboard_id: int, env_id: Optional[str]
) -> bool:
plugin_id = getattr(task, "plugin_id", None)
if plugin_id not in {"superset-backup", "llm_dashboard_validation"}:
return False
params = getattr(task, "params", {}) or {}
dashboard_id_str = str(dashboard_id)
if plugin_id == "llm_dashboard_validation":
task_dashboard_id = params.get("dashboard_id")
if str(task_dashboard_id) != dashboard_id_str:
return False
if env_id:
task_env = params.get("environment_id")
return str(task_env) == str(env_id)
return True
dashboard_ids = params.get("dashboard_ids") or params.get("dashboards") or []
normalized_ids = {str(item) for item in dashboard_ids}
if dashboard_id_str not in normalized_ids:
return False
if env_id:
task_env = params.get("environment_id") or params.get("env")
return str(task_env) == str(env_id)
return True
# [/DEF:_task_matches_dashboard:Function]
# [/DEF:DashboardProjection:Module]

View File

@@ -0,0 +1,8 @@
# [DEF:DashboardsRouter:Block]
# @COMPLEXITY: 1
# @PURPOSE: Single APIRouter instance for all dashboard endpoints.
# @RELATION: BINDS_TO -> [DashboardsApi]
from fastapi import APIRouter
router = APIRouter(prefix="/api/dashboards", tags=["Dashboards"])
# [/DEF:DashboardsRouter:Block]

View File

@@ -0,0 +1,232 @@
# [DEF:DashboardSchemas:Module]
# @COMPLEXITY: 1
# @SEMANTICS: api, dashboards, schemas, dto
# @PURPOSE: DTO classes for the Dashboard Hub API.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> [Pydantic]
from typing import List, Optional, Dict, Any, Literal
from pydantic import BaseModel, Field
# [DEF:GitStatus:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: DTO for dashboard Git synchronization status.
class GitStatus(BaseModel):
branch: Optional[str] = None
sync_status: Optional[str] = Field(None, pattern="^OK|DIFF|NO_REPO|ERROR$")
has_repo: Optional[bool] = None
has_changes_for_commit: Optional[bool] = None
# [/DEF:GitStatus:DataClass]
# [DEF:LastTask:DataClass]
# @COMPLEXITY: 2
# @PURPOSE: DTO for the most recent background task associated with a dashboard.
class LastTask(BaseModel):
task_id: Optional[str] = None
status: Optional[str] = Field(
None,
pattern="^PENDING|RUNNING|SUCCESS|FAILED|ERROR|AWAITING_INPUT|WAITING_INPUT|AWAITING_MAPPING$",
)
validation_status: Optional[str] = Field(None, pattern="^PASS|FAIL|WARN|UNKNOWN$")
# [/DEF:LastTask:DataClass]
# [DEF:DashboardItem:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: DTO representing a single dashboard with projected metadata.
class DashboardItem(BaseModel):
id: int
title: str
slug: Optional[str] = None
url: Optional[str] = None
last_modified: Optional[str] = None
created_by: Optional[str] = None
modified_by: Optional[str] = None
owners: Optional[List[str]] = None
git_status: Optional[GitStatus] = None
last_task: Optional[LastTask] = None
# [/DEF:DashboardItem:DataClass]
# [DEF:EffectiveProfileFilter:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: Metadata about applied profile filters for UI context.
class EffectiveProfileFilter(BaseModel):
applied: bool
source_page: Literal["dashboards_main", "other"] = "dashboards_main"
override_show_all: bool = False
username: Optional[str] = None
match_logic: Optional[
Literal["owners_or_modified_by", "slug_only", "owners_or_modified_by+slug_only"]
] = None
# [/DEF:EffectiveProfileFilter:DataClass]
# [DEF:DashboardsResponse:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: Envelope DTO for paginated dashboards list.
class DashboardsResponse(BaseModel):
dashboards: List[DashboardItem]
total: int
page: int
page_size: int
total_pages: int
effective_profile_filter: Optional[EffectiveProfileFilter] = None
# [/DEF:DashboardsResponse:DataClass]
# [DEF:DashboardChartItem:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: DTO for a chart linked to a dashboard.
class DashboardChartItem(BaseModel):
id: int
title: str
viz_type: Optional[str] = None
dataset_id: Optional[int] = None
last_modified: Optional[str] = None
overview: Optional[str] = None
# [/DEF:DashboardChartItem:DataClass]
# [DEF:DashboardDatasetItem:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: DTO for a dataset associated with a dashboard.
class DashboardDatasetItem(BaseModel):
id: int
table_name: str
schema: Optional[str] = None
database: str
last_modified: Optional[str] = None
overview: Optional[str] = None
# [/DEF:DashboardDatasetItem:DataClass]
# [DEF:DashboardDetailResponse:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: Detailed dashboard metadata including children.
class DashboardDetailResponse(BaseModel):
id: int
title: str
slug: Optional[str] = None
url: Optional[str] = None
description: Optional[str] = None
last_modified: Optional[str] = None
published: Optional[bool] = None
charts: List[DashboardChartItem]
datasets: List[DashboardDatasetItem]
chart_count: int
dataset_count: int
# [/DEF:DashboardDetailResponse:DataClass]
# [DEF:DashboardTaskHistoryItem:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: Individual history record entry.
class DashboardTaskHistoryItem(BaseModel):
id: str
plugin_id: str
status: str
validation_status: Optional[str] = None
started_at: Optional[str] = None
finished_at: Optional[str] = None
env_id: Optional[str] = None
summary: Optional[str] = None
# [/DEF:DashboardTaskHistoryItem:DataClass]
# [DEF:DashboardTaskHistoryResponse:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: Collection DTO for task history.
class DashboardTaskHistoryResponse(BaseModel):
dashboard_id: int
items: List[DashboardTaskHistoryItem]
# [/DEF:DashboardTaskHistoryResponse:DataClass]
# [DEF:DatabaseMapping:DataClass]
# @COMPLEXITY: 2
# @PURPOSE: DTO for cross-environment database ID mapping.
class DatabaseMapping(BaseModel):
source_db: str
target_db: str
source_db_uuid: Optional[str] = None
target_db_uuid: Optional[str] = None
confidence: float
# [/DEF:DatabaseMapping:DataClass]
# [DEF:DatabaseMappingsResponse:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: Wrapper for database mappings.
class DatabaseMappingsResponse(BaseModel):
mappings: List[DatabaseMapping]
# [/DEF:DatabaseMappingsResponse:DataClass]
# [DEF:MigrateRequest:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: DTO for dashboard migration requests.
class MigrateRequest(BaseModel):
source_env_id: str = Field(..., description="Source environment ID")
target_env_id: str = Field(..., description="Target environment ID")
dashboard_ids: List[int] = Field(
..., description="List of dashboard IDs to migrate"
)
db_mappings: Optional[Dict[str, str]] = Field(
None, description="Database mappings for migration"
)
replace_db_config: bool = Field(False, description="Replace database configuration")
# [/DEF:MigrateRequest:DataClass]
# [DEF:TaskResponse:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: DTO for async task ID return.
class TaskResponse(BaseModel):
task_id: str
# [/DEF:TaskResponse:DataClass]
# [DEF:BackupRequest:DataClass]
# @COMPLEXITY: 1
# @PURPOSE: DTO for dashboard backup requests.
class BackupRequest(BaseModel):
env_id: str = Field(..., description="Environment ID")
dashboard_ids: List[int] = Field(..., description="List of dashboard IDs to backup")
schedule: Optional[str] = Field(
None, description="Cron schedule for recurring backups (e.g., '0 0 * * *')"
)
# [/DEF:BackupRequest:DataClass]
# [/DEF:DashboardSchemas:Module]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
# [DEF:GitPackage:Module]
# @COMPLEXITY: 3
# @PURPOSE: Package root for decomposed git routes. Re-exports all public symbols from submodules.
# @LAYER: API
# @SEMANTICS: git, package, re-exports
# @RELATION: USES -> [GitRouter, GitDeps, GitHelpers, GitConfigRoutes, GitGiteaRoutes,
# GitRepoRoutes, GitRepoOperationsRoutes, GitRepoLifecycleRoutes,
# GitMergeRoutes, GitEnvironmentRoutes]
# @INVARIANT: git_service and os are module-level attributes for test monkeypatch compatibility.
# All route functions are re-exported for direct access via `from src.api.routes import git`.
import os
from src.services.git_service import GitService
from ._router import router
from ._deps import MAX_REPOSITORY_STATUS_BATCH
# -- git_service singleton (canonical instance; tests may monkeypatch git_routes.git_service) --
git_service = GitService()
# -- Config routes --
from ._config_routes import get_git_configs, create_git_config, update_git_config, delete_git_config, test_git_config # noqa: E501, F401
# -- Gitea routes --
from ._gitea_routes import list_gitea_repositories, create_gitea_repository, delete_gitea_repository, create_remote_repository # noqa: E501, F401
# -- Repo routes (core) --
from ._repo_routes import init_repository, get_repository_binding, delete_repository, get_branches, create_branch, checkout_branch # noqa: E501, F401
from ._helpers import _resolve_dashboard_id_from_ref, _resolve_dashboard_id_from_ref_async, _resolve_repo_key_from_ref # noqa: E501, F401 — re-exported for test monkeypatch compatibility
# -- Repo operations routes (commit, push, pull, status, diff, history, generate-message) --
from ._repo_operations_routes import commit_changes, push_changes, pull_changes, get_repository_status, get_repository_status_batch, get_repository_diff, get_history, generate_commit_message # noqa: E501, F401
# -- Repo lifecycle routes (sync, promote, deploy) --
from ._repo_lifecycle_routes import sync_dashboard, promote_dashboard, deploy_dashboard # noqa: E501, F401
# -- Merge routes --
from ._merge_routes import get_merge_status, get_merge_conflicts, resolve_merge_conflicts, abort_merge, continue_merge # noqa: E501, F401
# -- Environment routes --
from ._environment_routes import get_environments # noqa: E501, F401
# [/DEF:GitPackage:Module]

View File

@@ -0,0 +1,159 @@
# [DEF:GitConfigRoutes:Module]
# @COMPLEXITY: 2
# @PURPOSE: FastAPI endpoints for Git server configuration CRUD and connection testing.
# @LAYER: API
# @SEMANTICS: git, config, routes, crud
from typing import List
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from src.core.database import get_db
from src.core.logger import belief_scope, logger
from src.dependencies import has_permission
from src.models.git import GitServerConfig
from src.api.routes.git_schemas import (
GitServerConfigCreate,
GitServerConfigSchema,
GitServerConfigUpdate,
)
from ._router import router
from ._helpers import _get_git_config_or_404
from ._deps import get_git_service
# [DEF:get_git_configs:Function]
# @COMPLEXITY: 2
# @PURPOSE: List all configured Git servers.
@router.get("/config", response_model=List[GitServerConfigSchema])
async def get_git_configs(
db: Session = Depends(get_db),
_=Depends(has_permission("git_config", "READ")),
):
with belief_scope("get_git_configs"):
configs = db.query(GitServerConfig).all()
result = []
for config in configs:
schema = GitServerConfigSchema.from_orm(config)
schema.pat = "********"
result.append(schema)
return result
# [/DEF:get_git_configs:Function]
# [DEF:create_git_config:Function]
# @COMPLEXITY: 2
# @PURPOSE: Register a new Git server configuration.
@router.post("/config", response_model=GitServerConfigSchema)
async def create_git_config(
config: GitServerConfigCreate,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("create_git_config"):
config_dict = config.dict(exclude={"config_id"})
db_config = GitServerConfig(**config_dict)
db.add(db_config)
db.commit()
db.refresh(db_config)
return db_config
# [/DEF:create_git_config:Function]
# [DEF:update_git_config:Function]
# @COMPLEXITY: 2
# @PURPOSE: Update an existing Git server configuration.
@router.put("/config/{config_id}", response_model=GitServerConfigSchema)
async def update_git_config(
config_id: str,
config_update: GitServerConfigUpdate,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_git_config"):
db_config = (
db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
)
if not db_config:
raise HTTPException(status_code=404, detail="Configuration not found")
update_data = config_update.dict(exclude_unset=True)
if update_data.get("pat") == "********":
update_data.pop("pat")
for key, value in update_data.items():
setattr(db_config, key, value)
db.commit()
db.refresh(db_config)
result_schema = GitServerConfigSchema.from_orm(db_config)
result_schema.pat = "********"
return result_schema
# [/DEF:update_git_config:Function]
# [DEF:delete_git_config:Function]
# @COMPLEXITY: 2
# @PURPOSE: Remove a Git server configuration.
@router.delete("/config/{config_id}")
async def delete_git_config(
config_id: str,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("delete_git_config"):
db_config = (
db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
)
if not db_config:
raise HTTPException(status_code=404, detail="Configuration not found")
db.delete(db_config)
db.commit()
return {"status": "success", "message": "Configuration deleted"}
# [/DEF:delete_git_config:Function]
# [DEF:test_git_config:Function]
# @COMPLEXITY: 2
# @PURPOSE: Validate connection to a Git server using provided credentials.
@router.post("/config/test")
async def test_git_config(
config: GitServerConfigCreate,
db: Session = Depends(get_db),
_=Depends(has_permission("git_config", "READ")),
):
with belief_scope("test_git_config"):
_git_service = get_git_service()
pat_to_use = config.pat
if pat_to_use == "********":
if config.config_id:
db_config = (
db.query(GitServerConfig)
.filter(GitServerConfig.id == config.config_id)
.first()
)
if db_config:
pat_to_use = db_config.pat
else:
db_config = (
db.query(GitServerConfig)
.filter(GitServerConfig.url == config.url)
.first()
)
if db_config:
pat_to_use = db_config.pat
success = await _git_service.test_connection(
config.provider, config.url, pat_to_use
)
if success:
return {"status": "success", "message": "Connection successful"}
else:
raise HTTPException(status_code=400, detail="Connection failed")
# [/DEF:test_git_config:Function]
# [/DEF:GitConfigRoutes:Module]

View File

@@ -0,0 +1,24 @@
# [DEF:GitDeps:Module]
# @COMPLEXITY: 1
# @PURPOSE: Shared dependency wiring for monkeypatch-safe git_service access and constants.
# @LAYER: API
# @SEMANTICS: git, deps, service-locator
# @INVARIANT: get_git_service() resolves from sys.modules at call time so test monkeypatching
# of git_routes.git_service (the __init__ attribute) takes effect across all submodules.
import sys
# Batch limit for repository status queries
MAX_REPOSITORY_STATUS_BATCH: int = 50
def get_git_service():
"""Resolve git_service from the git package root at call time.
This indirection ensures that test monkeypatching via
``monkeypatch.setattr(git_routes, 'git_service', mock)``
is visible to every submodule function, because each call
re-resolves ``sys.modules['src.api.routes.git'].git_service``.
"""
return sys.modules["src.api.routes.git"].git_service
# [/DEF:GitDeps:Module]

View File

@@ -0,0 +1,36 @@
# [DEF:GitEnvironmentRoutes:Module]
# @COMPLEXITY: 2
# @PURPOSE: FastAPI endpoint for listing deployment environments.
# @LAYER: API
# @SEMANTICS: git, environments, routes
from typing import List
from fastapi import Depends
from src.core.logger import belief_scope
from src.dependencies import get_config_manager, has_permission
from src.api.routes.git_schemas import DeploymentEnvironmentSchema
from ._router import router
# [DEF:get_environments:Function]
# @COMPLEXITY: 2
# @PURPOSE: List all deployment environments.
@router.get("/environments", response_model=List[DeploymentEnvironmentSchema])
async def get_environments(
config_manager=Depends(get_config_manager),
_=Depends(has_permission("environments", "READ")),
):
with belief_scope("get_environments"):
envs = config_manager.get_environments()
return [
DeploymentEnvironmentSchema(
id=e.id, name=e.name, superset_url=e.url, is_active=True
)
for e in envs
]
# [/DEF:get_environments:Function]
# [/DEF:GitEnvironmentRoutes:Module]

View File

@@ -0,0 +1,188 @@
# [DEF:GitGiteaRoutes:Module]
# @COMPLEXITY: 2
# @PURPOSE: FastAPI endpoints for Gitea-specific repository operations.
# @LAYER: API
# @SEMANTICS: git, gitea, routes, repositories
from typing import List
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from src.core.database import get_db
from src.core.logger import belief_scope
from src.dependencies import has_permission
from src.models.git import GitProvider
from src.api.routes.git_schemas import (
GiteaRepoCreateRequest,
GiteaRepoSchema,
RemoteRepoCreateRequest,
RemoteRepoSchema,
)
from ._router import router
from ._helpers import _get_git_config_or_404
from ._deps import get_git_service
# [DEF:list_gitea_repositories:Function]
# @COMPLEXITY: 2
# @PURPOSE: List repositories in Gitea for a saved Gitea config.
@router.get("/config/{config_id}/gitea/repos", response_model=List[GiteaRepoSchema])
async def list_gitea_repositories(
config_id: str,
db: Session = Depends(get_db),
_=Depends(has_permission("git_config", "READ")),
):
_gs = get_git_service()
with belief_scope("list_gitea_repositories"):
config = _get_git_config_or_404(db, config_id)
if config.provider != GitProvider.GITEA:
raise HTTPException(
status_code=400, detail="This endpoint supports GITEA provider only"
)
repos = await _gs.list_gitea_repositories(config.url, config.pat)
return [
GiteaRepoSchema(
name=repo.get("name", ""),
full_name=repo.get("full_name", ""),
private=bool(repo.get("private", False)),
clone_url=repo.get("clone_url"),
html_url=repo.get("html_url"),
ssh_url=repo.get("ssh_url"),
default_branch=repo.get("default_branch"),
)
for repo in repos
]
# [/DEF:list_gitea_repositories:Function]
# [DEF:create_gitea_repository:Function]
# @COMPLEXITY: 2
# @PURPOSE: Create a repository in Gitea for a saved Gitea config.
@router.post("/config/{config_id}/gitea/repos", response_model=GiteaRepoSchema)
async def create_gitea_repository(
config_id: str,
request: GiteaRepoCreateRequest,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
_gs = get_git_service()
with belief_scope("create_gitea_repository"):
config = _get_git_config_or_404(db, config_id)
if config.provider != GitProvider.GITEA:
raise HTTPException(
status_code=400, detail="This endpoint supports GITEA provider only"
)
repo = await _gs.create_gitea_repository(
server_url=config.url,
pat=config.pat,
name=request.name,
private=request.private,
description=request.description,
auto_init=request.auto_init,
default_branch=request.default_branch,
)
return GiteaRepoSchema(
name=repo.get("name", ""),
full_name=repo.get("full_name", ""),
private=bool(repo.get("private", False)),
clone_url=repo.get("clone_url"),
html_url=repo.get("html_url"),
ssh_url=repo.get("ssh_url"),
default_branch=repo.get("default_branch"),
)
# [/DEF:create_gitea_repository:Function]
# [DEF:delete_gitea_repository:Function]
# @COMPLEXITY: 2
# @PURPOSE: Delete repository in Gitea for a saved Gitea config.
@router.delete("/config/{config_id}/gitea/repos/{owner}/{repo_name}")
async def delete_gitea_repository(
config_id: str,
owner: str,
repo_name: str,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
_gs = get_git_service()
with belief_scope("delete_gitea_repository"):
config = _get_git_config_or_404(db, config_id)
if config.provider != GitProvider.GITEA:
raise HTTPException(
status_code=400, detail="This endpoint supports GITEA provider only"
)
await _gs.delete_gitea_repository(
server_url=config.url,
pat=config.pat,
owner=owner,
repo_name=repo_name,
)
return {"status": "success", "message": "Repository deleted"}
# [/DEF:delete_gitea_repository:Function]
# [DEF:create_remote_repository:Function]
# @COMPLEXITY: 2
# @PURPOSE: Create repository on remote Git server using selected provider config.
@router.post("/config/{config_id}/repositories", response_model=RemoteRepoSchema)
async def create_remote_repository(
config_id: str,
request: RemoteRepoCreateRequest,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
_gs = get_git_service()
with belief_scope("create_remote_repository"):
config = _get_git_config_or_404(db, config_id)
if config.provider == GitProvider.GITEA:
repo = await _gs.create_gitea_repository(
server_url=config.url,
pat=config.pat,
name=request.name,
private=request.private,
description=request.description,
auto_init=request.auto_init,
default_branch=request.default_branch,
)
elif config.provider == GitProvider.GITHUB:
repo = await _gs.create_github_repository(
server_url=config.url,
pat=config.pat,
name=request.name,
private=request.private,
description=request.description,
auto_init=request.auto_init,
default_branch=request.default_branch,
)
elif config.provider == GitProvider.GITLAB:
repo = await _gs.create_gitlab_repository(
server_url=config.url,
pat=config.pat,
name=request.name,
private=request.private,
description=request.description,
auto_init=request.auto_init,
default_branch=request.default_branch,
)
else:
raise HTTPException(
status_code=501,
detail=f"Provider {config.provider} is not supported",
)
return RemoteRepoSchema(
provider=config.provider,
name=repo.get("name", ""),
full_name=repo.get("full_name", repo.get("name", "")),
private=bool(repo.get("private", False)),
clone_url=repo.get("clone_url"),
html_url=repo.get("html_url"),
ssh_url=repo.get("ssh_url"),
default_branch=repo.get("default_branch"),
)
# [/DEF:create_remote_repository:Function]
# [/DEF:GitGiteaRoutes:Module]

View File

@@ -0,0 +1,345 @@
# [DEF:GitHelpers:Module]
# @COMPLEXITY: 3
# @PURPOSE: Shared helper functions for Git route modules.
# @LAYER: API
# @SEMANTICS: git, helpers, resolution, identity
# @RELATION USES -> [GitDeps]
# @RELATION USES -> [SupersetClient]
# @RELATION USES -> [UserDashboardPreference]
import os
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.core.superset_client import SupersetClient
from src.dependencies import get_config_manager
from src.models.auth import User
from src.models.git import GitServerConfig
from src.models.profile import UserDashboardPreference
from ._deps import get_git_service, MAX_REPOSITORY_STATUS_BATCH
# [DEF:_build_no_repo_status_payload:Function]
# @COMPLEXITY: 1
# @PURPOSE: Build a consistent status payload for dashboards without initialized repositories.
# @POST: Returns a stable payload compatible with frontend repository status parsing.
def _build_no_repo_status_payload() -> dict:
return {
"is_dirty": False,
"untracked_files": [],
"modified_files": [],
"staged_files": [],
"current_branch": None,
"upstream_branch": None,
"has_upstream": False,
"ahead_count": 0,
"behind_count": 0,
"is_diverged": False,
"sync_state": "NO_REPO",
"sync_status": "NO_REPO",
"has_repo": False,
}
# [/DEF:_build_no_repo_status_payload:Function]
# [DEF:_handle_unexpected_git_route_error:Function]
# @COMPLEXITY: 1
# @PURPOSE: Convert unexpected route-level exceptions to stable 500 API responses.
# @PRE: `error` is a non-HTTPException instance.
# @POST: Raises HTTPException(500) with route-specific context.
def _handle_unexpected_git_route_error(route_name: str, error: Exception) -> None:
logger.error(f"[{route_name}][Coherence:Failed] {error}")
raise HTTPException(status_code=500, detail=f"{route_name} failed: {str(error)}")
# [/DEF:_handle_unexpected_git_route_error:Function]
# [DEF:_resolve_repository_status:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve repository status for one dashboard with graceful NO_REPO semantics.
# @PRE: `dashboard_id` is a valid integer.
# @POST: Returns standard status payload or `NO_REPO` payload when repository path is absent.
def _resolve_repository_status(dashboard_id: int) -> dict:
git_service = get_git_service()
repo_path = git_service._get_repo_path(dashboard_id)
if not os.path.exists(repo_path):
logger.debug(
f"[get_repository_status][Action] Repository is not initialized for dashboard {dashboard_id}"
)
return _build_no_repo_status_payload()
try:
return git_service.get_status(dashboard_id)
except HTTPException as e:
if e.status_code == 404:
logger.debug(
f"[get_repository_status][Action] Repository is not initialized for dashboard {dashboard_id}"
)
return _build_no_repo_status_payload()
raise
# [/DEF:_resolve_repository_status:Function]
# [DEF:_get_git_config_or_404:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve GitServerConfig by id or raise 404.
def _get_git_config_or_404(db: Session, config_id: str) -> GitServerConfig:
config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="Git configuration not found")
return config
# [/DEF:_get_git_config_or_404:Function]
# [DEF:_find_dashboard_id_by_slug:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard numeric ID by slug in a specific environment.
def _find_dashboard_id_by_slug(
client: SupersetClient,
dashboard_slug: str,
) -> Optional[int]:
query_variants = [
{
"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
{
"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
]
for query in query_variants:
try:
_count, dashboards = client.get_dashboards_page(query=query)
if dashboards:
resolved_id = dashboards[0].get("id")
if resolved_id is not None:
return int(resolved_id)
except Exception:
continue
return None
# [/DEF:_find_dashboard_id_by_slug:Function]
# [DEF:_resolve_dashboard_id_from_ref:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard ID from slug-or-id reference for Git routes.
def _resolve_dashboard_id_from_ref(
dashboard_ref: str,
config_manager,
env_id: Optional[str] = None,
) -> int:
normalized_ref = str(dashboard_ref or "").strip()
if not normalized_ref:
raise HTTPException(status_code=400, detail="dashboard_ref is required")
if normalized_ref.isdigit():
return int(normalized_ref)
if not env_id:
raise HTTPException(
status_code=400,
detail="env_id is required for slug-based Git operations",
)
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
dashboard_id = _find_dashboard_id_by_slug(SupersetClient(env), normalized_ref)
if dashboard_id is None:
raise HTTPException(
status_code=404, detail=f"Dashboard slug '{normalized_ref}' not found"
)
return dashboard_id
# [/DEF:_resolve_dashboard_id_from_ref:Function]
# [DEF:_find_dashboard_id_by_slug_async:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard numeric ID by slug asynchronously for hot-path Git routes.
async def _find_dashboard_id_by_slug_async(
client: "AsyncSupersetClient",
dashboard_slug: str,
) -> Optional[int]:
query_variants = [
{
"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
{
"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
]
for query in query_variants:
try:
_count, dashboards = await client.get_dashboards_page_async(query=query)
if dashboards:
resolved_id = dashboards[0].get("id")
if resolved_id is not None:
return int(resolved_id)
except Exception:
continue
return None
# [/DEF:_find_dashboard_id_by_slug_async:Function]
# [DEF:_resolve_dashboard_id_from_ref_async:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard ID asynchronously from slug-or-id reference for hot Git routes.
async def _resolve_dashboard_id_from_ref_async(
dashboard_ref: str,
config_manager,
env_id: Optional[str] = None,
) -> int:
normalized_ref = str(dashboard_ref or "").strip()
if not normalized_ref:
raise HTTPException(status_code=400, detail="dashboard_ref is required")
if normalized_ref.isdigit():
return int(normalized_ref)
if not env_id:
raise HTTPException(
status_code=400,
detail="env_id is required for slug-based Git operations",
)
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
from src.core.async_superset_client import AsyncSupersetClient
client = AsyncSupersetClient(env)
try:
dashboard_id = await _find_dashboard_id_by_slug_async(client, normalized_ref)
if dashboard_id is None:
raise HTTPException(
status_code=404, detail=f"Dashboard slug '{normalized_ref}' not found"
)
return dashboard_id
finally:
await client.aclose()
# [/DEF:_resolve_dashboard_id_from_ref_async:Function]
# [DEF:_resolve_repo_key_from_ref:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve repository folder key with slug-first strategy and deterministic fallback.
def _resolve_repo_key_from_ref(
dashboard_ref: str,
dashboard_id: int,
config_manager,
env_id: Optional[str] = None,
) -> str:
normalized_ref = str(dashboard_ref or "").strip()
if normalized_ref and not normalized_ref.isdigit():
return normalized_ref
if env_id:
try:
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if env:
payload = SupersetClient(env).get_dashboard(dashboard_id)
dashboard_data = (
payload.get("result", payload) if isinstance(payload, dict) else {}
)
dashboard_slug = dashboard_data.get("slug")
if dashboard_slug:
return str(dashboard_slug)
except Exception:
pass
return f"dashboard-{dashboard_id}"
# [/DEF:_resolve_repo_key_from_ref:Function]
# [DEF:_sanitize_optional_identity_value:Function]
# @COMPLEXITY: 1
# @PURPOSE: Normalize optional identity value into trimmed string or None.
def _sanitize_optional_identity_value(value: Optional[str]) -> Optional[str]:
normalized = str(value or "").strip()
if not normalized:
return None
return normalized
# [/DEF:_sanitize_optional_identity_value:Function]
# [DEF:_resolve_current_user_git_identity:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve configured Git username/email from current user's profile preferences.
def _resolve_current_user_git_identity(
db: Session,
current_user: Optional[User],
) -> Optional[tuple[str, str]]:
if db is None or not hasattr(db, "query"):
return None
user_id = _sanitize_optional_identity_value(getattr(current_user, "id", None))
if not user_id:
return None
try:
preference = (
db.query(UserDashboardPreference)
.filter(UserDashboardPreference.user_id == user_id)
.first()
)
except Exception as resolve_error:
logger.warning(
"[_resolve_current_user_git_identity][Action] Failed to load profile preference for user %s: %s",
user_id,
resolve_error,
)
return None
if not preference:
return None
git_username = _sanitize_optional_identity_value(
getattr(preference, "git_username", None)
)
git_email = _sanitize_optional_identity_value(
getattr(preference, "git_email", None)
)
if not git_username or not git_email:
return None
return git_username, git_email
# [/DEF:_resolve_current_user_git_identity:Function]
# [DEF:_apply_git_identity_from_profile:Function]
# @COMPLEXITY: 2
# @PURPOSE: Apply user-scoped Git identity to repository-local config before write/pull operations.
def _apply_git_identity_from_profile(
dashboard_id: int,
db: Session,
current_user: Optional[User],
) -> None:
identity = _resolve_current_user_git_identity(db, current_user)
if not identity:
return
git_service = get_git_service()
configure_identity = getattr(git_service, "configure_identity", None)
if not callable(configure_identity):
return
git_username, git_email = identity
configure_identity(dashboard_id, git_username, git_email)
# [/DEF:_apply_git_identity_from_profile:Function]
# [/DEF:GitHelpers:Module]

View File

@@ -0,0 +1,169 @@
# [DEF:GitMergeRoutes:Module]
# @COMPLEXITY: 3
# @PURPOSE: FastAPI endpoints for merge operations (status, conflicts, resolve, abort, continue).
# @LAYER: API
# @SEMANTICS: git, merge, routes, conflicts
from typing import List, Optional
from fastapi import Depends, HTTPException
from src.core.logger import belief_scope
from src.dependencies import get_config_manager, has_permission
from src.api.routes.git_schemas import (
MergeConflictFileSchema,
MergeContinueRequest,
MergeResolveRequest,
MergeStatusSchema,
)
from ._router import router
from ._helpers import (
_handle_unexpected_git_route_error,
)
from ._deps import get_git_service
# [DEF:get_merge_status:Function]
# @COMPLEXITY: 2
# @PURPOSE: Return unfinished-merge status for repository (web-only recovery support).
@router.get(
"/repositories/{dashboard_ref}/merge/status", response_model=MergeStatusSchema
)
async def get_merge_status(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("get_merge_status"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.get_merge_status(dashboard_id)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_merge_status", e)
# [/DEF:get_merge_status:Function]
# [DEF:get_merge_conflicts:Function]
# @COMPLEXITY: 2
# @PURPOSE: Return conflicted files with mine/theirs previews for web conflict resolver.
@router.get(
"/repositories/{dashboard_ref}/merge/conflicts",
response_model=List[MergeConflictFileSchema],
)
async def get_merge_conflicts(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("get_merge_conflicts"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.get_merge_conflicts(dashboard_id)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_merge_conflicts", e)
# [/DEF:get_merge_conflicts:Function]
# [DEF:resolve_merge_conflicts:Function]
# @COMPLEXITY: 3
# @PURPOSE: Apply mine/theirs/manual conflict resolutions from WebUI and stage files.
# @RELATION: CALLS -> [GitService]
@router.post("/repositories/{dashboard_ref}/merge/resolve")
async def resolve_merge_conflicts(
dashboard_ref: str,
resolve_data: MergeResolveRequest,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("resolve_merge_conflicts"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
resolved_files = _gs.resolve_merge_conflicts(
dashboard_id,
[item.dict() for item in resolve_data.resolutions],
)
return {"status": "success", "resolved_files": resolved_files}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("resolve_merge_conflicts", e)
# [/DEF:resolve_merge_conflicts:Function]
# [DEF:abort_merge:Function]
# @COMPLEXITY: 2
# @PURPOSE: Abort unfinished merge from WebUI flow.
@router.post("/repositories/{dashboard_ref}/merge/abort")
async def abort_merge(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("abort_merge"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.abort_merge(dashboard_id)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("abort_merge", e)
# [/DEF:abort_merge:Function]
# [DEF:continue_merge:Function]
# @COMPLEXITY: 3
# @PURPOSE: Finalize unfinished merge from WebUI flow.
# @RELATION: CALLS -> [GitService]
@router.post("/repositories/{dashboard_ref}/merge/continue")
async def continue_merge(
dashboard_ref: str,
continue_data: MergeContinueRequest,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("continue_merge"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.continue_merge(dashboard_id, continue_data.message)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("continue_merge", e)
# [/DEF:continue_merge:Function]
# [/DEF:GitMergeRoutes:Module]

View File

@@ -0,0 +1,228 @@
# [DEF:GitRepoLifecycleRoutes:Module]
# @COMPLEXITY: 3
# @PURPOSE: FastAPI endpoints for Git lifecycle operations (sync, promote, deploy).
# @LAYER: API
# @SEMANTICS: git, lifecycle, sync, promote, deploy
import typing
from typing import Optional
from fastapi import Depends, HTTPException
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, has_permission
from src.models.auth import User
from src.models.git import GitProvider, GitRepository
from src.api.routes.git_schemas import (
DeployRequest,
PromoteRequest,
PromoteResponse,
)
from ._router import router
from ._helpers import (
_apply_git_identity_from_profile,
_get_git_config_or_404,
_handle_unexpected_git_route_error,
)
from ._deps import get_git_service
# [DEF:sync_dashboard:Function]
# @COMPLEXITY: 3
# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin.
# @RELATION: CALLS -> [GitPlugin]
@router.post("/repositories/{dashboard_ref}/sync")
async def sync_dashboard(
dashboard_ref: str,
env_id: Optional[str] = None,
source_env_id: typing.Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("sync_dashboard"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
return await plugin.execute(
{
"operation": "sync",
"dashboard_id": dashboard_id,
"source_env_id": source_env_id,
}
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("sync_dashboard", e)
# [/DEF:sync_dashboard:Function]
# [DEF:promote_dashboard:Function]
# @COMPLEXITY: 3
# @PURPOSE: Promote changes between branches via MR or direct merge.
# @RELATION: CALLS -> [GitPlugin]
@router.post("/repositories/{dashboard_ref}/promote", response_model=PromoteResponse)
async def promote_dashboard(
dashboard_ref: str,
payload: PromoteRequest,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("promote_dashboard"):
from . import _resolve_dashboard_id_from_ref
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
db_repo = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if not db_repo:
raise HTTPException(
status_code=404,
detail=f"Repository for dashboard {dashboard_ref} is not initialized",
)
config = _get_git_config_or_404(db, db_repo.config_id)
from_branch = payload.from_branch.strip()
to_branch = payload.to_branch.strip()
if not from_branch or not to_branch:
raise HTTPException(
status_code=400, detail="from_branch and to_branch are required"
)
if from_branch == to_branch:
raise HTTPException(
status_code=400, detail="from_branch and to_branch must be different"
)
mode = (payload.mode or "mr").strip().lower()
if mode == "direct":
reason = (payload.reason or "").strip()
if not reason:
raise HTTPException(
status_code=400, detail="Direct promote requires non-empty reason"
)
logger.warning(
"[promote_dashboard][PolicyViolation] Direct promote without MR by actor=unknown dashboard_ref=%s from=%s to=%s reason=%s",
dashboard_ref,
from_branch,
to_branch,
reason,
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
result = _gs.promote_direct_merge(
dashboard_id=dashboard_id,
from_branch=from_branch,
to_branch=to_branch,
)
return PromoteResponse(
mode="direct",
from_branch=from_branch,
to_branch=to_branch,
status=result.get("status", "merged"),
policy_violation=True,
)
title = (payload.title or "").strip() or f"Promote {from_branch} -> {to_branch}"
description = payload.description
if config.provider == GitProvider.GITEA:
pr = await _gs.create_gitea_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
)
elif config.provider == GitProvider.GITHUB:
pr = await _gs.create_github_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
draft=payload.draft,
)
elif config.provider == GitProvider.GITLAB:
pr = await _gs.create_gitlab_merge_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
remove_source_branch=payload.remove_source_branch,
)
else:
raise HTTPException(
status_code=501,
detail=f"Provider {config.provider} does not support promotion API",
)
return PromoteResponse(
mode="mr",
from_branch=from_branch,
to_branch=to_branch,
status=pr.get("status", "opened"),
url=pr.get("url"),
reference_id=str(pr.get("id")) if pr.get("id") is not None else None,
policy_violation=False,
)
# [/DEF:promote_dashboard:Function]
# [DEF:deploy_dashboard:Function]
# @COMPLEXITY: 3
# @PURPOSE: Deploy dashboard from Git to a target environment.
# @RELATION: CALLS -> [GitPlugin]
@router.post("/repositories/{dashboard_ref}/deploy")
async def deploy_dashboard(
dashboard_ref: str,
deploy_data: DeployRequest,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("deploy_dashboard"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
return await plugin.execute(
{
"operation": "deploy",
"dashboard_id": dashboard_id,
"environment_id": deploy_data.environment_id,
}
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("deploy_dashboard", e)
# [/DEF:deploy_dashboard:Function]
# [/DEF:GitRepoLifecycleRoutes:Module]

View File

@@ -0,0 +1,365 @@
# [DEF:GitRepoOperationsRoutes:Module]
# @COMPLEXITY: 3
# @PURPOSE: FastAPI endpoints for Git repository operations (commit, push, pull, status, diff, history).
# @LAYER: API
# @SEMANTICS: git, repository, operations, commit, push, pull, status
from typing import List, Optional
from fastapi import Depends, HTTPException
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, has_permission
from src.models.auth import User
from src.models.git import GitRepository, GitServerConfig
from src.api.routes.git_schemas import (
CommitCreate,
CommitSchema,
RepoStatusBatchRequest,
RepoStatusBatchResponse,
)
from ._router import router
from ._helpers import (
_apply_git_identity_from_profile,
_build_no_repo_status_payload,
_handle_unexpected_git_route_error,
_resolve_repository_status,
)
from ._deps import get_git_service, MAX_REPOSITORY_STATUS_BATCH
# [DEF:commit_changes:Function]
# @COMPLEXITY: 2
# @PURPOSE: Stage and commit changes in the dashboard's repository.
@router.post("/repositories/{dashboard_ref}/commit")
async def commit_changes(
dashboard_ref: str,
commit_data: CommitCreate,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("commit_changes"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
_gs.commit_changes(
dashboard_id, commit_data.message, commit_data.files
)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("commit_changes", e)
# [/DEF:commit_changes:Function]
# [DEF:push_changes:Function]
# @COMPLEXITY: 2
# @PURPOSE: Push local commits to the remote repository.
@router.post("/repositories/{dashboard_ref}/push")
async def push_changes(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("push_changes"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_gs.push_changes(dashboard_id)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("push_changes", e)
# [/DEF:push_changes:Function]
# [DEF:pull_changes:Function]
# @COMPLEXITY: 3
# @PURPOSE: Pull changes from the remote repository.
# @RELATION: CALLS -> [GitService]
@router.post("/repositories/{dashboard_ref}/pull")
async def pull_changes(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("pull_changes"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
db_repo = None
config_url = None
config_provider = None
try:
db_repo_candidate = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if getattr(db_repo_candidate, "config_id", None):
db_repo = db_repo_candidate
config_row = (
db.query(GitServerConfig)
.filter(GitServerConfig.id == db_repo.config_id)
.first()
)
if config_row:
config_url = config_row.url
config_provider = config_row.provider
except Exception as diagnostics_error:
logger.warning(
"[pull_changes][Action] Failed to load repository binding diagnostics for dashboard %s: %s",
dashboard_id,
diagnostics_error,
)
logger.info(
"[pull_changes][Action] Route diagnostics dashboard_ref=%s env_id=%s resolved_dashboard_id=%s "
"binding_exists=%s binding_local_path=%s binding_remote_url=%s binding_config_id=%s config_provider=%s config_url=%s",
dashboard_ref,
env_id,
dashboard_id,
bool(db_repo),
(db_repo.local_path if db_repo else None),
(db_repo.remote_url if db_repo else None),
(db_repo.config_id if db_repo else None),
config_provider,
config_url,
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
_gs.pull_changes(dashboard_id)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("pull_changes", e)
# [/DEF:pull_changes:Function]
# [DEF:get_repository_status:Function]
# @COMPLEXITY: 2
# @PURPOSE: Get current Git status for a dashboard repository.
@router.get("/repositories/{dashboard_ref}/status")
async def get_repository_status(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("get_repository_status"):
from . import _resolve_dashboard_id_from_ref_async
try:
dashboard_id = await _resolve_dashboard_id_from_ref_async(
dashboard_ref, config_manager, env_id
)
return _resolve_repository_status(dashboard_id)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_repository_status", e)
# [/DEF:get_repository_status:Function]
# [DEF:get_repository_status_batch:Function]
# @COMPLEXITY: 2
# @PURPOSE: Get Git statuses for multiple dashboard repositories in one request.
@router.post("/repositories/status/batch", response_model=RepoStatusBatchResponse)
async def get_repository_status_batch(
request: RepoStatusBatchRequest,
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("get_repository_status_batch"):
dashboard_ids = list(dict.fromkeys(request.dashboard_ids))
if len(dashboard_ids) > MAX_REPOSITORY_STATUS_BATCH:
logger.warning(
"[get_repository_status_batch][Action] Batch size %s exceeds limit %s. Truncating request.",
len(dashboard_ids),
MAX_REPOSITORY_STATUS_BATCH,
)
dashboard_ids = dashboard_ids[:MAX_REPOSITORY_STATUS_BATCH]
statuses = {}
for dashboard_id in dashboard_ids:
try:
statuses[str(dashboard_id)] = _resolve_repository_status(dashboard_id)
except HTTPException:
statuses[str(dashboard_id)] = {
**_build_no_repo_status_payload(),
"sync_state": "ERROR",
"sync_status": "ERROR",
}
except Exception as e:
logger.error(
f"[get_repository_status_batch][Coherence:Failed] Failed for dashboard {dashboard_id}: {e}"
)
statuses[str(dashboard_id)] = {
**_build_no_repo_status_payload(),
"sync_state": "ERROR",
"sync_status": "ERROR",
}
return RepoStatusBatchResponse(statuses=statuses)
# [/DEF:get_repository_status_batch:Function]
# [DEF:get_repository_diff:Function]
# @COMPLEXITY: 2
# @PURPOSE: Get Git diff for a dashboard repository.
@router.get("/repositories/{dashboard_ref}/diff")
async def get_repository_diff(
dashboard_ref: str,
file_path: Optional[str] = None,
staged: bool = False,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("get_repository_diff"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.get_diff(dashboard_id, file_path, staged)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_repository_diff", e)
# [/DEF:get_repository_diff:Function]
# [DEF:get_history:Function]
# @COMPLEXITY: 2
# @PURPOSE: View commit history for a dashboard's repository.
@router.get("/repositories/{dashboard_ref}/history", response_model=List[CommitSchema])
async def get_history(
dashboard_ref: str,
limit: int = 50,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("get_history"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.get_commit_history(dashboard_id, limit)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_history", e)
# [/DEF:get_history:Function]
# [DEF:generate_commit_message:Function]
# @COMPLEXITY: 3
# @PURPOSE: Generate a suggested commit message using LLM.
# @RELATION: CALLS -> [GitService]
@router.post("/repositories/{dashboard_ref}/generate-message")
async def generate_commit_message(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("generate_commit_message"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
diff = _gs.get_diff(dashboard_id, staged=True)
if not diff:
diff = _gs.get_diff(dashboard_id, staged=False)
if not diff:
return {"message": "No changes detected"}
history_objs = _gs.get_commit_history(dashboard_id, limit=5)
history = [h.message for h in history_objs if hasattr(h, "message")]
from ...services.llm_provider import LLMProviderService
from ...plugins.llm_analysis.service import LLMClient
from ...plugins.llm_analysis.models import LLMProviderType
from ...services.llm_prompt_templates import (
DEFAULT_LLM_PROMPTS,
normalize_llm_settings,
resolve_bound_provider_id,
)
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
llm_settings = normalize_llm_settings(
config_manager.get_config().settings.llm
)
bound_provider_id = resolve_bound_provider_id(llm_settings, "git_commit")
provider = next((p for p in providers if p.id == bound_provider_id), None)
if not provider:
provider = next((p for p in providers if p.is_active), None)
if not provider:
raise HTTPException(
status_code=400, detail="No active LLM provider found"
)
api_key = llm_service.get_decrypted_api_key(provider.id)
client = LLMClient(
provider_type=LLMProviderType(provider.provider_type),
api_key=api_key,
base_url=provider.base_url,
default_model=provider.default_model,
)
from ...plugins.git.llm_extension import GitLLMExtension
extension = GitLLMExtension(client)
git_prompt = llm_settings["prompts"].get(
"git_commit_prompt",
DEFAULT_LLM_PROMPTS["git_commit_prompt"],
)
message = await extension.suggest_commit_message(
diff,
history,
prompt_template=git_prompt,
)
return {"message": message}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("generate_commit_message", e)
# [/DEF:generate_commit_message:Function]
# [/DEF:GitRepoOperationsRoutes:Module]

View File

@@ -0,0 +1,269 @@
# [DEF:GitRepoRoutes:Module]
# @COMPLEXITY: 3
# @PURPOSE: FastAPI endpoints for core Git repository operations (init, binding, branches, checkout).
# @LAYER: API
# @SEMANTICS: git, repository, routes, init, branches
from typing import List, Optional
from fastapi import Depends, HTTPException
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, has_permission
from src.models.auth import User
from src.models.git import GitRepository, GitServerConfig
from src.api.routes.git_schemas import (
BranchCheckout,
BranchCreate,
BranchSchema,
RepoInitRequest,
RepositoryBindingSchema,
)
from ._router import router
from ._helpers import (
_apply_git_identity_from_profile,
_get_git_config_or_404,
_handle_unexpected_git_route_error,
)
from ._deps import get_git_service
# [DEF:init_repository:Function]
# @COMPLEXITY: 3
# @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init.
# @RELATION: CALLS -> [GitService]
@router.post("/repositories/{dashboard_ref}/init")
async def init_repository(
dashboard_ref: str,
init_data: RepoInitRequest,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("init_repository"):
from . import _resolve_dashboard_id_from_ref, _resolve_repo_key_from_ref
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
repo_key = _resolve_repo_key_from_ref(
dashboard_ref, dashboard_id, config_manager, env_id
)
config = (
db.query(GitServerConfig)
.filter(GitServerConfig.id == init_data.config_id)
.first()
)
if not config:
raise HTTPException(status_code=404, detail="Git configuration not found")
try:
logger.info(
f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}"
)
_gs.init_repo(
dashboard_id,
init_data.remote_url,
config.pat,
repo_key=repo_key,
default_branch=config.default_branch,
)
repo_path = _gs._get_repo_path(dashboard_id, repo_key=repo_key)
db_repo = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if not db_repo:
db_repo = GitRepository(
dashboard_id=dashboard_id,
config_id=config.id,
remote_url=init_data.remote_url,
local_path=repo_path,
current_branch="dev",
)
db.add(db_repo)
else:
db_repo.config_id = config.id
db_repo.remote_url = init_data.remote_url
db_repo.local_path = repo_path
db_repo.current_branch = "dev"
db.commit()
logger.info(
f"[init_repository][Coherence:OK] Repository initialized for dashboard {dashboard_id}"
)
return {"status": "success", "message": "Repository initialized"}
except Exception as e:
db.rollback()
logger.error(
f"[init_repository][Coherence:Failed] Failed to init repository: {e}"
)
if isinstance(e, HTTPException):
raise
_handle_unexpected_git_route_error("init_repository", e)
# [/DEF:init_repository:Function]
# [DEF:get_repository_binding:Function]
# @COMPLEXITY: 2
# @PURPOSE: Return repository binding with provider metadata for selected dashboard.
@router.get("/repositories/{dashboard_ref}", response_model=RepositoryBindingSchema)
async def get_repository_binding(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("get_repository_binding"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
db_repo = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if not db_repo:
raise HTTPException(
status_code=404, detail="Repository not initialized"
)
config = _get_git_config_or_404(db, db_repo.config_id)
return RepositoryBindingSchema(
dashboard_id=db_repo.dashboard_id,
config_id=db_repo.config_id,
provider=config.provider,
remote_url=db_repo.remote_url,
local_path=db_repo.local_path,
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_repository_binding", e)
# [/DEF:get_repository_binding:Function]
# [DEF:delete_repository:Function]
# @COMPLEXITY: 2
# @PURPOSE: Delete local repository workspace and DB binding for selected dashboard.
@router.delete("/repositories/{dashboard_ref}")
async def delete_repository(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("delete_repository"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_gs.delete_repo(dashboard_id)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("delete_repository", e)
# [/DEF:delete_repository:Function]
# [DEF:get_branches:Function]
# @COMPLEXITY: 2
# @PURPOSE: List all branches for a dashboard's repository.
@router.get("/repositories/{dashboard_ref}/branches", response_model=List[BranchSchema])
async def get_branches(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("get_branches"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.list_branches(dashboard_id)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_branches", e)
# [/DEF:get_branches:Function]
# [DEF:create_branch:Function]
# @COMPLEXITY: 2
# @PURPOSE: Create a new branch in the dashboard's repository.
@router.post("/repositories/{dashboard_ref}/branches")
async def create_branch(
dashboard_ref: str,
branch_data: BranchCreate,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("create_branch"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
_gs.create_branch(
dashboard_id, branch_data.name, branch_data.from_branch
)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("create_branch", e)
# [/DEF:create_branch:Function]
# [DEF:checkout_branch:Function]
# @COMPLEXITY: 2
# @PURPOSE: Switch the dashboard's repository to a specific branch.
@router.post("/repositories/{dashboard_ref}/checkout")
async def checkout_branch(
dashboard_ref: str,
checkout_data: BranchCheckout,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("checkout_branch"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_gs.checkout_branch(dashboard_id, checkout_data.name)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("checkout_branch", e)
# [/DEF:checkout_branch:Function]
# [/DEF:GitRepoRoutes:Module]

View File

@@ -0,0 +1,10 @@
# [DEF:GitRouter:Module]
# @COMPLEXITY: 1
# @PURPOSE: Shared APIRouter for all Git route modules.
# @LAYER: API
# @SEMANTICS: git, routes, fastapi, router
from fastapi import APIRouter
router = APIRouter(tags=["git"])
# [/DEF:GitRouter:Module]

View File

@@ -0,0 +1,37 @@
# [DEF:TranslateRoutes:Module]
# @COMPLEXITY: 4
# @SEMANTICS: api, routes, translate
# @PURPOSE: API routes for LLM-based SQL/dashboard translation management including terminology dictionary CRUD and import.
# @LAYER: UI (API)
# @RELATION: DEPENDS_ON -> [TranslateJobResponse]
# @RELATION: DEPENDS_ON -> [DictionaryManager:Class]
# @RELATION: DEPENDS_ON -> [get_current_user]
# @RELATION: DEPENDS_ON -> [get_db]
# @RELATION: DEPENDS_ON -> [has_permission]
# @RELATION: DEPENDS_ON -> [ConfigManager]
# @RELATION: DEPENDS_ON -> [SupersetClient]
# @RATIONALE: Snapshot isolation — in-progress runs use config snapshot; config edits affect future runs only.
# @REJECTED: Invalidating in-progress runs on config edit would break scheduled run continuity.
"""
Translate routes package.
Re-exports the shared router and all route handler modules.
All sub-modules register their handlers on the router at import time.
"""
from ._router import router
from . import _job_routes # noqa: F401 — registers job handlers
from . import _run_routes # noqa: F401 — registers run handlers
from . import _run_list_routes # noqa: F401 — registers run list/detail/csv handlers
from . import _preview_routes # noqa: F401 — registers preview handlers
from . import _dictionary_routes # noqa: F401 — registers dictionary handlers
from . import _schedule_routes # noqa: F401 — registers schedule handlers
from . import _metrics_routes # noqa: F401 — registers metrics handlers
from . import _correction_routes # noqa: F401 — registers correction handlers
__all__ = [
"router",
]
# [/DEF:TranslateRoutes:Module]

View File

@@ -0,0 +1,97 @@
# [DEF:TranslateCorrectionRoutesModule:Module]
# @COMPLEXITY: 2
# @PURPOSE: Term correction submission endpoints.
# @LAYER: API
# @SEMANTICS: api, routes, translate, corrections
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission
from ....plugins.translate.dictionary import DictionaryManager
from ....schemas.translate import (
TermCorrectionSubmit,
TermCorrectionBulkSubmit,
CorrectionSubmitResponse,
)
from ._router import router
# ============================================================
# Corrections
# ============================================================
# [DEF:submit_correction:Function]
# @PURPOSE: Submit a term correction from a run result review.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Correction applied with conflict detection.
@router.post("/corrections")
async def submit_correction(
payload: TermCorrectionSubmit,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Submit a term correction from a run result review."""
logger.info(f"[translate_routes][submit_correction] User: {current_user.username}")
if not payload.dictionary_id:
raise HTTPException(status_code=422, detail="dictionary_id is required")
try:
result = DictionaryManager.submit_correction(
db,
dict_id=payload.dictionary_id,
source_term=payload.source_term,
incorrect_target_term=payload.incorrect_target_term,
corrected_target_term=payload.corrected_target_term,
origin_run_id=payload.origin_run_id,
origin_row_key=payload.origin_row_key,
origin_user_id=current_user.username,
)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:submit_correction:Function]
# [DEF:submit_bulk_corrections:Function]
# @PURPOSE: Submit multiple term corrections atomically.
# @PRE: User has translate.dictionary.edit permission.
# @POST: All corrections applied or none with conflict list.
@router.post("/corrections/bulk")
async def submit_bulk_corrections(
payload: TermCorrectionBulkSubmit,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Submit multiple term corrections atomically."""
logger.info(f"[translate_routes][submit_bulk_corrections] User: {current_user.username}")
try:
corr_list = []
for c in payload.corrections:
corr_list.append({
"source_term": c.source_term,
"incorrect_target_term": c.incorrect_target_term,
"corrected_target_term": c.corrected_target_term,
"origin_run_id": c.origin_run_id,
"origin_row_key": c.origin_row_key,
})
result = DictionaryManager.submit_bulk_corrections(
db,
dict_id=payload.dictionary_id,
corrections=corr_list,
origin_user_id=current_user.username,
)
if result.get("status") == "conflicts":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=result)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:submit_bulk_corrections:Function]
# [/DEF:TranslateCorrectionRoutesModule:Module]

View File

@@ -0,0 +1,356 @@
# [DEF:TranslateDictionaryRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Terminology Dictionary CRUD, entries management, and import routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, dictionaries
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission
from ....plugins.translate.dictionary import DictionaryManager
from ....schemas.translate import (
DictionaryCreate,
DictionaryEntryCreate,
DictionaryEntryResponse,
DictionaryImport,
DictionaryResponse,
)
from ._router import router
from ._helpers import _dict_to_response, _get_dictionary_entry_counts
# ============================================================
# Terminology Dictionaries
# ============================================================
# [DEF:list_dictionaries:Function]
# @PURPOSE: List all terminology dictionaries.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns list of dictionaries with total count.
@router.get("/dictionaries")
async def list_dictionaries(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "VIEW")),
db: Session = Depends(get_db),
):
"""List all terminology dictionaries."""
with belief_scope("list_dictionaries"):
logger.reason(f"User: {current_user.username}")
dicts, total = DictionaryManager.list_dictionaries(db, page=page, page_size=page_size)
dict_ids = [d.id for d in dicts]
counts = _get_dictionary_entry_counts(db, dict_ids) if dict_ids else {}
items = [_dict_to_response(d, counts.get(d.id, 0)) for d in dicts]
logger.reflect("Dictionaries listed", {"total": total, "returned": len(items)})
return {"items": items, "total": total, "page": page, "page_size": page_size}
# [/DEF:list_dictionaries:Function]
# [DEF:get_dictionary:Function]
# @PURPOSE: Get a single terminology dictionary by ID.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns the dictionary with entry_count.
@router.get("/dictionaries/{dictionary_id}")
async def get_dictionary(
dictionary_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "VIEW")),
db: Session = Depends(get_db),
):
"""Get a terminology dictionary by ID."""
with belief_scope("get_dictionary"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
d = DictionaryManager.get_dictionary(db, dictionary_id)
from ...models.translate import DictionaryEntry
entry_count = db.query(DictionaryEntry.id).filter(DictionaryEntry.dictionary_id == dictionary_id).count()
logger.reflect("Dictionary found", {"id": dictionary_id})
return _dict_to_response(d, entry_count)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_dictionary:Function]
# [DEF:create_dictionary:Function]
# @PURPOSE: Create a new terminology dictionary.
# @PRE: User has translate.dictionary.create permission.
# @POST: Returns the created dictionary.
@router.post("/dictionaries", status_code=status.HTTP_201_CREATED)
async def create_dictionary(
payload: DictionaryCreate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "CREATE")),
db: Session = Depends(get_db),
):
"""Create a new terminology dictionary."""
with belief_scope("create_dictionary"):
logger.reason(f"User: {current_user.username}")
d = DictionaryManager.create_dictionary(
db,
name=payload.name,
source_dialect=payload.source_dialect,
target_dialect=payload.target_dialect,
created_by=current_user.username,
description=payload.description,
is_active=payload.is_active,
)
logger.reflect("Dictionary created", {"id": d.id})
return _dict_to_response(d, 0)
# [/DEF:create_dictionary:Function]
# [DEF:update_dictionary:Function]
# @PURPOSE: Update an existing terminology dictionary.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Returns the updated dictionary.
@router.put("/dictionaries/{dictionary_id}")
async def update_dictionary(
dictionary_id: str,
payload: DictionaryCreate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Update a terminology dictionary."""
with belief_scope("update_dictionary"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
d = DictionaryManager.update_dictionary(
db,
dict_id=dictionary_id,
name=payload.name,
description=payload.description,
source_dialect=payload.source_dialect,
target_dialect=payload.target_dialect,
is_active=payload.is_active,
)
from ...models.translate import DictionaryEntry
entry_count = db.query(DictionaryEntry.id).filter(DictionaryEntry.dictionary_id == dictionary_id).count()
logger.reflect("Dictionary updated", {"id": dictionary_id})
return _dict_to_response(d, entry_count)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:update_dictionary:Function]
# [DEF:delete_dictionary:Function]
# @PURPOSE: Delete a terminology dictionary, blocked if attached to active/scheduled jobs.
# @PRE: User has translate.dictionary.delete permission.
# @POST: Dictionary is deleted.
@router.delete("/dictionaries/{dictionary_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dictionary(
dictionary_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "DELETE")),
db: Session = Depends(get_db),
):
"""Delete a terminology dictionary."""
with belief_scope("delete_dictionary"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
DictionaryManager.delete_dictionary(db, dictionary_id)
logger.reflect("Dictionary deleted", {"id": dictionary_id})
return None
except ValueError as e:
if "active/scheduled" in str(e):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:delete_dictionary:Function]
# ============================================================
# Dictionary Entries CRUD
# ============================================================
# [DEF:list_dictionary_entries:Function]
# @PURPOSE: List entries for a dictionary.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns paginated list of entries.
@router.get("/dictionaries/{dictionary_id}/entries")
async def list_dictionary_entries(
dictionary_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=500),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "VIEW")),
db: Session = Depends(get_db),
):
"""List entries for a dictionary."""
with belief_scope("list_dictionary_entries"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
entries, total = DictionaryManager.list_entries(db, dictionary_id, page=page, page_size=page_size)
items = [
{
"id": e.id,
"dictionary_id": e.dictionary_id,
"source_term": e.source_term,
"source_term_normalized": e.source_term_normalized,
"target_term": e.target_term,
"context_notes": e.context_notes,
"created_at": e.created_at,
"updated_at": e.updated_at,
}
for e in entries
]
logger.reflect("Entries listed", {"dict_id": dictionary_id, "total": total})
return {"items": items, "total": total, "page": page, "page_size": page_size}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:list_dictionary_entries:Function]
# [DEF:add_dictionary_entry:Function]
# @PURPOSE: Add a new entry to a dictionary.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is created.
@router.post("/dictionaries/{dictionary_id}/entries", status_code=status.HTTP_201_CREATED)
async def add_dictionary_entry(
dictionary_id: str,
payload: DictionaryEntryCreate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Add a new entry to a dictionary."""
with belief_scope("add_dictionary_entry"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
entry = DictionaryManager.add_entry(
db, dictionary_id,
source_term=payload.source_term,
target_term=payload.target_term,
context_notes=payload.context_notes,
)
logger.reflect("Entry added", {"entry_id": entry.id})
return {
"id": entry.id,
"dictionary_id": entry.dictionary_id,
"source_term": entry.source_term,
"source_term_normalized": entry.source_term_normalized,
"target_term": entry.target_term,
"context_notes": entry.context_notes,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
except ValueError as e:
if "already exists" in str(e):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:add_dictionary_entry:Function]
# [DEF:edit_dictionary_entry:Function]
# @PURPOSE: Update an existing dictionary entry.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is updated.
@router.put("/dictionaries/{dictionary_id}/entries/{entry_id}")
async def edit_dictionary_entry(
dictionary_id: str,
entry_id: str,
payload: DictionaryEntryCreate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Update an existing dictionary entry."""
with belief_scope("edit_dictionary_entry"):
logger.reason(f"Entry: {entry_id}, User: {current_user.username}")
try:
entry = DictionaryManager.edit_entry(
db, entry_id,
source_term=payload.source_term,
target_term=payload.target_term,
context_notes=payload.context_notes,
)
logger.reflect("Entry updated", {"entry_id": entry_id})
return {
"id": entry.id,
"dictionary_id": entry.dictionary_id,
"source_term": entry.source_term,
"source_term_normalized": entry.source_term_normalized,
"target_term": entry.target_term,
"context_notes": entry.context_notes,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
except ValueError as e:
if "already exists" in str(e):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:edit_dictionary_entry:Function]
# [DEF:delete_dictionary_entry:Function]
# @PURPOSE: Delete a dictionary entry.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is deleted.
@router.delete("/dictionaries/{dictionary_id}/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dictionary_entry(
dictionary_id: str,
entry_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Delete a dictionary entry."""
with belief_scope("delete_dictionary_entry"):
logger.reason(f"Entry: {entry_id}, User: {current_user.username}")
try:
DictionaryManager.delete_entry(db, entry_id)
logger.reflect("Entry deleted", {"entry_id": entry_id})
return None
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:delete_dictionary_entry:Function]
# [DEF:import_dictionary_entries:Function]
# @PURPOSE: Import entries into a terminology dictionary from CSV/TSV content.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entries are imported per conflict mode.
@router.post("/dictionaries/{dictionary_id}/import")
async def import_dictionary_entries(
dictionary_id: str,
payload: DictionaryImport,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.dictionary", "EDIT")),
db: Session = Depends(get_db),
):
"""Import entries into a terminology dictionary from CSV/TSV content."""
with belief_scope("import_dictionary_entries"):
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
if payload.on_conflict not in ("overwrite", "keep_existing", "cancel"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="on_conflict must be 'overwrite', 'keep_existing', or 'cancel'",
)
try:
result = DictionaryManager.import_entries(
db,
dict_id=dictionary_id,
content=payload.content,
delimiter=payload.delimiter,
on_conflict=payload.on_conflict,
preview_only=payload.preview_only,
)
logger.reflect("Import completed", {
"dict_id": dictionary_id,
"total": result["total"],
"errors": len(result["errors"]),
})
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:import_dictionary_entries:Function]
# [/DEF:TranslateDictionaryRoutesModule:Module]

View File

@@ -0,0 +1,75 @@
# [DEF:TranslateHelpersModule:Module]
# @COMPLEXITY: 2
# @PURPOSE: Shared helper functions for translate route handlers.
# @LAYER: API
# @SEMANTICS: api, routes, translate, helpers
# @RELATION: DEPENDS_ON -> [models.translate]
from typing import Any, Dict, List
from sqlalchemy.orm import Session
# [DEF:_run_to_response:Function]
# @COMPLEXITY: 2
# @PURPOSE: Convert TranslationRun ORM to response dict.
def _run_to_response(run: Any) -> dict:
return {
"id": run.id,
"job_id": run.job_id,
"status": run.status,
"trigger_type": run.trigger_type,
"started_at": run.started_at.isoformat() if run.started_at else None,
"completed_at": run.completed_at.isoformat() if run.completed_at else None,
"error_message": run.error_message,
"total_records": run.total_records or 0,
"successful_records": run.successful_records or 0,
"failed_records": run.failed_records or 0,
"skipped_records": run.skipped_records or 0,
"insert_status": run.insert_status,
"superset_execution_id": run.superset_execution_id,
"config_snapshot": run.config_snapshot,
"key_hash": run.key_hash,
"config_hash": run.config_hash,
"dict_snapshot_hash": run.dict_snapshot_hash,
"created_by": run.created_by,
"created_at": run.created_at.isoformat() if run.created_at else None,
}
# [/DEF:_run_to_response:Function]
# [DEF:_dict_to_response:Function]
# @COMPLEXITY: 2
# @PURPOSE: Convert a TerminologyDictionary ORM model to a response dict with computed entry_count.
@staticmethod
def _dict_to_response(d: Any, entry_count: int = 0) -> dict:
return {
"id": d.id,
"name": d.name,
"description": d.description,
"source_dialect": d.source_dialect,
"target_dialect": d.target_dialect,
"is_active": d.is_active,
"created_by": d.created_by,
"created_at": d.created_at,
"updated_at": d.updated_at,
"entry_count": entry_count,
}
# [/DEF:_dict_to_response:Function]
# [DEF:_get_dictionary_entry_counts:Function]
# @COMPLEXITY: 2
# @PURPOSE: Get entry counts for a list of dictionary IDs.
def _get_dictionary_entry_counts(db: Session, dict_ids: List[str]) -> Dict[str, int]:
from sqlalchemy import func
from ...models.translate import DictionaryEntry
counts = (
db.query(DictionaryEntry.dictionary_id, func.count(DictionaryEntry.id))
.filter(DictionaryEntry.dictionary_id.in_(dict_ids))
.group_by(DictionaryEntry.dictionary_id)
.all()
)
return {row[0]: row[1] for row in counts}
# [/DEF:_get_dictionary_entry_counts:Function]
# [/DEF:TranslateHelpersModule:Module]

View File

@@ -0,0 +1,250 @@
# [DEF:TranslateJobRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Translation Job CRUD and datasource column routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, jobs
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.service import (
TranslateJobService,
job_to_response,
get_datasource_columns as fetch_datasource_columns_service,
)
from ....schemas.translate import (
TranslateJobCreate,
TranslateJobUpdate,
TranslateJobResponse,
DatasourceColumnsResponse,
DuplicateJobResponse,
)
from ._router import router
# ============================================================
# Translation Job CRUD
# ============================================================
# [DEF:list_jobs:Function]
# @PURPOSE: List all translation jobs.
# @PRE: User has translate.job.view permission.
# @POST: Returns list of translation jobs.
@router.get("/jobs", response_model=List[TranslateJobResponse])
async def list_jobs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""List all translation jobs."""
logger.info(f"[translate_routes][list_jobs] User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
total, jobs = service.list_jobs(
page=page,
page_size=page_size,
status_filter=status,
)
results = []
for job in jobs:
dict_ids = service.get_job_dictionary_ids(job.id)
results.append(job_to_response(job, dict_ids))
return results
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:list_jobs:Function]
# [DEF:get_job:Function]
# @PURPOSE: Get a single translation job by ID.
# @PRE: User has translate.job.view permission.
# @POST: Returns the translation job.
@router.get("/jobs/{job_id}", response_model=TranslateJobResponse)
async def get_job(
job_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get a translation job by ID."""
logger.info(f"[translate_routes][get_job] Job: {job_id}, User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
job = service.get_job(job_id)
dict_ids = service.get_job_dictionary_ids(job.id)
return job_to_response(job, dict_ids)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_job:Function]
# [DEF:create_job:Function]
# @PURPOSE: Create a new translation job.
# @PRE: User has translate.job.create permission.
# @POST: Returns the created translation job.
# @SIDE_EFFECT: Validates columns via SupersetClient; caches database_dialect.
@router.post("/jobs", response_model=TranslateJobResponse, status_code=status.HTTP_201_CREATED)
async def create_job(
payload: TranslateJobCreate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "CREATE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Create a new translation job."""
logger.info(f"[translate_routes][create_job] User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
job = service.create_job(payload)
dict_ids = service.get_job_dictionary_ids(job.id)
return job_to_response(job, dict_ids)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
# [/DEF:create_job:Function]
# [DEF:update_job:Function]
# @PURPOSE: Update an existing translation job.
# @PRE: User has translate.job.edit permission.
# @POST: Returns the updated translation job.
# @SIDE_EFFECT: Re-detects database_dialect if datasource changed.
@router.put("/jobs/{job_id}", response_model=TranslateJobResponse)
async def update_job(
job_id: str,
payload: TranslateJobUpdate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EDIT")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Update a translation job."""
logger.info(f"[translate_routes][update_job] Job: {job_id}, User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
job = service.update_job(job_id, payload)
dict_ids = service.get_job_dictionary_ids(job.id)
return job_to_response(job, dict_ids)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:update_job:Function]
# [DEF:delete_job:Function]
# @PURPOSE: Delete a translation job.
# @PRE: User has translate.job.delete permission.
# @POST: Job is deleted.
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_job(
job_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "DELETE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Delete a translation job."""
logger.info(f"[translate_routes][delete_job] Job: {job_id}, User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
service.delete_job(job_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:delete_job:Function]
# [DEF:duplicate_job:Function]
# @PURPOSE: Duplicate a translation job.
# @PRE: User has translate.job.create permission.
# @POST: Returns the duplicated job with status DRAFT.
@router.post("/jobs/{job_id}/duplicate", response_model=DuplicateJobResponse, status_code=status.HTTP_201_CREATED)
async def duplicate_job(
job_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "CREATE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Duplicate a translation job."""
logger.info(f"[translate_routes][duplicate_job] Job: {job_id}, User: {current_user.username}")
try:
service = TranslateJobService(db, config_manager, current_user.username)
new_job = service.duplicate_job(job_id)
return DuplicateJobResponse(
id=new_job.id,
name=new_job.name,
message="Job duplicated successfully",
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:duplicate_job:Function]
# [DEF:get_datasource_columns:Function]
# @PURPOSE: Get column metadata and database dialect for a Superset datasource.
# @PRE: User has translate.job.view permission.
# @POST: Returns column list with metadata and database_dialect.
# @SIDE_EFFECT: Queries Superset API for dataset detail and database info.
# @RATIONALE: Dialect detection from Superset connection cached on TranslationJob at save time.
# @REJECTED: Hardcoding dialect list per database — would drift from actual Superset config.
@router.get("/datasources")
async def list_datasources(
env_id: str = Query(..., description="Superset environment ID"),
search: Optional[str] = Query(None, description="Search by table name"),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""List all Superset datasets available for translation jobs."""
with belief_scope("list_datasources"):
try:
service = TranslateJobService(db, config_manager)
datasets = service.fetch_available_datasources(env_id, search)
return datasets
except Exception as e:
logger.error(f"[translate_routes][list_datasources] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
@router.get("/datasources/{datasource_id}/columns", response_model=DatasourceColumnsResponse)
async def get_datasource_columns(
datasource_id: int,
env_id: str = Query(..., description="Superset environment ID"),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "VIEW")),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get column metadata and database dialect for a Superset datasource."""
logger.info(
f"[translate_routes][get_datasource_columns] "
f"Datasource: {datasource_id}, Env: {env_id}, User: {current_user.username}"
)
try:
result = fetch_datasource_columns_service(
datasource_id=datasource_id,
env_id=env_id,
config_manager=config_manager,
)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"[translate_routes][get_datasource_columns] Error: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to fetch datasource columns from Superset: {e}",
)
# [/DEF:get_datasource_columns:Function]
# [/DEF:TranslateJobRoutesModule:Module]

View File

@@ -0,0 +1,67 @@
# [DEF:TranslateMetricsRoutesModule:Module]
# @COMPLEXITY: 2
# @PURPOSE: Translation Metrics endpoints.
# @LAYER: API
# @SEMANTICS: api, routes, translate, metrics
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission
from ....plugins.translate.metrics import TranslationMetrics
from ._router import router
# ============================================================
# Metrics
# ============================================================
# [DEF:get_metrics:Function]
# @PURPOSE: Get translation metrics, optionally filtered by job.
# @PRE: User has translate.metrics.view permission.
# @POST: Returns metrics data.
@router.get("/metrics")
async def get_metrics(
job_id: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.metrics", "VIEW")),
db: Session = Depends(get_db),
):
"""Get translation metrics."""
logger.info(f"[translate_routes][get_metrics] User: {current_user.username}")
try:
metrics_svc = TranslationMetrics(db)
if job_id:
return metrics_svc.get_job_metrics(job_id)
return metrics_svc.get_all_metrics()
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_metrics:Function]
# [DEF:get_job_metrics:Function]
# @PURPOSE: Get aggregated metrics for a specific job.
# @PRE: User has translate.metrics.view permission.
# @POST: Returns metrics dict.
@router.get("/jobs/{job_id}/metrics")
async def get_job_metrics(
job_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.metrics", "VIEW")),
db: Session = Depends(get_db),
):
"""Get aggregated metrics for a specific job."""
logger.info(f"[translate_routes][get_job_metrics] Job: {job_id}, User: {current_user.username}")
try:
metrics_svc = TranslationMetrics(db)
return metrics_svc.get_job_metrics(job_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_job_metrics:Function]
# [/DEF:TranslateMetricsRoutesModule:Module]

View File

@@ -0,0 +1,194 @@
# [DEF:TranslatePreviewRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Translation Preview session management routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, preview
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.preview import TranslationPreview
from ....schemas.translate import (
PreviewRequest,
PreviewRowUpdate,
PreviewAcceptResponse,
PreviewRow,
)
from ._router import router
# ============================================================
# Preview
# ============================================================
# [DEF:preview_translation:Function]
# @PURPOSE: Preview a translation before applying it.
# @PRE: User has translate.job.execute permission.
# @POST: Returns a preview session with records and cost estimation.
# @SIDE_EFFECT: Fetches sample data from Superset; calls LLM provider; creates DB rows.
@router.post("/jobs/{job_id}/preview", status_code=status.HTTP_201_CREATED)
async def preview_translation(
job_id: str,
payload: PreviewRequest = None,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Preview a translation before applying it."""
logger.info(f"[translate_routes][preview_translation] Job: {job_id}, User: {current_user.username}")
if payload is None:
payload = PreviewRequest()
try:
preview_service = TranslationPreview(db, config_manager, current_user.username)
result = preview_service.preview_rows(
job_id=job_id,
sample_size=payload.sample_size,
prompt_template=payload.prompt_template,
)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"[translate_routes][preview_translation] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Preview failed: {e}")
# [/DEF:preview_translation:Function]
# [DEF:update_preview_row:Function]
# @PURPOSE: Approve, edit, or reject a preview row.
# @PRE: User has translate.job.execute permission.
# @POST: Preview row status is updated.
@router.put("/jobs/{job_id}/preview/rows/{row_key}")
async def update_preview_row(
job_id: str,
row_key: str,
payload: PreviewRowUpdate,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Approve, edit, or reject a preview row."""
logger.info(f"[translate_routes][update_preview_row] Job: {job_id}, Row: {row_key}, Action: {payload.action}")
try:
preview_service = TranslationPreview(db, config_manager, current_user.username)
result = preview_service.update_preview_row(
job_id=job_id,
row_id=row_key,
action=payload.action,
translation=payload.translation,
feedback=payload.feedback,
)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:update_preview_row:Function]
# [DEF:accept_preview_session:Function]
# @PURPOSE: Accept a preview session, marking it as the quality gate for full execution.
# @PRE: User has translate.job.execute permission. Job has an ACTIVE preview session.
# @POST: Preview session is marked as APPLIED; full execution can proceed.
@router.post("/jobs/{job_id}/preview/accept")
async def accept_preview_session(
job_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Accept a preview session, enabling full translation execution."""
logger.info(f"[translate_routes][accept_preview_session] Job: {job_id}, User: {current_user.username}")
try:
preview_service = TranslationPreview(db, config_manager, current_user.username)
result = preview_service.accept_preview_session(job_id=job_id)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:accept_preview_session:Function]
# [DEF:apply_preview:Function]
# @PURPOSE: Apply a preview session (alias for accept when accepting at session level).
# @PRE: User has translate.job.execute permission.
# @POST: Preview is applied.
@router.post("/preview/{session_id}/apply")
async def apply_preview(
session_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Apply a preview session by session ID."""
logger.info(f"[translate_routes][apply_preview] Session: {session_id}, User: {current_user.username}")
# Find job_id from session
from ...models.translate import TranslationPreviewSession
session = db.query(TranslationPreviewSession).filter(TranslationPreviewSession.id == session_id).first()
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Preview session '{session_id}' not found")
try:
preview_service = TranslationPreview(db, config_manager, current_user.username)
result = preview_service.accept_preview_session(job_id=session.job_id)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:apply_preview:Function]
# ============================================================
# Preview Records
# ============================================================
# [DEF:get_preview_records:Function]
# @PURPOSE: Get records for a preview session.
# @PRE: User has translate.job.view permission.
# @POST: Returns list of preview records.
@router.get("/preview/{session_id}/records", response_model=List[PreviewRow])
async def get_preview_records(
session_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "VIEW")),
db: Session = Depends(get_db),
):
"""Get records for a preview session."""
logger.info(f"[translate_routes][get_preview_records] Session: {session_id}, User: {current_user.username}")
try:
from ...models.translate import TranslationPreviewSession, TranslationPreviewRecord
session = db.query(TranslationPreviewSession).filter(TranslationPreviewSession.id == session_id).first()
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Preview session '{session_id}' not found")
records = (
db.query(TranslationPreviewRecord)
.filter(TranslationPreviewRecord.session_id == session_id)
.all()
)
return [
PreviewRow(
id=r.id,
source_sql=r.source_sql,
target_sql=r.target_sql,
source_object_type=r.source_object_type,
source_object_id=r.source_object_id,
source_object_name=r.source_object_name,
status=r.status,
feedback=r.feedback,
)
for r in records
]
except HTTPException:
raise
except Exception as e:
logger.error(f"[translate_routes][get_preview_records] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:get_preview_records:Function]
# [/DEF:TranslatePreviewRoutesModule:Module]

View File

@@ -0,0 +1,14 @@
# [DEF:TranslateRouterModule:Module]
# @COMPLEXITY: 1
# @PURPOSE: APIRouter instance for translate routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, router
from fastapi import APIRouter
# [DEF:translate_router:Global]
# @PURPOSE: APIRouter instance for all translate sub-routes.
router = APIRouter(prefix="/api/translate", tags=["translate"])
# [/DEF:translate_router:Global]
# [/DEF:TranslateRouterModule:Module]

View File

@@ -0,0 +1,206 @@
# [DEF:TranslateRunListRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Translation Run listing, detail, and CSV download routes (cross-job).
# @LAYER: API
# @SEMANTICS: api, routes, translate, runs, list, detail
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.orchestrator import TranslationOrchestrator
from ....plugins.translate.events import TranslationEventLog
from ._router import router
# ============================================================
# List Runs (cross-job)
# ============================================================
# [DEF:list_runs:Function]
# @PURPOSE: List all runs with cross-job filtering and pagination.
# @PRE: User has translate.history.view permission.
# @POST: Returns paginated list of runs.
@router.get("/runs")
async def list_runs(
job_id: Optional[str] = Query(None),
run_status: Optional[str] = Query(None),
trigger_type: Optional[str] = Query(None),
created_by: Optional[str] = Query(None),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.history", "VIEW")),
db: Session = Depends(get_db),
):
"""List all runs with cross-job filtering and pagination."""
logger.info(f"[translate_routes][list_runs] User: {current_user.username}")
try:
from ...models.translate import TranslationRun
query = db.query(TranslationRun)
if job_id:
query = query.filter(TranslationRun.job_id == job_id)
if run_status:
query = query.filter(TranslationRun.status == run_status)
if trigger_type:
query = query.filter(TranslationRun.trigger_type == trigger_type)
if created_by:
query = query.filter(TranslationRun.created_by == created_by)
if date_from:
try:
from datetime import datetime
dt_from = datetime.fromisoformat(date_from)
query = query.filter(TranslationRun.created_at >= dt_from)
except ValueError:
pass
if date_to:
try:
from datetime import datetime
dt_to = datetime.fromisoformat(date_to)
query = query.filter(TranslationRun.created_at <= dt_to)
except ValueError:
pass
total = query.count()
runs = (
query.order_by(TranslationRun.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
items = []
for r in runs:
items.append({
"id": r.id,
"job_id": r.job_id,
"status": r.status,
"trigger_type": r.trigger_type,
"started_at": r.started_at.isoformat() if r.started_at else None,
"completed_at": r.completed_at.isoformat() if r.completed_at else None,
"error_message": r.error_message,
"total_records": r.total_records or 0,
"successful_records": r.successful_records or 0,
"failed_records": r.failed_records or 0,
"skipped_records": r.skipped_records or 0,
"insert_status": r.insert_status,
"superset_execution_id": r.superset_execution_id,
"config_hash": r.config_hash,
"dict_snapshot_hash": r.dict_snapshot_hash,
"created_by": r.created_by,
"created_at": r.created_at.isoformat() if r.created_at else None,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
except Exception as e:
logger.error(f"[translate_routes][list_runs] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:list_runs:Function]
# ============================================================
# Run Detail
# ============================================================
# [DEF:get_run_detail:Function]
# @PURPOSE: Get detailed run info with config_snapshot, records, events.
# @PRE: User has translate.history.view permission.
# @POST: Returns run detail with records and events.
@router.get("/runs/{run_id}/detail")
async def get_run_detail(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.history", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get detailed run info with config_snapshot, records, events."""
logger.info(f"[translate_routes][get_run_detail] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
status_info = orch.get_run_status(run_id)
records = orch.get_run_records(run_id, page=1, page_size=50)
events = TranslationEventLog(db).query_events(run_id=run_id)
event_summary = TranslationEventLog(db).get_run_event_summary(run_id)
# Get config snapshot from run
from ...models.translate import TranslationRun
run = db.query(TranslationRun).filter(TranslationRun.id == run_id).first()
config_snapshot = run.config_snapshot if run else None
return {
**status_info,
"config_snapshot": config_snapshot,
"config_hash": run.config_hash if run else None,
"records": records,
"events": events,
"event_invariants": event_summary,
}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_run_detail:Function]
# ============================================================
# Download Skipped CSV
# ============================================================
# [DEF:download_skipped_csv:Function]
# @PURPOSE: Download a CSV of skipped translation records from a run.
# @PRE: User has translate.job.view permission.
# @POST: Returns CSV file of skipped records.
@router.get("/runs/{run_id}/skipped.csv")
async def download_skipped_csv(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "VIEW")),
db: Session = Depends(get_db),
):
"""Download skipped translation records as CSV."""
logger.info(f"[translate_routes][download_skipped_csv] Run: {run_id}, User: {current_user.username}")
try:
from ...models.translate import TranslationRecord
from fastapi.responses import StreamingResponse
import csv
import io
records = (
db.query(TranslationRecord)
.filter(
TranslationRecord.run_id == run_id,
TranslationRecord.status == "SKIPPED",
)
.all()
)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "source_sql", "target_sql", "source_object_type",
"source_object_id", "source_object_name", "error_message"])
for rec in records:
writer.writerow([
rec.id, rec.source_sql, rec.target_sql, rec.source_object_type,
rec.source_object_id, rec.source_object_name, rec.error_message,
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=skipped_{run_id}.csv"},
)
except Exception as e:
logger.error(f"[translate_routes][download_skipped_csv] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:download_skipped_csv:Function]
# [/DEF:TranslateRunListRoutesModule:Module]

View File

@@ -0,0 +1,252 @@
# [DEF:TranslateRunRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Translation Run execution, history, status, records and batches routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, runs
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.orchestrator import TranslationOrchestrator
from ....plugins.translate.events import TranslationEventLog
from ._router import router
from ._helpers import _run_to_response
# ============================================================
# Translation Run / Execute
# ============================================================
# [DEF:run_translation:Function]
# @PURPOSE: Execute a translation job (trigger a run).
# @PRE: User has translate.job.execute permission.
# @POST: Returns the created translation run.
@router.post("/jobs/{job_id}/run", status_code=status.HTTP_201_CREATED)
async def run_translation(
job_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Execute a translation job (trigger a run)."""
logger.info(f"[translate_routes][run_translation] Job: {job_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
run = orch.start_run(job_id=job_id, is_scheduled=False)
# Execute asynchronously in background
import threading
threading.Thread(
target=orch.execute_run,
args=(run,),
daemon=True,
).start()
return _run_to_response(run)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"[translate_routes][run_translation] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Run failed: {e}")
# [/DEF:run_translation:Function]
# [DEF:retry_run:Function]
# @PURPOSE: Retry failed batches in a translation run.
# @PRE: User has translate.job.execute permission.
# @POST: Returns the updated translation run.
@router.post("/runs/{run_id}/retry")
async def retry_run(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Retry failed batches in a translation run."""
logger.info(f"[translate_routes][retry_run] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
run = orch.retry_failed_batches(run_id)
return _run_to_response(run)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"[translate_routes][retry_run] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Retry failed: {e}")
# [/DEF:retry_run:Function]
# [DEF:retry_insert:Function]
# @PURPOSE: Retry the SQL insert phase for a completed run.
# @PRE: User has translate.job.execute permission.
# @POST: Returns the updated run.
@router.post("/runs/{run_id}/retry-insert")
async def retry_insert(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Retry the SQL insert phase for a completed run."""
logger.info(f"[translate_routes][retry_insert] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
run = orch.retry_insert(run_id)
return _run_to_response(run)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"[translate_routes][retry_insert] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Retry insert failed: {e}")
# [/DEF:retry_insert:Function]
# [DEF:cancel_run:Function]
# @PURPOSE: Cancel a running translation.
# @PRE: User has translate.job.execute permission.
# @POST: Run is cancelled.
@router.post("/runs/{run_id}/cancel")
async def cancel_run(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Cancel a running translation."""
logger.info(f"[translate_routes][cancel_run] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
run = orch.cancel_run(run_id)
return _run_to_response(run)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:cancel_run:Function]
# [DEF:get_run_history:Function]
# @PURPOSE: Get run history for a translation job.
# @PRE: User has translate.history.view permission.
# @POST: Returns list of runs.
@router.get("/jobs/{job_id}/runs")
async def get_run_history(
job_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.history", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get run history for a translation job."""
logger.info(f"[translate_routes][get_run_history] Job: {job_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
total, runs = orch.get_run_history(job_id, page=page, page_size=page_size)
return {"items": runs, "total": total, "page": page, "page_size": page_size}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_run_history:Function]
# [DEF:get_run_status:Function]
# @PURPOSE: Get status and statistics for a translation run.
# @PRE: User has translate.history.view permission.
# @POST: Returns run details with statistics.
@router.get("/runs/{run_id}")
async def get_run_status(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.history", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get status and statistics for a translation run."""
logger.info(f"[translate_routes][get_run_status] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
return orch.get_run_status(run_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_run_status:Function]
# [DEF:get_run_records:Function]
# @PURPOSE: Get paginated records for a translation run.
# @PRE: User has translate.history.view permission.
# @POST: Returns paginated records.
@router.get("/runs/{run_id}/records")
async def get_run_records(
run_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=500),
status: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.history", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get paginated records for a translation run."""
logger.info(f"[translate_routes][get_run_records] Run: {run_id}, User: {current_user.username}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
return orch.get_run_records(run_id, page=page, page_size=page_size, status_filter=status)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_run_records:Function]
# ============================================================
# Batches
# ============================================================
# [DEF:get_batches:Function]
# @PURPOSE: Get batches for a translation run.
# @PRE: User has translate.job.view permission.
# @POST: Returns list of batches.
@router.get("/runs/{run_id}/batches")
async def get_batches(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "VIEW")),
db: Session = Depends(get_db),
):
"""Get batches for a translation run."""
logger.info(f"[translate_routes][get_batches] Run: {run_id}, User: {current_user.username}")
try:
from ...models.translate import TranslationBatch
batches = (
db.query(TranslationBatch)
.filter(TranslationBatch.run_id == run_id)
.order_by(TranslationBatch.batch_index.asc())
.all()
)
return [
{
"id": b.id,
"run_id": b.run_id,
"batch_index": b.batch_index,
"status": b.status,
"total_records": b.total_records or 0,
"successful_records": b.successful_records or 0,
"failed_records": b.failed_records or 0,
"started_at": b.started_at.isoformat() if b.started_at else None,
"completed_at": b.completed_at.isoformat() if b.completed_at else None,
"created_at": b.created_at.isoformat() if b.created_at else None,
}
for b in batches
]
except Exception as e:
logger.error(f"[translate_routes][get_batches] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:get_batches:Function]
# [/DEF:TranslateRunRoutesModule:Module]

View File

@@ -0,0 +1,235 @@
# [DEF:TranslateScheduleRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Translation Schedule management routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, schedule
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.scheduler import TranslationScheduler
from ....schemas.translate import ScheduleConfig, ScheduleResponse
from ._router import router
# ============================================================
# Schedule
# ============================================================
# [DEF:get_schedule:Function]
# @PURPOSE: Get the schedule for a translation job.
# @PRE: User has translate.schedule.view permission.
# @POST: Returns the schedule configuration.
@router.get("/jobs/{job_id}/schedule")
async def get_schedule(
job_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.schedule", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Get schedule for a translation job."""
logger.info(f"[translate_routes][get_schedule] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
schedule = scheduler.get_schedule(job_id)
return {
"id": schedule.id,
"job_id": schedule.job_id,
"cron_expression": schedule.cron_expression,
"timezone": schedule.timezone,
"is_active": schedule.is_active,
"last_run_at": schedule.last_run_at.isoformat() if schedule.last_run_at else None,
"next_run_at": schedule.next_run_at.isoformat() if schedule.next_run_at else None,
"created_by": schedule.created_by,
"created_at": schedule.created_at.isoformat() if schedule.created_at else None,
"updated_at": schedule.updated_at.isoformat() if schedule.updated_at else None,
}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_schedule:Function]
# [DEF:set_schedule:Function]
# @PURPOSE: Set or update the schedule for a translation job.
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is created or updated.
@router.put("/jobs/{job_id}/schedule")
async def set_schedule(
job_id: str,
payload: ScheduleConfig,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.schedule", "MANAGE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Set or update schedule for a translation job."""
logger.info(f"[translate_routes][set_schedule] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
# Check if schedule already exists
from ...models.translate import TranslationSchedule
existing = db.query(TranslationSchedule).filter(
TranslationSchedule.job_id == job_id
).first()
if existing:
schedule = scheduler.update_schedule(
job_id,
cron_expression=payload.cron_expression,
timezone=payload.timezone,
is_active=payload.is_active,
)
else:
schedule = scheduler.create_schedule(
job_id,
cron_expression=payload.cron_expression,
timezone=payload.timezone,
is_active=payload.is_active,
)
# Register with APScheduler via SchedulerService
from ...dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.add_translation_job(
schedule_id=schedule.id,
job_id=job_id,
cron_expression=schedule.cron_expression,
timezone=schedule.timezone,
)
return {
"id": schedule.id,
"job_id": schedule.job_id,
"cron_expression": schedule.cron_expression,
"timezone": schedule.timezone,
"is_active": schedule.is_active,
"last_run_at": schedule.last_run_at.isoformat() if schedule.last_run_at else None,
"created_by": schedule.created_by,
"created_at": schedule.created_at.isoformat() if schedule.created_at else None,
"updated_at": schedule.updated_at.isoformat() if schedule.updated_at else None,
}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:set_schedule:Function]
# [DEF:enable_schedule:Function]
# @PURPOSE: Enable a schedule for a translation job.
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is enabled.
@router.post("/jobs/{job_id}/schedule/enable")
async def enable_schedule(
job_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.schedule", "MANAGE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Enable a schedule for a translation job."""
logger.info(f"[translate_routes][enable_schedule] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
schedule = scheduler.set_schedule_active(job_id, is_active=True)
from ...dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.add_translation_job(
schedule_id=schedule.id,
job_id=job_id,
cron_expression=schedule.cron_expression,
timezone=schedule.timezone,
)
return {"status": "enabled", "job_id": job_id}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:enable_schedule:Function]
# [DEF:disable_schedule:Function]
# @PURPOSE: Disable a schedule for a translation job.
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is disabled.
@router.post("/jobs/{job_id}/schedule/disable")
async def disable_schedule(
job_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.schedule", "MANAGE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Disable a schedule for a translation job."""
logger.info(f"[translate_routes][disable_schedule] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
scheduler.set_schedule_active(job_id, is_active=False)
from ...dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.remove_translation_job(schedule_id=job_id)
return {"status": "disabled", "job_id": job_id}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:disable_schedule:Function]
# [DEF:delete_schedule:Function]
# @PURPOSE: Delete the schedule for a translation job.
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is removed.
@router.delete("/jobs/{job_id}/schedule", status_code=status.HTTP_204_NO_CONTENT)
async def delete_schedule(
job_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.schedule", "MANAGE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Delete schedule for a translation job."""
logger.info(f"[translate_routes][delete_schedule] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
scheduler.delete_schedule(job_id)
from ...dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.remove_translation_job(schedule_id=job_id)
return None
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:delete_schedule:Function]
# [DEF:get_next_executions:Function]
# @PURPOSE: Preview next N executions for a job's schedule.
# @PRE: User has translate.schedule.view permission.
# @POST: Returns next execution times.
@router.get("/jobs/{job_id}/schedule/next-executions")
async def get_next_executions(
job_id: str,
n: int = Query(3, ge=1, le=10),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.schedule", "VIEW")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Preview next N executions for a job's schedule."""
logger.info(f"[translate_routes][get_next_executions] Job: {job_id}, User: {current_user.username}")
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
schedule = scheduler.get_schedule(job_id)
next_times = TranslationScheduler.get_next_executions(
schedule.cron_expression, schedule.timezone, n=n
)
return {
"job_id": job_id,
"cron_expression": schedule.cron_expression,
"timezone": schedule.timezone,
"next_executions": next_times,
}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_next_executions:Function]
# [/DEF:TranslateScheduleRoutesModule:Module]