diff --git a/backend/delete_running_tasks.py b/backend/delete_running_tasks.py index 927bc7a..cfe5016 100644 --- a/backend/delete_running_tasks.py +++ b/backend/delete_running_tasks.py @@ -1,10 +1,17 @@ #!/usr/bin/env python3 -"""Script to delete tasks with RUNNING status from the database.""" +# [DEF:backend.delete_running_tasks:Module] +# @PURPOSE: Script to delete tasks with RUNNING status from the database. +# @LAYER: Utility +# @SEMANTICS: maintenance, database, cleanup from sqlalchemy.orm import Session from src.core.database import TasksSessionLocal from src.models.task import TaskRecord +# [DEF:delete_running_tasks:Function] +# @PURPOSE: Delete all tasks with RUNNING status from the database. +# @PRE: Database is accessible and TaskRecord model is defined. +# @POST: All tasks with status 'RUNNING' are removed from the database. def delete_running_tasks(): """Delete all tasks with RUNNING status from the database.""" session: Session = TasksSessionLocal() @@ -30,6 +37,8 @@ def delete_running_tasks(): print(f"Error deleting tasks: {e}") finally: session.close() +# [/DEF:delete_running_tasks:Function] if __name__ == "__main__": delete_running_tasks() +# [/DEF:backend.delete_running_tasks:Module] diff --git a/backend/src/api/routes/git.py b/backend/src/api/routes/git.py index dc28ea9..361e451 100644 --- a/backend/src/api/routes/git.py +++ b/backend/src/api/routes/git.py @@ -30,6 +30,8 @@ git_service = GitService() # [DEF:get_git_configs:Function] # @PURPOSE: List all configured Git servers. +# @PRE: Database session `db` is available. +# @POST: Returns a list of all GitServerConfig objects from the database. # @RETURN: List[GitServerConfigSchema] @router.get("/config", response_model=List[GitServerConfigSchema]) async def get_git_configs(db: Session = Depends(get_db)): @@ -39,6 +41,8 @@ async def get_git_configs(db: Session = Depends(get_db)): # [DEF:create_git_config:Function] # @PURPOSE: Register a new Git server configuration. +# @PRE: `config` contains valid GitServerConfigCreate data. +# @POST: A new GitServerConfig record is created in the database. # @PARAM: config (GitServerConfigCreate) # @RETURN: GitServerConfigSchema @router.post("/config", response_model=GitServerConfigSchema) @@ -53,6 +57,8 @@ async def create_git_config(config: GitServerConfigCreate, db: Session = Depends # [DEF:delete_git_config:Function] # @PURPOSE: Remove a Git server configuration. +# @PRE: `config_id` corresponds to an existing configuration. +# @POST: The configuration record is removed from the database. # @PARAM: config_id (str) @router.delete("/config/{config_id}") async def delete_git_config(config_id: str, db: Session = Depends(get_db)): @@ -68,6 +74,8 @@ async def delete_git_config(config_id: str, db: Session = Depends(get_db)): # [DEF:test_git_config:Function] # @PURPOSE: Validate connection to a Git server using provided credentials. +# @PRE: `config` contains provider, url, and pat. +# @POST: Returns success if the connection is validated via GitService. # @PARAM: config (GitServerConfigCreate) @router.post("/config/test") async def test_git_config(config: GitServerConfigCreate): @@ -81,6 +89,8 @@ async def test_git_config(config: GitServerConfigCreate): # [DEF:init_repository:Function] # @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init. +# @PRE: `dashboard_id` exists and `init_data` contains valid config_id and remote_url. +# @POST: Repository is initialized on disk and a GitRepository record is saved in DB. # @PARAM: dashboard_id (int) # @PARAM: init_data (RepoInitRequest) @router.post("/repositories/{dashboard_id}/init") @@ -123,6 +133,8 @@ async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Ses # [DEF:get_branches:Function] # @PURPOSE: List all branches for a dashboard's repository. +# @PRE: Repository for `dashboard_id` is initialized. +# @POST: Returns a list of branches from the local repository. # @PARAM: dashboard_id (int) # @RETURN: List[BranchSchema] @router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema]) @@ -136,6 +148,8 @@ async def get_branches(dashboard_id: int): # [DEF:create_branch:Function] # @PURPOSE: Create a new branch in the dashboard's repository. +# @PRE: `dashboard_id` repository exists and `branch_data` has name and from_branch. +# @POST: A new branch is created in the local repository. # @PARAM: dashboard_id (int) # @PARAM: branch_data (BranchCreate) @router.post("/repositories/{dashboard_id}/branches") @@ -150,6 +164,8 @@ async def create_branch(dashboard_id: int, branch_data: BranchCreate): # [DEF:checkout_branch:Function] # @PURPOSE: Switch the dashboard's repository to a specific branch. +# @PRE: `dashboard_id` repository exists and branch `checkout_data.name` exists. +# @POST: The local repository HEAD is moved to the specified branch. # @PARAM: dashboard_id (int) # @PARAM: checkout_data (BranchCheckout) @router.post("/repositories/{dashboard_id}/checkout") @@ -164,6 +180,8 @@ async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout): # [DEF:commit_changes:Function] # @PURPOSE: Stage and commit changes in the dashboard's repository. +# @PRE: `dashboard_id` repository exists and `commit_data` has message and files. +# @POST: Specified files are staged and a new commit is created. # @PARAM: dashboard_id (int) # @PARAM: commit_data (CommitCreate) @router.post("/repositories/{dashboard_id}/commit") @@ -178,6 +196,8 @@ async def commit_changes(dashboard_id: int, commit_data: CommitCreate): # [DEF:push_changes:Function] # @PURPOSE: Push local commits to the remote repository. +# @PRE: `dashboard_id` repository exists and has a remote configured. +# @POST: Local commits are pushed to the remote repository. # @PARAM: dashboard_id (int) @router.post("/repositories/{dashboard_id}/push") async def push_changes(dashboard_id: int): @@ -191,6 +211,8 @@ async def push_changes(dashboard_id: int): # [DEF:pull_changes:Function] # @PURPOSE: Pull changes from the remote repository. +# @PRE: `dashboard_id` repository exists and has a remote configured. +# @POST: Remote changes are fetched and merged into the local branch. # @PARAM: dashboard_id (int) @router.post("/repositories/{dashboard_id}/pull") async def pull_changes(dashboard_id: int): @@ -204,6 +226,8 @@ async def pull_changes(dashboard_id: int): # [DEF:sync_dashboard:Function] # @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin. +# @PRE: `dashboard_id` is valid; GitPlugin is available. +# @POST: Dashboard YAMLs are exported from Superset and committed to Git. # @PARAM: dashboard_id (int) # @PARAM: source_env_id (Optional[str]) @router.post("/repositories/{dashboard_id}/sync") @@ -223,6 +247,8 @@ async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str] # [DEF:get_environments:Function] # @PURPOSE: List all deployment environments. +# @PRE: Config manager is accessible. +# @POST: Returns a list of DeploymentEnvironmentSchema objects. # @RETURN: List[DeploymentEnvironmentSchema] @router.get("/environments", response_model=List[DeploymentEnvironmentSchema]) async def get_environments(config_manager=Depends(get_config_manager)): @@ -240,6 +266,8 @@ async def get_environments(config_manager=Depends(get_config_manager)): # [DEF:deploy_dashboard:Function] # @PURPOSE: Deploy dashboard from Git to a target environment. +# @PRE: `dashboard_id` and `deploy_data.environment_id` are valid. +# @POST: Dashboard YAMLs are read from Git and imported into the target Superset. # @PARAM: dashboard_id (int) # @PARAM: deploy_data (DeployRequest) @router.post("/repositories/{dashboard_id}/deploy") @@ -259,6 +287,8 @@ async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest): # [DEF:get_history:Function] # @PURPOSE: View commit history for a dashboard's repository. +# @PRE: `dashboard_id` repository exists. +# @POST: Returns a list of recent commits from the repository. # @PARAM: dashboard_id (int) # @PARAM: limit (int) # @RETURN: List[CommitSchema] @@ -273,6 +303,8 @@ async def get_history(dashboard_id: int, limit: int = 50): # [DEF:get_repository_status:Function] # @PURPOSE: Get current Git status for a dashboard repository. +# @PRE: `dashboard_id` repository exists. +# @POST: Returns the status of the working directory (staged, unstaged, untracked). # @PARAM: dashboard_id (int) # @RETURN: dict @router.get("/repositories/{dashboard_id}/status") @@ -286,6 +318,8 @@ async def get_repository_status(dashboard_id: int): # [DEF:get_repository_diff:Function] # @PURPOSE: Get Git diff for a dashboard repository. +# @PRE: `dashboard_id` repository exists. +# @POST: Returns the diff text for the specified file or all changes. # @PARAM: dashboard_id (int) # @PARAM: file_path (Optional[str]) # @PARAM: staged (bool) diff --git a/backend/src/api/routes/git_schemas.py b/backend/src/api/routes/git_schemas.py index 9b362ae..b4fcdda 100644 --- a/backend/src/api/routes/git_schemas.py +++ b/backend/src/api/routes/git_schemas.py @@ -14,6 +14,7 @@ from uuid import UUID from src.models.git import GitProvider, GitStatus, SyncStatus # [DEF:GitServerConfigBase:Class] +# @PURPOSE: Base schema for Git server configuration attributes. class GitServerConfigBase(BaseModel): name: str = Field(..., description="Display name for the Git server") provider: GitProvider = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)") @@ -23,12 +24,14 @@ class GitServerConfigBase(BaseModel): # [/DEF:GitServerConfigBase:Class] # [DEF:GitServerConfigCreate:Class] +# @PURPOSE: Schema for creating a new Git server configuration. class GitServerConfigCreate(GitServerConfigBase): """Schema for creating a new Git server configuration.""" pass # [/DEF:GitServerConfigCreate:Class] # [DEF:GitServerConfigSchema:Class] +# @PURPOSE: Schema for representing a Git server configuration with metadata. class GitServerConfigSchema(GitServerConfigBase): """Schema for representing a Git server configuration with metadata.""" id: str @@ -40,6 +43,7 @@ class GitServerConfigSchema(GitServerConfigBase): # [/DEF:GitServerConfigSchema:Class] # [DEF:GitRepositorySchema:Class] +# @PURPOSE: Schema for tracking a local Git repository linked to a dashboard. class GitRepositorySchema(BaseModel): """Schema for tracking a local Git repository linked to a dashboard.""" id: str @@ -55,6 +59,7 @@ class GitRepositorySchema(BaseModel): # [/DEF:GitRepositorySchema:Class] # [DEF:BranchSchema:Class] +# @PURPOSE: Schema for representing a Git branch metadata. class BranchSchema(BaseModel): """Schema for representing a Git branch.""" name: str @@ -64,6 +69,7 @@ class BranchSchema(BaseModel): # [/DEF:BranchSchema:Class] # [DEF:CommitSchema:Class] +# @PURPOSE: Schema for representing Git commit details. class CommitSchema(BaseModel): """Schema for representing a Git commit.""" hash: str @@ -75,6 +81,7 @@ class CommitSchema(BaseModel): # [/DEF:CommitSchema:Class] # [DEF:BranchCreate:Class] +# @PURPOSE: Schema for branch creation requests. class BranchCreate(BaseModel): """Schema for branch creation requests.""" name: str @@ -82,12 +89,14 @@ class BranchCreate(BaseModel): # [/DEF:BranchCreate:Class] # [DEF:BranchCheckout:Class] +# @PURPOSE: Schema for branch checkout requests. class BranchCheckout(BaseModel): """Schema for branch checkout requests.""" name: str # [/DEF:BranchCheckout:Class] # [DEF:CommitCreate:Class] +# @PURPOSE: Schema for staging and committing changes. class CommitCreate(BaseModel): """Schema for staging and committing changes.""" message: str @@ -95,6 +104,7 @@ class CommitCreate(BaseModel): # [/DEF:CommitCreate:Class] # [DEF:ConflictResolution:Class] +# @PURPOSE: Schema for resolving merge conflicts. class ConflictResolution(BaseModel): """Schema for resolving merge conflicts.""" file_path: str @@ -103,6 +113,7 @@ class ConflictResolution(BaseModel): # [/DEF:ConflictResolution:Class] # [DEF:DeploymentEnvironmentSchema:Class] +# @PURPOSE: Schema for representing a target deployment environment. class DeploymentEnvironmentSchema(BaseModel): """Schema for representing a target deployment environment.""" id: str @@ -115,12 +126,14 @@ class DeploymentEnvironmentSchema(BaseModel): # [/DEF:DeploymentEnvironmentSchema:Class] # [DEF:DeployRequest:Class] +# @PURPOSE: Schema for dashboard deployment requests. class DeployRequest(BaseModel): """Schema for deployment requests.""" environment_id: str # [/DEF:DeployRequest:Class] # [DEF:RepoInitRequest:Class] +# @PURPOSE: Schema for repository initialization requests. class RepoInitRequest(BaseModel): """Schema for repository initialization requests.""" config_id: str diff --git a/backend/src/core/logger.py b/backend/src/core/logger.py index 8ff5d61..7177c7d 100755 --- a/backend/src/core/logger.py +++ b/backend/src/core/logger.py @@ -197,11 +197,17 @@ logger = logging.getLogger("superset_tools_app") # @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:Function] + # @PURPOSE: Internal decorator for belief scope. def decorator(func): + # [DEF:wrapper:Function] + # @PURPOSE: Internal wrapper that enters belief scope. def wrapper(*args, **kwargs): with belief_scope(anchor_id): return func(*args, **kwargs) + # [/DEF:wrapper:Function] return wrapper + # [/DEF:decorator:Function] return decorator # [/DEF:believed:Function] logger.setLevel(logging.INFO) diff --git a/backend/src/core/superset_client.py b/backend/src/core/superset_client.py index 62d17be..9e07395 100644 --- a/backend/src/core/superset_client.py +++ b/backend/src/core/superset_client.py @@ -65,6 +65,8 @@ class SupersetClient: @property # [DEF:headers:Function] # @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом. + # @PRE: APIClient is initialized and authenticated. + # @POST: Returns a dictionary of HTTP headers. def headers(self) -> dict: with belief_scope("headers"): return self.network.headers @@ -75,6 +77,8 @@ class SupersetClient: # [DEF:get_dashboards:Function] # @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию. # @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса для API. + # @PRE: Client is authenticated. + # @POST: Returns a tuple with total count and list of dashboards. # @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов). def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: with belief_scope("get_dashboards"): @@ -94,6 +98,8 @@ class SupersetClient: # [DEF:get_dashboards_summary:Function] # @PURPOSE: Fetches dashboard metadata optimized for the grid. + # @PRE: Client is authenticated. + # @POST: Returns a list of dashboard metadata summaries. # @RETURN: List[Dict] def get_dashboards_summary(self) -> List[Dict]: with belief_scope("SupersetClient.get_dashboards_summary"): @@ -117,6 +123,8 @@ class SupersetClient: # [DEF:export_dashboard:Function] # @PURPOSE: Экспортирует дашборд в виде ZIP-архива. # @PARAM: dashboard_id (int) - ID дашборда для экспорта. + # @PRE: dashboard_id must exist in Superset. + # @POST: Returns ZIP content and filename. # @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла. def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]: with belief_scope("export_dashboard"): @@ -140,6 +148,8 @@ class SupersetClient: # @PARAM: file_name (Union[str, Path]) - Путь к ZIP-архиву. # @PARAM: dash_id (Optional[int]) - ID дашборда для удаления при сбое. # @PARAM: dash_slug (Optional[str]) - Slug дашборда для поиска ID. + # @PRE: file_name must be a valid ZIP dashboard export. + # @POST: Dashboard is imported or re-imported after deletion. # @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"): @@ -165,6 +175,8 @@ class SupersetClient: # [DEF:delete_dashboard:Function] # @PURPOSE: Удаляет дашборд по его ID или slug. # @PARAM: dashboard_id (Union[int, str]) - ID или slug дашборда. + # @PRE: dashboard_id must exist. + # @POST: Dashboard is removed from Superset. 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) @@ -183,6 +195,8 @@ class SupersetClient: # [DEF:get_datasets:Function] # @PURPOSE: Получает полный список датасетов, автоматически обрабатывая пагинацию. # @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса. + # @PRE: Client is authenticated. + # @POST: Returns total count and list of datasets. # @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список датасетов). def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: with belief_scope("get_datasets"): @@ -201,6 +215,8 @@ class SupersetClient: # [DEF:get_dataset:Function] # @PURPOSE: Получает информацию о конкретном датасете по его ID. # @PARAM: dataset_id (int) - ID датасета. + # @PRE: dataset_id must exist. + # @POST: Returns dataset details. # @RETURN: Dict - Информация о датасете. def get_dataset(self, dataset_id: int) -> Dict: with belief_scope("SupersetClient.get_dataset", f"id={dataset_id}"): @@ -215,6 +231,8 @@ class SupersetClient: # @PURPOSE: Обновляет данные датасета по его ID. # @PARAM: dataset_id (int) - ID датасета. # @PARAM: data (Dict) - Данные для обновления. + # @PRE: dataset_id must exist. + # @POST: Dataset is updated in Superset. # @RETURN: Dict - Ответ API. def update_dataset(self, dataset_id: int, data: Dict) -> Dict: with belief_scope("SupersetClient.update_dataset", f"id={dataset_id}"): @@ -237,6 +255,8 @@ class SupersetClient: # [DEF:get_databases:Function] # @PURPOSE: Получает полный список баз данных. # @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса. + # @PRE: Client is authenticated. + # @POST: Returns total count and list of databases. # @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список баз данных). def get_databases(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]: with belief_scope("get_databases"): @@ -256,6 +276,8 @@ class SupersetClient: # [DEF:get_database:Function] # @PURPOSE: Получает информацию о конкретной базе данных по её ID. # @PARAM: database_id (int) - ID базы данных. + # @PRE: database_id must exist. + # @POST: Returns database details. # @RETURN: Dict - Информация о базе данных. def get_database(self, database_id: int) -> Dict: with belief_scope("get_database"): @@ -268,6 +290,8 @@ class SupersetClient: # [DEF:get_databases_summary:Function] # @PURPOSE: Fetch a summary of databases including uuid, name, and engine. + # @PRE: Client is authenticated. + # @POST: Returns list of database summaries. # @RETURN: List[Dict] - Summary of databases. def get_databases_summary(self) -> List[Dict]: with belief_scope("SupersetClient.get_databases_summary"): @@ -286,6 +310,8 @@ class SupersetClient: # [DEF:get_database_by_uuid:Function] # @PURPOSE: Find a database by its UUID. # @PARAM: db_uuid (str) - The UUID of the database. + # @PRE: db_uuid must be a valid UUID string. + # @POST: Returns database info or None. # @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}"): @@ -301,6 +327,9 @@ class SupersetClient: # [SECTION: HELPERS] # [DEF:_resolve_target_id_for_delete:Function] + # @PURPOSE: Resolves a dashboard ID from either an ID or a slug. + # @PRE: Either dash_id or dash_slug should be provided. + # @POST: Returns the resolved ID or 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"): if dash_id is not None: @@ -319,6 +348,9 @@ class SupersetClient: # [/DEF:_resolve_target_id_for_delete:Function] # [DEF:_do_import:Function] + # @PURPOSE: Performs the actual multipart upload for import. + # @PRE: file_name must be a path to an existing ZIP file. + # @POST: Returns the API response from the upload. 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}") @@ -336,6 +368,9 @@ class SupersetClient: # [/DEF:_do_import:Function] # [DEF:_validate_export_response:Function] + # @PURPOSE: Validates that the export response is a non-empty ZIP archive. + # @PRE: response must be a valid requests.Response object. + # @POST: Raises SupersetAPIError if validation fails. def _validate_export_response(self, response: Response, dashboard_id: int) -> None: with belief_scope("_validate_export_response"): content_type = response.headers.get("Content-Type", "") @@ -346,6 +381,9 @@ class SupersetClient: # [/DEF:_validate_export_response:Function] # [DEF:_resolve_export_filename:Function] + # @PURPOSE: Determines the filename for an exported dashboard. + # @PRE: response must contain Content-Disposition header or dashboard_id must be provided. + # @POST: Returns a sanitized filename string. 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)) @@ -358,6 +396,9 @@ class SupersetClient: # [/DEF:_resolve_export_filename:Function] # [DEF:_validate_query_params:Function] + # @PURPOSE: Ensures query parameters have default page and page_size. + # @PRE: query can be None or a dictionary. + # @POST: Returns a dictionary with at least page and page_size. def _validate_query_params(self, query: Optional[Dict]) -> Dict: with belief_scope("_validate_query_params"): base_query = {"page": 0, "page_size": 1000} @@ -365,6 +406,9 @@ class SupersetClient: # [/DEF:_validate_query_params:Function] # [DEF:_fetch_total_object_count:Function] + # @PURPOSE: Fetches the total number of items for a given endpoint. + # @PRE: endpoint must be a valid Superset API path. + # @POST: Returns the total count as an integer. def _fetch_total_object_count(self, endpoint: str) -> int: with belief_scope("_fetch_total_object_count"): return self.network.fetch_paginated_count( @@ -375,12 +419,18 @@ class SupersetClient: # [/DEF:_fetch_total_object_count:Function] # [DEF:_fetch_all_pages:Function] + # @PURPOSE: Iterates through all pages to collect all data items. + # @PRE: pagination_options must contain base_query, total_count, and results_field. + # @POST: Returns a combined list of all items. 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] + # @PURPOSE: Validates that the file to be imported is a valid ZIP with metadata.yaml. + # @PRE: zip_path must be a path to a file. + # @POST: Raises error if file is missing, not a ZIP, or missing metadata. def _validate_import_file(self, zip_path: Union[str, Path]) -> None: with belief_scope("_validate_import_file"): path = Path(zip_path) diff --git a/backend/src/core/utils/fileio.py b/backend/src/core/utils/fileio.py index 572ddec..bf35cc2 100644 --- a/backend/src/core/utils/fileio.py +++ b/backend/src/core/utils/fileio.py @@ -24,8 +24,10 @@ from ..logger import logger as app_logger, belief_scope # [/SECTION] # [DEF:InvalidZipFormatError:Class] +# @PURPOSE: Exception raised when a file is not a valid ZIP archive. class InvalidZipFormatError(Exception): pass +# [/DEF:InvalidZipFormatError:Class] # [DEF:create_temp_file:Function] # @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением. diff --git a/backend/src/core/utils/network.py b/backend/src/core/utils/network.py index 77ac554..164e2ca 100644 --- a/backend/src/core/utils/network.py +++ b/backend/src/core/utils/network.py @@ -20,31 +20,71 @@ from ..logger import logger as app_logger, belief_scope # [/SECTION] # [DEF:SupersetAPIError:Class] +# @PURPOSE: Base exception for all Superset API related errors. class SupersetAPIError(Exception): + # [DEF:__init__:Function] + # @PURPOSE: Initializes the exception with a message and context. + # @PRE: message is a string, context is a dict. + # @POST: Exception is initialized with context. def __init__(self, message: str = "Superset API error", **context: Any): - self.context = context - super().__init__(f"[API_FAILURE] {message} | Context: {self.context}") + with belief_scope("SupersetAPIError.__init__"): + self.context = context + super().__init__(f"[API_FAILURE] {message} | Context: {self.context}") + # [/DEF:__init__:Function] +# [/DEF:SupersetAPIError:Class] # [DEF:AuthenticationError:Class] +# @PURPOSE: Exception raised when authentication fails. class AuthenticationError(SupersetAPIError): + # [DEF:__init__:Function] + # @PURPOSE: Initializes the authentication error. + # @PRE: message is a string, context is a dict. + # @POST: AuthenticationError is initialized. def __init__(self, message: str = "Authentication failed", **context: Any): - super().__init__(message, type="authentication", **context) + with belief_scope("AuthenticationError.__init__"): + super().__init__(message, type="authentication", **context) + # [/DEF:__init__:Function] +# [/DEF:AuthenticationError:Class] # [DEF:PermissionDeniedError:Class] +# @PURPOSE: Exception raised when access is denied. class PermissionDeniedError(AuthenticationError): + # [DEF:__init__:Function] + # @PURPOSE: Initializes the permission denied error. + # @PRE: message is a string, context is a dict. + # @POST: PermissionDeniedError is initialized. def __init__(self, message: str = "Permission denied", **context: Any): - super().__init__(message, **context) + with belief_scope("PermissionDeniedError.__init__"): + super().__init__(message, **context) + # [/DEF:__init__:Function] +# [/DEF:PermissionDeniedError:Class] # [DEF:DashboardNotFoundError:Class] +# @PURPOSE: Exception raised when a dashboard cannot be found. class DashboardNotFoundError(SupersetAPIError): + # [DEF:__init__:Function] + # @PURPOSE: Initializes the not found error with resource ID. + # @PRE: resource_id is provided. + # @POST: DashboardNotFoundError is initialized. 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) + with belief_scope("DashboardNotFoundError.__init__"): + super().__init__(f"Dashboard '{resource_id}' {message}", subtype="not_found", resource_id=resource_id, **context) + # [/DEF:__init__:Function] +# [/DEF:DashboardNotFoundError:Class] # [DEF:NetworkError:Class] +# @PURPOSE: Exception raised when a network level error occurs. class NetworkError(Exception): + # [DEF:__init__:Function] + # @PURPOSE: Initializes the network error. + # @PRE: message is a string. + # @POST: NetworkError is initialized. def __init__(self, message: str = "Network connection failed", **context: Any): - self.context = context - super().__init__(f"[NETWORK_FAILURE] {message} | Context: {self.context}") + with belief_scope("NetworkError.__init__"): + self.context = context + super().__init__(f"[NETWORK_FAILURE] {message} | Context: {self.context}") + # [/DEF:__init__:Function] +# [/DEF:NetworkError:Class] # [DEF:APIClient:Class] # @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов. diff --git a/backend/src/models/git.py b/backend/src/models/git.py index c3d4c77..f6beef5 100644 --- a/backend/src/models/git.py +++ b/backend/src/models/git.py @@ -1,8 +1,8 @@ -""" -[DEF:GitModels:Module] -Git-specific SQLAlchemy models for configuration and repository tracking. -@RELATION: specs/011-git-integration-dashboard/data-model.md -""" +# [DEF:GitModels:Module] +# @SEMANTICS: git, models, sqlalchemy, database, schema +# @PURPOSE: Git-specific SQLAlchemy models for configuration and repository tracking. +# @LAYER: Model +# @RELATION: specs/011-git-integration-dashboard/data-model.md import enum from datetime import datetime diff --git a/backend/src/plugins/git_plugin.py b/backend/src/plugins/git_plugin.py index 7a24780..0eeb7ff 100644 --- a/backend/src/plugins/git_plugin.py +++ b/backend/src/plugins/git_plugin.py @@ -31,6 +31,7 @@ class GitPlugin(PluginBase): # [DEF:__init__:Function] # @PURPOSE: Инициализирует плагин и его зависимости. + # @PRE: config.json exists or shared config_manager is available. # @POST: Инициализированы git_service и config_manager. def __init__(self): with belief_scope("GitPlugin.__init__"): @@ -59,23 +60,49 @@ class GitPlugin(PluginBase): # [/DEF:__init__:Function] @property + # [DEF:id:Function] + # @PURPOSE: Returns the plugin identifier. + # @PRE: GitPlugin is initialized. + # @POST: Returns 'git-integration'. def id(self) -> str: - return "git-integration" + with belief_scope("GitPlugin.id"): + return "git-integration" + # [/DEF:id:Function] @property + # [DEF:name:Function] + # @PURPOSE: Returns the plugin name. + # @PRE: GitPlugin is initialized. + # @POST: Returns the human-readable name. def name(self) -> str: - return "Git Integration" + with belief_scope("GitPlugin.name"): + return "Git Integration" + # [/DEF:name:Function] @property + # [DEF:description:Function] + # @PURPOSE: Returns the plugin description. + # @PRE: GitPlugin is initialized. + # @POST: Returns the plugin's purpose description. def description(self) -> str: - return "Version control for Superset dashboards" + with belief_scope("GitPlugin.description"): + return "Version control for Superset dashboards" + # [/DEF:description:Function] @property + # [DEF:version:Function] + # @PURPOSE: Returns the plugin version. + # @PRE: GitPlugin is initialized. + # @POST: Returns the version string. def version(self) -> str: - return "0.1.0" + with belief_scope("GitPlugin.version"): + return "0.1.0" + # [/DEF:version:Function] # [DEF:get_schema:Function] # @PURPOSE: Возвращает JSON-схему параметров для выполнения задач плагина. + # @PRE: GitPlugin is initialized. + # @POST: Returns a JSON schema dictionary. # @RETURN: Dict[str, Any] - Схема параметров. def get_schema(self) -> Dict[str, Any]: with belief_scope("GitPlugin.get_schema"): @@ -93,6 +120,7 @@ class GitPlugin(PluginBase): # [DEF:initialize:Function] # @PURPOSE: Выполняет начальную настройку плагина. + # @PRE: GitPlugin is initialized. # @POST: Плагин готов к выполнению задач. async def initialize(self): with belief_scope("GitPlugin.initialize"): @@ -281,6 +309,8 @@ class GitPlugin(PluginBase): # [DEF:_get_env:Function] # @PURPOSE: Вспомогательный метод для получения конфигурации окружения. # @PARAM: env_id (Optional[str]) - ID окружения. + # @PRE: env_id is a string or None. + # @POST: Returns an Environment object from config or DB. # @RETURN: Environment - Объект конфигурации окружения. def _get_env(self, env_id: Optional[str] = None): with belief_scope("GitPlugin._get_env"): @@ -341,5 +371,6 @@ class GitPlugin(PluginBase): raise ValueError("No environments configured. Please add a Superset Environment in Settings.") # [/DEF:_get_env:Function] + # [/DEF:initialize:Function] # [/DEF:GitPlugin:Class] # [/DEF:backend.src.plugins.git_plugin:Module] \ No newline at end of file diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index e8d6d8e..468d4fd 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -29,6 +29,8 @@ class GitService: # [DEF:__init__:Function] # @PURPOSE: Initializes the GitService with a base path for repositories. # @PARAM: base_path (str) - Root directory for all Git clones. + # @PRE: base_path is a valid string path. + # @POST: GitService is initialized; base_path directory exists. def __init__(self, base_path: str = "backend/git_repos"): with belief_scope("GitService.__init__"): self.base_path = base_path @@ -39,9 +41,12 @@ class GitService: # [DEF:_get_repo_path:Function] # @PURPOSE: Resolves the local filesystem path for a dashboard's repository. # @PARAM: dashboard_id (int) + # @PRE: dashboard_id is an integer. + # @POST: Returns the absolute or relative path to the dashboard's repo. # @RETURN: str def _get_repo_path(self, dashboard_id: int) -> str: - return os.path.join(self.base_path, str(dashboard_id)) + with belief_scope("GitService._get_repo_path"): + return os.path.join(self.base_path, str(dashboard_id)) # [/DEF:_get_repo_path:Function] # [DEF:init_repo:Function] @@ -49,6 +54,8 @@ class GitService: # @PARAM: dashboard_id (int) # @PARAM: remote_url (str) # @PARAM: pat (str) - Personal Access Token for authentication. + # @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided. + # @POST: Repository is cloned or opened at the local path. # @RETURN: Repo - GitPython Repo object. def init_repo(self, dashboard_id: int, remote_url: str, pat: str) -> Repo: with belief_scope("GitService.init_repo"): @@ -71,7 +78,8 @@ class GitService: # [DEF:get_repo:Function] # @PURPOSE: Get Repo object for a dashboard. - # @PRE: Repository must exist on disk. + # @PRE: Repository must exist on disk for the given dashboard_id. + # @POST: Returns a GitPython Repo instance for the dashboard. # @RETURN: Repo def get_repo(self, dashboard_id: int) -> Repo: with belief_scope("GitService.get_repo"): @@ -88,6 +96,8 @@ class GitService: # [DEF:list_branches:Function] # @PURPOSE: List all branches for a dashboard's repository. + # @PRE: Repository for dashboard_id exists. + # @POST: Returns a list of branch metadata dictionaries. # @RETURN: List[dict] def list_branches(self, dashboard_id: int) -> List[dict]: with belief_scope("GitService.list_branches"): @@ -142,6 +152,8 @@ class GitService: # @PURPOSE: Create a new branch from an existing one. # @PARAM: name (str) - New branch name. # @PARAM: from_branch (str) - Source branch. + # @PRE: Repository exists; name is valid; from_branch exists or repo is empty. + # @POST: A new branch is created in the repository. 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) @@ -171,10 +183,11 @@ class GitService: 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. + # @PRE: Repository exists and the specified branch name exists. + # @POST: The repository working directory is updated to the specified branch. def checkout_branch(self, dashboard_id: int, name: str): with belief_scope("GitService.checkout_branch"): repo = self.get_repo(dashboard_id) @@ -186,6 +199,8 @@ class GitService: # @PURPOSE: Stage and commit changes. # @PARAM: message (str) - Commit message. # @PARAM: files (List[str]) - Optional list of specific files to stage. + # @PRE: Repository exists and has changes (dirty) or files are specified. + # @POST: Changes are staged and a new commit is created. 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) @@ -208,6 +223,8 @@ class GitService: # [DEF:push_changes:Function] # @PURPOSE: Push local commits to remote. + # @PRE: Repository exists and has an 'origin' remote. + # @POST: Local branch commits are pushed to origin. def push_changes(self, dashboard_id: int): with belief_scope("GitService.push_changes"): repo = self.get_repo(dashboard_id) @@ -240,6 +257,8 @@ class GitService: # [DEF:pull_changes:Function] # @PURPOSE: Pull changes from remote. + # @PRE: Repository exists and has an 'origin' remote. + # @POST: Changes from origin are pulled and merged into the active branch. def pull_changes(self, dashboard_id: int): with belief_scope("GitService.pull_changes"): repo = self.get_repo(dashboard_id) @@ -261,6 +280,8 @@ class GitService: # [DEF:get_status:Function] # @PURPOSE: Get current repository status (dirty files, untracked, etc.) + # @PRE: Repository for dashboard_id exists. + # @POST: Returns a dictionary representing the Git status. # @RETURN: dict def get_status(self, dashboard_id: int) -> dict: with belief_scope("GitService.get_status"): @@ -287,6 +308,8 @@ class GitService: # @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. + # @PRE: Repository for dashboard_id exists. + # @POST: Returns the diff text as a string. # @RETURN: str def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str: with belief_scope("GitService.get_diff"): @@ -303,6 +326,8 @@ class GitService: # [DEF:get_commit_history:Function] # @PURPOSE: Retrieve commit history for a repository. # @PARAM: limit (int) - Max number of commits to return. + # @PRE: Repository for dashboard_id exists. + # @POST: Returns a list of dictionaries for each commit in history. # @RETURN: List[dict] def get_commit_history(self, dashboard_id: int, limit: int = 50) -> List[dict]: with belief_scope("GitService.get_commit_history"): @@ -333,6 +358,8 @@ class GitService: # @PARAM: provider (GitProvider) # @PARAM: url (str) # @PARAM: pat (str) + # @PRE: provider is valid; url is a valid HTTP(S) URL; pat is provided. + # @POST: Returns True if connection to the provider's API succeeds. # @RETURN: bool async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool: with belief_scope("GitService.test_connection"): diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 813b37d..544b80a 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -18,6 +18,4 @@ def test_environment_model(): 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] +# [/DEF:test_environment_model:Function] diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100755 index 0000000..372450f --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.svelte-kit +build + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/src/components/git/BranchSelector.svelte b/frontend/src/components/git/BranchSelector.svelte index d9a1989..fbc7b9a 100644 --- a/frontend/src/components/git/BranchSelector.svelte +++ b/frontend/src/components/git/BranchSelector.svelte @@ -33,6 +33,11 @@ const dispatch = createEventDispatcher(); // [DEF:onMount:Function] + /** + * @purpose Load branches when component is mounted. + * @pre Component is initialized. + * @post loadBranches is called. + */ onMount(async () => { await loadBranches(); }); @@ -41,6 +46,7 @@ // [DEF:loadBranches:Function] /** * @purpose Загружает список веток для дашборда. + * @pre dashboardId is provided. * @post branches обновлен. */ async function loadBranches() { @@ -59,6 +65,11 @@ // [/DEF:loadBranches:Function] // [DEF:handleSelect:Function] + /** + * @purpose Handles branch selection from dropdown. + * @pre event contains branch name. + * @post handleCheckout is called with selected branch. + */ function handleSelect(event) { handleCheckout(event.target.value); } @@ -88,7 +99,8 @@ // [DEF:handleCreate:Function] /** * @purpose Создает новую ветку. - * @post Новая ветка создана и загружена. + * @pre newBranchName is not empty. + * @post Новая ветка создана и загружена; showCreate reset. */ async function handleCreate() { if (!newBranchName) return; diff --git a/frontend/src/components/git/CommitHistory.svelte b/frontend/src/components/git/CommitHistory.svelte index b0d5770..4314613 100644 --- a/frontend/src/components/git/CommitHistory.svelte +++ b/frontend/src/components/git/CommitHistory.svelte @@ -27,6 +27,8 @@ // [DEF:onMount:Function] /** * @purpose Load history when component is mounted. + * @pre Component is initialized with dashboardId. + * @post loadHistory is called. */ onMount(async () => { await loadHistory(); @@ -36,6 +38,7 @@ // [DEF:loadHistory:Function] /** * @purpose Fetch commit history from the backend. + * @pre dashboardId is valid. * @post history state is updated. */ async function loadHistory() { diff --git a/frontend/src/components/git/DeploymentModal.svelte b/frontend/src/components/git/DeploymentModal.svelte index 08a8baa..d562dd5 100644 --- a/frontend/src/components/git/DeploymentModal.svelte +++ b/frontend/src/components/git/DeploymentModal.svelte @@ -32,6 +32,7 @@ // [DEF:loadStatus:Watcher] $: if (show) loadEnvironments(); + // [/DEF:loadStatus:Watcher] // [DEF:loadEnvironments:Function] /** diff --git a/frontend/src/components/git/GitManager.svelte b/frontend/src/components/git/GitManager.svelte index 4be9a2b..28d36be 100644 --- a/frontend/src/components/git/GitManager.svelte +++ b/frontend/src/components/git/GitManager.svelte @@ -51,6 +51,8 @@ // [DEF:checkStatus:Function] /** * @purpose Проверяет, инициализирован ли репозиторий для данного дашборда. + * @pre Component is mounted and has dashboardId. + * @post initialized state is set; configs loaded if not initialized. */ async function checkStatus() { checkingStatus = true; @@ -72,6 +74,8 @@ // [DEF:handleInit:Function] /** * @purpose Инициализирует репозиторий для дашборда. + * @pre selectedConfigId and remoteUrl are provided. + * @post Repository is created on backend; initialized set to true. */ async function handleInit() { if (!selectedConfigId || !remoteUrl) { @@ -94,6 +98,8 @@ // [DEF:handleSync:Function] /** * @purpose Синхронизирует состояние Superset с локальным Git-репозиторием. + * @pre Repository is initialized. + * @post Dashboard YAMLs are exported to Git and staged. */ async function handleSync() { loading = true; @@ -111,6 +117,11 @@ // [/DEF:handleSync:Function] // [DEF:handlePush:Function] + /** + * @purpose Pushes local commits to the remote repository. + * @pre Repository is initialized and has commits. + * @post Changes are pushed to origin. + */ async function handlePush() { loading = true; try { @@ -125,6 +136,11 @@ // [/DEF:handlePush:Function] // [DEF:handlePull:Function] + /** + * @purpose Pulls changes from the remote repository. + * @pre Repository is initialized. + * @post Local branch is updated with remote changes. + */ async function handlePull() { loading = true; try { diff --git a/frontend/src/routes/git/+page.svelte b/frontend/src/routes/git/+page.svelte index 08071c2..eb5cf51 100644 --- a/frontend/src/routes/git/+page.svelte +++ b/frontend/src/routes/git/+page.svelte @@ -1,4 +1,9 @@ +