9 Commits

Author SHA1 Message Date
07ec2d9797 Работает создание коммитов и перенос в новый enviroment 2026-01-23 13:57:44 +03:00
e9d3f3c827 tasks ready 2026-01-22 23:59:16 +03:00
26ba015b75 +gitignore 2026-01-22 23:25:29 +03:00
49129d3e86 fix error 2026-01-22 23:18:48 +03:00
d99a13d91f refactor complete 2026-01-22 17:37:17 +03:00
203ce446f4 ашч 2026-01-21 14:00:48 +03:00
c96d50a3f4 fix(backend): standardize superset client init and auth
- Update plugins (debug, mapper, search) to explicitly map environment config to SupersetConfig
- Add authenticate method to SupersetClient for explicit session management
- Add get_environment method to ConfigManager
- Fix navbar dropdown hover stability in frontend with invisible bridge
2026-01-20 19:31:17 +03:00
3bbe320949 TaskLog fix 2026-01-19 17:10:43 +03:00
2d2435642d bug fixs 2026-01-19 00:07:06 +03:00
89 changed files with 7923 additions and 6663 deletions

6
.gitignore vendored
View File

@@ -59,9 +59,13 @@ Thumbs.db
*.ps1
keyring passwords.py
*github*
*git*
*tech_spec*
dashboards
backend/mappings.db
backend/tasks.db
# Git Integration repositories
backend/git_repos/

View File

@@ -1 +1 @@
{"mcpServers":{"tavily":{"command":"npx","args":["-y","tavily-mcp@0.2.3"],"env":{"TAVILY_API_KEY":"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"},"alwaysAllow":[]}}}
{"mcpServers":{}}

View File

@@ -20,6 +20,11 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
- SQLite (`tasks.db`), JSON (`config.json`) (009-backup-scheduler)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, `superset_tool` (internal lib) (010-refactor-cli-to-web)
- SQLite (for job history/results, connection configs), Filesystem (for temporary file uploads) (010-refactor-cli-to-web)
- Python 3.9+ + FastAPI, Pydantic, requests, pyyaml (migrated from superset_tool) (012-remove-superset-tool)
- SQLite (tasks.db, migrations.db), Filesystem (012-remove-superset-tool)
- Filesystem (local git repo), SQLite (for GitServerConfig, Environment) (011-git-integration-dashboard)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API (011-git-integration-dashboard)
- SQLite (for config/history), Filesystem (local Git repositories) (011-git-integration-dashboard)
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
@@ -40,9 +45,9 @@ cd src; pytest; ruff check .
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
## Recent Changes
- 010-refactor-cli-to-web: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, `superset_tool` (internal lib)
- 009-backup-scheduler: Added Python 3.9+, Node.js 18+ + FastAPI, APScheduler, SQLAlchemy, SvelteKit, Tailwind CSS
- 009-backup-scheduler: Added Python 3.9+, Node.js 18+ + FastAPI, APScheduler, SQLAlchemy, SvelteKit, Tailwind CSS
- 011-git-integration-dashboard: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API
- 011-git-integration-dashboard: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API
- 011-git-integration-dashboard: Added Python 3.9+, Node.js 18+
<!-- MANUAL ADDITIONS START -->

Submodule backend/backend/git_repos/12 added at d592fa7ed5

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Script to delete tasks with RUNNING status from the database."""
from sqlalchemy.orm import Session
from src.core.database import TasksSessionLocal
from src.models.task import TaskRecord
def delete_running_tasks():
"""Delete all tasks with RUNNING status from the database."""
session: Session = TasksSessionLocal()
try:
# Find all task records with RUNNING status
running_tasks = session.query(TaskRecord).filter(TaskRecord.status == "RUNNING").all()
if not running_tasks:
print("No RUNNING tasks found.")
return
print(f"Found {len(running_tasks)} RUNNING tasks:")
for task in running_tasks:
print(f"- Task ID: {task.id}, Type: {task.type}")
# Delete the found tasks
session.query(TaskRecord).filter(TaskRecord.status == "RUNNING").delete(synchronize_session=False)
session.commit()
print(f"Successfully deleted {len(running_tasks)} RUNNING tasks.")
except Exception as e:
session.rollback()
print(f"Error deleting tasks: {e}")
finally:
session.close()
if __name__ == "__main__":
delete_running_tasks()

Binary file not shown.

View File

@@ -42,4 +42,6 @@ urllib3==2.6.2
uvicorn==0.38.0
websockets==15.0.1
pandas
psycopg2-binary
psycopg2-binary
openpyxl
GitPython==3.1.44

View File

@@ -1 +1 @@
from . import plugins, tasks, settings, connections
from . import plugins, tasks, settings, connections, environments, mappings, migration, git

View File

@@ -11,12 +11,11 @@
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict, Optional
from backend.src.dependencies import get_config_manager, get_scheduler_service
from backend.src.core.superset_client import SupersetClient
from superset_tool.models import SupersetConfig
from ...dependencies import get_config_manager, get_scheduler_service
from ...core.superset_client import SupersetClient
from pydantic import BaseModel, Field
from backend.src.core.config_models import Environment as EnvModel
from backend.src.core.logger import belief_scope
from ...core.config_models import Environment as EnvModel
from ...core.logger import belief_scope
# [/SECTION]
router = APIRouter()
@@ -62,7 +61,7 @@ async def get_environments(config_manager=Depends(get_config_manager)):
backup_schedule=ScheduleSchema(
enabled=e.backup_schedule.enabled,
cron_expression=e.backup_schedule.cron_expression
) if e.backup_schedule else None
) if getattr(e, 'backup_schedule', None) else None
) for e in envs
]
# [/DEF:get_environments:Function]
@@ -114,18 +113,7 @@ async def get_environment_databases(id: str, config_manager=Depends(get_config_m
try:
# Initialize SupersetClient from environment config
# Note: We need to map Environment model to SupersetConfig
superset_config = SupersetConfig(
env=env.name,
base_url=env.url,
auth={
"provider": "db", # Defaulting to db provider
"username": env.username,
"password": env.password,
"refresh": "false"
}
)
client = SupersetClient(superset_config)
client = SupersetClient(env)
return client.get_databases_summary()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch databases: {str(e)}")

View File

@@ -0,0 +1,303 @@
# [DEF:backend.src.api.routes.git:Module]
#
# @SEMANTICS: git, routes, api, fastapi, repository, deployment
# @PURPOSE: Provides FastAPI endpoints for Git integration operations.
# @LAYER: API
# @RELATION: USES -> src.services.git_service.GitService
# @RELATION: USES -> src.api.routes.git_schemas
# @RELATION: USES -> src.models.git
#
# @INVARIANT: All Git operations must be routed through GitService.
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
import typing
from src.dependencies import get_config_manager
from src.core.database import get_db
from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository
from src.api.routes.git_schemas import (
GitServerConfigSchema, GitServerConfigCreate,
GitRepositorySchema, BranchSchema, BranchCreate,
BranchCheckout, CommitSchema, CommitCreate,
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest
)
from src.services.git_service import GitService
from src.core.logger import logger, belief_scope
router = APIRouter(prefix="/api/git", tags=["git"])
git_service = GitService()
# [DEF:get_git_configs:Function]
# @PURPOSE: List all configured Git servers.
# @RETURN: List[GitServerConfigSchema]
@router.get("/config", response_model=List[GitServerConfigSchema])
async def get_git_configs(db: Session = Depends(get_db)):
with belief_scope("get_git_configs"):
return db.query(GitServerConfig).all()
# [/DEF:get_git_configs:Function]
# [DEF:create_git_config:Function]
# @PURPOSE: Register a new Git server configuration.
# @PARAM: config (GitServerConfigCreate)
# @RETURN: GitServerConfigSchema
@router.post("/config", response_model=GitServerConfigSchema)
async def create_git_config(config: GitServerConfigCreate, db: Session = Depends(get_db)):
with belief_scope("create_git_config"):
db_config = GitServerConfig(**config.dict())
db.add(db_config)
db.commit()
db.refresh(db_config)
return db_config
# [/DEF:create_git_config:Function]
# [DEF:delete_git_config:Function]
# @PURPOSE: Remove a Git server configuration.
# @PARAM: config_id (str)
@router.delete("/config/{config_id}")
async def delete_git_config(config_id: str, db: Session = Depends(get_db)):
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]
# @PURPOSE: Validate connection to a Git server using provided credentials.
# @PARAM: config (GitServerConfigCreate)
@router.post("/config/test")
async def test_git_config(config: GitServerConfigCreate):
with belief_scope("test_git_config"):
success = await git_service.test_connection(config.provider, config.url, config.pat)
if success:
return {"status": "success", "message": "Connection successful"}
else:
raise HTTPException(status_code=400, detail="Connection failed")
# [/DEF:test_git_config:Function]
# [DEF:init_repository:Function]
# @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init.
# @PARAM: dashboard_id (int)
# @PARAM: init_data (RepoInitRequest)
@router.post("/repositories/{dashboard_id}/init")
async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Session = Depends(get_db)):
with belief_scope("init_repository"):
# 1. Get config
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:
# 2. Perform Git clone/init
logger.info(f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}")
git_service.init_repo(dashboard_id, init_data.remote_url, config.pat)
# 3. Save to DB
repo_path = git_service._get_repo_path(dashboard_id)
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
)
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.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}")
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:init_repository:Function]
# [DEF:get_branches:Function]
# @PURPOSE: List all branches for a dashboard's repository.
# @PARAM: dashboard_id (int)
# @RETURN: List[BranchSchema]
@router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema])
async def get_branches(dashboard_id: int):
with belief_scope("get_branches"):
try:
return git_service.list_branches(dashboard_id)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
# [/DEF:get_branches:Function]
# [DEF:create_branch:Function]
# @PURPOSE: Create a new branch in the dashboard's repository.
# @PARAM: dashboard_id (int)
# @PARAM: branch_data (BranchCreate)
@router.post("/repositories/{dashboard_id}/branches")
async def create_branch(dashboard_id: int, branch_data: BranchCreate):
with belief_scope("create_branch"):
try:
git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:create_branch:Function]
# [DEF:checkout_branch:Function]
# @PURPOSE: Switch the dashboard's repository to a specific branch.
# @PARAM: dashboard_id (int)
# @PARAM: checkout_data (BranchCheckout)
@router.post("/repositories/{dashboard_id}/checkout")
async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout):
with belief_scope("checkout_branch"):
try:
git_service.checkout_branch(dashboard_id, checkout_data.name)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:checkout_branch:Function]
# [DEF:commit_changes:Function]
# @PURPOSE: Stage and commit changes in the dashboard's repository.
# @PARAM: dashboard_id (int)
# @PARAM: commit_data (CommitCreate)
@router.post("/repositories/{dashboard_id}/commit")
async def commit_changes(dashboard_id: int, commit_data: CommitCreate):
with belief_scope("commit_changes"):
try:
git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:commit_changes:Function]
# [DEF:push_changes:Function]
# @PURPOSE: Push local commits to the remote repository.
# @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/push")
async def push_changes(dashboard_id: int):
with belief_scope("push_changes"):
try:
git_service.push_changes(dashboard_id)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:push_changes:Function]
# [DEF:pull_changes:Function]
# @PURPOSE: Pull changes from the remote repository.
# @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/pull")
async def pull_changes(dashboard_id: int):
with belief_scope("pull_changes"):
try:
git_service.pull_changes(dashboard_id)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:pull_changes:Function]
# [DEF:sync_dashboard:Function]
# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin.
# @PARAM: dashboard_id (int)
# @PARAM: source_env_id (Optional[str])
@router.post("/repositories/{dashboard_id}/sync")
async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str] = None):
with belief_scope("sync_dashboard"):
try:
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 Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:sync_dashboard:Function]
# [DEF:get_environments:Function]
# @PURPOSE: List all deployment environments.
# @RETURN: List[DeploymentEnvironmentSchema]
@router.get("/environments", response_model=List[DeploymentEnvironmentSchema])
async def get_environments(config_manager=Depends(get_config_manager)):
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:deploy_dashboard:Function]
# @PURPOSE: Deploy dashboard from Git to a target environment.
# @PARAM: dashboard_id (int)
# @PARAM: deploy_data (DeployRequest)
@router.post("/repositories/{dashboard_id}/deploy")
async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest):
with belief_scope("deploy_dashboard"):
try:
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 Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:deploy_dashboard:Function]
# [DEF:get_history:Function]
# @PURPOSE: View commit history for a dashboard's repository.
# @PARAM: dashboard_id (int)
# @PARAM: limit (int)
# @RETURN: List[CommitSchema]
@router.get("/repositories/{dashboard_id}/history", response_model=List[CommitSchema])
async def get_history(dashboard_id: int, limit: int = 50):
with belief_scope("get_history"):
try:
return git_service.get_commit_history(dashboard_id, limit)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
# [/DEF:get_history:Function]
# [DEF:get_repository_status:Function]
# @PURPOSE: Get current Git status for a dashboard repository.
# @PARAM: dashboard_id (int)
# @RETURN: dict
@router.get("/repositories/{dashboard_id}/status")
async def get_repository_status(dashboard_id: int):
with belief_scope("get_repository_status"):
try:
return git_service.get_status(dashboard_id)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:get_repository_status:Function]
# [DEF:get_repository_diff:Function]
# @PURPOSE: Get Git diff for a dashboard repository.
# @PARAM: dashboard_id (int)
# @PARAM: file_path (Optional[str])
# @PARAM: staged (bool)
# @RETURN: str
@router.get("/repositories/{dashboard_id}/diff")
async def get_repository_diff(dashboard_id: int, file_path: Optional[str] = None, staged: bool = False):
with belief_scope("get_repository_diff"):
try:
diff_text = git_service.get_diff(dashboard_id, file_path, staged)
return diff_text
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:get_repository_diff:Function]
# [/DEF:backend.src.api.routes.git:Module]

View File

@@ -0,0 +1,130 @@
# [DEF:backend.src.api.routes.git_schemas:Module]
#
# @SEMANTICS: git, schemas, pydantic, api, contracts
# @PURPOSE: Defines Pydantic models for the Git integration API layer.
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.models.git
#
# @INVARIANT: All schemas must be compatible with the FastAPI router.
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from uuid import UUID
from src.models.git import GitProvider, GitStatus, SyncStatus
# [DEF:GitServerConfigBase:Class]
class GitServerConfigBase(BaseModel):
name: str = Field(..., description="Display name for the Git server")
provider: GitProvider = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)")
url: str = Field(..., description="Server base URL")
pat: str = Field(..., description="Personal Access Token")
default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)")
# [/DEF:GitServerConfigBase:Class]
# [DEF:GitServerConfigCreate:Class]
class GitServerConfigCreate(GitServerConfigBase):
"""Schema for creating a new Git server configuration."""
pass
# [/DEF:GitServerConfigCreate:Class]
# [DEF:GitServerConfigSchema:Class]
class GitServerConfigSchema(GitServerConfigBase):
"""Schema for representing a Git server configuration with metadata."""
id: str
status: GitStatus
last_validated: datetime
class Config:
from_attributes = True
# [/DEF:GitServerConfigSchema:Class]
# [DEF:GitRepositorySchema:Class]
class GitRepositorySchema(BaseModel):
"""Schema for tracking a local Git repository linked to a dashboard."""
id: str
dashboard_id: int
config_id: str
remote_url: str
local_path: str
current_branch: str
sync_status: SyncStatus
class Config:
from_attributes = True
# [/DEF:GitRepositorySchema:Class]
# [DEF:BranchSchema:Class]
class BranchSchema(BaseModel):
"""Schema for representing a Git branch."""
name: str
commit_hash: str
is_remote: bool
last_updated: datetime
# [/DEF:BranchSchema:Class]
# [DEF:CommitSchema:Class]
class CommitSchema(BaseModel):
"""Schema for representing a Git commit."""
hash: str
author: str
email: str
timestamp: datetime
message: str
files_changed: List[str]
# [/DEF:CommitSchema:Class]
# [DEF:BranchCreate:Class]
class BranchCreate(BaseModel):
"""Schema for branch creation requests."""
name: str
from_branch: str
# [/DEF:BranchCreate:Class]
# [DEF:BranchCheckout:Class]
class BranchCheckout(BaseModel):
"""Schema for branch checkout requests."""
name: str
# [/DEF:BranchCheckout:Class]
# [DEF:CommitCreate:Class]
class CommitCreate(BaseModel):
"""Schema for staging and committing changes."""
message: str
files: List[str]
# [/DEF:CommitCreate:Class]
# [DEF:ConflictResolution:Class]
class ConflictResolution(BaseModel):
"""Schema for resolving merge conflicts."""
file_path: str
resolution: str = Field(pattern="^(mine|theirs|manual)$")
content: Optional[str] = None
# [/DEF:ConflictResolution:Class]
# [DEF:DeploymentEnvironmentSchema:Class]
class DeploymentEnvironmentSchema(BaseModel):
"""Schema for representing a target deployment environment."""
id: str
name: str
superset_url: str
is_active: bool
class Config:
from_attributes = True
# [/DEF:DeploymentEnvironmentSchema:Class]
# [DEF:DeployRequest:Class]
class DeployRequest(BaseModel):
"""Schema for deployment requests."""
environment_id: str
# [/DEF:DeployRequest:Class]
# [DEF:RepoInitRequest:Class]
class RepoInitRequest(BaseModel):
"""Schema for repository initialization requests."""
config_id: str
remote_url: str
# [/DEF:RepoInitRequest:Class]
# [/DEF:backend.src.api.routes.git_schemas:Module]

View File

@@ -13,9 +13,10 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from backend.src.dependencies import get_config_manager
from backend.src.core.database import get_db
from backend.src.models.mapping import DatabaseMapping
from ...core.logger import belief_scope
from ...dependencies import get_config_manager
from ...core.database import get_db
from ...models.mapping import DatabaseMapping
from pydantic import BaseModel
# [/SECTION]

View File

@@ -7,10 +7,10 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict
from backend.src.dependencies import get_config_manager, get_task_manager
from backend.src.models.dashboard import DashboardMetadata, DashboardSelection
from backend.src.core.superset_client import SupersetClient
from superset_tool.models import SupersetConfig
from ...dependencies import get_config_manager, get_task_manager
from ...models.dashboard import DashboardMetadata, DashboardSelection
from ...core.superset_client import SupersetClient
from ...core.logger import belief_scope
router = APIRouter(prefix="/api", tags=["migration"])
@@ -22,19 +22,13 @@ router = APIRouter(prefix="/api", tags=["migration"])
# @RETURN: List[DashboardMetadata]
@router.get("/environments/{env_id}/dashboards", response_model=List[DashboardMetadata])
async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)):
environments = config_manager.get_environments()
with belief_scope("get_dashboards", f"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:
raise HTTPException(status_code=404, detail="Environment not found")
config = SupersetConfig(
env=env.name,
base_url=env.url,
auth={'provider': 'db', 'username': env.username, 'password': env.password, 'refresh': False},
verify_ssl=True,
timeout=30
)
client = SupersetClient(config)
client = SupersetClient(env)
dashboards = client.get_dashboards_summary()
return dashboards
# [/DEF:get_dashboards:Function]
@@ -47,8 +41,9 @@ async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)
# @RETURN: Dict - {"task_id": str, "message": str}
@router.post("/migration/execute")
async def execute_migration(selection: DashboardSelection, config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager)):
# Validate environments exist
environments = config_manager.get_environments()
with belief_scope("execute_migration"):
# Validate environments exist
environments = config_manager.get_environments()
env_ids = {e.id for e in environments}
if selection.source_env_id not in env_ids or selection.target_env_id not in env_ids:
raise HTTPException(status_code=400, detail="Invalid source or target environment")

View File

@@ -17,7 +17,6 @@ from ...dependencies import get_config_manager
from ...core.config_manager import ConfigManager
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
from superset_tool.models import SupersetConfig
import os
# [/SECTION]
@@ -28,7 +27,7 @@ router = APIRouter()
# @PRE: Config manager is available.
# @POST: Returns masked AppConfig.
# @RETURN: AppConfig - The current configuration.
@router.get("/", response_model=AppConfig)
@router.get("", response_model=AppConfig)
async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)):
with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings")
@@ -85,17 +84,7 @@ async def add_environment(
# Validate connection before adding
try:
superset_config = SupersetConfig(
env=env.name,
base_url=env.url,
auth={
"provider": "db",
"username": env.username,
"password": env.password,
"refresh": "true"
}
)
client = SupersetClient(config=superset_config)
client = SupersetClient(env)
client.get_dashboards(query={"page_size": 1})
except Exception as e:
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
@@ -130,17 +119,7 @@ async def update_environment(
# Validate connection before updating
try:
superset_config = SupersetConfig(
env=env_to_validate.name,
base_url=env_to_validate.url,
auth={
"provider": "db",
"username": env_to_validate.username,
"password": env_to_validate.password,
"refresh": "true"
}
)
client = SupersetClient(config=superset_config)
client = SupersetClient(env_to_validate)
client.get_dashboards(query={"page_size": 1})
except Exception as e:
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
@@ -187,21 +166,8 @@ async def test_environment_connection(
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
try:
# Create SupersetConfig
# Note: SupersetConfig expects 'auth' dict with specific keys
superset_config = SupersetConfig(
env=env.name,
base_url=env.url,
auth={
"provider": "db", # Defaulting to db for now
"username": env.username,
"password": env.password,
"refresh": "true"
}
)
# Initialize client (this will trigger authentication)
client = SupersetClient(config=superset_config)
client = SupersetClient(env)
# Try a simple request to verify
client.get_dashboards(query={"page_size": 1})

View File

@@ -6,10 +6,8 @@
import sys
from pathlib import Path
# Add project root to sys.path to allow importing superset_tool
# Assuming app.py is in backend/src/
# project_root is used for static files mounting
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.append(str(project_root))
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
@@ -20,7 +18,7 @@ import os
from .dependencies import get_task_manager, get_scheduler_service
from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git
from .core.database import init_db
# [DEF:App:Global]
@@ -90,6 +88,7 @@ app.include_router(connections.router, prefix="/api/settings/connections", tags=
app.include_router(environments.router, prefix="/api/environments", tags=["Environments"])
app.include_router(mappings.router)
app.include_router(migration.router)
app.include_router(git.router)
# [DEF:websocket_endpoint:Function]
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task.
@@ -167,7 +166,8 @@ if frontend_path.exists():
with belief_scope("serve_spa", f"path={file_path}"):
# Don't serve SPA for API routes that fell through
if file_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API endpoint not found")
logger.info(f"[DEBUG] API route fell through to serve_spa: {file_path}")
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
full_path = frontend_path / file_path
if full_path.is_file():

View File

@@ -186,6 +186,20 @@ class ConfigManager:
return len(self.config.environments) > 0
# [/DEF:has_environments:Function]
# [DEF:get_environment:Function]
# @PURPOSE: Returns a single environment by ID.
# @PRE: self.config is set and isinstance(env_id, str) and len(env_id) > 0.
# @POST: Returns Environment object if found, None otherwise.
# @PARAM: env_id (str) - The ID of the environment to retrieve.
# @RETURN: Optional[Environment] - The environment with the given ID, or None.
def get_environment(self, env_id: str) -> Optional[Environment]:
with belief_scope("get_environment"):
for env in self.config.environments:
if env.id == env_id:
return env
return None
# [/DEF:get_environment:Function]
# [DEF:add_environment:Function]
# @PURPOSE: Adds a new environment to the configuration.
# @PRE: isinstance(env, Environment)

View File

@@ -23,6 +23,8 @@ class Environment(BaseModel):
url: str
username: str
password: str # Will be masked in UI
verify_ssl: bool = True
timeout: int = 30
is_default: bool = False
backup_schedule: Schedule = Field(default_factory=Schedule)
# [/DEF:Environment:DataClass]

View File

@@ -15,6 +15,7 @@ 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 .logger import belief_scope
import os
# [/SECTION]

View File

@@ -29,11 +29,10 @@ class BeliefFormatter(logging.Formatter):
# @PARAM: record (logging.LogRecord) - The log record to format.
# @RETURN: str - The formatted log message.
def format(self, record):
msg = super().format(record)
anchor_id = getattr(_belief_state, 'anchor_id', None)
if anchor_id:
msg = f"[{anchor_id}][Action] {msg}"
return msg
record.msg = f"[{anchor_id}][Action] {record.msg}"
return super().format(record)
# [/DEF:format:Function]
# [/DEF:BeliefFormatter:Class]
@@ -193,6 +192,18 @@ class WebSocketLogHandler(logging.Handler):
# @SEMANTICS: logger, global, instance
# @PURPOSE: The global logger instance for the application, configured with both a console handler and the custom WebSocket handler.
logger = logging.getLogger("superset_tools_app")
# [DEF:believed:Function]
# @PURPOSE: A decorator that wraps a function in a belief scope.
# @PARAM: anchor_id (str) - The identifier for the semantic block.
def believed(anchor_id: str):
def decorator(func):
def wrapper(*args, **kwargs):
with belief_scope(anchor_id):
return func(*args, **kwargs)
return wrapper
return decorator
# [/DEF:believed:Function]
logger.setLevel(logging.INFO)
# Create a formatter

View File

@@ -1,74 +1,102 @@
# [DEF:backend.src.core.superset_client:Module]
#
# @SEMANTICS: superset, api, client, database, metadata
# @PURPOSE: Extends the base SupersetClient with database-specific metadata fetching.
# @SEMANTICS: superset, api, client, rest, http, dashboard, dataset, import, export
# @PURPOSE: Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию.
# @LAYER: Core
# @RELATION: INHERITS_FROM -> superset_tool.client.SupersetClient
# @RELATION: USES -> backend.src.core.utils.network.APIClient
# @RELATION: USES -> backend.src.core.config_models.Environment
#
# @INVARIANT: All database metadata requests must include UUID and name.
# @INVARIANT: All network operations must use the internal APIClient instance.
# @PUBLIC_API: SupersetClient
# [SECTION: IMPORTS]
from typing import List, Dict, Optional, Tuple
from backend.src.core.logger import belief_scope
from superset_tool.client import SupersetClient as BaseSupersetClient
from superset_tool.models import SupersetConfig
import json
import zipfile
from pathlib import Path
from typing import Any, 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.fileio import get_filename_from_headers
from .config_models import Environment
# [/SECTION]
# [DEF:SupersetClient:Class]
# @PURPOSE: Extended SupersetClient for migration-specific operations.
class SupersetClient(BaseSupersetClient):
# [DEF:get_databases_summary:Function]
# @PURPOSE: Fetch a summary of databases including uuid, name, and engine.
# @PRE: self.network must be initialized and authenticated.
# @POST: Returns a list of database dictionaries with 'engine' field.
# @RETURN: List[Dict] - Summary of databases.
def get_databases_summary(self) -> List[Dict]:
with belief_scope("SupersetClient.get_databases_summary"):
"""
Fetch a summary of databases including uuid, name, and engine.
"""
query = {
"columns": ["uuid", "database_name", "backend"]
}
_, databases = self.get_databases(query=query)
# Map 'backend' to 'engine' for consistency with contracts
for db in databases:
db['engine'] = db.pop('backend', None)
return databases
# [/DEF:get_databases_summary:Function]
# @PURPOSE: Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами.
class SupersetClient:
# [DEF:__init__:Function]
# @PURPOSE: Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
# @PRE: `env` должен быть валидным объектом Environment.
# @POST: Атрибуты `env` и `network` созданы и готовы к работе.
# @PARAM: env (Environment) - Конфигурация окружения.
def __init__(self, env: Environment):
with belief_scope("__init__"):
app_logger.info("[SupersetClient.__init__][Enter] Initializing SupersetClient for env %s.", env.name)
self.env = env
# Construct auth payload expected by Superset API
auth_payload = {
"username": env.username,
"password": env.password,
"provider": "db",
"refresh": "true"
}
self.network = APIClient(
config={
"base_url": env.url,
"auth": auth_payload
},
verify_ssl=env.verify_ssl,
timeout=env.timeout
)
self.delete_before_reimport: bool = False
app_logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.")
# [/DEF:__init__:Function]
# [DEF:get_database_by_uuid:Function]
# @PURPOSE: Find a database by its UUID.
# @PRE: db_uuid must be a string.
# @POST: Returns database metadata if found.
# @PARAM: db_uuid (str) - The UUID of the database.
# @RETURN: Optional[Dict] - Database info if found, else None.
def get_database_by_uuid(self, db_uuid: str) -> Optional[Dict]:
with belief_scope("SupersetClient.get_database_by_uuid", f"uuid={db_uuid}"):
"""
Find a database by its UUID.
"""
query = {
"filters": [{"col": "uuid", "op": "eq", "value": db_uuid}]
}
_, databases = self.get_databases(query=query)
return databases[0] if databases else None
# [/DEF:get_database_by_uuid:Function]
# [DEF:authenticate:Function]
# @PURPOSE: Authenticates the client using the configured credentials.
# @PRE: self.network must be initialized with valid auth configuration.
# @POST: Client is authenticated and tokens are stored.
# @RETURN: Dict[str, str] - Authentication tokens.
def authenticate(self) -> Dict[str, str]:
with belief_scope("SupersetClient.authenticate"):
return self.network.authenticate()
# [/DEF:authenticate:Function]
@property
# [DEF:headers:Function]
# @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
def headers(self) -> dict:
with belief_scope("headers"):
return self.network.headers
# [/DEF:headers:Function]
# [SECTION: DASHBOARD OPERATIONS]
# [DEF:get_dashboards:Function]
# @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию.
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса для API.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов).
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_dashboards"):
app_logger.info("[get_dashboards][Enter] Fetching dashboards.")
validated_query = self._validate_query_params(query or {})
if 'columns' not in validated_query:
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"]
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
paginated_data = self._fetch_all_pages(
endpoint="/dashboard/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
app_logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
return total_count, paginated_data
# [/DEF:get_dashboards:Function]
# [DEF:get_dashboards_summary:Function]
# @PURPOSE: Fetches dashboard metadata optimized for the grid.
# @PRE: self.network must be authenticated.
# @POST: Returns a list of dashboard dictionaries mapped to the grid schema.
# @RETURN: List[Dict]
def get_dashboards_summary(self) -> List[Dict]:
with belief_scope("SupersetClient.get_dashboards_summary"):
"""
Fetches dashboard metadata optimized for the grid.
Returns a list of dictionaries mapped to DashboardMetadata fields.
"""
query = {
"columns": ["id", "dashboard_title", "changed_on_utc", "published"]
}
@@ -86,34 +114,287 @@ class SupersetClient(BaseSupersetClient):
return result
# [/DEF:get_dashboards_summary:Function]
# [DEF:export_dashboard:Function]
# @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
# @PARAM: dashboard_id (int) - ID дашборда для экспорта.
# @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла.
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
with belief_scope("export_dashboard"):
app_logger.info("[export_dashboard][Enter] Exporting dashboard %s.", dashboard_id)
response = self.network.request(
method="GET",
endpoint="/dashboard/export/",
params={"q": json.dumps([dashboard_id])},
stream=True,
raw_response=True,
)
response = cast(Response, response)
self._validate_export_response(response, dashboard_id)
filename = self._resolve_export_filename(response, dashboard_id)
app_logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename)
return response.content, filename
# [/DEF:export_dashboard:Function]
# [DEF:import_dashboard:Function]
# @PURPOSE: Импортирует дашборд из ZIP-файла.
# @PARAM: file_name (Union[str, Path]) - Путь к ZIP-архиву.
# @PARAM: dash_id (Optional[int]) - ID дашборда для удаления при сбое.
# @PARAM: dash_slug (Optional[str]) - Slug дашборда для поиска ID.
# @RETURN: Dict - Ответ API в случае успеха.
def import_dashboard(self, file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict:
with belief_scope("import_dashboard"):
file_path = str(file_name)
self._validate_import_file(file_path)
try:
return self._do_import(file_path)
except Exception as exc:
app_logger.error("[import_dashboard][Failure] First import attempt failed: %s", exc, exc_info=True)
if not self.delete_before_reimport:
raise
target_id = self._resolve_target_id_for_delete(dash_id, dash_slug)
if target_id is None:
app_logger.error("[import_dashboard][Failure] No ID available for delete-retry.")
raise
self.delete_dashboard(target_id)
app_logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id)
return self._do_import(file_path)
# [/DEF:import_dashboard:Function]
# [DEF:delete_dashboard:Function]
# @PURPOSE: Удаляет дашборд по его ID или slug.
# @PARAM: dashboard_id (Union[int, str]) - ID или slug дашборда.
def delete_dashboard(self, dashboard_id: Union[int, str]) -> None:
with belief_scope("delete_dashboard"):
app_logger.info("[delete_dashboard][Enter] Deleting dashboard %s.", dashboard_id)
response = self.network.request(method="DELETE", endpoint=f"/dashboard/{dashboard_id}")
response = cast(Dict, response)
if response.get("result", True) is not False:
app_logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id)
else:
app_logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response)
# [/DEF:delete_dashboard:Function]
# [/SECTION]
# [SECTION: DATASET OPERATIONS]
# [DEF:get_datasets:Function]
# @PURPOSE: Получает полный список датасетов, автоматически обрабатывая пагинацию.
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список датасетов).
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_datasets"):
app_logger.info("[get_datasets][Enter] Fetching datasets.")
validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/dataset/")
paginated_data = self._fetch_all_pages(
endpoint="/dataset/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
app_logger.info("[get_datasets][Exit] Found %d datasets.", total_count)
return total_count, paginated_data
# [/DEF:get_datasets:Function]
# [DEF:get_dataset:Function]
# @PURPOSE: Fetch full dataset structure including columns and metrics.
# @PRE: dataset_id must be a valid integer.
# @POST: Returns full dataset metadata from Superset API.
# @PARAM: dataset_id (int) - The ID of the dataset.
# @RETURN: Dict - The dataset metadata.
# @PURPOSE: Получает информацию о конкретном датасете по его ID.
# @PARAM: dataset_id (int) - ID датасета.
# @RETURN: Dict - Информация о датасете.
def get_dataset(self, dataset_id: int) -> Dict:
with belief_scope("SupersetClient.get_dataset", f"id={dataset_id}"):
"""
Fetch full dataset structure.
"""
return self.network.get(f"/api/v1/dataset/{dataset_id}").json()
app_logger.info("[get_dataset][Enter] Fetching dataset %s.", dataset_id)
response = self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}")
response = cast(Dict, response)
app_logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id)
return response
# [/DEF:get_dataset:Function]
# [DEF:update_dataset:Function]
# @PURPOSE: Update dataset metadata.
# @PRE: dataset_id must be valid, data must be a valid Superset dataset payload.
# @POST: Dataset is updated in Superset.
# @PARAM: dataset_id (int) - The ID of the dataset.
# @PARAM: data (Dict) - The payload for update.
def update_dataset(self, dataset_id: int, data: Dict):
# @PURPOSE: Обновляет данные датасета по его ID.
# @PARAM: dataset_id (int) - ID датасета.
# @PARAM: data (Dict) - Данные для обновления.
# @RETURN: Dict - Ответ API.
def update_dataset(self, dataset_id: int, data: Dict) -> Dict:
with belief_scope("SupersetClient.update_dataset", f"id={dataset_id}"):
"""
Update dataset metadata.
"""
self.network.put(f"/api/v1/dataset/{dataset_id}", json=data)
app_logger.info("[update_dataset][Enter] Updating dataset %s.", dataset_id)
response = self.network.request(
method="PUT",
endpoint=f"/dataset/{dataset_id}",
data=json.dumps(data),
headers={'Content-Type': 'application/json'}
)
response = cast(Dict, response)
app_logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id)
return response
# [/DEF:update_dataset:Function]
# [/SECTION]
# [SECTION: DATABASE OPERATIONS]
# [DEF:get_databases:Function]
# @PURPOSE: Получает полный список баз данных.
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список баз данных).
def get_databases(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_databases"):
app_logger.info("[get_databases][Enter] Fetching databases.")
validated_query = self._validate_query_params(query or {})
if 'columns' not in validated_query:
validated_query['columns'] = []
total_count = self._fetch_total_object_count(endpoint="/database/")
paginated_data = self._fetch_all_pages(
endpoint="/database/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
app_logger.info("[get_databases][Exit] Found %d databases.", total_count)
return total_count, paginated_data
# [/DEF:get_databases:Function]
# [DEF:get_database:Function]
# @PURPOSE: Получает информацию о конкретной базе данных по её ID.
# @PARAM: database_id (int) - ID базы данных.
# @RETURN: Dict - Информация о базе данных.
def get_database(self, database_id: int) -> Dict:
with belief_scope("get_database"):
app_logger.info("[get_database][Enter] Fetching database %s.", database_id)
response = self.network.request(method="GET", endpoint=f"/database/{database_id}")
response = cast(Dict, response)
app_logger.info("[get_database][Exit] Got database %s.", database_id)
return response
# [/DEF:get_database:Function]
# [DEF:get_databases_summary:Function]
# @PURPOSE: Fetch a summary of databases including uuid, name, and engine.
# @RETURN: List[Dict] - Summary of databases.
def get_databases_summary(self) -> List[Dict]:
with belief_scope("SupersetClient.get_databases_summary"):
query = {
"columns": ["uuid", "database_name", "backend"]
}
_, databases = self.get_databases(query=query)
# Map 'backend' to 'engine' for consistency with contracts
for db in databases:
db['engine'] = db.pop('backend', None)
return databases
# [/DEF:get_databases_summary:Function]
# [DEF:get_database_by_uuid:Function]
# @PURPOSE: Find a database by its UUID.
# @PARAM: db_uuid (str) - The UUID of the database.
# @RETURN: Optional[Dict] - Database info if found, else None.
def get_database_by_uuid(self, db_uuid: str) -> Optional[Dict]:
with belief_scope("SupersetClient.get_database_by_uuid", f"uuid={db_uuid}"):
query = {
"filters": [{"col": "uuid", "op": "eq", "value": db_uuid}]
}
_, databases = self.get_databases(query=query)
return databases[0] if databases else None
# [/DEF:get_database_by_uuid:Function]
# [/SECTION]
# [SECTION: HELPERS]
# [DEF:_resolve_target_id_for_delete:Function]
def _resolve_target_id_for_delete(self, dash_id: Optional[int], dash_slug: Optional[str]) -> Optional[int]:
with belief_scope("_resolve_target_id_for_delete"):
if dash_id is not None:
return dash_id
if dash_slug is not None:
app_logger.debug("[_resolve_target_id_for_delete][State] Resolving ID by slug '%s'.", dash_slug)
try:
_, candidates = self.get_dashboards(query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]})
if candidates:
target_id = candidates[0]["id"]
app_logger.debug("[_resolve_target_id_for_delete][Success] Resolved slug to ID %s.", target_id)
return target_id
except Exception as e:
app_logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e)
return None
# [/DEF:_resolve_target_id_for_delete:Function]
# [DEF:_do_import:Function]
def _do_import(self, file_name: Union[str, Path]) -> Dict:
with belief_scope("_do_import"):
app_logger.debug(f"[_do_import][State] Uploading file: {file_name}")
file_path = Path(file_name)
if not file_path.exists():
app_logger.error(f"[_do_import][Failure] File does not exist: {file_name}")
raise FileNotFoundError(f"File does not exist: {file_name}")
return self.network.upload_file(
endpoint="/dashboard/import/",
file_info={"file_obj": file_path, "file_name": file_path.name, "form_field": "formData"},
extra_data={"overwrite": "true"},
timeout=self.env.timeout * 2,
)
# [/DEF:_do_import:Function]
# [DEF:_validate_export_response:Function]
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
with belief_scope("_validate_export_response"):
content_type = response.headers.get("Content-Type", "")
if "application/zip" not in content_type:
raise SupersetAPIError(f"Получен не ZIP-архив (Content-Type: {content_type})")
if not response.content:
raise SupersetAPIError("Получены пустые данные при экспорте")
# [/DEF:_validate_export_response:Function]
# [DEF:_resolve_export_filename:Function]
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
with belief_scope("_resolve_export_filename"):
filename = get_filename_from_headers(dict(response.headers))
if not filename:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
app_logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename)
return filename
# [/DEF:_resolve_export_filename:Function]
# [DEF:_validate_query_params:Function]
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
with belief_scope("_validate_query_params"):
base_query = {"page": 0, "page_size": 1000}
return {**base_query, **(query or {})}
# [/DEF:_validate_query_params:Function]
# [DEF:_fetch_total_object_count:Function]
def _fetch_total_object_count(self, endpoint: str) -> int:
with belief_scope("_fetch_total_object_count"):
return self.network.fetch_paginated_count(
endpoint=endpoint,
query_params={"page": 0, "page_size": 1},
count_field="count",
)
# [/DEF:_fetch_total_object_count:Function]
# [DEF:_fetch_all_pages:Function]
def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]:
with belief_scope("_fetch_all_pages"):
return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options)
# [/DEF:_fetch_all_pages:Function]
# [DEF:_validate_import_file:Function]
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
with belief_scope("_validate_import_file"):
path = Path(zip_path)
if not path.exists():
raise FileNotFoundError(f"Файл {zip_path} не существует")
if not zipfile.is_zipfile(path):
raise SupersetAPIError(f"Файл {zip_path} не является ZIP-архивом")
with zipfile.ZipFile(path, "r") as zf:
if not any(n.endswith("metadata.yaml") for n in zf.namelist()):
raise SupersetAPIError(f"Архив {zip_path} не содержит 'metadata.yaml'")
# [/DEF:_validate_import_file:Function]
# [/SECTION]
# [/DEF:SupersetClient:Class]
# [/DEF:backend.src.core.superset_client:Module]

View File

@@ -1,240 +1,237 @@
# [DEF:superset_tool.utils.dataset_mapper:Module]
#
# @SEMANTICS: dataset, mapping, postgresql, xlsx, superset
# @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> pandas
# @RELATION: DEPENDS_ON -> psycopg2
# @PUBLIC_API: DatasetMapper
# [SECTION: IMPORTS]
import pandas as pd # type: ignore
import psycopg2 # type: ignore
from superset_tool.client import SupersetClient
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.logger import SupersetLogger
from typing import Dict, List, Optional, Any
# [/SECTION]
# [DEF:DatasetMapper:Class]
# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset.
class DatasetMapper:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the mapper.
# @PRE: logger должен быть экземпляром SupersetLogger.
# @POST: Объект DatasetMapper инициализирован.
def __init__(self, logger: SupersetLogger):
self.logger = logger
# [/DEF:__init__:Function]
# [DEF:get_postgres_comments:Function]
# @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL.
# @PRE: db_config должен содержать валидные параметры подключения (host, port, user, password, dbname).
# @PRE: table_name и table_schema должны быть строками.
# @POST: Возвращается словарь, где ключи - имена колонок, значения - комментарии из БД.
# @THROW: Exception - При ошибках подключения или выполнения запроса к БД.
# @PARAM: db_config (Dict) - Конфигурация для подключения к БД.
# @PARAM: table_name (str) - Имя таблицы.
# @PARAM: table_schema (str) - Схема таблицы.
# @RETURN: Dict[str, str] - Словарь с комментариями к колонкам.
def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]:
with self.logger.belief_scope("Fetch comments from PostgreSQL"):
self.logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name)
query = f"""
SELECT
cols.column_name,
CASE
WHEN pg_catalog.col_description(
(SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = cols.table_name
AND n.nspname = cols.table_schema),
cols.ordinal_position::int
) LIKE '%|%' THEN
split_part(
pg_catalog.col_description(
(SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = cols.table_name
AND n.nspname = cols.table_schema),
cols.ordinal_position::int
),
'|',
1
)
ELSE
pg_catalog.col_description(
(SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = cols.table_name
AND n.nspname = cols.table_schema),
cols.ordinal_position::int
)
END AS column_comment
FROM
information_schema.columns cols
WHERE cols.table_catalog = '{db_config.get('dbname')}' AND cols.table_name = '{table_name}' AND cols.table_schema = '{table_schema}';
"""
comments = {}
try:
with psycopg2.connect(**db_config) as conn, conn.cursor() as cursor:
cursor.execute(query)
for row in cursor.fetchall():
if row[1]:
comments[row[0]] = row[1]
self.logger.info("[get_postgres_comments][Success] Fetched %d comments.", len(comments))
except Exception as e:
self.logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True)
raise
return comments
# [/DEF:get_postgres_comments:Function]
# [DEF:load_excel_mappings:Function]
# @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла.
# @PRE: file_path должен указывать на существующий XLSX файл.
# @POST: Возвращается словарь с меппингами из файла.
# @THROW: Exception - При ошибках чтения файла или парсинга.
# @PARAM: file_path (str) - Путь к XLSX файлу.
# @RETURN: Dict[str, str] - Словарь с меппингами.
def load_excel_mappings(self, file_path: str) -> Dict[str, str]:
with self.logger.belief_scope("Load mappings from Excel"):
self.logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path)
try:
df = pd.read_excel(file_path)
mappings = df.set_index('column_name')['verbose_name'].to_dict()
self.logger.info("[load_excel_mappings][Success] Loaded %d mappings.", len(mappings))
return mappings
except Exception as e:
self.logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True)
raise
# [/DEF:load_excel_mappings:Function]
# [DEF:run_mapping:Function]
# @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset.
# @PRE: superset_client должен быть авторизован.
# @PRE: dataset_id должен быть существующим ID в Superset.
# @POST: Если найдены изменения, датасет в Superset обновлен через API.
# @RELATION: CALLS -> self.get_postgres_comments
# @RELATION: CALLS -> self.load_excel_mappings
# @RELATION: CALLS -> superset_client.get_dataset
# @RELATION: CALLS -> superset_client.update_dataset
# @PARAM: superset_client (SupersetClient) - Клиент Superset.
# @PARAM: dataset_id (int) - ID датасета для обновления.
# @PARAM: source (str) - Источник данных ('postgres', 'excel', 'both').
# @PARAM: postgres_config (Optional[Dict]) - Конфигурация для подключения к PostgreSQL.
# @PARAM: excel_path (Optional[str]) - Путь к XLSX файлу.
# @PARAM: table_name (Optional[str]) - Имя таблицы в PostgreSQL.
# @PARAM: table_schema (Optional[str]) - Схема таблицы в PostgreSQL.
def run_mapping(self, superset_client: SupersetClient, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None):
with self.logger.belief_scope(f"Run dataset mapping for ID {dataset_id}"):
self.logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source)
mappings: Dict[str, str] = {}
try:
if source in ['postgres', 'both']:
assert postgres_config and table_name and table_schema, "Postgres config is required."
mappings.update(self.get_postgres_comments(postgres_config, table_name, table_schema))
if source in ['excel', 'both']:
assert excel_path, "Excel path is required."
mappings.update(self.load_excel_mappings(excel_path))
if source not in ['postgres', 'excel', 'both']:
self.logger.error("[run_mapping][Failure] Invalid source: %s.", source)
return
dataset_response = superset_client.get_dataset(dataset_id)
dataset_data = dataset_response['result']
original_columns = dataset_data.get('columns', [])
updated_columns = []
changes_made = False
for column in original_columns:
col_name = column.get('column_name')
new_column = {
"column_name": col_name,
"id": column.get("id"),
"advanced_data_type": column.get("advanced_data_type"),
"description": column.get("description"),
"expression": column.get("expression"),
"extra": column.get("extra"),
"filterable": column.get("filterable"),
"groupby": column.get("groupby"),
"is_active": column.get("is_active"),
"is_dttm": column.get("is_dttm"),
"python_date_format": column.get("python_date_format"),
"type": column.get("type"),
"uuid": column.get("uuid"),
"verbose_name": column.get("verbose_name"),
}
new_column = {k: v for k, v in new_column.items() if v is not None}
if col_name in mappings:
mapping_value = mappings[col_name]
if isinstance(mapping_value, str) and new_column.get('verbose_name') != mapping_value:
new_column['verbose_name'] = mapping_value
changes_made = True
updated_columns.append(new_column)
updated_metrics = []
for metric in dataset_data.get("metrics", []):
new_metric = {
"id": metric.get("id"),
"metric_name": metric.get("metric_name"),
"expression": metric.get("expression"),
"verbose_name": metric.get("verbose_name"),
"description": metric.get("description"),
"d3format": metric.get("d3format"),
"currency": metric.get("currency"),
"extra": metric.get("extra"),
"warning_text": metric.get("warning_text"),
"metric_type": metric.get("metric_type"),
"uuid": metric.get("uuid"),
}
updated_metrics.append({k: v for k, v in new_metric.items() if v is not None})
if changes_made:
payload_for_update = {
"database_id": dataset_data.get("database", {}).get("id"),
"table_name": dataset_data.get("table_name"),
"schema": dataset_data.get("schema"),
"columns": updated_columns,
"owners": [owner["id"] for owner in dataset_data.get("owners", [])],
"metrics": updated_metrics,
"extra": dataset_data.get("extra"),
"description": dataset_data.get("description"),
"sql": dataset_data.get("sql"),
"cache_timeout": dataset_data.get("cache_timeout"),
"catalog": dataset_data.get("catalog"),
"default_endpoint": dataset_data.get("default_endpoint"),
"external_url": dataset_data.get("external_url"),
"fetch_values_predicate": dataset_data.get("fetch_values_predicate"),
"filter_select_enabled": dataset_data.get("filter_select_enabled"),
"is_managed_externally": dataset_data.get("is_managed_externally"),
"is_sqllab_view": dataset_data.get("is_sqllab_view"),
"main_dttm_col": dataset_data.get("main_dttm_col"),
"normalize_columns": dataset_data.get("normalize_columns"),
"offset": dataset_data.get("offset"),
"template_params": dataset_data.get("template_params"),
}
payload_for_update = {k: v for k, v in payload_for_update.items() if v is not None}
superset_client.update_dataset(dataset_id, payload_for_update)
self.logger.info("[run_mapping][Success] Dataset %d columns' verbose_name updated.", dataset_id)
else:
self.logger.info("[run_mapping][State] No changes in columns' verbose_name, skipping update.")
except (AssertionError, FileNotFoundError, Exception) as e:
self.logger.error("[run_mapping][Failure] %s", e, exc_info=True)
return
# [/DEF:run_mapping:Function]
# [/DEF:DatasetMapper:Class]
# [/DEF:superset_tool.utils.dataset_mapper:Module]
# [DEF:backend.core.utils.dataset_mapper:Module]
#
# @SEMANTICS: dataset, mapping, postgresql, xlsx, superset
# @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> backend.core.superset_client
# @RELATION: DEPENDS_ON -> pandas
# @RELATION: DEPENDS_ON -> psycopg2
# @PUBLIC_API: DatasetMapper
# [SECTION: IMPORTS]
import pandas as pd # type: ignore
import psycopg2 # type: ignore
from typing import Dict, List, Optional, Any
from ..logger import logger as app_logger, belief_scope
# [/SECTION]
# [DEF:DatasetMapper:Class]
# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset.
class DatasetMapper:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the mapper.
# @POST: Объект DatasetMapper инициализирован.
def __init__(self):
pass
# [/DEF:__init__:Function]
# [DEF:get_postgres_comments:Function]
# @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL.
# @PRE: db_config должен содержать валидные параметры подключения (host, port, user, password, dbname).
# @PRE: table_name и table_schema должны быть строками.
# @POST: Возвращается словарь, где ключи - имена колонок, значения - комментарии из БД.
# @THROW: Exception - При ошибках подключения или выполнения запроса к БД.
# @PARAM: db_config (Dict) - Конфигурация для подключения к БД.
# @PARAM: table_name (str) - Имя таблицы.
# @PARAM: table_schema (str) - Схема таблицы.
# @RETURN: Dict[str, str] - Словарь с комментариями к колонкам.
def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]:
with belief_scope("Fetch comments from PostgreSQL"):
app_logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name)
query = f"""
SELECT
cols.column_name,
CASE
WHEN pg_catalog.col_description(
(SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = cols.table_name
AND n.nspname = cols.table_schema),
cols.ordinal_position::int
) LIKE '%|%' THEN
split_part(
pg_catalog.col_description(
(SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = cols.table_name
AND n.nspname = cols.table_schema),
cols.ordinal_position::int
),
'|',
1
)
ELSE
pg_catalog.col_description(
(SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = cols.table_name
AND n.nspname = cols.table_schema),
cols.ordinal_position::int
)
END AS column_comment
FROM
information_schema.columns cols
WHERE cols.table_catalog = '{db_config.get('dbname')}' AND cols.table_name = '{table_name}' AND cols.table_schema = '{table_schema}';
"""
comments = {}
try:
with psycopg2.connect(**db_config) as conn, conn.cursor() as cursor:
cursor.execute(query)
for row in cursor.fetchall():
if row[1]:
comments[row[0]] = row[1]
app_logger.info("[get_postgres_comments][Success] Fetched %d comments.", len(comments))
except Exception as e:
app_logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True)
raise
return comments
# [/DEF:get_postgres_comments:Function]
# [DEF:load_excel_mappings:Function]
# @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла.
# @PRE: file_path должен указывать на существующий XLSX файл.
# @POST: Возвращается словарь с меппингами из файла.
# @THROW: Exception - При ошибках чтения файла или парсинга.
# @PARAM: file_path (str) - Путь к XLSX файлу.
# @RETURN: Dict[str, str] - Словарь с меппингами.
def load_excel_mappings(self, file_path: str) -> Dict[str, str]:
with belief_scope("Load mappings from Excel"):
app_logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path)
try:
df = pd.read_excel(file_path)
mappings = df.set_index('column_name')['verbose_name'].to_dict()
app_logger.info("[load_excel_mappings][Success] Loaded %d mappings.", len(mappings))
return mappings
except Exception as e:
app_logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True)
raise
# [/DEF:load_excel_mappings:Function]
# [DEF:run_mapping:Function]
# @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset.
# @PRE: superset_client должен быть авторизован.
# @PRE: dataset_id должен быть существующим ID в Superset.
# @POST: Если найдены изменения, датасет в Superset обновлен через API.
# @RELATION: CALLS -> self.get_postgres_comments
# @RELATION: CALLS -> self.load_excel_mappings
# @RELATION: CALLS -> superset_client.get_dataset
# @RELATION: CALLS -> superset_client.update_dataset
# @PARAM: superset_client (Any) - Клиент Superset.
# @PARAM: dataset_id (int) - ID датасета для обновления.
# @PARAM: source (str) - Источник данных ('postgres', 'excel', 'both').
# @PARAM: postgres_config (Optional[Dict]) - Конфигурация для подключения к PostgreSQL.
# @PARAM: excel_path (Optional[str]) - Путь к XLSX файлу.
# @PARAM: table_name (Optional[str]) - Имя таблицы в PostgreSQL.
# @PARAM: table_schema (Optional[str]) - Схема таблицы в PostgreSQL.
def run_mapping(self, superset_client: Any, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None):
with belief_scope(f"Run dataset mapping for ID {dataset_id}"):
app_logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source)
mappings: Dict[str, str] = {}
try:
if source in ['postgres', 'both']:
assert postgres_config and table_name and table_schema, "Postgres config is required."
mappings.update(self.get_postgres_comments(postgres_config, table_name, table_schema))
if source in ['excel', 'both']:
assert excel_path, "Excel path is required."
mappings.update(self.load_excel_mappings(excel_path))
if source not in ['postgres', 'excel', 'both']:
app_logger.error("[run_mapping][Failure] Invalid source: %s.", source)
return
dataset_response = superset_client.get_dataset(dataset_id)
dataset_data = dataset_response['result']
original_columns = dataset_data.get('columns', [])
updated_columns = []
changes_made = False
for column in original_columns:
col_name = column.get('column_name')
new_column = {
"column_name": col_name,
"id": column.get("id"),
"advanced_data_type": column.get("advanced_data_type"),
"description": column.get("description"),
"expression": column.get("expression"),
"extra": column.get("extra"),
"filterable": column.get("filterable"),
"groupby": column.get("groupby"),
"is_active": column.get("is_active"),
"is_dttm": column.get("is_dttm"),
"python_date_format": column.get("python_date_format"),
"type": column.get("type"),
"uuid": column.get("uuid"),
"verbose_name": column.get("verbose_name"),
}
new_column = {k: v for k, v in new_column.items() if v is not None}
if col_name in mappings:
mapping_value = mappings[col_name]
if isinstance(mapping_value, str) and new_column.get('verbose_name') != mapping_value:
new_column['verbose_name'] = mapping_value
changes_made = True
updated_columns.append(new_column)
updated_metrics = []
for metric in dataset_data.get("metrics", []):
new_metric = {
"id": metric.get("id"),
"metric_name": metric.get("metric_name"),
"expression": metric.get("expression"),
"verbose_name": metric.get("verbose_name"),
"description": metric.get("description"),
"d3format": metric.get("d3format"),
"currency": metric.get("currency"),
"extra": metric.get("extra"),
"warning_text": metric.get("warning_text"),
"metric_type": metric.get("metric_type"),
"uuid": metric.get("uuid"),
}
updated_metrics.append({k: v for k, v in new_metric.items() if v is not None})
if changes_made:
payload_for_update = {
"database_id": dataset_data.get("database", {}).get("id"),
"table_name": dataset_data.get("table_name"),
"schema": dataset_data.get("schema"),
"columns": updated_columns,
"owners": [owner["id"] for owner in dataset_data.get("owners", [])],
"metrics": updated_metrics,
"extra": dataset_data.get("extra"),
"description": dataset_data.get("description"),
"sql": dataset_data.get("sql"),
"cache_timeout": dataset_data.get("cache_timeout"),
"catalog": dataset_data.get("catalog"),
"default_endpoint": dataset_data.get("default_endpoint"),
"external_url": dataset_data.get("external_url"),
"fetch_values_predicate": dataset_data.get("fetch_values_predicate"),
"filter_select_enabled": dataset_data.get("filter_select_enabled"),
"is_managed_externally": dataset_data.get("is_managed_externally"),
"is_sqllab_view": dataset_data.get("is_sqllab_view"),
"main_dttm_col": dataset_data.get("main_dttm_col"),
"normalize_columns": dataset_data.get("normalize_columns"),
"offset": dataset_data.get("offset"),
"template_params": dataset_data.get("template_params"),
}
payload_for_update = {k: v for k, v in payload_for_update.items() if v is not None}
superset_client.update_dataset(dataset_id, payload_for_update)
app_logger.info("[run_mapping][Success] Dataset %d columns' verbose_name updated.", dataset_id)
else:
app_logger.info("[run_mapping][State] No changes in columns' verbose_name, skipping update.")
except (AssertionError, FileNotFoundError, Exception) as e:
app_logger.error("[run_mapping][Failure] %s", e, exc_info=True)
return
# [/DEF:run_mapping:Function]
# [/DEF:DatasetMapper:Class]
# [/DEF:backend.core.utils.dataset_mapper:Module]

View File

@@ -1,507 +1,486 @@
# [DEF:superset_tool.utils.fileio:Module]
#
# @SEMANTICS: file, io, zip, yaml, temp, archive, utility
# @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> superset_tool.exceptions
# @RELATION: DEPENDS_ON -> superset_tool.utils.logger
# @RELATION: DEPENDS_ON -> pyyaml
# @PUBLIC_API: create_temp_file, remove_empty_directories, read_dashboard_from_disk, calculate_crc32, RetentionPolicy, archive_exports, save_and_unpack_dashboard, update_yamls, create_dashboard_export, sanitize_filename, get_filename_from_headers, consolidate_archive_folders
# [SECTION: IMPORTS]
import os
import re
import zipfile
from pathlib import Path
from typing import Any, Optional, Tuple, Dict, List, Union, LiteralString, Generator
from contextlib import contextmanager
import tempfile
from datetime import date, datetime
import glob
import shutil
import zlib
from dataclasses import dataclass
import yaml
from superset_tool.exceptions import InvalidZipFormatError
from superset_tool.utils.logger import SupersetLogger
# [/SECTION]
# [DEF:create_temp_file:Function]
# @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
# @PRE: suffix должен быть строкой, определяющей тип ресурса.
# @POST: Временный ресурс создан и путь к нему возвращен; ресурс удален после выхода из контекста.
# @PARAM: content (Optional[bytes]) - Бинарное содержимое для записи во временный файл.
# @PARAM: suffix (str) - Суффикс ресурса. Если `.dir`, создается директория.
# @PARAM: mode (str) - Режим записи в файл (e.g., 'wb').
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @YIELDS: Path - Путь к временному ресурсу.
# @THROW: IOError - При ошибках создания ресурса.
@contextmanager
def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode: str = 'wb', dry_run = False, logger: Optional[SupersetLogger] = None) -> Generator[Path, None, None]:
logger = logger or SupersetLogger(name="fileio")
with logger.belief_scope("Create temporary resource"):
resource_path = None
is_dir = suffix.startswith('.dir')
try:
if is_dir:
with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir:
resource_path = Path(temp_dir)
logger.debug("[create_temp_file][State] Created temporary directory: %s", resource_path)
yield resource_path
else:
fd, temp_path_str = tempfile.mkstemp(suffix=suffix)
resource_path = Path(temp_path_str)
os.close(fd)
if content:
resource_path.write_bytes(content)
logger.debug("[create_temp_file][State] Created temporary file: %s", resource_path)
yield resource_path
finally:
if resource_path and resource_path.exists() and not dry_run:
try:
if resource_path.is_dir():
shutil.rmtree(resource_path)
logger.debug("[create_temp_file][Cleanup] Removed temporary directory: %s", resource_path)
else:
resource_path.unlink()
logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path)
except OSError as e:
logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e)
# [/DEF:create_temp_file:Function]
# [DEF:remove_empty_directories:Function]
# @PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути.
# @PRE: root_dir должен быть путем к существующей директории.
# @POST: Все пустые поддиректории удалены, возвращено их количество.
# @PARAM: root_dir (str) - Путь к корневой директории для очистки.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: int - Количество удаленных директорий.
def remove_empty_directories(root_dir: str, logger: Optional[SupersetLogger] = None) -> int:
logger = logger or SupersetLogger(name="fileio")
with logger.belief_scope(f"Remove empty directories in {root_dir}"):
logger.info("[remove_empty_directories][Enter] Starting cleanup of empty directories in %s", root_dir)
removed_count = 0
if not os.path.isdir(root_dir):
logger.error("[remove_empty_directories][Failure] Directory not found: %s", root_dir)
return 0
for current_dir, _, _ in os.walk(root_dir, topdown=False):
if not os.listdir(current_dir):
try:
os.rmdir(current_dir)
removed_count += 1
logger.info("[remove_empty_directories][State] Removed empty directory: %s", current_dir)
except OSError as e:
logger.error("[remove_empty_directories][Failure] Failed to remove %s: %s", current_dir, e)
logger.info("[remove_empty_directories][Exit] Removed %d empty directories.", removed_count)
return removed_count
# [/DEF:remove_empty_directories:Function]
# [DEF:read_dashboard_from_disk:Function]
# @PURPOSE: Читает бинарное содержимое файла с диска.
# @PRE: file_path должен указывать на существующий файл.
# @POST: Возвращает байты содержимого и имя файла.
# @PARAM: file_path (str) - Путь к файлу.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла).
# @THROW: FileNotFoundError - Если файл не найден.
def read_dashboard_from_disk(file_path: str, logger: Optional[SupersetLogger] = None) -> Tuple[bytes, str]:
logger = logger or SupersetLogger(name="fileio")
with logger.belief_scope(f"Read dashboard from {file_path}"):
path = Path(file_path)
assert path.is_file(), f"Файл дашборда не найден: {file_path}"
logger.info("[read_dashboard_from_disk][Enter] Reading file: %s", file_path)
content = path.read_bytes()
if not content:
logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path)
return content, path.name
# [/DEF:read_dashboard_from_disk:Function]
# [DEF:calculate_crc32:Function]
# @PURPOSE: Вычисляет контрольную сумму CRC32 для файла.
# @PRE: file_path должен быть объектом Path к существующему файлу.
# @POST: Возвращает 8-значную hex-строку CRC32.
# @PARAM: file_path (Path) - Путь к файлу.
# @RETURN: str - 8-значное шестнадцатеричное представление CRC32.
# @THROW: IOError - При ошибках чтения файла.
def calculate_crc32(file_path: Path) -> str:
logger = SupersetLogger(name="fileio")
with logger.belief_scope(f"Calculate CRC32 for {file_path}"):
with open(file_path, 'rb') as f:
crc32_value = zlib.crc32(f.read())
return f"{crc32_value:08x}"
# [/DEF:calculate_crc32:Function]
# [SECTION: DATA_CLASSES]
# [DEF:RetentionPolicy:DataClass]
# @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
@dataclass
class RetentionPolicy:
daily: int = 7
weekly: int = 4
monthly: int = 12
# [/DEF:RetentionPolicy:DataClass]
# [/SECTION]
# [DEF:archive_exports:Function]
# @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
# @PRE: output_dir должен быть путем к существующей директории.
# @POST: Старые или дублирующиеся архивы удалены согласно политике.
# @RELATION: CALLS -> apply_retention_policy
# @RELATION: CALLS -> calculate_crc32
# @PARAM: output_dir (str) - Директория с архивами.
# @PARAM: policy (RetentionPolicy) - Политика хранения.
# @PARAM: deduplicate (bool) - Флаг для включения удаления дубликатов по CRC32.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False, logger: Optional[SupersetLogger] = None) -> None:
logger = logger or SupersetLogger(name="fileio")
with logger.belief_scope(f"Archive exports in {output_dir}"):
output_path = Path(output_dir)
if not output_path.is_dir():
logger.warning("[archive_exports][Skip] Archive directory not found: %s", output_dir)
return
logger.info("[archive_exports][Enter] Managing archive in %s", output_dir)
# 1. Collect all zip files
zip_files = list(output_path.glob("*.zip"))
if not zip_files:
logger.info("[archive_exports][State] No zip files found in %s", output_dir)
return
# 2. Deduplication
if deduplicate:
logger.info("[archive_exports][State] Starting deduplication...")
checksums = {}
files_to_remove = []
# Sort by modification time (newest first) to keep the latest version
zip_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
for file_path in zip_files:
try:
crc = calculate_crc32(file_path)
if crc in checksums:
files_to_remove.append(file_path)
logger.debug("[archive_exports][State] Duplicate found: %s (same as %s)", file_path.name, checksums[crc].name)
else:
checksums[crc] = file_path
except Exception as e:
logger.error("[archive_exports][Failure] Failed to calculate CRC32 for %s: %s", file_path, e)
for f in files_to_remove:
try:
f.unlink()
zip_files.remove(f)
logger.info("[archive_exports][State] Removed duplicate: %s", f.name)
except OSError as e:
logger.error("[archive_exports][Failure] Failed to remove duplicate %s: %s", f, e)
# 3. Retention Policy
files_with_dates = []
for file_path in zip_files:
# Try to extract date from filename
# Pattern: ..._YYYYMMDD_HHMMSS.zip or ..._YYYYMMDD.zip
match = re.search(r'_(\d{8})_', file_path.name)
file_date = None
if match:
try:
date_str = match.group(1)
file_date = datetime.strptime(date_str, "%Y%m%d").date()
except ValueError:
pass
if not file_date:
# Fallback to modification time
file_date = datetime.fromtimestamp(file_path.stat().st_mtime).date()
files_with_dates.append((file_path, file_date))
files_to_keep = apply_retention_policy(files_with_dates, policy, logger)
for file_path, _ in files_with_dates:
if file_path not in files_to_keep:
try:
file_path.unlink()
logger.info("[archive_exports][State] Removed by retention policy: %s", file_path.name)
except OSError as e:
logger.error("[archive_exports][Failure] Failed to remove %s: %s", file_path, e)
# [/DEF:archive_exports:Function]
# [DEF:apply_retention_policy:Function]
# @PURPOSE: (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
# @PRE: files_with_dates is a list of (Path, date) tuples.
# @POST: Returns a set of files to keep.
# @PARAM: files_with_dates (List[Tuple[Path, date]]) - Список файлов с датами.
# @PARAM: policy (RetentionPolicy) - Политика хранения.
# @PARAM: logger (SupersetLogger) - Логгер.
# @RETURN: set - Множество путей к файлам, которые должны быть сохранены.
def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: RetentionPolicy, logger: SupersetLogger) -> set:
with logger.belief_scope("Apply retention policy"):
# Сортируем по дате (от новой к старой)
sorted_files = sorted(files_with_dates, key=lambda x: x[1], reverse=True)
# Словарь для хранения файлов по категориям
daily_files = []
weekly_files = []
monthly_files = []
today = date.today()
for file_path, file_date in sorted_files:
# Ежедневные
if (today - file_date).days < policy.daily:
daily_files.append(file_path)
# Еженедельные
elif (today - file_date).days < policy.weekly * 7:
weekly_files.append(file_path)
# Ежемесячные
elif (today - file_date).days < policy.monthly * 30:
monthly_files.append(file_path)
# Возвращаем множество файлов, которые нужно сохранить
files_to_keep = set()
files_to_keep.update(daily_files)
files_to_keep.update(weekly_files[:policy.weekly])
files_to_keep.update(monthly_files[:policy.monthly])
logger.debug("[apply_retention_policy][State] Keeping %d files according to retention policy", len(files_to_keep))
return files_to_keep
# [/DEF:apply_retention_policy:Function]
# [DEF:save_and_unpack_dashboard:Function]
# @PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
# @PRE: zip_content должен быть байтами валидного ZIP-архива.
# @POST: ZIP-файл сохранен, и если unpack=True, он распакован в output_dir.
# @PARAM: zip_content (bytes) - Содержимое ZIP-архива.
# @PARAM: output_dir (Union[str, Path]) - Директория для сохранения.
# @PARAM: unpack (bool) - Флаг, нужно ли распаковывать архив.
# @PARAM: original_filename (Optional[str]) - Исходное имя файла для сохранения.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: Tuple[Path, Optional[Path]] - Путь к ZIP-файлу и, если применимо, путь к директории с распаковкой.
# @THROW: InvalidZipFormatError - При ошибке формата ZIP.
def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path], unpack: bool = False, original_filename: Optional[str] = None, logger: Optional[SupersetLogger] = None) -> Tuple[Path, Optional[Path]]:
logger = logger or SupersetLogger(name="fileio")
with logger.belief_scope("Save and unpack dashboard"):
logger.info("[save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: %s", unpack)
try:
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
zip_name = sanitize_filename(original_filename) if original_filename else f"dashboard_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
zip_path = output_path / zip_name
zip_path.write_bytes(zip_content)
logger.info("[save_and_unpack_dashboard][State] Dashboard saved to: %s", zip_path)
if unpack:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(output_path)
logger.info("[save_and_unpack_dashboard][State] Dashboard unpacked to: %s", output_path)
return zip_path, output_path
return zip_path, None
except zipfile.BadZipFile as e:
logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e)
raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e
# [/DEF:save_and_unpack_dashboard:Function]
# [DEF:update_yamls:Function]
# @PURPOSE: Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex.
# @PRE: path должен быть существующей директорией.
# @POST: Все YAML файлы в директории обновлены согласно переданным параметрам.
# @RELATION: CALLS -> _update_yaml_file
# @THROW: FileNotFoundError - Если `path` не существует.
# @PARAM: db_configs (Optional[List[Dict]]) - Список конфигураций для замены.
# @PARAM: path (str) - Путь к директории с YAML файлами.
# @PARAM: regexp_pattern (Optional[LiteralString]) - Паттерн для поиска.
# @PARAM: replace_string (Optional[LiteralString]) - Строка для замены.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def update_yamls(db_configs: Optional[List[Dict[str, Any]]] = None, path: str = "dashboards", regexp_pattern: Optional[LiteralString] = None, replace_string: Optional[LiteralString] = None, logger: Optional[SupersetLogger] = None) -> None:
logger = logger or SupersetLogger(name="fileio")
with logger.belief_scope("Update YAML configurations"):
logger.info("[update_yamls][Enter] Starting YAML configuration update.")
dir_path = Path(path)
assert dir_path.is_dir(), f"Путь {path} не существует или не является директорией"
configs: List[Dict[str, Any]] = db_configs or []
for file_path in dir_path.rglob("*.yaml"):
_update_yaml_file(file_path, configs, regexp_pattern, replace_string, logger)
# [/DEF:update_yamls:Function]
# [DEF:_update_yaml_file:Function]
# @PURPOSE: (Helper) Обновляет один YAML файл.
# @PRE: file_path должен быть объектом Path к существующему YAML файлу.
# @POST: Файл обновлен согласно переданным конфигурациям или регулярному выражению.
# @PARAM: file_path (Path) - Путь к файлу.
# @PARAM: db_configs (List[Dict]) - Конфигурации.
# @PARAM: regexp_pattern (Optional[str]) - Паттерн.
# @PARAM: replace_string (Optional[str]) - Замена.
# @PARAM: logger (SupersetLogger) - Логгер.
def _update_yaml_file(file_path: Path, db_configs: List[Dict[str, Any]], regexp_pattern: Optional[str], replace_string: Optional[str], logger: SupersetLogger) -> None:
with logger.belief_scope(f"Update YAML file: {file_path}"):
# Читаем содержимое файла
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
logger.error("[_update_yaml_file][Failure] Failed to read %s: %s", file_path, e)
return
# Если задан pattern и replace_string, применяем замену по регулярному выражению
if regexp_pattern and replace_string:
try:
new_content = re.sub(regexp_pattern, replace_string, content)
if new_content != content:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(new_content)
logger.info("[_update_yaml_file][State] Updated %s using regex pattern", file_path)
except Exception as e:
logger.error("[_update_yaml_file][Failure] Error applying regex to %s: %s", file_path, e)
# Если заданы конфигурации, заменяем значения (поддержка old/new)
if db_configs:
try:
# Прямой текстовый заменитель для старых/новых значений, чтобы сохранить структуру файла
modified_content = content
for cfg in db_configs:
# Ожидаем структуру: {'old': {...}, 'new': {...}}
old_cfg = cfg.get('old', {})
new_cfg = cfg.get('new', {})
for key, old_val in old_cfg.items():
if key in new_cfg:
new_val = new_cfg[key]
# Заменяем только точные совпадения старого значения в тексте YAML, используя ключ для контекста
if isinstance(old_val, str):
# Ищем паттерн: key: "value" или key: value
key_pattern = re.escape(key)
val_pattern = re.escape(old_val)
# Группы: 1=ключ+разделитель, 2=открывающая кавычка (опц), 3=значение, 4=закрывающая кавычка (опц)
pattern = rf'({key_pattern}\s*:\s*)(["\']?)({val_pattern})(["\']?)'
# [DEF:replacer:Function]
# @PURPOSE: Функция замены, сохраняющая кавычки если они были.
# @PRE: match должен быть объектом совпадения регулярного выражения.
# @POST: Возвращает строку с новым значением, сохраняя префикс и кавычки.
def replacer(match):
with logger.belief_scope("replacer"):
prefix = match.group(1)
quote_open = match.group(2)
quote_close = match.group(4)
return f"{prefix}{quote_open}{new_val}{quote_close}"
# [/DEF:replacer:Function]
modified_content = re.sub(pattern, replacer, modified_content)
logger.info("[_update_yaml_file][State] Replaced '%s' with '%s' for key %s in %s", old_val, new_val, key, file_path)
# Записываем обратно изменённый контент без парсинга YAML, сохраняем оригинальное форматирование
with open(file_path, 'w', encoding='utf-8') as f:
f.write(modified_content)
except Exception as e:
logger.error("[_update_yaml_file][Failure] Error performing raw replacement in %s: %s", file_path, e)
# [/DEF:_update_yaml_file:Function]
# [DEF:create_dashboard_export:Function]
# @PURPOSE: Создает ZIP-архив из указанных исходных путей.
# @PRE: source_paths должен содержать существующие пути.
# @POST: ZIP-архив создан по пути zip_path.
# @PARAM: zip_path (Union[str, Path]) - Путь для сохранения ZIP архива.
# @PARAM: source_paths (List[Union[str, Path]]) - Список исходных путей для архивации.
# @PARAM: exclude_extensions (Optional[List[str]]) - Список расширений для исключения.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
# @RETURN: bool - `True` при успехе, `False` при ошибке.
def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union[str, Path]], exclude_extensions: Optional[List[str]] = None, logger: Optional[SupersetLogger] = None) -> bool:
logger = logger or SupersetLogger(name="fileio")
with logger.belief_scope(f"Create dashboard export: {zip_path}"):
logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path)
try:
exclude_ext = [ext.lower() for ext in exclude_extensions or []]
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for src_path_str in source_paths:
src_path = Path(src_path_str)
assert src_path.exists(), f"Путь не найден: {src_path}"
for item in src_path.rglob('*'):
if item.is_file() and item.suffix.lower() not in exclude_ext:
arcname = item.relative_to(src_path.parent)
zipf.write(item, arcname)
logger.info("[create_dashboard_export][Exit] Archive created: %s", zip_path)
return True
except (IOError, zipfile.BadZipFile, AssertionError) as e:
logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True)
return False
# [/DEF:create_dashboard_export:Function]
# [DEF:sanitize_filename:Function]
# @PURPOSE: Очищает строку от символов, недопустимых в именах файлов.
# @PRE: filename должен быть строкой.
# @POST: Возвращает строку без спецсимволов.
# @PARAM: filename (str) - Исходное имя файла.
# @RETURN: str - Очищенная строка.
def sanitize_filename(filename: str) -> str:
logger = SupersetLogger(name="fileio")
with logger.belief_scope(f"Sanitize filename: {filename}"):
return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
# [/DEF:sanitize_filename:Function]
# [DEF:get_filename_from_headers:Function]
# @PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
# @PRE: headers должен быть словарем заголовков.
# @POST: Возвращает имя файла или None, если заголовок отсутствует.
# @PARAM: headers (dict) - Словарь HTTP заголовков.
# @RETURN: Optional[str] - Имя файла or `None`.
def get_filename_from_headers(headers: dict) -> Optional[str]:
logger = SupersetLogger(name="fileio")
with logger.belief_scope("Get filename from headers"):
content_disposition = headers.get("Content-Disposition", "")
if match := re.search(r'filename="?([^"]+)"?', content_disposition):
return match.group(1).strip()
return None
# [/DEF:get_filename_from_headers:Function]
# [DEF:consolidate_archive_folders:Function]
# @PURPOSE: Консолидирует директории архивов на основе общего слага в имени.
# @PRE: root_directory должен быть объектом Path к существующей директории.
# @POST: Директории с одинаковым префиксом объединены в одну.
# @THROW: TypeError, ValueError - Если `root_directory` невалиден.
# @PARAM: root_directory (Path) - Корневая директория для консолидации.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def consolidate_archive_folders(root_directory: Path, logger: Optional[SupersetLogger] = None) -> None:
logger = logger or SupersetLogger(name="fileio")
with logger.belief_scope(f"Consolidate archives in {root_directory}"):
assert isinstance(root_directory, Path), "root_directory must be a Path object."
assert root_directory.is_dir(), "root_directory must be an existing directory."
logger.info("[consolidate_archive_folders][Enter] Consolidating archives in %s", root_directory)
# Собираем все директории с архивами
archive_dirs = []
for item in root_directory.iterdir():
if item.is_dir():
# Проверяем, есть ли в директории ZIP-архивы
if any(item.glob("*.zip")):
archive_dirs.append(item)
# Группируем по слагу (части имени до первого '_')
slug_groups = {}
for dir_path in archive_dirs:
dir_name = dir_path.name
slug = dir_name.split('_')[0] if '_' in dir_name else dir_name
if slug not in slug_groups:
slug_groups[slug] = []
slug_groups[slug].append(dir_path)
# Для каждой группы консолидируем
for slug, dirs in slug_groups.items():
if len(dirs) <= 1:
continue
# Создаем целевую директорию
target_dir = root_directory / slug
target_dir.mkdir(exist_ok=True)
logger.info("[consolidate_archive_folders][State] Consolidating %d directories under %s", len(dirs), target_dir)
# Перемещаем содержимое
for source_dir in dirs:
if source_dir == target_dir:
continue
for item in source_dir.iterdir():
dest_item = target_dir / item.name
try:
if item.is_dir():
shutil.move(str(item), str(dest_item))
else:
shutil.move(str(item), str(dest_item))
except Exception as e:
logger.error("[consolidate_archive_folders][Failure] Failed to move %s to %s: %s", item, dest_item, e)
# Удаляем исходную директорию
try:
source_dir.rmdir()
logger.info("[consolidate_archive_folders][State] Removed source directory: %s", source_dir)
except Exception as e:
logger.error("[consolidate_archive_folders][Failure] Failed to remove source directory %s: %s", source_dir, e)
# [/DEF:consolidate_archive_folders:Function]
# [/DEF:superset_tool.utils.fileio:Module]
# [DEF:backend.core.utils.fileio:Module]
#
# @SEMANTICS: file, io, zip, yaml, temp, archive, utility
# @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> backend.src.core.logger
# @RELATION: DEPENDS_ON -> pyyaml
# @PUBLIC_API: create_temp_file, remove_empty_directories, read_dashboard_from_disk, calculate_crc32, RetentionPolicy, archive_exports, save_and_unpack_dashboard, update_yamls, create_dashboard_export, sanitize_filename, get_filename_from_headers, consolidate_archive_folders
# [SECTION: IMPORTS]
import os
import re
import zipfile
from pathlib import Path
from typing import Any, Optional, Tuple, Dict, List, Union, LiteralString, Generator
from contextlib import contextmanager
import tempfile
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]
# [DEF:InvalidZipFormatError:Class]
class InvalidZipFormatError(Exception):
pass
# [DEF:create_temp_file:Function]
# @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
# @PRE: suffix должен быть строкой, определяющей тип ресурса.
# @POST: Временный ресурс создан и путь к нему возвращен; ресурс удален после выхода из контекста.
# @PARAM: content (Optional[bytes]) - Бинарное содержимое для записи во временный файл.
# @PARAM: suffix (str) - Суффикс ресурса. Если `.dir`, создается директория.
# @PARAM: mode (str) - Режим записи в файл (e.g., 'wb').
# @YIELDS: Path - Путь к временному ресурсу.
# @THROW: IOError - При ошибках создания ресурса.
@contextmanager
def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode: str = 'wb', dry_run = False) -> Generator[Path, None, None]:
with belief_scope("Create temporary resource"):
resource_path = None
is_dir = suffix.startswith('.dir')
try:
if is_dir:
with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir:
resource_path = Path(temp_dir)
app_logger.debug("[create_temp_file][State] Created temporary directory: %s", resource_path)
yield resource_path
else:
fd, temp_path_str = tempfile.mkstemp(suffix=suffix)
resource_path = Path(temp_path_str)
os.close(fd)
if content:
resource_path.write_bytes(content)
app_logger.debug("[create_temp_file][State] Created temporary file: %s", resource_path)
yield resource_path
finally:
if resource_path and resource_path.exists() and not dry_run:
try:
if resource_path.is_dir():
shutil.rmtree(resource_path)
app_logger.debug("[create_temp_file][Cleanup] Removed temporary directory: %s", resource_path)
else:
resource_path.unlink()
app_logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path)
except OSError as e:
app_logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e)
# [/DEF:create_temp_file:Function]
# [DEF:remove_empty_directories:Function]
# @PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути.
# @PRE: root_dir должен быть путем к существующей директории.
# @POST: Все пустые поддиректории удалены, возвращено их количество.
# @PARAM: root_dir (str) - Путь к корневой директории для очистки.
# @RETURN: int - Количество удаленных директорий.
def remove_empty_directories(root_dir: str) -> int:
with belief_scope(f"Remove empty directories in {root_dir}"):
app_logger.info("[remove_empty_directories][Enter] Starting cleanup of empty directories in %s", root_dir)
removed_count = 0
if not os.path.isdir(root_dir):
app_logger.error("[remove_empty_directories][Failure] Directory not found: %s", root_dir)
return 0
for current_dir, _, _ in os.walk(root_dir, topdown=False):
if not os.listdir(current_dir):
try:
os.rmdir(current_dir)
removed_count += 1
app_logger.info("[remove_empty_directories][State] Removed empty directory: %s", current_dir)
except OSError as e:
app_logger.error("[remove_empty_directories][Failure] Failed to remove %s: %s", current_dir, e)
app_logger.info("[remove_empty_directories][Exit] Removed %d empty directories.", removed_count)
return removed_count
# [/DEF:remove_empty_directories:Function]
# [DEF:read_dashboard_from_disk:Function]
# @PURPOSE: Читает бинарное содержимое файла с диска.
# @PRE: file_path должен указывать на существующий файл.
# @POST: Возвращает байты содержимого и имя файла.
# @PARAM: file_path (str) - Путь к файлу.
# @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла).
# @THROW: FileNotFoundError - Если файл не найден.
def read_dashboard_from_disk(file_path: str) -> Tuple[bytes, str]:
with belief_scope(f"Read dashboard from {file_path}"):
path = Path(file_path)
assert path.is_file(), f"Файл дашборда не найден: {file_path}"
app_logger.info("[read_dashboard_from_disk][Enter] Reading file: %s", file_path)
content = path.read_bytes()
if not content:
app_logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path)
return content, path.name
# [/DEF:read_dashboard_from_disk:Function]
# [DEF:calculate_crc32:Function]
# @PURPOSE: Вычисляет контрольную сумму CRC32 для файла.
# @PRE: file_path должен быть объектом Path к существующему файлу.
# @POST: Возвращает 8-значную hex-строку CRC32.
# @PARAM: file_path (Path) - Путь к файлу.
# @RETURN: str - 8-значное шестнадцатеричное представление CRC32.
# @THROW: IOError - При ошибках чтения файла.
def calculate_crc32(file_path: Path) -> str:
with belief_scope(f"Calculate CRC32 for {file_path}"):
with open(file_path, 'rb') as f:
crc32_value = zlib.crc32(f.read())
return f"{crc32_value:08x}"
# [/DEF:calculate_crc32:Function]
# [SECTION: DATA_CLASSES]
# [DEF:RetentionPolicy:DataClass]
# @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
@dataclass
class RetentionPolicy:
daily: int = 7
weekly: int = 4
monthly: int = 12
# [/DEF:RetentionPolicy:DataClass]
# [/SECTION]
# [DEF:archive_exports:Function]
# @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
# @PRE: output_dir должен быть путем к существующей директории.
# @POST: Старые или дублирующиеся архивы удалены согласно политике.
# @RELATION: CALLS -> apply_retention_policy
# @RELATION: CALLS -> calculate_crc32
# @PARAM: output_dir (str) - Директория с архивами.
# @PARAM: policy (RetentionPolicy) - Политика хранения.
# @PARAM: deduplicate (bool) - Флаг для включения удаления дубликатов по CRC32.
def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False) -> None:
with belief_scope(f"Archive exports in {output_dir}"):
output_path = Path(output_dir)
if not output_path.is_dir():
app_logger.warning("[archive_exports][Skip] Archive directory not found: %s", output_dir)
return
app_logger.info("[archive_exports][Enter] Managing archive in %s", output_dir)
# 1. Collect all zip files
zip_files = list(output_path.glob("*.zip"))
if not zip_files:
app_logger.info("[archive_exports][State] No zip files found in %s", output_dir)
return
# 2. Deduplication
if deduplicate:
app_logger.info("[archive_exports][State] Starting deduplication...")
checksums = {}
files_to_remove = []
# Sort by modification time (newest first) to keep the latest version
zip_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
for file_path in zip_files:
try:
crc = calculate_crc32(file_path)
if crc in checksums:
files_to_remove.append(file_path)
app_logger.debug("[archive_exports][State] Duplicate found: %s (same as %s)", file_path.name, checksums[crc].name)
else:
checksums[crc] = file_path
except Exception as e:
app_logger.error("[archive_exports][Failure] Failed to calculate CRC32 for %s: %s", file_path, e)
for f in files_to_remove:
try:
f.unlink()
zip_files.remove(f)
app_logger.info("[archive_exports][State] Removed duplicate: %s", f.name)
except OSError as e:
app_logger.error("[archive_exports][Failure] Failed to remove duplicate %s: %s", f, e)
# 3. Retention Policy
files_with_dates = []
for file_path in zip_files:
# Try to extract date from filename
# Pattern: ..._YYYYMMDD_HHMMSS.zip or ..._YYYYMMDD.zip
match = re.search(r'_(\d{8})_', file_path.name)
file_date = None
if match:
try:
date_str = match.group(1)
file_date = datetime.strptime(date_str, "%Y%m%d").date()
except ValueError:
pass
if not file_date:
# Fallback to modification time
file_date = datetime.fromtimestamp(file_path.stat().st_mtime).date()
files_with_dates.append((file_path, file_date))
files_to_keep = apply_retention_policy(files_with_dates, policy)
for file_path, _ in files_with_dates:
if file_path not in files_to_keep:
try:
file_path.unlink()
app_logger.info("[archive_exports][State] Removed by retention policy: %s", file_path.name)
except OSError as e:
app_logger.error("[archive_exports][Failure] Failed to remove %s: %s", file_path, e)
# [/DEF:archive_exports:Function]
# [DEF:apply_retention_policy:Function]
# @PURPOSE: (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
# @PRE: files_with_dates is a list of (Path, date) tuples.
# @POST: Returns a set of files to keep.
# @PARAM: files_with_dates (List[Tuple[Path, date]]) - Список файлов с датами.
# @PARAM: policy (RetentionPolicy) - Политика хранения.
# @RETURN: set - Множество путей к файлам, которые должны быть сохранены.
def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: RetentionPolicy) -> set:
with belief_scope("Apply retention policy"):
# Сортируем по дате (от новой к старой)
sorted_files = sorted(files_with_dates, key=lambda x: x[1], reverse=True)
# Словарь для хранения файлов по категориям
daily_files = []
weekly_files = []
monthly_files = []
today = date.today()
for file_path, file_date in sorted_files:
# Ежедневные
if (today - file_date).days < policy.daily:
daily_files.append(file_path)
# Еженедельные
elif (today - file_date).days < policy.weekly * 7:
weekly_files.append(file_path)
# Ежемесячные
elif (today - file_date).days < policy.monthly * 30:
monthly_files.append(file_path)
# Возвращаем множество файлов, которые нужно сохранить
files_to_keep = set()
files_to_keep.update(daily_files)
files_to_keep.update(weekly_files[:policy.weekly])
files_to_keep.update(monthly_files[:policy.monthly])
app_logger.debug("[apply_retention_policy][State] Keeping %d files according to retention policy", len(files_to_keep))
return files_to_keep
# [/DEF:apply_retention_policy:Function]
# [DEF:save_and_unpack_dashboard:Function]
# @PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
# @PRE: zip_content должен быть байтами валидного ZIP-архива.
# @POST: ZIP-файл сохранен, и если unpack=True, он распакован в output_dir.
# @PARAM: zip_content (bytes) - Содержимое ZIP-архива.
# @PARAM: output_dir (Union[str, Path]) - Директория для сохранения.
# @PARAM: unpack (bool) - Флаг, нужно ли распаковывать архив.
# @PARAM: original_filename (Optional[str]) - Исходное имя файла для сохранения.
# @RETURN: Tuple[Path, Optional[Path]] - Путь к ZIP-файлу и, если применимо, путь к директории с распаковкой.
# @THROW: InvalidZipFormatError - При ошибке формата ZIP.
def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path], unpack: bool = False, original_filename: Optional[str] = None) -> Tuple[Path, Optional[Path]]:
with belief_scope("Save and unpack dashboard"):
app_logger.info("[save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: %s", unpack)
try:
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
zip_name = sanitize_filename(original_filename) if original_filename else f"dashboard_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
zip_path = output_path / zip_name
zip_path.write_bytes(zip_content)
app_logger.info("[save_and_unpack_dashboard][State] Dashboard saved to: %s", zip_path)
if unpack:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(output_path)
app_logger.info("[save_and_unpack_dashboard][State] Dashboard unpacked to: %s", output_path)
return zip_path, output_path
return zip_path, None
except zipfile.BadZipFile as e:
app_logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e)
raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e
# [/DEF:save_and_unpack_dashboard:Function]
# [DEF:update_yamls:Function]
# @PURPOSE: Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex.
# @PRE: path должен быть существующей директорией.
# @POST: Все YAML файлы в директории обновлены согласно переданным параметрам.
# @RELATION: CALLS -> _update_yaml_file
# @THROW: FileNotFoundError - Если `path` не существует.
# @PARAM: db_configs (Optional[List[Dict]]) - Список конфигураций для замены.
# @PARAM: path (str) - Путь к директории с YAML файлами.
# @PARAM: regexp_pattern (Optional[LiteralString]) - Паттерн для поиска.
# @PARAM: replace_string (Optional[LiteralString]) - Строка для замены.
def update_yamls(db_configs: Optional[List[Dict[str, Any]]] = None, path: str = "dashboards", regexp_pattern: Optional[LiteralString] = None, replace_string: Optional[LiteralString] = None) -> None:
with belief_scope("Update YAML configurations"):
app_logger.info("[update_yamls][Enter] Starting YAML configuration update.")
dir_path = Path(path)
assert dir_path.is_dir(), f"Путь {path} не существует или не является директорией"
configs: List[Dict[str, Any]] = db_configs or []
for file_path in dir_path.rglob("*.yaml"):
_update_yaml_file(file_path, configs, regexp_pattern, replace_string)
# [/DEF:update_yamls:Function]
# [DEF:_update_yaml_file:Function]
# @PURPOSE: (Helper) Обновляет один YAML файл.
# @PRE: file_path должен быть объектом Path к существующему YAML файлу.
# @POST: Файл обновлен согласно переданным конфигурациям или регулярному выражению.
# @PARAM: file_path (Path) - Путь к файлу.
# @PARAM: db_configs (List[Dict]) - Конфигурации.
# @PARAM: regexp_pattern (Optional[str]) - Паттерн.
# @PARAM: replace_string (Optional[str]) - Замена.
def _update_yaml_file(file_path: Path, db_configs: List[Dict[str, Any]], regexp_pattern: Optional[str], replace_string: Optional[str]) -> None:
with belief_scope(f"Update YAML file: {file_path}"):
# Читаем содержимое файла
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
app_logger.error("[_update_yaml_file][Failure] Failed to read %s: %s", file_path, e)
return
# Если задан pattern и replace_string, применяем замену по регулярному выражению
if regexp_pattern and replace_string:
try:
new_content = re.sub(regexp_pattern, replace_string, content)
if new_content != content:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(new_content)
app_logger.info("[_update_yaml_file][State] Updated %s using regex pattern", file_path)
except Exception as e:
app_logger.error("[_update_yaml_file][Failure] Error applying regex to %s: %s", file_path, e)
# Если заданы конфигурации, заменяем значения (поддержка old/new)
if db_configs:
try:
# Прямой текстовый заменитель для старых/новых значений, чтобы сохранить структуру файла
modified_content = content
for cfg in db_configs:
# Ожидаем структуру: {'old': {...}, 'new': {...}}
old_cfg = cfg.get('old', {})
new_cfg = cfg.get('new', {})
for key, old_val in old_cfg.items():
if key in new_cfg:
new_val = new_cfg[key]
# Заменяем только точные совпадения старого значения в тексте YAML, используя ключ для контекста
if isinstance(old_val, str):
# Ищем паттерн: key: "value" или key: value
key_pattern = re.escape(key)
val_pattern = re.escape(old_val)
# Группы: 1=ключ+разделитель, 2=открывающая кавычка (опц), 3=значение, 4=закрывающая кавычка (опц)
pattern = rf'({key_pattern}\s*:\s*)(["\']?)({val_pattern})(["\']?)'
# [DEF:replacer:Function]
# @PURPOSE: Функция замены, сохраняющая кавычки если они были.
# @PRE: match должен быть объектом совпадения регулярного выражения.
# @POST: Возвращает строку с новым значением, сохраняя префикс и кавычки.
def replacer(match):
prefix = match.group(1)
quote_open = match.group(2)
quote_close = match.group(4)
return f"{prefix}{quote_open}{new_val}{quote_close}"
# [/DEF:replacer:Function]
modified_content = re.sub(pattern, replacer, modified_content)
app_logger.info("[_update_yaml_file][State] Replaced '%s' with '%s' for key %s in %s", old_val, new_val, key, file_path)
# Записываем обратно изменённый контент без парсинга YAML, сохраняем оригинальное форматирование
with open(file_path, 'w', encoding='utf-8') as f:
f.write(modified_content)
except Exception as e:
app_logger.error("[_update_yaml_file][Failure] Error performing raw replacement in %s: %s", file_path, e)
# [/DEF:_update_yaml_file:Function]
# [DEF:create_dashboard_export:Function]
# @PURPOSE: Создает ZIP-архив из указанных исходных путей.
# @PRE: source_paths должен содержать существующие пути.
# @POST: ZIP-архив создан по пути zip_path.
# @PARAM: zip_path (Union[str, Path]) - Путь для сохранения ZIP архива.
# @PARAM: source_paths (List[Union[str, Path]]) - Список исходных путей для архивации.
# @PARAM: exclude_extensions (Optional[List[str]]) - Список расширений для исключения.
# @RETURN: bool - `True` при успехе, `False` при ошибке.
def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union[str, Path]], exclude_extensions: Optional[List[str]] = None) -> bool:
with belief_scope(f"Create dashboard export: {zip_path}"):
app_logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path)
try:
exclude_ext = [ext.lower() for ext in exclude_extensions or []]
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for src_path_str in source_paths:
src_path = Path(src_path_str)
assert src_path.exists(), f"Путь не найден: {src_path}"
for item in src_path.rglob('*'):
if item.is_file() and item.suffix.lower() not in exclude_ext:
arcname = item.relative_to(src_path.parent)
zipf.write(item, arcname)
app_logger.info("[create_dashboard_export][Exit] Archive created: %s", zip_path)
return True
except (IOError, zipfile.BadZipFile, AssertionError) as e:
app_logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True)
return False
# [/DEF:create_dashboard_export:Function]
# [DEF:sanitize_filename:Function]
# @PURPOSE: Очищает строку от символов, недопустимых в именах файлов.
# @PRE: filename должен быть строкой.
# @POST: Возвращает строку без спецсимволов.
# @PARAM: filename (str) - Исходное имя файла.
# @RETURN: str - Очищенная строка.
def sanitize_filename(filename: str) -> str:
with belief_scope(f"Sanitize filename: {filename}"):
return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
# [/DEF:sanitize_filename:Function]
# [DEF:get_filename_from_headers:Function]
# @PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
# @PRE: headers должен быть словарем заголовков.
# @POST: Возвращает имя файла или None, если заголовок отсутствует.
# @PARAM: headers (dict) - Словарь HTTP заголовков.
# @RETURN: Optional[str] - Имя файла or `None`.
def get_filename_from_headers(headers: dict) -> Optional[str]:
with belief_scope("Get filename from headers"):
content_disposition = headers.get("Content-Disposition", "")
if match := re.search(r'filename="?([^"]+)"?', content_disposition):
return match.group(1).strip()
return None
# [/DEF:get_filename_from_headers:Function]
# [DEF:consolidate_archive_folders:Function]
# @PURPOSE: Консолидирует директории архивов на основе общего слага в имени.
# @PRE: root_directory должен быть объектом Path к существующей директории.
# @POST: Директории с одинаковым префиксом объединены в одну.
# @THROW: TypeError, ValueError - Если `root_directory` невалиден.
# @PARAM: root_directory (Path) - Корневая директория для консолидации.
def consolidate_archive_folders(root_directory: Path) -> None:
with belief_scope(f"Consolidate archives in {root_directory}"):
assert isinstance(root_directory, Path), "root_directory must be a Path object."
assert root_directory.is_dir(), "root_directory must be an existing directory."
app_logger.info("[consolidate_archive_folders][Enter] Consolidating archives in %s", root_directory)
# Собираем все директории с архивами
archive_dirs = []
for item in root_directory.iterdir():
if item.is_dir():
# Проверяем, есть ли в директории ZIP-архивы
if any(item.glob("*.zip")):
archive_dirs.append(item)
# Группируем по слагу (части имени до первого '_')
slug_groups = {}
for dir_path in archive_dirs:
dir_name = dir_path.name
slug = dir_name.split('_')[0] if '_' in dir_name else dir_name
if slug not in slug_groups:
slug_groups[slug] = []
slug_groups[slug].append(dir_path)
# Для каждой группы консолидируем
for slug, dirs in slug_groups.items():
if len(dirs) <= 1:
continue
# Создаем целевую директорию
target_dir = root_directory / slug
target_dir.mkdir(exist_ok=True)
app_logger.info("[consolidate_archive_folders][State] Consolidating %d directories under %s", len(dirs), target_dir)
# Перемещаем содержимое
for source_dir in dirs:
if source_dir == target_dir:
continue
for item in source_dir.iterdir():
dest_item = target_dir / item.name
try:
if item.is_dir():
shutil.move(str(item), str(dest_item))
else:
shutil.move(str(item), str(dest_item))
except Exception as e:
app_logger.error("[consolidate_archive_folders][Failure] Failed to move %s to %s: %s", item, dest_item, e)
# Удаляем исходную директорию
try:
source_dir.rmdir()
app_logger.info("[consolidate_archive_folders][State] Removed source directory: %s", source_dir)
except Exception as e:
app_logger.error("[consolidate_archive_folders][Failure] Failed to remove source directory %s: %s", source_dir, e)
# [/DEF:consolidate_archive_folders:Function]
# [/DEF:backend.core.utils.fileio:Module]

View File

@@ -1,265 +1,286 @@
# [DEF:superset_tool.utils.network:Module]
#
# @SEMANTICS: network, http, client, api, requests, session, authentication
# @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> superset_tool.exceptions
# @RELATION: DEPENDS_ON -> superset_tool.utils.logger
# @RELATION: DEPENDS_ON -> requests
# @PUBLIC_API: APIClient
# [SECTION: IMPORTS]
from typing import Optional, Dict, Any, List, Union, cast
import json
import io
from pathlib import Path
import requests
from requests.adapters import HTTPAdapter
import urllib3
from superset_tool.utils.logger import belief_scope
from urllib3.util.retry import Retry
from superset_tool.exceptions import AuthenticationError, NetworkError, DashboardNotFoundError, SupersetAPIError, PermissionDeniedError
from superset_tool.utils.logger import SupersetLogger
# [/SECTION]
# [DEF:APIClient:Class]
# @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов.
class APIClient:
DEFAULT_TIMEOUT = 30
# [DEF:__init__:Function]
# @PURPOSE: Инициализирует API клиент с конфигурацией, сессией и логгером.
# @PARAM: config (Dict[str, Any]) - Конфигурация.
# @PARAM: verify_ssl (bool) - Проверять ли SSL.
# @PARAM: timeout (int) - Таймаут запросов.
# @PARAM: logger (Optional[SupersetLogger]) - Логгер.
# @PRE: config must contain 'base_url' and 'auth'.
# @POST: APIClient instance is initialized with a session.
def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT, logger: Optional[SupersetLogger] = None):
with belief_scope("__init__"):
self.logger = logger or SupersetLogger(name="APIClient")
self.logger.info("[APIClient.__init__][Entry] Initializing APIClient.")
self.base_url: str = config.get("base_url", "")
self.auth = config.get("auth")
self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
self.session = self._init_session()
self._tokens: Dict[str, str] = {}
self._authenticated = False
self.logger.info("[APIClient.__init__][Exit] APIClient initialized.")
# [/DEF:__init__:Function]
# [DEF:_init_session:Function]
# @PURPOSE: Создает и настраивает `requests.Session` с retry-логикой.
# @PRE: self.request_settings must be initialized.
# @POST: Returns a configured requests.Session instance.
# @RETURN: requests.Session - Настроенная сессия.
def _init_session(self) -> requests.Session:
with belief_scope("_init_session"):
session = requests.Session()
retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retries)
session.mount('http://', adapter)
session.mount('https://', adapter)
if not self.request_settings["verify_ssl"]:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.logger.warning("[_init_session][State] SSL verification disabled.")
session.verify = self.request_settings["verify_ssl"]
return session
# [/DEF:_init_session:Function]
# [DEF:authenticate:Function]
# @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены.
# @PRE: self.auth and self.base_url must be valid.
# @POST: `self._tokens` заполнен, `self._authenticated` установлен в `True`.
# @RETURN: Dict[str, str] - Словарь с токенами.
# @THROW: AuthenticationError, NetworkError - при ошибках.
def authenticate(self) -> Dict[str, str]:
with belief_scope("authenticate"):
self.logger.info("[authenticate][Enter] Authenticating to %s", self.base_url)
try:
login_url = f"{self.base_url}/security/login"
response = self.session.post(login_url, json=self.auth, timeout=self.request_settings["timeout"])
response.raise_for_status()
access_token = response.json()["access_token"]
csrf_url = f"{self.base_url}/security/csrf_token/"
csrf_response = self.session.get(csrf_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=self.request_settings["timeout"])
csrf_response.raise_for_status()
self._tokens = {"access_token": access_token, "csrf_token": csrf_response.json()["result"]}
self._authenticated = True
self.logger.info("[authenticate][Exit] Authenticated successfully.")
return self._tokens
except requests.exceptions.HTTPError as e:
raise AuthenticationError(f"Authentication failed: {e}") from e
except (requests.exceptions.RequestException, KeyError) as e:
raise NetworkError(f"Network or parsing error during authentication: {e}") from e
# [/DEF:authenticate:Function]
@property
# [DEF:headers:Function]
# @PURPOSE: Возвращает HTTP-заголовки для аутентифицированных запросов.
# @PRE: APIClient is initialized and authenticated or can be authenticated.
# @POST: Returns headers including auth tokens.
def headers(self) -> Dict[str, str]:
with belief_scope("headers"):
if not self._authenticated: self.authenticate()
return {
"Authorization": f"Bearer {self._tokens['access_token']}",
"X-CSRFToken": self._tokens.get("csrf_token", ""),
"Referer": self.base_url,
"Content-Type": "application/json"
}
# [/DEF:headers:Function]
# [DEF:request:Function]
# @PURPOSE: Выполняет универсальный HTTP-запрос к API.
# @PARAM: method (str) - HTTP метод.
# @PARAM: endpoint (str) - API эндпоинт.
# @PARAM: headers (Optional[Dict]) - Дополнительные заголовки.
# @PARAM: raw_response (bool) - Возвращать ли сырой ответ.
# @PRE: method and endpoint must be strings.
# @POST: Returns response content or raw Response object.
# @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`.
# @THROW: SupersetAPIError, NetworkError и их подклассы.
def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]:
with belief_scope("request"):
full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy()
if headers: _headers.update(headers)
try:
response = self.session.request(method, full_url, headers=_headers, **kwargs)
response.raise_for_status()
return response if raw_response else response.json()
except requests.exceptions.HTTPError as e:
self._handle_http_error(e, endpoint)
except requests.exceptions.RequestException as e:
self._handle_network_error(e, full_url)
# [/DEF:request:Function]
# [DEF:_handle_http_error:Function]
# @PURPOSE: (Helper) Преобразует HTTP ошибки в кастомные исключения.
# @PARAM: e (requests.exceptions.HTTPError) - Ошибка.
# @PARAM: endpoint (str) - Эндпоинт.
# @PRE: e must be a valid HTTPError with a response.
# @POST: Raises a specific SupersetAPIError or subclass.
def _handle_http_error(self, e: requests.exceptions.HTTPError, endpoint: str):
with belief_scope("_handle_http_error"):
status_code = e.response.status_code
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]
# [DEF:_handle_network_error:Function]
# @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`.
# @PARAM: e (requests.exceptions.RequestException) - Ошибка.
# @PARAM: url (str) - URL.
# @PRE: e must be a RequestException.
# @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}"
raise NetworkError(msg, url=url) from e
# [/DEF:_handle_network_error:Function]
# [DEF:upload_file:Function]
# @PURPOSE: Загружает файл на сервер через multipart/form-data.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: file_info (Dict[str, Any]) - Информация о файле.
# @PARAM: extra_data (Optional[Dict]) - Дополнительные данные.
# @PARAM: timeout (Optional[int]) - Таймаут.
# @PRE: file_info must contain 'file_obj' and 'file_name'.
# @POST: File is uploaded and response returned.
# @RETURN: Ответ API в виде словаря.
# @THROW: SupersetAPIError, NetworkError, TypeError.
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)
file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file")
files_payload = {}
if isinstance(file_obj, (str, Path)):
with open(file_obj, 'rb') as f:
files_payload = {form_field: (file_name, f.read(), 'application/x-zip-compressed')}
elif isinstance(file_obj, io.BytesIO):
files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')}
else:
raise TypeError(f"Unsupported file_obj type: {type(file_obj)}")
return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
# [/DEF:upload_file:Function]
# [DEF:_perform_upload:Function]
# @PURPOSE: (Helper) Выполняет POST запрос с файлом.
# @PARAM: url (str) - URL.
# @PARAM: files (Dict) - Файлы.
# @PARAM: data (Optional[Dict]) - Данные.
# @PARAM: headers (Dict) - Заголовки.
# @PARAM: timeout (Optional[int]) - Таймаут.
# @PRE: url, files, and headers must be provided.
# @POST: POST request is performed and JSON response returned.
# @RETURN: Dict - Ответ.
def _perform_upload(self, url: str, files: Dict, data: Optional[Dict], headers: Dict, timeout: Optional[int]) -> Dict:
with belief_scope("_perform_upload"):
try:
response = self.session.post(url, files=files, data=data or {}, headers=headers, timeout=timeout or self.request_settings["timeout"])
response.raise_for_status()
# Добавляем логирование для отладки
if response.status_code == 200:
try:
return response.json()
except Exception as json_e:
self.logger.debug(f"[_perform_upload][Debug] Response is not valid JSON: {response.text[:200]}...")
raise SupersetAPIError(f"API error during upload: Response is not valid JSON: {json_e}") from json_e
return response.json()
except requests.exceptions.HTTPError as e:
raise SupersetAPIError(f"API error during upload: {e.response.text}") from e
except requests.exceptions.RequestException as e:
raise NetworkError(f"Network error during upload: {e}", url=url) from e
# [/DEF:_perform_upload:Function]
# [DEF:fetch_paginated_count:Function]
# @PURPOSE: Получает общее количество элементов для пагинации.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: query_params (Dict) - Параметры запроса.
# @PARAM: count_field (str) - Поле с количеством.
# @PRE: query_params must be a dictionary.
# @POST: Returns total count of items.
# @RETURN: int - Количество.
def fetch_paginated_count(self, endpoint: str, query_params: Dict, count_field: str = "count") -> int:
with belief_scope("fetch_paginated_count"):
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query_params)}))
return response_json.get(count_field, 0)
# [/DEF:fetch_paginated_count:Function]
# [DEF:fetch_paginated_data:Function]
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: pagination_options (Dict[str, Any]) - Опции пагинации.
# @PRE: pagination_options must contain 'base_query', 'total_count', 'results_field'.
# @POST: Returns all items across all pages.
# @RETURN: List[Any] - Список данных.
def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]:
with belief_scope("fetch_paginated_data"):
base_query, total_count = pagination_options["base_query"], pagination_options["total_count"]
results_field, page_size = pagination_options["results_field"], base_query.get('page_size')
assert page_size and page_size > 0, "'page_size' must be a positive number."
results = []
for page in range((total_count + page_size - 1) // page_size):
query = {**base_query, 'page': page}
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)}))
results.extend(response_json.get(results_field, []))
return results
# [/DEF:fetch_paginated_data:Function]
# [/DEF:APIClient:Class]
# [/DEF:superset_tool.utils.network:Module]
# [DEF:backend.core.utils.network:Module]
#
# @SEMANTICS: network, http, client, api, requests, session, authentication
# @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> backend.src.core.logger
# @RELATION: DEPENDS_ON -> requests
# @PUBLIC_API: APIClient
# [SECTION: IMPORTS]
from typing import Optional, Dict, Any, List, Union, cast
import json
import io
from pathlib import Path
import requests
from requests.adapters import HTTPAdapter
import urllib3
from urllib3.util.retry import Retry
from ..logger import logger as app_logger, belief_scope
# [/SECTION]
# [DEF:SupersetAPIError:Class]
class SupersetAPIError(Exception):
def __init__(self, message: str = "Superset API error", **context: Any):
self.context = context
super().__init__(f"[API_FAILURE] {message} | Context: {self.context}")
# [DEF:AuthenticationError:Class]
class AuthenticationError(SupersetAPIError):
def __init__(self, message: str = "Authentication failed", **context: Any):
super().__init__(message, type="authentication", **context)
# [DEF:PermissionDeniedError:Class]
class PermissionDeniedError(AuthenticationError):
def __init__(self, message: str = "Permission denied", **context: Any):
super().__init__(message, **context)
# [DEF:DashboardNotFoundError:Class]
class DashboardNotFoundError(SupersetAPIError):
def __init__(self, resource_id: Union[int, str], message: str = "Dashboard not found", **context: Any):
super().__init__(f"Dashboard '{resource_id}' {message}", subtype="not_found", resource_id=resource_id, **context)
# [DEF:NetworkError:Class]
class NetworkError(Exception):
def __init__(self, message: str = "Network connection failed", **context: Any):
self.context = context
super().__init__(f"[NETWORK_FAILURE] {message} | Context: {self.context}")
# [DEF:APIClient:Class]
# @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов.
class APIClient:
DEFAULT_TIMEOUT = 30
# [DEF:__init__:Function]
# @PURPOSE: Инициализирует API клиент с конфигурацией, сессией и логгером.
# @PARAM: config (Dict[str, Any]) - Конфигурация.
# @PARAM: verify_ssl (bool) - Проверять ли SSL.
# @PARAM: timeout (int) - Таймаут запросов.
# @PRE: config must contain 'base_url' and 'auth'.
# @POST: APIClient instance is initialized with a session.
def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT):
with belief_scope("__init__"):
app_logger.info("[APIClient.__init__][Entry] Initializing APIClient.")
self.base_url: str = config.get("base_url", "")
self.auth = config.get("auth")
self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
self.session = self._init_session()
self._tokens: Dict[str, str] = {}
self._authenticated = False
app_logger.info("[APIClient.__init__][Exit] APIClient initialized.")
# [/DEF:__init__:Function]
# [DEF:_init_session:Function]
# @PURPOSE: Создает и настраивает `requests.Session` с retry-логикой.
# @PRE: self.request_settings must be initialized.
# @POST: Returns a configured requests.Session instance.
# @RETURN: requests.Session - Настроенная сессия.
def _init_session(self) -> requests.Session:
with belief_scope("_init_session"):
session = requests.Session()
retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retries)
session.mount('http://', adapter)
session.mount('https://', adapter)
if not self.request_settings["verify_ssl"]:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app_logger.warning("[_init_session][State] SSL verification disabled.")
session.verify = self.request_settings["verify_ssl"]
return session
# [/DEF:_init_session:Function]
# [DEF:authenticate:Function]
# @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены.
# @PRE: self.auth and self.base_url must be valid.
# @POST: `self._tokens` заполнен, `self._authenticated` установлен в `True`.
# @RETURN: Dict[str, str] - Словарь с токенами.
# @THROW: AuthenticationError, NetworkError - при ошибках.
def authenticate(self) -> Dict[str, str]:
with belief_scope("authenticate"):
app_logger.info("[authenticate][Enter] Authenticating to %s", self.base_url)
try:
login_url = f"{self.base_url}/security/login"
response = self.session.post(login_url, json=self.auth, timeout=self.request_settings["timeout"])
response.raise_for_status()
access_token = response.json()["access_token"]
csrf_url = f"{self.base_url}/security/csrf_token/"
csrf_response = self.session.get(csrf_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=self.request_settings["timeout"])
csrf_response.raise_for_status()
self._tokens = {"access_token": access_token, "csrf_token": csrf_response.json()["result"]}
self._authenticated = True
app_logger.info("[authenticate][Exit] Authenticated successfully.")
return self._tokens
except requests.exceptions.HTTPError as e:
raise AuthenticationError(f"Authentication failed: {e}") from e
except (requests.exceptions.RequestException, KeyError) as e:
raise NetworkError(f"Network or parsing error during authentication: {e}") from e
# [/DEF:authenticate:Function]
@property
# [DEF:headers:Function]
# @PURPOSE: Возвращает HTTP-заголовки для аутентифицированных запросов.
# @PRE: APIClient is initialized and authenticated or can be authenticated.
# @POST: Returns headers including auth tokens.
def headers(self) -> Dict[str, str]:
with belief_scope("headers"):
if not self._authenticated: self.authenticate()
return {
"Authorization": f"Bearer {self._tokens['access_token']}",
"X-CSRFToken": self._tokens.get("csrf_token", ""),
"Referer": self.base_url,
"Content-Type": "application/json"
}
# [/DEF:headers:Function]
# [DEF:request:Function]
# @PURPOSE: Выполняет универсальный HTTP-запрос к API.
# @PARAM: method (str) - HTTP метод.
# @PARAM: endpoint (str) - API эндпоинт.
# @PARAM: headers (Optional[Dict]) - Дополнительные заголовки.
# @PARAM: raw_response (bool) - Возвращать ли сырой ответ.
# @PRE: method and endpoint must be strings.
# @POST: Returns response content or raw Response object.
# @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`.
# @THROW: SupersetAPIError, NetworkError и их подклассы.
def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]:
with belief_scope("request"):
full_url = f"{self.base_url}{endpoint}"
_headers = self.headers.copy()
if headers: _headers.update(headers)
try:
response = self.session.request(method, full_url, headers=_headers, **kwargs)
response.raise_for_status()
return response if raw_response else response.json()
except requests.exceptions.HTTPError as e:
self._handle_http_error(e, endpoint)
except requests.exceptions.RequestException as e:
self._handle_network_error(e, full_url)
# [/DEF:request:Function]
# [DEF:_handle_http_error:Function]
# @PURPOSE: (Helper) Преобразует HTTP ошибки в кастомные исключения.
# @PARAM: e (requests.exceptions.HTTPError) - Ошибка.
# @PARAM: endpoint (str) - Эндпоинт.
# @PRE: e must be a valid HTTPError with a response.
# @POST: Raises a specific SupersetAPIError or subclass.
def _handle_http_error(self, e: requests.exceptions.HTTPError, endpoint: str):
with belief_scope("_handle_http_error"):
status_code = e.response.status_code
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]
# [DEF:_handle_network_error:Function]
# @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`.
# @PARAM: e (requests.exceptions.RequestException) - Ошибка.
# @PARAM: url (str) - URL.
# @PRE: e must be a RequestException.
# @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}"
raise NetworkError(msg, url=url) from e
# [/DEF:_handle_network_error:Function]
# [DEF:upload_file:Function]
# @PURPOSE: Загружает файл на сервер через multipart/form-data.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: file_info (Dict[str, Any]) - Информация о файле.
# @PARAM: extra_data (Optional[Dict]) - Дополнительные данные.
# @PARAM: timeout (Optional[int]) - Таймаут.
# @PRE: file_info must contain 'file_obj' and 'file_name'.
# @POST: File is uploaded and response returned.
# @RETURN: Ответ API в виде словаря.
# @THROW: SupersetAPIError, NetworkError, TypeError.
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)
file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file")
files_payload = {}
if isinstance(file_obj, (str, Path)):
with open(file_obj, 'rb') as f:
files_payload = {form_field: (file_name, f.read(), 'application/x-zip-compressed')}
elif isinstance(file_obj, io.BytesIO):
files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')}
else:
raise TypeError(f"Unsupported file_obj type: {type(file_obj)}")
return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
# [/DEF:upload_file:Function]
# [DEF:_perform_upload:Function]
# @PURPOSE: (Helper) Выполняет POST запрос с файлом.
# @PARAM: url (str) - URL.
# @PARAM: files (Dict) - Файлы.
# @PARAM: data (Optional[Dict]) - Данные.
# @PARAM: headers (Dict) - Заголовки.
# @PARAM: timeout (Optional[int]) - Таймаут.
# @PRE: url, files, and headers must be provided.
# @POST: POST request is performed and JSON response returned.
# @RETURN: Dict - Ответ.
def _perform_upload(self, url: str, files: Dict, data: Optional[Dict], headers: Dict, timeout: Optional[int]) -> Dict:
with belief_scope("_perform_upload"):
try:
response = self.session.post(url, files=files, data=data or {}, headers=headers, timeout=timeout or self.request_settings["timeout"])
response.raise_for_status()
if response.status_code == 200:
try:
return response.json()
except Exception as json_e:
app_logger.debug(f"[_perform_upload][Debug] Response is not valid JSON: {response.text[:200]}...")
raise SupersetAPIError(f"API error during upload: Response is not valid JSON: {json_e}") from json_e
return response.json()
except requests.exceptions.HTTPError as e:
raise SupersetAPIError(f"API error during upload: {e.response.text}") from e
except requests.exceptions.RequestException as e:
raise NetworkError(f"Network error during upload: {e}", url=url) from e
# [/DEF:_perform_upload:Function]
# [DEF:fetch_paginated_count:Function]
# @PURPOSE: Получает общее количество элементов для пагинации.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: query_params (Dict) - Параметры запроса.
# @PARAM: count_field (str) - Поле с количеством.
# @PRE: query_params must be a dictionary.
# @POST: Returns total count of items.
# @RETURN: int - Количество.
def fetch_paginated_count(self, endpoint: str, query_params: Dict, count_field: str = "count") -> int:
with belief_scope("fetch_paginated_count"):
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query_params)}))
return response_json.get(count_field, 0)
# [/DEF:fetch_paginated_count:Function]
# [DEF:fetch_paginated_data:Function]
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
# @PARAM: endpoint (str) - Эндпоинт.
# @PARAM: pagination_options (Dict[str, Any]) - Опции пагинации.
# @PRE: pagination_options must contain 'base_query', 'total_count', 'results_field'.
# @POST: Returns all items across all pages.
# @RETURN: List[Any] - Список данных.
def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]:
with belief_scope("fetch_paginated_data"):
base_query, total_count = pagination_options["base_query"], pagination_options["total_count"]
results_field, page_size = pagination_options["results_field"], base_query.get('page_size')
assert page_size and page_size > 0, "'page_size' must be a positive number."
results = []
for page in range((total_count + page_size - 1) // page_size):
query = {**base_query, 'page': page}
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)}))
results.extend(response_json.get(results_field, []))
return results
# [/DEF:fetch_paginated_data:Function]
# [/DEF:APIClient:Class]
# [/DEF:backend.core.utils.network:Module]

73
backend/src/models/git.py Normal file
View File

@@ -0,0 +1,73 @@
"""
[DEF:GitModels:Module]
Git-specific SQLAlchemy models for configuration and repository tracking.
@RELATION: specs/011-git-integration-dashboard/data-model.md
"""
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
class GitProvider(str, enum.Enum):
GITHUB = "GITHUB"
GITLAB = "GITLAB"
GITEA = "GITEA"
class GitStatus(str, enum.Enum):
CONNECTED = "CONNECTED"
FAILED = "FAILED"
UNKNOWN = "UNKNOWN"
class SyncStatus(str, enum.Enum):
CLEAN = "CLEAN"
DIRTY = "DIRTY"
CONFLICT = "CONFLICT"
class GitServerConfig(Base):
"""
[DEF:GitServerConfig:Class]
Configuration for a Git server connection.
"""
__tablename__ = "git_server_configs"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(255), nullable=False)
provider = Column(Enum(GitProvider), nullable=False)
url = Column(String(255), nullable=False)
pat = Column(String(255), nullable=False) # PERSONAL ACCESS TOKEN
default_repository = Column(String(255), nullable=True)
status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN)
last_validated = Column(DateTime, default=datetime.utcnow)
class GitRepository(Base):
"""
[DEF:GitRepository:Class]
Tracking for a local Git repository linked to a dashboard.
"""
__tablename__ = "git_repositories"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
dashboard_id = Column(Integer, nullable=False, unique=True)
config_id = Column(String(36), ForeignKey("git_server_configs.id"), nullable=False)
remote_url = Column(String(255), nullable=False)
local_path = Column(String(255), nullable=False)
current_branch = Column(String(255), default="main")
sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN)
class DeploymentEnvironment(Base):
"""
[DEF:DeploymentEnvironment:Class]
Target Superset environments for dashboard deployment.
"""
__tablename__ = "deployment_environments"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(255), nullable=False)
superset_url = Column(String(255), nullable=False)
superset_token = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
# [/DEF:GitModels:Module]

View File

@@ -12,10 +12,9 @@ from requests.exceptions import RequestException
from ..core.plugin_base import PluginBase
from ..core.logger import belief_scope
from superset_tool.client import SupersetClient
from superset_tool.exceptions import SupersetAPIError
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.fileio import (
from ..core.superset_client import SupersetClient
from ..core.utils.network import SupersetAPIError
from ..core.utils.fileio import (
save_and_unpack_dashboard,
archive_exports,
sanitize_filename,
@@ -23,7 +22,6 @@ from superset_tool.utils.fileio import (
remove_empty_directories,
RetentionPolicy
)
from superset_tool.utils.init_clients import setup_clients
from ..dependencies import get_config_manager
# [DEF:BackupPlugin:Class]
@@ -131,25 +129,25 @@ class BackupPlugin(PluginBase):
backup_path_str = params.get("backup_path") or config_manager.get_config().settings.backup_path
backup_path = Path(backup_path_str)
logger = SupersetLogger(log_dir=backup_path / "Logs", console=True)
logger.info(f"[BackupPlugin][Entry] Starting backup for {env}.")
from ..core.logger import logger as app_logger
app_logger.info(f"[BackupPlugin][Entry] Starting backup for {env}.")
try:
config_manager = get_config_manager()
if not config_manager.has_environments():
raise ValueError("No Superset environments configured. Please add an environment in Settings.")
clients = setup_clients(logger, custom_envs=config_manager.get_environments())
client = clients.get(env)
if not client:
env_config = config_manager.get_environment(env)
if not env_config:
raise ValueError(f"Environment '{env}' not found in configuration.")
client = SupersetClient(env_config)
dashboard_count, dashboard_meta = client.get_dashboards()
logger.info(f"[BackupPlugin][Progress] Found {dashboard_count} dashboards to export in {env}.")
app_logger.info(f"[BackupPlugin][Progress] Found {dashboard_count} dashboards to export in {env}.")
if dashboard_count == 0:
logger.info("[BackupPlugin][Exit] No dashboards to back up.")
app_logger.info("[BackupPlugin][Exit] No dashboards to back up.")
return
for db in dashboard_meta:
@@ -169,23 +167,22 @@ class BackupPlugin(PluginBase):
zip_content=zip_content,
original_filename=filename,
output_dir=dashboard_dir,
unpack=False,
logger=logger
unpack=False
)
archive_exports(str(dashboard_dir), policy=RetentionPolicy(), logger=logger)
archive_exports(str(dashboard_dir), policy=RetentionPolicy())
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
logger.error(f"[BackupPlugin][Failure] Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}", exc_info=True)
app_logger.error(f"[BackupPlugin][Failure] Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}", exc_info=True)
continue
consolidate_archive_folders(backup_path / env.upper(), logger=logger)
remove_empty_directories(str(backup_path / env.upper()), logger=logger)
consolidate_archive_folders(backup_path / env.upper())
remove_empty_directories(str(backup_path / env.upper()))
logger.info(f"[BackupPlugin][CoherenceCheck:Passed] Backup logic completed for {env}.")
app_logger.info(f"[BackupPlugin][CoherenceCheck:Passed] Backup logic completed for {env}.")
except (RequestException, IOError, KeyError) as e:
logger.critical(f"[BackupPlugin][Failure] Fatal error during backup for {env}: {e}", exc_info=True)
app_logger.critical(f"[BackupPlugin][Failure] Fatal error during backup for {env}: {e}", exc_info=True)
raise e
# [/DEF:execute:Function]
# [/DEF:BackupPlugin:Class]

View File

@@ -0,0 +1,345 @@
# [DEF:backend.src.plugins.git_plugin:Module]
#
# @SEMANTICS: git, plugin, dashboard, version_control, sync, deploy
# @PURPOSE: Предоставляет плагин для версионирования и развертывания дашбордов Superset.
# @LAYER: Plugin
# @RELATION: INHERITS_FROM -> src.core.plugin_base.PluginBase
# @RELATION: USES -> src.services.git_service.GitService
# @RELATION: USES -> src.core.superset_client.SupersetClient
# @RELATION: USES -> src.core.config_manager.ConfigManager
#
# @INVARIANT: Все операции с Git должны выполняться через GitService.
# @CONSTRAINT: Плагин работает только с распакованными YAML-экспортами Superset.
# [SECTION: IMPORTS]
import os
import io
import shutil
import zipfile
from pathlib import Path
from typing import Dict, Any, Optional
from src.core.plugin_base import PluginBase
from src.services.git_service import GitService
from src.core.logger import logger, belief_scope
from src.core.config_manager import ConfigManager
from src.core.superset_client import SupersetClient
# [/SECTION]
# [DEF:GitPlugin:Class]
# @PURPOSE: Реализация плагина Git Integration для управления версиями дашбордов.
class GitPlugin(PluginBase):
# [DEF:__init__:Function]
# @PURPOSE: Инициализирует плагин и его зависимости.
# @POST: Инициализированы git_service и config_manager.
def __init__(self):
with belief_scope("GitPlugin.__init__"):
logger.info("[GitPlugin.__init__][Entry] Initializing GitPlugin.")
self.git_service = GitService()
# Robust config path resolution:
# 1. Try absolute path from src/dependencies.py style if possible
# 2. Try relative paths based on common execution patterns
if os.path.exists("../config.json"):
config_path = "../config.json"
elif os.path.exists("config.json"):
config_path = "config.json"
else:
# Fallback to the one initialized in dependencies if we can import it
try:
from src.dependencies import config_manager
self.config_manager = config_manager
logger.info("[GitPlugin.__init__][Exit] GitPlugin initialized using shared config_manager.")
return
except:
config_path = "config.json"
self.config_manager = ConfigManager(config_path)
logger.info(f"[GitPlugin.__init__][Exit] GitPlugin initialized with {config_path}")
# [/DEF:__init__:Function]
@property
def id(self) -> str:
return "git-integration"
@property
def name(self) -> str:
return "Git Integration"
@property
def description(self) -> str:
return "Version control for Superset dashboards"
@property
def version(self) -> str:
return "0.1.0"
# [DEF:get_schema:Function]
# @PURPOSE: Возвращает JSON-схему параметров для выполнения задач плагина.
# @RETURN: Dict[str, Any] - Схема параметров.
def get_schema(self) -> Dict[str, Any]:
with belief_scope("GitPlugin.get_schema"):
return {
"type": "object",
"properties": {
"operation": {"type": "string", "enum": ["sync", "deploy", "history"]},
"dashboard_id": {"type": "integer"},
"environment_id": {"type": "string"},
"source_env_id": {"type": "string"}
},
"required": ["operation", "dashboard_id"]
}
# [/DEF:get_schema:Function]
# [DEF:initialize:Function]
# @PURPOSE: Выполняет начальную настройку плагина.
# @POST: Плагин готов к выполнению задач.
async def initialize(self):
with belief_scope("GitPlugin.initialize"):
logger.info("[GitPlugin.initialize][Action] Initializing Git Integration Plugin logic.")
# [DEF:execute:Function]
# @PURPOSE: Основной метод выполнения задач плагина.
# @PRE: task_data содержит 'operation' и 'dashboard_id'.
# @POST: Возвращает результат выполнения операции.
# @PARAM: task_data (Dict[str, Any]) - Данные задачи.
# @RETURN: Dict[str, Any] - Статус и сообщение.
# @RELATION: CALLS -> self._handle_sync
# @RELATION: CALLS -> self._handle_deploy
async def execute(self, task_data: Dict[str, Any]) -> Dict[str, Any]:
with belief_scope("GitPlugin.execute"):
operation = task_data.get("operation")
dashboard_id = task_data.get("dashboard_id")
logger.info(f"[GitPlugin.execute][Entry] Executing operation: {operation} for dashboard {dashboard_id}")
if operation == "sync":
source_env_id = task_data.get("source_env_id")
result = await self._handle_sync(dashboard_id, source_env_id)
elif operation == "deploy":
env_id = task_data.get("environment_id")
result = await self._handle_deploy(dashboard_id, env_id)
elif operation == "history":
result = {"status": "success", "message": "History available via API"}
else:
logger.error(f"[GitPlugin.execute][Coherence:Failed] Unknown operation: {operation}")
raise ValueError(f"Unknown operation: {operation}")
logger.info(f"[GitPlugin.execute][Exit] Operation {operation} completed.")
return result
# [/DEF:execute:Function]
# [DEF:_handle_sync:Function]
# @PURPOSE: Экспортирует дашборд из Superset и распаковывает в Git-репозиторий.
# @PRE: Репозиторий для дашборда должен существовать.
# @POST: Файлы в репозитории обновлены до текущего состояния в Superset.
# @PARAM: dashboard_id (int) - ID дашборда.
# @PARAM: source_env_id (Optional[str]) - ID исходного окружения.
# @RETURN: Dict[str, str] - Результат синхронизации.
# @SIDE_EFFECT: Изменяет файлы в локальной рабочей директории репозитория.
# @RELATION: CALLS -> src.services.git_service.GitService.get_repo
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.export_dashboard
async def _handle_sync(self, dashboard_id: int, source_env_id: Optional[str] = None) -> Dict[str, str]:
with belief_scope("GitPlugin._handle_sync"):
try:
# 1. Получение репозитория
repo = self.git_service.get_repo(dashboard_id)
repo_path = Path(repo.working_dir)
logger.info(f"[_handle_sync][Action] Target repo path: {repo_path}")
# 2. Настройка клиента Superset
env = self._get_env(source_env_id)
client = SupersetClient(env)
client.authenticate()
# 3. Экспорт дашборда
logger.info(f"[_handle_sync][Action] Exporting dashboard {dashboard_id} from {env.name}")
zip_bytes, _ = client.export_dashboard(dashboard_id)
# 4. Распаковка с выравниванием структуры (flattening)
logger.info(f"[_handle_sync][Action] Unpacking export to {repo_path}")
# Список папок/файлов, которые мы ожидаем от Superset
managed_dirs = ["dashboards", "charts", "datasets", "databases"]
managed_files = ["metadata.yaml"]
# Очистка старых данных перед распаковкой, чтобы не оставалось "призраков"
for d in managed_dirs:
d_path = repo_path / d
if d_path.exists() and d_path.is_dir():
shutil.rmtree(d_path)
for f in managed_files:
f_path = repo_path / f
if f_path.exists():
f_path.unlink()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
# Superset экспортирует всё в подпапку dashboard_export_timestamp/
# Нам нужно найти это имя папки
namelist = zf.namelist()
if not namelist:
raise ValueError("Export ZIP is empty")
root_folder = namelist[0].split('/')[0]
logger.info(f"[_handle_sync][Action] Detected root folder in ZIP: {root_folder}")
for member in zf.infolist():
if member.filename.startswith(root_folder + "/") and len(member.filename) > len(root_folder) + 1:
# Убираем префикс папки
relative_path = member.filename[len(root_folder)+1:]
target_path = repo_path / relative_path
if member.is_dir():
target_path.mkdir(parents=True, exist_ok=True)
else:
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as source, open(target_path, "wb") as target:
shutil.copyfileobj(source, target)
# 5. Автоматический staging изменений (не коммит, чтобы юзер мог проверить diff)
try:
repo.git.add(A=True)
logger.info(f"[_handle_sync][Action] Changes staged in git")
except Exception as ge:
logger.warning(f"[_handle_sync][Action] Failed to stage changes: {ge}")
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}")
raise
# [/DEF:_handle_sync:Function]
# [DEF:_handle_deploy:Function]
# @PURPOSE: Упаковывает репозиторий в ZIP и импортирует в целевое окружение Superset.
# @PRE: environment_id должен соответствовать настроенному окружению.
# @POST: Дашборд импортирован в целевой Superset.
# @PARAM: dashboard_id (int) - ID дашборда.
# @PARAM: env_id (str) - ID целевого окружения.
# @RETURN: Dict[str, Any] - Результат деплоя.
# @SIDE_EFFECT: Создает и удаляет временный ZIP-файл.
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.import_dashboard
async def _handle_deploy(self, dashboard_id: int, env_id: str) -> Dict[str, Any]:
with belief_scope("GitPlugin._handle_deploy"):
try:
if not env_id:
raise ValueError("Target environment ID required for deployment")
# 1. Получение репозитория
repo = self.git_service.get_repo(dashboard_id)
repo_path = Path(repo.working_dir)
# 2. Упаковка в ZIP
logger.info(f"[_handle_deploy][Action] Packing repository {repo_path} for deployment.")
zip_buffer = io.BytesIO()
# Superset expects a root directory in the ZIP (e.g., dashboard_export_20240101T000000/)
root_dir_name = f"dashboard_export_{dashboard_id}"
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(repo_path):
if ".git" in dirs:
dirs.remove(".git")
for file in files:
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)
zf.write(file_path, arcname)
zip_buffer.seek(0)
# 3. Настройка клиента Superset
env = self.config_manager.get_environment(env_id)
if not env:
raise ValueError(f"Environment {env_id} not found")
client = SupersetClient(env)
client.authenticate()
# 4. Импорт
temp_zip_path = repo_path / f"deploy_{dashboard_id}.zip"
logger.info(f"[_handle_deploy][Action] Saving temporary zip to {temp_zip_path}")
with open(temp_zip_path, "wb") as f:
f.write(zip_buffer.getvalue())
try:
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}.")
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}")
raise
# [/DEF:_handle_deploy:Function]
# [DEF:_get_env:Function]
# @PURPOSE: Вспомогательный метод для получения конфигурации окружения.
# @PARAM: env_id (Optional[str]) - ID окружения.
# @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}")
# 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}")
return env
# Priority 2: Database (DeploymentEnvironment)
from src.core.database import SessionLocal
from src.models.git import DeploymentEnvironment
db = SessionLocal()
try:
if env_id:
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()
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}")
from src.core.config_models import Environment
# Use token as password for SupersetClient
return Environment(
id=db_env.id,
name=db_env.name,
url=db_env.superset_url,
username="admin",
password=db_env.superset_token,
verify_ssl=True
)
finally:
db.close()
# Priority 3: ConfigManager Default (if no env_id provided)
envs = self.config_manager.get_environments()
if envs:
if env_id:
# If env_id was provided but not found in DB or specifically by ID in config,
# 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")
return env
if not env_id:
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}")
raise ValueError("No environments configured. Please add a Superset Environment in Settings.")
# [/DEF:_get_env:Function]
# [/DEF:GitPlugin:Class]
# [/DEF:backend.src.plugins.git_plugin:Module]

View File

@@ -12,8 +12,7 @@ from ..core.superset_client import SupersetClient
from ..core.logger import logger, belief_scope
from ..core.database import SessionLocal
from ..models.connection import ConnectionConfig
from superset_tool.utils.dataset_mapper import DatasetMapper
from superset_tool.utils.logger import SupersetLogger
from ..core.utils.dataset_mapper import DatasetMapper
# [/SECTION]
# [DEF:MapperPlugin:Class]
@@ -173,9 +172,7 @@ class MapperPlugin(PluginBase):
logger.info(f"[MapperPlugin.execute][Action] Starting mapping for dataset {dataset_id} in {env_name}")
# Use internal SupersetLogger for DatasetMapper
s_logger = SupersetLogger(name="dataset_mapper_plugin")
mapper = DatasetMapper(s_logger)
mapper = DatasetMapper()
try:
mapper.run_mapping(

View File

@@ -13,11 +13,9 @@ import re
from ..core.plugin_base import PluginBase
from ..core.logger import belief_scope
from superset_tool.client import SupersetClient
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.fileio import create_temp_file, update_yamls, create_dashboard_export
from ..core.superset_client import SupersetClient
from ..core.utils.fileio import create_temp_file, update_yamls, create_dashboard_export
from ..dependencies import get_config_manager
from superset_tool.utils.logger import SupersetLogger
from ..core.migration_engine import MigrationEngine
from ..core.database import SessionLocal
from ..models.mapping import DatabaseMapping, Environment
@@ -150,7 +148,7 @@ class MigrationPlugin(PluginBase):
from ..dependencies import get_task_manager
tm = get_task_manager()
class TaskLoggerProxy(SupersetLogger):
class TaskLoggerProxy:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the proxy logger.
# @PRE: None.
@@ -158,7 +156,7 @@ class MigrationPlugin(PluginBase):
def __init__(self):
with belief_scope("__init__"):
# Initialize parent with dummy values since we override methods
super().__init__(console=False)
pass
# [/DEF:__init__:Function]
# [DEF:debug:Function]
@@ -246,9 +244,8 @@ class MigrationPlugin(PluginBase):
logger.info(f"[MigrationPlugin][State] Resolved environments: {from_env_name} -> {to_env_name}")
all_clients = setup_clients(logger, custom_envs=environments)
from_c = all_clients.get(from_env_name)
to_c = all_clients.get(to_env_name)
from_c = SupersetClient(src_env)
to_c = SupersetClient(tgt_env)
if not from_c or not to_c:
raise ValueError(f"Clients not initialized for environments: {from_env_name}, {to_env_name}")

View File

@@ -0,0 +1,380 @@
# [DEF:backend.src.services.git_service:Module]
#
# @SEMANTICS: git, service, gitpython, repository, version_control
# @PURPOSE: Core Git logic using GitPython to manage dashboard repositories.
# @LAYER: Service
# @RELATION: INHERITS_FROM -> None
# @RELATION: USED_BY -> src.api.routes.git
# @RELATION: USED_BY -> src.plugins.git_plugin
#
# @INVARIANT: All Git operations must be performed on a valid local directory.
import os
import shutil
import httpx
from git import Repo, RemoteProgress
from fastapi import HTTPException
from typing import List, Optional
from datetime import datetime
from src.core.logger import logger, belief_scope
from src.models.git import GitProvider
# [DEF:GitService:Class]
# @PURPOSE: Wrapper for GitPython operations with semantic logging and error handling.
class GitService:
"""
Wrapper for GitPython operations.
"""
# [DEF:__init__:Function]
# @PURPOSE: Initializes the GitService with a base path for repositories.
# @PARAM: base_path (str) - Root directory for all Git clones.
def __init__(self, base_path: str = "backend/git_repos"):
with belief_scope("GitService.__init__"):
self.base_path = base_path
if not os.path.exists(self.base_path):
os.makedirs(self.base_path)
# [/DEF:__init__:Function]
# [DEF:_get_repo_path:Function]
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
# @PARAM: dashboard_id (int)
# @RETURN: str
def _get_repo_path(self, dashboard_id: int) -> str:
return os.path.join(self.base_path, str(dashboard_id))
# [/DEF:_get_repo_path:Function]
# [DEF:init_repo:Function]
# @PURPOSE: Initialize or clone a repository for a dashboard.
# @PARAM: dashboard_id (int)
# @PARAM: remote_url (str)
# @PARAM: pat (str) - Personal Access Token for authentication.
# @RETURN: Repo - GitPython Repo object.
def init_repo(self, dashboard_id: int, remote_url: str, pat: str) -> Repo:
with belief_scope("GitService.init_repo"):
repo_path = self._get_repo_path(dashboard_id)
# Inject PAT into remote URL if needed
if pat and "://" in remote_url:
proto, rest = remote_url.split("://", 1)
auth_url = f"{proto}://oauth2:{pat}@{rest}"
else:
auth_url = remote_url
if os.path.exists(repo_path):
logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}")
return Repo(repo_path)
logger.info(f"[init_repo][Action] Cloning {remote_url} to {repo_path}")
return Repo.clone_from(auth_url, repo_path)
# [/DEF:init_repo:Function]
# [DEF:get_repo:Function]
# @PURPOSE: Get Repo object for a dashboard.
# @PRE: Repository must exist on disk.
# @RETURN: Repo
def get_repo(self, dashboard_id: int) -> Repo:
with belief_scope("GitService.get_repo"):
repo_path = self._get_repo_path(dashboard_id)
if not os.path.exists(repo_path):
logger.error(f"[get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist")
raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found")
try:
return Repo(repo_path)
except Exception as e:
logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
# [/DEF:get_repo:Function]
# [DEF:list_branches:Function]
# @PURPOSE: List all branches for a dashboard's repository.
# @RETURN: List[dict]
def list_branches(self, dashboard_id: int) -> List[dict]:
with belief_scope("GitService.list_branches"):
repo = self.get_repo(dashboard_id)
logger.info(f"[list_branches][Action] Listing branches for {dashboard_id}. Refs: {repo.refs}")
branches = []
# Add existing refs
for ref in repo.refs:
try:
# Strip prefixes for UI
name = ref.name.replace('refs/heads/', '').replace('refs/remotes/origin/', '')
# Avoid duplicates (e.g. local and remote with same name)
if any(b['name'] == name for b in branches):
continue
branches.append({
"name": name,
"commit_hash": ref.commit.hexsha if hasattr(ref, 'commit') else "0000000",
"is_remote": ref.is_remote() if hasattr(ref, 'is_remote') else False,
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow()
})
except Exception as e:
logger.warning(f"[list_branches][Action] Skipping ref {ref}: {e}")
# Ensure the current active branch is in the list even if it has no commits or refs
try:
active_name = repo.active_branch.name
if not any(b['name'] == active_name for b in branches):
branches.append({
"name": active_name,
"commit_hash": "0000000",
"is_remote": False,
"last_updated": datetime.utcnow()
})
except Exception as e:
logger.warning(f"[list_branches][Action] Could not determine active branch: {e}")
# If everything else failed and list is still empty, add default
if not branches:
branches.append({
"name": "main",
"commit_hash": "0000000",
"is_remote": False,
"last_updated": datetime.utcnow()
})
return branches
# [/DEF:list_branches:Function]
# [DEF:create_branch:Function]
# @PURPOSE: Create a new branch from an existing one.
# @PARAM: name (str) - New branch name.
# @PARAM: from_branch (str) - Source branch.
def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
with belief_scope("GitService.create_branch"):
repo = self.get_repo(dashboard_id)
logger.info(f"[create_branch][Action] Creating branch {name} from {from_branch}")
# 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.")
readme_path = os.path.join(repo.working_dir, "README.md")
if not os.path.exists(readme_path):
with open(readme_path, "w") as f:
f.write(f"# Dashboard {dashboard_id}\nGit repository for Superset dashboard integration.")
repo.index.add(["README.md"])
repo.index.commit("Initial commit")
# Verify source branch exists
try:
repo.commit(from_branch)
except:
logger.warning(f"[create_branch][Action] Source branch {from_branch} not found, using HEAD")
from_branch = repo.head
try:
new_branch = repo.create_head(name, from_branch)
return new_branch
except Exception as e:
logger.error(f"[create_branch][Coherence:Failed] {e}")
raise
# [/DEF:create_branch:Function]
# [/DEF:create_branch:Function]
# [DEF:checkout_branch:Function]
# @PURPOSE: Switch to a specific branch.
def checkout_branch(self, dashboard_id: int, name: str):
with belief_scope("GitService.checkout_branch"):
repo = self.get_repo(dashboard_id)
logger.info(f"[checkout_branch][Action] Checking out branch {name}")
repo.git.checkout(name)
# [/DEF:checkout_branch:Function]
# [DEF:commit_changes:Function]
# @PURPOSE: Stage and commit changes.
# @PARAM: message (str) - Commit message.
# @PARAM: files (List[str]) - Optional list of specific files to stage.
def commit_changes(self, dashboard_id: int, message: str, files: List[str] = None):
with belief_scope("GitService.commit_changes"):
repo = self.get_repo(dashboard_id)
# Check if there are any changes to commit
if not repo.is_dirty(untracked_files=True) and not files:
logger.info(f"[commit_changes][Action] No changes to commit for dashboard {dashboard_id}")
return
if files:
logger.info(f"[commit_changes][Action] Staging files: {files}")
repo.index.add(files)
else:
logger.info("[commit_changes][Action] Staging all changes")
repo.git.add(A=True)
repo.index.commit(message)
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
# [/DEF:commit_changes:Function]
# [DEF:push_changes:Function]
# @PURPOSE: Push local commits to remote.
def push_changes(self, dashboard_id: int):
with belief_scope("GitService.push_changes"):
repo = self.get_repo(dashboard_id)
# Ensure we have something to push
if not repo.heads:
logger.warning(f"[push_changes][Coherence:Failed] No local branches to push for dashboard {dashboard_id}")
return
try:
origin = repo.remote(name='origin')
except ValueError:
logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
# Check if current branch has an upstream
try:
current_branch = repo.active_branch
logger.info(f"[push_changes][Action] Pushing branch {current_branch.name} to origin")
# Using a timeout for network operations
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
for info in push_info:
if info.flags & info.ERROR:
logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}")
raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}")
except Exception as e:
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}")
# [/DEF:push_changes:Function]
# [DEF:pull_changes:Function]
# @PURPOSE: Pull changes from remote.
def pull_changes(self, dashboard_id: int):
with belief_scope("GitService.pull_changes"):
repo = self.get_repo(dashboard_id)
try:
origin = repo.remote(name='origin')
logger.info("[pull_changes][Action] Pulling changes from origin")
fetch_info = origin.pull()
for info in fetch_info:
if info.flags & info.ERROR:
logger.error(f"[pull_changes][Coherence:Failed] Error pulling ref {info.ref}: {info.note}")
raise Exception(f"Git pull error for {info.ref}: {info.note}")
except ValueError:
logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
except Exception as e:
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}")
# [/DEF:pull_changes:Function]
# [DEF:get_status:Function]
# @PURPOSE: Get current repository status (dirty files, untracked, etc.)
# @RETURN: dict
def get_status(self, dashboard_id: int) -> dict:
with belief_scope("GitService.get_status"):
repo = self.get_repo(dashboard_id)
# Handle empty repository (no commits)
has_commits = False
try:
repo.head.commit
has_commits = True
except (ValueError, Exception):
has_commits = False
return {
"is_dirty": repo.is_dirty(untracked_files=True),
"untracked_files": repo.untracked_files,
"modified_files": [item.a_path for item in repo.index.diff(None)],
"staged_files": [item.a_path for item in repo.index.diff("HEAD")] if has_commits else [],
"current_branch": repo.active_branch.name
}
# [/DEF:get_status:Function]
# [DEF:get_diff:Function]
# @PURPOSE: Generate diff for a file or the whole repository.
# @PARAM: file_path (str) - Optional specific file.
# @PARAM: staged (bool) - Whether to show staged changes.
# @RETURN: str
def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str:
with belief_scope("GitService.get_diff"):
repo = self.get_repo(dashboard_id)
diff_args = []
if staged:
diff_args.append("--staged")
if file_path:
return repo.git.diff(*diff_args, "--", file_path)
return repo.git.diff(*diff_args)
# [/DEF:get_diff:Function]
# [DEF:get_commit_history:Function]
# @PURPOSE: Retrieve commit history for a repository.
# @PARAM: limit (int) - Max number of commits to return.
# @RETURN: List[dict]
def get_commit_history(self, dashboard_id: int, limit: int = 50) -> List[dict]:
with belief_scope("GitService.get_commit_history"):
repo = self.get_repo(dashboard_id)
commits = []
try:
# Check if there are any commits at all
if not repo.heads and not repo.remotes:
return []
for commit in repo.iter_commits(max_count=limit):
commits.append({
"hash": commit.hexsha,
"author": commit.author.name,
"email": commit.author.email,
"timestamp": datetime.fromtimestamp(commit.committed_date),
"message": commit.message.strip(),
"files_changed": list(commit.stats.files.keys())
})
except Exception as e:
logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}")
return []
return commits
# [/DEF:get_commit_history:Function]
# [DEF:test_connection:Function]
# @PURPOSE: Test connection to Git provider using PAT.
# @PARAM: provider (GitProvider)
# @PARAM: url (str)
# @PARAM: pat (str)
# @RETURN: bool
async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool:
with belief_scope("GitService.test_connection"):
# Check for offline mode or local-only URLs
if ".local" in url or "localhost" in url:
logger.info("[test_connection][Action] Local/Offline mode detected for URL")
return True
if not url.startswith(('http://', 'https://')):
logger.error(f"[test_connection][Coherence:Failed] Invalid URL protocol: {url}")
return False
if not pat or not pat.strip():
logger.error("[test_connection][Coherence:Failed] Git PAT is missing or empty")
return False
pat = pat.strip()
try:
async with httpx.AsyncClient() as client:
if provider == GitProvider.GITHUB:
headers = {"Authorization": f"token {pat}"}
api_url = "https://api.github.com/user" if "github.com" in url else f"{url.rstrip('/')}/api/v3/user"
resp = await client.get(api_url, headers=headers)
elif provider == GitProvider.GITLAB:
headers = {"PRIVATE-TOKEN": pat}
api_url = f"{url.rstrip('/')}/api/v4/user"
resp = await client.get(api_url, headers=headers)
elif provider == GitProvider.GITEA:
headers = {"Authorization": f"token {pat}"}
api_url = f"{url.rstrip('/')}/api/v1/user"
resp = await client.get(api_url, headers=headers)
else:
return False
if resp.status_code != 200:
logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}")
return resp.status_code == 200
except Exception as e:
logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
return False
# [/DEF:test_connection:Function]
# [/DEF:GitService:Class]
# [/DEF:backend.src.services.git_service:Module]

View File

@@ -13,7 +13,6 @@ from typing import List, Dict
from backend.src.core.logger import belief_scope
from backend.src.core.superset_client import SupersetClient
from backend.src.core.utils.matching import suggest_mappings
from superset_tool.models import SupersetConfig
# [/SECTION]
# [DEF:MappingService:Class]
@@ -43,17 +42,7 @@ class MappingService:
if not env:
raise ValueError(f"Environment {env_id} not found")
superset_config = SupersetConfig(
env=env.name,
base_url=env.url,
auth={
"provider": "db",
"username": env.username,
"password": env.password,
"refresh": "false"
}
)
return SupersetClient(superset_config)
return SupersetClient(env)
# [/DEF:_get_client:Function]
# [DEF:get_suggestions:Function]

Binary file not shown.

View File

@@ -1,5 +1,5 @@
import pytest
from backend.src.core.logger import belief_scope, logger
from src.core.logger import belief_scope, logger
# [DEF:test_belief_scope_logs_entry_action_exit:Function]

View File

@@ -1,62 +1,23 @@
import pytest
from superset_tool.models import SupersetConfig
from superset_tool.utils.logger import belief_scope
from src.core.config_models import Environment
from src.core.logger import belief_scope
# [DEF:test_superset_config_url_normalization:Function]
# @PURPOSE: Tests that SupersetConfig correctly normalizes the base URL.
# @PRE: SupersetConfig class is available.
# @POST: URL normalization is verified.
def test_superset_config_url_normalization():
with belief_scope("test_superset_config_url_normalization"):
auth = {
"provider": "db",
"username": "admin",
"password": "password",
"refresh": "token"
}
# Test with /api/v1 already present
config = SupersetConfig(
env="dev",
base_url="http://localhost:8088/api/v1",
auth=auth
# [DEF:test_environment_model:Function]
# @PURPOSE: Tests that Environment model correctly stores values.
# @PRE: Environment class is available.
# @POST: Values are verified.
def test_environment_model():
with belief_scope("test_environment_model"):
env = Environment(
id="test-id",
name="test-env",
url="http://localhost:8088/api/v1",
username="admin",
password="password"
)
assert config.base_url == "http://localhost:8088/api/v1"
# Test without /api/v1
config = SupersetConfig(
env="dev",
base_url="http://localhost:8088",
auth=auth
)
assert config.base_url == "http://localhost:8088/api/v1"
# Test with trailing slash
config = SupersetConfig(
env="dev",
base_url="http://localhost:8088/",
auth=auth
)
assert config.base_url == "http://localhost:8088/api/v1"
assert env.id == "test-id"
assert env.name == "test-env"
assert env.url == "http://localhost:8088/api/v1"
# [/DEF:test_superset_config_url_normalization:Function]
# [DEF:test_superset_config_invalid_url:Function]
# @PURPOSE: Tests that SupersetConfig raises ValueError for invalid URLs.
# @PRE: SupersetConfig class is available.
# @POST: ValueError is raised for invalid URLs.
def test_superset_config_invalid_url():
with belief_scope("test_superset_config_invalid_url"):
auth = {
"provider": "db",
"username": "admin",
"password": "password",
"refresh": "token"
}
with pytest.raises(ValueError, match="Must start with http:// or https://"):
SupersetConfig(
env="dev",
base_url="localhost:8088",
auth=auth
)
# [/DEF:test_superset_config_invalid_url:Function]

View File

@@ -0,0 +1,55 @@
slice_name: "FI-0083 \u0421\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043A\u0430\
\ \u043F\u043E \u0414\u0417/\u041F\u0414\u0417"
description: null
certified_by: null
certification_details: null
viz_type: pivot_table_v2
params:
datasource: 859__table
viz_type: pivot_table_v2
slice_id: 4019
groupbyColumns:
- dt
groupbyRows:
- counterparty_search_name
- attribute
time_grain_sqla: P1M
temporal_columns_lookup:
dt: true
metrics:
- m_debt_amount
- m_overdue_amount
metricsLayout: COLUMNS
adhoc_filters:
- clause: WHERE
comparator: No filter
expressionType: SIMPLE
operator: TEMPORAL_RANGE
subject: dt
row_limit: '90000'
order_desc: false
aggregateFunction: Sum
combineMetric: true
valueFormat: SMART_NUMBER
date_format: smart_date
rowOrder: key_a_to_z
colOrder: key_a_to_z
value_font_size: 12
header_font_size: 12
label_align: left
column_config:
m_debt_amount:
d3NumberFormat: ',d'
m_overdue_amount:
d3NumberFormat: ',d'
conditional_formatting: []
extra_form_data: {}
dashboards:
- 184
query_context: '{"datasource":{"id":859,"type":"table"},"force":false,"queries":[{"filters":[{"col":"dt","op":"TEMPORAL_RANGE","val":"No
filter"}],"extras":{"having":"","where":""},"applied_time_extras":{},"columns":[{"timeGrain":"P1M","columnType":"BASE_AXIS","sqlExpression":"dt","label":"dt","expressionType":"SQL"},"counterparty_search_name","attribute"],"metrics":["m_debt_amount","m_overdue_amount"],"orderby":[["m_debt_amount",true]],"annotation_layers":[],"row_limit":90000,"series_limit":0,"order_desc":false,"url_params":{},"custom_params":{},"custom_form_data":{}}],"form_data":{"datasource":"859__table","viz_type":"pivot_table_v2","slice_id":4019,"groupbyColumns":["dt"],"groupbyRows":["counterparty_search_name","attribute"],"time_grain_sqla":"P1M","temporal_columns_lookup":{"dt":true},"metrics":["m_debt_amount","m_overdue_amount"],"metricsLayout":"COLUMNS","adhoc_filters":[{"clause":"WHERE","comparator":"No
filter","expressionType":"SIMPLE","operator":"TEMPORAL_RANGE","subject":"dt"}],"row_limit":"90000","order_desc":false,"aggregateFunction":"Sum","combineMetric":true,"valueFormat":"SMART_NUMBER","date_format":"smart_date","rowOrder":"key_a_to_z","colOrder":"key_a_to_z","value_font_size":12,"header_font_size":12,"label_align":"left","column_config":{"m_debt_amount":{"d3NumberFormat":",d"},"m_overdue_amount":{"d3NumberFormat":",d"}},"conditional_formatting":[],"extra_form_data":{},"dashboards":[184],"force":false,"result_format":"json","result_type":"full"},"result_format":"json","result_type":"full"}'
cache_timeout: null
uuid: 9c293065-73e2-4d9b-a175-d188ff8ef575
version: 1.0.0
dataset_uuid: 9e645dc0-da25-4f61-9465-6e649b0bc4b1

View File

@@ -0,0 +1,13 @@
database_name: Prod Clickhouse
sqlalchemy_uri: clickhousedb+connect://viz_superset_click_prod:XXXXXXXXXX@rgm-s-khclk.hq.root.ad:443/dm
cache_timeout: null
expose_in_sqllab: true
allow_run_async: false
allow_ctas: false
allow_cvas: false
allow_dml: true
allow_file_upload: false
extra:
allows_virtual_table_explore: true
uuid: 97aced68-326a-4094-b381-27980560efa9
version: 1.0.0

View File

@@ -0,0 +1,119 @@
table_name: "FI-0080-06 \u041A\u0430\u043B\u0435\u043D\u0434\u0430\u0440\u044C (\u041E\
\u0431\u0449\u0438\u0439 \u0441\u043F\u0440\u0430\u0432\u043E\u0447\u043D\u0438\u043A\
)"
main_dttm_col: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
schema: dm_view
sql: "-- [HEADER]\r\n-- [\u041D\u0410\u0417\u041D\u0410\u0427\u0415\u041D\u0418\u0415\
]: \u041F\u043E\u043B\u0443\u0447\u0435\u043D\u0438\u0435 \u0434\u0438\u0430\u043F\
\u0430\u0437\u043E\u043D\u0430 \u0434\u0430\u0442 \u0434\u043B\u044F \u043E\u0442\
\u0447\u0435\u0442\u0430 \u043E \u0437\u0430\u0434\u043E\u043B\u0436\u0435\u043D\
\u043D\u043E\u0441\u0442\u044F\u0445 \u043F\u043E \u043E\u0431\u043E\u0440\u043E\
\u0442\u043D\u044B\u043C \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043C\r\
\n-- [\u041A\u041B\u042E\u0427\u0415\u0412\u042B\u0415 \u041A\u041E\u041B\u041E\u041D\
\u041A\u0418]:\r\n-- - from_dt_txt: \u041D\u0430\u0447\u0430\u043B\u044C\u043D\
\u0430\u044F \u0434\u0430\u0442\u0430 \u0432 \u0444\u043E\u0440\u043C\u0430\u0442\
\u0435 DD.MM.YYYY\r\n-- - to_dt_txt: \u041A\u043E\u043D\u0435\u0447\u043D\u0430\
\u044F \u0434\u0430\u0442\u0430 \u0432 \u0444\u043E\u0440\u043C\u0430\u0442\u0435\
\ DD.MM.YYYY\r\n-- [JINJA \u041F\u0410\u0420\u0410\u041C\u0415\u0422\u0420\u042B\
]:\r\n-- - {{ filter_values(\"yes_no_check\") }}: \u0424\u0438\u043B\u044C\u0442\
\u0440 \"\u0414\u0430/\u041D\u0435\u0442\" \u0434\u043B\u044F \u043E\u0433\u0440\
\u0430\u043D\u0438\u0447\u0435\u043D\u0438\u044F \u0432\u044B\u0431\u043E\u0440\u043A\
\u0438 \u043F\u043E \u0434\u0430\u0442\u0435\r\n-- [\u041B\u041E\u0413\u0418\u041A\
\u0410]: \u041E\u043F\u0440\u0435\u0434\u0435\u043B\u044F\u0435\u0442 \u043F\u043E\
\u0440\u043E\u0433\u043E\u0432\u0443\u044E \u0434\u0430\u0442\u0443 \u0432 \u0437\
\u0430\u0432\u0438\u0441\u0438\u043C\u043E\u0441\u0442\u0438 \u043E\u0442 \u0442\
\u0435\u043A\u0443\u0449\u0435\u0433\u043E \u0434\u043D\u044F \u043C\u0435\u0441\
\u044F\u0446\u0430 \u0438 \u0444\u0438\u043B\u044C\u0442\u0440\u0443\u0435\u0442\
\ \u0434\u0430\u043D\u043D\u044B\u0435\r\n\r\nWITH date_threshold AS (\r\n SELECT\
\ \r\n -- \u041E\u043F\u0440\u0435\u0434\u0435\u043B\u044F\u0435\u043C \u043F\
\u043E\u0440\u043E\u0433\u043E\u0432\u0443\u044E \u0434\u0430\u0442\u0443 \u0432\
\ \u0437\u0430\u0432\u0438\u0441\u0438\u043C\u043E\u0441\u0442\u0438 \u043E\u0442\
\ \u0442\u0435\u043A\u0443\u0449\u0435\u0433\u043E \u0434\u043D\u044F \r\n \
\ CASE \r\n WHEN toDayOfMonth(now()) <= 10 THEN \r\n \
\ toStartOfMonth(dateSub(MONTH, 1, now())) \r\n ELSE \r\n \
\ toStartOfMonth(now()) \r\n END AS cutoff_date \r\n),\r\nfiltered_dates\
\ AS (\r\n SELECT \r\n dt,\r\n formatDateTime(dt, '%d.%m.%Y') AS\
\ from_dt_txt,\r\n formatDateTime(dt, '%d.%m.%Y') AS to_dt_txt\r\n \
\ --dt as from_dt_txt,\r\n -- dt as to_dt_txt\r\n FROM dm_view.account_debt_for_working_capital_final\r\
\n WHERE 1=1\r\n -- \u0411\u0435\u0437\u043E\u043F\u0430\u0441\u043D\u0430\
\u044F \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0430 \u0444\u0438\u043B\u044C\
\u0442\u0440\u0430\r\n {% if filter_values(\"yes_no_check\") | length !=\
\ 0 %}\r\n {% if filter_values(\"yes_no_check\")[0] == \"\u0414\u0430\
\" %}\r\n AND dt < (SELECT cutoff_date FROM date_threshold)\r\n \
\ {% endif %}\r\n {% endif %}\r\n)\r\nSELECT \r\ndt,\r\n from_dt_txt,\r\
\n to_dt_txt,\r\n formatDateTime(toLastDayOfMonth(dt), '%d.%m.%Y') as last_day_of_month_dt_txt\r\
\nFROM \r\n filtered_dates\r\nGROUP BY \r\n dt, from_dt_txt, to_dt_txt\r\n\
ORDER BY \r\n dt DESC"
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
uuid: fca62707-6947-4440-a16b-70cb6a5cea5b
metrics:
- metric_name: max_date
verbose_name: max_date
metric_type: count
expression: max(dt)
description: null
d3format: null
currency: null
extra:
warning_markdown: ''
warning_text: null
columns:
- column_name: from_dt_txt
verbose_name: null
is_dttm: true
is_active: true
type: String
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: '%Y'
extra: {}
- column_name: dt
verbose_name: null
is_dttm: true
is_active: true
type: Date
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra: {}
- column_name: last_day_of_month_dt_txt
verbose_name: null
is_dttm: false
is_active: true
type: String
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra: {}
- column_name: to_dt_txt
verbose_name: null
is_dttm: true
is_active: true
type: String
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra: {}
version: 1.0.0
database_uuid: 97aced68-326a-4094-b381-27980560efa9

View File

@@ -0,0 +1,190 @@
table_name: "FI-0090 \u0421\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043A\u0430\
\ \u043F\u043E \u0414\u0417/\u041F\u0414\u0417"
main_dttm_col: dt
description: null
default_endpoint: null
offset: 0
cache_timeout: null
schema: dm_view
sql: "-- [JINJA_BLOCK] \u0426\u0435\u043D\u0442\u0440\u0430\u043B\u0438\u0437\u043E\
\u0432\u0430\u043D\u043D\u043E\u0435 \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0435\
\u043D\u0438\u0435 \u0432\u0441\u0435\u0445 Jinja \u043F\u0435\u0440\u0435\u043C\
\u0435\u043D\u043D\u044B\u0445\r\n{% set raw_to = filter_values('last_day_of_month_dt_txt')[0]\
\ \r\n if filter_values('last_day_of_month_dt_txt') else '01.05.2025'\
\ %}\r\n\r\n{# \u0440\u0430\u0437\u0431\u0438\u0432\u0430\u0435\u043C \xABDD.MM.YYYY\xBB\
\ \u043D\u0430 \u0447\u0430\u0441\u0442\u0438 #}\r\n{% set to_parts = raw_to.split('.')\
\ %}\r\n\r\n{# \u0441\u043E\u0431\u0438\u0440\u0430\u0435\u043C ISO\u2011\u0441\u0442\
\u0440\u043E\u043A\u0443 \xABYYYY-MM-DD\xBB #}\r\n{% set to_dt = to_parts[2] \
\ ~ '-' ~ to_parts[1] ~ '-' ~ to_parts[0] %}\r\n\r\nwith \r\ncp_relations_type\
\ AS (\r\n select * from ( SELECT \r\n ctd.counterparty_code AS counterparty_code,\r\
\n min(dt_from) as dt_from,\r\n max(dt_to) as dt_to,\r\n crt.relation_type_code\
\ || ' ' || crt.relation_type_name AS relation_type_code_name\r\n FROM\r\n \
\ dm_view.counterparty_td ctd\r\n JOIN dm_view.counterparty_relation_type_texts\
\ crt \r\n ON ctd.relation_type_code = crt.relation_type_code\r\n GROUP\
\ BY\r\n ctd.counterparty_code, ctd.counterparty_full_name,\r\n crt.relation_type_code,crt.relation_type_name)\r\
\n WHERE \r\n dt_from <= toDate('{{to_dt }}') AND \r\n \
\ dt_to >= toDate('{{to_dt }}')\r\n ),\r\nt_debt as \r\n(SELECT dt, \r\n\
counterparty_search_name,\r\ncp_relations_type.relation_type_code_name as relation_type_code_name,\r\
\nunit_balance_code || ' ' || unit_balance_name as unit_balance_code_name,\r\n'1.\
\ \u0421\u0443\u043C\u043C\u0430' as attribute,\r\nsum(debt_balance_subposition_no_revaluation_usd_amount)\
\ as debt_amount,\r\nsumIf(debt_balance_subposition_no_revaluation_usd_amount,dt_overdue\
\ < dt) as overdue_amount\r\nfrom dm_view.account_debt_for_working_capital t_debt\r\
\njoin cp_relations_type ON\r\ncp_relations_type.counterparty_code = t_debt.counterparty_code\r\
\nwhere dt = toLastDayOfMonth(dt)\r\nand match(general_ledger_account_code,'((62)|(60)|(76))')\r\
\nand debit_or_credit = 'S'\r\nand account_type = 'D'\r\nand dt between addMonths(toDate('{{to_dt\
\ }}'),-12) and toDate('{{to_dt }}')\r\ngroup by dt, counterparty_search_name,unit_balance_code_name,relation_type_code_name\r\
\n),\r\n\r\nt_transaction_count_base as \r\n(\r\nselect *,\r\ncp_relations_type.relation_type_code_name\
\ as relation_type_code_name,\r\nunit_balance_code || ' ' || unit_balance_name as\
\ unit_balance_code_name,\r\n case when dt_overdue<dt_clearing then\r\n \
\ dateDiff(day, dt_overdue, dt_clearing) \r\n else 0\r\n end\
\ as overdue_days\r\nfrom dm_view.accounting_documents_leading_to_debt t_docs\r\n\
join cp_relations_type ON\r\ncp_relations_type.counterparty_code = t_docs.counterparty_code\r\
\nwhere 1=1\r\n\r\nand match(general_ledger_account_code,'((62)|(60)|(76))')\r\n\
and debit_or_credit = 'S'\r\nand account_type = 'D'\r\n)\r\n\r\nselect * from t_debt\r\
\n\r\nunion all \r\n\r\nselect toLastDayOfMonth(dt_debt) as dt, \r\ncounterparty_search_name,\r\
\nrelation_type_code_name,\r\nunit_balance_code_name,\r\n'2. \u043A\u043E\u043B\u0438\
\u0447\u0435\u0441\u0442\u0432\u043E \u0442\u0440\u0430\u043D\u0437\u0430\u043A\u0446\
\u0438\u0439 \u0432 \u043C\u0435\u0441\u044F\u0446' as attribute,\r\ncount(1) as\
\ debt_amount,\r\nnull as overdue_amount\r\nfrom t_transaction_count_base\r\nwhere\
\ dt_debt between addMonths(toDate('{{to_dt }}'),-12) and toDate('{{to_dt }}')\r\
\ngroup by toLastDayOfMonth(dt_debt), \r\ncounterparty_search_name,\r\nrelation_type_code_name,\r\
\nunit_balance_code_name,attribute\r\n\r\nunion all \r\n\r\nselect toLastDayOfMonth(dt_clearing)\
\ as dt, \r\ncounterparty_search_name,\r\nrelation_type_code_name,\r\nunit_balance_code_name,\r\
\n'2. \u043A\u043E\u043B\u0438\u0447\u0435\u0441\u0442\u0432\u043E \u0442\u0440\u0430\
\u043D\u0437\u0430\u043A\u0446\u0438\u0439 \u0432 \u043C\u0435\u0441\u044F\u0446\
' as attribute,\r\nnull as debt_amount,\r\ncount(1) as overdue_amount\r\nfrom t_transaction_count_base\r\
\nwhere dt_clearing between addMonths(toDate('{{to_dt }}'),-12) and toDate('{{to_dt\
\ }}')\r\nand overdue_days > 0\r\ngroup by toLastDayOfMonth(dt_clearing), \r\ncounterparty_search_name,\r\
\nrelation_type_code_name,\r\nunit_balance_code_name,attribute\r\n\r\nunion all\
\ \r\n\r\nselect toLastDayOfMonth(dt_clearing) as dt, \r\ncounterparty_search_name,\r\
\nrelation_type_code_name,\r\nunit_balance_code_name,\r\nmultiIf(\r\noverdue_days\
\ < 30,'3. \u0434\u043E 30',\r\noverdue_days between 30 and 60, '4. \u043E\u0442\
\ 30 \u0434\u043E 60',\r\noverdue_days between 61 and 90, '5. \u043E\u0442 61 \u0434\
\u043E 90',\r\noverdue_days>90,'6. \u0431\u043E\u043B\u0435\u0435 90 \u0434\u043D\
',\r\nnull\r\n)\r\n as attribute,\r\nnull as debt_amount,\r\ncount(1) as overdue_amount\r\
\nfrom t_transaction_count_base\r\nwhere dt_clearing between addMonths(toDate('{{to_dt\
\ }}'),-12) and toDate('{{to_dt }}')\r\nand overdue_days > 0\r\ngroup by toLastDayOfMonth(dt_clearing),\
\ \r\ncounterparty_search_name,\r\nrelation_type_code_name,\r\nattribute,unit_balance_code_name,attribute\r\
\n"
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
uuid: 9e645dc0-da25-4f61-9465-6e649b0bc4b1
metrics:
- metric_name: m_debt_amount
verbose_name: "\u0414\u0417, $"
metric_type: count
expression: sum(debt_amount)
description: null
d3format: null
currency: null
extra:
warning_markdown: ''
warning_text: null
- metric_name: m_overdue_amount
verbose_name: "\u041F\u0414\u0417, $"
metric_type: null
expression: sum(overdue_amount)
description: null
d3format: null
currency: null
extra:
warning_markdown: ''
warning_text: null
columns:
- column_name: debt_amount
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(Decimal(38, 2))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: overdue_amount
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(Decimal(38, 2))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: dt
verbose_name: null
is_dttm: true
is_active: true
type: Nullable(Date)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: unit_balance_code_name
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(String)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: relation_type_code_name
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(String)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: counterparty_search_name
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(String)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
- column_name: attribute
verbose_name: null
is_dttm: false
is_active: true
type: Nullable(String)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
extra:
warning_markdown: null
version: 1.0.0
database_uuid: 97aced68-326a-4094-b381-27980560efa9

View File

@@ -0,0 +1,3 @@
version: 1.0.0
type: Dashboard
timestamp: '2026-01-14T11:21:08.078620+00:00'

7
frontend/.eslintignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
build/
.svelte-kit/
.vite/
coverage/
*.min.js

9
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
build/
.svelte-kit/
.vite/
coverage/
package-lock.json
yarn.lock
pnpm-lock.yaml

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -1,4 +1,5 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",

View File

@@ -1,117 +0,0 @@
<!-- [DEF:App:Component] -->
<!--
@SEMANTICS: main, entrypoint, layout, navigation
@PURPOSE: The root component of the frontend application. Manages navigation and layout.
@LAYER: UI
@RELATION: DEPENDS_ON -> frontend/src/pages/Dashboard.svelte
@RELATION: DEPENDS_ON -> frontend/src/pages/Settings.svelte
@RELATION: DEPENDS_ON -> frontend/src/lib/stores.js
@INVARIANT: Navigation state must be persisted in the currentPage store.
-->
<script>
// [SECTION: IMPORTS]
import { get } from 'svelte/store';
import Dashboard from './pages/Dashboard.svelte';
import Settings from './pages/Settings.svelte';
import { selectedPlugin, selectedTask, currentPage } from './lib/stores.js';
import TaskRunner from './components/TaskRunner.svelte';
import DynamicForm from './components/DynamicForm.svelte';
import { api } from './lib/api.js';
import Toast from './components/Toast.svelte';
// [/SECTION]
// [DEF:handleFormSubmit:Function]
/**
* @purpose Handles form submission for task creation.
* @pre event.detail contains form parameters.
* @post Task is created and selectedTask is updated.
* @param {CustomEvent} event - The submit event from DynamicForm.
*/
async function handleFormSubmit(event) {
console.log("[App.handleFormSubmit][Action] Handling form submission for task creation.");
const params = event.detail;
try {
const plugin = get(selectedPlugin);
const task = await api.createTask(plugin.id, params);
selectedTask.set(task);
selectedPlugin.set(null);
console.log(`[App.handleFormSubmit][Coherence:OK] Task created id=${task.id}`);
} catch (error) {
console.error(`[App.handleFormSubmit][Coherence:Failed] Task creation failed error=${error}`);
}
}
// [/DEF:handleFormSubmit:Function]
// [DEF:navigate:Function]
/**
* @purpose Changes the current page and resets state.
* @pre Target page name is provided.
* @post currentPage store is updated and selection state is reset.
* @param {string} page - Target page name.
*/
function navigate(page) {
console.log(`[App.navigate][Action] Navigating to ${page}.`);
// Reset selection first
if (page !== get(currentPage)) {
selectedPlugin.set(null);
selectedTask.set(null);
}
// Then set page
currentPage.set(page);
}
// [/DEF:navigate:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<Toast />
<main class="bg-gray-50 min-h-screen">
<header class="bg-white shadow-md p-4 flex justify-between items-center">
<button
type="button"
class="text-3xl font-bold text-gray-800 focus:outline-none"
on:click={() => navigate('dashboard')}
>
Superset Tools
</button>
<nav class="space-x-4">
<button
type="button"
on:click={() => navigate('dashboard')}
class="text-gray-600 hover:text-blue-600 font-medium {$currentPage === 'dashboard' ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
Dashboard
</button>
<button
type="button"
on:click={() => navigate('settings')}
class="text-gray-600 hover:text-blue-600 font-medium {$currentPage === 'settings' ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
Settings
</button>
</nav>
</header>
<div class="p-4">
{#if $currentPage === 'settings'}
<Settings />
{:else if $selectedTask}
<TaskRunner />
<button on:click={() => selectedTask.set(null)} class="mt-4 bg-blue-500 text-white p-2 rounded">
Back to Task List
</button>
{:else if $selectedPlugin}
<h2 class="text-2xl font-bold mb-4">{$selectedPlugin.name}</h2>
<DynamicForm schema={$selectedPlugin.schema} on:submit={handleFormSubmit} />
<button on:click={() => selectedPlugin.set(null)} class="mt-4 bg-gray-500 text-white p-2 rounded">
Back to Dashboard
</button>
{:else}
<Dashboard />
{/if}
</div>
</main>
<!-- [/SECTION] -->
<!-- [/DEF:App:Component] -->

View File

@@ -12,6 +12,7 @@
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import type { DashboardMetadata } from '../types/dashboard';
import GitManager from './git/GitManager.svelte';
// [/SECTION]
// [SECTION: PROPS]
@@ -27,6 +28,12 @@
let sortDirection: "asc" | "desc" = "asc";
// [/SECTION]
// [SECTION: UI STATE]
let showGitManager = false;
let gitDashboardId: number | null = null;
let gitDashboardTitle = "";
// [/SECTION]
// [SECTION: DERIVED]
$: filteredDashboards = dashboards.filter(d =>
d.title.toLowerCase().includes(filterText.toLowerCase())
@@ -120,6 +127,17 @@
}
// [/DEF:goToPage:Function]
// [DEF:openGit:Function]
/**
* @purpose Opens the Git management modal for a dashboard.
*/
function openGit(dashboard: DashboardMetadata) {
gitDashboardId = dashboard.id;
gitDashboardTitle = dashboard.title;
showGitManager = true;
}
// [/DEF:openGit:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
@@ -156,6 +174,7 @@
<th class="px-4 py-2 border-b cursor-pointer" on:click={() => handleSort('status')}>
Status {sortColumn === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''}
</th>
<th class="px-4 py-2 border-b">Git</th>
</tr>
</thead>
<tbody>
@@ -175,6 +194,14 @@
{dashboard.status}
</span>
</td>
<td class="px-4 py-2 border-b">
<button
on:click={() => openGit(dashboard)}
class="text-indigo-600 hover:text-indigo-900 text-sm font-medium"
>
Manage Git
</button>
</td>
</tr>
{/each}
</tbody>
@@ -204,6 +231,15 @@
</div>
</div>
</div>
{#if showGitManager && gitDashboardId}
<GitManager
dashboardId={gitDashboardId}
dashboardTitle={gitDashboardTitle}
bind:show={showGitManager}
/>
{/if}
<!-- [/SECTION] -->
<style>

View File

@@ -29,6 +29,12 @@
>
Migration
</a>
<a
href="/git"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/git') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
Git
</a>
<a
href="/tasks"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/tasks') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
@@ -39,7 +45,7 @@
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/tools') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
Tools
</button>
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100">
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
<a href="/tools/search" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Dataset Search</a>
<a href="/tools/mapper" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Dataset Mapper</a>
<a href="/tools/debug" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">System Debug</a>
@@ -49,9 +55,11 @@
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/settings') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
Settings
</button>
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100">
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
<a href="/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">General Settings</a>
<a href="/settings/connections" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Connections</a>
<a href="/settings/git" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Git Integration</a>
<a href="/settings/environments" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">Environments</a>
</div>
</div>
</nav>

View File

@@ -1,15 +1,16 @@
<!-- [DEF:TaskLogViewer:Component] -->
<!--
@SEMANTICS: task, log, viewer, modal
@PURPOSE: Displays detailed logs for a specific task in a modal.
@SEMANTICS: task, log, viewer, modal, inline
@PURPOSE: Displays detailed logs for a specific task in a modal or inline.
@LAYER: UI
@RELATION: USES -> frontend/src/lib/api.js (inferred)
@RELATION: USES -> frontend/src/services/taskService.js
-->
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { getTaskLogs } from '../services/taskService.js';
export let show = false;
export let inline = false;
export let taskId = null;
export let taskStatus = null; // To know if we should poll
@@ -22,19 +23,27 @@
let autoScroll = true;
let logContainer;
$: shouldShow = inline || show;
// [DEF:fetchLogs:Function]
// @PURPOSE: Fetches logs for the current task.
// @PRE: taskId must be set.
// @POST: logs array is updated with data from taskService.
/**
* @purpose Fetches logs for the current task.
* @pre taskId must be set.
* @post logs array is updated with data from taskService.
* @side_effect Updates logs, loading, and error state.
*/
async function fetchLogs() {
if (!taskId) return;
console.log(`[fetchLogs][Action] Fetching logs for task context={{'taskId': '${taskId}'}}`);
try {
logs = await getTaskLogs(taskId);
if (autoScroll) {
scrollToBottom();
}
console.log(`[fetchLogs][Coherence:OK] Logs fetched context={{'count': ${logs.length}}}`);
} catch (e) {
error = e.message;
console.error(`[fetchLogs][Coherence:Failed] Error fetching logs context={{'error': '${e.message}'}}`);
} finally {
loading = false;
}
@@ -42,9 +51,11 @@
// [/DEF:fetchLogs:Function]
// [DEF:scrollToBottom:Function]
// @PURPOSE: Scrolls the log container to the bottom.
// @PRE: logContainer element must be bound.
// @POST: logContainer scrollTop is set to scrollHeight.
/**
* @purpose Scrolls the log container to the bottom.
* @pre logContainer element must be bound.
* @post logContainer scrollTop is set to scrollHeight.
*/
function scrollToBottom() {
if (logContainer) {
setTimeout(() => {
@@ -55,9 +66,11 @@
// [/DEF:scrollToBottom:Function]
// [DEF:handleScroll:Function]
// @PURPOSE: Updates auto-scroll preference based on scroll position.
// @PRE: logContainer scroll event fired.
// @POST: autoScroll boolean is updated.
/**
* @purpose Updates auto-scroll preference based on scroll position.
* @pre logContainer scroll event fired.
* @post autoScroll boolean is updated.
*/
function handleScroll() {
if (!logContainer) return;
// If user scrolls up, disable auto-scroll
@@ -68,9 +81,11 @@
// [/DEF:handleScroll:Function]
// [DEF:close:Function]
// @PURPOSE: Closes the log viewer modal.
// @PRE: Modal is open.
// @POST: Modal is closed and close event is dispatched.
/**
* @purpose Closes the log viewer modal.
* @pre Modal is open.
* @post Modal is closed and close event is dispatched.
*/
function close() {
dispatch('close');
show = false;
@@ -78,9 +93,11 @@
// [/DEF:close:Function]
// [DEF:getLogLevelColor:Function]
// @PURPOSE: Returns the CSS color class for a given log level.
// @PRE: level string is provided.
// @POST: Returns tailwind color class string.
/**
* @purpose Returns the CSS color class for a given log level.
* @pre level string is provided.
* @post Returns tailwind color class string.
*/
function getLogLevelColor(level) {
switch (level) {
case 'INFO': return 'text-blue-600';
@@ -92,8 +109,10 @@
}
// [/DEF:getLogLevelColor:Function]
// React to changes in show/taskId
$: if (show && taskId) {
// React to changes in show/taskId/taskStatus
$: if (shouldShow && taskId) {
if (interval) clearInterval(interval);
logs = [];
loading = true;
error = "";
@@ -108,76 +127,120 @@
}
// [DEF:onDestroy:Function]
// @PURPOSE: Cleans up the polling interval.
// @PRE: Component is being destroyed.
// @POST: Polling interval is cleared.
/**
* @purpose Cleans up the polling interval.
* @pre Component is being destroyed.
* @post Polling interval is cleared.
*/
onDestroy(() => {
if (interval) clearInterval(interval);
});
// [/DEF:onDestroy:Function]
</script>
{#if show}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" on:click={close}></div>
{#if shouldShow}
{#if inline}
<div class="flex flex-col h-full w-full p-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">
Task Logs <span class="text-sm text-gray-500 font-normal">({taskId})</span>
</h3>
<button on:click={fetchLogs} class="text-sm text-indigo-600 hover:text-indigo-900">Refresh</button>
</div>
<div class="flex-1 border rounded-md bg-gray-50 p-4 overflow-y-auto font-mono text-sm"
bind:this={logContainer}
on:scroll={handleScroll}>
{#if loading && logs.length === 0}
<p class="text-gray-500 text-center">Loading logs...</p>
{:else if error}
<p class="text-red-500 text-center">{error}</p>
{:else if logs.length === 0}
<p class="text-gray-500 text-center">No logs available.</p>
{:else}
{#each logs as log}
<div class="mb-1 hover:bg-gray-100 p-1 rounded">
<span class="text-gray-400 text-xs mr-2">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span class="font-bold text-xs mr-2 w-16 inline-block {getLogLevelColor(log.level)}">
[{log.level}]
</span>
<span class="text-gray-800 break-words">
{log.message}
</span>
{#if log.context}
<div class="ml-24 text-xs text-gray-500 mt-1 bg-gray-100 p-1 rounded overflow-x-auto">
<pre>{JSON.stringify(log.context, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
{:else}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" on:click={close}></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900 flex justify-between items-center" id="modal-title">
<span>Task Logs <span class="text-sm text-gray-500 font-normal">({taskId})</span></span>
<button on:click={fetchLogs} class="text-sm text-indigo-600 hover:text-indigo-900">Refresh</button>
</h3>
<div class="mt-4 border rounded-md bg-gray-50 p-4 h-96 overflow-y-auto font-mono text-sm"
bind:this={logContainer}
on:scroll={handleScroll}>
{#if loading && logs.length === 0}
<p class="text-gray-500 text-center">Loading logs...</p>
{:else if error}
<p class="text-red-500 text-center">{error}</p>
{:else if logs.length === 0}
<p class="text-gray-500 text-center">No logs available.</p>
{:else}
{#each logs as log}
<div class="mb-1 hover:bg-gray-100 p-1 rounded">
<span class="text-gray-400 text-xs mr-2">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span class="font-bold text-xs mr-2 w-16 inline-block {getLogLevelColor(log.level)}">
[{log.level}]
</span>
<span class="text-gray-800 break-words">
{log.message}
</span>
{#if log.context}
<div class="ml-24 text-xs text-gray-500 mt-1 bg-gray-100 p-1 rounded overflow-x-auto">
<pre>{JSON.stringify(log.context, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
{/if}
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900 flex justify-between items-center" id="modal-title">
<span>Task Logs <span class="text-sm text-gray-500 font-normal">({taskId})</span></span>
<button on:click={fetchLogs} class="text-sm text-indigo-600 hover:text-indigo-900">Refresh</button>
</h3>
<div class="mt-4 border rounded-md bg-gray-50 p-4 h-96 overflow-y-auto font-mono text-sm"
bind:this={logContainer}
on:scroll={handleScroll}>
{#if loading && logs.length === 0}
<p class="text-gray-500 text-center">Loading logs...</p>
{:else if error}
<p class="text-red-500 text-center">{error}</p>
{:else if logs.length === 0}
<p class="text-gray-500 text-center">No logs available.</p>
{:else}
{#each logs as log}
<div class="mb-1 hover:bg-gray-100 p-1 rounded">
<span class="text-gray-400 text-xs mr-2">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span class="font-bold text-xs mr-2 w-16 inline-block {getLogLevelColor(log.level)}">
[{log.level}]
</span>
<span class="text-gray-800 break-words">
{log.message}
</span>
{#if log.context}
<div class="ml-24 text-xs text-gray-500 mt-1 bg-gray-100 p-1 rounded overflow-x-auto">
<pre>{JSON.stringify(log.context, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
on:click={close}
>
Close
</button>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
on:click={close}
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
{/if}
{/if}
<!-- [/DEF:TaskLogViewer:Component] -->

View File

@@ -0,0 +1,170 @@
<!-- [DEF:BranchSelector:Component] -->
<!--
@SEMANTICS: git, branch, selection, checkout
@PURPOSE: UI для выбора и создания веток Git.
@LAYER: Component
@RELATION: CALLS -> gitService.getBranches
@RELATION: CALLS -> gitService.checkoutBranch
@RELATION: CALLS -> gitService.createBranch
@RELATION: DISPATCHES -> change
-->
<script>
// [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let currentBranch = 'main';
// [/SECTION]
// [SECTION: STATE]
let branches = [];
let loading = false;
let showCreate = false;
let newBranchName = '';
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:onMount:Function]
onMount(async () => {
await loadBranches();
});
// [/DEF:onMount:Function]
// [DEF:loadBranches:Function]
/**
* @purpose Загружает список веток для дашборда.
* @post branches обновлен.
*/
async function loadBranches() {
console.log(`[BranchSelector][Action] Loading branches for dashboard ${dashboardId}`);
loading = true;
try {
branches = await gitService.getBranches(dashboardId);
console.log(`[BranchSelector][Coherence:OK] Loaded ${branches.length} branches`);
} catch (e) {
console.error(`[BranchSelector][Coherence:Failed] ${e.message}`);
toast('Failed to load branches', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadBranches:Function]
// [DEF:handleSelect:Function]
function handleSelect(event) {
handleCheckout(event.target.value);
}
// [/DEF:handleSelect:Function]
// [DEF:handleCheckout:Function]
/**
* @purpose Переключает текущую ветку.
* @param {string} branchName - Имя ветки.
* @post currentBranch обновлен, событие отправлено.
*/
async function handleCheckout(branchName) {
console.log(`[BranchSelector][Action] Checking out branch ${branchName}`);
try {
await gitService.checkoutBranch(dashboardId, branchName);
currentBranch = branchName;
dispatch('change', { branch: branchName });
toast(`Switched to ${branchName}`, 'success');
console.log(`[BranchSelector][Coherence:OK] Checked out ${branchName}`);
} catch (e) {
console.error(`[BranchSelector][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
}
}
// [/DEF:handleCheckout:Function]
// [DEF:handleCreate:Function]
/**
* @purpose Создает новую ветку.
* @post Новая ветка создана и загружена.
*/
async function handleCreate() {
if (!newBranchName) return;
console.log(`[BranchSelector][Action] Creating branch ${newBranchName} from ${currentBranch}`);
try {
await gitService.createBranch(dashboardId, newBranchName, currentBranch);
toast(`Created branch ${newBranchName}`, 'success');
showCreate = false;
newBranchName = '';
await loadBranches();
console.log(`[BranchSelector][Coherence:OK] Branch created`);
} catch (e) {
console.error(`[BranchSelector][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
}
}
// [/DEF:handleCreate:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<div class="relative">
<select
value={currentBranch}
on:change={handleSelect}
disabled={loading}
class="bg-white border rounded px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{#each branches as branch}
<option value={branch.name}>{branch.name}</option>
{/each}
</select>
{#if loading}
<span class="absolute -right-6 top-1">
<svg class="animate-spin h-4 w-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
{/if}
</div>
<button
on:click={() => showCreate = !showCreate}
disabled={loading}
class="text-blue-600 hover:text-blue-800 text-sm font-medium disabled:opacity-50"
>
+ New Branch
</button>
</div>
{#if showCreate}
<div class="flex items-center space-x-1 bg-gray-50 p-2 rounded border border-dashed">
<input
type="text"
bind:value={newBranchName}
placeholder="branch-name"
disabled={loading}
class="border rounded px-2 py-1 text-sm w-full max-w-[150px]"
/>
<button
on:click={handleCreate}
disabled={loading || !newBranchName}
class="bg-green-600 text-white px-3 py-1 rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50"
>
{loading ? '...' : 'Create'}
</button>
<button
on:click={() => showCreate = false}
disabled={loading}
class="text-gray-500 hover:text-gray-700 text-xs px-2 py-1 disabled:opacity-50"
>
Cancel
</button>
</div>
{/if}
</div>
<!-- [/SECTION] -->
<!-- [/DEF:BranchSelector:Component] -->

View File

@@ -0,0 +1,90 @@
<!-- [DEF:CommitHistory:Component] -->
<!--
@SEMANTICS: git, history, commits, audit
@PURPOSE: Displays the commit history for a specific dashboard.
@LAYER: Component
@RELATION: CALLS -> gitService.getHistory
-->
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
// [/SECTION]
// [SECTION: STATE]
let history = [];
let loading = false;
// [/SECTION]
// [DEF:onMount:Function]
/**
* @purpose Load history when component is mounted.
*/
onMount(async () => {
await loadHistory();
});
// [/DEF:onMount:Function]
// [DEF:loadHistory:Function]
/**
* @purpose Fetch commit history from the backend.
* @post history state is updated.
*/
async function loadHistory() {
console.log(`[CommitHistory][Action] Loading history for dashboard ${dashboardId}`);
loading = true;
try {
history = await gitService.getHistory(dashboardId);
console.log(`[CommitHistory][Coherence:OK] Loaded ${history.length} commits`);
} catch (e) {
console.error(`[CommitHistory][Coherence:Failed] ${e.message}`);
toast('Failed to load commit history', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadHistory:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="mt-6">
<h3 class="text-lg font-semibold mb-4 flex justify-between items-center">
Commit History
<button on:click={loadHistory} class="text-sm text-blue-600 hover:underline">Refresh</button>
</h3>
{#if loading}
<div class="flex items-center space-x-2 text-gray-500">
<svg class="animate-spin h-4 w-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Loading history...</span>
</div>
{:else if history.length === 0}
<p class="text-gray-500 italic">No commits yet.</p>
{:else}
<div class="space-y-3 max-h-96 overflow-y-auto pr-2">
{#each history as commit}
<div class="border-l-2 border-blue-500 pl-4 py-1">
<div class="flex justify-between items-start">
<span class="font-medium text-sm">{commit.message}</span>
<span class="text-xs text-gray-400 font-mono">{commit.hash.substring(0, 7)}</span>
</div>
<div class="text-xs text-gray-500 mt-1">
{commit.author}{new Date(commit.timestamp).toLocaleString()}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- [/SECTION] -->
<!-- [/DEF:CommitHistory:Component] -->

View File

@@ -0,0 +1,175 @@
<!-- [DEF:CommitModal:Component] -->
<!--
@SEMANTICS: git, commit, modal, version_control, diff
@PURPOSE: Модальное окно для создания коммита с просмотром изменений (diff).
@LAYER: Component
@RELATION: CALLS -> gitService.commit
@RELATION: CALLS -> gitService.getStatus
@RELATION: CALLS -> gitService.getDiff
@RELATION: DISPATCHES -> commit
-->
<script>
// [SECTION: IMPORTS]
import { createEventDispatcher, onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let show = false;
// [/SECTION]
// [SECTION: STATE]
let message = '';
let committing = false;
let status = null;
let diff = '';
let loading = false;
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:loadStatus:Function]
/**
* @purpose Загружает текущий статус репозитория и diff.
* @pre dashboardId должен быть валидным.
*/
async function loadStatus() {
if (!dashboardId || !show) return;
loading = true;
try {
console.log(`[CommitModal][Action] Loading status and diff for ${dashboardId}`);
status = await gitService.getStatus(dashboardId);
// Fetch both unstaged and staged diffs to show complete picture
const unstagedDiff = await gitService.getDiff(dashboardId, null, false);
const stagedDiff = await gitService.getDiff(dashboardId, null, true);
diff = "";
if (stagedDiff) diff += "--- STAGED CHANGES ---\n" + stagedDiff + "\n\n";
if (unstagedDiff) diff += "--- UNSTAGED CHANGES ---\n" + unstagedDiff;
if (!diff) diff = "";
} catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast('Failed to load changes', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadStatus:Function]
// [DEF:handleCommit:Function]
/**
* @purpose Создает коммит с указанным сообщением.
* @pre message не должно быть пустым.
* @post Коммит создан, событие отправлено, модальное окно закрыто.
*/
async function handleCommit() {
if (!message) return;
console.log(`[CommitModal][Action] Committing changes for dashboard ${dashboardId}`);
committing = true;
try {
await gitService.commit(dashboardId, message, []);
toast('Changes committed successfully', 'success');
dispatch('commit');
show = false;
message = '';
console.log(`[CommitModal][Coherence:OK] Committed`);
} catch (e) {
console.error(`[CommitModal][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
} finally {
committing = false;
}
}
// [/DEF:handleCommit:Function]
$: if (show) loadStatus();
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<h2 class="text-xl font-bold mb-4">Commit Changes</h2>
<div class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden">
<!-- Left: Message and Files -->
<div class="w-full md:w-1/3 flex flex-col">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Commit Message</label>
<textarea
bind:value={message}
class="w-full border rounded p-2 h-32 focus:ring-2 focus:ring-blue-500 outline-none resize-none"
placeholder="Describe your changes..."
></textarea>
</div>
{#if status}
<div class="flex-1 overflow-y-auto">
<h3 class="text-sm font-bold text-gray-500 uppercase mb-2">Changed Files</h3>
<ul class="text-xs space-y-1">
{#each status.staged_files as file}
<li class="text-green-600 flex items-center font-semibold" title="Staged">
<span class="mr-2">S</span> {file}
</li>
{/each}
{#each status.modified_files as file}
<li class="text-yellow-600 flex items-center" title="Modified (Unstaged)">
<span class="mr-2">M</span> {file}
</li>
{/each}
{#each status.untracked_files as file}
<li class="text-blue-600 flex items-center" title="Untracked">
<span class="mr-2">?</span> {file}
</li>
{/each}
</ul>
</div>
{/if}
</div>
<!-- Right: Diff Viewer -->
<div class="w-full md:w-2/3 flex flex-col overflow-hidden border rounded bg-gray-50">
<div class="bg-gray-200 px-3 py-1 text-xs font-bold text-gray-600 border-b">Changes Preview</div>
<div class="flex-1 overflow-auto p-2">
{#if loading}
<div class="flex items-center justify-center h-full text-gray-500">Loading diff...</div>
{:else if diff}
<pre class="text-xs font-mono whitespace-pre-wrap">{diff}</pre>
{:else}
<div class="flex items-center justify-center h-full text-gray-500 italic">No changes detected</div>
{/if}
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6 pt-4 border-t">
<button
on:click={() => show = false}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
Cancel
</button>
<button
on:click={handleCommit}
disabled={committing || !message || loading || (!status?.is_dirty && status?.staged_files?.length === 0)}
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{committing ? 'Committing...' : 'Commit'}
</button>
</div>
</div>
</div>
{/if}
<!-- [/SECTION] -->
<style>
pre {
tab-size: 4;
}
</style>
<!-- [/DEF:CommitModal:Component] -->

View File

@@ -0,0 +1,142 @@
<!-- [DEF:ConflictResolver:Component] -->
<!--
@SEMANTICS: git, conflict, resolution, merge
@PURPOSE: UI for resolving merge conflicts (Keep Mine / Keep Theirs).
@LAYER: Component
@RELATION: DISPATCHES -> resolve
@INVARIANT: User must resolve all conflicts before saving.
-->
<script>
// [SECTION: IMPORTS]
import { createEventDispatcher } from 'svelte';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
/** @type {Array<{file_path: string, mine: string, theirs: string}>} */
export let conflicts = [];
export let show = false;
// [/SECTION]
// [SECTION: STATE]
const dispatch = createEventDispatcher();
/** @type {Object.<string, 'mine' | 'theirs' | 'manual'>} */
let resolutions = {};
// [/SECTION]
// [DEF:resolve:Function]
/**
* @purpose Set resolution strategy for a file.
* @pre file path must exist in conflicts array.
* @post resolutions state is updated for the given file.
* @param {string} file - File path.
* @param {'mine'|'theirs'} strategy - Resolution strategy.
* @side_effect Updates resolutions state.
*/
function resolve(file, strategy) {
console.log(`[ConflictResolver][Action] Resolving ${file} with ${strategy}`);
resolutions[file] = strategy;
resolutions = { ...resolutions }; // Trigger update
}
// [/DEF:resolve:Function]
// [DEF:handleSave:Function]
/**
* @purpose Validate and submit resolutions.
* @pre All conflicts must have a resolution.
* @post 'resolve' event dispatched if valid.
* @side_effect Dispatches event and closes modal.
*/
function handleSave() {
// 1. Guard Clause (@PRE)
const unresolved = conflicts.filter(c => !resolutions[c.file_path]);
if (unresolved.length > 0) {
console.warn(`[ConflictResolver][Coherence:Failed] ${unresolved.length} unresolved conflicts`);
toast(`Please resolve all conflicts first. (${unresolved.length} remaining)`, 'error');
return;
}
// 2. Implementation
console.log(`[ConflictResolver][Coherence:OK] All conflicts resolved`);
dispatch('resolve', resolutions);
show = false;
}
// [/DEF:handleSave:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
<h2 class="text-xl font-bold mb-4 text-red-600">Merge Conflicts Detected</h2>
<p class="text-gray-600 mb-4">The following files have conflicts. Please choose how to resolve them.</p>
<div class="flex-1 overflow-y-auto space-y-6 mb-4 pr-2">
{#each conflicts as conflict}
<div class="border rounded-lg overflow-hidden">
<div class="bg-gray-100 px-4 py-2 font-medium border-b flex justify-between items-center">
<span>{conflict.file_path}</span>
{#if resolutions[conflict.file_path]}
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full uppercase font-bold">
Resolved: {resolutions[conflict.file_path]}
</span>
{/if}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-0 divide-x">
<div class="p-0 flex flex-col">
<div class="bg-blue-50 px-4 py-1 text-[10px] font-bold text-blue-600 uppercase border-b">Your Changes (Mine)</div>
<div class="p-4 bg-white flex-1 overflow-auto">
<pre class="text-xs font-mono whitespace-pre">{conflict.mine}</pre>
</div>
<button
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[conflict.file_path] === 'mine' ? 'bg-blue-600 text-white' : 'bg-gray-50 hover:bg-blue-50 text-blue-600'}"
on:click={() => resolve(conflict.file_path, 'mine')}
>
Keep Mine
</button>
</div>
<div class="p-0 flex flex-col">
<div class="bg-green-50 px-4 py-1 text-[10px] font-bold text-green-600 uppercase border-b">Remote Changes (Theirs)</div>
<div class="p-4 bg-white flex-1 overflow-auto">
<pre class="text-xs font-mono whitespace-pre">{conflict.theirs}</pre>
</div>
<button
class="w-full py-2 text-sm font-medium border-t transition-colors {resolutions[conflict.file_path] === 'theirs' ? 'bg-green-600 text-white' : 'bg-gray-50 hover:bg-green-50 text-green-600'}"
on:click={() => resolve(conflict.file_path, 'theirs')}
>
Keep Theirs
</button>
</div>
</div>
</div>
{/each}
</div>
<div class="flex justify-end space-x-3 pt-4 border-t">
<button
on:click={() => show = false}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded transition-colors"
>
Cancel
</button>
<button
on:click={handleSave}
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors shadow-sm"
>
Resolve & Continue
</button>
</div>
</div>
</div>
{/if}
<!-- [/SECTION] -->
<style>
pre {
tab-size: 4;
}
</style>
<!-- [/DEF:ConflictResolver:Component] -->

View File

@@ -0,0 +1,147 @@
<!-- [DEF:DeploymentModal:Component] -->
<!--
@SEMANTICS: deployment, git, environment, modal
@PURPOSE: Modal for deploying a dashboard to a target environment.
@LAYER: Component
@RELATION: CALLS -> frontend/src/services/gitService.js
@RELATION: DISPATCHES -> deploy
@INVARIANT: Cannot deploy without a selected environment.
-->
<script>
// [SECTION: IMPORTS]
import { onMount, createEventDispatcher } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let show = false;
// [/SECTION]
// [SECTION: STATE]
let environments = [];
let selectedEnv = '';
let loading = false;
let deploying = false;
// [/SECTION]
const dispatch = createEventDispatcher();
// [DEF:loadStatus:Watcher]
$: if (show) loadEnvironments();
// [DEF:loadEnvironments:Function]
/**
* @purpose Fetch available environments from API.
* @post environments state is populated.
* @side_effect Updates environments state.
*/
async function loadEnvironments() {
console.log(`[DeploymentModal][Action] Loading environments`);
loading = true;
try {
environments = await gitService.getEnvironments();
if (environments.length > 0) {
selectedEnv = environments[0].id;
}
console.log(`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`);
} catch (e) {
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
toast('Failed to load environments', 'error');
} finally {
loading = false;
}
}
// [/DEF:loadEnvironments:Function]
// [DEF:handleDeploy:Function]
/**
* @purpose Trigger deployment to selected environment.
* @pre selectedEnv must be set.
* @post deploy event dispatched on success.
* @side_effect Triggers API call, closes modal, shows toast.
*/
async function handleDeploy() {
if (!selectedEnv) return;
console.log(`[DeploymentModal][Action] Deploying to ${selectedEnv}`);
deploying = true;
try {
const result = await gitService.deploy(dashboardId, selectedEnv);
toast(result.message || 'Deployment triggered successfully', 'success');
dispatch('deploy');
show = false;
console.log(`[DeploymentModal][Coherence:OK] Deployment triggered`);
} catch (e) {
console.error(`[DeploymentModal][Coherence:Failed] ${e.message}`);
toast(e.message, 'error');
} finally {
deploying = false;
}
}
// [/DEF:handleDeploy:Function]
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white p-6 rounded-lg shadow-xl w-96">
<h2 class="text-xl font-bold mb-4">Deploy Dashboard</h2>
{#if loading}
<p class="text-gray-500">Loading environments...</p>
{:else if environments.length === 0}
<p class="text-red-500 mb-4">No deployment environments configured.</p>
<div class="flex justify-end">
<button
on:click={() => show = false}
class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
>
Close
</button>
</div>
{:else}
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Select Target Environment</label>
<select
bind:value={selectedEnv}
class="w-full border rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
>
{#each environments as env}
<option value={env.id}>{env.name} ({env.superset_url})</option>
{/each}
</select>
</div>
<div class="flex justify-end space-x-3">
<button
on:click={() => show = false}
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
Cancel
</button>
<button
on:click={handleDeploy}
disabled={deploying || !selectedEnv}
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center"
>
{#if deploying}
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Deploying...
{:else}
Deploy
{/if}
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- [/SECTION] -->
<!-- [/DEF:DeploymentModal:Component] -->

View File

@@ -0,0 +1,284 @@
<!-- [DEF:GitManager:Component] -->
<!--
@SEMANTICS: git, manager, dashboard, version_control, initialization
@PURPOSE: Центральный компонент для управления Git-операциями конкретного дашборда.
@LAYER: Component
@RELATION: USES -> BranchSelector
@RELATION: USES -> CommitModal
@RELATION: USES -> CommitHistory
@RELATION: USES -> DeploymentModal
@RELATION: USES -> ConflictResolver
@RELATION: CALLS -> gitService
-->
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { gitService } from '../../services/gitService';
import { addToast as toast } from '../../lib/toasts.js';
import BranchSelector from './BranchSelector.svelte';
import CommitModal from './CommitModal.svelte';
import CommitHistory from './CommitHistory.svelte';
import DeploymentModal from './DeploymentModal.svelte';
import ConflictResolver from './ConflictResolver.svelte';
// [/SECTION]
// [SECTION: PROPS]
export let dashboardId;
export let dashboardTitle = "";
export let show = false;
// [/SECTION]
// [SECTION: STATE]
let currentBranch = 'main';
let showCommitModal = false;
let showDeployModal = false;
let showHistory = true;
let showConflicts = false;
let conflicts = [];
let loading = false;
let initialized = false;
let checkingStatus = true;
// Initialization form state
let configs = [];
let selectedConfigId = "";
let remoteUrl = "";
// [/SECTION]
// [DEF:checkStatus:Function]
/**
* @purpose Проверяет, инициализирован ли репозиторий для данного дашборда.
*/
async function checkStatus() {
checkingStatus = true;
try {
// If we can get branches, it means repo exists
await gitService.getBranches(dashboardId);
initialized = true;
} catch (e) {
initialized = false;
// Load configs if not initialized
configs = await gitService.getConfigs();
if (configs.length > 0) selectedConfigId = configs[0].id;
} finally {
checkingStatus = false;
}
}
// [/DEF:checkStatus:Function]
// [DEF:handleInit:Function]
/**
* @purpose Инициализирует репозиторий для дашборда.
*/
async function handleInit() {
if (!selectedConfigId || !remoteUrl) {
toast('Please select a Git server and provide remote URL', 'error');
return;
}
loading = true;
try {
await gitService.initRepository(dashboardId, selectedConfigId, remoteUrl);
toast('Repository initialized successfully', 'success');
initialized = true;
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handleInit:Function]
// [DEF:handleSync:Function]
/**
* @purpose Синхронизирует состояние Superset с локальным Git-репозиторием.
*/
async function handleSync() {
loading = true;
try {
// Try to get selected environment from localStorage (set by EnvSelector)
const sourceEnvId = localStorage.getItem('selected_env_id');
await gitService.sync(dashboardId, sourceEnvId);
toast('Dashboard state synced to Git', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handleSync:Function]
// [DEF:handlePush:Function]
async function handlePush() {
loading = true;
try {
await gitService.push(dashboardId);
toast('Changes pushed to remote', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handlePush:Function]
// [DEF:handlePull:Function]
async function handlePull() {
loading = true;
try {
await gitService.pull(dashboardId);
toast('Changes pulled from remote', 'success');
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
// [/DEF:handlePull:Function]
onMount(checkStatus);
</script>
<!-- [SECTION: TEMPLATE] -->
{#if show}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white p-6 rounded-lg shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-6 border-b pb-4">
<div>
<h2 class="text-2xl font-bold">Git Management: {dashboardTitle}</h2>
<p class="text-sm text-gray-500">ID: {dashboardId}</p>
</div>
<button on:click={() => show = false} class="text-gray-500 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{#if checkingStatus}
<div class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else if !initialized}
<div class="max-w-md mx-auto py-8">
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-6">
<p class="text-sm text-blue-700">
This dashboard is not yet linked to a Git repository.
Please configure the repository details below.
</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Git Server</label>
<select bind:value={selectedConfigId} class="mt-1 block w-full border rounded p-2">
{#each configs as config}
<option value={config.id}>{config.name} ({config.provider})</option>
{/each}
</select>
{#if configs.length === 0}
<p class="text-xs text-red-500 mt-1">No Git servers configured. Go to Settings -> Git to add one.</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Remote Repository URL</label>
<input
type="text"
bind:value={remoteUrl}
placeholder="https://github.com/org/repo.git"
class="mt-1 block w-full border rounded p-2"
/>
</div>
<button
on:click={handleInit}
disabled={loading || configs.length === 0}
class="w-full bg-blue-600 text-white py-2 rounded font-medium hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Initializing...' : 'Initialize Repository'}
</button>
</div>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Left Column: Controls -->
<div class="md:col-span-1 space-y-6">
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Branch</h3>
<BranchSelector {dashboardId} bind:currentBranch />
</section>
<section class="space-y-2">
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Actions</h3>
<button
on:click={handleSync}
disabled={loading}
class="w-full flex items-center justify-center px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded text-sm font-medium transition"
>
Sync from Superset
</button>
<button
on:click={() => showCommitModal = true}
disabled={loading}
class="w-full flex items-center justify-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm font-medium transition"
>
Commit Changes
</button>
<div class="grid grid-cols-2 gap-2">
<button
on:click={handlePull}
disabled={loading}
class="flex items-center justify-center px-4 py-2 border hover:bg-gray-50 rounded text-sm font-medium transition"
>
Pull
</button>
<button
on:click={handlePush}
disabled={loading}
class="flex items-center justify-center px-4 py-2 border hover:bg-gray-50 rounded text-sm font-medium transition"
>
Push
</button>
</div>
</section>
<section>
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Deployment</h3>
<button
on:click={() => showDeployModal = true}
disabled={loading}
class="w-full flex items-center justify-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded text-sm font-medium transition"
>
Deploy to Environment
</button>
</section>
</div>
<!-- Right Column: History -->
<div class="md:col-span-2 border-l pl-6">
<CommitHistory {dashboardId} />
</div>
</div>
{/if}
</div>
</div>
{/if}
<CommitModal
{dashboardId}
bind:show={showCommitModal}
on:commit={() => { /* Refresh history */ }}
/>
<DeploymentModal
{dashboardId}
bind:show={showDeployModal}
/>
<ConflictResolver
{conflicts}
bind:show={showConflicts}
on:resolve={() => { /* Handle resolution */ }}
/>
<!-- [/SECTION] -->
<!-- [/DEF:GitManager:Component] -->

View File

@@ -1,18 +0,0 @@
// [DEF:main:Module]
// @SEMANTICS: entrypoint, svelte, init
// @PURPOSE: Entry point for the Svelte application.
// @LAYER: UI-Entry
import './app.css'
import App from './App.svelte'
// [DEF:app_instance:Data]
// @PURPOSE: Initialized Svelte app instance.
const app = new App({
target: document.getElementById('app'),
props: {}
})
// [/DEF:app_instance:Data]
export default app
// [/DEF:main:Module]

View File

@@ -23,6 +23,8 @@
console.log(`[Dashboard][Action] Selecting plugin: ${plugin.id}`);
if (plugin.id === 'superset-migration') {
goto('/migration');
} else if (plugin.id === 'git-integration') {
goto('/git');
} else {
selectedPlugin.set(plugin);
}

View File

@@ -0,0 +1,86 @@
<!-- [DEF:GitDashboardPage:Component] -->
<script lang="ts">
import { onMount } from 'svelte';
import DashboardGrid from '../../components/DashboardGrid.svelte';
import { addToast as toast } from '../../lib/toasts.js';
import type { DashboardMetadata } from '../../types/dashboard';
let environments: any[] = [];
let selectedEnvId = "";
let dashboards: DashboardMetadata[] = [];
let loading = true;
let fetchingDashboards = false;
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
if (environments.length > 0) {
selectedEnvId = environments[0].id;
}
} catch (e) {
toast(e.message, 'error');
} finally {
loading = false;
}
}
async function fetchDashboards(envId: string) {
if (!envId) return;
fetchingDashboards = true;
try {
const response = await fetch(`/api/environments/${envId}/dashboards`);
if (!response.ok) throw new Error('Failed to fetch dashboards');
dashboards = await response.json();
} catch (e) {
toast(e.message, 'error');
dashboards = [];
} finally {
fetchingDashboards = false;
}
}
onMount(fetchEnvironments);
$: if (selectedEnvId) {
fetchDashboards(selectedEnvId);
localStorage.setItem('selected_env_id', selectedEnvId);
}
</script>
<div class="max-w-6xl mx-auto p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">Git Dashboard Management</h1>
<div class="flex items-center space-x-4">
<label for="env-select" class="text-sm font-medium text-gray-700">Environment:</label>
<select
id="env-select"
bind:value={selectedEnvId}
class="border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2 border bg-white"
>
{#each environments as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
</div>
{#if loading}
<div class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-medium mb-4">Select Dashboard to Manage</h2>
{#if fetchingDashboards}
<p class="text-gray-500">Loading dashboards...</p>
{:else if dashboards.length > 0}
<DashboardGrid {dashboards} />
{:else}
<p class="text-gray-500 italic">No dashboards found in this environment.</p>
{/if}
</div>
{/if}
</div>
<!-- [/DEF:GitDashboardPage:Component] -->

View File

@@ -0,0 +1,40 @@
<script>
import { onMount } from 'svelte';
import { gitService } from '../../../services/gitService';
import { addToast as toast } from '../../../lib/toasts.js';
let environments = [];
onMount(async () => {
try {
environments = await gitService.getEnvironments();
} catch (e) {
toast(e.message, 'error');
}
});
</script>
<div class="p-6">
<h1 class="text-2xl font-bold mb-6">Deployment Environments</h1>
<div class="bg-white p-6 rounded shadow">
<h2 class="text-xl font-semibold mb-4">Target Environments</h2>
{#if environments.length === 0}
<p class="text-gray-500">No deployment environments configured.</p>
{:else}
<ul class="divide-y">
{#each environments as env}
<li class="py-3 flex justify-between items-center">
<div>
<span class="font-medium">{env.name}</span>
<div class="text-xs text-gray-400">{env.superset_url}</div>
</div>
<span class="px-2 py-1 text-xs rounded {env.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">
{env.is_active ? 'Active' : 'Inactive'}
</span>
</li>
{/each}
</ul>
{/if}
</div>
</div>

View File

@@ -0,0 +1,136 @@
<script>
import { onMount } from 'svelte';
import { gitService } from '../../../services/gitService';
import { addToast as toast } from '../../../lib/toasts.js';
let configs = [];
let newConfig = {
name: '',
provider: 'GITHUB',
url: 'https://github.com',
pat: '',
default_repository: ''
};
let testing = false;
onMount(async () => {
try {
configs = await gitService.getConfigs();
} catch (e) {
toast(e.message, 'error');
}
});
async function handleTest() {
testing = true;
try {
const result = await gitService.testConnection(newConfig);
if (result.status === 'success') {
toast('Connection successful', 'success');
} else {
toast(result.message || 'Connection failed', 'error');
}
} catch (e) {
toast('Connection failed', 'error');
} finally {
testing = false;
}
}
async function handleSave() {
try {
const saved = await gitService.createConfig(newConfig);
configs = [...configs, saved];
toast('Configuration saved', 'success');
newConfig = { name: '', provider: 'GITHUB', url: 'https://github.com', pat: '', default_repository: '' };
} catch (e) {
toast(e.message, 'error');
}
}
async function handleDelete(id) {
if (!confirm('Are you sure you want to delete this Git configuration?')) return;
try {
await gitService.deleteConfig(id);
configs = configs.filter(c => c.id !== id);
toast('Configuration deleted', 'success');
} catch (e) {
toast(e.message, 'error');
}
}
</script>
<div class="p-6">
<h1 class="text-2xl font-bold mb-6">Git Integration Settings</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- List of Configs -->
<div class="bg-white p-6 rounded shadow">
<h2 class="text-xl font-semibold mb-4">Configured Servers</h2>
{#if configs.length === 0}
<p class="text-gray-500">No Git servers configured.</p>
{:else}
<ul class="divide-y">
{#each configs as config}
<li class="py-3 flex justify-between items-center">
<div>
<span class="font-medium">{config.name}</span>
<span class="text-sm text-gray-500 ml-2">({config.provider})</span>
<div class="text-xs text-gray-400">{config.url}</div>
</div>
<div class="flex items-center space-x-4">
<span class="px-2 py-1 text-xs rounded {config.status === 'CONNECTED' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
{config.status}
</span>
<button on:click={() => handleDelete(config.id)} class="text-red-600 hover:text-red-800 p-1" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Add New Config -->
<div class="bg-white p-6 rounded shadow">
<h2 class="text-xl font-semibold mb-4">Add Git Server</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Display Name</label>
<input type="text" bind:value={newConfig.name} class="mt-1 block w-full border rounded p-2" placeholder="e.g. My GitHub" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Provider</label>
<select bind:value={newConfig.provider} class="mt-1 block w-full border rounded p-2">
<option value="GITHUB">GitHub</option>
<option value="GITLAB">GitLab</option>
<option value="GITEA">Gitea</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Server URL</label>
<input type="text" bind:value={newConfig.url} class="mt-1 block w-full border rounded p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Personal Access Token (PAT)</label>
<input type="password" bind:value={newConfig.pat} class="mt-1 block w-full border rounded p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Repository (Optional)</label>
<input type="text" bind:value={newConfig.default_repository} class="mt-1 block w-full border rounded p-2" placeholder="org/repo" />
</div>
<div class="flex space-x-4 pt-4">
<button on:click={handleTest} disabled={testing} class="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300 disabled:opacity-50">
{testing ? 'Testing...' : 'Test Connection'}
</button>
<button on:click={handleSave} class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
Save Configuration
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,3 +1,11 @@
<!-- [DEF:TaskManagementPage:Component] -->
<!--
@SEMANTICS: tasks, management, history, logs
@PURPOSE: Page for managing and monitoring tasks.
@LAYER: Page
@RELATION: USES -> TaskList
@RELATION: USES -> TaskLogViewer
-->
<script>
import { onMount, onDestroy } from 'svelte';
import { getTasks, createTask, getEnvironmentsList } from '../../lib/api';
@@ -14,11 +22,13 @@
let selectedEnvId = '';
// [DEF:loadInitialData:Function]
/* @PURPOSE: Loads tasks and environments on page initialization.
@PRE: API must be reachable.
@POST: tasks and environments variables are populated.
*/
/**
* @purpose Loads tasks and environments on page initialization.
* @pre API must be reachable.
* @post tasks and environments variables are populated.
*/
async function loadInitialData() {
console.log("[loadInitialData][Action] Loading initial tasks and environments");
try {
loading = true;
const [tasksData, envsData] = await Promise.all([
@@ -27,8 +37,9 @@
]);
tasks = tasksData;
environments = envsData;
console.log(`[loadInitialData][Coherence:OK] Data loaded context={{'tasks': ${tasks.length}, 'envs': ${environments.length}}}`);
} catch (error) {
console.error('Failed to load tasks data:', error);
console.error(`[loadInitialData][Coherence:Failed] Failed to load tasks data context={{'error': '${error.message}'}}`);
} finally {
loading = false;
}
@@ -36,10 +47,11 @@
// [/DEF:loadInitialData:Function]
// [DEF:refreshTasks:Function]
/* @PURPOSE: Periodically refreshes the task list.
@PRE: API must be reachable.
@POST: tasks variable is updated if data is valid.
*/
/**
* @purpose Periodically refreshes the task list.
* @pre API must be reachable.
* @post tasks variable is updated if data is valid.
*/
async function refreshTasks() {
try {
const data = await getTasks();
@@ -48,40 +60,45 @@
tasks = data;
}
} catch (error) {
console.error('Failed to refresh tasks:', error);
console.error(`[refreshTasks][Coherence:Failed] Failed to refresh tasks context={{'error': '${error.message}'}}`);
}
}
// [/DEF:refreshTasks:Function]
// [DEF:handleSelectTask:Function]
/* @PURPOSE: Updates the selected task ID when a task is clicked.
@PRE: event.detail.id must be provided.
@POST: selectedTaskId is updated.
*/
/**
* @purpose Updates the selected task ID when a task is clicked.
* @pre event.detail.id must be provided.
* @post selectedTaskId is updated.
*/
function handleSelectTask(event) {
selectedTaskId = event.detail.id;
console.log(`[handleSelectTask][Action] Task selected context={{'taskId': '${selectedTaskId}'}}`);
}
// [/DEF:handleSelectTask:Function]
// [DEF:handleRunBackup:Function]
/* @PURPOSE: Triggers a manual backup task for the selected environment.
@PRE: selectedEnvId must not be empty.
@POST: Backup task is created and task list is refreshed.
*/
/**
* @purpose Triggers a manual backup task for the selected environment.
* @pre selectedEnvId must not be empty.
* @post Backup task is created and task list is refreshed.
*/
async function handleRunBackup() {
if (!selectedEnvId) {
addToast('Please select an environment', 'error');
return;
}
console.log(`[handleRunBackup][Action] Starting backup for env context={{'envId': '${selectedEnvId}'}}`);
try {
const task = await createTask('superset-backup', { environment_id: selectedEnvId });
addToast('Backup task started', 'success');
showBackupModal = false;
selectedTaskId = task.id;
await refreshTasks();
console.log(`[handleRunBackup][Coherence:OK] Backup task created context={{'taskId': '${task.id}'}}`);
} catch (error) {
console.error('Failed to start backup:', error);
console.error(`[handleRunBackup][Coherence:Failed] Failed to start backup context={{'error': '${error.message}'}}`);
}
}
// [/DEF:handleRunBackup:Function]
@@ -117,7 +134,11 @@
<h2 class="text-lg font-semibold mb-3 text-gray-700">Task Details & Logs</h2>
{#if selectedTaskId}
<div class="bg-white rounded-lg shadow-lg h-[600px] flex flex-col">
<TaskLogViewer taskId={selectedTaskId} />
<TaskLogViewer
taskId={selectedTaskId}
taskStatus={tasks.find(t => t.id === selectedTaskId)?.status}
inline={true}
/>
</div>
{:else}
<div class="bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg h-[600px] flex items-center justify-center text-gray-500">
@@ -161,4 +182,6 @@
</div>
</div>
</div>
{/if}
{/if}
<!-- [/DEF:TaskManagementPage:Component] -->

View File

@@ -0,0 +1,325 @@
/**
* [DEF:GitServiceClient:Module]
* @SEMANTICS: git, service, api, client
* @PURPOSE: API client for Git operations, managing the communication between frontend and backend.
* @LAYER: Service
* @RELATION: DEPENDS_ON -> specs/011-git-integration-dashboard/contracts/api.md
*/
const API_BASE = '/api/git';
// [DEF:gitService:Action]
export const gitService = {
/**
* [DEF:getConfigs:Function]
* @purpose Fetches all Git server configurations.
* @pre User must be authenticated.
* @post Returns a list of Git server configurations.
* @returns {Promise<Array>} List of configs.
*/
async getConfigs() {
console.log('[getConfigs][Action] Fetching Git configs');
const response = await fetch(`${API_BASE}/config`);
if (!response.ok) throw new Error('Failed to fetch Git configs');
return response.json();
},
/**
* [DEF:createConfig:Function]
* @purpose Creates a new Git server configuration.
* @pre Config object must be valid.
* @post New config is created and returned.
* @param {Object} config - Configuration details.
* @returns {Promise<Object>} Created config.
*/
async createConfig(config) {
console.log('[createConfig][Action] Creating Git config');
const response = await fetch(`${API_BASE}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response.ok) throw new Error('Failed to create Git config');
return response.json();
},
/**
* [DEF:deleteConfig:Function]
* @purpose Deletes an existing Git server configuration.
* @pre configId must exist.
* @post Config is deleted from the backend.
* @param {string} configId - ID of the config to delete.
* @returns {Promise<Object>} Result of deletion.
*/
async deleteConfig(configId) {
console.log(`[deleteConfig][Action] Deleting Git config ${configId}`);
const response = await fetch(`${API_BASE}/config/${configId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete Git config');
return response.json();
},
/**
* [DEF:testConnection:Function]
* @purpose Tests the connection to a Git server with provided credentials.
* @pre Config must contain valid URL and PAT.
* @post Returns connection status (success/failure).
* @param {Object} config - Configuration to test.
* @returns {Promise<Object>} Connection test result.
*/
async testConnection(config) {
console.log('[testConnection][Action] Testing Git connection');
const response = await fetch(`${API_BASE}/config/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
return response.json();
},
/**
* [DEF:initRepository:Function]
* @purpose Initializes or clones a Git repository for a dashboard.
* @pre Dashboard must exist and config_id must be valid.
* @post Repository is initialized on the backend.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} configId - ID of the Git config.
* @param {string} remoteUrl - URL of the remote repository.
* @returns {Promise<Object>} Initialization result.
*/
async initRepository(dashboardId, configId, remoteUrl) {
console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config_id: configId, remote_url: remoteUrl })
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to initialize repository');
}
return response.json();
},
/**
* [DEF:getBranches:Function]
* @purpose Retrieves the list of branches for a dashboard's repository.
* @pre Repository must be initialized.
* @post Returns a list of branches.
* @param {number} dashboardId - ID of the dashboard.
* @returns {Promise<Array>} List of branches.
*/
async getBranches(dashboardId) {
console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`);
if (!response.ok) throw new Error('Failed to fetch branches');
return response.json();
},
/**
* [DEF:createBranch:Function]
* @purpose Creates a new branch in the dashboard's repository.
* @pre Source branch must exist.
* @post New branch is created.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} name - New branch name.
* @param {string} fromBranch - Source branch name.
* @returns {Promise<Object>} Creation result.
*/
async createBranch(dashboardId, name, fromBranch) {
console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, from_branch: fromBranch })
});
if (!response.ok) throw new Error('Failed to create branch');
return response.json();
},
/**
* [DEF:checkoutBranch:Function]
* @purpose Switches the repository to a different branch.
* @pre Target branch must exist.
* @post Repository head is moved to the target branch.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} name - Branch name to checkout.
* @returns {Promise<Object>} Checkout result.
*/
async checkoutBranch(dashboardId, name) {
console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!response.ok) throw new Error('Failed to checkout branch');
return response.json();
},
/**
* [DEF:commit:Function]
* @purpose Stages and commits changes to the repository.
* @pre Message must not be empty.
* @post Changes are committed to the current branch.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} message - Commit message.
* @param {Array} files - Optional list of files to commit.
* @returns {Promise<Object>} Commit result.
*/
async commit(dashboardId, message, files) {
console.log(`[commit][Action] Committing changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/commit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, files })
});
if (!response.ok) throw new Error('Failed to commit changes');
return response.json();
},
/**
* [DEF:push:Function]
* @purpose Pushes local commits to the remote repository.
* @pre Remote must be configured and accessible.
* @post Remote is updated with local commits.
* @param {number} dashboardId - ID of the dashboard.
* @returns {Promise<Object>} Push result.
*/
async push(dashboardId) {
console.log(`[push][Action] Pushing changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/push`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to push changes');
return response.json();
},
/**
* [DEF:pull:Function]
* @purpose Pulls changes from the remote repository.
* @pre Remote must be configured and accessible.
* @post Local repository is updated with remote changes.
* @param {number} dashboardId - ID of the dashboard.
* @returns {Promise<Object>} Pull result.
*/
async pull(dashboardId) {
console.log(`[pull][Action] Pulling changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/pull`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to pull changes');
return response.json();
},
/**
* [DEF:getEnvironments:Function]
* @purpose Retrieves available deployment environments.
* @post Returns a list of environments.
* @returns {Promise<Array>} List of environments.
*/
async getEnvironments() {
console.log('[getEnvironments][Action] Fetching environments');
const response = await fetch(`${API_BASE}/environments`);
if (!response.ok) throw new Error('Failed to fetch environments');
return response.json();
},
/**
* [DEF:deploy:Function]
* @purpose Deploys a dashboard to a target environment.
* @pre Environment must be active and accessible.
* @post Dashboard is imported into the target Superset instance.
* @param {number} dashboardId - ID of the dashboard.
* @param {string} environmentId - ID of the target environment.
* @returns {Promise<Object>} Deployment result.
*/
async deploy(dashboardId, environmentId) {
console.log(`[deploy][Action] Deploying dashboard ${dashboardId} to environment ${environmentId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id: environmentId })
});
if (!response.ok) throw new Error('Failed to deploy dashboard');
return response.json();
},
/**
* [DEF:getHistory:Function]
* @purpose Retrieves the commit history for a dashboard.
* @param {number} dashboardId - ID of the dashboard.
* @param {number} limit - Maximum number of commits to return.
* @returns {Promise<Array>} List of commits.
*/
async getHistory(dashboardId, limit = 50) {
console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/history?limit=${limit}`);
if (!response.ok) throw new Error('Failed to fetch commit history');
return response.json();
},
/**
* [DEF:sync:Function]
* @purpose Synchronizes the local dashboard state with the Git repository.
* @param {number} dashboardId - ID of the dashboard.
* @param {string|null} sourceEnvId - Optional source environment ID.
* @returns {Promise<Object>} Sync result.
*/
async sync(dashboardId, sourceEnvId = null) {
console.log(`[sync][Action] Syncing dashboard ${dashboardId}`);
const url = new URL(`${window.location.origin}${API_BASE}/repositories/${dashboardId}/sync`);
if (sourceEnvId) url.searchParams.append('source_env_id', sourceEnvId);
const response = await fetch(url, {
method: 'POST'
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to sync dashboard');
}
return response.json();
},
/**
* [DEF:getStatus:Function]
* @purpose Fetches the current Git status for a dashboard repository.
* @pre dashboardId must be a valid integer.
* @post Returns a status object with dirty files and branch info.
* @param {number} dashboardId - The ID of the dashboard.
* @returns {Promise<Object>} Status details.
*/
async getStatus(dashboardId) {
console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/status`);
if (!response.ok) throw new Error('Failed to fetch status');
return response.json();
},
/**
* [DEF:getDiff:Function]
* @purpose Retrieves the diff for specific files or the whole repository.
* @pre dashboardId must be a valid integer.
* @post Returns the Git diff string.
* @param {number} dashboardId - The ID of the dashboard.
* @param {string|null} filePath - Optional specific file path.
* @param {boolean} staged - Whether to show staged changes.
* @returns {Promise<string>} The diff content.
*/
async getDiff(dashboardId, filePath = null, staged = false) {
console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardId} (file: ${filePath}, staged: ${staged})`);
let url = `${API_BASE}/repositories/${dashboardId}/diff`;
const params = new URLSearchParams();
if (filePath) params.append('file_path', filePath);
if (staged) params.append('staged', 'true');
if (params.toString()) url += `?${params.toString()}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch diff');
return response.json();
}
};
// [/DEF:gitService:Action]
// [/DEF:GitServiceClient:Module]

View File

@@ -1,413 +0,0 @@
# [DEF:migration_script:Module]
#
# @SEMANTICS: migration, cli, superset, ui, logging, error-recovery, batch-delete
# @PURPOSE: Предоставляет интерактивный CLI для миграции дашбордов Superset между окружениями с возможностью восстановления после ошибок.
# @LAYER: App
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> superset_tool.utils
# @PUBLIC_API: Migration
# [SECTION: IMPORTS]
import json
import logging
import sys
import zipfile
import re
from pathlib import Path
from typing import List, Optional, Tuple, Dict
from superset_tool.client import SupersetClient
from superset_tool.utils.init_clients import setup_clients
from superset_tool.utils.fileio import create_temp_file, update_yamls, create_dashboard_export
from superset_tool.utils.whiptail_fallback import menu, checklist, yesno, msgbox, inputbox, gauge
from superset_tool.utils.logger import SupersetLogger
# [/SECTION]
# [DEF:Migration:Class]
# @PURPOSE: Инкапсулирует логику интерактивной миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта.
# @RELATION: CREATES_INSTANCE_OF -> SupersetLogger
# @RELATION: USES -> SupersetClient
class Migration:
"""
Интерактивный процесс миграции дашбордов.
"""
# [DEF:__init__:Function]
# @PURPOSE: Инициализирует сервис миграции, настраивает логгер и начальные состояния.
# @PRE: None.
# @POST: `self.logger` готов к использованию; `enable_delete_on_failure` = `False`.
def __init__(self) -> None:
default_log_dir = Path.cwd() / "logs"
self.logger = SupersetLogger(
name="migration_script",
log_dir=default_log_dir,
level=logging.INFO,
console=True,
)
self.enable_delete_on_failure = False
self.from_c: Optional[SupersetClient] = None
self.to_c: Optional[SupersetClient] = None
self.dashboards_to_migrate: List[dict] = []
self.db_config_replacement: Optional[dict] = None
self._failed_imports: List[dict] = []
# [/DEF:__init__:Function]
# [DEF:run:Function]
# @PURPOSE: Точка входа последовательный запуск всех шагов миграции.
# @PRE: Логгер готов.
# @POST: Скрипт завершён, пользователю выведено сообщение.
# @RELATION: CALLS -> self.ask_delete_on_failure
# @RELATION: CALLS -> self.select_environments
# @RELATION: CALLS -> self.select_dashboards
# @RELATION: CALLS -> self.confirm_db_config_replacement
# @RELATION: CALLS -> self.execute_migration
def run(self) -> None:
with self.logger.belief_scope("Migration.run"):
self.logger.info("[run][Entry] Запуск скрипта миграции.")
self.ask_delete_on_failure()
self.select_environments()
self.select_dashboards()
self.confirm_db_config_replacement()
self.execute_migration()
self.logger.info("[run][Exit] Скрипт миграции завершён.")
# [/DEF:run:Function]
# [DEF:ask_delete_on_failure:Function]
# @PURPOSE: Запрашивает у пользователя, следует ли удалять дашборд при ошибке импорта.
# @PRE: None.
# @POST: `self.enable_delete_on_failure` установлен.
# @RELATION: CALLS -> yesno
def ask_delete_on_failure(self) -> None:
with self.logger.belief_scope("Migration.ask_delete_on_failure"):
self.enable_delete_on_failure = yesno(
"Поведение при ошибке импорта",
"Если импорт завершится ошибкой, удалить существующий дашборд и попытаться импортировать заново?",
)
self.logger.info(
"[ask_delete_on_failure][State] Delete-on-failure = %s",
self.enable_delete_on_failure,
)
# [/DEF:ask_delete_on_failure:Function]
# [DEF:select_environments:Function]
# @PURPOSE: Позволяет пользователю выбрать исходное и целевое окружения Superset.
# @PRE: `setup_clients` успешно инициализирует все клиенты.
# @POST: `self.from_c` и `self.to_c` установлены.
# @RELATION: CALLS -> setup_clients
# @RELATION: CALLS -> menu
def select_environments(self) -> None:
with self.logger.belief_scope("Migration.select_environments"):
self.logger.info("[select_environments][Entry] Шаг 1/5: Выбор окружений.")
try:
all_clients = setup_clients(self.logger)
available_envs = list(all_clients.keys())
except Exception as e:
self.logger.error("[select_environments][Failure] %s", e, exc_info=True)
msgbox("Ошибка", "Не удалось инициализировать клиенты.")
return
rc, from_env_name = menu(
title="Выбор окружения",
prompt="Исходное окружение:",
choices=available_envs,
)
if rc != 0 or from_env_name is None:
self.logger.info("[select_environments][State] Source environment selection cancelled.")
return
self.from_c = all_clients[from_env_name]
self.logger.info("[select_environments][State] from = %s", from_env_name)
available_envs.remove(from_env_name)
rc, to_env_name = menu(
title="Выбор окружения",
prompt="Целевое окружение:",
choices=available_envs,
)
if rc != 0 or to_env_name is None:
self.logger.info("[select_environments][State] Target environment selection cancelled.")
return
self.to_c = all_clients[to_env_name]
self.logger.info("[select_environments][State] to = %s", to_env_name)
self.logger.info("[select_environments][Exit] Шаг 1 завершён.")
# [/DEF:select_environments:Function]
# [DEF:select_dashboards:Function]
# @PURPOSE: Позволяет пользователю выбрать набор дашбордов для миграции.
# @PRE: `self.from_c` инициализирован.
# @POST: `self.dashboards_to_migrate` заполнен.
# @RELATION: CALLS -> self.from_c.get_dashboards
# @RELATION: CALLS -> checklist
def select_dashboards(self) -> None:
with self.logger.belief_scope("Migration.select_dashboards"):
self.logger.info("[select_dashboards][Entry] Шаг 2/5: Выбор дашбордов.")
if self.from_c is None:
self.logger.error("[select_dashboards][Failure] Source client not initialized.")
msgbox("Ошибка", "Исходное окружение не выбрано.")
return
try:
_, all_dashboards = self.from_c.get_dashboards()
if not all_dashboards:
self.logger.warning("[select_dashboards][State] No dashboards.")
msgbox("Информация", "В исходном окружении нет дашбордов.")
return
rc, regex = inputbox("Поиск", "Введите регулярное выражение для поиска дашбордов:")
if rc != 0:
return
# Ensure regex is a string and perform caseinsensitive search
regex_str = str(regex)
filtered_dashboards = [
d for d in all_dashboards if re.search(regex_str, d["dashboard_title"], re.IGNORECASE)
]
options = [("ALL", "Все дашборды")] + [
(str(d["id"]), d["dashboard_title"]) for d in filtered_dashboards
]
rc, selected = checklist(
title="Выбор дашбордов",
prompt="Отметьте нужные дашборды (введите номера):",
options=options,
)
if rc != 0:
return
if "ALL" in selected:
self.dashboards_to_migrate = filtered_dashboards
else:
self.dashboards_to_migrate = [
d for d in filtered_dashboards if str(d["id"]) in selected
]
self.logger.info(
"[select_dashboards][State] Выбрано %d дашбордов.",
len(self.dashboards_to_migrate),
)
except Exception as e:
self.logger.error("[select_dashboards][Failure] %s", e, exc_info=True)
msgbox("Ошибка", "Не удалось получить список дашбордов.")
self.logger.info("[select_dashboards][Exit] Шаг 2 завершён.")
# [/DEF:select_dashboards:Function]
# [DEF:confirm_db_config_replacement:Function]
# @PURPOSE: Запрашивает у пользователя, требуется ли заменить имена БД в YAML-файлах.
# @PRE: None.
# @POST: `self.db_config_replacement` либо `None`, либо заполнен.
# @RELATION: CALLS -> yesno
# @RELATION: CALLS -> self._select_databases
def confirm_db_config_replacement(self) -> None:
with self.logger.belief_scope("Migration.confirm_db_config_replacement"):
if yesno("Замена БД", "Заменить конфигурацию БД в YAMLфайлах?"):
old_db, new_db = self._select_databases()
if not old_db or not new_db:
self.logger.info("[confirm_db_config_replacement][State] Selection cancelled.")
return
print(f"old_db: {old_db}")
old_result = old_db.get("result", {})
new_result = new_db.get("result", {})
self.db_config_replacement = {
"old": {
"database_name": old_result.get("database_name"),
"uuid": old_result.get("uuid"),
"database_uuid": old_result.get("uuid"),
"id": str(old_db.get("id"))
},
"new": {
"database_name": new_result.get("database_name"),
"uuid": new_result.get("uuid"),
"database_uuid": new_result.get("uuid"),
"id": str(new_db.get("id"))
}
}
self.logger.info("[confirm_db_config_replacement][State] Replacement set: %s", self.db_config_replacement)
else:
self.logger.info("[confirm_db_config_replacement][State] Skipped.")
# [/DEF:confirm_db_config_replacement:Function]
# [DEF:_select_databases:Function]
# @PURPOSE: Позволяет пользователю выбрать исходную и целевую БД через API.
# @PRE: Clients are initialized.
# @POST: Возвращает кортеж (старая БД, новая БД) или (None, None) при отмене.
# @RELATION: CALLS -> self.from_c.get_databases
# @RELATION: CALLS -> self.to_c.get_databases
# @RELATION: CALLS -> self.from_c.get_database
# @RELATION: CALLS -> self.to_c.get_database
# @RELATION: CALLS -> menu
def _select_databases(self) -> Tuple[Optional[Dict], Optional[Dict]]:
with self.logger.belief_scope("Migration._select_databases"):
self.logger.info("[_select_databases][Entry] Selecting databases from both environments.")
if self.from_c is None or self.to_c is None:
self.logger.error("[_select_databases][Failure] Source or target client not initialized.")
msgbox("Ошибка", "Исходное или целевое окружение не выбрано.")
return None, None
# Получаем список БД из обоих окружений
try:
_, from_dbs = self.from_c.get_databases()
_, to_dbs = self.to_c.get_databases()
except Exception as e:
self.logger.error("[_select_databases][Failure] Failed to fetch databases: %s", e)
msgbox("Ошибка", "Не удалось получить список баз данных.")
return None, None
# Формируем список для выбора
# По Swagger документации, в ответе API поле называется "database_name"
from_choices = []
for db in from_dbs:
db_name = db.get("database_name", "Без имени")
from_choices.append((str(db["id"]), f"{db_name} (ID: {db['id']})"))
to_choices = []
for db in to_dbs:
db_name = db.get("database_name", "Без имени")
to_choices.append((str(db["id"]), f"{db_name} (ID: {db['id']})"))
# Показываем список БД для исходного окружения
rc, from_sel = menu(
title="Выбор исходной БД",
prompt="Выберите исходную БД:",
choices=[f"{name}" for id, name in from_choices]
)
if rc != 0:
return None, None
# Определяем выбранную БД
from_db_id = from_choices[[choice[1] for choice in from_choices].index(from_sel)][0]
# Получаем полную информацию о выбранной БД из исходного окружения
try:
from_db = self.from_c.get_database(int(from_db_id))
except Exception as e:
self.logger.error("[_select_databases][Failure] Failed to fetch database details: %s", e)
msgbox("Ошибка", "Не удалось получить информацию о выбранной базе данных.")
return None, None
# Показываем список БД для целевого окружения
rc, to_sel = menu(
title="Выбор целевой БД",
prompt="Выберите целевую БД:",
choices=[f"{name}" for id, name in to_choices]
)
if rc != 0:
return None, None
# Определяем выбранную БД
to_db_id = to_choices[[choice[1] for choice in to_choices].index(to_sel)][0]
# Получаем полную информацию о выбранной БД из целевого окружения
try:
to_db = self.to_c.get_database(int(to_db_id))
except Exception as e:
self.logger.error("[_select_databases][Failure] Failed to fetch database details: %s", e)
msgbox("Ошибка", "Не удалось получить информацию о выбранной базе данных.")
return None, None
self.logger.info("[_select_databases][Exit] Selected databases: %s -> %s", from_db.get("database_name", "Без имени"), to_db.get("database_name", "Без имени"))
return from_db, to_db
# [/DEF:_select_databases:Function]
# [DEF:_batch_delete_by_ids:Function]
# @PURPOSE: Удаляет набор дашбордов по их ID единым запросом.
# @PRE: `ids` непустой список целых чисел.
# @POST: Все указанные дашборды удалены (если они существовали).
# @RELATION: CALLS -> self.to_c.network.request
# @PARAM: ids (List[int]) - Список ID дашбордов для удаления.
def _batch_delete_by_ids(self, ids: List[int]) -> None:
with self.logger.belief_scope("Migration._batch_delete_by_ids", f"ids={ids}"):
if not ids:
self.logger.debug("[_batch_delete_by_ids][Skip] Empty ID list nothing to delete.")
return
if self.to_c is None:
self.logger.error("[_batch_delete_by_ids][Failure] Target client not initialized.")
msgbox("Ошибка", "Целевое окружение не выбрано.")
return
self.logger.info("[_batch_delete_by_ids][Entry] Deleting dashboards IDs: %s", ids)
q_param = json.dumps(ids)
response = self.to_c.network.request(method="DELETE", endpoint="/dashboard/", params={"q": q_param})
if isinstance(response, dict) and response.get("result", True) is False:
self.logger.warning("[_batch_delete_by_ids][Warning] Unexpected delete response: %s", response)
else:
self.logger.info("[_batch_delete_by_ids][Success] Delete request completed.")
# [/DEF:_batch_delete_by_ids:Function]
# [DEF:execute_migration:Function]
# @PURPOSE: Выполняет экспорт-импорт дашбордов, обрабатывает ошибки и, при необходимости, выполняет процедуру восстановления.
# @PRE: `self.dashboards_to_migrate` не пуст; `self.from_c` и `self.to_c` инициализированы.
# @POST: Успешные дашборды импортированы; неудачные - восстановлены или залогированы.
# @RELATION: CALLS -> self.from_c.export_dashboard
# @RELATION: CALLS -> create_temp_file
# @RELATION: CALLS -> update_yamls
# @RELATION: CALLS -> create_dashboard_export
# @RELATION: CALLS -> self.to_c.import_dashboard
# @RELATION: CALLS -> self._batch_delete_by_ids
def execute_migration(self) -> None:
with self.logger.belief_scope("Migration.execute_migration"):
if not self.dashboards_to_migrate:
self.logger.warning("[execute_migration][Skip] No dashboards to migrate.")
msgbox("Информация", "Нет дашбордов для миграции.")
return
if self.from_c is None or self.to_c is None:
self.logger.error("[execute_migration][Failure] Source or target client not initialized.")
msgbox("Ошибка", "Исходное или целевое окружение не выбрано.")
return
total = len(self.dashboards_to_migrate)
self.logger.info("[execute_migration][Entry] Starting migration of %d dashboards.", total)
self.to_c.delete_before_reimport = self.enable_delete_on_failure
with gauge("Миграция...", width=60, height=10) as g:
for i, dash in enumerate(self.dashboards_to_migrate):
dash_id, dash_slug, title = dash["id"], dash.get("slug"), dash["dashboard_title"]
g.set_text(f"Миграция: {title} ({i + 1}/{total})")
g.set_percent(int((i / total) * 100))
exported_content = None # Initialize exported_content
try:
exported_content, _ = self.from_c.export_dashboard(dash_id)
with create_temp_file(content=exported_content, dry_run=True, suffix=".zip", logger=self.logger) as tmp_zip_path, \
create_temp_file(suffix=".dir", logger=self.logger) as tmp_unpack_dir:
if not self.db_config_replacement:
self.to_c.import_dashboard(file_name=tmp_zip_path, dash_id=dash_id, dash_slug=dash_slug)
else:
with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_unpack_dir)
if self.db_config_replacement:
update_yamls(db_configs=[self.db_config_replacement], path=str(tmp_unpack_dir))
with create_temp_file(suffix=".zip", dry_run=True, logger=self.logger) as tmp_new_zip:
create_dashboard_export(zip_path=tmp_new_zip, source_paths=[str(p) for p in Path(tmp_unpack_dir).glob("**/*")])
self.to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug)
self.logger.info("[execute_migration][Success] Dashboard %s imported.", title)
except Exception as exc:
self.logger.error("[execute_migration][Failure] %s", exc, exc_info=True)
self._failed_imports.append({"slug": dash_slug, "dash_id": dash_id, "zip_content": exported_content})
msgbox("Ошибка", f"Не удалось мигрировать дашборд {title}.\n\n{exc}")
g.set_percent(100)
if self.enable_delete_on_failure and self._failed_imports:
self.logger.info("[execute_migration][Recovery] %d dashboards failed. Starting recovery.", len(self._failed_imports))
_, target_dashboards = self.to_c.get_dashboards()
slug_to_id = {d["slug"]: d["id"] for d in target_dashboards if "slug" in d and "id" in d}
ids_to_delete = [slug_to_id[f["slug"]] for f in self._failed_imports if f["slug"] in slug_to_id]
self._batch_delete_by_ids(ids_to_delete)
for fail in self._failed_imports:
with create_temp_file(content=fail["zip_content"], suffix=".zip", logger=self.logger) as retry_zip:
self.to_c.import_dashboard(file_name=retry_zip, dash_id=fail["dash_id"], dash_slug=fail["slug"])
self.logger.info("[execute_migration][Recovered] Dashboard slug '%s' re-imported.", fail["slug"])
self.logger.info("[execute_migration][Exit] Migration finished.")
msgbox("Ошибка" if self._failed_imports else "Информация", "Миграция завершена!")
# [/DEF:execute_migration:Function]
# [/DEF:Migration:Class]
if __name__ == "__main__":
Migration().run()
# [/DEF:migration_script:Module]

View File

@@ -1,21 +0,0 @@
import sys
import os
from pathlib import Path
# Add root to sys.path
sys.path.append(os.getcwd())
try:
from backend.src.core.plugin_loader import PluginLoader
except ImportError as e:
print(f"Failed to import PluginLoader: {e}")
sys.exit(1)
plugin_dir = Path("backend/src/plugins").absolute()
print(f"Plugin dir: {plugin_dir}")
loader = PluginLoader(str(plugin_dir))
configs = loader.get_all_plugin_configs()
print(f"Loaded plugins: {len(configs)}")
for config in configs:
print(f" - {config.id}")

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Git Integration Plugin for Dashboard Development
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-18
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
**VALIDATION COMPLETE**: All checklist items pass. The specification is ready for `/speckit.clarify` or `/speckit.plan`

View File

@@ -0,0 +1,93 @@
# API Contracts: Git Integration Plugin
**Feature**: Git Integration for Dashboard Development
**Date**: 2026-01-22
## Base Path
`/api/git`
## Endpoints
### 1. Configuration
#### `GET /config/{dashboard_uuid}`
Retrieve Git configuration for a specific dashboard.
- **Response**: `GitServerConfig` (excluding full token)
#### `POST /config`
Save or update Git configuration.
- **Request**: `GitServerConfig`
- **Response**: `GitServerConfig`
### 2. Repository Operations
#### `POST /init/{dashboard_uuid}`
Initialize/Clone the repository for the dashboard.
- **Request**: Empty (uses stored config)
- **Response**: `{ "status": "success", "message": "Repository cloned" }`
#### `GET /status/{dashboard_uuid}`
Get current status (changes between Superset export and local git HEAD).
- **Response**:
```json
{
"branch": "main",
"changes": [
{ "file_path": "charts/sales.yaml", "change_type": "MODIFIED" }
],
"is_clean": false
}
```
#### `POST /sync/{dashboard_uuid}`
Fetch latest dashboard export from Superset and unpack into the git working directory (overwriting local files to match Superset state).
- **Response**: `{ "status": "success", "changes_detected": true }`
### 3. Branch Management
#### `GET /branches/{dashboard_uuid}`
List all local and remote branches.
- **Response**: `List[Branch]`
#### `POST /branches/{dashboard_uuid}`
Create a new branch.
- **Request**: `{ "name": "feature/new-chart", "source_branch": "main" }`
- **Response**: `Branch`
#### `POST /checkout/{dashboard_uuid}`
Switch to a different branch. **Warning**: This updates the Superset Dashboard content to match the branch state!
- **Request**: `{ "branch_name": "main" }`
- **Response**: `{ "status": "success", "message": "Switched to main and updated dashboard" }`
### 4. Commit & Push
#### `POST /commit/{dashboard_uuid}`
Commit staged changes.
- **Request**: `{ "message": "Updated sales chart", "files": ["charts/sales.yaml"] }`
- **Response**: `Commit`
#### `POST /push/{dashboard_uuid}`
Push commits to remote.
- **Response**: `{ "status": "success" }`
#### `POST /pull/{dashboard_uuid}`
Pull changes from remote.
- **Response**: `{ "status": "success", "updates": [...] }`
### 5. History
#### `GET /history/{dashboard_uuid}`
Get commit log.
- **Query Params**: `limit=20`, `branch=main`
- **Response**: `List[Commit]`
### 6. Deployment
#### `GET /environments`
List configured target environments.
- **Response**: `List[Environment]`
#### `POST /deploy/{dashboard_uuid}`
Deploy current branch state to a target environment.
- **Request**: `{ "environment_id": "uuid", "commit_hash": "optional-hash" }`
- **Response**: `{ "status": "success", "job_id": "..." }`

View File

@@ -0,0 +1,76 @@
# Data Model: Git Integration Plugin
**Feature**: Git Integration for Dashboard Development
**Date**: 2026-01-22
## Entities
### 1. GitServerConfig
Configuration for connecting a dashboard to a Git repository.
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Unique identifier |
| `dashboard_uuid` | UUID | The Superset Dashboard UUID this config applies to |
| `provider` | Enum | `GITHUB`, `GITLAB`, `GITEA`, `GENERIC` |
| `server_url` | String | Base URL of the git server (e.g., `https://gitlab.com`) |
| `repo_url` | String | Full HTTPS clone URL |
| `username` | String | Username for auth |
| `pat_token` | String | Personal Access Token (stored securely) |
| `created_at` | DateTime | Creation timestamp |
| `updated_at` | DateTime | Last update timestamp |
### 2. Environment
Target environments for deployment.
| Field | Type | Description |
|-------|------|-------------|
| `id` | UUID | Unique identifier |
| `name` | String | Display name (e.g., "Production", "Staging") |
| `superset_url` | String | Base URL of the target Superset instance |
| `auth_token` | String | Authentication token/credentials for the target API |
| `is_active` | Boolean | Whether this environment is enabled |
### 3. Branch (DTO)
Data Transfer Object representing a Git branch.
| Field | Type | Description |
|-------|------|-------------|
| `name` | String | Branch name (e.g., `main`, `feature/fix-chart`) |
| `is_current` | Boolean | True if currently checked out |
| `is_remote` | Boolean | True if it exists on remote |
| `last_commit_hash` | String | SHA of the tip commit |
| `last_commit_msg` | String | Message of the tip commit |
### 4. Commit (DTO)
Data Transfer Object representing a Git commit.
| Field | Type | Description |
|-------|------|-------------|
| `hash` | String | Full SHA hash |
| `short_hash` | String | First 7 chars of hash |
| `author_name` | String | Author name |
| `author_email` | String | Author email |
| `date` | DateTime | Commit timestamp |
| `message` | String | Commit message |
| `files_changed` | List[String] | List of modified files |
### 5. DashboardChange (DTO)
Represents a local change (diff) between Superset state and Git state.
| Field | Type | Description |
|-------|------|-------------|
| `file_path` | String | Relative path in repo |
| `change_type` | Enum | `ADDED`, `MODIFIED`, `DELETED`, `RENAMED` |
| `diff_content` | String | Unified diff string |
## Relationships
- **One-to-One**: `Dashboard` (Superset concept) <-> `GitServerConfig`.
- **Many-to-Many**: `Dashboard` <-> `Environment` (Technically environments are global or scoped, but for MVP they can be global settings available to all dashboards).
## Validation Rules
- `repo_url`: Must be a valid HTTPS URL ending in `.git`.
- `pat_token`: Must not be empty.
- `branch.name`: Must follow git branch naming conventions (no spaces, special chars).

View File

@@ -0,0 +1,100 @@
# Implementation Plan: Git Integration Plugin for Dashboard Development
**Branch**: `011-git-integration-dashboard` | **Date**: 2026-01-22 | **Spec**: [spec.md](specs/011-git-integration-dashboard/spec.md)
**Input**: Feature specification from `/specs/011-git-integration-dashboard/spec.md`
## Summary
Implement a Git integration plugin that allows dashboard developers to version control their Superset dashboards. The plugin will support GitLab, GitHub, and Gitea, enabling branch management, committing/pushing changes (as unpacked Superset exports), and deploying to target environments via the Superset API.
## Technical Context
**Language/Version**: Python 3.9+ (Backend), Node.js 18+ (Frontend)
**Primary Dependencies**: FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API
**Storage**: SQLite (for config/history), Filesystem (local Git repositories)
**Testing**: pytest (Backend), SvelteKit testing utilities (Frontend)
**Target Platform**: Linux server
**Project Type**: Web application (Frontend + Backend)
**Performance Goals**: Branch switching < 5s, Search/Filter history < 2s
**Constraints**: Offline mode support, Superset API dependency for deployment, PAT-based authentication
**Scale/Scope**: 1 repository per dashboard, support for up to 1000 commits per repo
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
1. **Semantic Protocol Compliance**: All new modules must include headers, `[DEF]` anchors, and `@RELATION` tags as per `semantic_protocol.md`.
2. **Causal Validity**: API contracts and data models must be defined in `specs/` before implementation.
3. **Everything is a Plugin**: The Git integration must be implemented as a `PluginBase` subclass in `backend/src/plugins/`.
4. **Design by Contract**: Use Pydantic models for request/response validation and internal state transitions.
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
backend/
├── src/
│ ├── api/routes/git.py # Git integration endpoints
│ ├── models/git.py # Git-specific Pydantic/SQLAlchemy models (GitServerConfig, DashboardChange)
│ ├── plugins/git_plugin.py # PluginBase implementation (1 repo = 1 dashboard logic)
│ └── services/git_service.py # Core Git logic (GitPython wrapper)
└── tests/
└── plugins/test_git.py
frontend/
├── src/
│ ├── components/git/ # Git UI components (BranchSelector, CommitModal, ConflictResolver)
│ ├── routes/settings/git/ # Git configuration pages (+page.svelte)
│ └── services/gitService.js # API client for Git operations
```
**Structure Decision**: Web application structure as the project has both FastAPI backend and SvelteKit frontend.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
[e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
## Implementation Phases
### Phase 2: Backend Implementation (Plugin & Service)
1. **Data Models**: Implement `GitServerConfig`, `Branch`, `Commit`, `Environment`, and `DashboardChange` in `src/models/git.py`.
2. **Git Service**: Implement `GitService` using `GitPython`. Focus on:
* Repo initialization and cloning (1 repo per dashboard strategy).
* Branch management (list, create, checkout).
* Stage, commit, push, pull.
* History retrieval and diff generation.
3. **Git Plugin**: Implement `GitPlugin(PluginBase)`.
* Integrate with `superset_tool` for exporting dashboards as unpacked YAML files (metadata, charts, datasets).
* Handle unpacking ZIP exports into the repo structure.
4. **API Routes**: Implement `/api/git/*` endpoints for config, sync, and history.
### Phase 3: Frontend Implementation
1. **Configuration UI**: Settings page for `GitServerConfig` (Provider selection, PAT validation).
2. **Dashboard Integration**: Add Git controls to the Dashboard view.
* Branch selector (Create/Switch).
* Commit/Push/Pull buttons with status indicators.
* History viewer with search/filter.
3. **Conflict Resolution UI**: Implementation of "Keep Mine/Theirs" file picker for YAML content.
### Phase 4: Deployment Integration
1. **Environment Management**: CRUD for `Environment` (Target Superset instances).
2. **Deployment Logic**: Implement deployment via Superset Import API (POST /api/v1/dashboard/import/).
* Handle zip packing from Git repo state before import.

View File

@@ -0,0 +1,43 @@
# Quickstart: Git Integration for Dashboards
## Prerequisites
- A running Superset instance.
- A Git repository (GitLab, GitHub, etc.) created for your dashboard.
- A Personal Access Token (PAT) with `repo` scope.
## Setup Guide
### 1. Configure Git Connection
1. Navigate to the **Git Integration** tab in the tools menu.
2. Select your Dashboard from the dropdown.
3. Enter your Git Provider details:
- **Repo URL**: `https://github.com/myorg/sales-dashboard.git`
- **Username**: `myuser`
- **PAT**: `ghp_xxxxxxxxxxxx`
4. Click **Save & Connect**. The system will clone the repository.
### 2. Development Workflow
#### Making Changes
1. Edit your dashboard in Superset as usual.
2. Go to the **Git Integration** panel.
3. Click **Sync Status**. This pulls the latest state from Superset and compares it with the Git repo.
4. You will see a list of changed files (e.g., `charts/my-chart.yaml`).
#### Committing
1. Select the files you want to include.
2. Enter a commit message.
3. Click **Commit**.
#### Pushing
1. Click **Push** to send your changes to the remote repository.
### 3. Branching
1. To work on a new feature, go to the **Branches** tab.
2. Enter a name (e.g., `feature/Q4-updates`) and click **Create Branch**.
3. The system automatically switches to this branch.
### 4. Deploying
1. Switch to the **Deploy** tab.
2. Select the target environment (e.g., "Production").
3. Click **Deploy**. The current version of the dashboard will be imported into the target environment.

View File

@@ -0,0 +1,74 @@
# Research & Decisions: Git Integration Plugin
**Feature**: Git Integration for Dashboard Development
**Date**: 2026-01-22
**Status**: Finalized
## 1. Unknowns & Clarifications
The following clarifications were resolved during the specification phase:
| Question | Answer | Implication |
|----------|--------|-------------|
| **Dashboard Content** | Unpacked Superset export (YAMLs for metadata, charts, datasets, DBs). | We need to use `superset_tool` or internal logic to unzip exports before committing, and zip them before importing/deploying. |
| **Repo Structure** | 1 Repository per Dashboard. | Simplifies conflict resolution (no cross-dashboard conflicts). Requires managing multiple local clones if multiple dashboards are edited. |
| **Deployment** | Import via Superset API. | Need to repackage the YAML files into a ZIP structure compatible with Superset Import API. |
| **Conflicts** | UI-based "Keep Mine / Keep Theirs". | We need a frontend diff viewer/resolver. |
| **Auth** | Personal Access Token (PAT). | Uniform auth mechanism for GitHub/GitLab/Gitea. |
## 2. Technology Decisions
### 2.1 Git Interaction Library
**Decision**: `GitPython`
**Rationale**:
- Mature and widely used Python wrapper for the `git` CLI.
- Supports all required operations (clone, fetch, pull, push, branch, commit, diff).
- Easier to handle output parsing compared to raw `subprocess` calls.
**Alternatives Considered**:
- `pygit2`: Bindings for `libgit2`. Faster but harder to install (binary dependencies) and more complex API.
- `subprocess.run(['git', ...])`: Too manual, error-prone parsing.
### 2.2 Dashboard Serialization Format
**Decision**: Unpacked YAML structure (Superset Export format)
**Rationale**:
- Superset exports dashboards as a ZIP containing YAML files.
- Unpacking this allows Git to track changes at a granular level (e.g., "changed x-axis label in chart A") rather than a binary blob change.
- Enables meaningful diffs and merge conflict resolution.
### 2.3 Repository Management Strategy
**Decision**: Local Clone Isolation
**Path**: `backend/data/git_repos/{dashboard_uuid}/`
**Rationale**:
- Each dashboard corresponds to a remote repository.
- Isolating clones by dashboard UUID prevents collisions.
- The backend acts as a bridge between the Superset instance (source of truth for "current state" in UI) and the Git repo (source of truth for version control).
### 2.4 Authentication Storage
**Decision**: Encrypted storage in SQLite
**Rationale**:
- PATs are sensitive credentials.
- They should not be stored in plain text.
- We will use the existing `config_manager` or a new secure storage utility if available, or standard encryption if not. (For MVP, standard storage in SQLite `GitServerConfig` table is acceptable per current project standards, assuming internal tool usage).
## 3. Architecture Patterns
### 3.1 The "Bridge" Workflow
1. **Edit**: User edits dashboard in Superset UI.
2. **Sync/Stage**: User clicks "Git" in our plugin. Plugin fetches current dashboard export from Superset API, unpacks it to the local git repo working directory.
3. **Diff**: `git status` / `git diff` shows changes between Superset state and last commit.
4. **Commit**: User selects files and commits.
5. **Push**: Pushes to remote.
### 3.2 Deployment Workflow
1. **Select**: User selects target environment (another Superset instance).
2. **Package**: Plugin zips the current `HEAD` (or selected commit) of the repo.
3. **Upload**: Plugin POSTs the ZIP to the target environment's Import API.
## 4. Risks & Mitigations
- **Risk**: Superset Export format changes.
- *Mitigation*: We rely on the Superset version's export format. If it changes, the YAML structure changes, but Git will just see it as text changes. The Import API compatibility is the main constraint.
- **Risk**: Large repositories.
- *Mitigation*: We are doing 1 repo per dashboard, which naturally limits size.
- **Risk**: Concurrent edits.
- *Mitigation*: Git itself handles this via locking, but we should ensure our backend doesn't try to run parallel git ops on the same folder.

View File

@@ -0,0 +1,171 @@
# Feature Specification: Git Integration Plugin for Dashboard Development
**Feature Branch**: `011-git-integration-dashboard`
**Created**: 2026-01-18
**Status**: In Progress
**Input**: User description: "Нужен плагин интеграции git в разработку дашбордов. Требования - возможность настройки целевого git сервера (базово - интеграция с gitlab), хранение и синхронизация веток разработки дашбордов, возможность публикацию в другое целевое окружение после коммита"
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - Configure Git Server Connection (Priority: P1)
A dashboard developer needs to connect the system to their Git server (GitLab, GitHub, or Gitea) to enable version control for dashboard development. They want to configure the Git server provider, URL, authentication credentials, and repository details through a simple form interface.
**Why this priority**: This is the foundational requirement - without Git server configuration, no other Git functionality can work. It's the entry point for all Git operations.
**Independent Test**: Can be fully tested by configuring a Git server connection (e.g., GitHub) and verifying the connection test succeeds, delivering the ability to establish Git server connectivity.
**Acceptance Scenarios**:
1. **Given** a user is on the Git integration settings page, **When** they select a provider (GitLab/GitHub/Gitea) and enter valid server URL and PAT, **Then** the system successfully validates the connection
2. **Given** a user enters invalid Git credentials, **When** they test the connection, **Then** the system displays a clear error message indicating authentication failure
3. **Given** a user has configured a Git server, **When** they save the settings, **Then** the configuration is persisted and can be retrieved later
---
### User Story 2 - Dashboard Branch Management (Priority: P1)
A dashboard developer needs to create, switch between, and manage different development branches for their dashboards. They want to see available branches, create new feature branches, and switch between branches to work on different dashboard versions.
**Why this priority**: This is core to the Git workflow - developers need to manage branches to work on different features or versions of dashboards simultaneously.
**Independent Test**: Can be fully tested by creating a new branch, switching to it, and verifying the branch operations work correctly, delivering basic Git branch workflow capabilities.
**Acceptance Scenarios**:
1. **Given** a Git server is configured, **When** a user views the branch list, **Then** they see all available branches from the remote repository
2. **Given** a user wants to create a new feature branch, **When** they specify a branch name, **Then** the system creates the branch both locally and pushes it to the remote repository
3. **Given** multiple branches exist, **When** a user switches to a different branch, **Then** the dashboard content updates to reflect the selected branch's state
---
### User Story 3 - Dashboard Synchronization with Git (Priority: P1)
A dashboard developer needs to synchronize their local dashboard changes with the Git repository. They want to commit changes, push to remote branches, and pull updates from other developers working on the same dashboard.
**Why this priority**: This enables collaborative development and ensures changes are properly tracked and shared between team members.
**Independent Test**: Can be fully tested by making dashboard changes, committing them, and pushing to the remote repository, delivering complete Git workflow integration.
**Acceptance Scenarios**:
1. **Given** a user has made changes to a dashboard, **When** they commit the changes with a message, **Then** the changes are committed to the current branch
2. **Given** local changes exist, **When** a user pushes to the remote repository, **Then** the changes are successfully pushed and visible to other team members
3. **Given** remote changes exist, **When** a user pulls from the remote repository, **Then** local dashboard content is updated with the latest changes
---
### User Story 4 - Environment Deployment (Priority: P2)
A dashboard developer needs to deploy their dashboard changes to different target environments (e.g., staging, production) after committing changes. They want to select a target environment and trigger the deployment process.
**Why this priority**: This enables the complete development-to-production workflow, allowing teams to promote dashboard changes through different environments.
**Independent Test**: Can be fully tested by selecting a target environment and triggering deployment, delivering the ability to promote dashboard changes to different environments.
**Acceptance Scenarios**:
1. **Given** dashboard changes are committed, **When** a user selects a target environment for deployment, **Then** the system validates the deployment configuration
2. **Given** deployment is initiated, **When** the process completes, **Then** the user receives clear feedback on deployment success or failure
3. **Given** multiple environments are configured, **When** a user deploys to one environment, **Then** other environments remain unaffected
---
### User Story 5 - Git History and Change Tracking (Priority: P3)
A dashboard developer needs to view the commit history and changes made to dashboards over time. They want to see who made changes, when they were made, and what specific changes were included in each commit.
**Why this priority**: This provides visibility into the development process and helps with debugging, auditing, and understanding the evolution of dashboards.
**Independent Test**: Can be fully tested by viewing commit history and examining specific commits, delivering transparency into dashboard development history.
**Acceptance Scenarios**:
1. **Given** multiple commits exist, **When** a user views the commit history, **Then** they see a chronological list of all commits with relevant details
2. **Given** a specific commit is selected, **When** the user views commit details, **Then** they see the changes included in that commit
3. **Given** commit history is available, **When** a user searches for specific changes, **Then** they can filter and find relevant commits
---
### Edge Cases
- What happens when Git server is temporarily unavailable during synchronization?
- How does system handle merge conflicts when multiple developers modify the same dashboard?
- What happens when a user tries to deploy to an environment that doesn't exist or is misconfigured?
- How does system handle large dashboard files that exceed Git repository size limits?
- What happens when Git authentication tokens expire during operations?
- How does system handle network interruptions during long-running operations like large pushes?
## Requirements *(mandatory)*
## Clarifications
### Session 2026-01-22
- Q: Что именно должно входить в состав данных дашборда в Git-репозитории? → A: Распакованный архив экспорта Superset (YAML файлы: метаданные, чарты, датасеты, базы данных).
- Q: Какова структура хранения дашбордов в Git-репозитории? → A: Один репозиторий соответствует одному дашборду.
- Q: Как должен происходить деплой на целевое окружение после коммита? → A: Импорт через API (Superset Import API).
- Q: Как система должна обрабатывать конфликты слияния (merge conflicts)? → A: UI для выбора версии (Keep Mine / Keep Theirs) на уровне файлов.
- Q: Как должен происходить выбор дашборда для работы с Git? → A: Выбор конкретного дашборда из списка в UI.
- Q: What triggers a deployment to a target environment? → A: Manual trigger by user from UI
- Q: How should the system handle authentication for the different providers (GitHub, GitLab, Gitea)? → A: Option [A] - Personal Access Token (PAT) for all providers
### Functional Requirements
- **FR-001**: System MUST allow users to configure Git server connection settings including provider type (GitHub, GitLab, Gitea), server URL, authentication via Personal Access Token (PAT), and repository details
- **FR-002**: System MUST support GitLab, GitHub, and Gitea as supported Git server providers
- **FR-003**: System MUST validate Git server connection using the provided PAT for the selected provider before saving configuration
- **FR-004**: Users MUST be able to view all available branches from the configured Git repository
- **FR-005**: Users MUST be able to create new branches both locally and remotely with proper naming validation
- **FR-006**: Users MUST be able to switch between existing branches and have dashboard content update accordingly
- **FR-007**: System MUST allow users to commit dashboard changes with commit messages and optional file selection
- **FR-008**: System MUST support pushing local commits to remote repository branches
- **FR-009**: System MUST support pulling changes from remote repository to local working directory
- **FR-010**: System MUST handle merge conflicts with a built-in UI providing "Keep Mine", "Keep Theirs", and "Manual Edit" (via a text area editor for YAML content) resolution options
- **FR-011**: Users MUST be able to configure multiple target environments for dashboard deployment
- **FR-012**: System MUST allow users to manually trigger deployment to a selected target environment from the UI
- **FR-013**: System MUST validate deployment configuration before initiating deployment process
- **FR-014**: System MUST provide clear feedback on deployment success or failure with detailed logs
- **FR-015**: Users MUST be able to view commit history with details including author, timestamp, and commit message
- **FR-016**: System MUST display detailed changes included in each commit for audit purposes
- **FR-017**: System MUST handle PAT expiration gracefully with re-authentication prompts
- **FR-018**: System MUST provide offline mode functionality when Git server is unavailable
- **FR-019**: System MUST store and validate dashboard data as a standard unpacked Superset export (YAML files for dashboard metadata, charts, datasets, and database connections) within their respective directories.
- **FR-020**: Users MUST be able to search and filter commit history by date, author, or message content
- **FR-021**: System MUST support rollback functionality to previous dashboard versions via Git operations
### Key Entities *(include if feature involves data)*
- **GitServerConfig**: Represents Git server connection configuration including server URL, Personal Access Token (PAT), repository path, and connection status
- **Branch**: Represents a Git branch with properties like name, commit hash, last updated timestamp, and remote tracking status
- **Commit**: Represents a Git commit with properties like commit hash, author, timestamp, commit message, and list of changed files
- **Environment**: Represents a deployment target environment with properties like name, URL, authentication details, and deployment status
- **DashboardChange**: Represents changes made to dashboard directories (YAML metadata + assets) including file paths, change type (add/modify/delete), and content differences
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can successfully configure a supported Git server connection (GitLab, GitHub, or Gitea) and validate it within 2 minutes
- **SC-002**: Dashboard branch switching operations complete within 5 seconds for repositories with up to 100 commits
- **SC-003**: Commit and push operations succeed in 95% of attempts under normal network conditions
- **SC-004**: Deployment to target environments completes successfully within 30 seconds for standard dashboard configurations
- **SC-005**: Users can view and navigate through commit history for dashboards with up to 1000 commits without performance degradation
- **SC-006**: Merge conflict resolution guidance is provided in 100% of conflict scenarios with clear resolution steps
- **SC-007**: Authentication token refresh process completes within 10 seconds when tokens expire
- **SC-008**: System maintains dashboard state consistency across branch switches in 99% of operations
- **SC-009**: Deployment rollback operations complete within 1 minute for standard dashboard configurations
- **SC-010**: Users can search and filter commit history with results displayed within 2 seconds for repositories with up to 500 commits

View File

@@ -0,0 +1,88 @@
# Tasks: Git Integration Plugin
**Feature**: Git Integration for Dashboard Development
**Status**: Completed
**Total Tasks**: 35
## Phase 1: Setup
**Goal**: Initialize project structure and dependencies.
- [x] T001 Install GitPython dependency in `backend/requirements.txt`
- [x] T002 Create backend directory structure (routes, models, plugins, services)
- [x] T003 Create frontend directory structure (components, routes, services)
## Phase 2: Foundational
**Goal**: Implement core data models and service skeletons.
**Prerequisites**: Phase 1
- [x] T004 Create Git Pydantic models (Branch, Commit, DashboardChange) in `backend/src/api/routes/git_schemas.py`
- [x] T005 Create GitServerConfig and Environment SQLAlchemy models in `backend/src/models/git.py`
- [x] T006 Implement GitService class skeleton with GitPython init in `backend/src/services/git_service.py`
- [x] T007 Implement GitPlugin class skeleton inheriting PluginBase in `backend/src/plugins/git_plugin.py`
## Phase 3: User Story 1 - Configure Git Server (P1)
**Goal**: Enable users to configure and validate Git server connections.
**Prerequisites**: Phase 2
- [x] T008 [US1] Implement `GitService.test_connection` method in `backend/src/services/git_service.py`
- [x] T009 [US1] Implement GET/POST `/api/git/config` endpoints in `backend/src/api/routes/git.py`
- [x] T010 [US1] Create `frontend/src/services/gitService.js` API client
- [x] T011 [US1] Create `frontend/src/components/tools/ConnectionForm.svelte` (or similar) for Git config
- [x] T012 [US1] Implement Settings page at `frontend/src/routes/settings/git/+page.svelte`
## Phase 4: User Story 2 - Branch Management (P1)
**Goal**: Enable creating and switching branches for dashboards.
**Prerequisites**: Phase 3
- [x] T013 [US2] Implement `GitService` branch operations (list, create, checkout) in `backend/src/services/git_service.py`
- [x] T014 [US2] Implement `GitService.init_repo` (clone/init strategy) in `backend/src/services/git_service.py`
- [x] T015 [US2] Implement GET/POST `/api/git/branches` endpoints in `backend/src/api/routes/git.py`
- [x] T016 [US2] Implement POST `/api/git/checkout` endpoint in `backend/src/api/routes/git.py`
- [x] T017 [US2] Create `frontend/src/components/git/BranchSelector.svelte` component
- [x] T018 [US2] Update Dashboard page to include Git controls container
## Phase 5: User Story 3 - Synchronization (P1)
**Goal**: Enable committing, pushing, and pulling changes.
**Prerequisites**: Phase 4
- [x] T019 [US3] Implement Dashboard export/unpack logic (using SupersetClient/superset_tool) in `backend/src/plugins/git_plugin.py`
- [x] T020 [US3] Implement `GitService` status and diff generation methods in `backend/src/services/git_service.py`
- [x] T021 [US3] Implement `GitService` commit, push, and pull methods in `backend/src/services/git_service.py`
- [x] T022 [US3] Implement `/api/git/sync`, `/commit`, `/push`, `/pull` endpoints in `backend/src/api/routes/git.py`
- [x] T023 [US3] Create `frontend/src/components/git/CommitModal.svelte` with diff viewer
- [x] T024 [US3] Create `frontend/src/components/git/ConflictResolver.svelte` (Keep Mine/Theirs UI)
## Phase 6: User Story 4 - Deployment (P2)
**Goal**: Deploy dashboard versions to target environments.
**Prerequisites**: Phase 5
- [x] T025 [US4] Implement Environment CRUD endpoints in `backend/src/api/routes/git.py`
- [x] T026 [US4] Implement deployment logic (zip packing + Import API) in `backend/src/plugins/git_plugin.py`
- [x] T027 [US4] Implement POST `/api/git/deploy` endpoint in `backend/src/api/routes/git.py`
- [x] T028 [US4] Create Deployment modal/interface in `frontend/src/components/git/DeploymentModal.svelte`
## Phase 7: User Story 5 - History (P3)
**Goal**: View commit history and audit changes.
**Prerequisites**: Phase 5
- [x] T029 [US5] Implement `GitService.get_history` method in `backend/src/services/git_service.py`
- [x] T030 [US5] Implement GET `/api/git/history` endpoint in `backend/src/api/routes/git.py`
- [x] T031 [US5] Create `frontend/src/components/git/CommitHistory.svelte` component
- [x] T032 [US5] Integrate History viewer into Dashboard page
## Phase 8: Polish & Cross-Cutting
**Goal**: Finalize error handling, UI feedback, and performance.
- [x] T033 Add comprehensive error handling for Git operations (timeouts, auth failures)
- [x] T034 Add loading states and progress indicators to all Git UI components
- [x] T035 Verify offline mode behavior and graceful degradation
## Dependencies
- US1 -> US2 -> US3 -> US4
- US3 -> US5
- US1 is the critical path.
## Implementation Strategy
- MVP: Complete US1, US2, and US3 (Configure, Branch, Sync). This allows local-only versioning if remote is optional, or full sync if remote is configured.
- Incremental: Add Deployment (US4) and History (US5) after core sync workflow is stable.

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Backend Refactoring - Remove superset_tool
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-22
**Feature**: [Link to spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs) - *Refactoring is technical by nature, but focused on structural goals.*
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@@ -0,0 +1,39 @@
# API Contracts: 012-remove-superset-tool
## SupersetClient Internal API
The `SupersetClient` will provide the following methods for internal backend use:
### `get_dashboards(query: Optional[Dict] = None) -> Tuple[int, List[Dict]]`
- **Purpose**: Fetches paginated dashboards.
- **Contract**:
- `query`: Optional filters and pagination parameters.
- Returns: `(total_count, dashboard_list)`.
### `export_dashboard(dashboard_id: int) -> Tuple[bytes, str]`
- **Purpose**: Exports a dashboard as a ZIP file.
- **Contract**:
- `dashboard_id`: ID of the dashboard.
- Returns: `(zip_content, filename)`.
### `import_dashboard(file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict`
- **Purpose**: Imports a dashboard from a ZIP file.
- **Contract**:
- `file_name`: Path to ZIP.
- `dash_id`/`dash_slug`: Optional identifiers for delete-retry logic.
- Returns: API response dictionary.
## Configuration Models (Pydantic)
### `Environment`
```python
class Environment(BaseModel):
id: str
name: str
url: str
username: str
password: str
verify_ssl: bool = True
timeout: int = 30
is_default: bool = False
backup_schedule: Schedule = Field(default_factory=Schedule)

View File

@@ -0,0 +1,38 @@
# Data Model: 012-remove-superset-tool
## Entities
### 1. Environment (Updated)
- **Source**: `backend/src/core/config_models.py`
- **Purpose**: Represents a Superset environment configuration, now incorporating all fields required for the `SupersetClient`.
- **Fields**:
- `id`: `str` (Unique identifier)
- `name`: `str` (Display name)
- `url`: `str` (Base URL for API)
- `username`: `str`
- `password`: `str`
- `verify_ssl`: `bool` (Default: `True`)
- `timeout`: `int` (Default: `30`)
- `is_default`: `bool` (Default: `False`)
- `backup_schedule`: `Schedule`
- **Validation Rules**:
- `url` must start with `http://` or `https://`.
- `timeout` must be positive.
### 2. API Contract (SupersetClient)
- **Source**: `backend/src/core/superset_client.py`
- **Purpose**: Self-contained client for Superset API interactions.
- **Key Methods**:
- `authenticate()`
- `get_dashboards(query)`
- `get_datasets(query)`
- `get_databases(query)`
- `export_dashboard(dashboard_id)`
- `import_dashboard(file_name, dash_id, dash_slug)`
- `delete_dashboard(dashboard_id)`
- `get_databases_summary()`
- `get_dashboards_summary()`
## State Transitions
- **Authentication**: Unauthenticated -> Authenticated (token stored in memory/client session).
- **Import/Export**: File System <-> Superset API.

View File

@@ -0,0 +1,69 @@
# Implementation Plan: 012-remove-superset-tool
**Branch**: `012-remove-superset-tool` | **Date**: 2026-01-22 | **Spec**: [spec.md](specs/012-remove-superset-tool/spec.md)
## Summary
The primary requirement is to refactor the backend by removing the redundant `superset_tool` module and merging its essential logic into the `backend` package. This involves migrating the `SupersetClient` logic, utility modules, and configuration models while deprecating CLI-specific interactive logic.
## Technical Context
**Language/Version**: Python 3.9+
**Primary Dependencies**: FastAPI, Pydantic, requests, pyyaml (migrated from superset_tool)
**Storage**: SQLite (tasks.db, migrations.db), Filesystem
**Testing**: pytest
**Target Platform**: Linux server
**Project Type**: Web application (FastAPI backend + SvelteKit frontend)
**Performance Goals**: N/A (Cleanup task)
**Constraints**: Zero references to `superset_tool` in `backend/` source code.
**Scale/Scope**: Refactoring core backend infrastructure.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
1. **Semantic Protocol Compliance**: All migrated code must use `[DEF]` anchors and `@RELATION` tags as per `semantic_protocol.md`.
2. **Causal Validity**: Contracts (Pydantic models and API surfaces) must be defined before implementation.
3. **Everything is a Plugin**: Ensure that logic previously in `superset_tool` that acts as a tool or extension is integrated within the backend's plugin architecture if applicable.
4. **Fractal Complexity Limit**: Migrated modules must adhere to complexity limits.
## Project Structure
### Documentation (this feature)
```text
specs/012-remove-superset-tool/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output
```
### Source Code (repository root)
```text
backend/
├── src/
│ ├── api/ # API routes
│ ├── core/ # Core logic (Target for migration)
│ │ ├── utils/ # Shared utilities
│ │ ├── config_models.py
│ │ ├── logger.py
│ │ └── superset_client.py
│ ├── models/ # Database models
│ ├── plugins/ # Plugin system
│ └── app.py
└── tests/ # Backend tests
```
**Structure Decision**: Web application structure. The `superset_tool` logic will be absorbed into `backend/src/core/` and its subdirectories.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | | |

View File

@@ -0,0 +1,20 @@
# Quickstart: 012-remove-superset-tool
## Overview
This feature refactors the backend to remove the `superset_tool` dependency. All Superset-related logic is now centralized in `backend/src/core/superset_client.py`.
## Key Changes
1. **Models**: `Environment` model in `backend/src/core/config_models.py` now includes `verify_ssl` and `timeout`.
2. **Client**: `SupersetClient` in `backend/src/core/superset_client.py` is now self-contained and does not inherit from any external module.
3. **Utilities**: Network and file I/O utilities previously in `superset_tool` are now available in `backend/src/core/utils/`.
## Verification Steps
1. **Run Backend**: Start the FastAPI server and ensure no import errors occur.
```bash
cd backend && .venv/bin/python3 -m uvicorn src.app:app --reload
```
2. **Check Connections**: Verify that existing Superset connections load correctly in the UI/API.
3. **Test Migration**: Trigger a dashboard list refresh or a migration task to confirm the consolidated client works as expected.
## Cleanup
Once verified, the `superset_tool/` directory and root-level CLI scripts (`migration_script.py`, `run_mapper.py`) can be deleted.

View File

@@ -0,0 +1,32 @@
# Research: 012-remove-superset-tool
## Unknowns & Clarifications
### 1. Merging `SupersetConfig` into `Environment`
- **Decision**: Fields from `SupersetConfig` (auth, verify_ssl, timeout) will be merged into the `Environment` model in `backend/src/core/config_models.py`.
- **Rationale**: Centralizes configuration and removes dependency on `superset_tool.models`.
- **Alternatives considered**: Keeping them separate, but that defeats the purpose of the refactoring.
### 2. Utility Migration
- **Decision**: `superset_tool/utils/` will be flattened into `backend/src/core/utils/`.
- **Rationale**: Simplifies the directory structure and aligns with the backend's existing utility pattern.
- **Alternatives considered**: Creating a `backend/src/core/utils/superset/` subdirectory, but flattening is preferred for simplicity.
### 3. CLI Deprecation
- **Decision**: `migration_script.py`, `run_mapper.py`, and `whiptail_fallback.py` will be deprecated and not migrated.
- **Rationale**: The web UI now handles these operations, and maintaining interactive CLI logic in the backend is out of scope.
### 4. Logging Consolidation
- **Decision**: Remove `SupersetLogger` and `belief_scope` from `superset_tool` and use `backend/src/core/logger.py`.
- **Rationale**: Ensures consistent logging across the entire backend application.
## Dependency Analysis
- `backend/requirements.txt` already contains `requests`, `PyYAML`, `pydantic`.
- `superset_tool/requirements.txt` is empty, indicating it likely relied on the same environment or was partially integrated.
- **Action**: Ensure all `superset_tool` imports are replaced with backend-local equivalents.
## Integration Patterns
- `backend/src/core/superset_client.py` currently inherits from `superset_tool.client.SupersetClient`.
- **Action**: Move all methods from `superset_tool/client.py` into `backend/src/core/superset_client.py` and remove the inheritance. Refactor `APIClient` (from `superset_tool/utils/network.py`) to be a utility or private helper within the backend.

View File

@@ -0,0 +1,78 @@
# Feature Specification: Backend Refactoring - Remove superset_tool
**Feature Branch**: `012-remove-superset-tool`
**Created**: 2026-01-22
**Status**: Completed
**Input**: User description: "нужен рефакторинг бека - я хочу исключить модуль superset_tool, пусть останется только backend"
## Clarifications
### Session 2026-01-22
- Q: How should we reconcile the `SupersetConfig` model from `superset_tool` with the `Environment` model in the backend? → A: Merge all `SupersetConfig` fields (e.g., `verify_ssl`, `timeout`) into the `Environment` model in `backend/src/core/config_models.py`.
- Q: Where should the utility modules from `superset_tool/utils/` be moved within the `backend/src/core/` directory? → A: Flatten them directly into `backend/src/core/utils/` alongside existing utilities.
- Q: Should interactive CLI utilities (whiptail_fallback.py, migration_script.py) be migrated? → A: No, deprecate them and do not migrate CLI-specific interactive UI logic to the backend.
- Q: How should we handle `SupersetLogger` and `belief_scope`? → A: Consolidate all logging into `backend/src/core/logger.py` and remove the redundant `SupersetLogger` class.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Maintain Core Functionality (Priority: P1)
As a developer, I want to remove the redundant `superset_tool` module and merge its essential logic into the `backend` package so that the codebase is easier to maintain and deploy.
**Why this priority**: This is a critical architectural cleanup. The existence of two overlapping modules (`superset_tool` and `backend/src/core/superset_client.py`) creates confusion and potential bugs.
**Independent Test**: The application (FastAPI backend) should start and perform all Superset-related operations (fetching dashboards, datasets, etc.) without the `superset_tool` directory present in the root.
**Acceptance Scenarios**:
1. **Given** the `superset_tool` directory is deleted, **When** I run the backend server, **Then** it should start without `ModuleNotFoundError`.
2. **Given** the backend is running, **When** I trigger a dashboard list refresh in the UI, **Then** the backend should successfully fetch data from Superset using the migrated client logic.
3. **Given** a migration task, **When** I execute it, **Then** it should successfully use the consolidated `SupersetClient` to interact with the API.
---
### User Story 2 - Unified Configuration and Models (Priority: P2)
As a developer, I want to use a single set of Pydantic models and configuration logic within the `backend` package instead of relying on external `superset_tool.models`.
**Why this priority**: Eliminates duplicate definitions and ensures consistency across the backend.
**Independent Test**: Verify that `backend/src/core/config_models.py` (or equivalent) contains all necessary fields previously defined in `superset_tool/models.py`.
**Acceptance Scenarios**:
1. **Given** a connection configuration in the database, **When** the backend loads it, **Then** it should correctly instantiate the consolidated configuration models.
---
### Edge Cases
- **Deprecated Scripts**: CLI-only scripts like `migration_script.py` and `run_mapper.py` in the root will be deprecated and removed as their logic is now in the web UI.
- **Dependency Conflicts**: How does the system handle potential version conflicts between `backend/requirements.txt` and `superset_tool/requirements.txt`?
- **Environment Variables**: Ensure that any environment variables previously used by `superset_tool` are still correctly loaded by the consolidated client.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST NOT depend on the `superset_tool` top-level module.
- **FR-002**: All essential logic from `superset_tool/client.py` MUST be migrated to `backend/src/core/superset_client.py`. Utility modules from `superset_tool/utils/` (network, fileio, dataset_mapper) MUST be migrated to `backend/src/core/utils/`.
- **FR-003**: `backend/src/core/superset_client.py` MUST be refactored to be a self-contained client that doesn't inherit from `superset_tool.client.BaseSupersetClient`.
- **FR-004**: Redundant logging logic (`SupersetLogger`) MUST be removed in favor of `backend/src/core/logger.py`.
- **FR-004**: The backend's `requirements.txt` MUST be updated to include any dependencies previously required by `superset_tool` (e.g., `requests`, `pyyaml`).
- **FR-005**: All imports in `backend/src/` that reference `superset_tool` MUST be updated to reference the new locations within the backend.
### Key Entities *(include if feature involves data)*
- **SupersetClient**: The consolidated class responsible for all interactions with the Superset REST API.
- **Environment**: The unified configuration model for Superset connections, incorporating legacy `SupersetConfig` fields.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Zero references to `superset_tool` in the `backend/` source code.
- **SC-002**: 100% of existing Superset integration tests pass after the refactoring.
- **SC-003**: The `superset_tool/` directory can be safely deleted from the repository.
- **SC-004**: Backend startup time remains unaffected or improves slightly due to simplified import structure.

View File

@@ -0,0 +1,34 @@
# Tasks: 012-remove-superset-tool
## Phase 1: Setup
- [x] T001 Initialize branch `012-remove-superset-tool`
- [x] T002 Update `backend/requirements.txt` with dependencies from `superset_tool` (requests, pyyaml)
## Phase 2: Foundational (Infrastructure Migration)
- [x] T003 [P] Migrate utility modules from `superset_tool/utils/` to `backend/src/core/utils/`
- [x] T004 [P] Refactor `backend/src/core/logger.py` to incorporate essential logging logic from `superset_tool/utils/logging.py`
- [x] T005 Update `Environment` model in `backend/src/core/config_models.py` with `verify_ssl` and `timeout` fields
## Phase 3: User Story 1 - Maintain Core Functionality [US1]
- [x] T006 [US1] Refactor `backend/src/core/superset_client.py` to be self-contained (remove inheritance from `superset_tool.client.BaseSupersetClient`)
- [x] T007 [US1] Migrate all remaining methods from `superset_tool/client.py` to `backend/src/core/superset_client.py`
- [x] T008 [P] [US1] Update all imports in `backend/src/` to remove references to `superset_tool`
- [x] T009 [US1] Verify backend starts without `superset_tool` directory present
- [x] T010 [US1] Verify dashboard and dataset fetching works via API/UI
## Phase 4: User Story 2 - Unified Configuration and Models [US2]
- [x] T011 [US2] Ensure all Pydantic models in `backend/src/core/config_models.py` cover legacy `superset_tool/models.py` requirements
- [x] T012 [US2] Update database loading logic to correctly populate new `Environment` fields
## Phase 5: Polish & Cleanup
- [x] T013 Remove `superset_tool/` directory
- [x] T014 Remove deprecated root scripts: `migration_script.py`, `run_mapper.py`, `whiptail_fallback.py`
- [x] T015 Run full backend test suite to ensure no regressions
## Dependencies
- Phase 2 must be completed before Phase 3.
- T006 and T007 are prerequisites for T008 and T009.
## Implementation Strategy
- **MVP First**: Focus on T006-T008 to ensure the backend can function independently of the `superset_tool` module.
- **Incremental Delivery**: Migrate utilities and models first to provide the necessary foundation for the client refactoring.

View File

@@ -51,350 +51,6 @@
- 📝 Generates the token-optimized project map.
- ƒ **_write_entity_md** (`Function`)
- 📝 Recursive helper to write entity tree to Markdown.
- 📦 **migration_script** (`Module`)
- 📝 Предоставляет интерактивный CLI для миграции дашбордов Superset между окружениями с возможностью восстановления после ошибок.
- 🏗️ Layer: App
- 🔗 DEPENDS_ON -> `superset_tool.client`
- 🔗 DEPENDS_ON -> `superset_tool.utils`
- **Migration** (`Class`)
- 📝 Инкапсулирует логику интерактивной миграции дашбордов с возможностью «удалить‑и‑перезаписать» при ошибке импорта.
- ƒ **__init__** (`Function`)
- 📝 Инициализирует сервис миграции, настраивает логгер и начальные состояния.
- ƒ **run** (`Function`)
- 📝 Точка входа последовательный запуск всех шагов миграции.
- 🔗 CALLS -> `self.ask_delete_on_failure`
- 🔗 CALLS -> `self.select_environments`
- 🔗 CALLS -> `self.select_dashboards`
- 🔗 CALLS -> `self.confirm_db_config_replacement`
- 🔗 CALLS -> `self.execute_migration`
- ƒ **ask_delete_on_failure** (`Function`)
- 📝 Запрашивает у пользователя, следует ли удалять дашборд при ошибке импорта.
- 🔗 CALLS -> `yesno`
- ƒ **select_environments** (`Function`)
- 📝 Позволяет пользователю выбрать исходное и целевое окружения Superset.
- 🔗 CALLS -> `setup_clients`
- 🔗 CALLS -> `menu`
- ƒ **select_dashboards** (`Function`)
- 📝 Позволяет пользователю выбрать набор дашбордов для миграции.
- 🔗 CALLS -> `self.from_c.get_dashboards`
- 🔗 CALLS -> `checklist`
- ƒ **confirm_db_config_replacement** (`Function`)
- 📝 Запрашивает у пользователя, требуется ли заменить имена БД в YAML-файлах.
- 🔗 CALLS -> `yesno`
- 🔗 CALLS -> `self._select_databases`
- ƒ **_select_databases** (`Function`)
- 📝 Позволяет пользователю выбрать исходную и целевую БД через API.
- 🔗 CALLS -> `self.from_c.get_databases`
- 🔗 CALLS -> `self.to_c.get_databases`
- 🔗 CALLS -> `self.from_c.get_database`
- 🔗 CALLS -> `self.to_c.get_database`
- 🔗 CALLS -> `menu`
- ƒ **_batch_delete_by_ids** (`Function`)
- 📝 Удаляет набор дашбордов по их ID единым запросом.
- 🔗 CALLS -> `self.to_c.network.request`
- ƒ **execute_migration** (`Function`)
- 📝 Выполняет экспорт-импорт дашбордов, обрабатывает ошибки и, при необходимости, выполняет процедуру восстановления.
- 🔗 CALLS -> `self.from_c.export_dashboard`
- 🔗 CALLS -> `create_temp_file`
- 🔗 CALLS -> `update_yamls`
- 🔗 CALLS -> `create_dashboard_export`
- 🔗 CALLS -> `self.to_c.import_dashboard`
- 🔗 CALLS -> `self._batch_delete_by_ids`
- 📦 **superset_tool.exceptions** (`Module`)
- 📝 Определяет иерархию пользовательских исключений для всего инструмента, обеспечивая единую точку обработки ошибок.
- 🏗️ Layer: Infra
- **SupersetToolError** (`Class`)
- 📝 Базовый класс для всех ошибок, генерируемых инструментом.
- 🔗 INHERITS_FROM -> `Exception`
- ƒ **__init__** (`Function`)
- 📝 Initializes the base tool error.
- **AuthenticationError** (`Class`)
- 📝 Ошибки, связанные с аутентификацией или авторизацией.
- 🔗 INHERITS_FROM -> `SupersetToolError`
- ƒ **__init__** (`Function`)
- 📝 Initializes an authentication error.
- **PermissionDeniedError** (`Class`)
- 📝 Ошибка, возникающая при отказе в доступе к ресурсу.
- 🔗 INHERITS_FROM -> `AuthenticationError`
- ƒ **__init__** (`Function`)
- 📝 Initializes a permission denied error.
- **SupersetAPIError** (`Class`)
- 📝 Общие ошибки при взаимодействии с Superset API.
- 🔗 INHERITS_FROM -> `SupersetToolError`
- ƒ **__init__** (`Function`)
- 📝 Initializes a Superset API error.
- **ExportError** (`Class`)
- 📝 Ошибки, специфичные для операций экспорта.
- 🔗 INHERITS_FROM -> `SupersetAPIError`
- ƒ **__init__** (`Function`)
- 📝 Initializes an export error.
- **DashboardNotFoundError** (`Class`)
- 📝 Ошибка, когда запрошенный дашборд или ресурс не найден (404).
- 🔗 INHERITS_FROM -> `SupersetAPIError`
- ƒ **__init__** (`Function`)
- 📝 Initializes a dashboard not found error.
- **DatasetNotFoundError** (`Class`)
- 📝 Ошибка, когда запрашиваемый набор данных не существует (404).
- 🔗 INHERITS_FROM -> `SupersetAPIError`
- ƒ **__init__** (`Function`)
- 📝 Initializes a dataset not found error.
- **InvalidZipFormatError** (`Class`)
- 📝 Ошибка, указывающая на некорректный формат или содержимое ZIP-архива.
- 🔗 INHERITS_FROM -> `SupersetToolError`
- ƒ **__init__** (`Function`)
- 📝 Initializes an invalid ZIP format error.
- **NetworkError** (`Class`)
- 📝 Ошибки, связанные с сетевым соединением.
- 🔗 INHERITS_FROM -> `SupersetToolError`
- ƒ **__init__** (`Function`)
- 📝 Initializes a network error.
- **FileOperationError** (`Class`)
- 📝 Общие ошибки файловых операций (I/O).
- 🔗 INHERITS_FROM -> `SupersetToolError`
- **InvalidFileStructureError** (`Class`)
- 📝 Ошибка, указывающая на некорректную структуру файлов или директорий.
- 🔗 INHERITS_FROM -> `FileOperationError`
- **ConfigurationError** (`Class`)
- 📝 Ошибки, связанные с неверной конфигурацией инструмента.
- 🔗 INHERITS_FROM -> `SupersetToolError`
- 📦 **superset_tool.models** (`Module`)
- 📝 Определяет Pydantic-модели для конфигурации инструмента, обеспечивая валидацию данных.
- 🏗️ Layer: Infra
- 🔗 DEPENDS_ON -> `pydantic`
- 🔗 DEPENDS_ON -> `superset_tool.utils.logger`
- **SupersetConfig** (`Class`)
- 📝 Модель конфигурации для подключения к одному экземпляру Superset API.
- 🔗 INHERITS_FROM -> `pydantic.BaseModel`
- ƒ **validate_auth** (`Function`)
- 📝 Проверяет, что словарь `auth` содержит все необходимые для аутентификации поля.
- ƒ **normalize_base_url** (`Function`)
- 📝 Нормализует `base_url`, добавляя `/api/v1`, если он отсутствует.
- **DatabaseConfig** (`Class`)
- 📝 Модель для параметров трансформации баз данных при миграции дашбордов.
- 🔗 INHERITS_FROM -> `pydantic.BaseModel`
- ƒ **validate_config** (`Function`)
- 📝 Проверяет, что словарь `database_config` содержит ключи 'old' и 'new'.
- 📦 **superset_tool.client** (`Module`)
- 📝 Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию.
- 🏗️ Layer: Domain
- 🔗 DEPENDS_ON -> `superset_tool.models`
- 🔗 DEPENDS_ON -> `superset_tool.exceptions`
- 🔗 DEPENDS_ON -> `superset_tool.utils`
- **SupersetClient** (`Class`)
- 📝 Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами.
- ƒ **__init__** (`Function`)
- 📝 Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
- ƒ **_validate_config** (`Function`)
- 📝 Проверяет, что переданный объект конфигурации имеет корректный тип.
- ƒ **headers** (`Function`)
- 📝 Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
- ƒ **get_dashboards** (`Function`)
- 📝 Получает полный список дашбордов, автоматически обрабатывая пагинацию.
- 🔗 CALLS -> `self._fetch_total_object_count`
- 🔗 CALLS -> `self._fetch_all_pages`
- ƒ **export_dashboard** (`Function`)
- 📝 Экспортирует дашборд в виде ZIP-архива.
- 🔗 CALLS -> `self.network.request`
- ƒ **import_dashboard** (`Function`)
- 📝 Импортирует дашборд из ZIP-файла с возможностью автоматического удаления и повторной попытки при ошибке.
- 🔗 CALLS -> `self._do_import`
- 🔗 CALLS -> `self.delete_dashboard`
- 🔗 CALLS -> `self.get_dashboards`
- ƒ **_resolve_target_id_for_delete** (`Function`)
- 📝 Определяет ID дашборда для удаления, используя ID или slug.
- ƒ **_do_import** (`Function`)
- 📝 Выполняет один запрос на импорт без обработки исключений.
- ƒ **delete_dashboard** (`Function`)
- 📝 Удаляет дашборд по его ID или slug.
- 🔗 CALLS -> `self.network.request`
- ƒ **_extract_dashboard_id_from_zip** (`Function`)
- 📝 Извлекает ID дашборда из `metadata.yaml` внутри ZIP-архива.
- ƒ **_extract_dashboard_slug_from_zip** (`Function`)
- 📝 Извлекает slug дашборда из `metadata.yaml` внутри ZIP-архива.
- ƒ **_validate_export_response** (`Function`)
- 📝 Проверяет, что HTTP-ответ на экспорт является валидным ZIP-архивом.
- ƒ **_resolve_export_filename** (`Function`)
- 📝 Определяет имя файла для экспорта из заголовков или генерирует его.
- ƒ **_validate_query_params** (`Function`)
- 📝 Формирует корректный набор параметров запроса с пагинацией.
- ƒ **_fetch_total_object_count** (`Function`)
- 📝 Получает общее количество объектов по указанному эндпоинту для пагинации.
- ƒ **_fetch_all_pages** (`Function`)
- 📝 Итерируется по всем страницам пагинированного API и собирает все данные.
- ƒ **_validate_import_file** (`Function`)
- 📝 Проверяет, что файл существует, является ZIP-архивом и содержит `metadata.yaml`.
- ƒ **get_datasets** (`Function`)
- 📝 Получает полный список датасетов, автоматически обрабатывая пагинацию.
- 🔗 CALLS -> `self._fetch_total_object_count`
- 🔗 CALLS -> `self._fetch_all_pages`
- ƒ **get_databases** (`Function`)
- 📝 Получает полный список баз данных, автоматически обрабатывая пагинацию.
- 🔗 CALLS -> `self._fetch_total_object_count`
- 🔗 CALLS -> `self._fetch_all_pages`
- ƒ **get_dataset** (`Function`)
- 📝 Получает информацию о конкретном датасете по его ID.
- 🔗 CALLS -> `self.network.request`
- ƒ **get_database** (`Function`)
- 📝 Получает информацию о конкретной базе данных по её ID.
- 🔗 CALLS -> `self.network.request`
- ƒ **update_dataset** (`Function`)
- 📝 Обновляет данные датасета по его ID.
- 🔗 CALLS -> `self.network.request`
- 📦 **superset_tool** (`Module`)
- 📝 Root package for superset_tool.
- 🏗️ Layer: Domain
- 📦 **superset_tool.utils.init_clients** (`Module`)
- 📝 Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD), используя `keyring` для безопасного доступа к паролям.
- 🏗️ Layer: Infra
- 🔗 DEPENDS_ON -> `superset_tool.models`
- 🔗 DEPENDS_ON -> `superset_tool.client`
- 🔗 DEPENDS_ON -> `keyring`
- ƒ **setup_clients** (`Function`)
- 📝 Инициализирует и возвращает словарь клиентов `SupersetClient`.
- 📦 **superset_tool.utils.logger** (`Module`)
- 📝 Предоставляет универсальную обёртку над стандартным `logging.Logger` для унифицированного создания и управления логгерами с выводом в консоль и/или файл.
- 🏗️ Layer: Infra
- ƒ **belief_scope** (`Function`)
- 📝 Context manager for belief state logging to maintain execution coherence.
- **SupersetLogger** (`Class`)
- 📝 Обёртка над `logging.Logger`, которая упрощает конфигурацию и использование логгеров.
- ƒ **__init__** (`Function`)
- 📝 Конфигурирует и инициализирует логгер, добавляя обработчики для файла и/или консоли.
- ƒ **_log** (`Function`)
- 📝 (Helper) Универсальный метод для вызова соответствующего уровня логирования.
- ƒ **info** (`Function`)
- 📝 Записывает сообщение уровня INFO.
- ƒ **debug** (`Function`)
- 📝 Записывает сообщение уровня DEBUG.
- ƒ **warning** (`Function`)
- 📝 Записывает сообщение уровня WARNING.
- ƒ **error** (`Function`)
- 📝 Записывает сообщение уровня ERROR.
- ƒ **critical** (`Function`)
- 📝 Записывает сообщение уровня CRITICAL.
- ƒ **exception** (`Function`)
- 📝 Записывает сообщение уровня ERROR вместе с трассировкой стека текущего исключения.
- 📦 **belief_scope** (`Method`)
- 📝 Instance method wrapper for belief_scope context manager.
- 📦 **superset_tool.utils.fileio** (`Module`)
- 📝 Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
- 🏗️ Layer: Infra
- 🔗 DEPENDS_ON -> `superset_tool.exceptions`
- 🔗 DEPENDS_ON -> `superset_tool.utils.logger`
- 🔗 DEPENDS_ON -> `pyyaml`
- ƒ **create_temp_file** (`Function`)
- 📝 Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
- ƒ **remove_empty_directories** (`Function`)
- 📝 Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути.
- ƒ **read_dashboard_from_disk** (`Function`)
- 📝 Читает бинарное содержимое файла с диска.
- ƒ **calculate_crc32** (`Function`)
- 📝 Вычисляет контрольную сумму CRC32 для файла.
- 📦 **RetentionPolicy** (`DataClass`)
- 📝 Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
- ƒ **archive_exports** (`Function`)
- 📝 Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
- 🔗 CALLS -> `apply_retention_policy`
- 🔗 CALLS -> `calculate_crc32`
- ƒ **apply_retention_policy** (`Function`)
- 📝 (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
- ƒ **save_and_unpack_dashboard** (`Function`)
- 📝 Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
- ƒ **update_yamls** (`Function`)
- 📝 Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex.
- 🔗 CALLS -> `_update_yaml_file`
- ƒ **_update_yaml_file** (`Function`)
- 📝 (Helper) Обновляет один YAML файл.
- ƒ **replacer** (`Function`)
- 📝 Функция замены, сохраняющая кавычки если они были.
- ƒ **create_dashboard_export** (`Function`)
- 📝 Создает ZIP-архив из указанных исходных путей.
- ƒ **sanitize_filename** (`Function`)
- 📝 Очищает строку от символов, недопустимых в именах файлов.
- ƒ **get_filename_from_headers** (`Function`)
- 📝 Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
- ƒ **consolidate_archive_folders** (`Function`)
- 📝 Консолидирует директории архивов на основе общего слага в имени.
- 📦 **superset_tool.utils.network** (`Module`)
- 📝 Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок.
- 🏗️ Layer: Infra
- 🔗 DEPENDS_ON -> `superset_tool.exceptions`
- 🔗 DEPENDS_ON -> `superset_tool.utils.logger`
- 🔗 DEPENDS_ON -> `requests`
- **APIClient** (`Class`)
- 📝 Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов.
- ƒ **__init__** (`Function`)
- 📝 Инициализирует API клиент с конфигурацией, сессией и логгером.
- ƒ **_init_session** (`Function`)
- 📝 Создает и настраивает `requests.Session` с retry-логикой.
- ƒ **authenticate** (`Function`)
- 📝 Выполняет аутентификацию в Superset API и получает access и CSRF токены.
- ƒ **headers** (`Function`)
- 📝 Возвращает HTTP-заголовки для аутентифицированных запросов.
- ƒ **request** (`Function`)
- 📝 Выполняет универсальный HTTP-запрос к API.
- ƒ **_handle_http_error** (`Function`)
- 📝 (Helper) Преобразует HTTP ошибки в кастомные исключения.
- ƒ **_handle_network_error** (`Function`)
- 📝 (Helper) Преобразует сетевые ошибки в `NetworkError`.
- ƒ **upload_file** (`Function`)
- 📝 Загружает файл на сервер через multipart/form-data.
- ƒ **_perform_upload** (`Function`)
- 📝 (Helper) Выполняет POST запрос с файлом.
- ƒ **fetch_paginated_count** (`Function`)
- 📝 Получает общее количество элементов для пагинации.
- ƒ **fetch_paginated_data** (`Function`)
- 📝 Автоматически собирает данные со всех страниц пагинированного эндпоинта.
- 📦 **superset_tool.utils.whiptail_fallback** (`Module`)
- 📝 Предоставляет плотный консольный UI-fallback для интерактивных диалогов, имитируя `whiptail` для систем, где он недоступен.
- 🏗️ Layer: UI
- ƒ **menu** (`Function`)
- 📝 Отображает меню выбора и возвращает выбранный элемент.
- ƒ **checklist** (`Function`)
- 📝 Отображает список с возможностью множественного выбора.
- ƒ **yesno** (`Function`)
- 📝 Задает вопрос с ответом да/нет.
- ƒ **msgbox** (`Function`)
- 📝 Отображает информационное сообщение.
- ƒ **inputbox** (`Function`)
- 📝 Запрашивает у пользователя текстовый ввод.
- **_ConsoleGauge** (`Class`)
- 📝 Контекстный менеджер для имитации `whiptail gauge` в консоли.
- ƒ **__init__** (`Function`)
- 📝 Initializes the gauge.
- ƒ **__enter__** (`Function`)
- 📝 Enters the context.
- ƒ **__exit__** (`Function`)
- 📝 Exits the context.
- ƒ **set_text** (`Function`)
- 📝 Sets the gauge text.
- ƒ **set_percent** (`Function`)
- 📝 Sets the gauge percentage.
- ƒ **gauge** (`Function`)
- 📝 Создает и возвращает экземпляр `_ConsoleGauge`.
- 📦 **superset_tool.utils.dataset_mapper** (`Module`)
- 📝 Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов.
- 🏗️ Layer: Domain
- 🔗 DEPENDS_ON -> `superset_tool.client`
- 🔗 DEPENDS_ON -> `pandas`
- 🔗 DEPENDS_ON -> `psycopg2`
- **DatasetMapper** (`Class`)
- 📝 Класс для меппинга и обновления verbose_map в датасетах Superset.
- ƒ **__init__** (`Function`)
- 📝 Initializes the mapper.
- ƒ **get_postgres_comments** (`Function`)
- 📝 Извлекает комментарии к колонкам из системного каталога PostgreSQL.
- ƒ **load_excel_mappings** (`Function`)
- 📝 Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла.
- ƒ **run_mapping** (`Function`)
- 📝 Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset.
- 🔗 CALLS -> `self.get_postgres_comments`
- 🔗 CALLS -> `self.load_excel_mappings`
- 🔗 CALLS -> `superset_client.get_dataset`
- 🔗 CALLS -> `superset_client.update_dataset`
- 📦 **superset_tool.utils** (`Module`)
- 📝 Utility package for superset_tool.
- 🏗️ Layer: Infra
- 📦 **main** (`Module`)
- 📝 Entry point for the Svelte application.
- 🏗️ Layer: UI-Entry
@@ -454,14 +110,17 @@
- 📝 Handles task creation from dynamic form submission.
- ƒ **load** (`Function`)
- 📝 Loads initial plugin data for the dashboard.
- ƒ **loadInitialData** (`Function`)
- 📝 Loads tasks and environments on page initialization.
- ƒ **refreshTasks** (`Function`)
- 📝 Periodically refreshes the task list.
- ƒ **handleSelectTask** (`Function`)
- 📝 Updates the selected task ID when a task is clicked.
- ƒ **handleRunBackup** (`Function`)
- 📝 Triggers a manual backup task for the selected environment.
- 🧩 **TaskManagementPage** (`Component`)
- 📝 Page for managing and monitoring tasks.
- 🏗️ Layer: Page
- ƒ **loadInitialData** (`Function`)
- 📝 Loads tasks and environments on page initialization.
- ƒ **refreshTasks** (`Function`)
- 📝 Periodically refreshes the task list.
- ƒ **handleSelectTask** (`Function`)
- 📝 Updates the selected task ID when a task is clicked.
- ƒ **handleRunBackup** (`Function`)
- 📝 Triggers a manual backup task for the selected environment.
- 🧩 **MigrationDashboard** (`Component`)
- 📝 Main dashboard for configuring and starting migrations.
- 🏗️ Layer: Page
@@ -579,7 +238,7 @@
- ƒ **getSuggestion** (`Function`)
- 📝 Finds a suggestion for a source database.
- 🧩 **TaskLogViewer** (`Component`)
- 📝 Displays detailed logs for a specific task in a modal.
- 📝 Displays detailed logs for a specific task in a modal or inline.
- 🏗️ Layer: UI
- ƒ **fetchLogs** (`Function`)
- 📝 Fetches logs for the current task.
@@ -745,21 +404,48 @@
- ƒ **get_scheduler_service** (`Function`)
- 📝 Dependency injector for the SchedulerService.
- 📦 **backend.src.core.superset_client** (`Module`)
- 📝 Extends the base SupersetClient with database-specific metadata fetching.
- 📝 Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию.
- 🏗️ Layer: Core
- 🔗 INHERITS_FROM -> `superset_tool.client.SupersetClient`
- **SupersetClient** (`Class`)
- 📝 Extended SupersetClient for migration-specific operations.
- 📝 Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами.
- ƒ **__init__** (`Function`)
- 📝 Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
- ƒ **authenticate** (`Function`)
- 📝 Authenticates the client using the configured credentials.
- ƒ **headers** (`Function`)
- 📝 Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
- ƒ **get_dashboards** (`Function`)
- 📝 Получает полный список дашбордов, автоматически обрабатывая пагинацию.
- ƒ **get_dashboards_summary** (`Function`)
- 📝 Fetches dashboard metadata optimized for the grid.
- ƒ **export_dashboard** (`Function`)
- 📝 Экспортирует дашборд в виде ZIP-архива.
- ƒ **import_dashboard** (`Function`)
- 📝 Импортирует дашборд из ZIP-файла.
- ƒ **delete_dashboard** (`Function`)
- 📝 Удаляет дашборд по его ID или slug.
- ƒ **get_datasets** (`Function`)
- 📝 Получает полный список датасетов, автоматически обрабатывая пагинацию.
- ƒ **get_dataset** (`Function`)
- 📝 Получает информацию о конкретном датасете по его ID.
- ƒ **update_dataset** (`Function`)
- 📝 Обновляет данные датасета по его ID.
- ƒ **get_databases** (`Function`)
- 📝 Получает полный список баз данных.
- ƒ **get_database** (`Function`)
- 📝 Получает информацию о конкретной базе данных по её ID.
- ƒ **get_databases_summary** (`Function`)
- 📝 Fetch a summary of databases including uuid, name, and engine.
- ƒ **get_database_by_uuid** (`Function`)
- 📝 Find a database by its UUID.
- ƒ **get_dashboards_summary** (`Function`)
- 📝 Fetches dashboard metadata optimized for the grid.
- ƒ **get_dataset** (`Function`)
- 📝 Fetch full dataset structure including columns and metrics.
- ƒ **update_dataset** (`Function`)
- 📝 Update dataset metadata.
- ƒ **_resolve_target_id_for_delete** (`Function`)
- ƒ **_do_import** (`Function`)
- ƒ **_validate_export_response** (`Function`)
- ƒ **_resolve_export_filename** (`Function`)
- ƒ **_validate_query_params** (`Function`)
- ƒ **_fetch_total_object_count** (`Function`)
- ƒ **_fetch_all_pages** (`Function`)
- ƒ **_validate_import_file** (`Function`)
- 📦 **ConfigManagerModule** (`Module`)
- 📝 Manages application configuration, including loading/saving to JSON and CRUD for environments.
- 🏗️ Layer: Core
@@ -785,6 +471,8 @@
- 📝 Returns the list of configured environments.
- ƒ **has_environments** (`Function`)
- 📝 Checks if at least one environment is configured.
- ƒ **get_environment** (`Function`)
- 📝 Returns a single environment by ID.
- ƒ **add_environment** (`Function`)
- 📝 Adds a new environment to the configuration.
- ƒ **update_environment** (`Function`)
@@ -862,6 +550,8 @@
- 📝 Returns a list of recent log entries from the buffer.
- 📦 **Logger** (`Global`)
- 📝 The global logger instance for the application, configured with both a console handler and the custom WebSocket handler.
- ƒ **believed** (`Function`)
- 📝 A decorator that wraps a function in a belief scope.
- **PluginLoader** (`Class`)
- 📝 Scans a specified directory for Python modules, dynamically loads them, and registers any classes that are valid implementations of the PluginBase interface.
- 🏗️ Layer: Core
@@ -907,12 +597,76 @@
- **PluginConfig** (`Class`)
- 📝 A Pydantic model used to represent the validated configuration and metadata of a loaded plugin. This object is what gets exposed to the API layer.
- 🏗️ Layer: Core
- 📦 **backend.core.utils.fileio** (`Module`)
- 📝 Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
- 🏗️ Layer: Infra
- 🔗 DEPENDS_ON -> `backend.src.core.logger`
- 🔗 DEPENDS_ON -> `pyyaml`
- **InvalidZipFormatError** (`Class`)
- ƒ **create_temp_file** (`Function`)
- 📝 Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
- ƒ **remove_empty_directories** (`Function`)
- 📝 Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути.
- ƒ **read_dashboard_from_disk** (`Function`)
- 📝 Читает бинарное содержимое файла с диска.
- ƒ **calculate_crc32** (`Function`)
- 📝 Вычисляет контрольную сумму CRC32 для файла.
- 📦 **RetentionPolicy** (`DataClass`)
- 📝 Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
- ƒ **archive_exports** (`Function`)
- 📝 Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
- 🔗 CALLS -> `apply_retention_policy`
- 🔗 CALLS -> `calculate_crc32`
- ƒ **apply_retention_policy** (`Function`)
- 📝 (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
- ƒ **save_and_unpack_dashboard** (`Function`)
- 📝 Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
- ƒ **update_yamls** (`Function`)
- 📝 Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex.
- 🔗 CALLS -> `_update_yaml_file`
- ƒ **_update_yaml_file** (`Function`)
- 📝 (Helper) Обновляет один YAML файл.
- ƒ **create_dashboard_export** (`Function`)
- 📝 Создает ZIP-архив из указанных исходных путей.
- ƒ **sanitize_filename** (`Function`)
- 📝 Очищает строку от символов, недопустимых в именах файлов.
- ƒ **get_filename_from_headers** (`Function`)
- 📝 Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
- ƒ **consolidate_archive_folders** (`Function`)
- 📝 Консолидирует директории архивов на основе общего слага в имени.
- 📦 **backend.core.utils.network** (`Module`)
- 📝 Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок.
- 🏗️ Layer: Infra
- 🔗 DEPENDS_ON -> `backend.src.core.logger`
- 🔗 DEPENDS_ON -> `requests`
- **SupersetAPIError** (`Class`)
- **AuthenticationError** (`Class`)
- 📦 **backend.src.core.utils.matching** (`Module`)
- 📝 Provides utility functions for fuzzy matching database names.
- 🏗️ Layer: Core
- 🔗 DEPENDS_ON -> `rapidfuzz`
- ƒ **suggest_mappings** (`Function`)
- 📝 Suggests mappings between source and target databases using fuzzy matching.
- 📦 **backend.core.utils.dataset_mapper** (`Module`)
- 📝 Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов.
- 🏗️ Layer: Domain
- 🔗 DEPENDS_ON -> `backend.core.superset_client`
- 🔗 DEPENDS_ON -> `pandas`
- 🔗 DEPENDS_ON -> `psycopg2`
- **DatasetMapper** (`Class`)
- 📝 Класс для меппинга и обновления verbose_map в датасетах Superset.
- ƒ **__init__** (`Function`)
- 📝 Initializes the mapper.
- ƒ **get_postgres_comments** (`Function`)
- 📝 Извлекает комментарии к колонкам из системного каталога PostgreSQL.
- ƒ **load_excel_mappings** (`Function`)
- 📝 Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла.
- ƒ **run_mapping** (`Function`)
- 📝 Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset.
- 🔗 CALLS -> `self.get_postgres_comments`
- 🔗 CALLS -> `self.load_excel_mappings`
- 🔗 CALLS -> `superset_client.get_dataset`
- 🔗 CALLS -> `superset_client.update_dataset`
- 📦 **TaskPersistenceModule** (`Module`)
- 📝 Handles the persistence of tasks using SQLAlchemy and the tasks.db database.
- 🏗️ Layer: Core
@@ -1228,10 +982,8 @@
- 📝 Returns the JSON schema for migration plugin parameters.
- ƒ **execute** (`Function`)
- 📝 Executes the dashboard migration logic.
- ƒ **test_superset_config_url_normalization** (`Function`)
- 📝 Tests that SupersetConfig correctly normalizes the base URL.
- ƒ **test_superset_config_invalid_url** (`Function`)
- 📝 Tests that SupersetConfig raises ValueError for invalid URLs.
- ƒ **test_environment_model** (`Function`)
- 📝 Tests that Environment model correctly stores values.
- ƒ **test_belief_scope_logs_entry_action_exit** (`Function`)
- 📝 Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs.
- ƒ **test_belief_scope_error_handling** (`Function`)

View File

@@ -1,14 +0,0 @@
# [DEF:superset_tool:Module]
# @SEMANTICS: package, root
# @PURPOSE: Root package for superset_tool.
# @LAYER: Domain
# @PUBLIC_API: SupersetClient, SupersetConfig
# [SECTION: IMPORTS]
from .client import SupersetClient
from .models import SupersetConfig
# [/SECTION]
__all__ = ["SupersetClient", "SupersetConfig"]
# [/DEF:superset_tool:Module]

View File

@@ -1,508 +0,0 @@
# [DEF:superset_tool.client:Module]
#
# @SEMANTICS: superset, api, client, rest, http, dashboard, dataset, import, export
# @PURPOSE: Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> superset_tool.models
# @RELATION: DEPENDS_ON -> superset_tool.exceptions
# @RELATION: DEPENDS_ON -> superset_tool.utils
#
# @INVARIANT: All network operations must use the internal APIClient instance.
# @CONSTRAINT: No direct use of 'requests' library outside of APIClient.
# @PUBLIC_API: SupersetClient
# [SECTION: IMPORTS]
import json
import zipfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union, cast
from requests import Response
from superset_tool.models import SupersetConfig
from superset_tool.exceptions import ExportError, InvalidZipFormatError
from superset_tool.utils.fileio import get_filename_from_headers
from superset_tool.utils.logger import SupersetLogger
from superset_tool.utils.network import APIClient
# [/SECTION]
# [DEF:SupersetClient:Class]
# @PURPOSE: Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами.
# @RELATION: CREATES_INSTANCE_OF -> APIClient
# @RELATION: USES -> SupersetConfig
class SupersetClient:
# [DEF:__init__:Function]
# @PURPOSE: Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
# @PRE: `config` должен быть валидным объектом SupersetConfig.
# @POST: Атрибуты `logger`, `config`, и `network` созданы и готовы к работе.
# @PARAM: config (SupersetConfig) - Конфигурация подключения.
# @PARAM: logger (Optional[SupersetLogger]) - Экземпляр логгера.
def __init__(self, config: SupersetConfig, logger: Optional[SupersetLogger] = None):
with belief_scope("__init__"):
self.logger = logger or SupersetLogger(name="SupersetClient")
self.logger.info("[SupersetClient.__init__][Enter] Initializing SupersetClient.")
self._validate_config(config)
self.config = config
self.network = APIClient(
config=config.dict(),
verify_ssl=config.verify_ssl,
timeout=config.timeout,
logger=self.logger,
)
self.delete_before_reimport: bool = False
self.logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.")
# [/DEF:__init__:Function]
# [DEF:_validate_config:Function]
# @PURPOSE: Проверяет, что переданный объект конфигурации имеет корректный тип.
# @PRE: `config` должен быть передан.
# @POST: Если проверка пройдена, выполнение продолжается.
# @THROW: TypeError - Если `config` не является экземпляром `SupersetConfig`.
# @PARAM: config (SupersetConfig) - Объект для проверки.
def _validate_config(self, config: SupersetConfig) -> None:
with belief_scope("_validate_config"):
self.logger.debug("[_validate_config][Enter] Validating SupersetConfig.")
assert isinstance(config, SupersetConfig), "Конфигурация должна быть экземпляром SupersetConfig"
self.logger.debug("[_validate_config][Exit] Config is valid.")
# [/DEF:_validate_config:Function]
@property
# [DEF:headers:Function]
# @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
# @PRE: self.network должен быть инициализирован.
# @POST: Возвращаемый словарь содержит актуальные заголовки, включая токен авторизации.
def headers(self) -> dict:
with belief_scope("headers"):
return self.network.headers
# [/DEF:headers:Function]
# [DEF:get_dashboards:Function]
# @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию.
# @RELATION: CALLS -> self._fetch_total_object_count
# @RELATION: CALLS -> self._fetch_all_pages
# @PRE: self.network должен быть инициализирован.
# @POST: Возвращаемый список содержит все дашборды, доступные по API.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса для API.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов).
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_dashboards"):
assert self.network, "[get_dashboards][PRE] Network client must be initialized."
self.logger.info("[get_dashboards][Enter] Fetching dashboards.")
validated_query = self._validate_query_params(query or {})
if 'columns' not in validated_query:
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"]
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
paginated_data = self._fetch_all_pages(
endpoint="/dashboard/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
self.logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
return total_count, paginated_data
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"]
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
paginated_data = self._fetch_all_pages(
endpoint="/dashboard/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
self.logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
return total_count, paginated_data
# [/DEF:get_dashboards:Function]
# [DEF:export_dashboard:Function]
# @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
# @RELATION: CALLS -> self.network.request
# @PRE: dashboard_id должен быть положительным целым числом.
# @POST: Возвращает бинарное содержимое ZIP-архива и имя файла.
# @THROW: ExportError - Если экспорт завершился неудачей.
# @PARAM: dashboard_id (int) - ID дашборда для экспорта.
# @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла.
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
with belief_scope("export_dashboard"):
assert isinstance(dashboard_id, int) and dashboard_id > 0, "[export_dashboard][PRE] dashboard_id must be a positive integer."
self.logger.info("[export_dashboard][Enter] Exporting dashboard %s.", dashboard_id)
response = self.network.request(
method="GET",
endpoint="/dashboard/export/",
params={"q": json.dumps([dashboard_id])},
stream=True,
raw_response=True,
)
response = cast(Response, response)
self._validate_export_response(response, dashboard_id)
filename = self._resolve_export_filename(response, dashboard_id)
self.logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename)
return response.content, filename
# [/DEF:export_dashboard:Function]
# [DEF:import_dashboard:Function]
# @PURPOSE: Импортирует дашборд из ZIP-файла с возможностью автоматического удаления и повторной попытки при ошибке.
# @RELATION: CALLS -> self._do_import
# @RELATION: CALLS -> self.delete_dashboard
# @RELATION: CALLS -> self.get_dashboards
# @PRE: Файл, указанный в `file_name`, должен существовать и быть валидным ZIP-архивом Superset.
# @POST: Дашборд успешно импортирован, возвращен ответ API.
# @THROW: FileNotFoundError - Если файл не найден.
# @THROW: InvalidZipFormatError - Если файл не является валидным ZIP-архивом Superset.
# @PARAM: file_name (Union[str, Path]) - Путь к ZIP-архиву.
# @PARAM: dash_id (Optional[int]) - ID дашборда для удаления при сбое.
# @PARAM: dash_slug (Optional[str]) - Slug дашборда для поиска ID, если ID не предоставлен.
# @RETURN: Dict - Ответ API в случае успеха.
def import_dashboard(self, file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict:
with belief_scope("import_dashboard"):
assert file_name, "[import_dashboard][PRE] file_name must be provided."
file_path = str(file_name)
self._validate_import_file(file_path)
try:
return self._do_import(file_path)
except Exception as exc:
self.logger.error("[import_dashboard][Failure] First import attempt failed: %s", exc, exc_info=True)
if not self.delete_before_reimport:
raise
target_id = self._resolve_target_id_for_delete(dash_id, dash_slug)
if target_id is None:
self.logger.error("[import_dashboard][Failure] No ID available for delete-retry.")
raise
self.delete_dashboard(target_id)
self.logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id)
return self._do_import(file_path)
# [/DEF:import_dashboard:Function]
# [DEF:_resolve_target_id_for_delete:Function]
# @PURPOSE: Определяет ID дашборда для удаления, используя ID или slug.
# @PARAM: dash_id (Optional[int]) - ID дашборда.
# @PARAM: dash_slug (Optional[str]) - Slug дашборда.
# @PRE: По крайней мере один из параметров (dash_id или dash_slug) должен быть предоставлен.
# @POST: Возвращает ID дашборда, если найден, иначе None.
# @THROW: APIError - В случае ошибки сетевого запроса при поиске по slug.
# @RETURN: Optional[int] - Найденный ID или None.
def _resolve_target_id_for_delete(self, dash_id: Optional[int], dash_slug: Optional[str]) -> Optional[int]:
with belief_scope("_resolve_target_id_for_delete"):
assert dash_id is not None or dash_slug is not None, "[_resolve_target_id_for_delete][PRE] At least one of ID or slug must be provided."
if dash_id is not None:
return dash_id
if dash_slug is not None:
self.logger.debug("[_resolve_target_id_for_delete][State] Resolving ID by slug '%s'.", dash_slug)
try:
_, candidates = self.get_dashboards(query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]})
if candidates:
target_id = candidates[0]["id"]
self.logger.debug("[_resolve_target_id_for_delete][Success] Resolved slug to ID %s.", target_id)
return target_id
except Exception as e:
self.logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e)
return None
self.logger.debug("[_resolve_target_id_for_delete][State] Resolving ID by slug '%s'.", dash_slug)
try:
_, candidates = self.get_dashboards(query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]})
if candidates:
target_id = candidates[0]["id"]
self.logger.debug("[_resolve_target_id_for_delete][Success] Resolved slug to ID %s.", target_id)
return target_id
except Exception as e:
self.logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e)
return None
# [/DEF:_resolve_target_id_for_delete:Function]
# [DEF:_do_import:Function]
# @PURPOSE: Выполняет один запрос на импорт без обработки исключений.
# @PRE: Файл должен существовать.
# @POST: Файл успешно загружен, возвращен ответ API.
# @THROW: FileNotFoundError - Если файл не существует.
# @PARAM: file_name (Union[str, Path]) - Путь к файлу.
# @RETURN: Dict - Ответ API.
def _do_import(self, file_name: Union[str, Path]) -> Dict:
with belief_scope("_do_import"):
self.logger.debug(f"[_do_import][State] Uploading file: {file_name}")
file_path = Path(file_name)
if file_path.exists():
self.logger.debug(f"[_do_import][State] File size: {file_path.stat().st_size} bytes")
else:
self.logger.error(f"[_do_import][Failure] File does not exist: {file_name}")
raise FileNotFoundError(f"File does not exist: {file_name}")
return self.network.upload_file(
endpoint="/dashboard/import/",
file_info={"file_obj": file_path, "file_name": file_path.name, "form_field": "formData"},
extra_data={"overwrite": "true"},
timeout=self.config.timeout * 2,
)
# [/DEF:_do_import:Function]
# [DEF:delete_dashboard:Function]
# @PURPOSE: Удаляет дашборд по его ID или slug.
# @RELATION: CALLS -> self.network.request
# @PRE: dashboard_id должен быть предоставлен.
# @POST: Дашборд удален или залогировано предупреждение.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @PARAM: dashboard_id (Union[int, str]) - ID или slug дашборда.
def delete_dashboard(self, dashboard_id: Union[int, str]) -> None:
with belief_scope("delete_dashboard"):
assert dashboard_id, "[delete_dashboard][PRE] dashboard_id must be provided."
self.logger.info("[delete_dashboard][Enter] Deleting dashboard %s.", dashboard_id)
response = self.network.request(method="DELETE", endpoint=f"/dashboard/{dashboard_id}")
response = cast(Dict, response)
if response.get("result", True) is not False:
self.logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id)
else:
self.logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response)
# [/DEF:delete_dashboard:Function]
# [DEF:_extract_dashboard_id_from_zip:Function]
# @PURPOSE: Извлекает ID дашборда из `metadata.yaml` внутри ZIP-архива.
# @PARAM: file_name (Union[str, Path]) - Путь к ZIP-файлу.
# @PRE: Файл, указанный в `file_name`, должен быть валидным ZIP-архивом.
# @POST: Возвращает ID дашборда, если найден в metadata.yaml, иначе None.
# @THROW: ImportError - Если не установлен `yaml`.
# @RETURN: Optional[int] - ID дашборда или None.
def _extract_dashboard_id_from_zip(self, file_name: Union[str, Path]) -> Optional[int]:
with belief_scope("_extract_dashboard_id_from_zip"):
assert zipfile.is_zipfile(file_name), "[_extract_dashboard_id_from_zip][PRE] file_name must be a valid zip file."
try:
import yaml
with zipfile.ZipFile(file_name, "r") as zf:
for name in zf.namelist():
if name.endswith("metadata.yaml"):
with zf.open(name) as meta_file:
meta = yaml.safe_load(meta_file)
dash_id = meta.get("dashboard_uuid") or meta.get("dashboard_id")
if dash_id: return int(dash_id)
except Exception as exc:
self.logger.error("[_extract_dashboard_id_from_zip][Failure] %s", exc, exc_info=True)
return None
# [/DEF:_extract_dashboard_id_from_zip:Function]
# [DEF:_extract_dashboard_slug_from_zip:Function]
# @PURPOSE: Извлекает slug дашборда из `metadata.yaml` внутри ZIP-архива.
# @PARAM: file_name (Union[str, Path]) - Путь к ZIP-файлу.
# @PRE: Файл, указанный в `file_name`, должен быть валидным ZIP-архивом.
# @POST: Возвращает slug дашборда, если найден в metadata.yaml, иначе None.
# @THROW: ImportError - Если не установлен `yaml`.
# @RETURN: Optional[str] - Slug дашборда или None.
def _extract_dashboard_slug_from_zip(self, file_name: Union[str, Path]) -> Optional[str]:
with belief_scope("_extract_dashboard_slug_from_zip"):
assert zipfile.is_zipfile(file_name), "[_extract_dashboard_slug_from_zip][PRE] file_name must be a valid zip file."
try:
import yaml
with zipfile.ZipFile(file_name, "r") as zf:
for name in zf.namelist():
if name.endswith("metadata.yaml"):
with zf.open(name) as meta_file:
meta = yaml.safe_load(meta_file)
if slug := meta.get("slug"):
return str(slug)
except Exception as exc:
self.logger.error("[_extract_dashboard_slug_from_zip][Failure] %s", exc, exc_info=True)
return None
# [/DEF:_extract_dashboard_slug_from_zip:Function]
# [DEF:_validate_export_response:Function]
# @PURPOSE: Проверяет, что HTTP-ответ на экспорт является валидным ZIP-архивом.
# @PRE: response должен быть объектом requests.Response.
# @POST: Проверка пройдена, если ответ является непустым ZIP-архивом.
# @THROW: ExportError - Если ответ не является ZIP-архивом или пуст.
# @PARAM: response (Response) - HTTP ответ.
# @PARAM: dashboard_id (int) - ID дашборда.
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
with belief_scope("_validate_export_response"):
assert isinstance(response, Response), "[_validate_export_response][PRE] response must be a requests.Response object."
content_type = response.headers.get("Content-Type", "")
if "application/zip" not in content_type:
raise ExportError(f"Получен не ZIP-архив (Content-Type: {content_type})")
if not response.content:
raise ExportError("Получены пустые данные при экспорте")
# [/DEF:_validate_export_response:Function]
# [DEF:_resolve_export_filename:Function]
# @PURPOSE: Определяет имя файла для экспорта из заголовков или генерирует его.
# @PRE: response должен быть объектом requests.Response.
# @POST: Возвращает непустое имя файла.
# @PARAM: response (Response) - HTTP ответ.
# @PARAM: dashboard_id (int) - ID дашборда.
# @RETURN: str - Имя файла.
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
with belief_scope("_resolve_export_filename"):
assert isinstance(response, Response), "[_resolve_export_filename][PRE] response must be a requests.Response object."
filename = get_filename_from_headers(dict(response.headers))
if not filename:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
self.logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename)
return filename
# [/DEF:_resolve_export_filename:Function]
# [DEF:_validate_query_params:Function]
# @PURPOSE: Формирует корректный набор параметров запроса с пагинацией.
# @PARAM: query (Optional[Dict]) - Исходные параметры.
# @PRE: query, если предоставлен, должен быть словарем.
# @POST: Возвращает словарь, содержащий базовые параметры пагинации, объединенные с `query`.
# @RETURN: Dict - Валидированные параметры.
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
with belief_scope("_validate_query_params"):
assert query is None or isinstance(query, dict), "[_validate_query_params][PRE] query must be a dictionary or None."
base_query = {"page": 0, "page_size": 1000}
return {**base_query, **(query or {})}
# [/DEF:_validate_query_params:Function]
# [DEF:_fetch_total_object_count:Function]
# @PURPOSE: Получает общее количество объектов по указанному эндпоинту для пагинации.
# @PARAM: endpoint (str) - API эндпоинт.
# @PRE: endpoint должен быть непустой строкой.
# @POST: Возвращает общее количество объектов (>= 0).
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: int - Количество объектов.
def _fetch_total_object_count(self, endpoint: str) -> int:
with belief_scope("_fetch_total_object_count"):
assert endpoint and isinstance(endpoint, str), "[_fetch_total_object_count][PRE] endpoint must be a non-empty string."
return self.network.fetch_paginated_count(
endpoint=endpoint,
query_params={"page": 0, "page_size": 1},
count_field="count",
)
# [/DEF:_fetch_total_object_count:Function]
# [DEF:_fetch_all_pages:Function]
# @PURPOSE: Итерируется по всем страницам пагинированного API и собирает все данные.
# @PARAM: endpoint (str) - API эндпоинт.
# @PARAM: pagination_options (Dict) - Опции пагинации.
# @PRE: endpoint должен быть непустой строкой, pagination_options - словарем.
# @POST: Возвращает полный список объектов.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: List[Dict] - Список всех объектов.
def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]:
with belief_scope("_fetch_all_pages"):
assert endpoint and isinstance(endpoint, str), "[_fetch_all_pages][PRE] endpoint must be a non-empty string."
assert isinstance(pagination_options, dict), "[_fetch_all_pages][PRE] pagination_options must be a dictionary."
return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options)
# [/DEF:_fetch_all_pages:Function]
# [DEF:_validate_import_file:Function]
# @PURPOSE: Проверяет, что файл существует, является ZIP-архивом и содержит `metadata.yaml`.
# @PRE: zip_path должен быть предоставлен.
# @POST: Проверка пройдена, если файл существует, является ZIP и содержит `metadata.yaml`.
# @THROW: FileNotFoundError - Если файл не найден.
# @THROW: InvalidZipFormatError - Если файл не является ZIP или не содержит `metadata.yaml`.
# @PARAM: zip_path (Union[str, Path]) - Путь к файлу.
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
with belief_scope("_validate_import_file"):
assert zip_path, "[_validate_import_file][PRE] zip_path must be provided."
path = Path(zip_path)
assert path.exists(), f"Файл {zip_path} не существует"
assert zipfile.is_zipfile(path), f"Файл {zip_path} не является ZIP-архивом"
with zipfile.ZipFile(path, "r") as zf:
assert any(n.endswith("metadata.yaml") for n in zf.namelist()), f"Архив {zip_path} не содержит 'metadata.yaml'"
# [/DEF:_validate_import_file:Function]
# [DEF:get_datasets:Function]
# @PURPOSE: Получает полный список датасетов, автоматически обрабатывая пагинацию.
# @RELATION: CALLS -> self._fetch_total_object_count
# @RELATION: CALLS -> self._fetch_all_pages
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
# @PRE: self.network должен быть инициализирован.
# @POST: Возвращаемый список содержит все датасеты, доступные по API.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список датасетов).
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_datasets"):
assert self.network, "[get_datasets][PRE] Network client must be initialized."
self.logger.info("[get_datasets][Enter] Fetching datasets.")
validated_query = self._validate_query_params(query)
total_count = self._fetch_total_object_count(endpoint="/dataset/")
paginated_data = self._fetch_all_pages(
endpoint="/dataset/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
self.logger.info("[get_datasets][Exit] Found %d datasets.", total_count)
return total_count, paginated_data
# [/DEF:get_datasets:Function]
# [DEF:get_databases:Function]
# @PURPOSE: Получает полный список баз данных, автоматически обрабатывая пагинацию.
# @RELATION: CALLS -> self._fetch_total_object_count
# @RELATION: CALLS -> self._fetch_all_pages
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
# @PRE: self.network должен быть инициализирован.
# @POST: Возвращаемый список содержит все базы данных, доступные по API.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список баз данных).
def get_databases(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
with belief_scope("get_databases"):
assert self.network, "[get_databases][PRE] Network client must be initialized."
self.logger.info("[get_databases][Enter] Fetching databases.")
validated_query = self._validate_query_params(query or {})
if 'columns' not in validated_query:
validated_query['columns'] = []
total_count = self._fetch_total_object_count(endpoint="/database/")
paginated_data = self._fetch_all_pages(
endpoint="/database/",
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
)
self.logger.info("[get_databases][Exit] Found %d databases.", total_count)
return total_count, paginated_data
# [/DEF:get_databases:Function]
# [DEF:get_dataset:Function]
# @PURPOSE: Получает информацию о конкретном датасете по его ID.
# @RELATION: CALLS -> self.network.request
# @PARAM: dataset_id (int) - ID датасета.
# @PRE: dataset_id должен быть положительным целым числом.
# @POST: Возвращает словарь с информацией о датасете.
# @THROW: APIError - В случае ошибки сетевого запроса или если датасет не найден.
# @RETURN: Dict - Информация о датасете.
def get_dataset(self, dataset_id: int) -> Dict:
with belief_scope("get_dataset"):
assert isinstance(dataset_id, int) and dataset_id > 0, "[get_dataset][PRE] dataset_id must be a positive integer."
self.logger.info("[get_dataset][Enter] Fetching dataset %s.", dataset_id)
response = self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}")
response = cast(Dict, response)
self.logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id)
return response
# [/DEF:get_dataset:Function]
# [DEF:get_database:Function]
# @PURPOSE: Получает информацию о конкретной базе данных по её ID.
# @RELATION: CALLS -> self.network.request
# @PARAM: database_id (int) - ID базы данных.
# @PRE: database_id должен быть положительным целым числом.
# @POST: Возвращает словарь с информацией о базе данных.
# @THROW: APIError - В случае ошибки сетевого запроса или если база данных не найдена.
# @RETURN: Dict - Информация о базе данных.
def get_database(self, database_id: int) -> Dict:
with belief_scope("get_database"):
assert isinstance(database_id, int) and database_id > 0, "[get_database][PRE] database_id must be a positive integer."
self.logger.info("[get_database][Enter] Fetching database %s.", database_id)
response = self.network.request(method="GET", endpoint=f"/database/{database_id}")
response = cast(Dict, response)
self.logger.info("[get_database][Exit] Got database %s.", database_id)
return response
# [/DEF:get_database:Function]
# [DEF:update_dataset:Function]
# @PURPOSE: Обновляет данные датасета по его ID.
# @RELATION: CALLS -> self.network.request
# @PARAM: dataset_id (int) - ID датасета.
# @PARAM: data (Dict) - Данные для обновления.
# @PRE: dataset_id должен быть положительным целым числом, data - непустым словарем.
# @POST: Датасет успешно обновлен, возвращен ответ API.
# @THROW: APIError - В случае ошибки сетевого запроса.
# @RETURN: Dict - Ответ API.
def update_dataset(self, dataset_id: int, data: Dict) -> Dict:
with belief_scope("update_dataset"):
assert isinstance(dataset_id, int) and dataset_id > 0, "[update_dataset][PRE] dataset_id must be a positive integer."
assert isinstance(data, dict) and data, "[update_dataset][PRE] data must be a non-empty dictionary."
self.logger.info("[update_dataset][Enter] Updating dataset %s.", dataset_id)
response = self.network.request(
method="PUT",
endpoint=f"/dataset/{dataset_id}",
data=json.dumps(data),
headers={'Content-Type': 'application/json'}
)
response = cast(Dict, response)
self.logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id)
return response
# [/DEF:update_dataset:Function]
# [/DEF:SupersetClient:Class]
# [/DEF:superset_tool.client:Module]

View File

@@ -1,173 +0,0 @@
# [DEF:superset_tool.exceptions:Module]
# @PURPOSE: Определяет иерархию пользовательских исключений для всего инструмента, обеспечивая единую точку обработки ошибок.
# @SEMANTICS: exception, error, hierarchy
# @LAYER: Infra
# [SECTION: IMPORTS]
from pathlib import Path
from typing import Optional, Dict, Any, Union
# [/SECTION]
# [DEF:SupersetToolError:Class]
# @PURPOSE: Базовый класс для всех ошибок, генерируемых инструментом.
# @RELATION: INHERITS_FROM -> Exception
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Optional[Dict[str, Any]]) - Дополнительный контекст ошибки.
class SupersetToolError(Exception):
# [DEF:__init__:Function]
# @PURPOSE: Initializes the base tool error.
# @PRE: message is a string, context is optional dict.
# @POST: Error is initialized with combined message and context.
def __init__(self, message: str, context: Optional[Dict[str, Any]] = None):
self.context = context or {}
super().__init__(f"{message} | Context: {self.context}")
# [/DEF:__init__:Function]
# [/DEF:SupersetToolError:Class]
# [DEF:AuthenticationError:Class]
# @PURPOSE: Ошибки, связанные с аутентификацией или авторизацией.
# @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class AuthenticationError(SupersetToolError):
# [DEF:__init__:Function]
# @PURPOSE: Initializes an authentication error.
# @PRE: Optional message and context.
# @POST: Error is initialized with authentication context.
def __init__(self, message: str = "Authentication failed", **context: Any):
super().__init__(f"[AUTH_FAILURE] {message}", context={"type": "authentication", **context})
# [/DEF:__init__:Function]
# [/DEF:AuthenticationError:Class]
# [DEF:PermissionDeniedError:Class]
# @PURPOSE: Ошибка, возникающая при отказе в доступе к ресурсу.
# @RELATION: INHERITS_FROM -> AuthenticationError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: required_permission (Optional[str]) - Требуемое разрешение.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class PermissionDeniedError(AuthenticationError):
# [DEF:__init__:Function]
# @PURPOSE: Initializes a permission denied error.
# @PRE: Optional message, permission string, and context.
# @POST: Error is initialized with permission details.
def __init__(self, message: str = "Permission denied", required_permission: Optional[str] = None, **context: Any):
full_message = f"Permission denied: {required_permission}" if required_permission else message
super().__init__(full_message, context={"required_permission": required_permission, **context})
# [/DEF:__init__:Function]
# [/DEF:PermissionDeniedError:Class]
# [DEF:SupersetAPIError:Class]
# @PURPOSE: Общие ошибки при взаимодействии с Superset API.
# @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class SupersetAPIError(SupersetToolError):
# [DEF:__init__:Function]
# @PURPOSE: Initializes a Superset API error.
# @PRE: Optional message and context.
# @POST: Error is initialized with API failure context.
def __init__(self, message: str = "Superset API error", **context: Any):
super().__init__(f"[API_FAILURE] {message}", context={"type": "api_call", **context})
# [/DEF:__init__:Function]
# [/DEF:SupersetAPIError:Class]
# [DEF:ExportError:Class]
# @PURPOSE: Ошибки, специфичные для операций экспорта.
# @RELATION: INHERITS_FROM -> SupersetAPIError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class ExportError(SupersetAPIError):
# [DEF:__init__:Function]
# @PURPOSE: Initializes an export error.
# @PRE: Optional message and context.
# @POST: Error is initialized with export failure subtype.
def __init__(self, message: str = "Dashboard export failed", **context: Any):
super().__init__(f"[EXPORT_FAILURE] {message}", context={"subtype": "export", **context})
# [/DEF:__init__:Function]
# [/DEF:ExportError:Class]
# [DEF:DashboardNotFoundError:Class]
# @PURPOSE: Ошибка, когда запрошенный дашборд или ресурс не найден (404).
# @RELATION: INHERITS_FROM -> SupersetAPIError
# @PARAM: dashboard_id_or_slug (Union[int, str]) - ID или slug дашборда.
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class DashboardNotFoundError(SupersetAPIError):
# [DEF:__init__:Function]
# @PURPOSE: Initializes a dashboard not found error.
# @PRE: dashboard_id_or_slug is provided.
# @POST: Error is initialized with resource identification.
def __init__(self, dashboard_id_or_slug: Union[int, str], message: str = "Dashboard not found", **context: Any):
super().__init__(f"[NOT_FOUND] Dashboard '{dashboard_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dashboard_id_or_slug, **context})
# [/DEF:__init__:Function]
# [/DEF:DashboardNotFoundError:Class]
# [DEF:DatasetNotFoundError:Class]
# @PURPOSE: Ошибка, когда запрашиваемый набор данных не существует (404).
# @RELATION: INHERITS_FROM -> SupersetAPIError
# @PARAM: dataset_id_or_slug (Union[int, str]) - ID или slug набора данных.
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class DatasetNotFoundError(SupersetAPIError):
# [DEF:__init__:Function]
# @PURPOSE: Initializes a dataset not found error.
# @PRE: dataset_id_or_slug is provided.
# @POST: Error is initialized with resource identification.
def __init__(self, dataset_id_or_slug: Union[int, str], message: str = "Dataset not found", **context: Any):
super().__init__(f"[NOT_FOUND] Dataset '{dataset_id_or_slug}' {message}", context={"subtype": "not_found", "resource_id": dataset_id_or_slug, **context})
# [/DEF:__init__:Function]
# [/DEF:DatasetNotFoundError:Class]
# [DEF:InvalidZipFormatError:Class]
# @PURPOSE: Ошибка, указывающая на некорректный формат или содержимое ZIP-архива.
# @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: file_path (Optional[Union[str, Path]]) - Путь к файлу.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class InvalidZipFormatError(SupersetToolError):
# [DEF:__init__:Function]
# @PURPOSE: Initializes an invalid ZIP format error.
# @PRE: Optional message, file path, and context.
# @POST: Error is initialized with file validation context.
def __init__(self, message: str = "Invalid ZIP format or content", file_path: Optional[Union[str, Path]] = None, **context: Any):
super().__init__(f"[FILE_ERROR] {message}", context={"type": "file_validation", "file_path": str(file_path) if file_path else "N/A", **context})
# [/DEF:__init__:Function]
# [/DEF:InvalidZipFormatError:Class]
# [DEF:NetworkError:Class]
# @PURPOSE: Ошибки, связанные с сетевым соединением.
# @RELATION: INHERITS_FROM -> SupersetToolError
# @PARAM: message (str) - Сообщение об ошибке.
# @PARAM: context (Any) - Дополнительный контекст ошибки.
class NetworkError(SupersetToolError):
# [DEF:__init__:Function]
# @PURPOSE: Initializes a network error.
# @PRE: Optional message and context.
# @POST: Error is initialized with network failure context.
def __init__(self, message: str = "Network connection failed", **context: Any):
super().__init__(f"[NETWORK_FAILURE] {message}", context={"type": "network", **context})
# [/DEF:__init__:Function]
# [/DEF:NetworkError:Class]
# [DEF:FileOperationError:Class]
# @PURPOSE: Общие ошибки файловых операций (I/O).
# @RELATION: INHERITS_FROM -> SupersetToolError
class FileOperationError(SupersetToolError):
pass
# [/DEF:FileOperationError:Class]
# [DEF:InvalidFileStructureError:Class]
# @PURPOSE: Ошибка, указывающая на некорректную структуру файлов или директорий.
# @RELATION: INHERITS_FROM -> FileOperationError
class InvalidFileStructureError(FileOperationError):
pass
# [/DEF:InvalidFileStructureError:Class]
# [DEF:ConfigurationError:Class]
# @PURPOSE: Ошибки, связанные с неверной конфигурацией инструмента.
# @RELATION: INHERITS_FROM -> SupersetToolError
class ConfigurationError(SupersetToolError):
pass
# [/DEF:ConfigurationError:Class]
# [/DEF:superset_tool.exceptions:Module]

View File

@@ -1,87 +0,0 @@
# [DEF:superset_tool.models:Module]
#
# @SEMANTICS: pydantic, model, config, validation, data-structure
# @PURPOSE: Определяет Pydantic-модели для конфигурации инструмента, обеспечивая валидацию данных.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> pydantic
# @RELATION: DEPENDS_ON -> superset_tool.utils.logger
# @PUBLIC_API: SupersetConfig, DatabaseConfig
# [SECTION: IMPORTS]
import re
from typing import Optional, Dict, Any
from pydantic import BaseModel, validator, Field
from .utils.logger import SupersetLogger
# [/SECTION]
# [DEF:SupersetConfig:Class]
# @PURPOSE: Модель конфигурации для подключения к одному экземпляру Superset API.
# @RELATION: INHERITS_FROM -> pydantic.BaseModel
class SupersetConfig(BaseModel):
env: str = Field(..., description="Название окружения (например, dev, prod).")
base_url: str = Field(..., description="Базовый URL Superset API, включая /api/v1.")
auth: Dict[str, Any] = Field(..., description="Словарь с данными для аутентификации (provider, username, password, refresh).")
verify_ssl: bool = Field(True, description="Флаг для проверки SSL-сертификатов.")
timeout: int = Field(30, description="Таймаут в секундах для HTTP-запросов.")
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.")
# [DEF:validate_auth:Function]
# @PURPOSE: Проверяет, что словарь `auth` содержит все необходимые для аутентификации поля.
# @PRE: `v` должен быть словарем.
# @POST: Возвращает `v`, если все обязательные поля (`provider`, `username`, `password`, `refresh`) присутствуют.
# @THROW: ValueError - Если отсутствуют обязательные поля.
# @PARAM: v (Dict[str, str]) - Значение поля auth.
@validator('auth')
def validate_auth(cls, v: Dict[str, Any]) -> Dict[str, Any]:
required = {'provider', 'username', 'password', 'refresh'}
if not required.issubset(v.keys()):
raise ValueError(f"Словарь 'auth' должен содержать поля: {required}. Отсутствующие: {required - v.keys()}")
return v
# [/DEF:validate_auth:Function]
# [DEF:normalize_base_url:Function]
# @PURPOSE: Нормализует `base_url`, добавляя `/api/v1`, если он отсутствует.
# @PRE: `v` должна быть строкой.
# @POST: Возвращает нормализованный `v`.
# @THROW: ValueError - Если формат URL невалиден.
# @PARAM: v (str) - Значение поля base_url.
@validator('base_url')
def normalize_base_url(cls, v: str) -> str:
v = v.strip()
if not v.startswith(('http://', 'https://')):
raise ValueError(f"Invalid URL scheme: {v}. Must start with http:// or https://")
if '/api/v1' not in v:
v = f"{v.rstrip('/')}/api/v1"
return v
# [/DEF:normalize_base_url:Function]
class Config:
arbitrary_types_allowed = True
# [/DEF:SupersetConfig:Class]
# [DEF:DatabaseConfig:Class]
# @PURPOSE: Модель для параметров трансформации баз данных при миграции дашбордов.
# @RELATION: INHERITS_FROM -> pydantic.BaseModel
class DatabaseConfig(BaseModel):
database_config: Dict[str, Dict[str, Any]] = Field(..., description="Словарь, содержащий 'old' и 'new' конфигурации базы данных.")
logger: Optional[SupersetLogger] = Field(None, description="Экземпляр логгера для логирования.")
# [DEF:validate_config:Function]
# @PURPOSE: Проверяет, что словарь `database_config` содержит ключи 'old' и 'new'.
# @PRE: `v` должен быть словарем.
# @POST: Возвращает `v`, если ключи 'old' и 'new' присутствуют.
# @THROW: ValueError - Если отсутствуют обязательные ключи.
# @PARAM: v (Dict[str, Dict[str, Any]]) - Значение поля database_config.
@validator('database_config')
def validate_config(cls, v: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
if not {'old', 'new'}.issubset(v.keys()):
raise ValueError("'database_config' должен содержать ключи 'old' и 'new'.")
return v
# [/DEF:validate_config:Function]
class Config:
arbitrary_types_allowed = True
# [/DEF:DatabaseConfig:Class]
# [/DEF:superset_tool.models:Module]

View File

@@ -1,5 +0,0 @@
# [DEF:superset_tool.utils:Module]
# @SEMANTICS: package, utils
# @PURPOSE: Utility package for superset_tool.
# @LAYER: Infra
# [/DEF:superset_tool.utils:Module]

View File

@@ -1,110 +0,0 @@
# [DEF:superset_tool.utils.init_clients:Module]
#
# @SEMANTICS: utility, factory, client, initialization, configuration
# @PURPOSE: Централизованно инициализирует клиенты Superset для различных окружений (DEV, PROD, SBX, PREPROD), используя `keyring` для безопасного доступа к паролям.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> superset_tool.models
# @RELATION: DEPENDS_ON -> superset_tool.client
# @RELATION: DEPENDS_ON -> keyring
# @PUBLIC_API: setup_clients
# [SECTION: IMPORTS]
import keyring
import os
from typing import Dict, List, Optional, Any
from superset_tool.models import SupersetConfig
from superset_tool.client import SupersetClient
from superset_tool.utils.logger import SupersetLogger
# [/SECTION]
# [DEF:setup_clients:Function]
# @PURPOSE: Инициализирует и возвращает словарь клиентов `SupersetClient`.
# @PRE: `logger` должен быть валидным экземпляром `SupersetLogger`.
# @POST: Возвращает словарь с инициализированными клиентами.
# @THROW: Exception - При любых других ошибках инициализации.
# @RELATION: CREATES_INSTANCE_OF -> SupersetConfig
# @RELATION: CREATES_INSTANCE_OF -> SupersetClient
# @PARAM: logger (SupersetLogger) - Экземпляр логгера для записи процесса.
# @PARAM: custom_envs (List[Dict[str, Any]]) - Список пользовательских настроек окружений.
# @RETURN: Dict[str, SupersetClient] - Словарь, где ключ - имя окружения, значение - `SupersetClient`.
def setup_clients(logger: SupersetLogger, custom_envs: Optional[List[Any]] = None) -> Dict[str, SupersetClient]:
logger.info("[setup_clients][Enter] Starting Superset clients initialization.")
clients = {}
try:
# Try to load from ConfigManager if available
try:
from backend.src.dependencies import get_config_manager
config_manager = get_config_manager()
envs = config_manager.get_environments()
if envs:
logger.info("[setup_clients][Action] Loading environments from ConfigManager")
for env in envs:
logger.debug("[setup_clients][State] Creating config for environment: %s", env.name)
config = SupersetConfig(
env=env.name,
base_url=env.url,
auth={"provider": "db", "username": env.username, "password": env.password, "refresh": "true"},
verify_ssl=False,
timeout=30,
logger=logger
)
clients[env.name] = SupersetClient(config, logger)
return clients
except (ImportError, Exception) as e:
logger.debug(f"[setup_clients][State] ConfigManager not available or failed: {e}")
if custom_envs:
for env in custom_envs:
# Handle both dict and object (like Pydantic model)
env_name = str(getattr(env, 'name', env.get('name') if isinstance(env, dict) else "unknown"))
base_url = str(getattr(env, 'url', env.get('url') if isinstance(env, dict) else ""))
username = str(getattr(env, 'username', env.get('username') if isinstance(env, dict) else ""))
password = str(getattr(env, 'password', env.get('password') if isinstance(env, dict) else ""))
logger.debug("[setup_clients][State] Creating config for custom environment: %s", env_name)
config = SupersetConfig(
env=env_name,
base_url=base_url,
auth={"provider": "db", "username": username, "password": password, "refresh": "true"},
verify_ssl=False,
timeout=30,
logger=logger
)
clients[env_name] = SupersetClient(config, logger)
else:
# Fallback to hardcoded environments with keyring
environments = {
"dev": "https://devta.bi.dwh.rusal.com/api/v1",
"prod": "https://prodta.bi.dwh.rusal.com/api/v1",
"sbx": "https://sandboxta.bi.dwh.rusal.com/api/v1",
"preprod": "https://preprodta.bi.dwh.rusal.com/api/v1",
"uatta": "https://uatta.bi.dwh.rusal.com/api/v1",
"dev5":"https://dev.bi.dwh.rusal.com/api/v1"
}
for env_name, base_url in environments.items():
logger.debug("[setup_clients][State] Creating config for environment: %s", env_name.upper())
password = keyring.get_password("system", f"{env_name} migrate")
if not password:
logger.warning(f"Пароль для '{env_name} migrate' не найден в keyring. Пропускаем.")
continue
config = SupersetConfig(
env=env_name,
base_url=base_url,
auth={"provider": "db", "username": "migrate_user", "password": password, "refresh": "true"},
verify_ssl=False,
timeout=30,
logger=logger
)
clients[env_name] = SupersetClient(config, logger)
logger.info("[setup_clients][Exit] All clients (%s) initialized successfully.", ', '.join(clients.keys()))
return clients
except Exception as e:
logger.critical("[setup_clients][Failure] Critical error during client initialization: %s", e, exc_info=True)
raise
# [/DEF:setup_clients:Function]
# [/DEF:superset_tool.utils.init_clients:Module]

View File

@@ -1,156 +0,0 @@
# [DEF:superset_tool.utils.logger:Module]
#
# @SEMANTICS: logging, utility, infrastructure, wrapper
# @PURPOSE: Предоставляет универсальную обёртку над стандартным `logging.Logger` для унифицированного создания и управления логгерами с выводом в консоль и/или файл.
# @LAYER: Infra
# @RELATION: WRAPS -> logging.Logger
#
# @INVARIANT: Логгер всегда должен иметь имя.
# @PUBLIC_API: SupersetLogger
# [SECTION: IMPORTS]
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional, Any, Mapping, Generator
from contextlib import contextmanager
# [/SECTION]
# [DEF:belief_scope:Function]
# @PURPOSE: Context manager for belief state logging to maintain execution coherence.
# @PRE: scope_id must be a string.
# @POST: Entry and exit actions are logged.
# @PARAM: scope_id (str) - Identifier for the logical scope.
@contextmanager
def belief_scope(scope_id: str) -> Generator[None, None, None]:
"""Context manager for belief state logging."""
logger = logging.getLogger("superset_tool")
logger.debug(f"[BELIEF_ENTRY] {scope_id}")
try:
yield
finally:
logger.debug(f"[BELIEF_EXIT] {scope_id}")
# [/DEF:belief_scope:Function]
# [DEF:SupersetLogger:Class]
# @PURPOSE: Обёртка над `logging.Logger`, которая упрощает конфигурацию и использование логгеров.
# @RELATION: WRAPS -> logging.Logger
class SupersetLogger:
# [DEF:__init__:Function]
# @PURPOSE: Конфигурирует и инициализирует логгер, добавляя обработчики для файла и/или консоли.
# @PRE: Если log_dir указан, путь должен быть валидным (или создаваемым).
# @POST: `self.logger` готов к использованию с настроенными обработчиками.
# @PARAM: name (str) - Идентификатор логгера.
# @PARAM: log_dir (Optional[Path]) - Директория для сохранения лог-файлов.
# @PARAM: level (int) - Уровень логирования (e.g., `logging.INFO`).
# @PARAM: console (bool) - Флаг для включения вывода в консоль.
def __init__(self, name: str = "superset_tool", log_dir: Optional[Path] = None, level: int = logging.INFO, console: bool = True, logger: Optional[logging.Logger] = None) -> None:
with belief_scope("__init__"):
if logger:
self.logger = logger
return
self.logger = logging.getLogger(name)
self.logger.setLevel(level)
self.logger.propagate = False
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
if self.logger.hasHandlers():
self.logger.handlers.clear()
if log_dir:
log_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d")
file_handler = logging.FileHandler(log_dir / f"{name}_{timestamp}.log", encoding="utf-8")
file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
if console:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
# [/DEF:__init__:Function]
# [DEF:_log:Function]
# @PURPOSE: (Helper) Универсальный метод для вызова соответствующего уровня логирования.
# @PRE: level_method должен быть вызываемым методом логгера. msg must be a string.
# @POST: Сообщение записано в лог.
# @PARAM: level_method (Any) - Метод логгера (info, debug, etc).
# @PARAM: msg (str) - Сообщение.
# @PARAM: args (Any) - Аргументы форматирования.
# @PARAM: extra (Optional[Mapping[str, Any]]) - Дополнительные данные.
# @PARAM: exc_info (bool) - Добавлять ли информацию об исключении.
def _log(self, level_method: Any, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
with belief_scope("_log"):
level_method(msg, *args, extra=extra, exc_info=exc_info)
# [/DEF:_log:Function]
# [DEF:info:Function]
# @PURPOSE: Записывает сообщение уровня INFO.
# @PRE: msg должен быть строкой.
# @POST: Сообщение уровня INFO записано.
def info(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
with belief_scope("info"):
self._log(self.logger.info, msg, *args, extra=extra, exc_info=exc_info)
# [/DEF:info:Function]
# [DEF:debug:Function]
# @PURPOSE: Записывает сообщение уровня DEBUG.
# @PRE: msg должен быть строкой.
# @POST: Сообщение уровня DEBUG записано.
def debug(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
with belief_scope("debug"):
self._log(self.logger.debug, msg, *args, extra=extra, exc_info=exc_info)
# [/DEF:debug:Function]
# [DEF:warning:Function]
# @PURPOSE: Записывает сообщение уровня WARNING.
# @PRE: msg должен быть строкой.
# @POST: Сообщение уровня WARNING записано.
def warning(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
with belief_scope("warning"):
self._log(self.logger.warning, msg, *args, extra=extra, exc_info=exc_info)
# [/DEF:warning:Function]
# [DEF:error:Function]
# @PURPOSE: Записывает сообщение уровня ERROR.
# @PRE: msg должен быть строкой.
# @POST: Сообщение уровня ERROR записано.
def error(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
with belief_scope("error"):
self._log(self.logger.error, msg, *args, extra=extra, exc_info=exc_info)
# [/DEF:error:Function]
# [DEF:critical:Function]
# @PURPOSE: Записывает сообщение уровня CRITICAL.
# @PRE: msg должен быть строкой.
# @POST: Сообщение уровня CRITICAL записано.
def critical(self, msg: str, *args: Any, extra: Optional[Mapping[str, Any]] = None, exc_info: bool = False) -> None:
with belief_scope("critical"):
self._log(self.logger.critical, msg, *args, extra=extra, exc_info=exc_info)
# [/DEF:critical:Function]
# [DEF:exception:Function]
# @PURPOSE: Записывает сообщение уровня ERROR вместе с трассировкой стека текущего исключения.
# @PRE: msg должен быть строкой.
# @POST: Сообщение об ошибке с traceback записано.
def exception(self, msg: str, *args: Any, **kwargs: Any) -> None:
with belief_scope("exception"):
self.logger.exception(msg, *args, **kwargs)
# [/DEF:exception:Function]
# [DEF:belief_scope:Method]
# @PURPOSE: Instance method wrapper for belief_scope context manager.
# @PRE: scope_id must be a string.
# @POST: Enters the belief scope.
@contextmanager
def belief_scope(self, scope_id: str) -> Generator[None, None, None]:
with belief_scope(scope_id):
yield
# [/DEF:belief_scope:Method]
# [/DEF:SupersetLogger:Class]
# [/DEF:superset_tool.utils.logger:Module]

View File

@@ -1,157 +0,0 @@
# [DEF:superset_tool.utils.whiptail_fallback:Module]
#
# @SEMANTICS: ui, fallback, console, utility, interactive
# @PURPOSE: Предоставляет плотный консольный UI-fallback для интерактивных диалогов, имитируя `whiptail` для систем, где он недоступен.
# @LAYER: UI
# @PUBLIC_API: menu, checklist, yesno, msgbox, inputbox, gauge
# [SECTION: IMPORTS]
import sys
from typing import List, Tuple, Optional, Any
from .logger import belief_scope
# [/SECTION]
# [DEF:menu:Function]
# @PURPOSE: Отображает меню выбора и возвращает выбранный элемент.
# @PARAM: title (str) - Заголовок меню.
# @PARAM: prompt (str) - Приглашение к вводу.
# @PARAM: choices (List[str]) - Список вариантов для выбора.
# @PRE: choices must be a non-empty list of strings.
# @POST: Returns a tuple with return code and selected choice.
# @RETURN: Tuple[int, Optional[str]] - Кортеж (код возврата, выбранный элемент). rc=0 - успех.
def menu(title: str, prompt: str, choices: List[str], **kwargs) -> Tuple[int, Optional[str]]:
with belief_scope("menu"):
print(f"\n=== {title} ===\n{prompt}")
for idx, item in enumerate(choices, 1):
print(f"{idx}) {item}")
try:
raw = input("\nВведите номер (0 отмена): ").strip()
sel = int(raw)
return (0, choices[sel - 1]) if 0 < sel <= len(choices) else (1, None)
except (ValueError, IndexError):
return 1, None
# [/DEF:menu:Function]
# [DEF:checklist:Function]
# @PURPOSE: Отображает список с возможностью множественного выбора.
# @PARAM: title (str) - Заголовок.
# @PARAM: prompt (str) - Приглашение к вводу.
# @PARAM: options (List[Tuple[str, str]]) - Список кортежей (значение, метка).
# @PRE: options must be a list of (value, label) tuples.
# @POST: Returns a list of selected values.
# @RETURN: Tuple[int, List[str]] - Кортеж (код возврата, список выбранных значений).
def checklist(title: str, prompt: str, options: List[Tuple[str, str]], **kwargs) -> Tuple[int, List[str]]:
with belief_scope("checklist"):
print(f"\n=== {title} ===\n{prompt}")
for idx, (val, label) in enumerate(options, 1):
print(f"{idx}) [{val}] {label}")
raw = input("\nВведите номера через запятую (пустой ввод → отказ): ").strip()
if not raw: return 1, []
try:
indices = {int(x.strip()) for x in raw.split(",") if x.strip()}
selected_values = [options[i - 1][0] for i in indices if 0 < i <= len(options)]
return 0, selected_values
except (ValueError, IndexError):
return 1, []
# [/DEF:checklist:Function]
# [DEF:yesno:Function]
# @PURPOSE: Задает вопрос с ответом да/нет.
# @PARAM: title (str) - Заголовок.
# @PARAM: question (str) - Вопрос для пользователя.
# @PRE: question must be a string.
# @POST: Returns boolean based on user input.
# @RETURN: bool - `True`, если пользователь ответил "да".
def yesno(title: str, question: str, **kwargs) -> bool:
with belief_scope("yesno"):
ans = input(f"\n=== {title} ===\n{question} (y/n): ").strip().lower()
return ans in ("y", "yes", "да", "д")
# [/DEF:yesno:Function]
# [DEF:msgbox:Function]
# @PURPOSE: Отображает информационное сообщение.
# @PARAM: title (str) - Заголовок.
# @PARAM: msg (str) - Текст сообщения.
# @PRE: msg must be a string.
# @POST: Message is printed to console.
def msgbox(title: str, msg: str, **kwargs) -> None:
with belief_scope("msgbox"):
print(f"\n=== {title} ===\n{msg}\n")
# [/DEF:msgbox:Function]
# [DEF:inputbox:Function]
# @PURPOSE: Запрашивает у пользователя текстовый ввод.
# @PARAM: title (str) - Заголовок.
# @PARAM: prompt (str) - Приглашение к вводу.
# @PRE: prompt must be a string.
# @POST: Returns user input string.
# @RETURN: Tuple[int, Optional[str]] - Кортеж (код возврата, введенная строка).
def inputbox(title: str, prompt: str, **kwargs) -> Tuple[int, Optional[str]]:
with belief_scope("inputbox"):
print(f"\n=== {title} ===")
val = input(f"{prompt}\n")
return (0, val) if val else (1, None)
# [/DEF:inputbox:Function]
# [DEF:_ConsoleGauge:Class]
# @PURPOSE: Контекстный менеджер для имитации `whiptail gauge` в консоли.
class _ConsoleGauge:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the gauge.
# @PRE: title must be a string.
# @POST: Instance initialized.
def __init__(self, title: str, **kwargs):
with belief_scope("__init__"):
self.title = title
# [/DEF:__init__:Function]
# [DEF:__enter__:Function]
# @PURPOSE: Enters the context.
# @PRE: Instance initialized.
# @POST: Header printed, returns self.
def __enter__(self):
with belief_scope("__enter__"):
print(f"\n=== {self.title} ===")
return self
# [/DEF:__enter__:Function]
# [DEF:__exit__:Function]
# @PURPOSE: Exits the context.
# @PRE: Context entered.
# @POST: Newline printed.
def __exit__(self, exc_type, exc_val, exc_tb):
with belief_scope("__exit__"):
sys.stdout.write("\n"); sys.stdout.flush()
# [/DEF:__exit__:Function]
# [DEF:set_text:Function]
# @PURPOSE: Sets the gauge text.
# @PRE: txt must be a string.
# @POST: Text written to stdout.
def set_text(self, txt: str) -> None:
with belief_scope("set_text"):
sys.stdout.write(f"\r{txt} "); sys.stdout.flush()
# [/DEF:set_text:Function]
# [DEF:set_percent:Function]
# @PURPOSE: Sets the gauge percentage.
# @PRE: percent must be an integer.
# @POST: Percentage written to stdout.
def set_percent(self, percent: int) -> None:
with belief_scope("set_percent"):
sys.stdout.write(f"{percent}%"); sys.stdout.flush()
# [/DEF:set_percent:Function]
# [/DEF:_ConsoleGauge:Class]
# [DEF:gauge:Function]
# @PURPOSE: Создает и возвращает экземпляр `_ConsoleGauge`.
# @PRE: title must be a string.
# @POST: Returns an instance of _ConsoleGauge.
# @PARAM: title (str) - Заголовок для индикатора прогресса.
# @RETURN: _ConsoleGauge - Экземпляр контекстного менеджера.
def gauge(title: str, **kwargs) -> _ConsoleGauge:
with belief_scope("gauge"):
return _ConsoleGauge(title, **kwargs)
# [/DEF:gauge:Function]
# [/DEF:superset_tool.utils.whiptail_fallback:Module]