linter + новые таски
This commit is contained in:
@@ -1 +1,3 @@
|
||||
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage, admin
|
||||
|
||||
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin']
|
||||
|
||||
@@ -21,8 +21,8 @@ from ...schemas.auth import (
|
||||
RoleSchema, RoleCreate, RoleUpdate, PermissionSchema,
|
||||
ADGroupMappingSchema, ADGroupMappingCreate
|
||||
)
|
||||
from ...models.auth import User, Role, Permission, ADGroupMapping
|
||||
from ...dependencies import has_permission, get_current_user
|
||||
from ...models.auth import User, Role, ADGroupMapping
|
||||
from ...dependencies import has_permission
|
||||
from ...core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from ...core.database import get_db
|
||||
from ...models.connection import ConnectionConfig
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from ...core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
105
backend/src/api/routes/dashboards.py
Normal file
105
backend/src/api/routes/dashboards.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# [DEF:backend.src.api.routes.dashboards:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: api, dashboards, resources, hub
|
||||
# @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status
|
||||
# @LAYER: API
|
||||
# @RELATION: DEPENDS_ON -> backend.src.dependencies
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.resource_service
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
|
||||
#
|
||||
# @INVARIANT: All dashboard responses include git_status and last_task metadata
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission
|
||||
from ...core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# [DEF:GitStatus:DataClass]
|
||||
class GitStatus(BaseModel):
|
||||
branch: Optional[str] = None
|
||||
sync_status: Optional[str] = Field(None, pattern="^OK|DIFF$")
|
||||
# [/DEF:GitStatus:DataClass]
|
||||
|
||||
# [DEF:LastTask:DataClass]
|
||||
class LastTask(BaseModel):
|
||||
task_id: Optional[str] = None
|
||||
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
|
||||
# [/DEF:LastTask:DataClass]
|
||||
|
||||
# [DEF:DashboardItem:DataClass]
|
||||
class DashboardItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
slug: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
last_modified: Optional[str] = None
|
||||
git_status: Optional[GitStatus] = None
|
||||
last_task: Optional[LastTask] = None
|
||||
# [/DEF:DashboardItem:DataClass]
|
||||
|
||||
# [DEF:DashboardsResponse:DataClass]
|
||||
class DashboardsResponse(BaseModel):
|
||||
dashboards: List[DashboardItem]
|
||||
total: int
|
||||
# [/DEF:DashboardsResponse:DataClass]
|
||||
|
||||
# [DEF:get_dashboards:Function]
|
||||
# @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
|
||||
# @POST: Returns a list of dashboards with enhanced metadata
|
||||
# @PARAM: env_id (str) - The environment ID to fetch dashboards from
|
||||
# @PARAM: search (Optional[str]) - Filter by title/slug
|
||||
# @RETURN: DashboardsResponse - List of dashboards with status metadata
|
||||
# @RELATION: CALLS -> ResourceService.get_dashboards_with_status
|
||||
@router.get("/api/dashboards", response_model=DashboardsResponse)
|
||||
async def get_dashboards(
|
||||
env_id: str,
|
||||
search: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
task_manager=Depends(get_task_manager),
|
||||
resource_service=Depends(get_resource_service),
|
||||
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||
):
|
||||
with belief_scope("get_dashboards", f"env_id={env_id}, search={search}"):
|
||||
# Validate environment exists
|
||||
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")
|
||||
|
||||
try:
|
||||
# Get all tasks for status lookup
|
||||
all_tasks = task_manager.get_all_tasks()
|
||||
|
||||
# Fetch dashboards with status using ResourceService
|
||||
dashboards = await resource_service.get_dashboards_with_status(env, all_tasks)
|
||||
|
||||
# Apply search filter if provided
|
||||
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()
|
||||
]
|
||||
|
||||
logger.info(f"[get_dashboards][Coherence:OK] Returning {len(dashboards)} dashboards")
|
||||
|
||||
return DashboardsResponse(
|
||||
dashboards=dashboards,
|
||||
total=len(dashboards)
|
||||
)
|
||||
|
||||
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:backend.src.api.routes.dashboards:Module]
|
||||
103
backend/src/api/routes/datasets.py
Normal file
103
backend/src/api/routes/datasets.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# [DEF:backend.src.api.routes.datasets:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: api, datasets, resources, hub
|
||||
# @PURPOSE: API endpoints for the Dataset Hub - listing datasets with mapping progress
|
||||
# @LAYER: API
|
||||
# @RELATION: DEPENDS_ON -> backend.src.dependencies
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.resource_service
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
|
||||
#
|
||||
# @INVARIANT: All dataset responses include last_task metadata
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission
|
||||
from ...core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# [DEF:MappedFields:DataClass]
|
||||
class MappedFields(BaseModel):
|
||||
total: int
|
||||
mapped: int
|
||||
# [/DEF:MappedFields:DataClass]
|
||||
|
||||
# [DEF:LastTask:DataClass]
|
||||
class LastTask(BaseModel):
|
||||
task_id: Optional[str] = None
|
||||
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
|
||||
# [/DEF:LastTask:DataClass]
|
||||
|
||||
# [DEF:DatasetItem:DataClass]
|
||||
class DatasetItem(BaseModel):
|
||||
id: int
|
||||
table_name: str
|
||||
schema: str
|
||||
database: str
|
||||
mapped_fields: Optional[MappedFields] = None
|
||||
last_task: Optional[LastTask] = None
|
||||
# [/DEF:DatasetItem:DataClass]
|
||||
|
||||
# [DEF:DatasetsResponse:DataClass]
|
||||
class DatasetsResponse(BaseModel):
|
||||
datasets: List[DatasetItem]
|
||||
total: int
|
||||
# [/DEF:DatasetsResponse:DataClass]
|
||||
|
||||
# [DEF:get_datasets:Function]
|
||||
# @PURPOSE: Fetch list of datasets from a specific environment with mapping progress
|
||||
# @PRE: env_id must be a valid environment ID
|
||||
# @POST: Returns a list of datasets with enhanced metadata
|
||||
# @PARAM: env_id (str) - The environment ID to fetch datasets from
|
||||
# @PARAM: search (Optional[str]) - Filter by table name
|
||||
# @RETURN: DatasetsResponse - List of datasets with status metadata
|
||||
# @RELATION: CALLS -> ResourceService.get_datasets_with_status
|
||||
@router.get("/api/datasets", response_model=DatasetsResponse)
|
||||
async def get_datasets(
|
||||
env_id: str,
|
||||
search: Optional[str] = None,
|
||||
config_manager=Depends(get_config_manager),
|
||||
task_manager=Depends(get_task_manager),
|
||||
resource_service=Depends(get_resource_service),
|
||||
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||
):
|
||||
with belief_scope("get_datasets", f"env_id={env_id}, search={search}"):
|
||||
# Validate environment exists
|
||||
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_datasets][Coherence:Failed] Environment not found: {env_id}")
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
try:
|
||||
# Get all tasks for status lookup
|
||||
all_tasks = task_manager.get_all_tasks()
|
||||
|
||||
# Fetch datasets with status using ResourceService
|
||||
datasets = await resource_service.get_datasets_with_status(env, all_tasks)
|
||||
|
||||
# Apply search filter if provided
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
datasets = [
|
||||
d for d in datasets
|
||||
if search_lower in d.get('table_name', '').lower()
|
||||
]
|
||||
|
||||
logger.info(f"[get_datasets][Coherence:OK] Returning {len(datasets)} datasets")
|
||||
|
||||
return DatasetsResponse(
|
||||
datasets=datasets,
|
||||
total=len(datasets)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[get_datasets][Coherence:Failed] Failed to fetch datasets: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Failed to fetch datasets: {str(e)}")
|
||||
# [/DEF:get_datasets:Function]
|
||||
|
||||
# [/DEF:backend.src.api.routes.datasets:Module]
|
||||
@@ -11,11 +11,10 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List, Dict, Optional
|
||||
from typing import List, Optional
|
||||
from ...dependencies import get_config_manager, get_scheduler_service, has_permission
|
||||
from ...core.superset_client import SupersetClient
|
||||
from pydantic import BaseModel, Field
|
||||
from ...core.config_models import Environment as EnvModel
|
||||
from ...core.logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ from typing import List, Optional
|
||||
import typing
|
||||
from src.dependencies import get_config_manager, has_permission
|
||||
from src.core.database import get_db
|
||||
from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository
|
||||
from src.models.git import GitServerConfig, GitRepository
|
||||
from src.api.routes.git_schemas import (
|
||||
GitServerConfigSchema, GitServerConfigCreate,
|
||||
GitRepositorySchema, BranchSchema, BranchCreate,
|
||||
BranchSchema, BranchCreate,
|
||||
BranchCheckout, CommitSchema, CommitCreate,
|
||||
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from src.models.git import GitProvider, GitStatus, SyncStatus
|
||||
|
||||
# [DEF:GitServerConfigBase:Class]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.dashboard
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List, Dict
|
||||
from typing import List
|
||||
from ...dependencies import get_config_manager, get_task_manager, has_permission
|
||||
from ...models.dashboard import DashboardMetadata, DashboardSelection
|
||||
from ...core.superset_client import SupersetClient
|
||||
|
||||
@@ -17,9 +17,8 @@ from ...core.config_models import AppConfig, Environment, GlobalSettings, Loggin
|
||||
from ...models.storage import StorageConfig
|
||||
from ...dependencies import get_config_manager, has_permission
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger, belief_scope, get_task_log_level
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
import os
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:LoggingConfigResponse:Class]
|
||||
@@ -279,4 +278,37 @@ async def update_logging_config(
|
||||
)
|
||||
# [/DEF:update_logging_config:Function]
|
||||
|
||||
# [DEF:ConsolidatedSettingsResponse:Class]
|
||||
class ConsolidatedSettingsResponse(BaseModel):
|
||||
environments: List[dict]
|
||||
connections: List[dict]
|
||||
llm: dict
|
||||
logging: dict
|
||||
storage: dict
|
||||
# [/DEF:ConsolidatedSettingsResponse:Class]
|
||||
|
||||
# [DEF:get_consolidated_settings:Function]
|
||||
# @PURPOSE: Retrieves all settings categories in a single call
|
||||
# @PRE: Config manager is available.
|
||||
# @POST: Returns all consolidated settings.
|
||||
# @RETURN: ConsolidatedSettingsResponse - All settings categories.
|
||||
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
|
||||
async def get_consolidated_settings(
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("admin:settings", "READ"))
|
||||
):
|
||||
with belief_scope("get_consolidated_settings"):
|
||||
logger.info("[get_consolidated_settings][Entry] Fetching all consolidated settings")
|
||||
|
||||
config = config_manager.get_config()
|
||||
|
||||
return ConsolidatedSettingsResponse(
|
||||
environments=config.environments,
|
||||
connections=config.settings.connections,
|
||||
llm=config.settings.llm,
|
||||
logging=config.settings.logging,
|
||||
storage=config.settings.storage
|
||||
)
|
||||
# [/DEF:get_consolidated_settings:Function]
|
||||
|
||||
# [/DEF:SettingsRouter:Module]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# @RELATION: Depends on the TaskManager. It is included by the main app.
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from ...core.logger import belief_scope
|
||||
|
||||
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
|
||||
|
||||
@@ -6,26 +6,23 @@
|
||||
# @RELATION: Depends on the dependency module and API route modules.
|
||||
# @INVARIANT: Only one FastAPI app instance exists per process.
|
||||
# @INVARIANT: All WebSocket connections must be properly cleaned up on disconnect.
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# project_root is used for static files mounting
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.utils.network import NetworkError
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets
|
||||
from .api import auth
|
||||
from .core.database import init_db
|
||||
|
||||
# [DEF:App:Global]
|
||||
# @SEMANTICS: app, fastapi, instance
|
||||
@@ -124,6 +121,8 @@ app.include_router(migration.router)
|
||||
app.include_router(git.router)
|
||||
app.include_router(llm.router)
|
||||
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
|
||||
app.include_router(dashboards.router, tags=["Dashboards"])
|
||||
app.include_router(datasets.router, tags=["Datasets"])
|
||||
|
||||
# [DEF:websocket_endpoint:Function]
|
||||
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering.
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
# [SECTION: IMPORTS]
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:AuthConfig:Class]
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from jose import JWTError, jwt
|
||||
from typing import Optional
|
||||
from jose import jwt
|
||||
from .config import auth_config
|
||||
from ..logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from ...models.auth import User, Role, Permission, ADGroupMapping
|
||||
from ...models.auth import User, Role, Permission
|
||||
from ..logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from .config_models import AppConfig, Environment, GlobalSettings
|
||||
from .config_models import AppConfig, Environment, GlobalSettings, StorageConfig
|
||||
from .logger import logger, configure_logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
@@ -46,7 +46,7 @@ class ConfigManager:
|
||||
# 3. Runtime check of @POST
|
||||
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
|
||||
|
||||
logger.info(f"[ConfigManager][Exit] Initialized")
|
||||
logger.info("[ConfigManager][Exit] Initialized")
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:_load_config:Function]
|
||||
@@ -59,7 +59,7 @@ class ConfigManager:
|
||||
logger.debug(f"[_load_config][Entry] Loading from {self.config_path}")
|
||||
|
||||
if not self.config_path.exists():
|
||||
logger.info(f"[_load_config][Action] Config file not found. Creating default.")
|
||||
logger.info("[_load_config][Action] Config file not found. Creating default.")
|
||||
default_config = AppConfig(
|
||||
environments=[],
|
||||
settings=GlobalSettings()
|
||||
@@ -75,7 +75,7 @@ class ConfigManager:
|
||||
del data["settings"]["backup_path"]
|
||||
|
||||
config = AppConfig(**data)
|
||||
logger.info(f"[_load_config][Coherence:OK] Configuration loaded")
|
||||
logger.info("[_load_config][Coherence:OK] Configuration loaded")
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error(f"[_load_config][Coherence:Failed] Error loading config: {e}")
|
||||
@@ -103,7 +103,7 @@ class ConfigManager:
|
||||
try:
|
||||
with open(self.config_path, "w") as f:
|
||||
json.dump(config.dict(), f, indent=4)
|
||||
logger.info(f"[_save_config_to_disk][Action] Configuration saved")
|
||||
logger.info("[_save_config_to_disk][Action] Configuration saved")
|
||||
except Exception as e:
|
||||
logger.error(f"[_save_config_to_disk][Coherence:Failed] Failed to save: {e}")
|
||||
# [/DEF:_save_config_to_disk:Function]
|
||||
@@ -134,7 +134,7 @@ class ConfigManager:
|
||||
# @PARAM: settings (GlobalSettings) - The new global settings.
|
||||
def update_global_settings(self, settings: GlobalSettings):
|
||||
with belief_scope("update_global_settings"):
|
||||
logger.info(f"[update_global_settings][Entry] Updating settings")
|
||||
logger.info("[update_global_settings][Entry] Updating settings")
|
||||
|
||||
# 1. Runtime check of @PRE
|
||||
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
|
||||
@@ -146,7 +146,7 @@ class ConfigManager:
|
||||
# Reconfigure logger with new settings
|
||||
configure_logger(settings.logging)
|
||||
|
||||
logger.info(f"[update_global_settings][Exit] Settings updated")
|
||||
logger.info("[update_global_settings][Exit] Settings updated")
|
||||
# [/DEF:update_global_settings:Function]
|
||||
|
||||
# [DEF:validate_path:Function]
|
||||
@@ -222,7 +222,7 @@ class ConfigManager:
|
||||
self.config.environments.append(env)
|
||||
self.save()
|
||||
|
||||
logger.info(f"[add_environment][Exit] Environment added")
|
||||
logger.info("[add_environment][Exit] Environment added")
|
||||
# [/DEF:add_environment:Function]
|
||||
|
||||
# [DEF:update_environment:Function]
|
||||
|
||||
@@ -11,14 +11,9 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from ..models.mapping import Base
|
||||
# Import models to ensure they're registered with Base
|
||||
from ..models.task import TaskRecord
|
||||
from ..models.connection import ConnectionConfig
|
||||
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
|
||||
from ..models.auth import User, Role, Permission, ADGroupMapping
|
||||
from ..models.llm import LLMProvider, ValidationRecord
|
||||
from .logger import belief_scope
|
||||
from .auth.config import auth_config
|
||||
import os
|
||||
|
||||
@@ -111,7 +111,6 @@ def configure_logger(config):
|
||||
|
||||
# Add file handler if file_path is set
|
||||
if config.file_path:
|
||||
import os
|
||||
from pathlib import Path
|
||||
log_file = Path(config.file_path)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -11,12 +11,10 @@
|
||||
import zipfile
|
||||
import yaml
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
from .logger import logger, belief_scope
|
||||
import yaml
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:MigrationEngine:Class]
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import sys # Added this line
|
||||
from typing import Dict, Type, List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
from .plugin_base import PluginBase, PluginConfig
|
||||
from jsonschema import validate
|
||||
from .logger import belief_scope
|
||||
|
||||
# [DEF:PluginLoader:Class]
|
||||
|
||||
@@ -10,7 +10,6 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from .logger import logger, belief_scope
|
||||
from .config_manager import ConfigManager
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union, cast
|
||||
from typing import Dict, List, Optional, Tuple, Union, cast
|
||||
from requests import Response
|
||||
from .logger import logger as app_logger, belief_scope
|
||||
from .utils.network import APIClient, SupersetAPIError, AuthenticationError, DashboardNotFoundError, NetworkError
|
||||
from .utils.network import APIClient, SupersetAPIError
|
||||
from .utils.fileio import get_filename_from_headers
|
||||
from .config_models import Environment
|
||||
# [/SECTION]
|
||||
@@ -212,6 +212,30 @@ class SupersetClient:
|
||||
return total_count, paginated_data
|
||||
# [/DEF:get_datasets:Function]
|
||||
|
||||
# [DEF:get_datasets_summary:Function]
|
||||
# @PURPOSE: Fetches dataset metadata optimized for the Dataset Hub grid.
|
||||
# @PRE: Client is authenticated.
|
||||
# @POST: Returns a list of dataset metadata summaries.
|
||||
# @RETURN: List[Dict]
|
||||
def get_datasets_summary(self) -> List[Dict]:
|
||||
with belief_scope("SupersetClient.get_datasets_summary"):
|
||||
query = {
|
||||
"columns": ["id", "table_name", "schema", "database"]
|
||||
}
|
||||
_, datasets = self.get_datasets(query=query)
|
||||
|
||||
# Map fields to match the contracts
|
||||
result = []
|
||||
for ds in datasets:
|
||||
result.append({
|
||||
"id": ds.get("id"),
|
||||
"table_name": ds.get("table_name"),
|
||||
"schema": ds.get("schema"),
|
||||
"database": ds.get("database", {}).get("database_name", "Unknown")
|
||||
})
|
||||
return result
|
||||
# [/DEF:get_datasets_summary:Function]
|
||||
|
||||
# [DEF:get_dataset:Function]
|
||||
# @PURPOSE: Получает информацию о конкретном датасете по его ID.
|
||||
# @PARAM: dataset_id (int) - ID датасета.
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
# @LAYER: Core
|
||||
# @RELATION: Uses TaskPersistenceService and TaskLogPersistenceService to delete old tasks and logs.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from .persistence import TaskPersistenceService, TaskLogPersistenceService
|
||||
from ..logger import logger, belief_scope
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# @INVARIANT: Each TaskContext is bound to a single task execution.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from typing import Dict, Any, Callable
|
||||
from .task_logger import TaskLogger
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from .models import Task, TaskStatus, LogEntry, LogFilter, LogStats, TaskLog
|
||||
from .models import Task, TaskStatus, LogEntry, LogFilter, LogStats
|
||||
from .persistence import TaskPersistenceService, TaskLogPersistenceService
|
||||
from .context import TaskContext
|
||||
from ..logger import logger, belief_scope, should_log_task_level
|
||||
@@ -136,7 +136,7 @@ class TaskManager:
|
||||
logger.error(f"Plugin with ID '{plugin_id}' not found.")
|
||||
raise ValueError(f"Plugin with ID '{plugin_id}' not found.")
|
||||
|
||||
plugin = self.plugin_loader.get_plugin(plugin_id)
|
||||
self.plugin_loader.get_plugin(plugin_id)
|
||||
|
||||
if not isinstance(params, dict):
|
||||
logger.error("Task parameters must be a dictionary.")
|
||||
@@ -248,7 +248,8 @@ class TaskManager:
|
||||
async def wait_for_resolution(self, task_id: str):
|
||||
with belief_scope("TaskManager.wait_for_resolution", f"task_id={task_id}"):
|
||||
task = self.tasks.get(task_id)
|
||||
if not task: return
|
||||
if not task:
|
||||
return
|
||||
|
||||
task.status = TaskStatus.AWAITING_MAPPING
|
||||
self.persistence_service.persist_task(task)
|
||||
@@ -269,7 +270,8 @@ class TaskManager:
|
||||
async def wait_for_input(self, task_id: str):
|
||||
with belief_scope("TaskManager.wait_for_input", f"task_id={task_id}"):
|
||||
task = self.tasks.get(task_id)
|
||||
if not task: return
|
||||
if not task:
|
||||
return
|
||||
|
||||
# Status is already set to AWAITING_INPUT by await_input()
|
||||
self.task_futures[task_id] = self.loop.create_future()
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional
|
||||
import json
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from ...models.task import TaskRecord, TaskLogRecord
|
||||
from ..database import TasksSessionLocal
|
||||
from .models import Task, TaskStatus, LogEntry, TaskLog, LogFilter, LogStats
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from datetime import datetime
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:TaskLogger:Class]
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# [SECTION: IMPORTS]
|
||||
import pandas as pd # type: ignore
|
||||
import psycopg2 # type: ignore
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Dict, Optional, Any
|
||||
from ..logger import logger as app_logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from datetime import date, datetime
|
||||
import shutil
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
import yaml
|
||||
from ..logger import logger as app_logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -177,7 +177,8 @@ class APIClient:
|
||||
# @POST: Returns headers including auth tokens.
|
||||
def headers(self) -> Dict[str, str]:
|
||||
with belief_scope("headers"):
|
||||
if not self._authenticated: self.authenticate()
|
||||
if not self._authenticated:
|
||||
self.authenticate()
|
||||
return {
|
||||
"Authorization": f"Bearer {self._tokens['access_token']}",
|
||||
"X-CSRFToken": self._tokens.get("csrf_token", ""),
|
||||
@@ -200,7 +201,8 @@ class APIClient:
|
||||
with belief_scope("request"):
|
||||
full_url = f"{self.base_url}{endpoint}"
|
||||
_headers = self.headers.copy()
|
||||
if headers: _headers.update(headers)
|
||||
if headers:
|
||||
_headers.update(headers)
|
||||
|
||||
try:
|
||||
response = self.session.request(method, full_url, headers=_headers, **kwargs)
|
||||
@@ -223,9 +225,12 @@ class APIClient:
|
||||
status_code = e.response.status_code
|
||||
if status_code == 502 or status_code == 503 or status_code == 504:
|
||||
raise NetworkError(f"Environment unavailable (Status {status_code})", status_code=status_code) from e
|
||||
if status_code == 404: raise DashboardNotFoundError(endpoint) from e
|
||||
if status_code == 403: raise PermissionDeniedError() from e
|
||||
if status_code == 401: raise AuthenticationError() from e
|
||||
if status_code == 404:
|
||||
raise DashboardNotFoundError(endpoint) from e
|
||||
if status_code == 403:
|
||||
raise PermissionDeniedError() from e
|
||||
if status_code == 401:
|
||||
raise AuthenticationError() from e
|
||||
raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e
|
||||
# [/DEF:_handle_http_error:Function]
|
||||
|
||||
@@ -237,9 +242,12 @@ class APIClient:
|
||||
# @POST: Raises a NetworkError.
|
||||
def _handle_network_error(self, e: requests.exceptions.RequestException, url: str):
|
||||
with belief_scope("_handle_network_error"):
|
||||
if isinstance(e, requests.exceptions.Timeout): msg = "Request timeout"
|
||||
elif isinstance(e, requests.exceptions.ConnectionError): msg = "Connection error"
|
||||
else: msg = f"Unknown network error: {e}"
|
||||
if isinstance(e, requests.exceptions.Timeout):
|
||||
msg = "Request timeout"
|
||||
elif isinstance(e, requests.exceptions.ConnectionError):
|
||||
msg = "Connection error"
|
||||
else:
|
||||
msg = f"Unknown network error: {e}"
|
||||
raise NetworkError(msg, url=url) from e
|
||||
# [/DEF:_handle_network_error:Function]
|
||||
|
||||
@@ -256,7 +264,9 @@ class APIClient:
|
||||
def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict:
|
||||
with belief_scope("upload_file"):
|
||||
full_url = f"{self.base_url}{endpoint}"
|
||||
_headers = self.headers.copy(); _headers.pop('Content-Type', None)
|
||||
_headers = self.headers.copy()
|
||||
_headers.pop('Content-Type', None)
|
||||
|
||||
|
||||
file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file")
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
# @RELATION: Used by the main app and API routers to get access to shared instances.
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
@@ -13,8 +12,9 @@ from .core.plugin_loader import PluginLoader
|
||||
from .core.task_manager import TaskManager
|
||||
from .core.config_manager import ConfigManager
|
||||
from .core.scheduler import SchedulerService
|
||||
from .services.resource_service import ResourceService
|
||||
from .core.database import init_db, get_auth_db
|
||||
from .core.logger import logger, belief_scope
|
||||
from .core.logger import logger
|
||||
from .core.auth.jwt import decode_token
|
||||
from .core.auth.repository import AuthRepository
|
||||
from .models.auth import User
|
||||
@@ -50,6 +50,9 @@ logger.info("TaskManager initialized")
|
||||
scheduler_service = SchedulerService(task_manager, config_manager)
|
||||
logger.info("SchedulerService initialized")
|
||||
|
||||
resource_service = ResourceService()
|
||||
logger.info("ResourceService initialized")
|
||||
|
||||
# [DEF:get_plugin_loader:Function]
|
||||
# @PURPOSE: Dependency injector for the PluginLoader.
|
||||
# @PRE: Global plugin_loader must be initialized.
|
||||
@@ -80,6 +83,16 @@ def get_scheduler_service() -> SchedulerService:
|
||||
return scheduler_service
|
||||
# [/DEF:get_scheduler_service:Function]
|
||||
|
||||
# [DEF:get_resource_service:Function]
|
||||
# @PURPOSE: Dependency injector for the ResourceService.
|
||||
# @PRE: Global resource_service must be initialized.
|
||||
# @POST: Returns shared ResourceService instance.
|
||||
# @RETURN: ResourceService - The shared resource service instance.
|
||||
def get_resource_service() -> ResourceService:
|
||||
"""Dependency injector for the ResourceService."""
|
||||
return resource_service
|
||||
# [/DEF:get_resource_service:Function]
|
||||
|
||||
# [DEF:oauth2_scheme:Variable]
|
||||
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
# [SECTION: IMPORTS]
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Table, Enum
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from .mapping import Base
|
||||
# [/SECTION]
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, DateTime, Enum, ForeignKey, Boolean
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
from src.core.database import Base
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# @LAYER: Domain
|
||||
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, JSON, Enum, Text
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, JSON, Text
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from .mapping import Base
|
||||
|
||||
@@ -95,7 +95,7 @@ class BackupPlugin(PluginBase):
|
||||
with belief_scope("get_schema"):
|
||||
config_manager = get_config_manager()
|
||||
envs = [e.name for e in config_manager.get_environments()]
|
||||
default_path = config_manager.get_config().settings.storage.root_path
|
||||
config_manager.get_config().settings.storage.root_path
|
||||
|
||||
return {
|
||||
"type": "object",
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> backend.src.plugins.llm_analysis.service.LLMClient
|
||||
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
from ..llm_analysis.service import LLMClient
|
||||
from ..llm_analysis.models import LLMProviderType
|
||||
from ...core.logger import belief_scope, logger
|
||||
|
||||
# [DEF:GitLLMExtension:Class]
|
||||
|
||||
@@ -54,7 +54,7 @@ class GitPlugin(PluginBase):
|
||||
self.config_manager = config_manager
|
||||
app_logger.info("GitPlugin initialized using shared config_manager.")
|
||||
return
|
||||
except:
|
||||
except Exception:
|
||||
config_path = "config.json"
|
||||
|
||||
self.config_manager = ConfigManager(config_path)
|
||||
@@ -135,7 +135,7 @@ class GitPlugin(PluginBase):
|
||||
# @POST: Плагин готов к выполнению задач.
|
||||
async def initialize(self):
|
||||
with belief_scope("GitPlugin.initialize"):
|
||||
logger.info("[GitPlugin.initialize][Action] Initializing Git Integration Plugin logic.")
|
||||
app_logger.info("[GitPlugin.initialize][Action] Initializing Git Integration Plugin logic.")
|
||||
|
||||
# [DEF:execute:Function]
|
||||
# @PURPOSE: Основной метод выполнения задач плагина с поддержкой TaskContext.
|
||||
@@ -246,15 +246,15 @@ class GitPlugin(PluginBase):
|
||||
# 5. Автоматический staging изменений (не коммит, чтобы юзер мог проверить diff)
|
||||
try:
|
||||
repo.git.add(A=True)
|
||||
logger.info(f"[_handle_sync][Action] Changes staged in git")
|
||||
app_logger.info("[_handle_sync][Action] Changes staged in git")
|
||||
except Exception as ge:
|
||||
logger.warning(f"[_handle_sync][Action] Failed to stage changes: {ge}")
|
||||
app_logger.warning(f"[_handle_sync][Action] Failed to stage changes: {ge}")
|
||||
|
||||
logger.info(f"[_handle_sync][Coherence:OK] Dashboard {dashboard_id} synced successfully.")
|
||||
app_logger.info(f"[_handle_sync][Coherence:OK] Dashboard {dashboard_id} synced successfully.")
|
||||
return {"status": "success", "message": "Dashboard synced and flattened in local repository"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[_handle_sync][Coherence:Failed] Sync failed: {e}")
|
||||
app_logger.error(f"[_handle_sync][Coherence:Failed] Sync failed: {e}")
|
||||
raise
|
||||
# [/DEF:_handle_sync:Function]
|
||||
|
||||
@@ -292,7 +292,8 @@ class GitPlugin(PluginBase):
|
||||
if ".git" in dirs:
|
||||
dirs.remove(".git")
|
||||
for file in files:
|
||||
if file == ".git" or file.endswith(".zip"): continue
|
||||
if file == ".git" or file.endswith(".zip"):
|
||||
continue
|
||||
file_path = Path(root) / file
|
||||
# Prepend the root directory name to the archive path
|
||||
arcname = Path(root_dir_name) / file_path.relative_to(repo_path)
|
||||
@@ -315,16 +316,16 @@ class GitPlugin(PluginBase):
|
||||
f.write(zip_buffer.getvalue())
|
||||
|
||||
try:
|
||||
logger.info(f"[_handle_deploy][Action] Importing dashboard to {env.name}")
|
||||
app_logger.info(f"[_handle_deploy][Action] Importing dashboard to {env.name}")
|
||||
result = client.import_dashboard(temp_zip_path)
|
||||
logger.info(f"[_handle_deploy][Coherence:OK] Deployment successful for dashboard {dashboard_id}.")
|
||||
app_logger.info(f"[_handle_deploy][Coherence:OK] Deployment successful for dashboard {dashboard_id}.")
|
||||
return {"status": "success", "message": f"Dashboard deployed to {env.name}", "details": result}
|
||||
finally:
|
||||
if temp_zip_path.exists():
|
||||
os.remove(temp_zip_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[_handle_deploy][Coherence:Failed] Deployment failed: {e}")
|
||||
app_logger.error(f"[_handle_deploy][Coherence:Failed] Deployment failed: {e}")
|
||||
raise
|
||||
# [/DEF:_handle_deploy:Function]
|
||||
|
||||
@@ -336,13 +337,13 @@ class GitPlugin(PluginBase):
|
||||
# @RETURN: Environment - Объект конфигурации окружения.
|
||||
def _get_env(self, env_id: Optional[str] = None):
|
||||
with belief_scope("GitPlugin._get_env"):
|
||||
logger.info(f"[_get_env][Entry] Fetching environment for ID: {env_id}")
|
||||
app_logger.info(f"[_get_env][Entry] Fetching environment for ID: {env_id}")
|
||||
|
||||
# Priority 1: ConfigManager (config.json)
|
||||
if env_id:
|
||||
env = self.config_manager.get_environment(env_id)
|
||||
if env:
|
||||
logger.info(f"[_get_env][Exit] Found environment by ID in ConfigManager: {env.name}")
|
||||
app_logger.info(f"[_get_env][Exit] Found environment by ID in ConfigManager: {env.name}")
|
||||
return env
|
||||
|
||||
# Priority 2: Database (DeploymentEnvironment)
|
||||
@@ -355,12 +356,12 @@ class GitPlugin(PluginBase):
|
||||
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.id == env_id).first()
|
||||
else:
|
||||
# If no ID, try to find active or any environment in DB
|
||||
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.is_active == True).first()
|
||||
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.is_active).first()
|
||||
if not db_env:
|
||||
db_env = db.query(DeploymentEnvironment).first()
|
||||
|
||||
if db_env:
|
||||
logger.info(f"[_get_env][Exit] Found environment in DB: {db_env.name}")
|
||||
app_logger.info(f"[_get_env][Exit] Found environment in DB: {db_env.name}")
|
||||
from src.core.config_models import Environment
|
||||
# Use token as password for SupersetClient
|
||||
return Environment(
|
||||
@@ -382,14 +383,14 @@ class GitPlugin(PluginBase):
|
||||
# but we have other envs, maybe it's one of them?
|
||||
env = next((e for e in envs if e.id == env_id), None)
|
||||
if env:
|
||||
logger.info(f"[_get_env][Exit] Found environment {env_id} in ConfigManager list")
|
||||
app_logger.info(f"[_get_env][Exit] Found environment {env_id} in ConfigManager list")
|
||||
return env
|
||||
|
||||
if not env_id:
|
||||
logger.info(f"[_get_env][Exit] Using first environment from ConfigManager: {envs[0].name}")
|
||||
app_logger.info(f"[_get_env][Exit] Using first environment from ConfigManager: {envs[0].name}")
|
||||
return envs[0]
|
||||
|
||||
logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}")
|
||||
app_logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}")
|
||||
raise ValueError("No environments configured. Please add a Superset Environment in Settings.")
|
||||
# [/DEF:_get_env:Function]
|
||||
|
||||
|
||||
@@ -9,4 +9,6 @@ LLM Analysis Plugin for automated dashboard validation and dataset documentation
|
||||
|
||||
from .plugin import DashboardValidationPlugin, DocumentationPlugin
|
||||
|
||||
__all__ = ['DashboardValidationPlugin', 'DocumentationPlugin']
|
||||
|
||||
# [/DEF:backend/src/plugins/llm_analysis/__init__.py:Module]
|
||||
|
||||
@@ -10,15 +10,13 @@
|
||||
# @RELATION: USES -> TaskContext
|
||||
# @INVARIANT: All LLM interactions must be executed as asynchronous tasks.
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from ...core.plugin_base import PluginBase
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...core.database import SessionLocal
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ...core.superset_client import SupersetClient
|
||||
from .service import ScreenshotService, LLMClient
|
||||
@@ -97,7 +95,7 @@ class DashboardValidationPlugin(PluginBase):
|
||||
log.error(f"LLM Provider {provider_id} not found")
|
||||
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||
|
||||
llm_log.debug(f"Retrieved provider config:")
|
||||
llm_log.debug("Retrieved provider config:")
|
||||
llm_log.debug(f" Provider ID: {db_provider.id}")
|
||||
llm_log.debug(f" Provider Name: {db_provider.name}")
|
||||
llm_log.debug(f" Provider Type: {db_provider.provider_type}")
|
||||
@@ -299,7 +297,7 @@ class DocumentationPlugin(PluginBase):
|
||||
log.error(f"LLM Provider {provider_id} not found")
|
||||
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||
|
||||
llm_log.debug(f"Retrieved provider config:")
|
||||
llm_log.debug("Retrieved provider config:")
|
||||
llm_log.debug(f" Provider ID: {db_provider.id}")
|
||||
llm_log.debug(f" Provider Name: {db_provider.name}")
|
||||
llm_log.debug(f" Provider Type: {db_provider.provider_type}")
|
||||
|
||||
@@ -12,12 +12,12 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import io
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Dict, Any
|
||||
from PIL import Image
|
||||
from playwright.async_api import async_playwright
|
||||
from openai import AsyncOpenAI, RateLimitError, AuthenticationError as OpenAIAuthenticationError
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
|
||||
from .models import LLMProviderType, ValidationResult, ValidationStatus, DetectedIssue
|
||||
from .models import LLMProviderType
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...core.config_models import Environment
|
||||
|
||||
@@ -96,7 +96,7 @@ class ScreenshotService:
|
||||
"password": ['input[name="password"]', 'input#password', 'input[placeholder*="Password"]', 'input[type="password"]'],
|
||||
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
|
||||
}
|
||||
logger.info(f"[DEBUG] Attempting to find login form elements...")
|
||||
logger.info("[DEBUG] Attempting to find login form elements...")
|
||||
|
||||
try:
|
||||
# Find and fill username
|
||||
@@ -190,27 +190,27 @@ class ScreenshotService:
|
||||
try:
|
||||
# Wait for the dashboard grid to be present
|
||||
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=30000)
|
||||
logger.info(f"[DEBUG] Dashboard container loaded")
|
||||
logger.info("[DEBUG] Dashboard container loaded")
|
||||
|
||||
# Wait for charts to finish loading (Superset uses loading spinners/skeletons)
|
||||
# We wait until loading indicators disappear or a timeout occurs
|
||||
try:
|
||||
# Wait for loading indicators to disappear
|
||||
await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=60000)
|
||||
logger.info(f"[DEBUG] Loading indicators hidden")
|
||||
except:
|
||||
logger.warning(f"[DEBUG] Timeout waiting for loading indicators to hide")
|
||||
logger.info("[DEBUG] Loading indicators hidden")
|
||||
except Exception:
|
||||
logger.warning("[DEBUG] Timeout waiting for loading indicators to hide")
|
||||
|
||||
# Wait for charts to actually render their content (e.g., ECharts, NVD3)
|
||||
# We look for common chart containers that should have content
|
||||
try:
|
||||
await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=60000)
|
||||
logger.info(f"[DEBUG] Chart content detected")
|
||||
except:
|
||||
logger.warning(f"[DEBUG] Timeout waiting for chart content")
|
||||
logger.info("[DEBUG] Chart content detected")
|
||||
except Exception:
|
||||
logger.warning("[DEBUG] Timeout waiting for chart content")
|
||||
|
||||
# Additional check: wait for all chart containers to have non-empty content
|
||||
logger.info(f"[DEBUG] Waiting for all charts to have rendered content...")
|
||||
logger.info("[DEBUG] Waiting for all charts to have rendered content...")
|
||||
await page.wait_for_function("""() => {
|
||||
const charts = document.querySelectorAll('.chart-container, .slice_container');
|
||||
if (charts.length === 0) return true; // No charts to wait for
|
||||
@@ -223,10 +223,10 @@ class ScreenshotService:
|
||||
return hasCanvas || hasSvg || hasContent;
|
||||
});
|
||||
}""", timeout=60000)
|
||||
logger.info(f"[DEBUG] All charts have rendered content")
|
||||
logger.info("[DEBUG] All charts have rendered content")
|
||||
|
||||
# Scroll to bottom and back to top to trigger lazy loading of all charts
|
||||
logger.info(f"[DEBUG] Scrolling to trigger lazy loading...")
|
||||
logger.info("[DEBUG] Scrolling to trigger lazy loading...")
|
||||
await page.evaluate("""async () => {
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
for (let i = 0; i < document.body.scrollHeight; i += 500) {
|
||||
@@ -241,7 +241,7 @@ class ScreenshotService:
|
||||
logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
|
||||
|
||||
# Final stabilization delay - increased for complex dashboards
|
||||
logger.info(f"[DEBUG] Final stabilization delay...")
|
||||
logger.info("[DEBUG] Final stabilization delay...")
|
||||
await asyncio.sleep(15)
|
||||
|
||||
# Logic to handle tabs and full-page capture
|
||||
@@ -251,7 +251,8 @@ class ScreenshotService:
|
||||
processed_tabs = set()
|
||||
|
||||
async def switch_tabs(depth=0):
|
||||
if depth > 3: return # Limit recursion depth
|
||||
if depth > 3:
|
||||
return # Limit recursion depth
|
||||
|
||||
tab_selectors = [
|
||||
'.ant-tabs-nav-list .ant-tabs-tab',
|
||||
@@ -262,7 +263,8 @@ class ScreenshotService:
|
||||
found_tabs = []
|
||||
for selector in tab_selectors:
|
||||
found_tabs = await page.locator(selector).all()
|
||||
if found_tabs: break
|
||||
if found_tabs:
|
||||
break
|
||||
|
||||
if found_tabs:
|
||||
logger.info(f"[DEBUG][TabSwitching] Found {len(found_tabs)} tabs at depth {depth}")
|
||||
@@ -292,7 +294,8 @@ class ScreenshotService:
|
||||
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
|
||||
await first_tab.click()
|
||||
await asyncio.sleep(1)
|
||||
except: pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await switch_tabs()
|
||||
|
||||
@@ -423,7 +426,7 @@ class LLMClient:
|
||||
self.default_model = default_model
|
||||
|
||||
# DEBUG: Log initialization parameters (without exposing full API key)
|
||||
logger.info(f"[LLMClient.__init__] Initializing LLM client:")
|
||||
logger.info("[LLMClient.__init__] Initializing LLM client:")
|
||||
logger.info(f"[LLMClient.__init__] Provider Type: {provider_type}")
|
||||
logger.info(f"[LLMClient.__init__] Base URL: {base_url}")
|
||||
logger.info(f"[LLMClient.__init__] Default Model: {default_model}")
|
||||
|
||||
@@ -7,15 +7,13 @@
|
||||
# @RELATION: DEPENDS_ON -> superset_tool.utils
|
||||
# @RELATION: USES -> TaskContext
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
from typing import Dict, Any, Optional
|
||||
import re
|
||||
|
||||
from ..core.plugin_base import PluginBase
|
||||
from ..core.logger import belief_scope, logger as app_logger
|
||||
from ..core.superset_client import SupersetClient
|
||||
from ..core.utils.fileio import create_temp_file, update_yamls, create_dashboard_export
|
||||
from ..core.utils.fileio import create_temp_file
|
||||
from ..dependencies import get_config_manager
|
||||
from ..core.migration_engine import MigrationEngine
|
||||
from ..core.database import SessionLocal
|
||||
@@ -151,8 +149,8 @@ class MigrationPlugin(PluginBase):
|
||||
dashboard_regex = params.get("dashboard_regex")
|
||||
|
||||
replace_db_config = params.get("replace_db_config", False)
|
||||
from_db_id = params.get("from_db_id")
|
||||
to_db_id = params.get("to_db_id")
|
||||
params.get("from_db_id")
|
||||
params.get("to_db_id")
|
||||
|
||||
# [DEF:MigrationPlugin.execute:Action]
|
||||
# @PURPOSE: Execute the migration logic with proper task logging.
|
||||
@@ -301,7 +299,7 @@ class MigrationPlugin(PluginBase):
|
||||
if match_alt:
|
||||
db_name = match_alt.group(1)
|
||||
|
||||
logger.warning(f"[MigrationPlugin][Action] Detected missing password for database: {db_name}")
|
||||
app_logger.warning(f"[MigrationPlugin][Action] Detected missing password for database: {db_name}")
|
||||
|
||||
if task_id:
|
||||
input_request = {
|
||||
@@ -320,19 +318,19 @@ class MigrationPlugin(PluginBase):
|
||||
|
||||
# Retry import with password
|
||||
if passwords:
|
||||
logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.")
|
||||
app_logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.")
|
||||
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords)
|
||||
logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.")
|
||||
app_logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.")
|
||||
# Clear passwords from params after use for security
|
||||
if "passwords" in task.params:
|
||||
del task.params["passwords"]
|
||||
continue
|
||||
|
||||
logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
|
||||
app_logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)
|
||||
|
||||
logger.info("[MigrationPlugin][Exit] Migration finished.")
|
||||
app_logger.info("[MigrationPlugin][Exit] Migration finished.")
|
||||
except Exception as e:
|
||||
logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
|
||||
app_logger.critical(f"[MigrationPlugin][Failure] Fatal error during migration: {e}", exc_info=True)
|
||||
raise e
|
||||
# [/DEF:MigrationPlugin.execute:Action]
|
||||
# [/DEF:execute:Function]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import re
|
||||
from typing import Dict, Any, List, Optional
|
||||
from typing import Dict, Any, Optional
|
||||
from ..core.plugin_base import PluginBase
|
||||
from ..core.superset_client import SupersetClient
|
||||
from ..core.logger import logger, belief_scope
|
||||
@@ -116,7 +116,7 @@ class SearchPlugin(PluginBase):
|
||||
log = context.logger if context else logger
|
||||
|
||||
# Create sub-loggers for different components
|
||||
superset_log = log.with_source("superset_api") if context else log
|
||||
log.with_source("superset_api") if context else log
|
||||
search_log = log.with_source("search") if context else log
|
||||
|
||||
if not env_name or not search_query:
|
||||
|
||||
@@ -19,7 +19,7 @@ from fastapi import UploadFile
|
||||
|
||||
from ...core.plugin_base import PluginBase
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.storage import StoredFile, FileCategory, StorageConfig
|
||||
from ...models.storage import StoredFile, FileCategory
|
||||
from ...dependencies import get_config_manager
|
||||
from ...core.task_manager.context import TaskContext
|
||||
# [/SECTION]
|
||||
@@ -126,7 +126,7 @@ class StoragePlugin(PluginBase):
|
||||
|
||||
# Create sub-loggers for different components
|
||||
storage_log = log.with_source("storage") if context else log
|
||||
filesystem_log = log.with_source("filesystem") if context else log
|
||||
log.with_source("filesystem") if context else log
|
||||
|
||||
storage_log.info(f"Executing with params: {params}")
|
||||
# [/DEF:execute:Function]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from datetime import datetime
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from src.core.database import AuthSessionLocal, init_db
|
||||
from src.core.auth.security import get_password_hash
|
||||
from src.models.auth import User, Role, Permission
|
||||
from src.models.auth import User, Role
|
||||
from src.core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -9,13 +9,12 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from src.core.database import init_db, auth_engine
|
||||
from src.core.database import init_db
|
||||
from src.core.logger import logger, belief_scope
|
||||
from src.scripts.seed_permissions import seed_permissions
|
||||
# [/SECTION]
|
||||
|
||||
18
backend/src/services/__init__.py
Normal file
18
backend/src/services/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# [DEF:backend.src.services:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: services, package, init
|
||||
# @PURPOSE: Package initialization for services module
|
||||
# @LAYER: Core
|
||||
# @RELATION: EXPORTS -> resource_service, mapping_service
|
||||
# @NOTE: Only export services that don't cause circular imports
|
||||
# @NOTE: GitService, AuthService, LLMProviderService have circular import issues - import directly when needed
|
||||
|
||||
# Only export services that don't cause circular imports
|
||||
from .mapping_service import MappingService
|
||||
from .resource_service import ResourceService
|
||||
|
||||
__all__ = [
|
||||
'MappingService',
|
||||
'ResourceService',
|
||||
]
|
||||
# [/DEF:backend.src.services:Module]
|
||||
@@ -10,11 +10,11 @@
|
||||
# @INVARIANT: Authentication must verify both credentials and account status.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing import Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models.auth import User, Role
|
||||
from ..core.auth.repository import AuthRepository
|
||||
from ..core.auth.security import verify_password, get_password_hash
|
||||
from ..core.auth.security import verify_password
|
||||
from ..core.auth.jwt import create_access_token
|
||||
from ..core.logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
@@ -10,11 +10,10 @@
|
||||
# @INVARIANT: All Git operations must be performed on a valid local directory.
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import httpx
|
||||
from git import Repo, RemoteProgress
|
||||
from git import Repo
|
||||
from fastapi import HTTPException
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from src.core.logger import logger, belief_scope
|
||||
from src.models.git import GitProvider
|
||||
@@ -167,7 +166,7 @@ class GitService:
|
||||
|
||||
# Handle empty repository case (no commits)
|
||||
if not repo.heads and not repo.remotes:
|
||||
logger.warning(f"[create_branch][Action] Repository is empty. Creating initial commit to enable branching.")
|
||||
logger.warning("[create_branch][Action] Repository is empty. Creating initial commit to enable branching.")
|
||||
readme_path = os.path.join(repo.working_dir, "README.md")
|
||||
if not os.path.exists(readme_path):
|
||||
with open(readme_path, "w") as f:
|
||||
@@ -178,7 +177,7 @@ class GitService:
|
||||
# Verify source branch exists
|
||||
try:
|
||||
repo.commit(from_branch)
|
||||
except:
|
||||
except Exception:
|
||||
logger.warning(f"[create_branch][Action] Source branch {from_branch} not found, using HEAD")
|
||||
from_branch = repo.head
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from ..models.llm import LLMProvider
|
||||
from ..plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
|
||||
from ..plugins.llm_analysis.models import LLMProviderConfig
|
||||
from ..core.logger import belief_scope, logger
|
||||
from cryptography.fernet import Fernet
|
||||
import os
|
||||
|
||||
251
backend/src/services/resource_service.py
Normal file
251
backend/src/services/resource_service.py
Normal file
@@ -0,0 +1,251 @@
|
||||
# [DEF:backend.src.services.resource_service:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: service, resources, dashboards, datasets, tasks, git
|
||||
# @PURPOSE: Shared service for fetching resource data with Git status and task status
|
||||
# @LAYER: Service
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
|
||||
# @RELATION: DEPENDS_ON -> backend.src.core.task_manager
|
||||
# @RELATION: DEPENDS_ON -> backend.src.services.git_service
|
||||
# @INVARIANT: All resources include metadata about their current state
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import List, Dict, Optional, Any
|
||||
from ..core.superset_client import SupersetClient
|
||||
from ..core.task_manager.models import Task
|
||||
from ..services.git_service import GitService
|
||||
from ..core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:ResourceService:Class]
|
||||
# @PURPOSE: Provides centralized access to resource data with enhanced metadata
|
||||
class ResourceService:
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initialize the resource service with dependencies
|
||||
# @PRE: None
|
||||
# @POST: ResourceService is ready to fetch resources
|
||||
def __init__(self):
|
||||
with belief_scope("ResourceService.__init__"):
|
||||
self.git_service = GitService()
|
||||
logger.info("[ResourceService][Action] Initialized ResourceService")
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:get_dashboards_with_status:Function]
|
||||
# @PURPOSE: Fetch dashboards from environment with Git status and last task status
|
||||
# @PRE: env is a valid Environment object
|
||||
# @POST: Returns list of dashboards with enhanced metadata
|
||||
# @PARAM: env (Environment) - The environment to fetch from
|
||||
# @PARAM: tasks (List[Task]) - List of tasks to check for status
|
||||
# @RETURN: List[Dict] - Dashboards with git_status and last_task fields
|
||||
# @RELATION: CALLS -> SupersetClient.get_dashboards_summary
|
||||
# @RELATION: CALLS -> self._get_git_status_for_dashboard
|
||||
# @RELATION: CALLS -> self._get_last_task_for_resource
|
||||
async def get_dashboards_with_status(
|
||||
self,
|
||||
env: Any,
|
||||
tasks: Optional[List[Task]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
with belief_scope("get_dashboards_with_status", f"env={env.id}"):
|
||||
client = SupersetClient(env)
|
||||
dashboards = client.get_dashboards_summary()
|
||||
|
||||
# Enhance each dashboard with Git status and task status
|
||||
result = []
|
||||
for dashboard in dashboards:
|
||||
# dashboard is already a dict, no need to call .dict()
|
||||
dashboard_dict = dashboard
|
||||
dashboard_id = dashboard_dict.get('id')
|
||||
|
||||
# Get Git status if repo exists
|
||||
git_status = self._get_git_status_for_dashboard(dashboard_id)
|
||||
dashboard_dict['git_status'] = git_status
|
||||
|
||||
# Get last task status
|
||||
last_task = self._get_last_task_for_resource(
|
||||
f"dashboard-{dashboard_id}",
|
||||
tasks
|
||||
)
|
||||
dashboard_dict['last_task'] = last_task
|
||||
|
||||
result.append(dashboard_dict)
|
||||
|
||||
logger.info(f"[ResourceService][Coherence:OK] Fetched {len(result)} dashboards with status")
|
||||
return result
|
||||
# [/DEF:get_dashboards_with_status:Function]
|
||||
|
||||
# [DEF:get_datasets_with_status:Function]
|
||||
# @PURPOSE: Fetch datasets from environment with mapping progress and last task status
|
||||
# @PRE: env is a valid Environment object
|
||||
# @POST: Returns list of datasets with enhanced metadata
|
||||
# @PARAM: env (Environment) - The environment to fetch from
|
||||
# @PARAM: tasks (List[Task]) - List of tasks to check for status
|
||||
# @RETURN: List[Dict] - Datasets with mapped_fields and last_task fields
|
||||
# @RELATION: CALLS -> SupersetClient.get_datasets_summary
|
||||
# @RELATION: CALLS -> self._get_last_task_for_resource
|
||||
async def get_datasets_with_status(
|
||||
self,
|
||||
env: Any,
|
||||
tasks: Optional[List[Task]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
with belief_scope("get_datasets_with_status", f"env={env.id}"):
|
||||
client = SupersetClient(env)
|
||||
datasets = client.get_datasets_summary()
|
||||
|
||||
# Enhance each dataset with task status
|
||||
result = []
|
||||
for dataset in datasets:
|
||||
# dataset is already a dict, no need to call .dict()
|
||||
dataset_dict = dataset
|
||||
dataset_id = dataset_dict.get('id')
|
||||
|
||||
# Get last task status
|
||||
last_task = self._get_last_task_for_resource(
|
||||
f"dataset-{dataset_id}",
|
||||
tasks
|
||||
)
|
||||
dataset_dict['last_task'] = last_task
|
||||
|
||||
result.append(dataset_dict)
|
||||
|
||||
logger.info(f"[ResourceService][Coherence:OK] Fetched {len(result)} datasets with status")
|
||||
return result
|
||||
# [/DEF:get_datasets_with_status:Function]
|
||||
|
||||
# [DEF:get_activity_summary:Function]
|
||||
# @PURPOSE: Get summary of active and recent tasks for the activity indicator
|
||||
# @PRE: tasks is a list of Task objects
|
||||
# @POST: Returns summary with active_count and recent_tasks
|
||||
# @PARAM: tasks (List[Task]) - List of tasks to summarize
|
||||
# @RETURN: Dict - Activity summary
|
||||
def get_activity_summary(self, tasks: List[Task]) -> Dict[str, Any]:
|
||||
with belief_scope("get_activity_summary"):
|
||||
# Count active (RUNNING, WAITING_INPUT) tasks
|
||||
active_tasks = [
|
||||
t for t in tasks
|
||||
if t.status in ['RUNNING', 'WAITING_INPUT']
|
||||
]
|
||||
|
||||
# Get recent tasks (last 5)
|
||||
recent_tasks = sorted(
|
||||
tasks,
|
||||
key=lambda t: t.created_at,
|
||||
reverse=True
|
||||
)[:5]
|
||||
|
||||
# Format recent tasks for frontend
|
||||
recent_tasks_formatted = []
|
||||
for task in recent_tasks:
|
||||
resource_name = self._extract_resource_name_from_task(task)
|
||||
recent_tasks_formatted.append({
|
||||
'task_id': str(task.id),
|
||||
'resource_name': resource_name,
|
||||
'resource_type': self._extract_resource_type_from_task(task),
|
||||
'status': task.status,
|
||||
'started_at': task.created_at.isoformat() if task.created_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
'active_count': len(active_tasks),
|
||||
'recent_tasks': recent_tasks_formatted
|
||||
}
|
||||
# [/DEF:get_activity_summary:Function]
|
||||
|
||||
# [DEF:_get_git_status_for_dashboard:Function]
|
||||
# @PURPOSE: Get Git sync status for a dashboard
|
||||
# @PRE: dashboard_id is a valid integer
|
||||
# @POST: Returns git status or None if no repo exists
|
||||
# @PARAM: dashboard_id (int) - The dashboard ID
|
||||
# @RETURN: Optional[Dict] - Git status with branch and sync_status
|
||||
# @RELATION: CALLS -> GitService.get_repo
|
||||
def _get_git_status_for_dashboard(self, dashboard_id: int) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
repo = self.git_service.get_repo(dashboard_id)
|
||||
if not repo:
|
||||
return None
|
||||
|
||||
# Check if there are uncommitted changes
|
||||
try:
|
||||
# Get current branch
|
||||
branch = repo.active_branch.name
|
||||
|
||||
# Check for uncommitted changes
|
||||
is_dirty = repo.is_dirty()
|
||||
|
||||
# Check for unpushed commits
|
||||
unpushed = len(list(repo.iter_commits(f'{branch}@{{u}}..{branch}'))) if '@{u}' in str(repo.refs) else 0
|
||||
|
||||
if is_dirty or unpushed > 0:
|
||||
sync_status = 'DIFF'
|
||||
else:
|
||||
sync_status = 'OK'
|
||||
|
||||
return {
|
||||
'branch': branch,
|
||||
'sync_status': sync_status
|
||||
}
|
||||
except Exception:
|
||||
logger.warning(f"[ResourceService][Warning] Failed to get git status for dashboard {dashboard_id}")
|
||||
return None
|
||||
except Exception:
|
||||
# No repo exists for this dashboard
|
||||
return None
|
||||
# [/DEF:_get_git_status_for_dashboard:Function]
|
||||
|
||||
# [DEF:_get_last_task_for_resource:Function]
|
||||
# @PURPOSE: Get the most recent task for a specific resource
|
||||
# @PRE: resource_id is a valid string
|
||||
# @POST: Returns task summary or None if no tasks found
|
||||
# @PARAM: resource_id (str) - The resource identifier (e.g., "dashboard-123")
|
||||
# @PARAM: tasks (Optional[List[Task]]) - List of tasks to search
|
||||
# @RETURN: Optional[Dict] - Task summary with task_id and status
|
||||
def _get_last_task_for_resource(
|
||||
self,
|
||||
resource_id: str,
|
||||
tasks: Optional[List[Task]] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not tasks:
|
||||
return None
|
||||
|
||||
# Filter tasks for this resource
|
||||
resource_tasks = []
|
||||
for task in tasks:
|
||||
params = task.params or {}
|
||||
if params.get('resource_id') == resource_id:
|
||||
resource_tasks.append(task)
|
||||
|
||||
if not resource_tasks:
|
||||
return None
|
||||
|
||||
# Get most recent task
|
||||
last_task = max(resource_tasks, key=lambda t: t.created_at)
|
||||
|
||||
return {
|
||||
'task_id': str(last_task.id),
|
||||
'status': last_task.status
|
||||
}
|
||||
# [/DEF:_get_last_task_for_resource:Function]
|
||||
|
||||
# [DEF:_extract_resource_name_from_task:Function]
|
||||
# @PURPOSE: Extract resource name from task params
|
||||
# @PRE: task is a valid Task object
|
||||
# @POST: Returns resource name or task ID
|
||||
# @PARAM: task (Task) - The task to extract from
|
||||
# @RETURN: str - Resource name or fallback
|
||||
def _extract_resource_name_from_task(self, task: Task) -> str:
|
||||
params = task.params or {}
|
||||
return params.get('resource_name', f"Task {task.id}")
|
||||
# [/DEF:_extract_resource_name_from_task:Function]
|
||||
|
||||
# [DEF:_extract_resource_type_from_task:Function]
|
||||
# @PURPOSE: Extract resource type from task params
|
||||
# @PRE: task is a valid Task object
|
||||
# @POST: Returns resource type or 'unknown'
|
||||
# @PARAM: task (Task) - The task to extract from
|
||||
# @RETURN: str - Resource type
|
||||
def _extract_resource_type_from_task(self, task: Task) -> str:
|
||||
params = task.params or {}
|
||||
return params.get('resource_type', 'unknown')
|
||||
# [/DEF:_extract_resource_type_from_task:Function]
|
||||
|
||||
# [/DEF:ResourceService:Class]
|
||||
# [/DEF:backend.src.services.resource_service:Module]
|
||||
BIN
backend/tasks.db
BIN
backend/tasks.db
Binary file not shown.
@@ -1,8 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Debug script to test Superset API authentication"""
|
||||
|
||||
import json
|
||||
import requests
|
||||
from pprint import pprint
|
||||
from src.core.superset_client import SupersetClient
|
||||
from src.core.config_manager import ConfigManager
|
||||
@@ -53,7 +51,7 @@ def main():
|
||||
print("\n--- Response Headers ---")
|
||||
pprint(dict(ui_response.headers))
|
||||
|
||||
print(f"\n--- Response Content Preview (200 chars) ---")
|
||||
print("\n--- Response Content Preview (200 chars) ---")
|
||||
print(repr(ui_response.text[:200]))
|
||||
|
||||
if ui_response.status_code == 200:
|
||||
|
||||
@@ -19,17 +19,17 @@ db = SessionLocal()
|
||||
provider = db.query(LLMProvider).filter(LLMProvider.id == '6c899741-4108-4196-aea4-f38ad2f0150e').first()
|
||||
|
||||
if provider:
|
||||
print(f"\nProvider found:")
|
||||
print("\nProvider found:")
|
||||
print(f" ID: {provider.id}")
|
||||
print(f" Name: {provider.name}")
|
||||
print(f" Encrypted API Key (first 50 chars): {provider.api_key[:50]}")
|
||||
print(f" Encrypted API Key Length: {len(provider.api_key)}")
|
||||
|
||||
# Test decryption
|
||||
print(f"\nAttempting decryption...")
|
||||
print("\nAttempting decryption...")
|
||||
try:
|
||||
decrypted = fernet.decrypt(provider.api_key.encode()).decode()
|
||||
print(f"Decryption successful!")
|
||||
print("Decryption successful!")
|
||||
print(f" Decrypted key length: {len(decrypted)}")
|
||||
print(f" Decrypted key (first 8 chars): {decrypted[:8]}")
|
||||
print(f" Decrypted key is empty: {len(decrypted) == 0}")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
@@ -8,7 +7,7 @@ sys.path.append(str(Path(__file__).parent.parent / "src"))
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from src.core.database import Base, get_auth_db
|
||||
from src.core.database import Base
|
||||
from src.models.auth import User, Role, Permission, ADGroupMapping
|
||||
from src.services.auth_service import AuthService
|
||||
from src.core.auth.repository import AuthRepository
|
||||
|
||||
67
backend/tests/test_dashboards_api.py
Normal file
67
backend/tests/test_dashboards_api.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# [DEF:backend.tests.test_dashboards_api:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Contract-driven tests for Dashboard Hub API
|
||||
# @RELATION: TESTS -> backend.src.api.routes.dashboards
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import MagicMock, patch
|
||||
from src.app import app
|
||||
from src.api.routes.dashboards import DashboardsResponse
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# [DEF:test_get_dashboards_success:Function]
|
||||
# @TEST: GET /api/dashboards returns 200 and valid schema
|
||||
# @PRE: env_id exists
|
||||
# @POST: Response matches DashboardsResponse schema
|
||||
def test_get_dashboards_success():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.get_resource_service") as mock_service, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
|
||||
# Mock environment
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_config.return_value.get_environments.return_value = [mock_env]
|
||||
|
||||
# Mock resource service response
|
||||
mock_service.return_value.get_dashboards_with_status.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Sales Report",
|
||||
"slug": "sales",
|
||||
"git_status": {"branch": "main", "sync_status": "OK"},
|
||||
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
|
||||
}
|
||||
]
|
||||
|
||||
# Mock permission
|
||||
mock_perm.return_value = lambda: True
|
||||
|
||||
response = client.get("/api/dashboards?env_id=prod")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "dashboards" in data
|
||||
assert len(data["dashboards"]) == 1
|
||||
assert data["dashboards"][0]["title"] == "Sales Report"
|
||||
# Validate against Pydantic model
|
||||
DashboardsResponse(**data)
|
||||
|
||||
# [DEF:test_get_dashboards_env_not_found:Function]
|
||||
# @TEST: GET /api/dashboards returns 404 if env_id missing
|
||||
# @PRE: env_id does not exist
|
||||
# @POST: Returns 404 error
|
||||
def test_get_dashboards_env_not_found():
|
||||
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
|
||||
patch("src.api.routes.dashboards.has_permission") as mock_perm:
|
||||
|
||||
mock_config.return_value.get_environments.return_value = []
|
||||
mock_perm.return_value = lambda: True
|
||||
|
||||
response = client.get("/api/dashboards?env_id=nonexistent")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
|
||||
# [/DEF:backend.tests.test_dashboards_api:Module]
|
||||
@@ -6,9 +6,7 @@
|
||||
# @TIER: STANDARD
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import pytest
|
||||
import logging
|
||||
from src.core.logger import (
|
||||
belief_scope,
|
||||
logger,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import pytest
|
||||
from src.core.config_models import Environment
|
||||
from src.core.logger import belief_scope
|
||||
|
||||
|
||||
123
backend/tests/test_resource_hubs.py
Normal file
123
backend/tests/test_resource_hubs.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import MagicMock
|
||||
from src.app import app
|
||||
from src.dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# [DEF:test_dashboards_api:Test]
|
||||
# @PURPOSE: Verify GET /api/dashboards contract compliance
|
||||
# @TEST: Valid env_id returns 200 and dashboard list
|
||||
# @TEST: Invalid env_id returns 404
|
||||
# @TEST: Search filter works
|
||||
|
||||
@pytest.fixture
|
||||
def mock_deps():
|
||||
config_manager = MagicMock()
|
||||
task_manager = MagicMock()
|
||||
resource_service = MagicMock()
|
||||
|
||||
# Mock environment
|
||||
env = MagicMock()
|
||||
env.id = "env1"
|
||||
config_manager.get_environments.return_value = [env]
|
||||
|
||||
# Mock tasks
|
||||
task_manager.get_all_tasks.return_value = []
|
||||
|
||||
# Mock dashboards
|
||||
resource_service.get_dashboards_with_status.return_value = [
|
||||
{"id": 1, "title": "Sales", "slug": "sales", "git_status": {"branch": "main", "sync_status": "OK"}, "last_task": None},
|
||||
{"id": 2, "title": "Marketing", "slug": "mkt", "git_status": None, "last_task": {"task_id": "t1", "status": "SUCCESS"}}
|
||||
]
|
||||
|
||||
app.dependency_overrides[get_config_manager] = lambda: config_manager
|
||||
app.dependency_overrides[get_task_manager] = lambda: task_manager
|
||||
app.dependency_overrides[get_resource_service] = lambda: resource_service
|
||||
|
||||
# Bypass permission check
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = "testadmin"
|
||||
|
||||
# Override both get_current_user and has_permission
|
||||
from src.dependencies import get_current_user
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
|
||||
# We need to override the specific instance returned by has_permission
|
||||
app.dependency_overrides[has_permission("plugin:migration", "READ")] = lambda: mock_user
|
||||
|
||||
yield {
|
||||
"config": config_manager,
|
||||
"task": task_manager,
|
||||
"resource": resource_service
|
||||
}
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_get_dashboards_success(mock_deps):
|
||||
response = client.get("/api/dashboards?env_id=env1")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "dashboards" in data
|
||||
assert len(data["dashboards"]) == 2
|
||||
assert data["dashboards"][0]["title"] == "Sales"
|
||||
assert data["dashboards"][0]["git_status"]["sync_status"] == "OK"
|
||||
|
||||
def test_get_dashboards_not_found(mock_deps):
|
||||
response = client.get("/api/dashboards?env_id=invalid")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_dashboards_search(mock_deps):
|
||||
response = client.get("/api/dashboards?env_id=env1&search=Sales")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["dashboards"]) == 1
|
||||
assert data["dashboards"][0]["title"] == "Sales"
|
||||
|
||||
# [/DEF:test_dashboards_api:Test]
|
||||
|
||||
# [DEF:test_datasets_api:Test]
|
||||
# @PURPOSE: Verify GET /api/datasets contract compliance
|
||||
# @TEST: Valid env_id returns 200 and dataset list
|
||||
# @TEST: Invalid env_id returns 404
|
||||
# @TEST: Search filter works
|
||||
# @TEST: Negative - Service failure returns 503
|
||||
|
||||
def test_get_datasets_success(mock_deps):
|
||||
mock_deps["resource"].get_datasets_with_status.return_value = [
|
||||
{"id": 1, "table_name": "orders", "schema": "public", "database": "db1", "mapped_fields": {"total": 10, "mapped": 5}, "last_task": None}
|
||||
]
|
||||
|
||||
response = client.get("/api/datasets?env_id=env1")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "datasets" in data
|
||||
assert len(data["datasets"]) == 1
|
||||
assert data["datasets"][0]["table_name"] == "orders"
|
||||
assert data["datasets"][0]["mapped_fields"]["mapped"] == 5
|
||||
|
||||
def test_get_datasets_not_found(mock_deps):
|
||||
response = client.get("/api/datasets?env_id=invalid")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_datasets_search(mock_deps):
|
||||
mock_deps["resource"].get_datasets_with_status.return_value = [
|
||||
{"id": 1, "table_name": "orders", "schema": "public", "database": "db1", "mapped_fields": {"total": 10, "mapped": 5}, "last_task": None},
|
||||
{"id": 2, "table_name": "users", "schema": "public", "database": "db1", "mapped_fields": {"total": 5, "mapped": 5}, "last_task": None}
|
||||
]
|
||||
|
||||
response = client.get("/api/datasets?env_id=env1&search=orders")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["datasets"]) == 1
|
||||
assert data["datasets"][0]["table_name"] == "orders"
|
||||
|
||||
def test_get_datasets_service_failure(mock_deps):
|
||||
mock_deps["resource"].get_datasets_with_status.side_effect = Exception("Superset down")
|
||||
|
||||
response = client.get("/api/datasets?env_id=env1")
|
||||
assert response.status_code == 503
|
||||
assert "Failed to fetch datasets" in response.json()["detail"]
|
||||
|
||||
# [/DEF:test_datasets_api:Test]
|
||||
47
backend/tests/test_resource_service.py
Normal file
47
backend/tests/test_resource_service.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# [DEF:backend.tests.test_resource_service:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Contract-driven tests for ResourceService
|
||||
# @RELATION: TESTS -> backend.src.services.resource_service
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboards_with_status():
|
||||
# [DEF:test_get_dashboards_with_status:Function]
|
||||
# @TEST: ResourceService correctly enhances dashboard data
|
||||
# @PRE: SupersetClient returns raw dashboards
|
||||
# @POST: Returned dicts contain git_status and last_task
|
||||
|
||||
with patch("src.services.resource_service.SupersetClient") as mock_client, \
|
||||
patch("src.services.resource_service.GitService") as mock_git:
|
||||
|
||||
service = ResourceService()
|
||||
|
||||
# Mock Superset response
|
||||
mock_client.return_value.get_dashboards_summary.return_value = [
|
||||
{"id": 1, "title": "Test Dashboard", "slug": "test"}
|
||||
]
|
||||
|
||||
# Mock Git status
|
||||
mock_git.return_value.get_repo.return_value = None # No repo
|
||||
|
||||
# Mock tasks
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-123"
|
||||
mock_task.status = "RUNNING"
|
||||
mock_task.params = {"resource_id": "dashboard-1"}
|
||||
|
||||
env = MagicMock()
|
||||
env.id = "prod"
|
||||
|
||||
result = await service.get_dashboards_with_status(env, [mock_task])
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == 1
|
||||
assert "git_status" in result[0]
|
||||
assert result[0]["last_task"]["task_id"] == "task-123"
|
||||
assert result[0]["last_task"]["status"] == "RUNNING"
|
||||
|
||||
# [/DEF:backend.tests.test_resource_service:Module]
|
||||
@@ -6,13 +6,10 @@
|
||||
# @TIER: STANDARD
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
from src.core.task_manager.task_logger import TaskLogger
|
||||
from src.core.task_manager.context import TaskContext
|
||||
from src.core.task_manager.models import LogEntry
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:TestTaskLogger:Class]
|
||||
|
||||
Reference in New Issue
Block a user