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:
@@ -37,6 +37,7 @@ __all__ = [
|
||||
"dashboards",
|
||||
"datasets",
|
||||
"health",
|
||||
"translate",
|
||||
]
|
||||
# [/DEF:Route_Group_Contracts:Block]
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
305
backend/src/api/routes/assistant/_admin_routes.py
Normal file
305
backend/src/api/routes/assistant/_admin_routes.py
Normal 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]
|
||||
@@ -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]
|
||||
|
||||
290
backend/src/api/routes/assistant/_dataset_review_dispatch.py
Normal file
290
backend/src/api/routes/assistant/_dataset_review_dispatch.py
Normal 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]
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
173
backend/src/api/routes/assistant/_llm_planner_intent.py
Normal file
173
backend/src/api/routes/assistant/_llm_planner_intent.py
Normal 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]
|
||||
@@ -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]
|
||||
|
||||
@@ -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
44
backend/src/api/routes/dashboards/__init__.py
Normal file
44
backend/src/api/routes/dashboards/__init__.py
Normal 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]
|
||||
176
backend/src/api/routes/dashboards/_action_routes.py
Normal file
176
backend/src/api/routes/dashboards/_action_routes.py
Normal 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]
|
||||
392
backend/src/api/routes/dashboards/_detail_routes.py
Normal file
392
backend/src/api/routes/dashboards/_detail_routes.py
Normal 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]
|
||||
187
backend/src/api/routes/dashboards/_helpers.py
Normal file
187
backend/src/api/routes/dashboards/_helpers.py
Normal 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]
|
||||
398
backend/src/api/routes/dashboards/_listing_routes.py
Normal file
398
backend/src/api/routes/dashboards/_listing_routes.py
Normal 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]
|
||||
260
backend/src/api/routes/dashboards/_projection.py
Normal file
260
backend/src/api/routes/dashboards/_projection.py
Normal 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]
|
||||
8
backend/src/api/routes/dashboards/_router.py
Normal file
8
backend/src/api/routes/dashboards/_router.py
Normal 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]
|
||||
232
backend/src/api/routes/dashboards/_schemas.py
Normal file
232
backend/src/api/routes/dashboards/_schemas.py
Normal 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
44
backend/src/api/routes/git/__init__.py
Normal file
44
backend/src/api/routes/git/__init__.py
Normal 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]
|
||||
159
backend/src/api/routes/git/_config_routes.py
Normal file
159
backend/src/api/routes/git/_config_routes.py
Normal 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]
|
||||
24
backend/src/api/routes/git/_deps.py
Normal file
24
backend/src/api/routes/git/_deps.py
Normal 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]
|
||||
36
backend/src/api/routes/git/_environment_routes.py
Normal file
36
backend/src/api/routes/git/_environment_routes.py
Normal 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]
|
||||
188
backend/src/api/routes/git/_gitea_routes.py
Normal file
188
backend/src/api/routes/git/_gitea_routes.py
Normal 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]
|
||||
345
backend/src/api/routes/git/_helpers.py
Normal file
345
backend/src/api/routes/git/_helpers.py
Normal 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]
|
||||
169
backend/src/api/routes/git/_merge_routes.py
Normal file
169
backend/src/api/routes/git/_merge_routes.py
Normal 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]
|
||||
228
backend/src/api/routes/git/_repo_lifecycle_routes.py
Normal file
228
backend/src/api/routes/git/_repo_lifecycle_routes.py
Normal 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]
|
||||
365
backend/src/api/routes/git/_repo_operations_routes.py
Normal file
365
backend/src/api/routes/git/_repo_operations_routes.py
Normal 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]
|
||||
269
backend/src/api/routes/git/_repo_routes.py
Normal file
269
backend/src/api/routes/git/_repo_routes.py
Normal 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]
|
||||
10
backend/src/api/routes/git/_router.py
Normal file
10
backend/src/api/routes/git/_router.py
Normal 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]
|
||||
37
backend/src/api/routes/translate/__init__.py
Normal file
37
backend/src/api/routes/translate/__init__.py
Normal 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]
|
||||
97
backend/src/api/routes/translate/_correction_routes.py
Normal file
97
backend/src/api/routes/translate/_correction_routes.py
Normal 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]
|
||||
356
backend/src/api/routes/translate/_dictionary_routes.py
Normal file
356
backend/src/api/routes/translate/_dictionary_routes.py
Normal 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]
|
||||
75
backend/src/api/routes/translate/_helpers.py
Normal file
75
backend/src/api/routes/translate/_helpers.py
Normal 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]
|
||||
250
backend/src/api/routes/translate/_job_routes.py
Normal file
250
backend/src/api/routes/translate/_job_routes.py
Normal 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]
|
||||
67
backend/src/api/routes/translate/_metrics_routes.py
Normal file
67
backend/src/api/routes/translate/_metrics_routes.py
Normal 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]
|
||||
194
backend/src/api/routes/translate/_preview_routes.py
Normal file
194
backend/src/api/routes/translate/_preview_routes.py
Normal 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]
|
||||
14
backend/src/api/routes/translate/_router.py
Normal file
14
backend/src/api/routes/translate/_router.py
Normal 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]
|
||||
206
backend/src/api/routes/translate/_run_list_routes.py
Normal file
206
backend/src/api/routes/translate/_run_list_routes.py
Normal 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]
|
||||
252
backend/src/api/routes/translate/_run_routes.py
Normal file
252
backend/src/api/routes/translate/_run_routes.py
Normal 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]
|
||||
235
backend/src/api/routes/translate/_schedule_routes.py
Normal file
235
backend/src/api/routes/translate/_schedule_routes.py
Normal 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]
|
||||
Reference in New Issue
Block a user