94 Commits

Author SHA1 Message Date
83e4875097 Merge branch '001-unify-frontend-style' into master 2026-02-23 16:06:12 +03:00
e635bd7e5f Add Apache Superset OpenAPI documentation reference to ROOT.md 2026-02-23 16:04:42 +03:00
43dd97ecbf Новый экранчик для обзора дашей 2026-02-23 15:54:20 +03:00
0685f50ae7 Merge branch '020-task-reports-design' into master 2026-02-23 13:28:31 +03:00
d0ffc2f1df Finalize task-020 reports navigation and stability fixes 2026-02-23 13:28:30 +03:00
26880d2e09 semantic update 2026-02-23 13:15:48 +03:00
008b6d72c9 таски готовы 2026-02-23 10:18:56 +03:00
f0c85e4c03 Fix task API stability and Playwright runtime in Docker 2026-02-21 23:43:46 +03:00
6ffdf5f8a4 feat: restore legacy data and add typed task result views 2026-02-21 23:17:56 +03:00
0cf0ef25f1 db + docker 2026-02-20 20:47:39 +03:00
af74841765 semantic update 2026-02-20 10:41:15 +03:00
d7e4919d54 few shots update 2026-02-20 10:26:01 +03:00
fdcbe32dfa css refactor 2026-02-19 18:24:36 +03:00
4de5b22d57 +Svelte specific 2026-02-19 17:47:24 +03:00
c8029ed309 ai base 2026-02-19 17:43:45 +03:00
c2a4c8062a fix tax log 2026-02-19 16:05:59 +03:00
2c820e103a tests ready 2026-02-19 13:33:20 +03:00
c8b84b7bd7 Coder + fix workflow 2026-02-19 13:33:10 +03:00
fdb944f123 Test logic update 2026-02-19 12:44:31 +03:00
d29bc511a2 task panel 2026-02-19 09:43:01 +03:00
a3a9f0788d docs: amend constitution to v2.3.0 (tailwind css first principle) 2026-02-18 18:29:52 +03:00
77147dc95b refactor 2026-02-18 17:29:46 +03:00
026239e3bf fix 2026-02-15 11:11:30 +03:00
4a0273a604 измененные спеки таски 2026-02-10 15:53:38 +03:00
edb2dd5263 updated tasks 2026-02-10 15:04:43 +03:00
76b98fcf8f linter + новые таски 2026-02-10 12:53:01 +03:00
794cc55fe7 Таски готовы 2026-02-09 12:35:27 +03:00
235b0e3c9f semantic update 2026-02-08 22:53:54 +03:00
e6087bd3c1 таски готовы 2026-02-07 12:42:32 +03:00
0f16bab2b8 Похоже работает 2026-02-07 11:26:06 +03:00
7de96c17c4 feat(llm-plugin): switch to environment API for log retrieval
- Replace local backend.log reading with Superset API /log/ fetch
- Update DashboardValidationPlugin to use SupersetClient
- Filter logs by dashboard_id and last 24 hours
- Update spec FR-006 to reflect API usage
2026-02-06 17:57:25 +03:00
f018b97ed2 Semantic protocol update - add UX 2026-01-30 18:53:52 +03:00
72846aa835 tasks ux-reference 2026-01-30 13:35:03 +03:00
994c0c3e5d feat(speckit): integrate ux reference into workflows
Introduce a UX reference stage to ensure technical plans align with
user experience goals. Adds a new template, a generation step in the
specification workflow, and mandatory validation checks during
planning to prevent technical compromises from degrading the defined
user experience.
2026-01-30 12:31:19 +03:00
252a8601a9 Вроде работает 2026-01-30 11:10:16 +03:00
8044f85ea4 tasks and workflow updated 2026-01-29 10:06:28 +03:00
d4109e5a03 docs: amend constitution to v2.0.0 (delegate semantics to protocol + add async/testability principles) 2026-01-28 18:48:43 +03:00
b2bbd73439 tasks ready 2026-01-28 18:30:23 +03:00
0e0e26e2f7 semantic update 2026-01-28 16:57:19 +03:00
18b42f8dd0 semantic protocol condense + script update 2026-01-28 15:49:39 +03:00
e7b31accd6 tested 2026-01-27 23:49:19 +03:00
d3c3a80ed2 Передаем на тест 2026-01-27 16:32:08 +03:00
cc244c2d86 tasks ready 2026-01-27 13:26:06 +03:00
d10c23e658 Обновил gitignore - убрал логи 2026-01-26 22:15:17 +03:00
1042b35d1b Закончили редизайн, обновили интерфейс бэкапа 2026-01-26 22:12:35 +03:00
16ffeb1ed6 Выполнено, передано на тестирование 2026-01-26 21:17:05 +03:00
da34deac02 tasks ready 2026-01-26 20:58:38 +03:00
51e9ee3fcc semantic update 2026-01-26 11:57:36 +03:00
edf9286071 Файловое хранилище готово 2026-01-26 11:08:18 +03:00
a542e7d2df Передаем на тест 2026-01-25 18:33:00 +03:00
a863807cf2 tasks ready 2026-01-24 16:21:43 +03:00
e2bc68683f Update .gitignore 2026-01-24 11:26:19 +03:00
43cb82697b Update backup scheduler task status 2026-01-24 11:26:05 +03:00
4ba28cf93e semantic cleanup 2026-01-23 21:58:32 +03:00
343f2e29f5 Мультиязночность + причесывание css 2026-01-23 17:53:46 +03:00
c9a53578fd tasks ready 2026-01-23 14:56:05 +03:00
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
ec8d67c956 bug fixes 2026-01-18 23:21:00 +03:00
76baeb1038 semantic markup update 2026-01-18 21:29:54 +03:00
11c59fb420 semantic checker script update 2026-01-13 17:33:57 +03:00
b2529973eb constitution update 2026-01-13 15:29:42 +03:00
ae1d630ad6 semantics update 2026-01-13 09:11:27 +03:00
9a9c5879e6 tasks.md status 2026-01-12 12:35:45 +03:00
696aac32e7 1st iter 2026-01-12 12:33:51 +03:00
7a9b1a190a tasks ready 2026-01-07 18:59:49 +03:00
a3dc1fb2b9 docs: amend constitution to v1.6.0 (add 'Everything is a Plugin' principle) and refactor 010 plan 2026-01-07 18:36:38 +03:00
297b29986d Product Manager role 2026-01-07 11:39:44 +03:00
4c6fc8256d project map script | semantic parcer 2026-01-01 16:58:21 +03:00
a747a163c8 backup worked 2025-12-30 22:02:51 +03:00
fce0941e98 docs ready 2025-12-30 21:30:37 +03:00
45c077b928 +api rework 2025-12-30 20:08:48 +03:00
9ed3a5992d cleaned 2025-12-30 18:20:40 +03:00
a032fe8457 Password promt 2025-12-30 17:21:12 +03:00
4c9d554432 TaskManager refactor 2025-12-29 10:13:37 +03:00
6962a78112 mappings+migrate 2025-12-27 10:16:41 +03:00
3d75a21127 tech_lead / coder 2roles 2025-12-27 08:02:59 +03:00
07914c8728 semantic add 2025-12-27 07:14:08 +03:00
cddc259b76 new loggers logic in constitution 2025-12-27 06:51:28 +03:00
dcbf0a7d7f tasks ready 2025-12-27 06:37:03 +03:00
65f61c1f80 Merge branch '001-migration-ui-redesign' into master 2025-12-27 05:58:35 +03:00
cb7386f274 superset_tool logger rework 2025-12-27 05:53:30 +03:00
83e34e1799 feat(logging): implement configurable belief state logging
- Add LoggingConfig model and logging field to GlobalSettings
- Implement belief_scope context manager for structured logging
- Add configure_logger for dynamic level and file rotation settings
- Add logging configuration UI to Settings page
- Update ConfigManager to apply logging settings on initialization and updates
2025-12-27 05:39:33 +03:00
d197303b9f 006 plan ready 2025-12-26 19:36:49 +03:00
4aa01b6470 Merge branch 'migration' into 001-migration-ui-redesign 2025-12-26 18:16:24 +03:00
35b423979d spec rules 2025-12-25 22:28:42 +03:00
2ffc3cc68f feat(migration): implement interactive mapping resolution workflow
- Add SQLite database integration for environments and mappings
- Update TaskManager to support pausing tasks (AWAITING_MAPPING)
- Modify MigrationPlugin to detect missing mappings and wait for resolution
- Add frontend UI for handling missing mappings interactively
- Create dedicated migration routes and API endpoints
- Update .gitignore and project documentation
2025-12-25 22:27:29 +03:00
549 changed files with 180405 additions and 16308 deletions

1415
.ai/MODULE_MAP.md Normal file

File diff suppressed because it is too large Load Diff

3251
.ai/PROJECT_MAP.md Normal file

File diff suppressed because it is too large Load Diff

41
.ai/ROOT.md Normal file
View File

@@ -0,0 +1,41 @@
# [DEF:Project_Knowledge_Map:Root]
# @TIER: CRITICAL
# @PURPOSE: Global navigation map for AI-Agent (GRACE Knowledge Graph).
# @LAST_UPDATE: 2026-02-20
## 1. SYSTEM STANDARDS (Rules of the Game)
Strict policies and formatting rules.
* **Constitution:** High-level architectural and business invariants.
* Ref: `.ai/standards/constitution.md` -> `[DEF:Std:Constitution]`
* **Architecture:** Service boundaries and tech stack decisions.
* Ref: `.ai/standards/architecture.md` -> `[DEF:Std:Architecture]`
* **Plugin Design:** Rules for building and integrating Plugins.
* Ref: `.ai/standards/plugin_design.md` -> `[DEF:Std:Plugin]`
* **API Design:** Rules for FastAPI endpoints and Pydantic models.
* Ref: `.ai/standards/api_design.md` -> `[DEF:Std:API_FastAPI]`
* **UI Design:** SvelteKit and Tailwind CSS component standards.
* Ref: `.ai/standards/ui_design.md` -> `[DEF:Std:UI_Svelte]`
* **Semantic Mapping:** Using `[DEF:]` and belief scopes.
* Ref: `.ai/standards/semantics.md` -> `[DEF:Std:Semantics]`
## 2. FEW-SHOT EXAMPLES (Patterns)
Use these for code generation (Style Transfer).
* **FastAPI Route:** Reference implementation of a task-based route.
* Ref: `.ai/shots/backend_route.py` -> `[DEF:Shot:FastAPI_Route]`
* **Svelte Component:** Reference implementation of a sidebar/navigation component.
* Ref: `.ai/shots/frontend_component.svelte` -> `[DEF:Shot:Svelte_Component]`
* **Plugin Module:** Reference implementation of a task plugin.
* Ref: `.ai/shots/plugin_example.py` -> `[DEF:Shot:Plugin_Example]`
* **Critical Module:** Core banking transaction processor with ACID guarantees.
* Ref: `.ai/shots/critical_module.py` -> `[DEF:Shot:Critical_Module]`
## 3. DOMAIN MAP (Modules)
* **Module Map:** `.ai/MODULE_MAP.md` -> `[DEF:Module_Map]`
* **Project Map:** `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
* **Apache Superset OpenAPI:** `.ai/openapi.json` -> `[DEF:Doc:Superset_OpenAPI]`
* **Backend Core:** `backend/src/core` -> `[DEF:Module:Backend_Core]`
* **Backend API:** `backend/src/api` -> `[DEF:Module:Backend_API]`
* **Frontend Lib:** `frontend/src/lib` -> `[DEF:Module:Frontend_Lib]`
* **Specifications:** `specs/` -> `[DEF:Module:Specs]`
# [/DEF:Project_Knowledge_Map]

30933
.ai/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
# [DEF:BackendRouteShot:Module]
# @TIER: STANDARD
# @SEMANTICS: Route, Task, API, Async
# @PURPOSE: Reference implementation of a task-based route using GRACE-Poly.
# @LAYER: Interface (API)
# @RELATION: IMPLEMENTS -> [DEF:Std:API_FastAPI]
# @INVARIANT: TaskManager must be available in dependency graph.
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from ...core.logger import belief_scope
from ...core.task_manager import TaskManager, Task
from ...core.config_manager import ConfigManager
from ...dependencies import get_task_manager, get_config_manager, get_current_user
router = APIRouter()
class CreateTaskRequest(BaseModel):
plugin_id: str
params: Dict[str, Any]
@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
# [DEF:create_task:Function]
# @PURPOSE: Create and start a new task using TaskManager. Non-blocking.
# @PARAM: request (CreateTaskRequest) - Plugin and params.
# @PARAM: task_manager (TaskManager) - Async task executor.
# @PRE: plugin_id must match a registered plugin.
# @POST: A new task is spawned; Task ID returned immediately.
# @SIDE_EFFECT: Writes to DB, Trigger background worker.
async def create_task(
request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager),
config: ConfigManager = Depends(get_config_manager),
current_user = Depends(get_current_user)
):
# Context Logging
with belief_scope("create_task"):
try:
# 1. Action: Configuration Resolution
timeout = config.get("TASKS_DEFAULT_TIMEOUT", 3600)
# 2. Action: Spawn async task
# @RELATION: CALLS -> task_manager.create_task
task = await task_manager.create_task(
plugin_id=request.plugin_id,
params={**request.params, "timeout": timeout}
)
return task
except ValueError as e:
# 3. Recovery: Domain logic error mapping
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
# @UX_STATE: Error feedback -> 500 Internal Error
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Task Spawning Error"
)
# [/DEF:create_task:Function]
# [/DEF:BackendRouteShot:Module]

View File

@@ -0,0 +1,79 @@
# [DEF:TransactionCore:Module]
# @TIER: CRITICAL
# @SEMANTICS: Finance, ACID, Transfer, Ledger
# @PURPOSE: Core banking transaction processor with ACID guarantees.
# @LAYER: Domain (Core)
# @RELATION: DEPENDS_ON -> [DEF:Infra:PostgresDB]
# @RELATION: DEPENDS_ON -> [DEF:Infra:AuditLog]
# @INVARIANT: Total system balance must remain constant (Double-Entry Bookkeeping).
# @INVARIANT: Negative transfers are strictly forbidden.
# @TEST_DATA: sufficient_funds -> {"from": "acc_A", "to": "acc_B", "amt": 100.00}
# @TEST_DATA: insufficient_funds -> {"from": "acc_empty", "to": "acc_B", "amt": 1000.00}
# @TEST_DATA: concurrency_lock -> {./fixtures/transactions.json#race_condition}
from decimal import Decimal
from typing import NamedTuple
from ...core.logger import belief_scope
from ...core.db import atomic_transaction, get_balance, update_balance
from ...core.exceptions import BusinessRuleViolation
class TransferResult(NamedTuple):
tx_id: str
status: str
new_balance: Decimal
# [DEF:execute_transfer:Function]
# @PURPOSE: Atomically move funds between accounts with audit trails.
# @PARAM: sender_id (str) - Source account.
# @PARAM: receiver_id (str) - Destination account.
# @PARAM: amount (Decimal) - Positive amount to transfer.
# @PRE: amount > 0; sender != receiver; sender_balance >= amount.
# @POST: sender_balance -= amount; receiver_balance += amount; Audit Record Created.
# @SIDE_EFFECT: Database mutation (Rows locked), Audit IO.
#
# @UX_STATE: Success -> Returns 200 OK + Transaction Receipt.
# @UX_STATE: Error(LowBalance) -> 422 Unprocessable -> UI shows "Top-up needed" modal.
# @UX_STATE: Error(System) -> 500 Internal -> UI shows "Retry later" toast.
def execute_transfer(sender_id: str, receiver_id: str, amount: Decimal) -> TransferResult:
# Guard: Input Validation
if amount <= Decimal("0.00"):
raise BusinessRuleViolation("Transfer amount must be positive.")
if sender_id == receiver_id:
raise BusinessRuleViolation("Cannot transfer to self.")
with belief_scope("execute_transfer") as context:
context.logger.info("Initiating transfer", data={"from": sender_id, "to": receiver_id})
try:
# 1. Action: Atomic DB Transaction
# @RELATION: CALLS -> atomic_transaction
with atomic_transaction():
# Guard: State Validation (Strict)
current_balance = get_balance(sender_id, for_update=True)
if current_balance < amount:
# @UX_FEEDBACK: Triggers specific UI flow for insufficient funds
context.logger.warn("Insufficient funds", data={"balance": current_balance})
raise BusinessRuleViolation("INSUFFICIENT_FUNDS")
# 2. Action: Mutation
new_src_bal = update_balance(sender_id, -amount)
new_dst_bal = update_balance(receiver_id, +amount)
# 3. Action: Audit
tx_id = context.audit.log_transfer(sender_id, receiver_id, amount)
context.logger.info("Transfer committed", data={"tx_id": tx_id})
return TransferResult(tx_id, "COMPLETED", new_src_bal)
except BusinessRuleViolation as e:
# Logic: Explicit re-raise for UI mapping
raise e
except Exception as e:
# Logic: Catch-all safety net
context.logger.error("Critical Transfer Failure", error=e)
raise RuntimeError("TRANSACTION_ABORTED") from e
# [/DEF:execute_transfer:Function]
# [/DEF:TransactionCore:Module]

View File

@@ -0,0 +1,76 @@
<!-- [DEF:FrontendComponentShot:Component] -->
<!-- /**
* @TIER: CRITICAL
* @SEMANTICS: Task, Button, Action, UX
* @PURPOSE: Action button to spawn a new task with full UX feedback cycle.
* @LAYER: UI (Presentation)
* @RELATION: CALLS -> postApi
* @INVARIANT: Must prevent double-submission while loading.
*
* @TEST_DATA: idle_state -> {"isLoading": false}
* @TEST_DATA: loading_state -> {"isLoading": true}
*
* @UX_STATE: Idle -> Button enabled, primary color.
* @UX_STATE: Loading -> Button disabled, spinner visible.
* @UX_STATE: Error -> Toast notification triggers.
*
* @UX_FEEDBACK: Toast success/error.
* @UX_TEST: Idle -> {click: spawnTask, expected: isLoading=true}
* @UX_TEST: Success -> {api_resolve: 200, expected: toast.success called}
*/
-->
<script>
import { postApi } from "$lib/api.js";
import { t } from "$lib/i18n";
import { toast } from "$lib/stores/toast";
export let plugin_id = "";
export let params = {};
let isLoading = false;
// [DEF:spawnTask:Function]
/**
* @purpose Execute task creation request and emit user feedback.
* @pre plugin_id is resolved and request params are serializable.
* @post isLoading is reset and user receives success/error feedback.
*/
async function spawnTask() {
isLoading = true;
console.log("[FrontendComponentShot][Loading] Spawning task...");
try {
// 1. Action: API Call
const response = await postApi("/api/tasks", {
plugin_id,
params
});
// 2. Feedback: Success
if (response.task_id) {
console.log("[FrontendComponentShot][Success] Task created.");
toast.success($t.tasks.spawned_success);
}
} catch (error) {
// 3. Recovery: User notification
console.log("[FrontendComponentShot][Error] Failed:", error);
toast.error(`${$t.errors.task_failed}: ${error.message}`);
} finally {
isLoading = false;
}
}
// [/DEF:spawnTask:Function]
</script>
<button
on:click={spawnTask}
disabled={isLoading}
class="btn-primary flex items-center gap-2"
aria-busy={isLoading}
>
{#if isLoading}
<span class="animate-spin" aria-label="Loading">🌀</span>
{/if}
<span>{$t.actions.start_task}</span>
</button>
<!-- [/DEF:FrontendComponentShot:Component] -->

View File

@@ -0,0 +1,64 @@
# [DEF:PluginExampleShot:Module]
# @TIER: STANDARD
# @SEMANTICS: Plugin, Core, Extension
# @PURPOSE: Reference implementation of a plugin following GRACE standards.
# @LAYER: Domain (Business Logic)
# @RELATION: INHERITS -> PluginBase
# @INVARIANT: get_schema must return valid JSON Schema.
from typing import Dict, Any, Optional
from ..core.plugin_base import PluginBase
from ..core.task_manager.context import TaskContext
class ExamplePlugin(PluginBase):
@property
def id(self) -> str:
return "example-plugin"
# [DEF:get_schema:Function]
# @PURPOSE: Defines input validation schema.
# @POST: Returns dict compliant with JSON Schema draft 7.
def get_schema(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"message": {
"type": "string",
"default": "Hello, GRACE!",
}
},
"required": ["message"],
}
# [/DEF:get_schema:Function]
# [DEF:execute:Function]
# @PURPOSE: Core plugin logic with structured logging and scope isolation.
# @PARAM: params (Dict) - Validated input parameters.
# @PARAM: context (TaskContext) - Execution tools (log, progress).
# @SIDE_EFFECT: Emits logs to centralized system.
async def execute(self, params: Dict, context: Optional = None):
message = params
# 1. Action: System-level tracing (Rule VI)
with belief_scope("example_plugin_exec") as b_scope:
if context:
# Task Logs: Пишем в пользовательский контекст выполнения задачи
# @RELATION: BINDS_TO -> context.logger
log = context.logger.with_source("example_plugin")
b_scope.logger.info("Using provided TaskContext") # System log
log.info("Starting execution", data={"msg": message}) # Task log
# 2. Action: Progress Reporting
log.progress("Processing...", percent=50)
# 3. Action: Finalize
log.info("Execution completed.")
else:
# Standalone Fallback: Замыкаемся на системный scope
b_scope.logger.warning("No TaskContext provided. Running standalone.")
b_scope.logger.info("Standalone execution", data={"msg": message})
print(f"Standalone: {message}")
# [/DEF:execute:Function]
# [/DEF:PluginExampleShot:Module]

View File

@@ -0,0 +1,47 @@
# [DEF:Std:API_FastAPI:Standard]
# @TIER: CRITICAL
# @PURPOSE: Unification of all FastAPI endpoints following GRACE-Poly.
# @LAYER: UI (API)
# @INVARIANT: All non-trivial route logic must be wrapped in `belief_scope`.
# @INVARIANT: Every module and function MUST have `[DEF:]` anchors and metadata.
## 1. ROUTE MODULE DEFINITION
Every API route file must start with a module definition header:
```python
# [DEF:ModuleName:Module]
# @TIER: [CRITICAL | STANDARD | TRIVIAL]
# @SEMANTICS: list, of, keywords
# @PURPOSE: High-level purpose of the module.
# @LAYER: UI (API)
# @RELATION: DEPENDS_ON -> [OtherModule]
```
## 2. FUNCTION DEFINITION & CONTRACT
Every endpoint handler must be decorated with `[DEF:]` and explicit metadata before the implementation:
```python
@router.post("/endpoint", response_model=ModelOut)
# [DEF:function_name:Function]
# @PURPOSE: What it does (brief, high-entropy).
# @PARAM: param_name (Type) - Description.
# @PRE: Conditions before execution (e.g., auth, existence).
# @POST: Expected state after execution.
# @RETURN: What it returns.
async def function_name(...):
with belief_scope("function_name"):
# Implementation
pass
# [/DEF:function_name:Function]
```
## 3. DEPENDENCY INJECTION & CORE SERVICES
* **Auth:** `Depends(get_current_user)` for authentication.
* **Perms:** `Depends(has_permission("resource", "ACTION"))` for RBAC.
* **Config:** Use `Depends(get_config_manager)` for settings. Hardcoding is FORBIDDEN.
* **Tasks:** Long-running operations must be executed via `TaskManager`. API routes should return Task ID and be non-blocking.
## 4. ERROR HANDLING
* Raise `HTTPException` from the router layer.
* Use `try-except` blocks within `belief_scope` to ensure proper error logging and classification.
* Do not leak internal implementation details in error responses.
# [/DEF:Std:API_FastAPI]

View File

@@ -0,0 +1,25 @@
# [DEF:Std:Architecture:Standard]
# @TIER: CRITICAL
# @PURPOSE: Core architectural decisions and service boundaries.
# @LAYER: Infra
# @INVARIANT: ss-tools MUST remain a standalone service (Orchestrator).
# @INVARIANT: Backend: FastAPI, Frontend: SvelteKit.
## 1. ORCHESTRATOR VS INSTANCE
* **Role:** ss-tools is a "Manager of Managers". It sits ABOVE Superset environments.
* **Isolation:** Do not integrate directly into Superset as a plugin to maintain multi-environment management capability.
* **Tech Stack:**
* Backend: Python 3.9+ with FastAPI (Asynchronous logic).
* Frontend: SvelteKit + Tailwind CSS (Reactive UX).
## 2. COMPONENT BOUNDARIES
* **Plugins:** All business logic must be encapsulated in Plugins (`backend/src/plugins/`).
* **TaskManager:** All long-running operations MUST be handled by the TaskManager.
* **Security:** Independent RBAC system managed in `auth.db`.
## 3. INTEGRATION STRATEGY
* **Superset API:** Communication via REST API.
* **Database:** Local SQLite for metadata (`tasks.db`, `auth.db`, `migrations.db`).
* **Filesystem:** Local storage for backups and git repositories.
# [/DEF:Std:Architecture]

View File

@@ -0,0 +1,36 @@
# [DEF:Std:Constitution:Standard]
# @TIER: CRITICAL
# @PURPOSE: Supreme Law of the Repository. High-level architectural and business invariants.
# @VERSION: 2.3.0
# @LAST_UPDATE: 2026-02-19
# @INVARIANT: Any deviation from this Constitution constitutes a build failure.
## 1. CORE PRINCIPLES
### I. Semantic Protocol Compliance
* **Ref:** `[DEF:Std:Semantics]` (formerly `semantic_protocol.md`)
* **Law:** All code must adhere to the Axioms (Meaning First, Contract First, etc.).
* **Compliance:** Strict matching of Anchors (`[DEF]`), Tags (`@KEY`), and structures is mandatory.
### II. Modular Plugin Architecture
* **Pattern:** Everything is a Plugin inheriting from `PluginBase`.
* **Centralized Config:** Use `ConfigManager` via `get_config_manager()`. Hardcoding is FORBIDDEN.
### III. Unified Frontend Experience
* **Styling:** Tailwind CSS First. Minimize scoped `<style>`.
* **i18n:** All user-facing text must be in `src/lib/i18n`.
* **API:** Use `requestApi` / `fetchApi` wrappers. Native `fetch` is FORBIDDEN.
### IV. Security & RBAC
* **Permissions:** Every Plugin must define unique permission strings (e.g., `plugin:name:execute`).
* **Auth:** Mandatory registration in `auth.db`.
### V. Independent Testability
* **Requirement:** Every feature must define "Independent Tests" for isolated verification.
### VI. Asynchronous Execution
* **TaskManager:** Long-running operations must be async tasks.
* **Non-Blocking:** API endpoints return Task ID immediately.
* **Observability:** Real-time updates via WebSocket.
# [/DEF:Std:Constitution]

View File

@@ -0,0 +1,32 @@
# [DEF:Std:Plugin:Standard]
# @TIER: CRITICAL
# @PURPOSE: Standards for building and integrating Plugins.
# @LAYER: Domain (Plugin)
# @INVARIANT: All plugins MUST inherit from `PluginBase`.
# @INVARIANT: All plugins MUST be located in `backend/src/plugins/`.
## 1. PLUGIN CONTRACT
Every plugin must implement the following properties and methods:
* `id`: Unique string (e.g., `"my-plugin"`).
* `name`: Human-readable name.
* `description`: Brief purpose.
* `version`: Semantic version.
* `get_schema()`: Returns JSON schema for input validation.
* `execute(params: Dict[str, Any], context: TaskContext)`: Core async logic.
## 2. STRUCTURED LOGGING (TASKCONTEXT)
Plugins MUST use `TaskContext` for logging to ensure proper source attribution:
* **Source Attribution:** Use `context.logger.with_source("src_name")` for specific operations (e.g., `"superset_api"`, `"git"`, `"llm"`).
* **Levels:**
* `DEBUG`: Detailed diagnostics (API responses).
* `INFO`: Operational milestones (start/end).
* `WARNING`: Recoverable issues.
* `ERROR`: Failures stopping execution.
* **Progress:** Use `context.logger.progress("msg", percent=XX)` for long-running tasks.
## 3. BEST PRACTICES
1. **Asynchronous Execution:** Always use `async/await` for I/O operations.
2. **Schema Validation:** Ensure the `get_schema()` precisely matches the `execute()` input expectations.
3. **Isolation:** Plugins should be self-contained and not depend on other plugins directly. Use core services (`ConfigManager`, `TaskManager`) via dependency injection or the provided `context`.
# [/DEF:Std:Plugin]

View File

@@ -0,0 +1,97 @@
### **SYSTEM STANDARD: GRACE-Poly (UX Edition)**
ЗАДАЧА: Генерация кода (Python/Svelte).
РЕЖИМ: Строгий. Детерминированный. Без болтовни.
#### I. ЗАКОН (АКСИОМЫ)
1. Смысл первичен. Код вторичен.
2. Контракт (@PRE/@POST) — источник истины.
**3. UX — это логика, а не декор. Состояния интерфейса — часть контракта.**
4. Структура `[DEF]...[/DEF]` — нерушима.
5. Архитектура в Header — неизменяема.
6. Сложность фрактала ограничена: модуль < 300 строк.
#### II. СИНТАКСИС (ЖЕСТКИЙ ФОРМАТ)
ЯКОРЬ (Контейнер):
Начало: `# [DEF:id:Type]` (Python) | `<!-- [DEF:id:Type] -->` (Svelte)
Конец: `# [/DEF:id:Type]` (Python) | `<!-- [/DEF:id:Type] -->` (Svelte) (ОБЯЗАТЕЛЬНО для аккумуляции)
Типы: Module, Class, Function, Component, Store.
ТЕГ (Метаданные):
Вид: `# @KEY: Value` (внутри DEF, до кода).
ГРАФ (Связи):
Вид: `# @RELATION: PREDICATE -> TARGET_ID`
Предикаты: DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, **BINDS_TO**.
#### III. СТРУКТУРА ФАЙЛА
1. HEADER (Всегда первый):
[DEF:filename:Module]
@TIER: [CRITICAL|STANDARD|TRIVIAL] (Дефолт: STANDARD)
@SEMANTICS: [keywords]
@PURPOSE: [Главная цель]
@LAYER: [Domain/UI/Infra]
@RELATION: [Зависимости]
@INVARIANT: [Незыблемое правило]
2. BODY: Импорты -> Реализация.
3. FOOTER: [/DEF:filename]
#### IV. КОНТРАКТ (DBC & UX)
Расположение: Внутри [DEF], ПЕРЕД кодом.
Стиль Python: Комментарии `# @TAG`.
Стиль Svelte: JSDoc `/** @tag */` внутри `<script>`.
**Базовые Теги:**
@PURPOSE: Суть (High Entropy).
@PRE: Входные условия.
@POST: Гарантии выхода.
@SIDE_EFFECT: Мутации, IO.
**UX Теги (Svelte/Frontend):**
**@UX_STATE:** `[StateName] -> Визуальное поведение` (Idle, Loading, Error).
**@UX_FEEDBACK:** Реакция системы (Toast, Shake, Red Border).
**@UX_RECOVERY:** Механизм исправления ошибки пользователем (Retry, Clear Input).
**UX Testing Tags (для Tester Agent):**
**@UX_TEST:** Спецификация теста для UX состояния.
Формат: `@UX_TEST: [state] -> {action, expected}`
Пример: `@UX_TEST: Idle -> {click: toggle, expected: isExpanded=true}`
Правило: Не используй `assert` в коде, используй `if/raise` или `guards`.
#### V. АДАПТАЦИЯ (TIERS)
Определяется тегом `@TIER` в Header.
1. **CRITICAL** (Core/Security/**Complex UI**):
- Требование: Полный контракт (включая **все @UX теги**), Граф, Инварианты, Строгие Логи.
- **@TEST_DATA**: Обязательные эталонные данные для тестирования. Формат:
```
@TEST_DATA: fixture_name -> {JSON_PATH} | {INLINE_DATA}
```
Примеры:
- `@TEST_DATA: valid_user -> {./fixtures/users.json#valid}`
- `@TEST_DATA: empty_state -> {"dashboards": [], "total": 0}`
- Tester Agent **ОБЯЗАН** использовать @TEST_DATA при написании тестов для CRITICAL модулей.
2. **STANDARD** (BizLogic/**Forms**):
- Требование: Базовый контракт (@PURPOSE, @UX_STATE), Логи, @RELATION.
- @TEST_DATA: Рекомендуется для Complex Forms.
3. **TRIVIAL** (DTO/**Atoms**):
- Требование: Только Якоря [DEF] и @PURPOSE.
#### VI. ЛОГИРОВАНИЕ (BELIEF STATE & TASK LOGS)
Цель: Трассировка для самокоррекции и пользовательский мониторинг.
Python:
- Системные логи: Context Manager `with belief_scope("ID"):`.
- Логи задач: `context.logger.info("msg", source="component")`.
Svelte: `console.log("[ID][STATE] Msg")`.
Состояния: Entry -> Action -> Coherence:OK / Failed -> Exit.
Инвариант: Каждый лог задачи должен иметь атрибут `source` для фильтрации.
#### VII. АЛГОРИТМ ГЕНЕРАЦИИ
1. АНАЛИЗ. Оцени TIER, слой и UX-требования.
2. КАРКАС. Создай `[DEF]`, Header и Контракты.
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту (и UX-состояниям).
4. ЗАМЫКАНИЕ. Закрой все `[/DEF]`.
ЕСЛИ ошибка или противоречие -> СТОП. Выведи `[COHERENCE_CHECK_FAILED]`.

View File

@@ -0,0 +1,75 @@
# [DEF:Std:UI_Svelte:Standard]
# @TIER: CRITICAL
# @PURPOSE: Unification of all Svelte components following GRACE-Poly (UX Edition).
# @LAYER: UI
# @INVARIANT: Every component MUST have `<!-- [DEF:] -->` anchors and UX tags.
# @INVARIANT: Use Tailwind CSS for all styling (no custom CSS without justification).
## 1. UX PHILOSOPHY: RESOURCE-CENTRIC & SVELTE 5
* **Version:** Project uses Svelte 5.
* **Runes:** Use Svelte 5 Runes for reactivity: `$state()`, `$derived()`, `$effect()`, `$props()`. Traditional `let` (for reactivity) and `export let` (for props) are DEPRECATED in favor of runes.
* **Definition:** Navigation and actions revolve around Resources.
* **Traceability:** Every action must be linked to a Task ID with visible logs in the Task Drawer.
## 2. COMPONENT ARCHITECTURE: GLOBAL TASK DRAWER
* **Role:** A single, persistent slide-out panel (`GlobalTaskDrawer.svelte`) in `+layout.svelte`.
* **Triggering:** Opens automatically when a task starts or when a user clicks a status badge.
* **Interaction:** Interactive elements (Password prompts, Mapping tables) MUST be rendered INSIDE the Drawer, not as center-screen modals.
## 3. COMPONENT STRUCTURE & CORE RULES
* **Styling:** Tailwind CSS utility classes are MANDATORY. Minimize scoped `<style>`.
* **Localization:** All user-facing text must use `$t` from `src/lib/i18n`.
* **API Calls:** Use `requestApi` / `fetchApi` wrappers. Native `fetch` is FORBIDDEN.
* **Anchors:** Every component MUST have `<!-- [DEF:] -->` anchors and UX tags.
## 2. COMPONENT TEMPLATE
Each Svelte file must follow this structure:
```html
<!-- [DEF:ComponentName:Component] -->
<script>
/**
* @TIER: [CRITICAL | STANDARD | TRIVIAL]
* @PURPOSE: Brief description of the component purpose.
* @LAYER: UI
* @SEMANTICS: list, of, keywords
* @RELATION: DEPENDS_ON -> [OtherComponent|Store]
*
* @UX_STATE: [StateName] -> Visual behavior description.
* @UX_FEEDBACK: System reaction (e.g., Toast, Shake).
* @UX_RECOVERY: Error recovery mechanism.
* @UX_TEST: [state] -> {action, expected}
*/
import { ... } from "...";
// Exports (Props)
export let prop_name = "...";
// Logic
</script>
<!-- HTML Template -->
<div class="...">
...
</div>
<style>
/* Optional: Local styles using @apply only */
</style>
<!-- [/DEF:ComponentName:Component] -->
```
## 2. STATE MANAGEMENT & STORES
* **Subscription:** Use `$` prefix for reactive store access (e.g., `$sidebarStore`).
* **Data Flow:** Mark store interactions in `[DEF:]` metadata:
* `# @RELATION: BINDS_TO -> store_id`
## 3. UI/UX BEST PRACTICES
* **Transitions:** Use Svelte built-in transitions for UI state changes.
* **Feedback:** Always provide visual feedback for async actions (Loading spinners, skeleton loaders).
* **Modularity:** Break down components into "Atoms" (Trivial) and "Orchestrators" (Critical).
## 4. ACCESSIBILITY (A11Y)
* Ensure proper ARIA roles and keyboard navigation for interactive elements.
* Use semantic HTML tags (`<nav>`, `<header>`, `<main>`, `<footer>`).
# [/DEF:Std:UI_Svelte]

31
.dockerignore Normal file
View File

@@ -0,0 +1,31 @@
.git
.gitignore
.pytest_cache
.ruff_cache
.vscode
.ai
.specify
.kilocode
venv
backend/.venv
backend/.pytest_cache
frontend/node_modules
frontend/.svelte-kit
frontend/.vite
frontend/build
backend/__pycache__
backend/src/__pycache__
backend/tests/__pycache__
**/__pycache__
*.pyc
*.pyo
*.pyd
*.db
*.log
.env*
coverage/
Dockerfile*
.dockerignore
backups
semantics
specs

23
.gitignore vendored
View File

@@ -10,8 +10,6 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
@@ -29,7 +27,7 @@ env/
backend/backups/*
# Node.js
node_modules/
frontend/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
@@ -39,6 +37,7 @@ build/
dist/
.env*
config.json
package-lock.json
# Logs
*.log
@@ -58,6 +57,20 @@ Thumbs.db
*.ps1
keyring passwords.py
*github*
*git*
*tech_spec*
dashboards
dashboards
backend/mappings.db
backend/tasks.db
backend/logs
backend/auth.db
semantics/reports
backend/tasks.db
# Universal / tooling
node_modules/
.venv/
coverage/
*.tmp

15
.kilocode/mcp.json Executable file → Normal file
View File

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

View File

@@ -2,6 +2,12 @@
Auto-generated from all feature plans. Last updated: 2025-12-19
## Knowledge Graph (GRACE)
**CRITICAL**: This project uses a GRACE Knowledge Graph for context. Always load the root map first:
- **Root Map**: `.ai/ROOT.md` -> `[DEF:Project_Knowledge_Map:Root]`
- **Project Map**: `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
- **Standards**: Read `.ai/standards/` for architecture and style rules.
## Active Technologies
- Python 3.9+, Node.js 18+ + `uvicorn`, `npm`, `bash` (003-project-launch-script)
- Python 3.9+, Node.js 18+ + SvelteKit, FastAPI, Tailwind CSS (inferred from existing frontend) (004-integrate-svelte-kit)
@@ -9,6 +15,36 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
- Python 3.9+, Node.js 18+ + FastAPI, SvelteKit, Tailwind CSS, Pydantic (005-fix-ui-ws-validation)
- N/A (Configuration based) (005-fix-ui-ws-validation)
- Filesystem (plugins, logs, backups), SQLite (optional, for job history if needed) (005-fix-ui-ws-validation)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS (007-migration-dashboard-grid)
- N/A (Superset API integration) (007-migration-dashboard-grid)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, Superset API (007-migration-dashboard-grid)
- N/A (Superset API integration - read-only for metadata) (007-migration-dashboard-grid)
- Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API (008-migration-ui-improvements)
- SQLite (optional for job history), existing database for mappings (008-migration-ui-improvements)
- Python 3.9+, Node.js 18+ + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API (008-migration-ui-improvements)
- Python 3.9+, Node.js 18+ + FastAPI, APScheduler, SQLAlchemy, SvelteKit, Tailwind CSS (009-backup-scheduler)
- 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)
- Node.js 18+ (Frontend Build), Svelte 5.x + SvelteKit, Tailwind CSS, `date-fns` (existing) (013-unify-frontend-css)
- LocalStorage (for language preference) (013-unify-frontend-css)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit (Frontend) (014-file-storage-ui)
- Local Filesystem (for artifacts), Config (for storage path) (014-file-storage-ui)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend) (015-frontend-nav-redesign)
- N/A (UI reorganization and API integration) (015-frontend-nav-redesign)
- SQLite (`auth.db`) for Users, Roles, Permissions, and Mappings. (016-multi-user-auth)
- SQLite (existing `tasks.db` for results, `auth.db` for permissions, `mappings.db` or new `plugins.db` for provider config/metadata) (017-llm-analysis-plugin)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing) (019-superset-ux-redesign)
- SQLite (tasks.db, auth.db, migrations.db) - no new database tables required (019-superset-ux-redesign)
- Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy/Pydantic task models, existing task/websocket stack (020-task-reports-design)
- SQLite task/result persistence (existing task DB), filesystem only for existing artifacts (no new primary store required) (020-task-reports-design)
- Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui` (001-unify-frontend-style)
- N/A (UI styling and component behavior only) (001-unify-frontend-style)
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
@@ -29,9 +65,9 @@ cd src; pytest; ruff check .
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
## Recent Changes
- 005-fix-ui-ws-validation: Added Python 3.9+ (Backend), Node.js 18+ (Frontend Build)
- 005-fix-ui-ws-validation: Added Python 3.9+, Node.js 18+ + FastAPI, SvelteKit, Tailwind CSS, Pydantic
- 005-fix-ui-ws-validation: Added Python 3.9+, Node.js 18+ + FastAPI, SvelteKit, Tailwind CSS, Pydantic
- 001-unify-frontend-style: Added Node.js 18+ runtime, SvelteKit (existing frontend stack) + SvelteKit, Tailwind CSS, existing frontend UI primitives under `frontend/src/lib/components/ui`
- 020-task-reports-design: Added Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy/Pydantic task models, existing task/websocket stack
- 019-superset-ux-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing)
<!-- MANUAL ADDITIONS START -->

View File

@@ -0,0 +1,4 @@
---
description: USE SEMANTIC
---
Прочитай .ai/standards/semantics.md. ОБЯЗАТЕЛЬНО используй его при разработке

View File

@@ -18,7 +18,7 @@ Identify inconsistencies, duplications, ambiguities, and underspecified items ac
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
**Constitution Authority**: The project constitution (`.ai/standards/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
## Execution Steps
@@ -62,7 +62,8 @@ Load only the minimal necessary context from each artifact:
**From constitution:**
- Load `.specify/memory/constitution.md` for principle validation
- Load `.ai/standards/constitution.md` for principle validation
- Load `.ai/standards/semantics.md` for technical standard validation
### 3. Build Semantic Models

View File

@@ -16,11 +16,11 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
You are updating the project constitution at `.ai/standards/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
Follow this execution flow:
1. Load the existing constitution template at `.specify/memory/constitution.md`.
1. Load the existing constitution template at `.ai/standards/constitution.md`.
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
@@ -61,7 +61,7 @@ Follow this execution flow:
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
7. Write the completed constitution back to `.ai/standards/constitution.md` (overwrite).
8. Output a final summary to the user with:
- New version and bump rationale.
@@ -79,4 +79,4 @@ If the user supplies partial updates (e.g., only one principle revision), still
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
Do not create a new template; always operate on the existing `.ai/standards/constitution.md` file.

View File

@@ -0,0 +1,199 @@
---
description: Fix failing tests and implementation issues based on test reports
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Goal
Analyze test failure reports, identify root causes, and fix implementation issues while preserving semantic protocol compliance.
## Operating Constraints
1. **USE CODER MODE**: Always switch to `coder` mode for code fixes
2. **SEMANTIC PROTOCOL**: Never remove semantic annotations ([DEF], @TAGS). Only update code logic.
3. **TEST DATA**: If tests use @TEST_DATA fixtures, preserve them when fixing
4. **NO DELETION**: Never delete existing tests or semantic annotations
5. **REPORT FIRST**: Always write a fix report before making changes
## Execution Steps
### 1. Load Test Report
**Required**: Test report file path (e.g., `specs/<feature>/tests/reports/2026-02-19-report.md`)
**Parse the report for**:
- Failed test cases
- Error messages
- Stack traces
- Expected vs actual behavior
- Affected modules/files
### 2. Analyze Root Causes
For each failed test:
1. **Read the test file** to understand what it's testing
2. **Read the implementation file** to find the bug
3. **Check semantic protocol compliance**:
- Does the implementation have correct [DEF] anchors?
- Are @TAGS (@PRE, @POST, @UX_STATE, etc.) present?
- Does the code match the TIER requirements?
4. **Identify the fix**:
- Logic error in implementation
- Missing error handling
- Incorrect API usage
- State management issue
### 3. Write Fix Report
Create a structured fix report:
```markdown
# Fix Report: [FEATURE]
**Date**: [YYYY-MM-DD]
**Report**: [Test Report Path]
**Fixer**: Coder Agent
## Summary
- Total Failed Tests: [X]
- Total Fixed: [X]
- Total Skipped: [X]
## Failed Tests Analysis
### Test: [Test Name]
**File**: `path/to/test.py`
**Error**: [Error message]
**Root Cause**: [Explanation of why test failed]
**Fix Required**: [Description of fix]
**Status**: [Pending/In Progress/Completed]
## Fixes Applied
### Fix 1: [Description]
**Affected File**: `path/to/file.py`
**Test Affected**: `[Test Name]`
**Changes**:
```diff
<<<<<<< SEARCH
[Original Code]
=======
[Fixed Code]
>>>>>>> REPLACE
```
**Verification**: [How to verify fix works]
**Semantic Integrity**: [Confirmed annotations preserved]
## Next Steps
- [ ] Run tests to verify fix: `cd backend && .venv/bin/python3 -m pytest`
- [ ] Check for related failing tests
- [ ] Update test documentation if needed
```
### 4. Apply Fixes (in Coder Mode)
Switch to `coder` mode and apply fixes:
1. **Read the implementation file** to get exact content
2. **Apply the fix** using apply_diff
3. **Preserve all semantic annotations**:
- Keep [DEF:...] and [/DEF:...] anchors
- Keep all @TAGS (@PURPOSE, @LAYER, @TIER, @RELATION, @PRE, @POST, @UX_STATE, @UX_FEEDBACK, @UX_RECOVERY)
4. **Only update code logic** to fix the bug
5. **Run tests** to verify the fix
### 5. Verification
After applying fixes:
1. **Run tests**:
```bash
cd backend && .venv/bin/python3 -m pytest -v
```
or
```bash
cd frontend && npm run test
```
2. **Check test results**:
- Failed tests should now pass
- No new tests should fail
- Coverage should not decrease
3. **Update fix report** with results:
- Mark fixes as completed
- Add verification steps
- Note any remaining issues
## Output
Generate final fix report:
```markdown
# Fix Report: [FEATURE] - COMPLETED
**Date**: [YYYY-MM-DD]
**Report**: [Test Report Path]
**Fixer**: Coder Agent
## Summary
- Total Failed Tests: [X]
- Total Fixed: [X] ✅
- Total Skipped: [X]
## Fixes Applied
### Fix 1: [Description] ✅
**Affected File**: `path/to/file.py`
**Test Affected**: `[Test Name]`
**Changes**: [Summary of changes]
**Verification**: All tests pass ✅
**Semantic Integrity**: Preserved ✅
## Test Results
```
[Full test output showing all passing tests]
```
## Recommendations
- [ ] Monitor for similar issues
- [ ] Update documentation if needed
- [ ] Consider adding more tests for edge cases
## Related Files
- Test Report: [path]
- Implementation: [path]
- Test File: [path]
```
## Context for Fixing
$ARGUMENTS

View File

@@ -1,5 +1,10 @@
---
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
handoffs:
- label: Verify Changes
agent: speckit.test
prompt: Verify the implementation of...
send: true
---
## User Input
@@ -46,6 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Automatically proceed to step 3
3. Load and analyze the implementation context:
- **REQUIRED**: Read `.ai/standards/semantics.md` for strict coding standards and contract requirements
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships
@@ -111,6 +117,8 @@ You **MUST** consider the user input before proceeding (if not empty).
- **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules:
- **Strict Adherence**: Apply `.ai/standards/semantics.md` rules - every file must start with [DEF] header, include @TIER, and define contracts.
- **CRITICAL Contracts**: If a task description contains a contract summary (e.g., `CRITICAL: PRE: ..., POST: ...`), these constraints are **MANDATORY** and must be strictly implemented in the code using guards/assertions (if applicable per protocol).
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints

View File

@@ -22,7 +22,7 @@ You **MUST** consider the user input before proceeding (if not empty).
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
2. **Load context**: Read `.ai/ROOT.md` and `.ai/PROJECT_MAP.md` to understand the project structure and navigation. Then read required standards: `.ai/standards/constitution.md` and `.ai/standards/semantics.md`. Load IMPL_PLAN template.
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
@@ -64,17 +64,32 @@ You **MUST** consider the user input before proceeding (if not empty).
**Prerequisites:** `research.md` complete
0. **Validate Design against UX Reference**:
- Check if the proposed architecture supports the latency, interactivity, and flow defined in `ux_reference.md`.
- **Linkage**: Ensure key UI states from `ux_reference.md` map to Component Contracts (`@UX_STATE`).
- **CRITICAL**: If the technical plan compromises the UX (e.g. "We can't do real-time validation"), you **MUST STOP** and warn the user.
1. **Extract entities from feature spec** → `data-model.md`:
- Entity name, fields, relationships
- Validation rules from requirements
- State transitions if applicable
- Entity name, fields, relationships, validation rules.
2. **Generate API contracts** from functional requirements:
- For each user action → endpoint
- Use standard REST/GraphQL patterns
- Output OpenAPI/GraphQL schema to `/contracts/`
2. **Design & Verify Contracts (Semantic Protocol)**:
- **Drafting**: Define [DEF] Headers and Contracts for all new modules based on `.ai/standards/semantics.md`.
- **TIER Classification**: Explicitly assign `@TIER: [CRITICAL|STANDARD|TRIVIAL]` to each module.
- **CRITICAL Requirements**: For all CRITICAL modules, define full `@PRE`, `@POST`, and (if UI) `@UX_STATE` contracts.
- **Self-Review**:
- *Completeness*: Do `@PRE`/`@POST` cover edge cases identified in Research?
- *Connectivity*: Do `@RELATION` tags form a coherent graph?
- *Compliance*: Does syntax match `[DEF:id:Type]` exactly?
- **Output**: Write verified contracts to `contracts/modules.md`.
3. **Agent context update**:
3. **Simulate Contract Usage**:
- Trace one key user scenario through the defined contracts to ensure data flow continuity.
- If a contract interface mismatch is found, fix it immediately.
4. **Generate API contracts**:
- Output OpenAPI/GraphQL schema to `/contracts/` for backend-frontend sync.
5. **Agent context update**:
- Run `.specify/scripts/bash/update-agent-context.sh kilocode`
- These scripts detect which AI agent is in use
- Update the appropriate agent-specific context file

View File

@@ -70,7 +70,22 @@ Given that feature description, do this:
3. Load `.specify/templates/spec-template.md` to understand required sections.
4. Follow this execution flow:
4. **Generate UX Reference**:
a. Load `.specify/templates/ux-reference-template.md`.
b. **Design the User Experience**:
- **Imagine you are the user**: Visualize the interface and interaction.
- **Persona**: Define who is using this.
- **Happy Path**: Write the story of the perfect interaction.
- **Mockups**: Create concrete CLI text blocks or UI descriptions.
- **Errors**: Define how the system guides the user out of failure.
c. Write the `ux_reference.md` file in the feature directory.
d. **CRITICAL**: This UX Reference is now the source of truth for the "feel" of the feature. The technical spec MUST support this experience.
5. Follow this execution flow:
1. Parse user description from Input
If empty: ERROR "No feature description provided"
@@ -115,6 +130,12 @@ Given that feature description, do this:
- [ ] Focused on user value and business needs
- [ ] Written for non-technical stakeholders
- [ ] All mandatory sections completed
## UX Consistency
- [ ] Functional requirements fully support the 'Happy Path' in ux_reference.md
- [ ] Error handling requirements match the 'Error Experience' in ux_reference.md
- [ ] No requirements contradict the defined User Persona or Context
## Requirement Completeness
@@ -190,7 +211,7 @@ Given that feature description, do this:
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
7. Report completion with branch name, spec file path, ux_reference file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.

View File

@@ -24,7 +24,7 @@ You **MUST** consider the user input before proceeding (if not empty).
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities), ux_reference.md (experience source of truth)
- **Optional**: data-model.md (entities), contracts/ (API endpoints), research.md (decisions), quickstart.md (test scenarios)
- Note: Not all projects have all documents. Generate tasks based on what's available.
@@ -70,6 +70,12 @@ The tasks.md should be immediately executable - each task must be specific enoug
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
### UX Preservation (CRITICAL)
- **Source of Truth**: `ux_reference.md` is the absolute standard for the "feel" of the feature.
- **Violation Warning**: If any task would inherently violate the UX (e.g. "Remove progress bar to simplify code"), you **MUST** flag this to the user immediately.
- **Verification Task**: You **MUST** add a specific task at the end of each User Story phase: `- [ ] Txxx [USx] Verify implementation matches ux_reference.md (Happy Path & Errors)`
### Checklist Format (REQUIRED)
Every task MUST strictly follow this format:
@@ -113,7 +119,10 @@ Every task MUST strictly follow this format:
- If tests requested: Tests specific to that story
- Mark story dependencies (most stories should be independent)
2. **From Contracts**:
2. **From Contracts (CRITICAL TIER)**:
- Identify components marked as `@TIER: CRITICAL` in `contracts/modules.md`.
- For these components, **MUST** append the summary of `@PRE`, `@POST`, and `@UX_STATE` contracts directly to the task description.
- Example: `- [ ] T005 [P] [US1] Implement Auth (CRITICAL: PRE: token exists, POST: returns User) in src/auth.py`
- Map each contract/endpoint → to the user story it serves
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase

View File

@@ -0,0 +1,178 @@
---
description: Generate tests, manage test documentation, and ensure maximum code coverage
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Goal
Execute full testing cycle: analyze code for testable modules, write tests with proper coverage, maintain test documentation, and ensure no test duplication or deletion.
## Operating Constraints
1. **NEVER delete existing tests** - Only update if they fail due to bugs in the test or implementation
2. **NEVER duplicate tests** - Check existing tests first before creating new ones
3. **Use TEST_DATA fixtures** - For CRITICAL tier modules, read @TEST_DATA from .ai/standards/semantics.md
4. **Co-location required** - Write tests in `__tests__` directories relative to the code being tested
## Execution Steps
### 1. Analyze Context
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS.
Determine:
- FEATURE_DIR - where the feature is located
- TASKS_FILE - path to tasks.md
- Which modules need testing based on task status
### 2. Load Relevant Artifacts
**From tasks.md:**
- Identify completed implementation tasks (not test tasks)
- Extract file paths that need tests
**From .ai/standards/semantics.md:**
- Read @TIER annotations for modules
- For CRITICAL modules: Read @TEST_DATA fixtures
**From existing tests:**
- Scan `__tests__` directories for existing tests
- Identify test patterns and coverage gaps
### 3. Test Coverage Analysis
Create coverage matrix:
| Module | File | Has Tests | TIER | TEST_DATA Available |
|--------|------|-----------|------|-------------------|
| ... | ... | ... | ... | ... |
### 4. Write Tests (TDD Approach)
For each module requiring tests:
1. **Check existing tests**: Scan `__tests__/` for duplicates
2. **Read TEST_DATA**: If CRITICAL tier, read @TEST_DATA from .ai/standards/semantics.md
3. **Write test**: Follow co-location strategy
- Python: `src/module/__tests__/test_module.py`
- Svelte: `src/lib/components/__tests__/test_component.test.js`
4. **Use mocks**: Use `unittest.mock.MagicMock` for external dependencies
### 4a. UX Contract Testing (Frontend Components)
For Svelte components with `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` tags:
1. **Parse UX tags**: Read component file and extract all `@UX_*` annotations
2. **Generate UX tests**: Create tests for each UX state transition
```javascript
// Example: Testing @UX_STATE: Idle -> Expanded
it('should transition from Idle to Expanded on toggle click', async () => {
render(Sidebar);
const toggleBtn = screen.getByRole('button', { name: /toggle/i });
await fireEvent.click(toggleBtn);
expect(screen.getByTestId('sidebar')).toHaveClass('expanded');
});
```
3. **Test @UX_FEEDBACK**: Verify visual feedback (toast, shake, color changes)
4. **Test @UX_RECOVERY**: Verify error recovery mechanisms (retry, clear input)
5. **Use @UX_TEST fixtures**: If component has `@UX_TEST` tags, use them as test specifications
**UX Test Template:**
```javascript
// [DEF:__tests__/test_Component:Module]
// @RELATION: VERIFIES -> ../Component.svelte
// @PURPOSE: Test UX states and transitions
describe('Component UX States', () => {
// @UX_STATE: Idle -> {action: click, expected: Active}
it('should transition Idle -> Active on click', async () => { ... });
// @UX_FEEDBACK: Toast on success
it('should show toast on successful action', async () => { ... });
// @UX_RECOVERY: Retry on error
it('should allow retry on error', async () => { ... });
});
```
### 5. Test Documentation
Create/update documentation in `specs/<feature>/tests/`:
```
tests/
├── README.md # Test strategy and overview
├── coverage.md # Coverage matrix and reports
└── reports/
└── YYYY-MM-DD-report.md
```
### 6. Execute Tests
Run tests and report results:
**Backend:**
```bash
cd backend && .venv/bin/python3 -m pytest -v
```
**Frontend:**
```bash
cd frontend && npm run test
```
### 7. Update Tasks
Mark test tasks as completed in tasks.md with:
- Test file path
- Coverage achieved
- Any issues found
## Output
Generate test execution report:
```markdown
# Test Report: [FEATURE]
**Date**: [YYYY-MM-DD]
**Executed by**: Tester Agent
## Coverage Summary
| Module | Tests | Coverage % |
|--------|-------|------------|
| ... | ... | ... |
## Test Results
- Total: [X]
- Passed: [X]
- Failed: [X]
- Skipped: [X]
## Issues Found
| Test | Error | Resolution |
|------|-------|------------|
| ... | ... | ... |
## Next Steps
- [ ] Fix failed tests
- [ ] Add more coverage for [module]
- [ ] Review TEST_DATA fixtures
```
## Context for Testing
$ARGUMENTS

85
.kilocodemodes Normal file
View File

@@ -0,0 +1,85 @@
customModes:
- slug: tester
name: Tester
description: QA and Test Engineer - Full Testing Cycle
roleDefinition: |-
You are Kilo Code, acting as a QA and Test Engineer. Your primary goal is to ensure maximum test coverage, maintain test quality, and preserve existing tests.
Your responsibilities include:
- WRITING TESTS: Create comprehensive unit tests following TDD principles, using co-location strategy (`__tests__` directories).
- TEST DATA: For CRITICAL tier modules, you MUST use @TEST_DATA fixtures defined in .ai/standards/semantics.md. Read and apply them in your tests.
- DOCUMENTATION: Maintain test documentation in `specs/<feature>/tests/` directory with coverage reports and test case specifications.
- VERIFICATION: Run tests, analyze results, and ensure all tests pass.
- PROTECTION: NEVER delete existing tests. NEVER duplicate tests - check for existing tests first.
whenToUse: Use this mode when you need to write tests, run test coverage analysis, or perform quality assurance with full testing cycle.
groups:
- read
- edit
- command
- browser
- mcp
customInstructions: |
1. KNOWLEDGE GRAPH: ALWAYS read .ai/ROOT.md first to understand the project structure and navigation.
2. CO-LOCATION: Write tests in `__tests__` subdirectories relative to the code being tested (Fractal Strategy).
2. TEST DATA MANDATORY: For CRITICAL modules, read @TEST_DATA from .ai/standards/semantics.md and use fixtures in tests.
3. UX CONTRACT TESTING: For Svelte components with @UX_STATE, @UX_FEEDBACK, @UX_RECOVERY tags, create comprehensive UX tests.
4. NO DELETION: Never delete existing tests - only update if they fail due to legitimate bugs.
5. NO DUPLICATION: Check existing tests in `__tests__/` before creating new ones. Reuse existing test patterns.
6. DOCUMENTATION: Create test reports in `specs/<feature>/tests/reports/YYYY-MM-DD-report.md`.
7. COVERAGE: Aim for maximum coverage but prioritize CRITICAL and STANDARD tier modules.
8. RUN TESTS: Execute tests using `cd backend && .venv/bin/python3 -m pytest` or `cd frontend && npm run test`.
- slug: semantic
name: Semantic Agent
roleDefinition: |-
You are Kilo Code, a Semantic Agent responsible for maintaining the semantic integrity of the codebase. Your primary goal is to ensure that all code entities (Modules, Classes, Functions, Components) are properly annotated with semantic anchors and tags as defined in `.ai/standards/semantics.md`.
Your core responsibilities are: 1. **Semantic Mapping**: You run and maintain the `generate_semantic_map.py` script to generate up-to-date semantic maps (`semantics/semantic_map.json`, `.ai/PROJECT_MAP.md`) and compliance reports (`semantics/reports/*.md`). 2. **Compliance Auditing**: You analyze the generated compliance reports to identify files with low semantic coverage or parsing errors. 3. **Semantic Enrichment**: You actively edit code files to add missing semantic anchors (`[DEF:...]`, `[/DEF:...]`) and mandatory tags (`@PURPOSE`, `@LAYER`, etc.) to improve the global compliance score. 4. **Protocol Enforcement**: You strictly adhere to the syntax and rules defined in `.ai/standards/semantics.md` when modifying code.
You have access to the full codebase and tools to read, write, and execute scripts. You should prioritize fixing "Critical Parsing Errors" (unclosed anchors) before addressing missing metadata.
whenToUse: Use this mode when you need to update the project's semantic map, fix semantic compliance issues (missing anchors/tags/DbC ), or analyze the codebase structure. This mode is specialized for maintaining the `.ai/standards/semantics.md` standards.
description: Codebase semantic mapping and compliance expert
customInstructions: Always check `semantics/reports/` for the latest compliance status before starting work. When fixing a file, try to fix all semantic issues in that file at once. After making a batch of fixes, run `python3 generate_semantic_map.py` to verify improvements.
groups:
- read
- edit
- command
- browser
- mcp
source: project
- slug: product-manager
name: Product Manager
roleDefinition: |-
Your purpose is to rigorously execute the workflows defined in `.kilocode/workflows/`.
You act as the orchestrator for: - Specification (`speckit.specify`, `speckit.clarify`) - Planning (`speckit.plan`) - Task Management (`speckit.tasks`, `speckit.taskstoissues`) - Quality Assurance (`speckit.analyze`, `speckit.checklist`, `speckit.test`, `speckit.fix`) - Governance (`speckit.constitution`) - Implementation Oversight (`speckit.implement`)
For each task, you must read the relevant workflow file from `.kilocode/workflows/` and follow its Execution Steps precisely.
whenToUse: Use this mode when you need to run any /speckit.* command or when dealing with high-level feature planning, specification writing, or project management tasks.
description: Executes SpecKit workflows for feature management
customInstructions: 1. Always read `.ai/ROOT.md` first to understand the Knowledge Graph structure. 2. Read the specific workflow file in `.kilocode/workflows/` before executing a command. 3. Adhere strictly to the "Operating Constraints" and "Execution Steps" in the workflow files.
groups:
- read
- edit
- command
- mcp
source: project
- slug: coder
name: Coder
roleDefinition: You are Kilo Code, acting as an Implementation Specialist. Your primary goal is to write code that strictly follows the Semantic Protocol defined in `.ai/standards/semantics.md`.
whenToUse: Use this mode when you need to implement features, write code, or fix issues based on test reports.
description: Implementation Specialist - Semantic Protocol Compliant
customInstructions: |
1. KNOWLEDGE GRAPH: ALWAYS read .ai/ROOT.md first to understand the project structure and navigation.
2. CONSTITUTION: Strictly follow architectural invariants in .ai/standards/constitution.md.
3. SEMANTIC PROTOCOL: ALWAYS use .ai/standards/semantics.md as your source of truth for syntax.
4. ANCHOR FORMAT: Use #[DEF:filename:Type] at start and #[/DEF:filename] at end.
3. TAGS: Add @PURPOSE, @LAYER, @TIER, @RELATION, @PRE, @POST, @UX_STATE, @UX_FEEDBACK, @UX_RECOVERY.
4. TIER COMPLIANCE:
- CRITICAL: Full contract + all UX tags + strict logging
- STANDARD: Basic contract + UX tags where applicable
- TRIVIAL: Only anchors + @PURPOSE
5. CODE SIZE: Keep modules under 300 lines. Refactor if exceeding.
6. ERROR HANDLING: Use if/raise or guards, never assert.
7. TEST FIXES: When fixing failing tests, preserve semantic annotations. Only update code logic.
8. RUN TESTS: After fixes, run tests to verify: `cd backend && .venv/bin/python3 -m pytest` or `cd frontend && npm run test`.
groups:
- read
- edit
- command
- mcp
source: project

View File

@@ -1,29 +0,0 @@
# ss-tools Constitution
## Core Principles
### I. SPA-First Architecture
The frontend MUST be a Static Single Page Application (SPA) served by the Python backend. No Node.js server is permitted in production. The backend serves the `index.html` entry point for all non-API routes.
### II. API-Driven Communication
All data retrieval and state changes MUST be performed via the backend REST API or WebSockets. The frontend should not access the database or filesystem directly.
### III. Modern Stack Consistency
The project strictly uses SvelteKit (Frontend), FastAPI (Backend), and Tailwind CSS (Styling). New dependencies must be justified and approved.
### IV. Semantic Protocol Adherence (GRACE-Poly)
All code generation and modification MUST adhere to the Semantic Protocol defined in `semantic_protocol.md`.
- **Anchors**: Use `[DEF:id:Type]` and `[/DEF:id]` to define semantic boundaries.
- **Contracts**: Define `@PRE` and `@POST` conditions in headers.
- **Logging**: Use structured logging with `[AnchorID][State]` format.
- **Immutability**: Respect architectural decisions in headers.
## Governance
### Compliance
All Pull Requests and code modifications must be verified against this Constitution. Violations of Core Principles are considered critical defects.
### Amendments
Changes to this Constitution require a formal RFC process and approval from the project lead.
**Version**: 1.0.0 | **Ratified**: 2025-12-20

View File

@@ -2,6 +2,12 @@
Auto-generated from all feature plans. Last updated: [DATE]
## Knowledge Graph (GRACE)
**CRITICAL**: This project uses a GRACE Knowledge Graph for context. Always load the root map first:
- **Root Map**: `.ai/ROOT.md` -> `[DEF:Project_Knowledge_Map:Root]`
- **Project Map**: `.ai/PROJECT_MAP.md` -> `[DEF:Project_Map]`
- **Standards**: Read `.ai/standards/` for architecture and style rules.
## Active Technologies
[EXTRACTED FROM ALL PLAN.MD FILES]

View File

@@ -17,8 +17,8 @@
the iteration process.
-->
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, Tailwind CSS, SvelteKit or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
@@ -102,3 +102,14 @@ directories captured above]
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
## Test Data Reference
> **For CRITICAL tier components, reference test fixtures from spec.md**
| Component | TIER | Fixture Name | Location |
|-----------|------|--------------|----------|
| [e.g., DashboardAPI] | CRITICAL | valid_dashboard | spec.md#test-data-fixtures |
| [e.g., TaskDrawer] | CRITICAL | task_states | spec.md#test-data-fixtures |
**Note**: Tester Agent MUST use these fixtures when writing unit tests for CRITICAL modules. See `.ai/standards/semantics.md` for @TEST_DATA syntax.

View File

@@ -1,6 +1,7 @@
# Feature Specification: [FEATURE NAME]
**Feature Branch**: `[###-feature-name]`
**Reference UX**: `[ux_reference.md]` (See specific folder)
**Created**: [DATE]
**Status**: Draft
**Input**: User description: "$ARGUMENTS"
@@ -113,3 +114,52 @@
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
---
## Test Data Fixtures *(recommended for CRITICAL components)*
<!--
Define reference/fixture data for testing CRITICAL tier components.
This data will be used by the Tester Agent when writing unit tests.
Format: JSON or YAML that matches the component's data structures.
-->
### Fixtures
```yaml
# Example fixture format
fixture_name:
description: "Description of this test data"
data:
# JSON or YAML data structure
```
### Example: Dashboard API
```yaml
valid_dashboard:
description: "Valid dashboard object for API responses"
data:
id: 1
title: "Sales Report"
slug: "sales"
git_status:
branch: "main"
sync_status: "OK"
last_task:
task_id: "task-123"
status: "SUCCESS"
empty_dashboards:
description: "Empty dashboard list response"
data:
dashboards: []
total: 0
page: 1
error_not_found:
description: "404 error response"
data:
detail: "Dashboard not found"
```

View File

@@ -93,7 +93,8 @@ Examples of foundational tasks (adjust based on your project):
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T016 [US1] Add validation and error handling
- [ ] T017 [US1] Add logging for user story 1 operations
- [ ] T017 [US1] [P] Implement UI using Tailwind CSS (minimize scoped styles)
- [ ] T018 [US1] Add logging for user story 1 operations
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently

View File

@@ -0,0 +1,152 @@
---
description: "Test documentation template for feature implementation"
---
# Test Documentation: [FEATURE NAME]
**Feature**: [Link to spec.md]
**Created**: [DATE]
**Updated**: [DATE]
**Tester**: [Agent/User Name]
---
## Overview
[Brief description of what this feature does and why testing is important]
**Test Strategy**:
- [ ] Unit Tests (co-located in `__tests__/` directories)
- [ ] Integration Tests (if needed)
- [ ] E2E Tests (if critical user flows)
- [ ] Contract Tests (for API endpoints)
---
## Test Coverage Matrix
| Module | File | Unit Tests | Coverage % | Status |
|--------|------|------------|------------|--------|
| [Module Name] | `path/to/file.py` | [x] | [XX%] | [Pass/Fail] |
| [Module Name] | `path/to/file.svelte` | [x] | [XX%] | [Pass/Fail] |
---
## Test Cases
### [Module Name]
**Target File**: `path/to/module.py`
| ID | Test Case | Type | Expected Result | Status |
|----|-----------|------|------------------|--------|
| TC001 | [Description] | [Unit/Integration] | [Expected] | [Pass/Fail] |
| TC002 | [Description] | [Unit/Integration] | [Expected] | [Pass/Fail] |
---
## Test Execution Reports
### Report [YYYY-MM-DD]
**Executed by**: [Tester]
**Duration**: [X] minutes
**Result**: [Pass/Fail]
**Summary**:
- Total Tests: [X]
- Passed: [X]
- Failed: [X]
- Skipped: [X]
**Failed Tests**:
| Test | Error | Resolution |
|------|-------|-------------|
| [Test Name] | [Error Message] | [How Fixed] |
---
## Anti-Patterns & Rules
### ✅ DO
1. Write tests BEFORE implementation (TDD approach)
2. Use co-location: `src/module/__tests__/test_module.py`
3. Use MagicMock for external dependencies (DB, Auth, APIs)
4. Include semantic annotations: `# @RELATION: VERIFIES -> module.name`
5. Test edge cases and error conditions
6. **Test UX states** for Svelte components (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY)
### ❌ DON'T
1. Delete existing tests (only update if they fail)
2. Duplicate tests - check for existing tests first
3. Test implementation details, not behavior
4. Use real external services in unit tests
5. Skip error handling tests
6. **Skip UX contract tests** for CRITICAL frontend components
---
## UX Contract Testing (Frontend)
### UX States Coverage
| Component | @UX_STATE | @UX_FEEDBACK | @UX_RECOVERY | Tests |
|-----------|-----------|--------------|--------------|-------|
| [Component] | [states] | [feedback] | [recovery] | [status] |
### UX Test Cases
| ID | Component | UX Tag | Test Action | Expected Result | Status |
|----|-----------|--------|-------------|-----------------|--------|
| UX001 | [Component] | @UX_STATE: Idle | [action] | [expected] | [Pass/Fail] |
| UX002 | [Component] | @UX_FEEDBACK | [action] | [expected] | [Pass/Fail] |
| UX003 | [Component] | @UX_RECOVERY | [action] | [expected] | [Pass/Fail] |
### UX Test Examples
```javascript
// Testing @UX_STATE transition
it('should transition from Idle to Loading on submit', async () => {
render(FormComponent);
await fireEvent.click(screen.getByText('Submit'));
expect(screen.getByTestId('form')).toHaveClass('loading');
});
// Testing @UX_FEEDBACK
it('should show error toast on validation failure', async () => {
render(FormComponent);
await fireEvent.click(screen.getByText('Submit'));
expect(screen.getByRole('alert')).toHaveTextContent('Validation error');
});
// Testing @UX_RECOVERY
it('should allow retry after error', async () => {
render(FormComponent);
// Trigger error state
await fireEvent.click(screen.getByText('Submit'));
// Click retry
await fireEvent.click(screen.getByText('Retry'));
expect(screen.getByTestId('form')).not.toHaveClass('error');
});
```
---
## Notes
- [Additional notes about testing approach]
- [Known issues or limitations]
- [Recommendations for future testing]
---
## Related Documents
- [spec.md](./spec.md)
- [plan.md](./plan.md)
- [tasks.md](./tasks.md)
- [contracts/](./contracts/)

View File

@@ -0,0 +1,67 @@
# UX Reference: [FEATURE NAME]
**Feature Branch**: `[###-feature-name]`
**Created**: [DATE]
**Status**: Draft
## 1. User Persona & Context
* **Who is the user?**: [e.g. Junior Developer, System Administrator, End User]
* **What is their goal?**: [e.g. Quickly deploy a hotfix, Visualize complex data]
* **Context**: [e.g. Running a command in a terminal on a remote server, Browsing the dashboard on a mobile device]
## 2. The "Happy Path" Narrative
[Write a short story (3-5 sentences) describing the perfect interaction from the user's perspective. Focus on how it *feels* - is it instant? Does it guide them?]
## 3. Interface Mockups
### CLI Interaction (if applicable)
```bash
# User runs this command:
$ command --flag value
# System responds immediately with:
[ spinner ] specific loading message...
# Success output:
✅ Operation completed successfully in 1.2s
- Created file: /path/to/file
- Updated config: /path/to/config
```
### UI Layout & Flow (if applicable)
**Screen/Component**: [Name]
* **Layout**: [Description of structure, e.g., "Two-column layout, left sidebar navigation..."]
* **Key Elements**:
* **[Button Name]**: Primary action. Color: Blue.
* **[Input Field]**: Placeholder text: "Enter your name...". Validation: Real-time.
* **States**:
* **Default**: Clean state, waiting for input.
* **Loading**: Skeleton loader replaces content area.
* **Success**: Toast notification appears top-right: "Saved!" (Green).
## 4. The "Error" Experience
**Philosophy**: Don't just report the error; guide the user to the fix.
### Scenario A: [Common Error, e.g. Invalid Input]
* **User Action**: Enters "123" in a text-only field.
* **System Response**:
* (UI) Input border turns Red. Message below input: "Please enter text only."
* (CLI) `❌ Error: Invalid input '123'. Expected text format.`
* **Recovery**: User can immediately re-type without refreshing/re-running.
### Scenario B: [System Failure, e.g. Network Timeout]
* **System Response**: "Unable to connect. Retrying in 3s... (Press C to cancel)"
* **Recovery**: Automatic retry or explicit "Retry Now" button.
## 5. Tone & Voice
* **Style**: [e.g. Concise, Technical, Friendly, Verbose]
* **Terminology**: [e.g. Use "Repository" not "Repo", "Directory" not "Folder"]

213
README.md
View File

@@ -1,119 +1,128 @@
# Инструменты автоматизации Superset
# Инструменты автоматизации Superset (ss-tools)
## Обзор
Этот репозиторий содержит Python-скрипты и библиотеку (`superset_tool`) для автоматизации задач в Apache Superset, таких как:
- **Резервное копирование**: Экспорт всех дашбордов из экземпляра Superset в локальное хранилище.
- **Миграция**: Перенос и преобразование дашбордов между разными средами Superset (например, Development, Sandbox, Production).
**ss-tools** — это современная платформа для автоматизации и управления экосистемой Apache Superset. Проект перешел от набора CLI-скриптов к полноценному веб-приложению с архитектурой Backend (FastAPI) + Frontend (SvelteKit), обеспечивая удобный интерфейс для сложных операций.
## Основные возможности
### 🚀 Миграция и управление дашбордами
- **Dashboard Grid**: Удобный просмотр всех дашбордов во всех окружениях (Dev, Sandbox, Prod) в едином интерфейсе.
- **Интеллектуальный маппинг**: Автоматическое и ручное сопоставление датасетов, таблиц и схем при переносе между окружениями.
- **Проверка зависимостей**: Валидация наличия всех необходимых компонентов перед миграцией.
### 📦 Резервное копирование
- **Планировщик (Scheduler)**: Автоматическое создание резервных копий дашбордов и датасетов по расписанию.
- **Хранилище**: Локальное хранение артефактов с возможностью управления через UI.
### 🛠 Git Интеграция
- **Version Control**: Возможность версионирования ассетов Superset.
- **Git Dashboard**: Управление ветками, коммитами и деплоем изменений напрямую из интерфейса.
- **Conflict Resolution**: Встроенные инструменты для разрешения конфликтов в YAML-конфигурациях.
### 🤖 LLM Анализ (AI Plugin)
- **Автоматический аудит**: Анализ состояния дашбордов на основе скриншотов и метаданных.
- **Генерация документации**: Автоматическое описание датасетов и колонок с помощью LLM (OpenAI, OpenRouter и др.).
- **Smart Validation**: Поиск аномалий и ошибок в визуализациях.
### 🔐 Безопасность и администрирование
- **Multi-user Auth**: Многопользовательский доступ с ролевой моделью (RBAC).
- **Управление подключениями**: Централизованная настройка доступов к различным инстансам Superset.
- **Логирование**: Подробная история выполнения всех фоновых задач.
## Технологический стек
- **Backend**: Python 3.9+, FastAPI, SQLAlchemy, APScheduler, Pydantic.
- **Frontend**: Node.js 18+, SvelteKit, Tailwind CSS.
- **Database**: PostgreSQL (для хранения метаданных, задач, логов и конфигурации).
## Структура проекта
- `backup_script.py`: Основной скрипт для выполнения запланированного резервного копирования дашбордов Superset.
- `migration_script.py`: Основной скрипт для переноса конкретных дашбордов между окружениями, включая переопределение соединений с базами данных.
- `search_script.py`: Скрипт для поиска данных во всех доступных датасетах на сервере
- `run_mapper.py`: CLI-скрипт для маппинга метаданных датасетов.
- `superset_tool/`:
- `client.py`: Python-клиент для взаимодействия с API Superset.
- `exceptions.py`: Пользовательские классы исключений для структурированной обработки ошибок.
- `models.py`: Pydantic-модели для валидации конфигурационных данных.
- `utils/`:
- `fileio.py`: Утилиты для работы с файловой системой (работа с архивами, парсинг YAML).
- `logger.py`: Конфигурация логгера для единообразного логирования в проекте.
- `network.py`: HTTP-клиент для сетевых запросов с обработкой аутентификации и повторных попыток.
- `init_clients.py`: Утилита для инициализации клиентов Superset для разных окружений.
- `dataset_mapper.py`: Логика маппинга метаданных датасетов.
- `backend/` — Серверная часть, API и логика плагинов.
- `frontend/` — Клиентская часть (SvelteKit приложение).
- `specs/` — Спецификации функций и планы реализации.
- `docs/` — Дополнительная документация по маппингу и разработке плагинов.
## Настройка
## Быстрый старт
### Требования
- Python 3.9+
- `pip` для управления пакетами.
- `keyring` для безопасного хранения паролей.
- Node.js 18+
- Настроенный доступ к API Superset
### Установка
1. **Клонируйте репозиторий:**
```bash
git clone https://prod.gitlab.dwh.rusal.com/dwh_bi/superset-tools.git
cd superset-tools
```
2. **Установите зависимости:**
```bash
pip install -r requirements.txt
```
(Возможно, потребуется создать `requirements.txt` с `pydantic`, `requests`, `keyring`, `PyYAML`, `urllib3`)
3. **Настройте пароли:**
Используйте `keyring` для хранения паролей API-пользователей Superset.
```python
import keyring
keyring.set_password("system", "dev migrate", "пароль пользователя migrate_user")
keyring.set_password("system", "prod migrate", "пароль пользователя migrate_user")
keyring.set_password("system", "sandbox migrate", "пароль пользователя migrate_user")
```
## Использование
### Запуск проекта (Web UI)
Для запуска backend и frontend серверов одной командой:
### Запуск
Для автоматической настройки окружений и запуска обоих серверов (Backend & Frontend) используйте скрипт:
```bash
./run.sh
```
*Скрипт создаст виртуальное окружение Python, установит зависимости `pip` и `npm`, и запустит сервисы.*
Опции:
- `--skip-install`: Пропустить проверку и установку зависимостей.
- `--skip-install`: Пропустить установку зависимостей.
- `--help`: Показать справку.
Переменные окружения:
- `BACKEND_PORT`: Порт для backend (по умолчанию 8000).
- `FRONTEND_PORT`: Порт для frontend (по умолчанию 5173).
Переменные окружения:
- `BACKEND_PORT`: Порт API (по умолчанию 8000).
- `FRONTEND_PORT`: Порт UI (по умолчанию 5173).
- `POSTGRES_URL`: Базовый URL PostgreSQL по умолчанию для всех подсистем.
- `DATABASE_URL`: URL основной БД (если не задан, используется `POSTGRES_URL`).
- `TASKS_DATABASE_URL`: URL БД задач/логов (если не задан, используется `DATABASE_URL`).
- `AUTH_DATABASE_URL`: URL БД авторизации (если не задан, используется PostgreSQL дефолт).
### Скрипт резервного копирования (`backup_script.py`)
Для создания резервных копий дашбордов из настроенных окружений Superset:
```bash
python backup_script.py
```
Резервные копии сохраняются в `P:\Superset\010 Бекапы\` по умолчанию. Логи хранятся в `P:\Superset\010 Бекапы\Logs`.
## Разработка
Проект следует строгим правилам разработки:
1. **Semantic Code Generation**: Использование протокола `.ai/standards/semantics.md` для обеспечения надежности кода.
2. **Design by Contract (DbC)**: Определение предусловий и постусловий для ключевых функций.
3. **Constitution**: Соблюдение правил, описанных в конституции проекта в папке `.specify/`.
### Скрипт миграции (`migration_script.py`)
Для переноса конкретного дашборда:
```bash
python migration_script.py
```
### Скрипт поиска (`search_script.py`)
Для поиска по текстовым паттернам в метаданных датасетов Superset:
```bash
python search_script.py
```
Скрипт использует регулярные выражения для поиска в полях датасетов, таких как SQL-запросы. Результаты поиска выводятся в лог и в консоль.
### Скрипт маппинга метаданных (`run_mapper.py`)
Для обновления метаданных датасета (например, verbose names) в Superset:
```bash
python run_mapper.py --source <source_type> --dataset-id <dataset_id> [--table-name <table_name>] [--table-schema <table_schema>] [--excel-path <path_to_excel>] [--env <environment>]
```
Если вы используете XLSX - файл должен содержать два столбца - column_name | verbose_name
Параметры:
- `--source`: Источник данных ('postgres', 'excel' или 'both').
- `--dataset-id`: ID датасета для обновления.
- `--table-name`: Имя таблицы для PostgreSQL.
- `--table-schema`: Схема таблицы для PostgreSQL.
- `--excel-path`: Путь к Excel-файлу.
- `--env`: Окружение Superset ('dev', 'prod' и т.д.).
Пример использования:
```bash
python run_mapper.py --source postgres --dataset-id 123 --table-name account_debt --table-schema dm_view --env dev
python run_mapper.py --source=excel --dataset-id=286 --excel-path=H:\dev\ss-tools\286_map.xlsx --env=dev
```
## Логирование
Логи пишутся в файл в директории `Logs` (например, `P:\Superset\010 Бекапы\Logs` для резервных копий) и выводятся в консоль. Уровень логирования по умолчанию — `INFO`.
## Разработка и вклад
- Следуйте **Semantic Code Generation Protocol** (см. `semantic_protocol.md`):
- Все определения обернуты в `[DEF]...[/DEF]`.
- Контракты (`@PRE`, `@POST`) определяются ДО реализации.
- Строгая типизация и иммутабельность архитектурных решений.
- Соблюдайте Конституцию проекта (`.specify/memory/constitution.md`).
- Используйте `Pydantic`-модели для валидации данных.
- Реализуйте всестороннюю обработку ошибок с помощью пользовательских исключений.
### Полезные команды
- **Backend**: `cd backend && .venv/bin/python3 -m uvicorn src.app:app --reload`
- **Frontend**: `cd frontend && npm run dev`
- **Тесты**: `cd backend && .venv/bin/pytest`
## Docker и CI/CD
### Локальный запуск в Docker (приложение + PostgreSQL)
```bash
docker compose up --build
```
После старта:
- UI/API: `http://localhost:8000`
- PostgreSQL: `localhost:5432` (`postgres/postgres`, DB `ss_tools`)
Остановить:
```bash
docker compose down
```
Полная очистка тома БД:
```bash
docker compose down -v
```
Если `postgres:16-alpine` не тянется из Docker Hub (TLS timeout), используйте fallback image:
```bash
POSTGRES_IMAGE=mirror.gcr.io/library/postgres:16-alpine docker compose up -d db
```
или:
```bash
POSTGRES_IMAGE=bitnami/postgresql:latest docker compose up -d db
```
Если на хосте уже занят `5432`, поднимайте Postgres на другом порту:
```bash
POSTGRES_HOST_PORT=5433 docker compose up -d db
```
### Миграция legacy-данных в PostgreSQL
Если нужно перенести старые данные из `tasks.db`/`config.json`:
```bash
cd backend
PYTHONPATH=. .venv/bin/python src/scripts/migrate_sqlite_to_postgres.py --sqlite-path tasks.db
```
### CI/CD
Добавлен workflow: `.github/workflows/ci-cd.yml`
- backend smoke tests
- frontend build
- docker build
- push образа в GHCR на `main/master`
## Контакты и вклад
Для добавления новых функций или исправления ошибок, пожалуйста, ознакомьтесь с `docs/plugin_dev.md` и создайте соответствующую спецификацию в `specs/`.

View File

@@ -1,269 +0,0 @@
2025-12-20 19:55:11,325 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 19:55:11,325 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 19:55:11,327 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 43, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 21:01:49,905 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 21:01:49,906 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 21:01:49,988 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 21:01:49,990 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 22:42:32,538 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 22:42:32,538 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 22:42:32,583 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 22:42:32,587 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 22:54:29,770 - INFO - [BackupPlugin][Entry] Starting backup for .
2025-12-20 22:54:29,771 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 22:54:29,831 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 22:54:29,833 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 22:54:34,078 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 22:54:34,078 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 22:54:34,079 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 22:54:34,079 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 22:59:25,060 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 22:59:25,060 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 22:59:25,114 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 22:59:25,117 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:00:31,156 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:00:31,156 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:00:31,157 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:00:31,162 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:00:34,710 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:00:34,710 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:00:34,710 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:00:34,711 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:01:43,894 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:01:43,894 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:01:43,895 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:01:43,895 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:04:07,731 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:04:07,731 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:04:07,732 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:04:07,732 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:06:39,641 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:06:39,642 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:06:39,687 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:06:39,689 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
Traceback (most recent call last):
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
config = SupersetConfig(
^^^^^^^^^^^^^^^
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
base_url
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/value_error
2025-12-20 23:30:36,090 - INFO - [BackupPlugin][Entry] Starting backup for superset.
2025-12-20 23:30:36,093 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
2025-12-20 23:30:36,128 - INFO - [setup_clients][Action] Loading environments from ConfigManager
2025-12-20 23:30:36,129 - INFO - [SupersetClient.__init__][Enter] Initializing SupersetClient.
2025-12-20 23:30:36,129 - INFO - [APIClient.__init__][Entry] Initializing APIClient.
2025-12-20 23:30:36,130 - WARNING - [_init_session][State] SSL verification disabled.
2025-12-20 23:30:36,130 - INFO - [APIClient.__init__][Exit] APIClient initialized.
2025-12-20 23:30:36,130 - INFO - [SupersetClient.__init__][Exit] SupersetClient initialized.
2025-12-20 23:30:36,130 - INFO - [get_dashboards][Enter] Fetching dashboards.
2025-12-20 23:30:36,131 - INFO - [authenticate][Enter] Authenticating to https://superset.bebesh.ru/api/v1
2025-12-20 23:30:36,897 - INFO - [authenticate][Exit] Authenticated successfully.
2025-12-20 23:30:37,527 - INFO - [get_dashboards][Exit] Found 11 dashboards.
2025-12-20 23:30:37,527 - INFO - [BackupPlugin][Progress] Found 11 dashboards to export in superset.
2025-12-20 23:30:37,529 - INFO - [export_dashboard][Enter] Exporting dashboard 11.
2025-12-20 23:30:38,224 - INFO - [export_dashboard][Exit] Exported dashboard 11 to dashboard_export_20251220T203037.zip.
2025-12-20 23:30:38,225 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:38,226 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/FCC New Coder Survey 2018/dashboard_export_20251220T203037.zip
2025-12-20 23:30:38,227 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/FCC New Coder Survey 2018
2025-12-20 23:30:38,230 - INFO - [export_dashboard][Enter] Exporting dashboard 10.
2025-12-20 23:30:38,438 - INFO - [export_dashboard][Exit] Exported dashboard 10 to dashboard_export_20251220T203038.zip.
2025-12-20 23:30:38,438 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:38,439 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/COVID Vaccine Dashboard/dashboard_export_20251220T203038.zip
2025-12-20 23:30:38,439 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/COVID Vaccine Dashboard
2025-12-20 23:30:38,440 - INFO - [export_dashboard][Enter] Exporting dashboard 9.
2025-12-20 23:30:38,853 - INFO - [export_dashboard][Exit] Exported dashboard 9 to dashboard_export_20251220T203038.zip.
2025-12-20 23:30:38,853 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:38,856 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Sales Dashboard/dashboard_export_20251220T203038.zip
2025-12-20 23:30:38,856 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Sales Dashboard
2025-12-20 23:30:38,858 - INFO - [export_dashboard][Enter] Exporting dashboard 8.
2025-12-20 23:30:38,939 - INFO - [export_dashboard][Exit] Exported dashboard 8 to dashboard_export_20251220T203038.zip.
2025-12-20 23:30:38,940 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:38,941 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Unicode Test/dashboard_export_20251220T203038.zip
2025-12-20 23:30:38,941 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Unicode Test
2025-12-20 23:30:38,942 - INFO - [export_dashboard][Enter] Exporting dashboard 7.
2025-12-20 23:30:39,148 - INFO - [export_dashboard][Exit] Exported dashboard 7 to dashboard_export_20251220T203038.zip.
2025-12-20 23:30:39,148 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:39,149 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Video Game Sales/dashboard_export_20251220T203038.zip
2025-12-20 23:30:39,149 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Video Game Sales
2025-12-20 23:30:39,150 - INFO - [export_dashboard][Enter] Exporting dashboard 6.
2025-12-20 23:30:39,689 - INFO - [export_dashboard][Exit] Exported dashboard 6 to dashboard_export_20251220T203039.zip.
2025-12-20 23:30:39,689 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:39,690 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Featured Charts/dashboard_export_20251220T203039.zip
2025-12-20 23:30:39,691 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Featured Charts
2025-12-20 23:30:39,692 - INFO - [export_dashboard][Enter] Exporting dashboard 5.
2025-12-20 23:30:39,960 - INFO - [export_dashboard][Exit] Exported dashboard 5 to dashboard_export_20251220T203039.zip.
2025-12-20 23:30:39,960 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:39,961 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Slack Dashboard/dashboard_export_20251220T203039.zip
2025-12-20 23:30:39,961 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Slack Dashboard
2025-12-20 23:30:39,962 - INFO - [export_dashboard][Enter] Exporting dashboard 4.
2025-12-20 23:30:40,196 - INFO - [export_dashboard][Exit] Exported dashboard 4 to dashboard_export_20251220T203039.zip.
2025-12-20 23:30:40,196 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:40,197 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/deck.gl Demo/dashboard_export_20251220T203039.zip
2025-12-20 23:30:40,197 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/deck.gl Demo
2025-12-20 23:30:40,198 - INFO - [export_dashboard][Enter] Exporting dashboard 3.
2025-12-20 23:30:40,745 - INFO - [export_dashboard][Exit] Exported dashboard 3 to dashboard_export_20251220T203040.zip.
2025-12-20 23:30:40,746 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:40,760 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Misc Charts/dashboard_export_20251220T203040.zip
2025-12-20 23:30:40,761 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Misc Charts
2025-12-20 23:30:40,762 - INFO - [export_dashboard][Enter] Exporting dashboard 2.
2025-12-20 23:30:40,928 - INFO - [export_dashboard][Exit] Exported dashboard 2 to dashboard_export_20251220T203040.zip.
2025-12-20 23:30:40,929 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:40,930 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/USA Births Names/dashboard_export_20251220T203040.zip
2025-12-20 23:30:40,931 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/USA Births Names
2025-12-20 23:30:40,932 - INFO - [export_dashboard][Enter] Exporting dashboard 1.
2025-12-20 23:30:41,582 - INFO - [export_dashboard][Exit] Exported dashboard 1 to dashboard_export_20251220T203040.zip.
2025-12-20 23:30:41,582 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
2025-12-20 23:30:41,749 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/World Bank's Data/dashboard_export_20251220T203040.zip
2025-12-20 23:30:41,750 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/World Bank's Data
2025-12-20 23:30:41,752 - INFO - [consolidate_archive_folders][Enter] Consolidating archives in backups/SUPERSET
2025-12-20 23:30:41,753 - INFO - [remove_empty_directories][Enter] Starting cleanup of empty directories in backups/SUPERSET
2025-12-20 23:30:41,758 - INFO - [remove_empty_directories][Exit] Removed 0 empty directories.
2025-12-20 23:30:41,758 - INFO - [BackupPlugin][CoherenceCheck:Passed] Backup logic completed for superset.

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
# [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()
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()
# [/DEF:delete_running_tasks:Function]
if __name__ == "__main__":
delete_running_tasks()
# [/DEF:backend.delete_running_tasks:Module]

1
backend/get_full_key.py Normal file
View File

@@ -0,0 +1 @@
{"print(f'Length": {"else": "print('Provider not found')\ndb.close()"}}

1
backend/git_repos/12 Submodule

Submodule backend/git_repos/12 added at 57ab7e8679

56509
backend/logs/app.log.1 Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

BIN
backend/migrations.db Normal file

Binary file not shown.

View File

@@ -1,14 +1,57 @@
fastapi
uvicorn
pydantic
authlib
python-multipart
starlette
jsonschema
requests
keyring
httpx
PyYAML
websockets
rapidfuzz
sqlalchemy
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.0
APScheduler==3.11.2
attrs==25.4.0
Authlib==1.6.6
certifi==2025.11.12
cffi==2.0.0
charset-normalizer==3.4.4
click==8.3.1
cryptography==46.0.3
fastapi==0.126.0
greenlet==3.3.0
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.11
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.3.0
jeepney==0.9.0
jsonschema==4.25.1
jsonschema-specifications==2025.9.1
keyring==25.7.0
more-itertools==10.8.0
pycparser==2.23
pydantic==2.12.5
pydantic-settings
pydantic_core==2.41.5
python-multipart==0.0.21
PyYAML==6.0.3
passlib[bcrypt]
python-jose[cryptography]
PyJWT
RapidFuzz==3.14.3
referencing==0.37.0
requests==2.32.5
rpds-py==0.30.0
SecretStorage==3.5.0
SQLAlchemy==2.0.45
starlette==0.50.0
typing-inspection==0.4.2
typing_extensions==4.15.0
tzlocal==5.3.1
urllib3==2.6.2
uvicorn==0.38.0
websockets==15.0.1
pandas
psycopg2-binary
openpyxl
GitPython==3.1.44
itsdangerous
email-validator
openai
playwright
tenacity
Pillow

View File

@@ -1,52 +1,118 @@
# [DEF:AuthModule:Module]
# @SEMANTICS: auth, authentication, adfs, oauth, middleware
# @PURPOSE: Implements ADFS authentication using Authlib for FastAPI. It provides a dependency to protect endpoints.
# @LAYER: UI (API)
# @RELATION: Used by API routers to protect endpoints that require authentication.
# [DEF:backend.src.api.auth:Module]
#
# @SEMANTICS: api, auth, routes, login, logout
# @PURPOSE: Authentication API endpoints.
# @LAYER: API
# @RELATION: USES -> backend.src.services.auth_service.AuthService
# @RELATION: USES -> backend.src.core.database.get_auth_db
#
# @INVARIANT: All auth endpoints must return consistent error codes.
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2AuthorizationCodeBearer
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from ..core.database import get_auth_db
from ..services.auth_service import AuthService
from ..schemas.auth import Token, User as UserSchema
from ..dependencies import get_current_user
from ..core.auth.oauth import oauth, is_adfs_configured
from ..core.auth.logger import log_security_event
from ..core.logger import belief_scope
import starlette.requests
# [/SECTION]
# Placeholder for ADFS configuration. In a real app, this would come from a secure source.
# Create an in-memory .env file
from io import StringIO
config_data = StringIO("""
ADFS_CLIENT_ID=your-client-id
ADFS_CLIENT_SECRET=your-client-secret
ADFS_SERVER_METADATA_URL=https://your-adfs-server/.well-known/openid-configuration
""")
config = Config(config_data)
oauth = OAuth(config)
# [DEF:router:Variable]
# @PURPOSE: APIRouter instance for authentication routes.
router = APIRouter(prefix="/api/auth", tags=["auth"])
# [/DEF:router:Variable]
oauth.register(
name='adfs',
server_metadata_url=config('ADFS_SERVER_METADATA_URL'),
client_kwargs={'scope': 'openid profile email'}
)
# [DEF:login_for_access_token:Function]
# @PURPOSE: Authenticates a user and returns a JWT access token.
# @PRE: form_data contains username and password.
# @POST: Returns a Token object on success.
# @THROW: HTTPException 401 if authentication fails.
# @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials.
# @PARAM: db (Session) - Auth database session.
# @RETURN: Token - The generated JWT token.
@router.post("/login", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_auth_db)
):
with belief_scope("api.auth.login"):
auth_service = AuthService(db)
user = auth_service.authenticate_user(form_data.username, form_data.password)
if not user:
log_security_event("LOGIN_FAILED", form_data.username, {"reason": "Invalid credentials"})
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
log_security_event("LOGIN_SUCCESS", user.username, {"source": "LOCAL"})
return auth_service.create_session(user)
# [/DEF:login_for_access_token:Function]
oauth2_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl="https://your-adfs-server/adfs/oauth2/authorize",
tokenUrl="https://your-adfs-server/adfs/oauth2/token",
)
# [DEF:read_users_me:Function]
# @PURPOSE: Retrieves the profile of the currently authenticated user.
# @PRE: Valid JWT token provided.
# @POST: Returns the current user's data.
# @PARAM: current_user (UserSchema) - The user extracted from the token.
# @RETURN: UserSchema - The current user profile.
@router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.me"):
return current_user
# [/DEF:read_users_me:Function]
async def get_current_user(token: str = Depends(oauth2_scheme)):
"""
Dependency to get the current user from the ADFS token.
This is a placeholder and needs to be fully implemented.
"""
# In a real implementation, you would:
# 1. Validate the token with ADFS.
# 2. Fetch user information.
# 3. Create a user object.
# For now, we'll just check if a token exists.
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# A real implementation would return a user object.
return {"placeholder_user": "user@example.com"}
# [/DEF]
# [DEF:logout:Function]
# @PURPOSE: Logs out the current user (placeholder for session revocation).
# @PRE: Valid JWT token provided.
# @POST: Returns success message.
@router.post("/logout")
async def logout(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.logout"):
log_security_event("LOGOUT", current_user.username)
# In a stateless JWT setup, client-side token deletion is primary.
# Server-side revocation (blacklisting) can be added here if needed.
return {"message": "Successfully logged out"}
# [/DEF:logout:Function]
# [DEF:login_adfs:Function]
# @PURPOSE: Initiates the ADFS OIDC login flow.
# @POST: Redirects the user to ADFS.
@router.get("/login/adfs")
async def login_adfs(request: starlette.requests.Request):
with belief_scope("api.auth.login_adfs"):
if not is_adfs_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables."
)
redirect_uri = request.url_for('auth_callback_adfs')
return await oauth.adfs.authorize_redirect(request, str(redirect_uri))
# [/DEF:login_adfs:Function]
# [DEF:auth_callback_adfs:Function]
# @PURPOSE: Handles the callback from ADFS after successful authentication.
# @POST: Provisions user JIT and returns session token.
@router.get("/callback/adfs", name="auth_callback_adfs")
async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)):
with belief_scope("api.auth.callback_adfs"):
if not is_adfs_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables."
)
token = await oauth.adfs.authorize_access_token(request)
user_info = token.get('userinfo')
if not user_info:
raise HTTPException(status_code=400, detail="Failed to retrieve user info from ADFS")
auth_service = AuthService(db)
user = auth_service.provision_adfs_user(user_info)
return auth_service.create_session(user)
# [/DEF:auth_callback_adfs:Function]
# [/DEF:backend.src.api.auth:Module]

View File

@@ -1 +1,10 @@
from . import plugins, tasks, settings
# Lazy loading of route modules to avoid import issues in tests
# This allows tests to import routes without triggering all module imports
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin', 'reports']
def __getattr__(name):
if name in __all__:
import importlib
return importlib.import_module(f".{name}", __name__)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -0,0 +1,357 @@
# [DEF:backend.src.api.routes.__tests__.test_dashboards:Module]
# @TIER: STANDARD
# @PURPOSE: Unit tests for Dashboards API endpoints
# @LAYER: API
# @RELATION: TESTS -> backend.src.api.routes.dashboards
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from fastapi.testclient import TestClient
from src.app import app
from src.api.routes.dashboards import DashboardsResponse
client = TestClient(app)
# [DEF:test_get_dashboards_success:Function]
# @TEST: GET /api/dashboards returns 200 and valid schema
# @PRE: env_id exists
# @POST: Response matches DashboardsResponse schema
def test_get_dashboards_success():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.get_resource_service") as mock_service, \
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock task manager
mock_task_mgr.return_value.get_all_tasks.return_value = []
# Mock resource service response
async def mock_get_dashboards(env, tasks):
return [
{
"id": 1,
"title": "Sales Report",
"slug": "sales",
"git_status": {"branch": "main", "sync_status": "OK"},
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
}
]
mock_service.return_value.get_dashboards_with_status = AsyncMock(
side_effect=mock_get_dashboards
)
# Mock permission
mock_perm.return_value = lambda: True
response = client.get("/api/dashboards?env_id=prod")
assert response.status_code == 200
data = response.json()
assert "dashboards" in data
assert "total" in data
assert "page" in data
# [/DEF:test_get_dashboards_success:Function]
# [DEF:test_get_dashboards_with_search:Function]
# @TEST: GET /api/dashboards filters by search term
# @PRE: search parameter provided
# @POST: Only matching dashboards returned
def test_get_dashboards_with_search():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.get_resource_service") as mock_service, \
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
mock_task_mgr.return_value.get_all_tasks.return_value = []
async def mock_get_dashboards(env, tasks):
return [
{"id": 1, "title": "Sales Report", "slug": "sales"},
{"id": 2, "title": "Marketing Dashboard", "slug": "marketing"}
]
mock_service.return_value.get_dashboards_with_status = AsyncMock(
side_effect=mock_get_dashboards
)
mock_perm.return_value = lambda: True
response = client.get("/api/dashboards?env_id=prod&search=sales")
assert response.status_code == 200
data = response.json()
# Filtered by search term
# [/DEF:test_get_dashboards_with_search:Function]
# [DEF:test_get_dashboards_env_not_found:Function]
# @TEST: GET /api/dashboards returns 404 if env_id missing
# @PRE: env_id does not exist
# @POST: Returns 404 error
def test_get_dashboards_env_not_found():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
mock_config.return_value.get_environments.return_value = []
mock_perm.return_value = lambda: True
response = client.get("/api/dashboards?env_id=nonexistent")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_get_dashboards_env_not_found:Function]
# [DEF:test_get_dashboards_invalid_pagination:Function]
# @TEST: GET /api/dashboards returns 400 for invalid page/page_size
# @PRE: page < 1 or page_size > 100
# @POST: Returns 400 error
def test_get_dashboards_invalid_pagination():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
mock_perm.return_value = lambda: True
# Invalid page
response = client.get("/api/dashboards?env_id=prod&page=0")
assert response.status_code == 400
assert "Page must be >= 1" in response.json()["detail"]
# Invalid page_size
response = client.get("/api/dashboards?env_id=prod&page_size=101")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# [/DEF:test_get_dashboards_invalid_pagination:Function]
# [DEF:test_get_dashboard_detail_success:Function]
# @TEST: GET /api/dashboards/{id} returns dashboard detail with charts and datasets
def test_get_dashboard_detail_success():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.has_permission") as mock_perm, \
patch("src.api.routes.dashboards.SupersetClient") as mock_client_cls:
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
mock_perm.return_value = lambda: True
mock_client = MagicMock()
mock_client.get_dashboard_detail.return_value = {
"id": 42,
"title": "Revenue Dashboard",
"slug": "revenue-dashboard",
"url": "/superset/dashboard/42/",
"description": "Overview",
"last_modified": "2026-02-20T10:00:00+00:00",
"published": True,
"charts": [
{
"id": 100,
"title": "Revenue by Month",
"viz_type": "line",
"dataset_id": 7,
"last_modified": "2026-02-19T10:00:00+00:00",
"overview": "line"
}
],
"datasets": [
{
"id": 7,
"table_name": "fact_revenue",
"schema": "mart",
"database": "Analytics",
"last_modified": "2026-02-18T10:00:00+00:00",
"overview": "mart.fact_revenue"
}
],
"chart_count": 1,
"dataset_count": 1
}
mock_client_cls.return_value = mock_client
response = client.get("/api/dashboards/42?env_id=prod")
assert response.status_code == 200
payload = response.json()
assert payload["id"] == 42
assert payload["chart_count"] == 1
assert payload["dataset_count"] == 1
# [/DEF:test_get_dashboard_detail_success:Function]
# [DEF:test_get_dashboard_detail_env_not_found:Function]
# @TEST: GET /api/dashboards/{id} returns 404 for missing environment
def test_get_dashboard_detail_env_not_found():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
mock_config.return_value.get_environments.return_value = []
mock_perm.return_value = lambda: True
response = client.get("/api/dashboards/42?env_id=missing")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_get_dashboard_detail_env_not_found:Function]
# [DEF:test_migrate_dashboards_success:Function]
# @TEST: POST /api/dashboards/migrate creates migration task
# @PRE: Valid source_env_id, target_env_id, dashboard_ids
# @POST: Returns task_id
def test_migrate_dashboards_success():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
# Mock environments
mock_source = MagicMock()
mock_source.id = "source"
mock_target = MagicMock()
mock_target.id = "target"
mock_config.return_value.get_environments.return_value = [mock_source, mock_target]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-migrate-123"
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
# Mock permission
mock_perm.return_value = lambda: True
response = client.post(
"/api/dashboards/migrate",
json={
"source_env_id": "source",
"target_env_id": "target",
"dashboard_ids": [1, 2, 3],
"db_mappings": {"old_db": "new_db"}
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# [/DEF:test_migrate_dashboards_success:Function]
# [DEF:test_migrate_dashboards_no_ids:Function]
# @TEST: POST /api/dashboards/migrate returns 400 for empty dashboard_ids
# @PRE: dashboard_ids is empty
# @POST: Returns 400 error
def test_migrate_dashboards_no_ids():
with patch("src.api.routes.dashboards.has_permission") as mock_perm:
mock_perm.return_value = lambda: True
response = client.post(
"/api/dashboards/migrate",
json={
"source_env_id": "source",
"target_env_id": "target",
"dashboard_ids": []
}
)
assert response.status_code == 400
assert "At least one dashboard ID must be provided" in response.json()["detail"]
# [/DEF:test_migrate_dashboards_no_ids:Function]
# [DEF:test_backup_dashboards_success:Function]
# @TEST: POST /api/dashboards/backup creates backup task
# @PRE: Valid env_id, dashboard_ids
# @POST: Returns task_id
def test_backup_dashboards_success():
with patch("src.api.routes.dashboards.get_config_manager") as mock_config, \
patch("src.api.routes.dashboards.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-backup-456"
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
# Mock permission
mock_perm.return_value = lambda: True
response = client.post(
"/api/dashboards/backup",
json={
"env_id": "prod",
"dashboard_ids": [1, 2, 3],
"schedule": "0 0 * * *"
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# [/DEF:test_backup_dashboards_success:Function]
# [DEF:test_get_database_mappings_success:Function]
# @TEST: GET /api/dashboards/db-mappings returns mapping suggestions
# @PRE: Valid source_env_id, target_env_id
# @POST: Returns list of database mappings
def test_get_database_mappings_success():
with patch("src.api.routes.dashboards.get_mapping_service") as mock_service, \
patch("src.api.routes.dashboards.has_permission") as mock_perm:
# Mock mapping service
mock_service.return_value.get_suggestions = AsyncMock(return_value=[
{
"source_db": "old_sales",
"target_db": "new_sales",
"source_db_uuid": "uuid-1",
"target_db_uuid": "uuid-2",
"confidence": 0.95
}
])
# Mock permission
mock_perm.return_value = lambda: True
response = client.get("/api/dashboards/db-mappings?source_env_id=prod&target_env_id=staging")
assert response.status_code == 200
data = response.json()
assert "mappings" in data
# [/DEF:test_get_database_mappings_success:Function]
# [/DEF:backend.src.api.routes.__tests__.test_dashboards:Module]

View File

@@ -0,0 +1,212 @@
# [DEF:backend.src.api.routes.__tests__.test_datasets:Module]
# @TIER: STANDARD
# @SEMANTICS: datasets, api, tests, pagination, mapping, docs
# @PURPOSE: Unit tests for Datasets API endpoints
# @LAYER: API
# @RELATION: TESTS -> backend.src.api.routes.datasets
# @INVARIANT: Endpoint contracts remain stable for success and validation failure paths.
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from fastapi.testclient import TestClient
from src.app import app
from src.api.routes.datasets import DatasetsResponse, DatasetDetailResponse
client = TestClient(app)
# [DEF:test_get_datasets_success:Function]
# @PURPOSE: Validate successful datasets listing contract for an existing environment.
# @TEST: GET /api/datasets returns 200 and valid schema
# @PRE: env_id exists
# @POST: Response matches DatasetsResponse schema
def test_get_datasets_success():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.get_resource_service") as mock_service, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock resource service response
mock_service.return_value.get_datasets_with_status.return_value = AsyncMock()(
return_value=[
{
"id": 1,
"table_name": "sales_data",
"schema": "public",
"database": "sales_db",
"mapped_fields": {"total": 10, "mapped": 5},
"last_task": {"task_id": "task-1", "status": "SUCCESS"}
}
]
)
# Mock permission
mock_perm.return_value = lambda: True
response = client.get("/api/datasets?env_id=prod")
assert response.status_code == 200
data = response.json()
assert "datasets" in data
assert len(data["datasets"]) >= 0
# Validate against Pydantic model
DatasetsResponse(**data)
# [/DEF:test_get_datasets_success:Function]
# [DEF:test_get_datasets_env_not_found:Function]
# @TEST: GET /api/datasets returns 404 if env_id missing
# @PRE: env_id does not exist
# @POST: Returns 404 error
def test_get_datasets_env_not_found():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
mock_config.return_value.get_environments.return_value = []
mock_perm.return_value = lambda: True
response = client.get("/api/datasets?env_id=nonexistent")
assert response.status_code == 404
assert "Environment not found" in response.json()["detail"]
# [/DEF:test_get_datasets_env_not_found:Function]
# [DEF:test_get_datasets_invalid_pagination:Function]
# @TEST: GET /api/datasets returns 400 for invalid page/page_size
# @PRE: page < 1 or page_size > 100
# @POST: Returns 400 error
def test_get_datasets_invalid_pagination():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
mock_perm.return_value = lambda: True
# Invalid page
response = client.get("/api/datasets?env_id=prod&page=0")
assert response.status_code == 400
assert "Page must be >= 1" in response.json()["detail"]
# Invalid page_size
response = client.get("/api/datasets?env_id=prod&page_size=0")
assert response.status_code == 400
assert "Page size must be between 1 and 100" in response.json()["detail"]
# [/DEF:test_get_datasets_invalid_pagination:Function]
# [DEF:test_map_columns_success:Function]
# @TEST: POST /api/datasets/map-columns creates mapping task
# @PRE: Valid env_id, dataset_ids, source_type
# @POST: Returns task_id
def test_map_columns_success():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-123"
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
# Mock permission
mock_perm.return_value = lambda: True
response = client.post(
"/api/datasets/map-columns",
json={
"env_id": "prod",
"dataset_ids": [1, 2, 3],
"source_type": "postgresql"
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# [/DEF:test_map_columns_success:Function]
# [DEF:test_map_columns_invalid_source_type:Function]
# @TEST: POST /api/datasets/map-columns returns 400 for invalid source_type
# @PRE: source_type is not 'postgresql' or 'xlsx'
# @POST: Returns 400 error
def test_map_columns_invalid_source_type():
with patch("src.api.routes.datasets.has_permission") as mock_perm:
mock_perm.return_value = lambda: True
response = client.post(
"/api/datasets/map-columns",
json={
"env_id": "prod",
"dataset_ids": [1],
"source_type": "invalid"
}
)
assert response.status_code == 400
assert "Source type must be 'postgresql' or 'xlsx'" in response.json()["detail"]
# [/DEF:test_map_columns_invalid_source_type:Function]
# [DEF:test_generate_docs_success:Function]
# @TEST: POST /api/datasets/generate-docs creates doc generation task
# @PRE: Valid env_id, dataset_ids, llm_provider
# @POST: Returns task_id
def test_generate_docs_success():
with patch("src.api.routes.datasets.get_config_manager") as mock_config, \
patch("src.api.routes.datasets.get_task_manager") as mock_task_mgr, \
patch("src.api.routes.datasets.has_permission") as mock_perm:
# Mock environment
mock_env = MagicMock()
mock_env.id = "prod"
mock_config.return_value.get_environments.return_value = [mock_env]
# Mock task manager
mock_task = MagicMock()
mock_task.id = "task-456"
mock_task_mgr.return_value.create_task = AsyncMock(return_value=mock_task)
# Mock permission
mock_perm.return_value = lambda: True
response = client.post(
"/api/datasets/generate-docs",
json={
"env_id": "prod",
"dataset_ids": [1],
"llm_provider": "openai"
}
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
# [/DEF:test_generate_docs_success:Function]
# [/DEF:backend.src.api.routes.__tests__.test_datasets:Module]

View File

@@ -0,0 +1,139 @@
# [DEF:backend.tests.test_reports_api:Module]
# @TIER: CRITICAL
# @SEMANTICS: tests, reports, api, contract, pagination, filtering
# @PURPOSE: Contract tests for GET /api/reports defaults, pagination, and filtering behavior.
# @LAYER: Domain (Tests)
# @RELATION: TESTS -> backend.src.api.routes.reports
# @INVARIANT: API response contract contains {items,total,page,page_size,has_next,applied_filters}.
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from fastapi.testclient import TestClient
from src.app import app
from src.core.task_manager.models import Task, TaskStatus
from src.dependencies import get_current_user, get_task_manager
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
def get_all_tasks(self):
return self._tasks
def _admin_user():
admin_role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[admin_role])
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, started_at: datetime, finished_at: datetime = None, result=None):
return Task(
id=task_id,
plugin_id=plugin_id,
status=status,
started_at=started_at,
finished_at=finished_at,
params={"environment_id": "env-1"},
result=result or {"summary": f"{plugin_id} {status.value.lower()}"},
)
def test_get_reports_default_pagination_contract():
now = datetime.utcnow()
tasks = [
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=10), now - timedelta(minutes=9)),
_make_task("t-2", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=8), now - timedelta(minutes=7)),
_make_task("t-3", "llm_dashboard_validation", TaskStatus.RUNNING, now - timedelta(minutes=6), None),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports")
assert response.status_code == 200
data = response.json()
assert set(["items", "total", "page", "page_size", "has_next", "applied_filters"]).issubset(data.keys())
assert data["page"] == 1
assert data["page_size"] == 20
assert data["total"] == 3
assert isinstance(data["items"], list)
assert data["applied_filters"]["sort_by"] == "updated_at"
assert data["applied_filters"]["sort_order"] == "desc"
finally:
app.dependency_overrides.clear()
def test_get_reports_filter_and_pagination():
now = datetime.utcnow()
tasks = [
_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=30), now - timedelta(minutes=29)),
_make_task("t-2", "superset-backup", TaskStatus.FAILED, now - timedelta(minutes=20), now - timedelta(minutes=19)),
_make_task("t-3", "superset-migration", TaskStatus.FAILED, now - timedelta(minutes=10), now - timedelta(minutes=9)),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports?task_types=backup&statuses=failed&page=1&page_size=1")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["page"] == 1
assert data["page_size"] == 1
assert data["has_next"] is False
assert len(data["items"]) == 1
assert data["items"][0]["task_type"] == "backup"
assert data["items"][0]["status"] == "failed"
finally:
app.dependency_overrides.clear()
def test_get_reports_handles_mixed_naive_and_aware_datetimes():
naive_now = datetime.utcnow()
aware_now = datetime.now(timezone.utc)
tasks = [
_make_task("t-naive", "superset-backup", TaskStatus.SUCCESS, naive_now - timedelta(minutes=5), naive_now - timedelta(minutes=4)),
_make_task("t-aware", "superset-migration", TaskStatus.FAILED, aware_now - timedelta(minutes=3), aware_now - timedelta(minutes=2)),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports?sort_by=updated_at&sort_order=desc")
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
assert len(data["items"]) == 2
finally:
app.dependency_overrides.clear()
def test_get_reports_invalid_filter_returns_400():
now = datetime.utcnow()
tasks = [_make_task("t-1", "superset-backup", TaskStatus.SUCCESS, now - timedelta(minutes=5), now - timedelta(minutes=4))]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports?task_types=bad_type")
assert response.status_code == 400
body = response.json()
assert "detail" in body
finally:
app.dependency_overrides.clear()
# [/DEF:backend.tests.test_reports_api:Module]

View File

@@ -0,0 +1,84 @@
# [DEF:backend.tests.test_reports_detail_api:Module]
# @TIER: CRITICAL
# @SEMANTICS: tests, reports, api, detail, diagnostics
# @PURPOSE: Contract tests for GET /api/reports/{report_id} detail endpoint behavior.
# @LAYER: Domain (Tests)
# @RELATION: TESTS -> backend.src.api.routes.reports
# @INVARIANT: Detail endpoint tests must keep deterministic assertions for success and not-found contracts.
from datetime import datetime, timedelta
from types import SimpleNamespace
from fastapi.testclient import TestClient
from src.app import app
from src.core.task_manager.models import Task, TaskStatus
from src.dependencies import get_current_user, get_task_manager
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
def get_all_tasks(self):
return self._tasks
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[role])
def _make_task(task_id: str, plugin_id: str, status: TaskStatus, result=None):
now = datetime.utcnow()
return Task(
id=task_id,
plugin_id=plugin_id,
status=status,
started_at=now - timedelta(minutes=2),
finished_at=now - timedelta(minutes=1) if status != TaskStatus.RUNNING else None,
params={"environment_id": "env-1"},
result=result or {"summary": f"{plugin_id} result"},
)
def test_get_report_detail_success():
task = _make_task(
"detail-1",
"superset-migration",
TaskStatus.FAILED,
result={"error": {"message": "Step failed", "next_actions": ["Check mapping", "Retry"]}},
)
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
try:
client = TestClient(app)
response = client.get("/api/reports/detail-1")
assert response.status_code == 200
data = response.json()
assert "report" in data
assert data["report"]["report_id"] == "detail-1"
assert "diagnostics" in data
assert "next_actions" in data
finally:
app.dependency_overrides.clear()
def test_get_report_detail_not_found():
task = _make_task("detail-2", "superset-backup", TaskStatus.SUCCESS)
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager([task])
try:
client = TestClient(app)
response = client.get("/api/reports/unknown-id")
assert response.status_code == 404
finally:
app.dependency_overrides.clear()
# [/DEF:backend.tests.test_reports_detail_api:Module]

View File

@@ -0,0 +1,81 @@
# [DEF:backend.tests.test_reports_openapi_conformance:Module]
# @TIER: CRITICAL
# @SEMANTICS: tests, reports, openapi, conformance
# @PURPOSE: Validate implemented reports payload shape against OpenAPI-required top-level contract fields.
# @LAYER: Domain (Tests)
# @RELATION: TESTS -> specs/020-task-reports-design/contracts/reports-api.openapi.yaml
# @INVARIANT: List and detail payloads include required contract keys.
from datetime import datetime
from types import SimpleNamespace
from fastapi.testclient import TestClient
from src.app import app
from src.core.task_manager.models import Task, TaskStatus
from src.dependencies import get_current_user, get_task_manager
class _FakeTaskManager:
def __init__(self, tasks):
self._tasks = tasks
def get_all_tasks(self):
return self._tasks
def _admin_user():
role = SimpleNamespace(name="Admin", permissions=[])
return SimpleNamespace(username="test-admin", roles=[role])
def _task(task_id: str, plugin_id: str, status: TaskStatus):
now = datetime.utcnow()
return Task(
id=task_id,
plugin_id=plugin_id,
status=status,
started_at=now,
finished_at=now if status != TaskStatus.RUNNING else None,
params={"environment_id": "env-1"},
result={"summary": f"{plugin_id} {status.value.lower()}"},
)
def test_reports_list_openapi_required_keys():
tasks = [
_task("r-1", "superset-backup", TaskStatus.SUCCESS),
_task("r-2", "superset-migration", TaskStatus.FAILED),
]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports")
assert response.status_code == 200
body = response.json()
required = {"items", "total", "page", "page_size", "has_next", "applied_filters"}
assert required.issubset(body.keys())
finally:
app.dependency_overrides.clear()
def test_reports_detail_openapi_required_keys():
tasks = [_task("r-3", "llm_dashboard_validation", TaskStatus.SUCCESS)]
app.dependency_overrides[get_current_user] = lambda: _admin_user()
app.dependency_overrides[get_task_manager] = lambda: _FakeTaskManager(tasks)
try:
client = TestClient(app)
response = client.get("/api/reports/r-3")
assert response.status_code == 200
body = response.json()
assert "report" in body
finally:
app.dependency_overrides.clear()
# [/DEF:backend.tests.test_reports_openapi_conformance:Module]

View File

@@ -0,0 +1,310 @@
# [DEF:backend.src.api.routes.admin:Module]
#
# @TIER: STANDARD
# @SEMANTICS: api, admin, users, roles, permissions
# @PURPOSE: Admin API endpoints for user and role management.
# @LAYER: API
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
# @RELATION: USES -> backend.src.dependencies.has_permission
#
# @INVARIANT: All endpoints in this module require 'Admin' role or 'admin' scope.
# [SECTION: IMPORTS]
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.database import get_auth_db
from ...core.auth.repository import AuthRepository
from ...core.auth.security import get_password_hash
from ...schemas.auth import (
User as UserSchema, UserCreate, UserUpdate,
RoleSchema, RoleCreate, RoleUpdate, PermissionSchema,
ADGroupMappingSchema, ADGroupMappingCreate
)
from ...models.auth import User, Role, ADGroupMapping
from ...dependencies import has_permission
from ...core.logger import logger, belief_scope
# [/SECTION]
# [DEF:router:Variable]
# @PURPOSE: APIRouter instance for admin routes.
router = APIRouter(prefix="/api/admin", tags=["admin"])
# [/DEF:router:Variable]
# [DEF:list_users:Function]
# @PURPOSE: Lists all registered users.
# @PRE: Current user has 'Admin' role.
# @POST: Returns a list of UserSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[UserSchema] - List of users.
@router.get("/users", response_model=List[UserSchema])
async def list_users(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "READ"))
):
with belief_scope("api.admin.list_users"):
users = db.query(User).all()
return users
# [/DEF:list_users:Function]
# [DEF:create_user:Function]
# @PURPOSE: Creates a new local user.
# @PRE: Current user has 'Admin' role.
# @POST: New user is created in the database.
# @PARAM: user_in (UserCreate) - New user data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: UserSchema - The created user.
@router.post("/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_user(
user_in: UserCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.create_user"):
repo = AuthRepository(db)
if repo.get_user_by_username(user_in.username):
raise HTTPException(status_code=400, detail="Username already exists")
new_user = User(
username=user_in.username,
email=user_in.email,
password_hash=get_password_hash(user_in.password),
auth_source="LOCAL",
is_active=user_in.is_active
)
for role_name in user_in.roles:
role = repo.get_role_by_name(role_name)
if role:
new_user.roles.append(role)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
# [/DEF:create_user:Function]
# [DEF:update_user:Function]
# @PURPOSE: Updates an existing user.
@router.put("/users/{user_id}", response_model=UserSchema)
async def update_user(
user_id: str,
user_in: UserUpdate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.update_user"):
repo = AuthRepository(db)
user = repo.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_in.email is not None:
user.email = user_in.email
if user_in.is_active is not None:
user.is_active = user_in.is_active
if user_in.password is not None:
user.password_hash = get_password_hash(user_in.password)
if user_in.roles is not None:
user.roles = []
for role_name in user_in.roles:
role = repo.get_role_by_name(role_name)
if role:
user.roles.append(role)
db.commit()
db.refresh(user)
return user
# [/DEF:update_user:Function]
# [DEF:delete_user:Function]
# @PURPOSE: Deletes a user.
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.delete_user"):
logger.info(f"[DEBUG] Attempting to delete user context={{'user_id': '{user_id}'}}")
repo = AuthRepository(db)
user = repo.get_user_by_id(user_id)
if not user:
logger.warning(f"[DEBUG] User not found for deletion context={{'user_id': '{user_id}'}}")
raise HTTPException(status_code=404, detail="User not found")
logger.info(f"[DEBUG] Found user to delete context={{'username': '{user.username}'}}")
db.delete(user)
db.commit()
logger.info(f"[DEBUG] Successfully deleted user context={{'user_id': '{user_id}'}}")
return None
# [/DEF:delete_user:Function]
# [DEF:list_roles:Function]
# @PURPOSE: Lists all available roles.
# @RETURN: List[RoleSchema] - List of roles.
# @RELATION: CALLS -> backend.src.models.auth.Role
@router.get("/roles", response_model=List[RoleSchema])
async def list_roles(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "READ"))
):
with belief_scope("api.admin.list_roles"):
return db.query(Role).all()
# [/DEF:list_roles:Function]
# [DEF:create_role:Function]
# @PURPOSE: Creates a new system role with associated permissions.
# @PRE: Role name must be unique.
# @POST: New Role record is created in auth.db.
# @PARAM: role_in (RoleCreate) - New role data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: RoleSchema - The created role.
# @SIDE_EFFECT: Commits new role and associations to auth.db.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_permission_by_id
@router.post("/roles", response_model=RoleSchema, status_code=status.HTTP_201_CREATED)
async def create_role(
role_in: RoleCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.create_role"):
if db.query(Role).filter(Role.name == role_in.name).first():
raise HTTPException(status_code=400, detail="Role already exists")
new_role = Role(name=role_in.name, description=role_in.description)
repo = AuthRepository(db)
for perm_id_or_str in role_in.permissions:
perm = repo.get_permission_by_id(perm_id_or_str)
if not perm and ":" in perm_id_or_str:
res, act = perm_id_or_str.split(":", 1)
perm = repo.get_permission_by_resource_action(res, act)
if perm:
new_role.permissions.append(perm)
db.add(new_role)
db.commit()
db.refresh(new_role)
return new_role
# [/DEF:create_role:Function]
# [DEF:update_role:Function]
# @PURPOSE: Updates an existing role's metadata and permissions.
# @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is updated in auth.db.
# @PARAM: role_id (str) - Target role identifier.
# @PARAM: role_in (RoleUpdate) - Updated role data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: RoleSchema - The updated role.
# @SIDE_EFFECT: Commits updates to auth.db.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
@router.put("/roles/{role_id}", response_model=RoleSchema)
async def update_role(
role_id: str,
role_in: RoleUpdate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.update_role"):
repo = AuthRepository(db)
role = repo.get_role_by_id(role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
if role_in.name is not None:
role.name = role_in.name
if role_in.description is not None:
role.description = role_in.description
if role_in.permissions is not None:
role.permissions = []
for perm_id_or_str in role_in.permissions:
perm = repo.get_permission_by_id(perm_id_or_str)
if not perm and ":" in perm_id_or_str:
res, act = perm_id_or_str.split(":", 1)
perm = repo.get_permission_by_resource_action(res, act)
if perm:
role.permissions.append(perm)
db.commit()
db.refresh(role)
return role
# [/DEF:update_role:Function]
# [DEF:delete_role:Function]
# @PURPOSE: Removes a role from the system.
# @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is removed from auth.db.
# @PARAM: role_id (str) - Target role identifier.
# @PARAM: db (Session) - Auth database session.
# @RETURN: None
# @SIDE_EFFECT: Deletes record from auth.db and commits.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role(
role_id: str,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.delete_role"):
repo = AuthRepository(db)
role = repo.get_role_by_id(role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
db.delete(role)
db.commit()
return None
# [/DEF:delete_role:Function]
# [DEF:list_permissions:Function]
# @PURPOSE: Lists all available system permissions for assignment.
# @POST: Returns a list of all PermissionSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[PermissionSchema] - List of permissions.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.list_permissions
@router.get("/permissions", response_model=List[PermissionSchema])
async def list_permissions(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "READ"))
):
with belief_scope("api.admin.list_permissions"):
repo = AuthRepository(db)
return repo.list_permissions()
# [/DEF:list_permissions:Function]
# [DEF:list_ad_mappings:Function]
# @PURPOSE: Lists all AD Group to Role mappings.
@router.get("/ad-mappings", response_model=List[ADGroupMappingSchema])
async def list_ad_mappings(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("api.admin.list_ad_mappings"):
return db.query(ADGroupMapping).all()
# [/DEF:list_ad_mappings:Function]
# [DEF:create_ad_mapping:Function]
# @PURPOSE: Creates a new AD Group mapping.
@router.post("/ad-mappings", response_model=ADGroupMappingSchema)
async def create_ad_mapping(
mapping_in: ADGroupMappingCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("api.admin.create_ad_mapping"):
new_mapping = ADGroupMapping(
ad_group=mapping_in.ad_group,
role_id=mapping_in.role_id
)
db.add(new_mapping)
db.commit()
db.refresh(new_mapping)
return new_mapping
# [/DEF:create_ad_mapping:Function]
# [/DEF:backend.src.api.routes.admin:Module]

View File

@@ -0,0 +1,100 @@
# [DEF:ConnectionsRouter:Module]
# @SEMANTICS: api, router, connections, database
# @PURPOSE: Defines the FastAPI router for managing external database connections.
# @LAYER: UI (API)
# @RELATION: Depends on SQLAlchemy session.
# @CONSTRAINT: Must use belief_scope for logging.
# [SECTION: IMPORTS]
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...models.connection import ConnectionConfig
from pydantic import BaseModel
from datetime import datetime
from ...core.logger import logger, belief_scope
# [/SECTION]
router = APIRouter()
# [DEF:ConnectionSchema:Class]
# @PURPOSE: Pydantic model for connection response.
class ConnectionSchema(BaseModel):
id: str
name: str
type: str
host: Optional[str] = None
port: Optional[int] = None
database: Optional[str] = None
username: Optional[str] = None
created_at: datetime
class Config:
orm_mode = True
# [/DEF:ConnectionSchema:Class]
# [DEF:ConnectionCreate:Class]
# @PURPOSE: Pydantic model for creating a connection.
class ConnectionCreate(BaseModel):
name: str
type: str
host: Optional[str] = None
port: Optional[int] = None
database: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
# [/DEF:ConnectionCreate:Class]
# [DEF:list_connections:Function]
# @PURPOSE: Lists all saved connections.
# @PRE: Database session is active.
# @POST: Returns list of connection configs.
# @PARAM: db (Session) - Database session.
# @RETURN: List[ConnectionSchema] - List of connections.
@router.get("", response_model=List[ConnectionSchema])
async def list_connections(db: Session = Depends(get_db)):
with belief_scope("ConnectionsRouter.list_connections"):
connections = db.query(ConnectionConfig).all()
return connections
# [/DEF:list_connections:Function]
# [DEF:create_connection:Function]
# @PURPOSE: Creates a new connection configuration.
# @PRE: Connection name is unique.
# @POST: Connection is saved to DB.
# @PARAM: connection (ConnectionCreate) - Config data.
# @PARAM: db (Session) - Database session.
# @RETURN: ConnectionSchema - Created connection.
@router.post("", response_model=ConnectionSchema, status_code=status.HTTP_201_CREATED)
async def create_connection(connection: ConnectionCreate, db: Session = Depends(get_db)):
with belief_scope("ConnectionsRouter.create_connection", f"name={connection.name}"):
db_connection = ConnectionConfig(**connection.dict())
db.add(db_connection)
db.commit()
db.refresh(db_connection)
logger.info(f"[ConnectionsRouter.create_connection][Success] Created connection {db_connection.id}")
return db_connection
# [/DEF:create_connection:Function]
# [DEF:delete_connection:Function]
# @PURPOSE: Deletes a connection configuration.
# @PRE: Connection ID exists.
# @POST: Connection is removed from DB.
# @PARAM: connection_id (str) - ID to delete.
# @PARAM: db (Session) - Database session.
# @RETURN: None.
@router.delete("/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_connection(connection_id: str, db: Session = Depends(get_db)):
with belief_scope("ConnectionsRouter.delete_connection", f"id={connection_id}"):
db_connection = db.query(ConnectionConfig).filter(ConnectionConfig.id == connection_id).first()
if not db_connection:
logger.error(f"[ConnectionsRouter.delete_connection][State] Connection {connection_id} not found")
raise HTTPException(status_code=404, detail="Connection not found")
db.delete(db_connection)
db.commit()
logger.info(f"[ConnectionsRouter.delete_connection][Success] Deleted connection {connection_id}")
return
# [/DEF:delete_connection:Function]
# [/DEF:ConnectionsRouter:Module]

View File

@@ -0,0 +1,396 @@
# [DEF:backend.src.api.routes.dashboards:Module]
#
# @TIER: STANDARD
# @SEMANTICS: api, dashboards, resources, hub
# @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.dependencies
# @RELATION: DEPENDS_ON -> backend.src.services.resource_service
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
#
# @INVARIANT: All dashboard responses include git_status and last_task metadata
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, get_mapping_service, has_permission
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
# [/SECTION]
router = APIRouter(prefix="/api/dashboards", tags=["Dashboards"])
# [DEF:GitStatus:DataClass]
class GitStatus(BaseModel):
branch: Optional[str] = None
sync_status: Optional[str] = Field(None, pattern="^OK|DIFF$")
# [/DEF:GitStatus:DataClass]
# [DEF:LastTask:DataClass]
class LastTask(BaseModel):
task_id: Optional[str] = None
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
# [/DEF:LastTask:DataClass]
# [DEF:DashboardItem:DataClass]
class DashboardItem(BaseModel):
id: int
title: str
slug: Optional[str] = None
url: Optional[str] = None
last_modified: Optional[str] = None
git_status: Optional[GitStatus] = None
last_task: Optional[LastTask] = None
# [/DEF:DashboardItem:DataClass]
# [DEF:DashboardsResponse:DataClass]
class DashboardsResponse(BaseModel):
dashboards: List[DashboardItem]
total: int
page: int
page_size: int
total_pages: int
# [/DEF:DashboardsResponse:DataClass]
# [DEF:DashboardChartItem:DataClass]
class DashboardChartItem(BaseModel):
id: int
title: str
viz_type: Optional[str] = None
dataset_id: Optional[int] = None
last_modified: Optional[str] = None
overview: Optional[str] = None
# [/DEF:DashboardChartItem:DataClass]
# [DEF:DashboardDatasetItem:DataClass]
class DashboardDatasetItem(BaseModel):
id: int
table_name: str
schema: Optional[str] = None
database: str
last_modified: Optional[str] = None
overview: Optional[str] = None
# [/DEF:DashboardDatasetItem:DataClass]
# [DEF:DashboardDetailResponse:DataClass]
class DashboardDetailResponse(BaseModel):
id: int
title: str
slug: Optional[str] = None
url: Optional[str] = None
description: Optional[str] = None
last_modified: Optional[str] = None
published: Optional[bool] = None
charts: List[DashboardChartItem]
datasets: List[DashboardDatasetItem]
chart_count: int
dataset_count: int
# [/DEF:DashboardDetailResponse:DataClass]
# [DEF:get_dashboards:Function]
# @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status
# @PRE: env_id must be a valid environment ID
# @PRE: page must be >= 1 if provided
# @PRE: page_size must be between 1 and 100 if provided
# @POST: Returns a list of dashboards with enhanced metadata and pagination info
# @POST: Response includes pagination metadata (page, page_size, total, total_pages)
# @PARAM: env_id (str) - The environment ID to fetch dashboards from
# @PARAM: search (Optional[str]) - Filter by title/slug
# @PARAM: page (Optional[int]) - Page number (default: 1)
# @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100)
# @RETURN: DashboardsResponse - List of dashboards with status metadata
# @RELATION: CALLS -> ResourceService.get_dashboards_with_status
@router.get("", response_model=DashboardsResponse)
async def get_dashboards(
env_id: str,
search: Optional[str] = None,
page: int = 1,
page_size: int = 10,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
resource_service=Depends(get_resource_service),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_dashboards", f"env_id={env_id}, search={search}, page={page}, page_size={page_size}"):
# Validate pagination parameters
if page < 1:
logger.error(f"[get_dashboards][Coherence:Failed] Invalid page: {page}")
raise HTTPException(status_code=400, detail="Page must be >= 1")
if page_size < 1 or page_size > 100:
logger.error(f"[get_dashboards][Coherence:Failed] Invalid page_size: {page_size}")
raise HTTPException(status_code=400, detail="Page size must be between 1 and 100")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dashboards][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Get all tasks for status lookup
all_tasks = task_manager.get_all_tasks()
# Fetch dashboards with status using ResourceService
dashboards = await resource_service.get_dashboards_with_status(env, all_tasks)
# Apply search filter if provided
if search:
search_lower = search.lower()
dashboards = [
d for d in dashboards
if search_lower in d.get('title', '').lower()
or search_lower in d.get('slug', '').lower()
]
# Calculate pagination
total = len(dashboards)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
# Slice dashboards for current page
paginated_dashboards = dashboards[start_idx:end_idx]
logger.info(f"[get_dashboards][Coherence:OK] Returning {len(paginated_dashboards)} dashboards (page {page}/{total_pages}, total: {total})")
return DashboardsResponse(
dashboards=paginated_dashboards,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
except Exception as e:
logger.error(f"[get_dashboards][Coherence:Failed] Failed to fetch dashboards: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboards: {str(e)}")
# [/DEF:get_dashboards:Function]
# [DEF:get_dashboard_detail:Function]
# @PURPOSE: Fetch detailed dashboard info with related charts and datasets
# @PRE: env_id must be valid and dashboard_id must exist
# @POST: Returns dashboard detail payload for overview page
# @RELATION: CALLS -> SupersetClient.get_dashboard_detail
@router.get("/{dashboard_id}", response_model=DashboardDetailResponse)
async def get_dashboard_detail(
dashboard_id: int,
env_id: str,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_dashboard_detail", f"dashboard_id={dashboard_id}, env_id={env_id}"):
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dashboard_detail][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
client = SupersetClient(env)
detail = client.get_dashboard_detail(dashboard_id)
logger.info(
f"[get_dashboard_detail][Coherence:OK] Dashboard {dashboard_id}: {detail.get('chart_count', 0)} charts, {detail.get('dataset_count', 0)} datasets"
)
return DashboardDetailResponse(**detail)
except HTTPException:
raise
except Exception as e:
logger.error(f"[get_dashboard_detail][Coherence:Failed] Failed to fetch dashboard detail: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboard detail: {str(e)}")
# [/DEF:get_dashboard_detail:Function]
# [DEF:MigrateRequest:DataClass]
class MigrateRequest(BaseModel):
source_env_id: str = Field(..., description="Source environment ID")
target_env_id: str = Field(..., description="Target environment ID")
dashboard_ids: List[int] = Field(..., description="List of dashboard IDs to migrate")
db_mappings: Optional[Dict[str, str]] = Field(None, description="Database mappings for migration")
replace_db_config: bool = Field(False, description="Replace database configuration")
# [/DEF:MigrateRequest:DataClass]
# [DEF:TaskResponse:DataClass]
class TaskResponse(BaseModel):
task_id: str
# [/DEF:TaskResponse:DataClass]
# [DEF:migrate_dashboards:Function]
# @PURPOSE: Trigger bulk migration of dashboards from source to target environment
# @PRE: User has permission plugin:migration:execute
# @PRE: source_env_id and target_env_id are valid environment IDs
# @PRE: dashboard_ids is a non-empty list
# @POST: Returns task_id for tracking migration progress
# @POST: Task is created and queued for execution
# @PARAM: request (MigrateRequest) - Migration request with source, target, and dashboard IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> MigrationPlugin
# @RELATION: CALLS -> task_manager.create_task
@router.post("/migrate", response_model=TaskResponse)
async def migrate_dashboards(
request: MigrateRequest,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
with belief_scope("migrate_dashboards", f"source={request.source_env_id}, target={request.target_env_id}, count={len(request.dashboard_ids)}"):
# Validate request
if not request.dashboard_ids:
logger.error("[migrate_dashboards][Coherence:Failed] No dashboard IDs provided")
raise HTTPException(status_code=400, detail="At least one dashboard ID must be provided")
# Validate environments exist
environments = config_manager.get_environments()
source_env = next((e for e in environments if e.id == request.source_env_id), None)
target_env = next((e for e in environments if e.id == request.target_env_id), None)
if not source_env:
logger.error(f"[migrate_dashboards][Coherence:Failed] Source environment not found: {request.source_env_id}")
raise HTTPException(status_code=404, detail="Source environment not found")
if not target_env:
logger.error(f"[migrate_dashboards][Coherence:Failed] Target environment not found: {request.target_env_id}")
raise HTTPException(status_code=404, detail="Target environment not found")
try:
# Create migration task
task_params = {
'source_env_id': request.source_env_id,
'target_env_id': request.target_env_id,
'selected_ids': request.dashboard_ids,
'replace_db_config': request.replace_db_config,
'db_mappings': request.db_mappings or {}
}
task_obj = await task_manager.create_task(
plugin_id='superset-migration',
params=task_params
)
logger.info(f"[migrate_dashboards][Coherence:OK] Migration task created: {task_obj.id} for {len(request.dashboard_ids)} dashboards")
return TaskResponse(task_id=str(task_obj.id))
except Exception as e:
logger.error(f"[migrate_dashboards][Coherence:Failed] Failed to create migration task: {e}")
raise HTTPException(status_code=503, detail=f"Failed to create migration task: {str(e)}")
# [/DEF:migrate_dashboards:Function]
# [DEF:BackupRequest:DataClass]
class BackupRequest(BaseModel):
env_id: str = Field(..., description="Environment ID")
dashboard_ids: List[int] = Field(..., description="List of dashboard IDs to backup")
schedule: Optional[str] = Field(None, description="Cron schedule for recurring backups (e.g., '0 0 * * *')")
# [/DEF:BackupRequest:DataClass]
# [DEF:backup_dashboards:Function]
# @PURPOSE: Trigger bulk backup of dashboards with optional cron schedule
# @PRE: User has permission plugin:backup:execute
# @PRE: env_id is a valid environment ID
# @PRE: dashboard_ids is a non-empty list
# @POST: Returns task_id for tracking backup progress
# @POST: Task is created and queued for execution
# @POST: If schedule is provided, a scheduled task is created
# @PARAM: request (BackupRequest) - Backup request with environment and dashboard IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> BackupPlugin
# @RELATION: CALLS -> task_manager.create_task
@router.post("/backup", response_model=TaskResponse)
async def backup_dashboards(
request: BackupRequest,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:backup", "EXECUTE"))
):
with belief_scope("backup_dashboards", f"env={request.env_id}, count={len(request.dashboard_ids)}, schedule={request.schedule}"):
# Validate request
if not request.dashboard_ids:
logger.error("[backup_dashboards][Coherence:Failed] No dashboard IDs provided")
raise HTTPException(status_code=400, detail="At least one dashboard ID must be provided")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == request.env_id), None)
if not env:
logger.error(f"[backup_dashboards][Coherence:Failed] Environment not found: {request.env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Create backup task
task_params = {
'env': request.env_id,
'dashboards': request.dashboard_ids,
'schedule': request.schedule
}
task_obj = await task_manager.create_task(
plugin_id='superset-backup',
params=task_params
)
logger.info(f"[backup_dashboards][Coherence:OK] Backup task created: {task_obj.id} for {len(request.dashboard_ids)} dashboards")
return TaskResponse(task_id=str(task_obj.id))
except Exception as e:
logger.error(f"[backup_dashboards][Coherence:Failed] Failed to create backup task: {e}")
raise HTTPException(status_code=503, detail=f"Failed to create backup task: {str(e)}")
# [/DEF:backup_dashboards:Function]
# [DEF:DatabaseMapping:DataClass]
class DatabaseMapping(BaseModel):
source_db: str
target_db: str
source_db_uuid: Optional[str] = None
target_db_uuid: Optional[str] = None
confidence: float
# [/DEF:DatabaseMapping:DataClass]
# [DEF:DatabaseMappingsResponse:DataClass]
class DatabaseMappingsResponse(BaseModel):
mappings: List[DatabaseMapping]
# [/DEF:DatabaseMappingsResponse:DataClass]
# [DEF:get_database_mappings:Function]
# @PURPOSE: Get database mapping suggestions between source and target environments
# @PRE: User has permission plugin:migration:read
# @PRE: source_env_id and target_env_id are valid environment IDs
# @POST: Returns list of suggested database mappings with confidence scores
# @PARAM: source_env_id (str) - Source environment ID
# @PARAM: target_env_id (str) - Target environment ID
# @RETURN: DatabaseMappingsResponse - List of suggested mappings
# @RELATION: CALLS -> MappingService.get_suggestions
@router.get("/db-mappings", response_model=DatabaseMappingsResponse)
async def get_database_mappings(
source_env_id: str,
target_env_id: str,
mapping_service=Depends(get_mapping_service),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_database_mappings", f"source={source_env_id}, target={target_env_id}"):
try:
# Get mapping suggestions using MappingService
suggestions = await mapping_service.get_suggestions(source_env_id, target_env_id)
# Format suggestions as DatabaseMapping objects
mappings = [
DatabaseMapping(
source_db=s.get('source_db', ''),
target_db=s.get('target_db', ''),
source_db_uuid=s.get('source_db_uuid'),
target_db_uuid=s.get('target_db_uuid'),
confidence=s.get('confidence', 0.0)
)
for s in suggestions
]
logger.info(f"[get_database_mappings][Coherence:OK] Returning {len(mappings)} database mapping suggestions")
return DatabaseMappingsResponse(mappings=mappings)
except Exception as e:
logger.error(f"[get_database_mappings][Coherence:Failed] Failed to get database mappings: {e}")
raise HTTPException(status_code=503, detail=f"Failed to get database mappings: {str(e)}")
# [/DEF:get_database_mappings:Function]
# [/DEF:backend.src.api.routes.dashboards:Module]

View File

@@ -0,0 +1,395 @@
# [DEF:backend.src.api.routes.datasets:Module]
#
# @TIER: STANDARD
# @SEMANTICS: api, datasets, resources, hub
# @PURPOSE: API endpoints for the Dataset Hub - listing datasets with mapping progress
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.dependencies
# @RELATION: DEPENDS_ON -> backend.src.services.resource_service
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
#
# @INVARIANT: All dataset responses include last_task metadata
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Optional
from pydantic import BaseModel, Field
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
# [/SECTION]
router = APIRouter(prefix="/api/datasets", tags=["Datasets"])
# [DEF:MappedFields:DataClass]
class MappedFields(BaseModel):
total: int
mapped: int
# [/DEF:MappedFields:DataClass]
# [DEF:LastTask:DataClass]
class LastTask(BaseModel):
task_id: Optional[str] = None
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
# [/DEF:LastTask:DataClass]
# [DEF:DatasetItem:DataClass]
class DatasetItem(BaseModel):
id: int
table_name: str
schema: str
database: str
mapped_fields: Optional[MappedFields] = None
last_task: Optional[LastTask] = None
# [/DEF:DatasetItem:DataClass]
# [DEF:LinkedDashboard:DataClass]
class LinkedDashboard(BaseModel):
id: int
title: str
slug: Optional[str] = None
# [/DEF:LinkedDashboard:DataClass]
# [DEF:DatasetColumn:DataClass]
class DatasetColumn(BaseModel):
id: int
name: str
type: Optional[str] = None
is_dttm: bool = False
is_active: bool = True
description: Optional[str] = None
# [/DEF:DatasetColumn:DataClass]
# [DEF:DatasetDetailResponse:DataClass]
class DatasetDetailResponse(BaseModel):
id: int
table_name: Optional[str] = None
schema: Optional[str] = None
database: str
description: Optional[str] = None
columns: List[DatasetColumn]
column_count: int
sql: Optional[str] = None
linked_dashboards: List[LinkedDashboard]
linked_dashboard_count: int
is_sqllab_view: bool = False
created_on: Optional[str] = None
changed_on: Optional[str] = None
# [/DEF:DatasetDetailResponse:DataClass]
# [DEF:DatasetsResponse:DataClass]
class DatasetsResponse(BaseModel):
datasets: List[DatasetItem]
total: int
page: int
page_size: int
total_pages: int
# [/DEF:DatasetsResponse:DataClass]
# [DEF:TaskResponse:DataClass]
class TaskResponse(BaseModel):
task_id: str
# [/DEF:TaskResponse:DataClass]
# [DEF:get_dataset_ids:Function]
# @PURPOSE: Fetch list of all dataset IDs from a specific environment (without pagination)
# @PRE: env_id must be a valid environment ID
# @POST: Returns a list of all dataset IDs
# @PARAM: env_id (str) - The environment ID to fetch datasets from
# @PARAM: search (Optional[str]) - Filter by table name
# @RETURN: List[int] - List of dataset IDs
# @RELATION: CALLS -> ResourceService.get_datasets_with_status
@router.get("/ids")
async def get_dataset_ids(
env_id: str,
search: Optional[str] = None,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
resource_service=Depends(get_resource_service),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_dataset_ids", f"env_id={env_id}, search={search}"):
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dataset_ids][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Get all tasks for status lookup
all_tasks = task_manager.get_all_tasks()
# Fetch datasets with status using ResourceService
datasets = await resource_service.get_datasets_with_status(env, all_tasks)
# Apply search filter if provided
if search:
search_lower = search.lower()
datasets = [
d for d in datasets
if search_lower in d.get('table_name', '').lower()
]
# Extract and return just the IDs
dataset_ids = [d['id'] for d in datasets]
logger.info(f"[get_dataset_ids][Coherence:OK] Returning {len(dataset_ids)} dataset IDs")
return {"dataset_ids": dataset_ids}
except Exception as e:
logger.error(f"[get_dataset_ids][Coherence:Failed] Failed to fetch dataset IDs: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dataset IDs: {str(e)}")
# [/DEF:get_dataset_ids:Function]
# [DEF:get_datasets:Function]
# @PURPOSE: Fetch list of datasets from a specific environment with mapping progress
# @PRE: env_id must be a valid environment ID
# @PRE: page must be >= 1 if provided
# @PRE: page_size must be between 1 and 100 if provided
# @POST: Returns a list of datasets with enhanced metadata and pagination info
# @POST: Response includes pagination metadata (page, page_size, total, total_pages)
# @PARAM: env_id (str) - The environment ID to fetch datasets from
# @PARAM: search (Optional[str]) - Filter by table name
# @PARAM: page (Optional[int]) - Page number (default: 1)
# @PARAM: page_size (Optional[int]) - Items per page (default: 10, max: 100)
# @RETURN: DatasetsResponse - List of datasets with status metadata
# @RELATION: CALLS -> ResourceService.get_datasets_with_status
@router.get("", response_model=DatasetsResponse)
async def get_datasets(
env_id: str,
search: Optional[str] = None,
page: int = 1,
page_size: int = 10,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
resource_service=Depends(get_resource_service),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_datasets", f"env_id={env_id}, search={search}, page={page}, page_size={page_size}"):
# Validate pagination parameters
if page < 1:
logger.error(f"[get_datasets][Coherence:Failed] Invalid page: {page}")
raise HTTPException(status_code=400, detail="Page must be >= 1")
if page_size < 1 or page_size > 100:
logger.error(f"[get_datasets][Coherence:Failed] Invalid page_size: {page_size}")
raise HTTPException(status_code=400, detail="Page size must be between 1 and 100")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_datasets][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Get all tasks for status lookup
all_tasks = task_manager.get_all_tasks()
# Fetch datasets with status using ResourceService
datasets = await resource_service.get_datasets_with_status(env, all_tasks)
# Apply search filter if provided
if search:
search_lower = search.lower()
datasets = [
d for d in datasets
if search_lower in d.get('table_name', '').lower()
]
# Calculate pagination
total = len(datasets)
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
# Slice datasets for current page
paginated_datasets = datasets[start_idx:end_idx]
logger.info(f"[get_datasets][Coherence:OK] Returning {len(paginated_datasets)} datasets (page {page}/{total_pages}, total: {total})")
return DatasetsResponse(
datasets=paginated_datasets,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
except Exception as e:
logger.error(f"[get_datasets][Coherence:Failed] Failed to fetch datasets: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch datasets: {str(e)}")
# [/DEF:get_datasets:Function]
# [DEF:MapColumnsRequest:DataClass]
class MapColumnsRequest(BaseModel):
env_id: str = Field(..., description="Environment ID")
dataset_ids: List[int] = Field(..., description="List of dataset IDs to map")
source_type: str = Field(..., description="Source type: 'postgresql' or 'xlsx'")
connection_id: Optional[str] = Field(None, description="Connection ID for PostgreSQL source")
file_data: Optional[str] = Field(None, description="File path or data for XLSX source")
# [/DEF:MapColumnsRequest:DataClass]
# [DEF:map_columns:Function]
# @PURPOSE: Trigger bulk column mapping for datasets
# @PRE: User has permission plugin:mapper:execute
# @PRE: env_id is a valid environment ID
# @PRE: dataset_ids is a non-empty list
# @POST: Returns task_id for tracking mapping progress
# @POST: Task is created and queued for execution
# @PARAM: request (MapColumnsRequest) - Mapping request with environment and dataset IDs
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> MapperPlugin
# @RELATION: CALLS -> task_manager.create_task
@router.post("/map-columns", response_model=TaskResponse)
async def map_columns(
request: MapColumnsRequest,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
with belief_scope("map_columns", f"env={request.env_id}, count={len(request.dataset_ids)}, source={request.source_type}"):
# Validate request
if not request.dataset_ids:
logger.error("[map_columns][Coherence:Failed] No dataset IDs provided")
raise HTTPException(status_code=400, detail="At least one dataset ID must be provided")
# Validate source type
if request.source_type not in ['postgresql', 'xlsx']:
logger.error(f"[map_columns][Coherence:Failed] Invalid source type: {request.source_type}")
raise HTTPException(status_code=400, detail="Source type must be 'postgresql' or 'xlsx'")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == request.env_id), None)
if not env:
logger.error(f"[map_columns][Coherence:Failed] Environment not found: {request.env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Create mapping task
task_params = {
'env': request.env_id,
'dataset_id': request.dataset_ids[0] if request.dataset_ids else None,
'source': request.source_type,
'connection_id': request.connection_id,
'file_data': request.file_data
}
task_obj = await task_manager.create_task(
plugin_id='dataset-mapper',
params=task_params
)
logger.info(f"[map_columns][Coherence:OK] Mapping task created: {task_obj.id} for {len(request.dataset_ids)} datasets")
return TaskResponse(task_id=str(task_obj.id))
except Exception as e:
logger.error(f"[map_columns][Coherence:Failed] Failed to create mapping task: {e}")
raise HTTPException(status_code=503, detail=f"Failed to create mapping task: {str(e)}")
# [/DEF:map_columns:Function]
# [DEF:GenerateDocsRequest:DataClass]
class GenerateDocsRequest(BaseModel):
env_id: str = Field(..., description="Environment ID")
dataset_ids: List[int] = Field(..., description="List of dataset IDs to generate docs for")
llm_provider: str = Field(..., description="LLM provider to use")
options: Optional[dict] = Field(None, description="Additional options for documentation generation")
# [/DEF:GenerateDocsRequest:DataClass]
# [DEF:generate_docs:Function]
# @PURPOSE: Trigger bulk documentation generation for datasets
# @PRE: User has permission plugin:llm_analysis:execute
# @PRE: env_id is a valid environment ID
# @PRE: dataset_ids is a non-empty list
# @POST: Returns task_id for tracking documentation generation progress
# @POST: Task is created and queued for execution
# @PARAM: request (GenerateDocsRequest) - Documentation generation request
# @RETURN: TaskResponse - Task ID for tracking
# @RELATION: DISPATCHES -> LLMAnalysisPlugin
# @RELATION: CALLS -> task_manager.create_task
@router.post("/generate-docs", response_model=TaskResponse)
async def generate_docs(
request: GenerateDocsRequest,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:llm_analysis", "EXECUTE"))
):
with belief_scope("generate_docs", f"env={request.env_id}, count={len(request.dataset_ids)}, provider={request.llm_provider}"):
# Validate request
if not request.dataset_ids:
logger.error("[generate_docs][Coherence:Failed] No dataset IDs provided")
raise HTTPException(status_code=400, detail="At least one dataset ID must be provided")
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == request.env_id), None)
if not env:
logger.error(f"[generate_docs][Coherence:Failed] Environment not found: {request.env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Create documentation generation task
task_params = {
'environment_id': request.env_id,
'dataset_id': str(request.dataset_ids[0]) if request.dataset_ids else None,
'provider_id': request.llm_provider,
'options': request.options or {}
}
task_obj = await task_manager.create_task(
plugin_id='llm_documentation',
params=task_params
)
logger.info(f"[generate_docs][Coherence:OK] Documentation generation task created: {task_obj.id} for {len(request.dataset_ids)} datasets")
return TaskResponse(task_id=str(task_obj.id))
except Exception as e:
logger.error(f"[generate_docs][Coherence:Failed] Failed to create documentation generation task: {e}")
raise HTTPException(status_code=503, detail=f"Failed to create documentation generation task: {str(e)}")
# [/DEF:generate_docs:Function]
# [DEF:get_dataset_detail:Function]
# @PURPOSE: Get detailed dataset information including columns and linked dashboards
# @PRE: env_id is a valid environment ID
# @PRE: dataset_id is a valid dataset ID
# @POST: Returns detailed dataset info with columns and linked dashboards
# @PARAM: env_id (str) - The environment ID
# @PARAM: dataset_id (int) - The dataset ID
# @RETURN: DatasetDetailResponse - Detailed dataset information
# @RELATION: CALLS -> SupersetClient.get_dataset_detail
@router.get("/{dataset_id}", response_model=DatasetDetailResponse)
async def get_dataset_detail(
env_id: str,
dataset_id: int,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:migration", "READ"))
):
with belief_scope("get_dataset_detail", f"env_id={env_id}, dataset_id={dataset_id}"):
# Validate environment exists
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
logger.error(f"[get_dataset_detail][Coherence:Failed] Environment not found: {env_id}")
raise HTTPException(status_code=404, detail="Environment not found")
try:
# Fetch detailed dataset info using SupersetClient
client = SupersetClient(env)
dataset_detail = client.get_dataset_detail(dataset_id)
logger.info(f"[get_dataset_detail][Coherence:OK] Retrieved dataset {dataset_id} with {dataset_detail['column_count']} columns and {dataset_detail['linked_dashboard_count']} linked dashboards")
return DatasetDetailResponse(**dataset_detail)
except Exception as e:
logger.error(f"[get_dataset_detail][Coherence:Failed] Failed to fetch dataset detail: {e}")
raise HTTPException(status_code=503, detail=f"Failed to fetch dataset detail: {str(e)}")
# [/DEF:get_dataset_detail:Function]
# [/DEF:backend.src.api.routes.datasets:Module]

View File

@@ -1,5 +1,6 @@
# [DEF:backend.src.api.routes.environments:Module]
#
# @TIER: STANDARD
# @SEMANTICS: api, environments, superset, databases
# @PURPOSE: API endpoints for listing environments and their databases.
# @LAYER: API
@@ -10,66 +11,126 @@
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict, Optional
from backend.src.dependencies import get_config_manager
from backend.src.core.superset_client import SupersetClient
from superset_tool.models import SupersetConfig
from pydantic import BaseModel
from typing import List, Optional
from ...dependencies import get_config_manager, get_scheduler_service, has_permission
from ...core.superset_client import SupersetClient
from pydantic import BaseModel, Field
from ...core.logger import belief_scope
# [/SECTION]
router = APIRouter(prefix="/api/environments", tags=["environments"])
router = APIRouter(prefix="/api/environments", tags=["Environments"])
# [DEF:ScheduleSchema:DataClass]
class ScheduleSchema(BaseModel):
enabled: bool = False
cron_expression: str = Field(..., pattern=r'^(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|((((\d+,)*\d+|(\d+(\/|-)\d+)|\d+|\*) ?){4,6})$')
# [/DEF:ScheduleSchema:DataClass]
# [DEF:EnvironmentResponse:DataClass]
class EnvironmentResponse(BaseModel):
id: str
name: str
url: str
# [/DEF:EnvironmentResponse]
backup_schedule: Optional[ScheduleSchema] = None
# [/DEF:EnvironmentResponse:DataClass]
# [DEF:DatabaseResponse:DataClass]
class DatabaseResponse(BaseModel):
uuid: str
database_name: str
engine: Optional[str]
# [/DEF:DatabaseResponse]
# [/DEF:DatabaseResponse:DataClass]
# [DEF:get_environments:Function]
# @PURPOSE: List all configured environments.
# @LAYER: API
# @SEMANTICS: list, environments, config
# @PRE: config_manager is injected via Depends.
# @POST: Returns a list of EnvironmentResponse objects.
# @RETURN: List[EnvironmentResponse]
@router.get("", response_model=List[EnvironmentResponse])
async def get_environments(config_manager=Depends(get_config_manager)):
envs = config_manager.get_environments()
return [EnvironmentResponse(id=e.id, name=e.name, url=e.url) for e in envs]
# [/DEF:get_environments]
async def get_environments(
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("environments", "READ"))
):
with belief_scope("get_environments"):
envs = config_manager.get_environments()
# Ensure envs is a list
if not isinstance(envs, list):
envs = []
return [
EnvironmentResponse(
id=e.id,
name=e.name,
url=e.url,
backup_schedule=ScheduleSchema(
enabled=e.backup_schedule.enabled,
cron_expression=e.backup_schedule.cron_expression
) if getattr(e, 'backup_schedule', None) else None
) for e in envs
]
# [/DEF:get_environments:Function]
# [DEF:update_environment_schedule:Function]
# @PURPOSE: Update backup schedule for an environment.
# @LAYER: API
# @SEMANTICS: update, schedule, backup, environment
# @PRE: Environment id exists, schedule is valid ScheduleSchema.
# @POST: Backup schedule updated and scheduler reloaded.
# @PARAM: id (str) - The environment ID.
# @PARAM: schedule (ScheduleSchema) - The new schedule.
@router.put("/{id}/schedule")
async def update_environment_schedule(
id: str,
schedule: ScheduleSchema,
config_manager=Depends(get_config_manager),
scheduler_service=Depends(get_scheduler_service),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_environment_schedule", f"id={id}"):
envs = config_manager.get_environments()
env = next((e for e in envs if e.id == id), None)
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
# Update environment config
env.backup_schedule.enabled = schedule.enabled
env.backup_schedule.cron_expression = schedule.cron_expression
config_manager.update_environment(id, env)
# Refresh scheduler
scheduler_service.load_schedules()
return {"message": "Schedule updated successfully"}
# [/DEF:update_environment_schedule:Function]
# [DEF:get_environment_databases:Function]
# @PURPOSE: Fetch the list of databases from a specific environment.
# @LAYER: API
# @SEMANTICS: fetch, databases, superset, environment
# @PRE: Environment id exists.
# @POST: Returns a list of database summaries from the environment.
# @PARAM: id (str) - The environment ID.
# @RETURN: List[Dict] - List of databases.
@router.get("/{id}/databases")
async def get_environment_databases(id: str, config_manager=Depends(get_config_manager)):
envs = config_manager.get_environments()
async def get_environment_databases(
id: str,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_environment_databases", f"id={id}"):
envs = config_manager.get_environments()
env = next((e for e in envs if e.id == id), None)
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
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)}")
# [/DEF:get_environment_databases]
# [/DEF:get_environment_databases:Function]
# [/DEF:backend.src.api.routes.environments]
# [/DEF:backend.src.api.routes.environments:Module]

View File

@@ -0,0 +1,456 @@
# [DEF:backend.src.api.routes.git:Module]
#
# @TIER: STANDARD
# @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, has_permission
from src.core.database import get_db
from src.models.git import GitServerConfig, GitRepository
from src.api.routes.git_schemas import (
GitServerConfigSchema, GitServerConfigCreate,
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(tags=["git"])
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),
_ = Depends(has_permission("admin:settings", "READ"))
):
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.
# @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)
async def create_git_config(
config: GitServerConfigCreate,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
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.
# @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),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("delete_git_config"):
db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
if not db_config:
raise HTTPException(status_code=404, detail="Configuration not found")
db.delete(db_config)
db.commit()
return {"status": "success", "message": "Configuration deleted"}
# [/DEF:delete_git_config:Function]
# [DEF:test_git_config:Function]
# @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,
_ = Depends(has_permission("admin:settings", "READ"))
):
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.
# @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")
async def init_repository(
dashboard_id: int,
init_data: RepoInitRequest,
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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])
async def get_branches(
dashboard_id: int,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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")
async def create_branch(
dashboard_id: int,
branch_data: BranchCreate,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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")
async def checkout_branch(
dashboard_id: int,
checkout_data: BranchCheckout,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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")
async def commit_changes(
dashboard_id: int,
commit_data: CommitCreate,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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")
async def sync_dashboard(
dashboard_id: int,
source_env_id: typing.Optional[str] = None,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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),
_ = Depends(has_permission("environments", "READ"))
):
with belief_scope("get_environments"):
envs = config_manager.get_environments()
return [
DeploymentEnvironmentSchema(
id=e.id,
name=e.name,
superset_url=e.url,
is_active=True
) for e in envs
]
# [/DEF:get_environments:Function]
# [DEF: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")
async def deploy_dashboard(
dashboard_id: int,
deploy_data: DeployRequest,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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]
@router.get("/repositories/{dashboard_id}/history", response_model=List[CommitSchema])
async def get_history(
dashboard_id: int,
limit: int = 50,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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")
async def get_repository_status(
dashboard_id: int,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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.
# @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)
# @RETURN: str
@router.get("/repositories/{dashboard_id}/diff")
async def get_repository_diff(
dashboard_id: int,
file_path: Optional[str] = None,
staged: bool = False,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
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:generate_commit_message:Function]
# @PURPOSE: Generate a suggested commit message using LLM.
# @PRE: Repository for `dashboard_id` is initialized.
# @POST: Returns a suggested commit message string.
@router.post("/repositories/{dashboard_id}/generate-message")
async def generate_commit_message(
dashboard_id: int,
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("generate_commit_message"):
try:
# 1. Get Diff
diff = git_service.get_diff(dashboard_id, staged=True)
if not diff:
diff = git_service.get_diff(dashboard_id, staged=False)
if not diff:
return {"message": "No changes detected"}
# 2. Get History
history_objs = git_service.get_commit_history(dashboard_id, limit=5)
history = [h.message for h in history_objs if hasattr(h, 'message')]
# 3. Get LLM Client
from ...services.llm_provider import LLMProviderService
from ...plugins.llm_analysis.service import LLMClient
from ...plugins.llm_analysis.models import LLMProviderType
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
provider = next((p for p in providers if p.is_active), None)
if not provider:
raise HTTPException(status_code=400, detail="No active LLM provider found")
api_key = llm_service.get_decrypted_api_key(provider.id)
client = LLMClient(
provider_type=LLMProviderType(provider.provider_type),
api_key=api_key,
base_url=provider.base_url,
default_model=provider.default_model
)
# 4. Generate Message
from ...plugins.git.llm_extension import GitLLMExtension
extension = GitLLMExtension(client)
message = await extension.suggest_commit_message(diff, history)
return {"message": message}
except Exception as e:
logger.error(f"Failed to generate commit message: {e}")
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:generate_commit_message:Function]
# [/DEF:backend.src.api.routes.git:Module]

View File

@@ -0,0 +1,144 @@
# [DEF:backend.src.api.routes.git_schemas:Module]
#
# @TIER: STANDARD
# @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 src.models.git import GitProvider, GitStatus, SyncStatus
# [DEF:GitServerConfigBase:Class]
# @TIER: TRIVIAL
# @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)")
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]
# @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
status: GitStatus
last_validated: datetime
class Config:
from_attributes = True
# [/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
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]
# @PURPOSE: Schema for representing a Git branch metadata.
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]
# @PURPOSE: Schema for representing Git commit details.
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]
# @PURPOSE: Schema for branch creation requests.
class BranchCreate(BaseModel):
"""Schema for branch creation requests."""
name: str
from_branch: str
# [/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
files: List[str]
# [/DEF:CommitCreate:Class]
# [DEF:ConflictResolution:Class]
# @PURPOSE: Schema for resolving merge conflicts.
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]
# @PURPOSE: Schema for representing a target deployment environment.
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]
# @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
remote_url: str
# [/DEF:RepoInitRequest:Class]
# [/DEF:backend.src.api.routes.git_schemas:Module]

View File

@@ -0,0 +1,207 @@
# [DEF:backend/src/api/routes/llm.py:Module]
# @TIER: STANDARD
# @SEMANTICS: api, routes, llm
# @PURPOSE: API routes for LLM provider configuration and management.
# @LAYER: UI (API)
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List
from ...core.logger import logger
from ...schemas.auth import User
from ...dependencies import get_current_user as get_current_active_user
from ...plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
from ...services.llm_provider import LLMProviderService
from ...core.database import get_db
from sqlalchemy.orm import Session
# [DEF:router:Global]
# @PURPOSE: APIRouter instance for LLM routes.
router = APIRouter(tags=["LLM"])
# [/DEF:router:Global]
# [DEF:get_providers:Function]
# @PURPOSE: Retrieve all LLM provider configurations.
# @PRE: User is authenticated.
# @POST: Returns list of LLMProviderConfig.
@router.get("/providers", response_model=List[LLMProviderConfig])
async def get_providers(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Get all LLM provider configurations.
"""
logger.info(f"[llm_routes][get_providers][Action] Fetching providers for user: {current_user.username}")
service = LLMProviderService(db)
providers = service.get_all_providers()
return [
LLMProviderConfig(
id=p.id,
provider_type=LLMProviderType(p.provider_type),
name=p.name,
base_url=p.base_url,
api_key="********",
default_model=p.default_model,
is_active=p.is_active
) for p in providers
]
# [/DEF:get_providers:Function]
# [DEF:create_provider:Function]
# @PURPOSE: Create a new LLM provider configuration.
# @PRE: User is authenticated and has admin permissions.
# @POST: Returns the created LLMProviderConfig.
@router.post("/providers", response_model=LLMProviderConfig, status_code=status.HTTP_201_CREATED)
async def create_provider(
config: LLMProviderConfig,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Create a new LLM provider configuration.
"""
service = LLMProviderService(db)
provider = service.create_provider(config)
return LLMProviderConfig(
id=provider.id,
provider_type=LLMProviderType(provider.provider_type),
name=provider.name,
base_url=provider.base_url,
api_key="********",
default_model=provider.default_model,
is_active=provider.is_active
)
# [/DEF:create_provider:Function]
# [DEF:update_provider:Function]
# @PURPOSE: Update an existing LLM provider configuration.
# @PRE: User is authenticated and has admin permissions.
# @POST: Returns the updated LLMProviderConfig.
@router.put("/providers/{provider_id}", response_model=LLMProviderConfig)
async def update_provider(
provider_id: str,
config: LLMProviderConfig,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Update an existing LLM provider configuration.
"""
service = LLMProviderService(db)
provider = service.update_provider(provider_id, config)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
return LLMProviderConfig(
id=provider.id,
provider_type=LLMProviderType(provider.provider_type),
name=provider.name,
base_url=provider.base_url,
api_key="********",
default_model=provider.default_model,
is_active=provider.is_active
)
# [/DEF:update_provider:Function]
# [DEF:delete_provider:Function]
# @PURPOSE: Delete an LLM provider configuration.
# @PRE: User is authenticated and has admin permissions.
# @POST: Returns success status.
@router.delete("/providers/{provider_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_provider(
provider_id: str,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Delete an LLM provider configuration.
"""
service = LLMProviderService(db)
if not service.delete_provider(provider_id):
raise HTTPException(status_code=404, detail="Provider not found")
return
# [/DEF:delete_provider:Function]
# [DEF:test_connection:Function]
# @PURPOSE: Test connection to an LLM provider.
# @PRE: User is authenticated.
# @POST: Returns success status and message.
@router.post("/providers/{provider_id}/test")
async def test_connection(
provider_id: str,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
logger.info(f"[llm_routes][test_connection][Action] Testing connection for provider_id: {provider_id}")
"""
Test connection to an LLM provider.
"""
from ...plugins.llm_analysis.service import LLMClient
service = LLMProviderService(db)
db_provider = service.get_provider(provider_id)
if not db_provider:
raise HTTPException(status_code=404, detail="Provider not found")
api_key = service.get_decrypted_api_key(provider_id)
# Check if API key was successfully decrypted
if not api_key:
logger.error(f"[llm_routes][test_connection] Failed to decrypt API key for provider {provider_id}")
raise HTTPException(
status_code=500,
detail="Failed to decrypt API key. The provider may have been encrypted with a different encryption key. Please update the provider with a new API key."
)
client = LLMClient(
provider_type=LLMProviderType(db_provider.provider_type),
api_key=api_key,
base_url=db_provider.base_url,
default_model=db_provider.default_model
)
try:
# Simple test call
await client.client.models.list()
return {"success": True, "message": "Connection successful"}
except Exception as e:
return {"success": False, "error": str(e)}
# [/DEF:test_connection:Function]
# [DEF:test_provider_config:Function]
# @PURPOSE: Test connection with a provided configuration (not yet saved).
# @PRE: User is authenticated.
# @POST: Returns success status and message.
@router.post("/providers/test")
async def test_provider_config(
config: LLMProviderConfig,
current_user: User = Depends(get_current_active_user)
):
"""
Test connection with a provided configuration.
"""
from ...plugins.llm_analysis.service import LLMClient
logger.info(f"[llm_routes][test_provider_config][Action] Testing config for {config.name}")
# Check if API key is provided
if not config.api_key or config.api_key == "********":
raise HTTPException(
status_code=400,
detail="API key is required for testing connection"
)
client = LLMClient(
provider_type=config.provider_type,
api_key=config.api_key,
base_url=config.base_url,
default_model=config.default_model
)
try:
# Simple test call
await client.client.models.list()
return {"success": True, "message": "Connection successful"}
except Exception as e:
return {"success": False, "error": str(e)}
# [/DEF:test_provider_config:Function]
# [/DEF:backend/src/api/routes/llm.py]

View File

@@ -1,5 +1,6 @@
# [DEF:backend.src.api.routes.mappings:Module]
#
# @TIER: STANDARD
# @SEMANTICS: api, mappings, database, fuzzy-matching
# @PURPOSE: API endpoints for managing database mappings and getting suggestions.
# @LAYER: API
@@ -13,13 +14,14 @@
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, has_permission
from ...core.database import get_db
from ...models.mapping import DatabaseMapping
from pydantic import BaseModel
# [/SECTION]
router = APIRouter(prefix="/api/mappings", tags=["mappings"])
router = APIRouter(tags=["mappings"])
# [DEF:MappingCreate:DataClass]
class MappingCreate(BaseModel):
@@ -29,7 +31,8 @@ class MappingCreate(BaseModel):
target_db_uuid: str
source_db_name: str
target_db_name: str
# [/DEF:MappingCreate]
engine: Optional[str] = None
# [/DEF:MappingCreate:DataClass]
# [DEF:MappingResponse:DataClass]
class MappingResponse(BaseModel):
@@ -40,71 +43,88 @@ class MappingResponse(BaseModel):
target_db_uuid: str
source_db_name: str
target_db_name: str
engine: Optional[str] = None
class Config:
from_attributes = True
# [/DEF:MappingResponse]
# [/DEF:MappingResponse:DataClass]
# [DEF:SuggestRequest:DataClass]
class SuggestRequest(BaseModel):
source_env_id: str
target_env_id: str
# [/DEF:SuggestRequest]
# [/DEF:SuggestRequest:DataClass]
# [DEF:get_mappings:Function]
# @PURPOSE: List all saved database mappings.
# @PRE: db session is injected.
# @POST: Returns filtered list of DatabaseMapping records.
@router.get("", response_model=List[MappingResponse])
async def get_mappings(
source_env_id: Optional[str] = None,
target_env_id: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
query = db.query(DatabaseMapping)
with belief_scope("get_mappings"):
query = db.query(DatabaseMapping)
if source_env_id:
query = query.filter(DatabaseMapping.source_env_id == source_env_id)
if target_env_id:
query = query.filter(DatabaseMapping.target_env_id == target_env_id)
return query.all()
# [/DEF:get_mappings]
# [/DEF:get_mappings:Function]
# [DEF:create_mapping:Function]
# @PURPOSE: Create or update a database mapping.
# @PRE: mapping is valid MappingCreate, db session is injected.
# @POST: DatabaseMapping created or updated in database.
@router.post("", response_model=MappingResponse)
async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)):
# Check if mapping already exists
existing = db.query(DatabaseMapping).filter(
DatabaseMapping.source_env_id == mapping.source_env_id,
DatabaseMapping.target_env_id == mapping.target_env_id,
DatabaseMapping.source_db_uuid == mapping.source_db_uuid
).first()
if existing:
existing.target_db_uuid = mapping.target_db_uuid
existing.target_db_name = mapping.target_db_name
async def create_mapping(
mapping: MappingCreate,
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
with belief_scope("create_mapping"):
# Check if mapping already exists
existing = db.query(DatabaseMapping).filter(
DatabaseMapping.source_env_id == mapping.source_env_id,
DatabaseMapping.target_env_id == mapping.target_env_id,
DatabaseMapping.source_db_uuid == mapping.source_db_uuid
).first()
if existing:
existing.target_db_uuid = mapping.target_db_uuid
existing.target_db_name = mapping.target_db_name
existing.engine = mapping.engine
db.commit()
db.refresh(existing)
return existing
new_mapping = DatabaseMapping(**mapping.dict())
db.add(new_mapping)
db.commit()
db.refresh(existing)
return existing
new_mapping = DatabaseMapping(**mapping.dict())
db.add(new_mapping)
db.commit()
db.refresh(new_mapping)
return new_mapping
# [/DEF:create_mapping]
db.refresh(new_mapping)
return new_mapping
# [/DEF:create_mapping:Function]
# [DEF:suggest_mappings_api:Function]
# @PURPOSE: Get suggested mappings based on fuzzy matching.
# @PRE: request is valid SuggestRequest, config_manager is injected.
# @POST: Returns mapping suggestions.
@router.post("/suggest")
async def suggest_mappings_api(
request: SuggestRequest,
config_manager=Depends(get_config_manager)
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
from backend.src.services.mapping_service import MappingService
service = MappingService(config_manager)
try:
return await service.get_suggestions(request.source_env_id, request.target_env_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# [/DEF:suggest_mappings_api]
with belief_scope("suggest_mappings_api"):
from ...services.mapping_service import MappingService
service = MappingService(config_manager)
try:
return await service.get_suggestions(request.source_env_id, request.target_env_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# [/DEF:suggest_mappings_api:Function]
# [/DEF:backend.src.api.routes.mappings]
# [/DEF:backend.src.api.routes.mappings:Module]

View File

@@ -0,0 +1,81 @@
# [DEF:backend.src.api.routes.migration:Module]
# @TIER: STANDARD
# @SEMANTICS: api, migration, dashboards
# @PURPOSE: API endpoints for migration operations.
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.dependencies
# @RELATION: DEPENDS_ON -> backend.src.models.dashboard
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from ...dependencies import get_config_manager, get_task_manager, has_permission
from ...models.dashboard import DashboardMetadata, DashboardSelection
from ...core.superset_client import SupersetClient
from ...core.logger import belief_scope
router = APIRouter(prefix="/api", tags=["migration"])
# [DEF:get_dashboards:Function]
# @PURPOSE: Fetch all dashboards from the specified environment for the grid.
# @PRE: Environment ID must be valid.
# @POST: Returns a list of dashboard metadata.
# @PARAM: env_id (str) - The ID of the environment to fetch from.
# @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),
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
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")
client = SupersetClient(env)
dashboards = client.get_dashboards_summary()
return dashboards
# [/DEF:get_dashboards:Function]
# [DEF:execute_migration:Function]
# @PURPOSE: Execute the migration of selected dashboards.
# @PRE: Selection must be valid and environments must exist.
# @POST: Starts the migration task and returns the task ID.
# @PARAM: selection (DashboardSelection) - The dashboards to migrate.
# @RETURN: Dict - {"task_id": str, "message": str}
@router.post("/execute")
async def execute_migration(
selection: DashboardSelection,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
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")
# Create migration task with debug logging
from ...core.logger import logger
# Include replace_db_config in the task parameters
task_params = selection.dict()
task_params['replace_db_config'] = selection.replace_db_config
logger.info(f"Creating migration task with params: {task_params}")
logger.info(f"Available environments: {env_ids}")
logger.info(f"Source env: {selection.source_env_id}, Target env: {selection.target_env_id}")
try:
task = await task_manager.create_task("superset-migration", task_params)
logger.info(f"Task created successfully: {task.id}")
return {"task_id": task.id, "message": "Migration initiated"}
except Exception as e:
logger.error(f"Task creation failed: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
# [/DEF:execute_migration:Function]
# [/DEF:backend.src.api.routes.migration:Module]

View File

@@ -1,4 +1,5 @@
# [DEF:PluginsRouter:Module]
# @TIER: STANDARD
# @SEMANTICS: api, router, plugins, list
# @PURPOSE: Defines the FastAPI router for plugin-related endpoints, allowing clients to list available plugins.
# @LAYER: UI (API)
@@ -7,16 +8,25 @@ from typing import List
from fastapi import APIRouter, Depends
from ...core.plugin_base import PluginConfig
from ...dependencies import get_plugin_loader
from ...dependencies import get_plugin_loader, has_permission
from ...core.logger import belief_scope
router = APIRouter()
@router.get("/", response_model=List[PluginConfig])
# [DEF:list_plugins:Function]
# @PURPOSE: Retrieve a list of all available plugins.
# @PRE: plugin_loader is injected via Depends.
# @POST: Returns a list of PluginConfig objects.
# @RETURN: List[PluginConfig] - List of registered plugins.
@router.get("", response_model=List[PluginConfig])
async def list_plugins(
plugin_loader = Depends(get_plugin_loader)
plugin_loader = Depends(get_plugin_loader),
_ = Depends(has_permission("plugins", "READ"))
):
"""
Retrieve a list of all available plugins.
"""
return plugin_loader.get_all_plugin_configs()
# [/DEF]
with belief_scope("list_plugins"):
"""
Retrieve a list of all available plugins.
"""
return plugin_loader.get_all_plugin_configs()
# [/DEF:list_plugins:Function]
# [/DEF:PluginsRouter:Module]

View File

@@ -0,0 +1,131 @@
# [DEF:ReportsRouter:Module]
# @TIER: CRITICAL
# @SEMANTICS: api, reports, list, detail, pagination, filters
# @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints.
# @LAYER: UI (API)
# @RELATION: DEPENDS_ON -> backend.src.services.reports.report_service.ReportsService
# @RELATION: DEPENDS_ON -> backend.src.dependencies
# @INVARIANT: Endpoints are read-only and do not trigger long-running tasks.
# [SECTION: IMPORTS]
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from ...dependencies import get_task_manager, has_permission
from ...core.task_manager import TaskManager
from ...core.logger import belief_scope
from ...models.report import ReportCollection, ReportDetailView, ReportQuery, ReportStatus, TaskType
from ...services.reports.report_service import ReportsService
# [/SECTION]
router = APIRouter(prefix="/api/reports", tags=["Reports"])
# [DEF:_parse_csv_enum_list:Function]
# @PURPOSE: Parse comma-separated query value into enum list.
# @PRE: raw may be None/empty or comma-separated values.
# @POST: Returns enum list or raises HTTP 400 with deterministic machine-readable payload.
# @PARAM: raw (Optional[str]) - Comma-separated enum values.
# @PARAM: enum_cls (type) - Enum class for validation.
# @PARAM: field_name (str) - Query field name for diagnostics.
# @RETURN: List - Parsed enum values.
def _parse_csv_enum_list(raw: Optional[str], enum_cls, field_name: str) -> List:
if raw is None or not raw.strip():
return []
values = [item.strip() for item in raw.split(",") if item.strip()]
parsed = []
invalid = []
for value in values:
try:
parsed.append(enum_cls(value))
except ValueError:
invalid.append(value)
if invalid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": f"Invalid values for '{field_name}'",
"field": field_name,
"invalid_values": invalid,
"allowed_values": [item.value for item in enum_cls],
},
)
return parsed
# [/DEF:_parse_csv_enum_list:Function]
# [DEF:list_reports:Function]
# @PURPOSE: Return paginated unified reports list.
# @PRE: authenticated/authorized request and validated query params.
# @POST: returns {items,total,page,page_size,has_next,applied_filters}.
# @POST: deterministic error payload for invalid filters.
@router.get("", response_model=ReportCollection)
async def list_reports(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
task_types: Optional[str] = Query(None, description="Comma-separated task types"),
statuses: Optional[str] = Query(None, description="Comma-separated statuses"),
time_from: Optional[datetime] = Query(None),
time_to: Optional[datetime] = Query(None),
search: Optional[str] = Query(None, max_length=200),
sort_by: str = Query("updated_at"),
sort_order: str = Query("desc"),
task_manager: TaskManager = Depends(get_task_manager),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope("list_reports"):
try:
parsed_task_types = _parse_csv_enum_list(task_types, TaskType, "task_types")
parsed_statuses = _parse_csv_enum_list(statuses, ReportStatus, "statuses")
query = ReportQuery(
page=page,
page_size=page_size,
task_types=parsed_task_types,
statuses=parsed_statuses,
time_from=time_from,
time_to=time_to,
search=search,
sort_by=sort_by,
sort_order=sort_order,
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": "Invalid query parameters",
"code": "INVALID_REPORT_QUERY",
"reason": str(exc),
},
)
service = ReportsService(task_manager)
return service.list_reports(query)
# [/DEF:list_reports:Function]
# [DEF:get_report_detail:Function]
# @PURPOSE: Return one normalized report detail with diagnostics and next actions.
# @PRE: authenticated/authorized request and existing report_id.
# @POST: returns normalized detail envelope or 404 when report is not found.
@router.get("/{report_id}", response_model=ReportDetailView)
async def get_report_detail(
report_id: str,
task_manager: TaskManager = Depends(get_task_manager),
_=Depends(has_permission("tasks", "READ")),
):
with belief_scope("get_report_detail", f"report_id={report_id}"):
service = ReportsService(task_manager)
detail = service.get_report_detail(report_id)
if not detail:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"message": "Report not found", "code": "REPORT_NOT_FOUND"},
)
return detail
# [/DEF:get_report_detail:Function]
# [/DEF:ReportsRouter:Module]

View File

@@ -12,78 +12,132 @@
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from ...core.config_models import AppConfig, Environment, GlobalSettings
from ...dependencies import get_config_manager
from pydantic import BaseModel
from ...core.config_models import AppConfig, Environment, GlobalSettings, LoggingConfig
from ...models.storage import StorageConfig
from ...dependencies import get_config_manager, has_permission
from ...core.config_manager import ConfigManager
from ...core.logger import logger
from superset_tool.client import SupersetClient
from superset_tool.models import SupersetConfig
import os
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
# [/SECTION]
# [DEF:LoggingConfigResponse:Class]
# @PURPOSE: Response model for logging configuration with current task log level.
# @SEMANTICS: logging, config, response
class LoggingConfigResponse(BaseModel):
level: str
task_log_level: str
enable_belief_state: bool
# [/DEF:LoggingConfigResponse:Class]
router = APIRouter()
# [DEF:get_settings:Function]
# @PURPOSE: Retrieves all application settings.
# @PRE: Config manager is available.
# @POST: Returns masked AppConfig.
# @RETURN: AppConfig - The current configuration.
@router.get("/", response_model=AppConfig)
async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)):
logger.info("[get_settings][Entry] Fetching all settings")
@router.get("", response_model=AppConfig)
async def get_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings")
config = config_manager.get_config().copy(deep=True)
# Mask passwords
for env in config.environments:
if env.password:
env.password = "********"
return config
# [/DEF:get_settings]
# [/DEF:get_settings:Function]
# [DEF:update_global_settings:Function]
# @PURPOSE: Updates global application settings.
# @PRE: New settings are provided.
# @POST: Global settings are updated.
# @PARAM: settings (GlobalSettings) - The new global settings.
# @RETURN: GlobalSettings - The updated settings.
@router.patch("/global", response_model=GlobalSettings)
async def update_global_settings(
settings: GlobalSettings,
config_manager: ConfigManager = Depends(get_config_manager)
settings: GlobalSettings,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
logger.info("[update_global_settings][Entry] Updating global settings")
with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating global settings")
config_manager.update_global_settings(settings)
return settings
# [/DEF:update_global_settings]
# [/DEF:update_global_settings:Function]
# [DEF:get_storage_settings:Function]
# @PURPOSE: Retrieves storage-specific settings.
# @RETURN: StorageConfig - The storage configuration.
@router.get("/storage", response_model=StorageConfig)
async def get_storage_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_storage_settings"):
return config_manager.get_config().settings.storage
# [/DEF:get_storage_settings:Function]
# [DEF:update_storage_settings:Function]
# @PURPOSE: Updates storage-specific settings.
# @PARAM: storage (StorageConfig) - The new storage settings.
# @POST: Storage settings are updated and saved.
# @RETURN: StorageConfig - The updated storage settings.
@router.put("/storage", response_model=StorageConfig)
async def update_storage_settings(
storage: StorageConfig,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_storage_settings"):
is_valid, message = config_manager.validate_path(storage.root_path)
if not is_valid:
raise HTTPException(status_code=400, detail=message)
settings = config_manager.get_config().settings
settings.storage = storage
config_manager.update_global_settings(settings)
return config_manager.get_config().settings.storage
# [/DEF:update_storage_settings:Function]
# [DEF:get_environments:Function]
# @PURPOSE: Lists all configured Superset environments.
# @PRE: Config manager is available.
# @POST: Returns list of environments.
# @RETURN: List[Environment] - List of environments.
@router.get("/environments", response_model=List[Environment])
async def get_environments(config_manager: ConfigManager = Depends(get_config_manager)):
logger.info("[get_environments][Entry] Fetching environments")
async def get_environments(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments")
return config_manager.get_environments()
# [/DEF:get_environments]
# [/DEF:get_environments:Function]
# [DEF:add_environment:Function]
# @PURPOSE: Adds a new Superset environment.
# @PRE: Environment data is valid and reachable.
# @POST: Environment is added to config.
# @PARAM: env (Environment) - The environment to add.
# @RETURN: Environment - The added environment.
@router.post("/environments", response_model=Environment)
async def add_environment(
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager)
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
# 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}")
@@ -91,20 +145,23 @@ async def add_environment(
config_manager.add_environment(env)
return env
# [/DEF:add_environment]
# [/DEF:add_environment:Function]
# [DEF:update_environment:Function]
# @PURPOSE: Updates an existing Superset environment.
# @PRE: ID and valid environment data are provided.
# @POST: Environment is updated in config.
# @PARAM: id (str) - The ID of the environment to update.
# @PARAM: env (Environment) - The updated environment data.
# @RETURN: Environment - The updated environment.
@router.put("/environments/{id}", response_model=Environment)
async def update_environment(
id: str,
env: Environment,
id: str,
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager)
):
logger.info(f"[update_environment][Entry] Updating environment {id}")
with belief_scope("update_environment"):
logger.info(f"[update_environment][Entry] Updating environment {id}")
# If password is masked, we need the real one for validation
env_to_validate = env.copy(deep=True)
@@ -115,17 +172,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}")
@@ -134,23 +181,28 @@ async def update_environment(
if config_manager.update_environment(id, env):
return env
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
# [/DEF:update_environment]
# [/DEF:update_environment:Function]
# [DEF:delete_environment:Function]
# @PURPOSE: Deletes a Superset environment.
# @PRE: ID is provided.
# @POST: Environment is removed from config.
# @PARAM: id (str) - The ID of the environment to delete.
@router.delete("/environments/{id}")
async def delete_environment(
id: str,
id: str,
config_manager: ConfigManager = Depends(get_config_manager)
):
logger.info(f"[delete_environment][Entry] Deleting environment {id}")
with belief_scope("delete_environment"):
logger.info(f"[delete_environment][Entry] Deleting environment {id}")
config_manager.delete_environment(id)
return {"message": f"Environment {id} deleted"}
# [/DEF:delete_environment]
# [/DEF:delete_environment:Function]
# [DEF:test_environment_connection:Function]
# @PURPOSE: Tests the connection to a Superset environment.
# @PRE: ID is provided.
# @POST: Returns success or error status.
# @PARAM: id (str) - The ID of the environment to test.
# @RETURN: dict - Success message or error.
@router.post("/environments/{id}/test")
@@ -158,7 +210,8 @@ async def test_environment_connection(
id: str,
config_manager: ConfigManager = Depends(get_config_manager)
):
logger.info(f"[test_environment_connection][Entry] Testing environment {id}")
with belief_scope("test_environment_connection"):
logger.info(f"[test_environment_connection][Entry] Testing environment {id}")
# Find environment
env = next((e for e in config_manager.get_environments() if e.id == id), None)
@@ -166,21 +219,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})
@@ -190,29 +230,147 @@ async def test_environment_connection(
except Exception as e:
logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}")
return {"status": "error", "message": str(e)}
# [/DEF:test_environment_connection]
# [/DEF:test_environment_connection:Function]
# [DEF:validate_backup_path:Function]
# @PURPOSE: Validates if a backup path exists and is writable.
# @PARAM: path (str) - The path to validate.
# @RETURN: dict - Validation result.
@router.post("/validate-path")
async def validate_backup_path(
path_data: dict,
config_manager: ConfigManager = Depends(get_config_manager)
# [DEF:get_logging_config:Function]
# @PURPOSE: Retrieves current logging configuration.
# @PRE: Config manager is available.
# @POST: Returns logging configuration.
# @RETURN: LoggingConfigResponse - The current logging config.
@router.get("/logging", response_model=LoggingConfigResponse)
async def get_logging_config(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
path = path_data.get("path")
if not path:
raise HTTPException(status_code=400, detail="Path is required")
logger.info(f"[validate_backup_path][Entry] Validating path: {path}")
valid, message = config_manager.validate_path(path)
if not valid:
return {"status": "error", "message": message}
return {"status": "success", "message": message}
# [/DEF:validate_backup_path]
with belief_scope("get_logging_config"):
logging_config = config_manager.get_config().settings.logging
return LoggingConfigResponse(
level=logging_config.level,
task_log_level=logging_config.task_log_level,
enable_belief_state=logging_config.enable_belief_state
)
# [/DEF:get_logging_config:Function]
# [/DEF:SettingsRouter]
# [DEF:update_logging_config:Function]
# @PURPOSE: Updates logging configuration.
# @PRE: New logging config is provided.
# @POST: Logging configuration is updated and saved.
# @PARAM: config (LoggingConfig) - The new logging configuration.
# @RETURN: LoggingConfigResponse - The updated logging config.
@router.patch("/logging", response_model=LoggingConfigResponse)
async def update_logging_config(
config: LoggingConfig,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_logging_config"):
logger.info(f"[update_logging_config][Entry] Updating logging config: level={config.level}, task_log_level={config.task_log_level}")
# Get current settings and update logging config
settings = config_manager.get_config().settings
settings.logging = config
config_manager.update_global_settings(settings)
return LoggingConfigResponse(
level=config.level,
task_log_level=config.task_log_level,
enable_belief_state=config.enable_belief_state
)
# [/DEF:update_logging_config:Function]
# [DEF:ConsolidatedSettingsResponse:Class]
class ConsolidatedSettingsResponse(BaseModel):
environments: List[dict]
connections: List[dict]
llm: dict
llm_providers: List[dict]
logging: dict
storage: dict
# [/DEF:ConsolidatedSettingsResponse:Class]
# [DEF:get_consolidated_settings:Function]
# @PURPOSE: Retrieves all settings categories in a single call
# @PRE: Config manager is available.
# @POST: Returns all consolidated settings.
# @RETURN: ConsolidatedSettingsResponse - All settings categories.
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
async def get_consolidated_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_consolidated_settings"):
logger.info("[get_consolidated_settings][Entry] Fetching all consolidated settings")
config = config_manager.get_config()
from ...services.llm_provider import LLMProviderService
from ...core.database import SessionLocal
db = SessionLocal()
try:
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
llm_providers_list = [
{
"id": p.id,
"provider_type": p.provider_type,
"name": p.name,
"base_url": p.base_url,
"api_key": "********",
"default_model": p.default_model,
"is_active": p.is_active
} for p in providers
]
finally:
db.close()
return ConsolidatedSettingsResponse(
environments=[env.dict() for env in config.environments],
connections=config.settings.connections,
llm=config.settings.llm,
llm_providers=llm_providers_list,
logging=config.settings.logging.dict(),
storage=config.settings.storage.dict()
)
# [/DEF:get_consolidated_settings:Function]
# [DEF:update_consolidated_settings:Function]
# @PURPOSE: Bulk update application settings from the consolidated view.
# @PRE: User has admin permissions, config is valid.
# @POST: Settings are updated and saved via ConfigManager.
@router.patch("/consolidated")
async def update_consolidated_settings(
settings_patch: dict,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_consolidated_settings"):
logger.info("[update_consolidated_settings][Entry] Applying consolidated settings patch")
current_config = config_manager.get_config()
current_settings = current_config.settings
# Update connections if provided
if "connections" in settings_patch:
current_settings.connections = settings_patch["connections"]
# Update LLM if provided
if "llm" in settings_patch:
current_settings.llm = settings_patch["llm"]
# Update Logging if provided
if "logging" in settings_patch:
current_settings.logging = LoggingConfig(**settings_patch["logging"])
# Update Storage if provided
if "storage" in settings_patch:
new_storage = StorageConfig(**settings_patch["storage"])
is_valid, message = config_manager.validate_path(new_storage.root_path)
if not is_valid:
raise HTTPException(status_code=400, detail=message)
current_settings.storage = new_storage
config_manager.update_global_settings(current_settings)
return {"status": "success", "message": "Settings updated"}
# [/DEF:update_consolidated_settings:Function]
# [/DEF:SettingsRouter:Module]

View File

@@ -0,0 +1,146 @@
# [DEF:storage_routes:Module]
#
# @TIER: STANDARD
# @SEMANTICS: storage, files, upload, download, backup, repository
# @PURPOSE: API endpoints for file storage management (backups and repositories).
# @LAYER: API
# @RELATION: DEPENDS_ON -> backend.src.models.storage
#
# @INVARIANT: All paths must be validated against path traversal.
# [SECTION: IMPORTS]
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse
from typing import List, Optional
from ...models.storage import StoredFile, FileCategory
from ...dependencies import get_plugin_loader, has_permission
from ...plugins.storage.plugin import StoragePlugin
from ...core.logger import belief_scope
# [/SECTION]
router = APIRouter(tags=["storage"])
# [DEF:list_files:Function]
# @PURPOSE: List all files and directories in the storage system.
#
# @PRE: None.
# @POST: Returns a list of StoredFile objects.
#
# @PARAM: category (Optional[FileCategory]) - Filter by category.
# @PARAM: path (Optional[str]) - Subpath within the category.
# @RETURN: List[StoredFile] - List of files/directories.
#
# @RELATION: CALLS -> StoragePlugin.list_files
@router.get("/files", response_model=List[StoredFile])
async def list_files(
category: Optional[FileCategory] = None,
path: Optional[str] = None,
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "READ"))
):
with belief_scope("list_files"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
return storage_plugin.list_files(category, path)
# [/DEF:list_files:Function]
# [DEF:upload_file:Function]
# @PURPOSE: Upload a file to the storage system.
#
# @PRE: category must be a valid FileCategory.
# @PRE: file must be a valid UploadFile.
# @POST: Returns the StoredFile object of the uploaded file.
#
# @PARAM: category (FileCategory) - Target category.
# @PARAM: path (Optional[str]) - Target subpath.
# @PARAM: file (UploadFile) - The file content.
# @RETURN: StoredFile - Metadata of the uploaded file.
#
# @SIDE_EFFECT: Writes file to the filesystem.
#
# @RELATION: CALLS -> StoragePlugin.save_file
@router.post("/upload", response_model=StoredFile, status_code=201)
async def upload_file(
category: FileCategory = Form(...),
path: Optional[str] = Form(None),
file: UploadFile = File(...),
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "WRITE"))
):
with belief_scope("upload_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
try:
return await storage_plugin.save_file(file, category, path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:upload_file:Function]
# [DEF:delete_file:Function]
# @PURPOSE: Delete a specific file or directory.
#
# @PRE: category must be a valid FileCategory.
# @POST: Item is removed from storage.
#
# @PARAM: category (FileCategory) - File category.
# @PARAM: path (str) - Relative path of the item.
# @RETURN: None
#
# @SIDE_EFFECT: Deletes item from the filesystem.
#
# @RELATION: CALLS -> StoragePlugin.delete_file
@router.delete("/files/{category}/{path:path}", status_code=204)
async def delete_file(
category: FileCategory,
path: str,
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "WRITE"))
):
with belief_scope("delete_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
try:
storage_plugin.delete_file(category, path)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="File not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:delete_file:Function]
# [DEF:download_file:Function]
# @PURPOSE: Retrieve a file for download.
#
# @PRE: category must be a valid FileCategory.
# @POST: Returns a FileResponse.
#
# @PARAM: category (FileCategory) - File category.
# @PARAM: path (str) - Relative path of the file.
# @RETURN: FileResponse - The file content.
#
# @RELATION: CALLS -> StoragePlugin.get_file_path
@router.get("/download/{category}/{path:path}")
async def download_file(
category: FileCategory,
path: str,
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "READ"))
):
with belief_scope("download_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
try:
abs_path = storage_plugin.get_file_path(category, path)
filename = Path(path).name
return FileResponse(path=abs_path, filename=filename)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="File not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# [/DEF:download_file:Function]
# [/DEF:storage_routes:Module]

View File

@@ -1,75 +1,303 @@
# [DEF:TasksRouter:Module]
# @SEMANTICS: api, router, tasks, create, list, get
# @TIER: STANDARD
# @SEMANTICS: api, router, tasks, create, list, get, logs
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
# @LAYER: UI (API)
# @RELATION: Depends on the TaskManager. It is included by the main app.
from typing import List, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel
from ...core.logger import belief_scope
from ...core.task_manager import TaskManager, Task
from ...dependencies import get_task_manager
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...core.task_manager.models import LogFilter, LogStats
from ...dependencies import get_task_manager, has_permission, get_current_user
router = APIRouter()
class CreateTaskRequest(BaseModel):
router = APIRouter()
TASK_TYPE_PLUGIN_MAP = {
"llm_validation": ["llm_dashboard_validation"],
"backup": ["superset-backup"],
"migration": ["superset-migration"],
}
class CreateTaskRequest(BaseModel):
plugin_id: str
params: Dict[str, Any]
class ResolveTaskRequest(BaseModel):
resolution_params: Dict[str, Any]
@router.post("/", response_model=Task, status_code=status.HTTP_201_CREATED)
class ResumeTaskRequest(BaseModel):
passwords: Dict[str, str]
@router.post("", response_model=Task, status_code=status.HTTP_201_CREATED)
# [DEF:create_task:Function]
# @PURPOSE: Create and start a new task for a given plugin.
# @PARAM: request (CreateTaskRequest) - The request body containing plugin_id and params.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: plugin_id must exist and params must be valid for that plugin.
# @POST: A new task is created and started.
# @RETURN: Task - The created task instance.
async def create_task(
request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
current_user = Depends(get_current_user)
):
# Dynamic permission check based on plugin_id
has_permission(f"plugin:{request.plugin_id}", "EXECUTE")(current_user)
"""
Create and start a new task for a given plugin.
"""
try:
task = await task_manager.create_task(
plugin_id=request.plugin_id,
params=request.params
)
return task
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
with belief_scope("create_task"):
try:
# Special handling for validation task to include provider config
if request.plugin_id == "llm_dashboard_validation":
from ...core.database import SessionLocal
from ...services.llm_provider import LLMProviderService
db = SessionLocal()
try:
llm_service = LLMProviderService(db)
provider_id = request.params.get("provider_id")
if provider_id:
db_provider = llm_service.get_provider(provider_id)
if not db_provider:
raise ValueError(f"LLM Provider {provider_id} not found")
finally:
db.close()
@router.get("/", response_model=List[Task])
async def list_tasks(
task_manager: TaskManager = Depends(get_task_manager)
):
"""
Retrieve a list of all tasks.
"""
return task_manager.get_all_tasks()
task = await task_manager.create_task(
plugin_id=request.plugin_id,
params=request.params
)
return task
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:create_task:Function]
@router.get("", response_model=List[Task])
# [DEF:list_tasks:Function]
# @PURPOSE: Retrieve a list of tasks with pagination and optional status filter.
# @PARAM: limit (int) - Maximum number of tasks to return.
# @PARAM: offset (int) - Number of tasks to skip.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_manager must be available.
# @POST: Returns a list of tasks.
# @RETURN: List[Task] - List of tasks.
async def list_tasks(
limit: int = 10,
offset: int = 0,
status_filter: Optional[TaskStatus] = Query(None, alias="status"),
task_type: Optional[str] = Query(None, description="Task category: llm_validation, backup, migration"),
plugin_id: Optional[List[str]] = Query(None, description="Filter by plugin_id (repeatable query param)"),
completed_only: bool = Query(False, description="Return only completed tasks (SUCCESS/FAILED)"),
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve a list of tasks with pagination and optional status filter.
"""
with belief_scope("list_tasks"):
plugin_filters = list(plugin_id) if plugin_id else []
if task_type:
if task_type not in TASK_TYPE_PLUGIN_MAP:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported task_type '{task_type}'. Allowed: {', '.join(TASK_TYPE_PLUGIN_MAP.keys())}"
)
plugin_filters.extend(TASK_TYPE_PLUGIN_MAP[task_type])
return task_manager.get_tasks(
limit=limit,
offset=offset,
status=status_filter,
plugin_ids=plugin_filters or None,
completed_only=completed_only
)
# [/DEF:list_tasks:Function]
@router.get("/{task_id}", response_model=Task)
# [DEF:get_task:Function]
# @PURPOSE: Retrieve the details of a specific task.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_id must exist.
# @POST: Returns task details or raises 404.
# @RETURN: Task - The task details.
async def get_task(
task_id: str,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve the details of a specific task.
"""
task = task_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return task
with belief_scope("get_task"):
task = task_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return task
# [/DEF:get_task:Function]
@router.get("/{task_id}/logs", response_model=List[LogEntry])
# [DEF:get_task_logs:Function]
# @PURPOSE: Retrieve logs for a specific task with optional filtering.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: level (Optional[str]) - Filter by log level (DEBUG, INFO, WARNING, ERROR).
# @PARAM: source (Optional[str]) - Filter by source component.
# @PARAM: search (Optional[str]) - Text search in message.
# @PARAM: offset (int) - Number of logs to skip.
# @PARAM: limit (int) - Maximum number of logs to return.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_id must exist.
# @POST: Returns a list of log entries or raises 404.
# @RETURN: List[LogEntry] - List of log entries.
# @TIER: CRITICAL
async def get_task_logs(
task_id: str,
level: Optional[str] = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
source: Optional[str] = Query(None, description="Filter by source component"),
search: Optional[str] = Query(None, description="Text search in message"),
offset: int = Query(0, ge=0, description="Number of logs to skip"),
limit: int = Query(100, ge=1, le=1000, description="Maximum number of logs to return"),
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve logs for a specific task with optional filtering.
Supports filtering by level, source, and text search.
"""
with belief_scope("get_task_logs"):
task = task_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
log_filter = LogFilter(
level=level.upper() if level else None,
source=source,
search=search,
offset=offset,
limit=limit
)
return task_manager.get_task_logs(task_id, log_filter)
# [/DEF:get_task_logs:Function]
@router.get("/{task_id}/logs/stats", response_model=LogStats)
# [DEF:get_task_log_stats:Function]
# @PURPOSE: Get statistics about logs for a task (counts by level and source).
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_id must exist.
# @POST: Returns log statistics or raises 404.
# @RETURN: LogStats - Statistics about task logs.
async def get_task_log_stats(
task_id: str,
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Get statistics about logs for a task (counts by level and source).
"""
with belief_scope("get_task_log_stats"):
task = task_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return task_manager.get_task_log_stats(task_id)
# [/DEF:get_task_log_stats:Function]
@router.get("/{task_id}/logs/sources", response_model=List[str])
# [DEF:get_task_log_sources:Function]
# @PURPOSE: Get unique sources for a task's logs.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_id must exist.
# @POST: Returns list of unique source names or raises 404.
# @RETURN: List[str] - Unique source names.
async def get_task_log_sources(
task_id: str,
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Get unique sources for a task's logs.
"""
with belief_scope("get_task_log_sources"):
task = task_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return task_manager.get_task_log_sources(task_id)
# [/DEF:get_task_log_sources:Function]
@router.post("/{task_id}/resolve", response_model=Task)
# [DEF:resolve_task:Function]
# @PURPOSE: Resolve a task that is awaiting mapping.
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: request (ResolveTaskRequest) - The resolution parameters.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task must be in AWAITING_MAPPING status.
# @POST: Task is resolved and resumes execution.
# @RETURN: Task - The updated task object.
async def resolve_task(
task_id: str,
request: ResolveTaskRequest,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "WRITE"))
):
"""
Resolve a task that is awaiting mapping.
"""
try:
await task_manager.resolve_task(task_id, request.resolution_params)
return task_manager.get_task(task_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF]
with belief_scope("resolve_task"):
try:
await task_manager.resolve_task(task_id, request.resolution_params)
return task_manager.get_task(task_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:resolve_task:Function]
@router.post("/{task_id}/resume", response_model=Task)
# [DEF:resume_task:Function]
# @PURPOSE: Resume a task that is awaiting input (e.g., passwords).
# @PARAM: task_id (str) - The unique identifier of the task.
# @PARAM: request (ResumeTaskRequest) - The input (passwords).
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task must be in AWAITING_INPUT status.
# @POST: Task resumes execution with provided input.
# @RETURN: Task - The updated task object.
async def resume_task(
task_id: str,
request: ResumeTaskRequest,
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "WRITE"))
):
"""
Resume a task that is awaiting input (e.g., passwords).
"""
with belief_scope("resume_task"):
try:
task_manager.resume_task_with_password(task_id, request.passwords)
return task_manager.get_task(task_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:resume_task:Function]
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
# [DEF:clear_tasks:Function]
# @PURPOSE: Clear tasks matching the status filter.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @PARAM: task_manager (TaskManager) - The task manager instance.
# @PRE: task_manager is available.
# @POST: Tasks are removed from memory/persistence.
async def clear_tasks(
status: Optional[TaskStatus] = None,
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "WRITE"))
):
"""
Clear tasks matching the status filter. If no filter, clears all non-running tasks.
"""
with belief_scope("clear_tasks", f"status={status}"):
task_manager.clear_tasks(status)
return
# [/DEF:clear_tasks:Function]
# [/DEF:TasksRouter:Module]

View File

@@ -1,30 +1,28 @@
# [DEF:AppModule:Module]
# @TIER: CRITICAL
# @SEMANTICS: app, main, entrypoint, fastapi
# @PURPOSE: The main entry point for the FastAPI application. It initializes the app, configures CORS, sets up dependencies, includes API routers, and defines the WebSocket endpoint for log streaming.
# @LAYER: UI (API)
# @RELATION: Depends on the dependency module and API route modules.
import sys
# @INVARIANT: Only one FastAPI app instance exists per process.
# @INVARIANT: All WebSocket connections must be properly cleaned up on disconnect.
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
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import asyncio
import os
from .dependencies import get_task_manager
from .core.logger import logger
from .api.routes import plugins, tasks, settings, environments, mappings
from .core.database import init_db
# Initialize database
init_db()
from .dependencies import get_task_manager, get_scheduler_service
from .core.utils.network import NetworkError
from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets, reports
from .api import auth
# [DEF:App:Global]
# @SEMANTICS: app, fastapi, instance
@@ -34,6 +32,35 @@ app = FastAPI(
description="API for managing Superset automation tools and plugins.",
version="1.0.0",
)
# [/DEF:App:Global]
# [DEF:startup_event:Function]
# @PURPOSE: Handles application startup tasks, such as starting the scheduler.
# @PRE: None.
# @POST: Scheduler is started.
# Startup event
@app.on_event("startup")
async def startup_event():
with belief_scope("startup_event"):
scheduler = get_scheduler_service()
scheduler.start()
# [/DEF:startup_event:Function]
# [DEF:shutdown_event:Function]
# @PURPOSE: Handles application shutdown tasks, such as stopping the scheduler.
# @PRE: None.
# @POST: Scheduler is stopped.
# Shutdown event
@app.on_event("shutdown")
async def shutdown_event():
with belief_scope("shutdown_event"):
scheduler = get_scheduler_service()
scheduler.stop()
# [/DEF:shutdown_event:Function]
# Configure Session Middleware (required by Authlib for OAuth2 flow)
from .core.auth.config import auth_config
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
# Configure CORS
app.add_middleware(
@@ -45,35 +72,148 @@ app.add_middleware(
)
# [DEF:log_requests:Function]
# @PURPOSE: Middleware to log incoming HTTP requests and their response status.
# @PRE: request is a FastAPI Request object.
# @POST: Logs request and response details.
# @PARAM: request (Request) - The incoming request object.
# @PARAM: call_next (Callable) - The next middleware or route handler.
@app.exception_handler(NetworkError)
async def network_error_handler(request: Request, exc: NetworkError):
with belief_scope("network_error_handler"):
logger.error(f"Network error: {exc}")
return HTTPException(
status_code=503,
detail="Environment unavailable. Please check if the Superset instance is running."
)
@app.middleware("http")
async def log_requests(request: Request, call_next):
# Avoid spamming logs for polling endpoints
is_polling = request.url.path.endswith("/api/tasks") and request.method == "GET"
if not is_polling:
logger.info(f"Incoming request: {request.method} {request.url.path}")
try:
response = await call_next(request)
if not is_polling:
logger.info(f"Response status: {response.status_code} for {request.url.path}")
return response
except NetworkError as e:
logger.error(f"Network error caught in middleware: {e}")
raise HTTPException(
status_code=503,
detail="Environment unavailable. Please check if the Superset instance is running."
)
# [/DEF:log_requests:Function]
# Include API routes
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
app.include_router(environments.router)
app.include_router(mappings.router)
app.include_router(connections.router, prefix="/api/settings/connections", tags=["Connections"])
app.include_router(environments.router, tags=["Environments"])
app.include_router(mappings.router, prefix="/api/mappings", tags=["Mappings"])
app.include_router(migration.router)
app.include_router(git.router, prefix="/api/git", tags=["Git"])
app.include_router(llm.router, prefix="/api/llm", tags=["LLM"])
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
app.include_router(dashboards.router)
app.include_router(datasets.router)
app.include_router(reports.router)
# [DEF:WebSocketEndpoint:Endpoint]
# @SEMANTICS: websocket, logs, streaming, real-time
# @PURPOSE: Provides a WebSocket endpoint for clients to connect to and receive real-time log entries for a specific task.
# [DEF:api.include_routers:Action]
# @PURPOSE: Registers all API routers with the FastAPI application.
# @LAYER: API
# @SEMANTICS: routes, registration, api
# [/DEF:api.include_routers:Action]
# [DEF:websocket_endpoint:Function]
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering.
# @PRE: task_id must be a valid task ID.
# @POST: WebSocket connection is managed and logs are streamed until disconnect.
# @TIER: CRITICAL
# @UX_STATE: Connecting -> Streaming -> (Disconnected)
@app.websocket("/ws/logs/{task_id}")
async def websocket_endpoint(websocket: WebSocket, task_id: str):
await websocket.accept()
logger.info(f"WebSocket connection accepted for task {task_id}")
async def websocket_endpoint(
websocket: WebSocket,
task_id: str,
source: str = None,
level: str = None
):
"""
WebSocket endpoint for real-time log streaming with optional server-side filtering.
Query Parameters:
source: Filter logs by source component (e.g., "plugin", "superset_api")
level: Filter logs by minimum level (DEBUG, INFO, WARNING, ERROR)
"""
with belief_scope("websocket_endpoint", f"task_id={task_id}"):
await websocket.accept()
# Normalize filter parameters
source_filter = source.lower() if source else None
level_filter = level.upper() if level else None
# Level hierarchy for filtering
level_hierarchy = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
min_level = level_hierarchy.get(level_filter, 0) if level_filter else 0
logger.info(f"WebSocket connection accepted for task {task_id} (source={source_filter}, level={level_filter})")
task_manager = get_task_manager()
queue = await task_manager.subscribe_logs(task_id)
def matches_filters(log_entry) -> bool:
"""Check if log entry matches the filter criteria."""
# Check source filter
if source_filter and log_entry.source.lower() != source_filter:
return False
# Check level filter
if level_filter:
log_level = level_hierarchy.get(log_entry.level.upper(), 0)
if log_level < min_level:
return False
return True
try:
# Send initial logs if any
initial_logs = task_manager.get_task_logs(task_id)
for log_entry in initial_logs:
# Convert datetime to string for JSON serialization
log_dict = log_entry.dict()
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
await websocket.send_json(log_dict)
# Stream new logs
logger.info(f"Starting log stream for task {task_id}")
# Send initial logs first to build context (apply filters)
initial_logs = task_manager.get_task_logs(task_id)
for log_entry in initial_logs:
if matches_filters(log_entry):
log_dict = log_entry.dict()
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
await websocket.send_json(log_dict)
# Force a check for AWAITING_INPUT status immediately upon connection
# This ensures that if the task is already waiting when the user connects, they get the prompt.
task = task_manager.get_task(task_id)
if task and task.status == "AWAITING_INPUT" and task.input_request:
# Construct a synthetic log entry to trigger the frontend handler
# This is a bit of a hack but avoids changing the websocket protocol significantly
synthetic_log = {
"timestamp": task.logs[-1].timestamp.isoformat() if task.logs else "2024-01-01T00:00:00",
"level": "INFO",
"message": "Task paused for user input (Connection Re-established)",
"context": {"input_request": task.input_request}
}
await websocket.send_json(synthetic_log)
while True:
log_entry = await queue.get()
# Apply server-side filtering
if not matches_filters(log_entry):
continue
log_dict = log_entry.dict()
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
await websocket.send_json(log_dict)
@@ -83,7 +223,9 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str):
if "Task completed successfully" in log_entry.message or "Task failed" in log_entry.message:
# Wait a bit to ensure client receives the last message
await asyncio.sleep(2)
break
# DO NOT BREAK here - allow client to keep connection open if they want to review logs
# or until they disconnect. Breaking closes the socket immediately.
# break
except WebSocketDisconnect:
logger.info(f"WebSocket connection disconnected for task {task_id}")
@@ -91,8 +233,7 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str):
logger.error(f"WebSocket error for task {task_id}: {e}")
finally:
task_manager.unsubscribe_logs(task_id, queue)
# [/DEF]
# [/DEF:websocket_endpoint:Function]
# [DEF:StaticFiles:Mount]
# @SEMANTICS: static, frontend, spa
@@ -100,20 +241,34 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str):
frontend_path = project_root / "frontend" / "build"
if frontend_path.exists():
app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static")
# Serve other static files from the root of build directory
@app.get("/{file_path:path}")
# [DEF:serve_spa:Function]
# @PURPOSE: Serves the SPA frontend for any path not matched by API routes.
# @PRE: frontend_path exists.
# @POST: Returns the requested file or index.html.
@app.get("/{file_path:path}", include_in_schema=False)
async def serve_spa(file_path: str):
# Only serve SPA for non-API paths
# API routes are registered separately and should be matched by FastAPI first
if file_path and (file_path.startswith("api/") or file_path.startswith("/api/") or file_path == "api"):
# This should not happen if API routers are properly registered
# Return 404 instead of serving HTML
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
full_path = frontend_path / file_path
if full_path.is_file():
if file_path and full_path.is_file():
return FileResponse(str(full_path))
# Fallback to index.html for SPA routing
return FileResponse(str(frontend_path / "index.html"))
# [/DEF:serve_spa:Function]
else:
# [DEF:RootEndpoint:Endpoint]
# @SEMANTICS: root, healthcheck
# @PURPOSE: A simple root endpoint to confirm that the API is running.
# [DEF:read_root:Function]
# @PURPOSE: A simple root endpoint to confirm that the API is running when frontend is missing.
# @PRE: None.
# @POST: Returns a JSON message indicating API status.
@app.get("/")
async def read_root():
return {"message": "Superset Tools API is running (Frontend build not found)"}
# [/DEF]
with belief_scope("read_root"):
return {"message": "Superset Tools API is running (Frontend build not found)"}
# [/DEF:read_root:Function]
# [/DEF:StaticFiles:Mount]
# [/DEF:AppModule:Module]

View File

@@ -0,0 +1,179 @@
# [DEF:test_auth:Module]
# @TIER: STANDARD
# @PURPOSE: Unit tests for authentication module
# @LAYER: Domain
# @RELATION: VERIFIES -> src.core.auth
import sys
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.core.database import Base
from src.models.auth import User, Role, Permission, ADGroupMapping
from src.services.auth_service import AuthService
from src.core.auth.repository import AuthRepository
from src.core.auth.security import verify_password, get_password_hash
# Create in-memory SQLite database for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create all tables
Base.metadata.create_all(bind=engine)
@pytest.fixture
def db_session():
"""Create a new database session with a transaction, rollback after test"""
connection = engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture
def auth_service(db_session):
return AuthService(db_session)
@pytest.fixture
def auth_repo(db_session):
return AuthRepository(db_session)
def test_create_user(auth_repo):
"""Test user creation"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
retrieved_user = auth_repo.get_user_by_username("testuser")
assert retrieved_user is not None
assert retrieved_user.username == "testuser"
assert retrieved_user.email == "test@example.com"
assert verify_password("testpassword123", retrieved_user.password_hash)
def test_authenticate_user(auth_service, auth_repo):
"""Test user authentication with valid and invalid credentials"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
# Test valid credentials
authenticated_user = auth_service.authenticate_user("testuser", "testpassword123")
assert authenticated_user is not None
assert authenticated_user.username == "testuser"
# Test invalid password
invalid_user = auth_service.authenticate_user("testuser", "wrongpassword")
assert invalid_user is None
# Test invalid username
invalid_user = auth_service.authenticate_user("nonexistent", "testpassword123")
assert invalid_user is None
def test_create_session(auth_service, auth_repo):
"""Test session token creation"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
session = auth_service.create_session(user)
assert "access_token" in session
assert "token_type" in session
assert session["token_type"] == "bearer"
assert len(session["access_token"]) > 0
def test_role_permission_association(auth_repo):
"""Test role and permission association"""
role = Role(name="Admin", description="System administrator")
perm1 = Permission(resource="admin:users", action="READ")
perm2 = Permission(resource="admin:users", action="WRITE")
role.permissions.extend([perm1, perm2])
auth_repo.db.add(role)
auth_repo.db.commit()
retrieved_role = auth_repo.get_role_by_name("Admin")
assert retrieved_role is not None
assert len(retrieved_role.permissions) == 2
permissions = [f"{p.resource}:{p.action}" for p in retrieved_role.permissions]
assert "admin:users:READ" in permissions
assert "admin:users:WRITE" in permissions
def test_user_role_association(auth_repo):
"""Test user and role association"""
role = Role(name="Admin", description="System administrator")
user = User(
username="adminuser",
email="admin@example.com",
password_hash=get_password_hash("adminpass123"),
auth_source="LOCAL"
)
user.roles.append(role)
auth_repo.db.add(role)
auth_repo.db.add(user)
auth_repo.db.commit()
retrieved_user = auth_repo.get_user_by_username("adminuser")
assert retrieved_user is not None
assert len(retrieved_user.roles) == 1
assert retrieved_user.roles[0].name == "Admin"
def test_ad_group_mapping(auth_repo):
"""Test AD group mapping"""
role = Role(name="ADFS_Admin", description="ADFS administrators")
auth_repo.db.add(role)
auth_repo.db.commit()
mapping = ADGroupMapping(ad_group="DOMAIN\\ADFS_Admins", role_id=role.id)
auth_repo.db.add(mapping)
auth_repo.db.commit()
retrieved_mapping = auth_repo.db.query(ADGroupMapping).filter_by(ad_group="DOMAIN\\ADFS_Admins").first()
assert retrieved_mapping is not None
assert retrieved_mapping.role_id == role.id
# [/DEF:test_auth:Module]

View File

@@ -0,0 +1,47 @@
# [DEF:backend.src.core.auth.config:Module]
#
# @SEMANTICS: auth, config, settings, jwt, adfs
# @PURPOSE: Centralized configuration for authentication and authorization.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> pydantic
#
# @INVARIANT: All sensitive configuration must have defaults or be loaded from environment.
# [SECTION: IMPORTS]
from pydantic import Field
from pydantic_settings import BaseSettings
# [/SECTION]
# [DEF:AuthConfig:Class]
# @PURPOSE: Holds authentication-related settings.
# @PRE: Environment variables may be provided via .env file.
# @POST: Returns a configuration object with validated settings.
class AuthConfig(BaseSettings):
# JWT Settings
SECRET_KEY: str = Field(default="super-secret-key-change-in-production", env="AUTH_SECRET_KEY")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Database Settings
AUTH_DATABASE_URL: str = Field(
default="postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools",
env="AUTH_DATABASE_URL",
)
# ADFS Settings
ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID")
ADFS_CLIENT_SECRET: str = Field(default="", env="ADFS_CLIENT_SECRET")
ADFS_METADATA_URL: str = Field(default="", env="ADFS_METADATA_URL")
class Config:
env_file = ".env"
extra = "ignore"
# [/DEF:AuthConfig:Class]
# [DEF:auth_config:Variable]
# @PURPOSE: Singleton instance of AuthConfig.
auth_config = AuthConfig()
# [/DEF:auth_config:Variable]
# [/DEF:backend.src.core.auth.config:Module]

View File

@@ -0,0 +1,55 @@
# [DEF:backend.src.core.auth.jwt:Module]
#
# @TIER: STANDARD
# @SEMANTICS: jwt, token, session, auth
# @PURPOSE: JWT token generation and validation logic.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> jose
# @RELATION: USES -> backend.src.core.auth.config.auth_config
#
# @INVARIANT: Tokens must include expiration time and user identifier.
# [SECTION: IMPORTS]
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt
from .config import auth_config
from ..logger import belief_scope
# [/SECTION]
# [DEF:create_access_token:Function]
# @PURPOSE: Generates a new JWT access token.
# @PRE: data dict contains 'sub' (user_id) and optional 'scopes' (roles).
# @POST: Returns a signed JWT string.
#
# @PARAM: data (dict) - Payload data for the token.
# @PARAM: expires_delta (Optional[timedelta]) - Custom expiration time.
# @RETURN: str - The encoded JWT.
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
with belief_scope("create_access_token"):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=auth_config.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, auth_config.SECRET_KEY, algorithm=auth_config.ALGORITHM)
return encoded_jwt
# [/DEF:create_access_token:Function]
# [DEF:decode_token:Function]
# @PURPOSE: Decodes and validates a JWT token.
# @PRE: token is a signed JWT string.
# @POST: Returns the decoded payload if valid.
#
# @PARAM: token (str) - The JWT to decode.
# @RETURN: dict - The decoded payload.
# @THROW: jose.JWTError - If token is invalid or expired.
def decode_token(token: str) -> dict:
with belief_scope("decode_token"):
payload = jwt.decode(token, auth_config.SECRET_KEY, algorithms=[auth_config.ALGORITHM])
return payload
# [/DEF:decode_token:Function]
# [/DEF:backend.src.core.auth.jwt:Module]

View File

@@ -0,0 +1,32 @@
# [DEF:backend.src.core.auth.logger:Module]
#
# @TIER: STANDARD
# @SEMANTICS: auth, logger, audit, security
# @PURPOSE: Audit logging for security-related events.
# @LAYER: Core
# @RELATION: USES -> backend.src.core.logger.belief_scope
#
# @INVARIANT: Must not log sensitive data like passwords or full tokens.
# [SECTION: IMPORTS]
from ..logger import logger, belief_scope
from datetime import datetime
# [/SECTION]
# [DEF:log_security_event:Function]
# @PURPOSE: Logs a security-related event for audit trails.
# @PRE: event_type and username are strings.
# @POST: Security event is written to the application log.
# @PARAM: event_type (str) - Type of event (e.g., LOGIN_SUCCESS, PERMISSION_DENIED).
# @PARAM: username (str) - The user involved in the event.
# @PARAM: details (dict) - Additional non-sensitive metadata.
def log_security_event(event_type: str, username: str, details: dict = None):
with belief_scope("log_security_event", f"{event_type}:{username}"):
timestamp = datetime.utcnow().isoformat()
msg = f"[AUDIT][{timestamp}][{event_type}] User: {username}"
if details:
msg += f" Details: {details}"
logger.info(msg)
# [/DEF:log_security_event:Function]
# [/DEF:backend.src.core.auth.logger:Module]

View File

@@ -0,0 +1,51 @@
# [DEF:backend.src.core.auth.oauth:Module]
#
# @SEMANTICS: auth, oauth, oidc, adfs
# @PURPOSE: ADFS OIDC configuration and client using Authlib.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> authlib
# @RELATION: USES -> backend.src.core.auth.config.auth_config
#
# @INVARIANT: Must use secure OIDC flows.
# [SECTION: IMPORTS]
from authlib.integrations.starlette_client import OAuth
from .config import auth_config
# [/SECTION]
# [DEF:oauth:Variable]
# @PURPOSE: Global Authlib OAuth registry.
oauth = OAuth()
# [/DEF:oauth:Variable]
# [DEF:register_adfs:Function]
# @PURPOSE: Registers the ADFS OIDC client.
# @PRE: ADFS configuration is provided in auth_config.
# @POST: ADFS client is registered in oauth registry.
def register_adfs():
if auth_config.ADFS_CLIENT_ID:
oauth.register(
name='adfs',
client_id=auth_config.ADFS_CLIENT_ID,
client_secret=auth_config.ADFS_CLIENT_SECRET,
server_metadata_url=auth_config.ADFS_METADATA_URL,
client_kwargs={
'scope': 'openid email profile groups'
}
)
# [/DEF:register_adfs:Function]
# [DEF:is_adfs_configured:Function]
# @PURPOSE: Checks if ADFS is properly configured.
# @PRE: None.
# @POST: Returns True if ADFS client is registered, False otherwise.
# @RETURN: bool - Configuration status.
def is_adfs_configured() -> bool:
"""Check if ADFS OAuth client is registered."""
return 'adfs' in oauth._registry
# [/DEF:is_adfs_configured:Function]
# Initial registration
register_adfs()
# [/DEF:backend.src.core.auth.oauth:Module]

View File

@@ -0,0 +1,123 @@
# [DEF:backend.src.core.auth.repository:Module]
#
# @SEMANTICS: auth, repository, database, user, role
# @PURPOSE: Data access layer for authentication-related entities.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy
# @RELATION: USES -> backend.src.models.auth
#
# @INVARIANT: All database operations must be performed within a session.
# [SECTION: IMPORTS]
from typing import Optional, List
from sqlalchemy.orm import Session
from ...models.auth import User, Role, Permission
from ..logger import belief_scope
# [/SECTION]
# [DEF:AuthRepository:Class]
# @PURPOSE: Encapsulates database operations for authentication.
class AuthRepository:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the repository with a database session.
# @PARAM: db (Session) - SQLAlchemy session.
def __init__(self, db: Session):
self.db = db
# [/DEF:__init__:Function]
# [DEF:get_user_by_username:Function]
# @PURPOSE: Retrieves a user by their username.
# @PRE: username is a string.
# @POST: Returns User object if found, else None.
# @PARAM: username (str) - The username to search for.
# @RETURN: Optional[User] - The found user or None.
def get_user_by_username(self, username: str) -> Optional[User]:
with belief_scope("AuthRepository.get_user_by_username"):
return self.db.query(User).filter(User.username == username).first()
# [/DEF:get_user_by_username:Function]
# [DEF:get_user_by_id:Function]
# @PURPOSE: Retrieves a user by their unique ID.
# @PRE: user_id is a valid UUID string.
# @POST: Returns User object if found, else None.
# @PARAM: user_id (str) - The user's unique identifier.
# @RETURN: Optional[User] - The found user or None.
def get_user_by_id(self, user_id: str) -> Optional[User]:
with belief_scope("AuthRepository.get_user_by_id"):
return self.db.query(User).filter(User.id == user_id).first()
# [/DEF:get_user_by_id:Function]
# [DEF:get_role_by_name:Function]
# @PURPOSE: Retrieves a role by its name.
# @PRE: name is a string.
# @POST: Returns Role object if found, else None.
# @PARAM: name (str) - The role name to search for.
# @RETURN: Optional[Role] - The found role or None.
def get_role_by_name(self, name: str) -> Optional[Role]:
with belief_scope("AuthRepository.get_role_by_name"):
return self.db.query(Role).filter(Role.name == name).first()
# [/DEF:get_role_by_name:Function]
# [DEF:update_last_login:Function]
# @PURPOSE: Updates the last_login timestamp for a user.
# @PRE: user object is a valid User instance.
# @POST: User's last_login is updated in the database.
# @SIDE_EFFECT: Commits the transaction.
# @PARAM: user (User) - The user to update.
def update_last_login(self, user: User):
with belief_scope("AuthRepository.update_last_login"):
from datetime import datetime
user.last_login = datetime.utcnow()
self.db.add(user)
self.db.commit()
# [/DEF:update_last_login:Function]
# [DEF:get_role_by_id:Function]
# @PURPOSE: Retrieves a role by its unique ID.
# @PRE: role_id is a string.
# @POST: Returns Role object if found, else None.
# @PARAM: role_id (str) - The role's unique identifier.
# @RETURN: Optional[Role] - The found role or None.
def get_role_by_id(self, role_id: str) -> Optional[Role]:
with belief_scope("AuthRepository.get_role_by_id"):
return self.db.query(Role).filter(Role.id == role_id).first()
# [/DEF:get_role_by_id:Function]
# [DEF:get_permission_by_id:Function]
# @PURPOSE: Retrieves a permission by its unique ID.
# @PRE: perm_id is a string.
# @POST: Returns Permission object if found, else None.
# @PARAM: perm_id (str) - The permission's unique identifier.
# @RETURN: Optional[Permission] - The found permission or None.
def get_permission_by_id(self, perm_id: str) -> Optional[Permission]:
with belief_scope("AuthRepository.get_permission_by_id"):
return self.db.query(Permission).filter(Permission.id == perm_id).first()
# [/DEF:get_permission_by_id:Function]
# [DEF:get_permission_by_resource_action:Function]
# @PURPOSE: Retrieves a permission by resource and action.
# @PRE: resource and action are strings.
# @POST: Returns Permission object if found, else None.
# @PARAM: resource (str) - The resource name.
# @PARAM: action (str) - The action name.
# @RETURN: Optional[Permission] - The found permission or None.
def get_permission_by_resource_action(self, resource: str, action: str) -> Optional[Permission]:
with belief_scope("AuthRepository.get_permission_by_resource_action"):
return self.db.query(Permission).filter(
Permission.resource == resource,
Permission.action == action
).first()
# [/DEF:get_permission_by_resource_action:Function]
# [DEF:list_permissions:Function]
# @PURPOSE: Lists all available permissions.
# @POST: Returns a list of all Permission objects.
# @RETURN: List[Permission] - List of permissions.
def list_permissions(self) -> List[Permission]:
with belief_scope("AuthRepository.list_permissions"):
return self.db.query(Permission).all()
# [/DEF:list_permissions:Function]
# [/DEF:AuthRepository:Class]
# [/DEF:backend.src.core.auth.repository:Module]

View File

@@ -0,0 +1,45 @@
# [DEF:backend.src.core.auth.security:Module]
#
# @SEMANTICS: security, password, hashing, bcrypt
# @PURPOSE: Utility for password hashing and verification using Passlib.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> passlib
#
# @INVARIANT: Uses bcrypt for hashing with standard work factor.
# [SECTION: IMPORTS]
import bcrypt
# [/SECTION]
# [DEF:verify_password:Function]
# @PURPOSE: Verifies a plain password against a hashed password.
# @PRE: plain_password is a string, hashed_password is a bcrypt hash.
# @POST: Returns True if password matches, False otherwise.
#
# @PARAM: plain_password (str) - The unhashed password.
# @PARAM: hashed_password (str) - The stored hash.
# @RETURN: bool - Verification result.
def verify_password(plain_password: str, hashed_password: str) -> bool:
if not hashed_password:
return False
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
except Exception:
return False
# [/DEF:verify_password:Function]
# [DEF:get_password_hash:Function]
# @PURPOSE: Generates a bcrypt hash for a plain password.
# @PRE: password is a string.
# @POST: Returns a secure bcrypt hash string.
#
# @PARAM: password (str) - The password to hash.
# @RETURN: str - The generated hash.
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
# [/DEF:get_password_hash:Function]
# [/DEF:backend.src.core.auth.security:Module]

516
backend/src/core/config_manager.py Executable file → Normal file
View File

@@ -1,230 +1,286 @@
# [DEF:ConfigManagerModule:Module]
#
# @SEMANTICS: config, manager, persistence, json
# @PURPOSE: Manages application configuration, including loading/saving to JSON and CRUD for environments.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> ConfigModels
# @RELATION: CALLS -> logger
# @RELATION: WRITES_TO -> config.json
#
# @INVARIANT: Configuration must always be valid according to AppConfig model.
# @PUBLIC_API: ConfigManager
# [SECTION: IMPORTS]
import json
import os
from pathlib import Path
from typing import Optional, List
from .config_models import AppConfig, Environment, GlobalSettings
from .logger import logger
# [/SECTION]
# [DEF:ConfigManager:Class]
# @PURPOSE: A class to handle application configuration persistence and management.
# @RELATION: WRITES_TO -> config.json
class ConfigManager:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the ConfigManager.
# @PRE: isinstance(config_path, str) and len(config_path) > 0
# @POST: self.config is an instance of AppConfig
# @PARAM: config_path (str) - Path to the configuration file.
def __init__(self, config_path: str = "config.json"):
# 1. Runtime check of @PRE
assert isinstance(config_path, str) and config_path, "config_path must be a non-empty string"
logger.info(f"[ConfigManager][Entry] Initializing with {config_path}")
# 2. Logic implementation
self.config_path = Path(config_path)
self.config: AppConfig = self._load_config()
# 3. Runtime check of @POST
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
logger.info(f"[ConfigManager][Exit] Initialized")
# [/DEF:__init__]
# [DEF:_load_config:Function]
# @PURPOSE: Loads the configuration from disk or creates a default one.
# @POST: isinstance(return, AppConfig)
# @RETURN: AppConfig - The loaded or default configuration.
def _load_config(self) -> AppConfig:
logger.debug(f"[_load_config][Entry] Loading from {self.config_path}")
if not self.config_path.exists():
logger.info(f"[_load_config][Action] Config file not found. Creating default.")
default_config = AppConfig(
environments=[],
settings=GlobalSettings(backup_path="backups")
)
self._save_config_to_disk(default_config)
return default_config
try:
with open(self.config_path, "r") as f:
data = json.load(f)
config = AppConfig(**data)
logger.info(f"[_load_config][Coherence:OK] Configuration loaded")
return config
except Exception as e:
logger.error(f"[_load_config][Coherence:Failed] Error loading config: {e}")
return AppConfig(
environments=[],
settings=GlobalSettings(backup_path="backups")
)
# [/DEF:_load_config]
# [DEF:_save_config_to_disk:Function]
# @PURPOSE: Saves the provided configuration object to disk.
# @PRE: isinstance(config, AppConfig)
# @PARAM: config (AppConfig) - The configuration to save.
def _save_config_to_disk(self, config: AppConfig):
logger.debug(f"[_save_config_to_disk][Entry] Saving to {self.config_path}")
# 1. Runtime check of @PRE
assert isinstance(config, AppConfig), "config must be an instance of AppConfig"
# 2. Logic implementation
try:
with open(self.config_path, "w") as f:
json.dump(config.dict(), f, indent=4)
logger.info(f"[_save_config_to_disk][Action] Configuration saved")
except Exception as e:
logger.error(f"[_save_config_to_disk][Coherence:Failed] Failed to save: {e}")
# [/DEF:_save_config_to_disk]
# [DEF:save:Function]
# @PURPOSE: Saves the current configuration state to disk.
def save(self):
self._save_config_to_disk(self.config)
# [/DEF:save]
# [DEF:get_config:Function]
# @PURPOSE: Returns the current configuration.
# @RETURN: AppConfig - The current configuration.
def get_config(self) -> AppConfig:
return self.config
# [/DEF:get_config]
# [DEF:update_global_settings:Function]
# @PURPOSE: Updates the global settings and persists the change.
# @PRE: isinstance(settings, GlobalSettings)
# @PARAM: settings (GlobalSettings) - The new global settings.
def update_global_settings(self, settings: GlobalSettings):
logger.info(f"[update_global_settings][Entry] Updating settings")
# 1. Runtime check of @PRE
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
# 2. Logic implementation
self.config.settings = settings
self.save()
logger.info(f"[update_global_settings][Exit] Settings updated")
# [/DEF:update_global_settings]
# [DEF:validate_path:Function]
# @PURPOSE: Validates if a path exists and is writable.
# @PARAM: path (str) - The path to validate.
# @RETURN: tuple (bool, str) - (is_valid, message)
def validate_path(self, path: str) -> tuple[bool, str]:
p = os.path.abspath(path)
if not os.path.exists(p):
try:
os.makedirs(p, exist_ok=True)
except Exception as e:
return False, f"Path does not exist and could not be created: {e}"
if not os.access(p, os.W_OK):
return False, "Path is not writable"
return True, "Path is valid and writable"
# [/DEF:validate_path]
# [DEF:get_environments:Function]
# @PURPOSE: Returns the list of configured environments.
# @RETURN: List[Environment] - List of environments.
def get_environments(self) -> List[Environment]:
return self.config.environments
# [/DEF:get_environments]
# [DEF:has_environments:Function]
# @PURPOSE: Checks if at least one environment is configured.
# @RETURN: bool - True if at least one environment exists.
def has_environments(self) -> bool:
return len(self.config.environments) > 0
# [/DEF:has_environments]
# [DEF:add_environment:Function]
# @PURPOSE: Adds a new environment to the configuration.
# @PRE: isinstance(env, Environment)
# @PARAM: env (Environment) - The environment to add.
def add_environment(self, env: Environment):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
# 1. Runtime check of @PRE
assert isinstance(env, Environment), "env must be an instance of Environment"
# 2. Logic implementation
# Check for duplicate ID and remove if exists
self.config.environments = [e for e in self.config.environments if e.id != env.id]
self.config.environments.append(env)
self.save()
logger.info(f"[add_environment][Exit] Environment added")
# [/DEF:add_environment]
# [DEF:update_environment:Function]
# @PURPOSE: Updates an existing environment.
# @PRE: isinstance(env_id, str) and len(env_id) > 0 and isinstance(updated_env, Environment)
# @PARAM: env_id (str) - The ID of the environment to update.
# @PARAM: updated_env (Environment) - The updated environment data.
# @RETURN: bool - True if updated, False otherwise.
def update_environment(self, env_id: str, updated_env: Environment) -> bool:
logger.info(f"[update_environment][Entry] Updating {env_id}")
# 1. Runtime check of @PRE
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
assert isinstance(updated_env, Environment), "updated_env must be an instance of Environment"
# 2. Logic implementation
for i, env in enumerate(self.config.environments):
if env.id == env_id:
# If password is masked, keep the old one
if updated_env.password == "********":
updated_env.password = env.password
self.config.environments[i] = updated_env
self.save()
logger.info(f"[update_environment][Coherence:OK] Updated {env_id}")
return True
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
return False
# [/DEF:update_environment]
# [DEF:delete_environment:Function]
# @PURPOSE: Deletes an environment by ID.
# @PRE: isinstance(env_id, str) and len(env_id) > 0
# @PARAM: env_id (str) - The ID of the environment to delete.
def delete_environment(self, env_id: str):
logger.info(f"[delete_environment][Entry] Deleting {env_id}")
# 1. Runtime check of @PRE
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
# 2. Logic implementation
original_count = len(self.config.environments)
self.config.environments = [e for e in self.config.environments if e.id != env_id]
if len(self.config.environments) < original_count:
self.save()
logger.info(f"[delete_environment][Action] Deleted {env_id}")
else:
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
# [/DEF:delete_environment]
# [/DEF:ConfigManager]
# [/DEF:ConfigManagerModule]
# [DEF:ConfigManagerModule:Module]
#
# @TIER: STANDARD
# @SEMANTICS: config, manager, persistence, postgresql
# @PURPOSE: Manages application configuration persisted in database with one-time migration from JSON.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> ConfigModels
# @RELATION: DEPENDS_ON -> AppConfigRecord
# @RELATION: CALLS -> logger
#
# @INVARIANT: Configuration must always be valid according to AppConfig model.
# @PUBLIC_API: ConfigManager
# [SECTION: IMPORTS]
import json
import os
from pathlib import Path
from typing import Optional, List
from sqlalchemy.orm import Session
from .config_models import AppConfig, Environment, GlobalSettings, StorageConfig
from .database import SessionLocal
from ..models.config import AppConfigRecord
from .logger import logger, configure_logger, belief_scope
# [/SECTION]
# [DEF:ConfigManager:Class]
# @TIER: STANDARD
# @PURPOSE: A class to handle application configuration persistence and management.
class ConfigManager:
# [DEF:__init__:Function]
# @TIER: STANDARD
# @PURPOSE: Initializes the ConfigManager.
# @PRE: isinstance(config_path, str) and len(config_path) > 0
# @POST: self.config is an instance of AppConfig
# @PARAM: config_path (str) - Path to legacy JSON config (used only for initial migration fallback).
def __init__(self, config_path: str = "config.json"):
with belief_scope("__init__"):
assert isinstance(config_path, str) and config_path, "config_path must be a non-empty string"
logger.info(f"[ConfigManager][Entry] Initializing with legacy path {config_path}")
self.config_path = Path(config_path)
self.config: AppConfig = self._load_config()
configure_logger(self.config.settings.logging)
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
logger.info("[ConfigManager][Exit] Initialized")
# [/DEF:__init__:Function]
# [DEF:_default_config:Function]
# @PURPOSE: Returns default application configuration.
# @RETURN: AppConfig - Default configuration.
def _default_config(self) -> AppConfig:
return AppConfig(
environments=[],
settings=GlobalSettings(storage=StorageConfig()),
)
# [/DEF:_default_config:Function]
# [DEF:_load_from_legacy_file:Function]
# @PURPOSE: Loads legacy configuration from config.json for migration fallback.
# @RETURN: AppConfig - Loaded or default configuration.
def _load_from_legacy_file(self) -> AppConfig:
with belief_scope("_load_from_legacy_file"):
if not self.config_path.exists():
logger.info("[_load_from_legacy_file][Action] Legacy config file not found, using defaults")
return self._default_config()
try:
with open(self.config_path, "r", encoding="utf-8") as f:
data = json.load(f)
logger.info("[_load_from_legacy_file][Coherence:OK] Legacy configuration loaded")
return AppConfig(**data)
except Exception as e:
logger.error(f"[_load_from_legacy_file][Coherence:Failed] Error loading legacy config: {e}")
return self._default_config()
# [/DEF:_load_from_legacy_file:Function]
# [DEF:_get_record:Function]
# @PURPOSE: Loads config record from DB.
# @PARAM: session (Session) - DB session.
# @RETURN: Optional[AppConfigRecord] - Existing record or None.
def _get_record(self, session: Session) -> Optional[AppConfigRecord]:
return session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
# [/DEF:_get_record:Function]
# [DEF:_load_config:Function]
# @PURPOSE: Loads the configuration from DB or performs one-time migration from JSON file.
# @PRE: DB session factory is available.
# @POST: isinstance(return, AppConfig)
# @RETURN: AppConfig - Loaded configuration.
def _load_config(self) -> AppConfig:
with belief_scope("_load_config"):
session: Session = SessionLocal()
try:
record = self._get_record(session)
if record and record.payload:
logger.info("[_load_config][Coherence:OK] Configuration loaded from database")
return AppConfig(**record.payload)
logger.info("[_load_config][Action] No database config found, migrating legacy config")
config = self._load_from_legacy_file()
self._save_config_to_db(config, session=session)
return config
except Exception as e:
logger.error(f"[_load_config][Coherence:Failed] Error loading config from DB: {e}")
return self._default_config()
finally:
session.close()
# [/DEF:_load_config:Function]
# [DEF:_save_config_to_db:Function]
# @PURPOSE: Saves the provided configuration object to DB.
# @PRE: isinstance(config, AppConfig)
# @POST: Configuration saved to database.
# @PARAM: config (AppConfig) - The configuration to save.
# @PARAM: session (Optional[Session]) - Existing DB session for transactional reuse.
def _save_config_to_db(self, config: AppConfig, session: Optional[Session] = None):
with belief_scope("_save_config_to_db"):
assert isinstance(config, AppConfig), "config must be an instance of AppConfig"
owns_session = session is None
db = session or SessionLocal()
try:
record = self._get_record(db)
payload = config.model_dump()
if record is None:
record = AppConfigRecord(id="global", payload=payload)
db.add(record)
else:
record.payload = payload
db.commit()
logger.info("[_save_config_to_db][Action] Configuration saved to database")
except Exception as e:
db.rollback()
logger.error(f"[_save_config_to_db][Coherence:Failed] Failed to save: {e}")
raise
finally:
if owns_session:
db.close()
# [/DEF:_save_config_to_db:Function]
# [DEF:save:Function]
# @PURPOSE: Saves the current configuration state to DB.
# @PRE: self.config is set.
# @POST: self._save_config_to_db called.
def save(self):
with belief_scope("save"):
self._save_config_to_db(self.config)
# [/DEF:save:Function]
# [DEF:get_config:Function]
# @PURPOSE: Returns the current configuration.
# @RETURN: AppConfig - The current configuration.
def get_config(self) -> AppConfig:
with belief_scope("get_config"):
return self.config
# [/DEF:get_config:Function]
# [DEF:update_global_settings:Function]
# @PURPOSE: Updates the global settings and persists the change.
# @PRE: isinstance(settings, GlobalSettings)
# @POST: self.config.settings updated and saved.
# @PARAM: settings (GlobalSettings) - The new global settings.
def update_global_settings(self, settings: GlobalSettings):
with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating settings")
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
self.config.settings = settings
self.save()
configure_logger(settings.logging)
logger.info("[update_global_settings][Exit] Settings updated")
# [/DEF:update_global_settings:Function]
# [DEF:validate_path:Function]
# @PURPOSE: Validates if a path exists and is writable.
# @PARAM: path (str) - The path to validate.
# @RETURN: tuple (bool, str) - (is_valid, message)
def validate_path(self, path: str) -> tuple[bool, str]:
with belief_scope("validate_path"):
p = os.path.abspath(path)
if not os.path.exists(p):
try:
os.makedirs(p, exist_ok=True)
except Exception as e:
return False, f"Path does not exist and could not be created: {e}"
if not os.access(p, os.W_OK):
return False, "Path is not writable"
return True, "Path is valid and writable"
# [/DEF:validate_path:Function]
# [DEF:get_environments:Function]
# @PURPOSE: Returns the list of configured environments.
# @RETURN: List[Environment] - List of environments.
def get_environments(self) -> List[Environment]:
with belief_scope("get_environments"):
return self.config.environments
# [/DEF:get_environments:Function]
# [DEF:has_environments:Function]
# @PURPOSE: Checks if at least one environment is configured.
# @RETURN: bool - True if at least one environment exists.
def has_environments(self) -> bool:
with belief_scope("has_environments"):
return len(self.config.environments) > 0
# [/DEF:has_environments:Function]
# [DEF:get_environment:Function]
# @PURPOSE: Returns a single environment by ID.
# @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.
# @PARAM: env (Environment) - The environment to add.
def add_environment(self, env: Environment):
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
assert isinstance(env, Environment), "env must be an instance of Environment"
self.config.environments = [e for e in self.config.environments if e.id != env.id]
self.config.environments.append(env)
self.save()
logger.info("[add_environment][Exit] Environment added")
# [/DEF:add_environment:Function]
# [DEF:update_environment:Function]
# @PURPOSE: Updates an existing environment.
# @PARAM: env_id (str) - The ID of the environment to update.
# @PARAM: updated_env (Environment) - The updated environment data.
# @RETURN: bool - True if updated, False otherwise.
def update_environment(self, env_id: str, updated_env: Environment) -> bool:
with belief_scope("update_environment"):
logger.info(f"[update_environment][Entry] Updating {env_id}")
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
assert isinstance(updated_env, Environment), "updated_env must be an instance of Environment"
for i, env in enumerate(self.config.environments):
if env.id == env_id:
if updated_env.password == "********":
updated_env.password = env.password
self.config.environments[i] = updated_env
self.save()
logger.info(f"[update_environment][Coherence:OK] Updated {env_id}")
return True
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
return False
# [/DEF:update_environment:Function]
# [DEF:delete_environment:Function]
# @PURPOSE: Deletes an environment by ID.
# @PARAM: env_id (str) - The ID of the environment to delete.
def delete_environment(self, env_id: str):
with belief_scope("delete_environment"):
logger.info(f"[delete_environment][Entry] Deleting {env_id}")
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
original_count = len(self.config.environments)
self.config.environments = [e for e in self.config.environments if e.id != env_id]
if len(self.config.environments) < original_count:
self.save()
logger.info(f"[delete_environment][Action] Deleted {env_id}")
else:
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
# [/DEF:delete_environment:Function]
# [/DEF:ConfigManager:Class]
# [/DEF:ConfigManagerModule:Module]

View File

@@ -1,12 +1,21 @@
# [DEF:ConfigModels:Module]
# @TIER: STANDARD
# @SEMANTICS: config, models, pydantic
# @PURPOSE: Defines the data models for application configuration using Pydantic.
# @LAYER: Core
# @RELATION: READS_FROM -> config.json
# @RELATION: READS_FROM -> app_configurations (database)
# @RELATION: USED_BY -> ConfigManager
from pydantic import BaseModel, Field
from typing import List, Optional
from ..models.storage import StorageConfig
# [DEF:Schedule:DataClass]
# @PURPOSE: Represents a backup schedule configuration.
class Schedule(BaseModel):
enabled: bool = False
cron_expression: str = "0 0 * * *" # Default: daily at midnight
# [/DEF:Schedule:DataClass]
# [DEF:Environment:DataClass]
# @PURPOSE: Represents a Superset environment configuration.
@@ -16,21 +25,43 @@ 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
# [/DEF:Environment]
backup_schedule: Schedule = Field(default_factory=Schedule)
# [/DEF:Environment:DataClass]
# [DEF:LoggingConfig:DataClass]
# @PURPOSE: Defines the configuration for the application's logging system.
class LoggingConfig(BaseModel):
level: str = "INFO"
task_log_level: str = "INFO" # Minimum level for task-specific logs (DEBUG, INFO, WARNING, ERROR)
file_path: Optional[str] = None
max_bytes: int = 10 * 1024 * 1024
backup_count: int = 5
enable_belief_state: bool = True
# [/DEF:LoggingConfig:DataClass]
# [DEF:GlobalSettings:DataClass]
# @PURPOSE: Represents global application settings.
class GlobalSettings(BaseModel):
backup_path: str
storage: StorageConfig = Field(default_factory=StorageConfig)
default_environment_id: Optional[str] = None
# [/DEF:GlobalSettings]
logging: LoggingConfig = Field(default_factory=LoggingConfig)
connections: List[dict] = []
llm: dict = Field(default_factory=lambda: {"providers": [], "default_provider": ""})
# Task retention settings
task_retention_days: int = 30
task_retention_limit: int = 100
pagination_limit: int = 10
# [/DEF:GlobalSettings:DataClass]
# [DEF:AppConfig:DataClass]
# @PURPOSE: The root configuration model containing all application settings.
class AppConfig(BaseModel):
environments: List[Environment] = []
settings: GlobalSettings
# [/DEF:AppConfig]
# [/DEF:AppConfig:DataClass]
# [/DEF:ConfigModels]
# [/DEF:ConfigModels:Module]

View File

@@ -1,48 +1,146 @@
# [DEF:backend.src.core.database:Module]
#
# @SEMANTICS: database, sqlite, sqlalchemy, session, persistence
# @PURPOSE: Configures the SQLite database connection and session management.
# @SEMANTICS: database, postgresql, sqlalchemy, session, persistence
# @PURPOSE: Configures database connection and session management (PostgreSQL-first).
# @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy
# @RELATION: USES -> backend.src.models.mapping
# @RELATION: USES -> backend.src.core.auth.config
#
# @INVARIANT: A single engine instance is used for the entire application.
# [SECTION: IMPORTS]
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from backend.src.models.mapping import Base
from sqlalchemy.orm import sessionmaker
from ..models.mapping import Base
# Import models to ensure they're registered with Base
from ..models import task as _task_models # noqa: F401
from ..models import auth as _auth_models # noqa: F401
from ..models import config as _config_models # noqa: F401
from ..models import llm as _llm_models # noqa: F401
from .logger import belief_scope
from .auth.config import auth_config
import os
from pathlib import Path
# [/SECTION]
# [DEF:BASE_DIR:Variable]
# @PURPOSE: Base directory for the backend.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# [/DEF:BASE_DIR:Variable]
# [DEF:DATABASE_URL:Constant]
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
# [/DEF:DATABASE_URL]
# @PURPOSE: URL for the main application database.
DEFAULT_POSTGRES_URL = os.getenv(
"POSTGRES_URL",
"postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools",
)
DATABASE_URL = os.getenv("DATABASE_URL", DEFAULT_POSTGRES_URL)
# [/DEF:DATABASE_URL:Constant]
# [DEF:TASKS_DATABASE_URL:Constant]
# @PURPOSE: URL for the tasks execution database.
# Defaults to DATABASE_URL to keep task logs in the same PostgreSQL instance.
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", DATABASE_URL)
# [/DEF:TASKS_DATABASE_URL:Constant]
# [DEF:AUTH_DATABASE_URL:Constant]
# @PURPOSE: URL for the authentication database.
AUTH_DATABASE_URL = os.getenv("AUTH_DATABASE_URL", auth_config.AUTH_DATABASE_URL)
# [/DEF:AUTH_DATABASE_URL:Constant]
# [DEF:engine:Variable]
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:engine]
def _build_engine(db_url: str):
with belief_scope("_build_engine"):
if db_url.startswith("sqlite"):
return create_engine(db_url, connect_args={"check_same_thread": False})
return create_engine(db_url, pool_pre_ping=True)
# @PURPOSE: SQLAlchemy engine for mappings database.
engine = _build_engine(DATABASE_URL)
# [/DEF:engine:Variable]
# [DEF:tasks_engine:Variable]
# @PURPOSE: SQLAlchemy engine for tasks database.
tasks_engine = _build_engine(TASKS_DATABASE_URL)
# [/DEF:tasks_engine:Variable]
# [DEF:auth_engine:Variable]
# @PURPOSE: SQLAlchemy engine for authentication database.
auth_engine = _build_engine(AUTH_DATABASE_URL)
# [/DEF:auth_engine:Variable]
# [DEF:SessionLocal:Class]
# @PURPOSE: A session factory for the main mappings database.
# @PRE: engine is initialized.
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# [/DEF:SessionLocal]
# [/DEF:SessionLocal:Class]
# [DEF:TasksSessionLocal:Class]
# @PURPOSE: A session factory for the tasks execution database.
# @PRE: tasks_engine is initialized.
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
# [/DEF:TasksSessionLocal:Class]
# [DEF:AuthSessionLocal:Class]
# @PURPOSE: A session factory for the authentication database.
# @PRE: auth_engine is initialized.
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
# [/DEF:AuthSessionLocal:Class]
# [DEF:init_db:Function]
# @PURPOSE: Initializes the database by creating all tables.
# @PRE: engine, tasks_engine and auth_engine are initialized.
# @POST: Database tables created in all databases.
# @SIDE_EFFECT: Creates physical database files if they don't exist.
def init_db():
Base.metadata.create_all(bind=engine)
# [/DEF:init_db]
with belief_scope("init_db"):
Base.metadata.create_all(bind=engine)
Base.metadata.create_all(bind=tasks_engine)
Base.metadata.create_all(bind=auth_engine)
# [/DEF:init_db:Function]
# [DEF:get_db:Function]
# @PURPOSE: Dependency for getting a database session.
# @PRE: SessionLocal is initialized.
# @POST: Session is closed after use.
# @RETURN: Generator[Session, None, None]
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# [/DEF:get_db]
with belief_scope("get_db"):
db = SessionLocal()
try:
yield db
finally:
db.close()
# [/DEF:get_db:Function]
# [/DEF:backend.src.core.database]
# [DEF:get_tasks_db:Function]
# @PURPOSE: Dependency for getting a tasks database session.
# @PRE: TasksSessionLocal is initialized.
# @POST: Session is closed after use.
# @RETURN: Generator[Session, None, None]
def get_tasks_db():
with belief_scope("get_tasks_db"):
db = TasksSessionLocal()
try:
yield db
finally:
db.close()
# [/DEF:get_tasks_db:Function]
# [DEF:get_auth_db:Function]
# @PURPOSE: Dependency for getting an authentication database session.
# @PRE: AuthSessionLocal is initialized.
# @POST: Session is closed after use.
# @RETURN: Generator[Session, None, None]
def get_auth_db():
with belief_scope("get_auth_db"):
db = AuthSessionLocal()
try:
yield db
finally:
db.close()
# [/DEF:get_auth_db:Function]
# [/DEF:backend.src.core.database:Module]

View File

@@ -4,12 +4,42 @@
# @LAYER: Core
# @RELATION: Used by the main application and other modules to log events. The WebSocketLogHandler is used by the WebSocket endpoint in app.py.
import logging
import threading
from datetime import datetime
from typing import Dict, Any, List, Optional
from collections import deque
from contextlib import contextmanager
from logging.handlers import RotatingFileHandler
from pydantic import BaseModel, Field
# Thread-local storage for belief state
_belief_state = threading.local()
# Global flag for belief state logging
_enable_belief_state = True
# Global task log level filter
_task_log_level = "INFO"
# [DEF:BeliefFormatter:Class]
# @PURPOSE: Custom logging formatter that adds belief state prefixes to log messages.
class BeliefFormatter(logging.Formatter):
# [DEF:format:Function]
# @PURPOSE: Formats the log record, adding belief state context if available.
# @PRE: record is a logging.LogRecord.
# @POST: Returns formatted string.
# @PARAM: record (logging.LogRecord) - The log record to format.
# @RETURN: str - The formatted log message.
# @SEMANTICS: logging, formatter, context
def format(self, record):
anchor_id = getattr(_belief_state, 'anchor_id', None)
if anchor_id:
record.msg = f"[{anchor_id}][Action] {record.msg}"
return super().format(record)
# [/DEF:format:Function]
# [/DEF:BeliefFormatter:Class]
# Re-using LogEntry from task_manager for consistency
# [DEF:LogEntry:Class]
# @SEMANTICS: log, entry, record, pydantic
@@ -20,7 +50,118 @@ class LogEntry(BaseModel):
message: str
context: Optional[Dict[str, Any]] = None
# [/DEF]
# [/DEF:LogEntry:Class]
# [DEF:belief_scope:Function]
# @PURPOSE: Context manager for structured Belief State logging.
# @PARAM: anchor_id (str) - The identifier for the current semantic block.
# @PARAM: message (str) - Optional entry message.
# @PRE: anchor_id must be provided.
# @POST: Thread-local belief state is updated and entry/exit logs are generated.
# @SEMANTICS: logging, context, belief_state
@contextmanager
def belief_scope(anchor_id: str, message: str = ""):
# Log Entry if enabled (DEBUG level to reduce noise)
if _enable_belief_state:
entry_msg = f"[{anchor_id}][Entry]"
if message:
entry_msg += f" {message}"
logger.debug(entry_msg)
# Set thread-local anchor_id
old_anchor = getattr(_belief_state, 'anchor_id', None)
_belief_state.anchor_id = anchor_id
try:
yield
# Log Coherence OK and Exit (DEBUG level to reduce noise)
logger.debug(f"[{anchor_id}][Coherence:OK]")
if _enable_belief_state:
logger.debug(f"[{anchor_id}][Exit]")
except Exception as e:
# Log Coherence Failed (DEBUG level to reduce noise)
logger.debug(f"[{anchor_id}][Coherence:Failed] {str(e)}")
raise
finally:
# Restore old anchor
_belief_state.anchor_id = old_anchor
# [/DEF:belief_scope:Function]
# [DEF:configure_logger:Function]
# @PURPOSE: Configures the logger with the provided logging settings.
# @PRE: config is a valid LoggingConfig instance.
# @POST: Logger level, handlers, belief state flag, and task log level are updated.
# @PARAM: config (LoggingConfig) - The logging configuration.
# @SEMANTICS: logging, configuration, initialization
def configure_logger(config):
global _enable_belief_state, _task_log_level
_enable_belief_state = config.enable_belief_state
_task_log_level = config.task_log_level.upper()
# Set logger level
level = getattr(logging, config.level.upper(), logging.INFO)
logger.setLevel(level)
# Remove existing file handlers
handlers_to_remove = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)]
for h in handlers_to_remove:
logger.removeHandler(h)
h.close()
# Add file handler if file_path is set
if config.file_path:
from pathlib import Path
log_file = Path(config.file_path)
log_file.parent.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
config.file_path,
maxBytes=config.max_bytes,
backupCount=config.backup_count
)
file_handler.setFormatter(BeliefFormatter(
'[%(asctime)s][%(levelname)s][%(name)s] %(message)s'
))
logger.addHandler(file_handler)
# Update existing handlers' formatters to BeliefFormatter
for handler in logger.handlers:
if not isinstance(handler, RotatingFileHandler):
handler.setFormatter(BeliefFormatter(
'[%(asctime)s][%(levelname)s][%(name)s] %(message)s'
))
# [/DEF:configure_logger:Function]
# [DEF:get_task_log_level:Function]
# @PURPOSE: Returns the current task log level filter.
# @PRE: None.
# @POST: Returns the task log level string.
# @RETURN: str - The current task log level (DEBUG, INFO, WARNING, ERROR).
# @SEMANTICS: logging, configuration, getter
def get_task_log_level() -> str:
"""Returns the current task log level filter."""
return _task_log_level
# [/DEF:get_task_log_level:Function]
# [DEF:should_log_task_level:Function]
# @PURPOSE: Checks if a log level should be recorded based on task_log_level setting.
# @PRE: level is a valid log level string.
# @POST: Returns True if level meets or exceeds task_log_level threshold.
# @PARAM: level (str) - The log level to check.
# @RETURN: bool - True if the level should be logged.
# @SEMANTICS: logging, filter, level
def should_log_task_level(level: str) -> bool:
"""Checks if a log level should be recorded based on task_log_level setting."""
level_order = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
current_level = _task_log_level.upper()
check_level = level.upper()
current_order = level_order.get(current_level, 1) # Default to INFO
check_order = level_order.get(check_level, 1)
return check_order >= current_order
# [/DEF:should_log_task_level:Function]
# [DEF:WebSocketLogHandler:Class]
# @SEMANTICS: logging, handler, websocket, buffer
@@ -30,12 +171,25 @@ class WebSocketLogHandler(logging.Handler):
A logging handler that stores log records and can be extended to send them
over WebSockets.
"""
# [DEF:__init__:Function]
# @PURPOSE: Initializes the handler with a fixed-capacity buffer.
# @PRE: capacity is an integer.
# @POST: Instance initialized with empty deque.
# @PARAM: capacity (int) - Maximum number of logs to keep in memory.
# @SEMANTICS: logging, initialization, buffer
def __init__(self, capacity: int = 1000):
super().__init__()
self.log_buffer: deque[LogEntry] = deque(maxlen=capacity)
# In a real implementation, you'd have a way to manage active WebSocket connections
# e.g., self.active_connections: Set[WebSocket] = set()
# [/DEF:__init__:Function]
# [DEF:emit:Function]
# @PURPOSE: Captures a log record, formats it, and stores it in the buffer.
# @PRE: record is a logging.LogRecord.
# @POST: Log is added to the log_buffer.
# @PARAM: record (logging.LogRecord) - The log record to emit.
# @SEMANTICS: logging, handler, buffer
def emit(self, record: logging.LogRecord):
try:
log_entry = LogEntry(
@@ -56,23 +210,55 @@ class WebSocketLogHandler(logging.Handler):
# Example: for ws in self.active_connections: await ws.send_json(log_entry.dict())
except Exception:
self.handleError(record)
# [/DEF:emit:Function]
# [DEF:get_recent_logs:Function]
# @PURPOSE: Returns a list of recent log entries from the buffer.
# @PRE: None.
# @POST: Returns list of LogEntry objects.
# @RETURN: List[LogEntry] - List of buffered log entries.
# @SEMANTICS: logging, buffer, retrieval
def get_recent_logs(self) -> List[LogEntry]:
"""
Returns a list of recent log entries from the buffer.
"""
return list(self.log_buffer)
# [/DEF:get_recent_logs:Function]
# [/DEF]
# [/DEF:WebSocketLogHandler:Class]
# [DEF:Logger:Global]
# @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.
# @PRE: anchor_id must be a string.
# @POST: Returns a decorator function.
def believed(anchor_id: str):
# [DEF:decorator:Function]
# @PURPOSE: Internal decorator for belief scope.
# @PRE: func must be a callable.
# @POST: Returns the wrapped function.
def decorator(func):
# [DEF:wrapper:Function]
# @PURPOSE: Internal wrapper that enters belief scope.
# @PRE: None.
# @POST: Executes the function within a 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)
# Create a formatter
formatter = logging.Formatter(
formatter = BeliefFormatter(
'[%(asctime)s][%(levelname)s][%(name)s] %(message)s'
)
@@ -89,4 +275,5 @@ logger.addHandler(websocket_log_handler)
# Example usage:
# logger.info("Application started", extra={"context_key": "context_value"})
# logger.error("An error occurred", exc_info=True)
# [/DEF]
# [/DEF:Logger:Global]
# [/DEF:LoggerModule:Module]

View File

@@ -0,0 +1,228 @@
# [DEF:test_logger:Module]
# @TIER: STANDARD
# @PURPOSE: Unit tests for logger module
# @LAYER: Infra
# @RELATION: VERIFIES -> src.core.logger
import sys
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from src.core.logger import (
belief_scope,
logger,
configure_logger,
get_task_log_level,
should_log_task_level
)
from src.core.config_models import LoggingConfig
# [DEF:test_belief_scope_logs_entry_action_exit_at_debug:Function]
# @PURPOSE: Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs at DEBUG level.
# @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG.
# @POST: Logs are verified to contain Entry, Action, and Exit tags at DEBUG level.
def test_belief_scope_logs_entry_action_exit_at_debug(caplog):
"""Test that belief_scope generates [ID][Entry], [ID][Action], and [ID][Exit] logs at DEBUG level."""
# Configure logger to DEBUG level
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=True
)
configure_logger(config)
caplog.set_level("DEBUG")
with belief_scope("TestFunction"):
logger.info("Doing something important")
# Check that the logs contain the expected patterns
log_messages = [record.message for record in caplog.records]
assert any("[TestFunction][Entry]" in msg for msg in log_messages), "Entry log not found"
assert any("[TestFunction][Action] Doing something important" in msg for msg in log_messages), "Action log not found"
assert any("[TestFunction][Exit]" in msg for msg in log_messages), "Exit log not found"
# Reset to INFO
config = LoggingConfig(level="INFO", task_log_level="INFO", enable_belief_state=True)
configure_logger(config)
# [/DEF:test_belief_scope_logs_entry_action_exit_at_debug:Function]
# [DEF:test_belief_scope_error_handling:Function]
# @PURPOSE: Test that belief_scope logs Coherence:Failed on exception.
# @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG.
# @POST: Logs are verified to contain Coherence:Failed tag.
def test_belief_scope_error_handling(caplog):
"""Test that belief_scope logs Coherence:Failed on exception."""
# Configure logger to DEBUG level
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=True
)
configure_logger(config)
caplog.set_level("DEBUG")
with pytest.raises(ValueError):
with belief_scope("FailingFunction"):
raise ValueError("Something went wrong")
log_messages = [record.message for record in caplog.records]
assert any("[FailingFunction][Entry]" in msg for msg in log_messages), "Entry log not found"
assert any("[FailingFunction][Coherence:Failed]" in msg for msg in log_messages), "Failed coherence log not found"
# Exit should not be logged on failure
# Reset to INFO
config = LoggingConfig(level="INFO", task_log_level="INFO", enable_belief_state=True)
configure_logger(config)
# [/DEF:test_belief_scope_error_handling:Function]
# [DEF:test_belief_scope_success_coherence:Function]
# @PURPOSE: Test that belief_scope logs Coherence:OK on success.
# @PRE: belief_scope is available. caplog fixture is used. Logger configured to DEBUG.
# @POST: Logs are verified to contain Coherence:OK tag.
def test_belief_scope_success_coherence(caplog):
"""Test that belief_scope logs Coherence:OK on success."""
# Configure logger to DEBUG level
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=True
)
configure_logger(config)
caplog.set_level("DEBUG")
with belief_scope("SuccessFunction"):
pass
log_messages = [record.message for record in caplog.records]
assert any("[SuccessFunction][Coherence:OK]" in msg for msg in log_messages), "Success coherence log not found"
# Reset to INFO
config = LoggingConfig(level="INFO", task_log_level="INFO", enable_belief_state=True)
configure_logger(config)
# [/DEF:test_belief_scope_success_coherence:Function]
# [DEF:test_belief_scope_not_visible_at_info:Function]
# @PURPOSE: Test that belief_scope Entry/Exit/Coherence logs are NOT visible at INFO level.
# @PRE: belief_scope is available. caplog fixture is used.
# @POST: Entry/Exit/Coherence logs are not captured at INFO level.
def test_belief_scope_not_visible_at_info(caplog):
"""Test that belief_scope Entry/Exit/Coherence logs are NOT visible at INFO level."""
caplog.set_level("INFO")
with belief_scope("InfoLevelFunction"):
logger.info("Doing something important")
log_messages = [record.message for record in caplog.records]
# Action log should be visible
assert any("[InfoLevelFunction][Action] Doing something important" in msg for msg in log_messages), "Action log not found"
# Entry/Exit/Coherence should NOT be visible at INFO level
assert not any("[InfoLevelFunction][Entry]" in msg for msg in log_messages), "Entry log should not be visible at INFO"
assert not any("[InfoLevelFunction][Exit]" in msg for msg in log_messages), "Exit log should not be visible at INFO"
assert not any("[InfoLevelFunction][Coherence:OK]" in msg for msg in log_messages), "Coherence log should not be visible at INFO"
# [/DEF:test_belief_scope_not_visible_at_info:Function]
# [DEF:test_task_log_level_default:Function]
# @PURPOSE: Test that default task log level is INFO.
# @PRE: None.
# @POST: Default level is INFO.
def test_task_log_level_default():
"""Test that default task log level is INFO."""
level = get_task_log_level()
assert level == "INFO"
# [/DEF:test_task_log_level_default:Function]
# [DEF:test_should_log_task_level:Function]
# @PURPOSE: Test that should_log_task_level correctly filters log levels.
# @PRE: None.
# @POST: Filtering works correctly for all level combinations.
def test_should_log_task_level():
"""Test that should_log_task_level correctly filters log levels."""
# Default level is INFO
assert should_log_task_level("ERROR") is True, "ERROR should be logged at INFO threshold"
assert should_log_task_level("WARNING") is True, "WARNING should be logged at INFO threshold"
assert should_log_task_level("INFO") is True, "INFO should be logged at INFO threshold"
assert should_log_task_level("DEBUG") is False, "DEBUG should NOT be logged at INFO threshold"
# [/DEF:test_should_log_task_level:Function]
# [DEF:test_configure_logger_task_log_level:Function]
# @PURPOSE: Test that configure_logger updates task_log_level.
# @PRE: LoggingConfig is available.
# @POST: task_log_level is updated correctly.
def test_configure_logger_task_log_level():
"""Test that configure_logger updates task_log_level."""
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=True
)
configure_logger(config)
assert get_task_log_level() == "DEBUG", "task_log_level should be DEBUG"
assert should_log_task_level("DEBUG") is True, "DEBUG should be logged at DEBUG threshold"
# Reset to INFO
config = LoggingConfig(
level="INFO",
task_log_level="INFO",
enable_belief_state=True
)
configure_logger(config)
assert get_task_log_level() == "INFO", "task_log_level should be reset to INFO"
# [/DEF:test_configure_logger_task_log_level:Function]
# [DEF:test_enable_belief_state_flag:Function]
# @PURPOSE: Test that enable_belief_state flag controls belief_scope logging.
# @PRE: LoggingConfig is available. caplog fixture is used.
# @POST: belief_scope logs are controlled by the flag.
def test_enable_belief_state_flag(caplog):
"""Test that enable_belief_state flag controls belief_scope logging."""
# Disable belief state
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=False
)
configure_logger(config)
caplog.set_level("DEBUG")
with belief_scope("DisabledFunction"):
logger.info("Doing something")
log_messages = [record.message for record in caplog.records]
# Entry and Exit should NOT be logged when disabled
assert not any("[DisabledFunction][Entry]" in msg for msg in log_messages), "Entry should not be logged when disabled"
assert not any("[DisabledFunction][Exit]" in msg for msg in log_messages), "Exit should not be logged when disabled"
# Coherence:OK should still be logged (internal tracking)
assert any("[DisabledFunction][Coherence:OK]" in msg for msg in log_messages), "Coherence should still be logged"
# Re-enable for other tests
config = LoggingConfig(
level="DEBUG",
task_log_level="DEBUG",
enable_belief_state=True
)
configure_logger(config)
# [/DEF:test_enable_belief_state_flag:Function]
# [/DEF:test_logger:Module]

View File

@@ -11,55 +11,76 @@
import zipfile
import yaml
import os
import shutil
import tempfile
from pathlib import Path
from typing import Dict
from .logger import logger, belief_scope
# [/SECTION]
# [DEF:MigrationEngine:Class]
# @PURPOSE: Engine for transforming Superset export ZIPs.
class MigrationEngine:
# [DEF:MigrationEngine.transform_zip:Function]
# [DEF:transform_zip:Function]
# @PURPOSE: Extracts ZIP, replaces database UUIDs in YAMLs, and re-packages.
# @PARAM: zip_path (str) - Path to the source ZIP file.
# @PARAM: output_path (str) - Path where the transformed ZIP will be saved.
# @PARAM: db_mapping (Dict[str, str]) - Mapping of source UUID to target UUID.
# @PARAM: strip_databases (bool) - Whether to remove the databases directory from the archive.
# @PRE: zip_path must point to a valid Superset export archive.
# @POST: Transformed archive is saved to output_path.
# @RETURN: bool - True if successful.
def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str]) -> bool:
def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str], strip_databases: bool = True) -> bool:
"""
Transform a Superset export ZIP by replacing database UUIDs.
"""
with tempfile.TemporaryDirectory() as temp_dir_str:
temp_dir = Path(temp_dir_str)
with belief_scope("MigrationEngine.transform_zip"):
with tempfile.TemporaryDirectory() as temp_dir_str:
temp_dir = Path(temp_dir_str)
try:
# 1. Extract
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(temp_dir)
try:
# 1. Extract
logger.info(f"[MigrationEngine.transform_zip][Action] Extracting ZIP: {zip_path}")
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(temp_dir)
# 2. Transform YAMLs
# Datasets are usually in datasets/*.yaml
dataset_files = list(temp_dir.glob("**/datasets/*.yaml"))
for ds_file in dataset_files:
self._transform_yaml(ds_file, db_mapping)
# 2. Transform YAMLs
# Datasets are usually in datasets/*.yaml
dataset_files = list(temp_dir.glob("**/datasets/**/*.yaml")) + list(temp_dir.glob("**/datasets/*.yaml"))
dataset_files = list(set(dataset_files))
logger.info(f"[MigrationEngine.transform_zip][State] Found {len(dataset_files)} dataset files.")
for ds_file in dataset_files:
logger.info(f"[MigrationEngine.transform_zip][Action] Transforming dataset: {ds_file}")
self._transform_yaml(ds_file, db_mapping)
# 3. Re-package
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(temp_dir):
for file in files:
file_path = Path(root) / file
arcname = file_path.relative_to(temp_dir)
zf.write(file_path, arcname)
return True
except Exception as e:
print(f"Error transforming ZIP: {e}")
return False
# 3. Re-package
logger.info(f"[MigrationEngine.transform_zip][Action] Re-packaging ZIP to: {output_path} (strip_databases={strip_databases})")
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(temp_dir):
rel_root = Path(root).relative_to(temp_dir)
if strip_databases and "databases" in rel_root.parts:
logger.info(f"[MigrationEngine.transform_zip][Action] Skipping file in databases directory: {rel_root}")
continue
# [DEF:MigrationEngine._transform_yaml:Function]
for file in files:
file_path = Path(root) / file
arcname = file_path.relative_to(temp_dir)
zf.write(file_path, arcname)
return True
except Exception as e:
logger.error(f"[MigrationEngine.transform_zip][Coherence:Failed] Error transforming ZIP: {e}")
return False
# [/DEF:transform_zip:Function]
# [DEF:_transform_yaml:Function]
# @PURPOSE: Replaces database_uuid in a single YAML file.
# @PARAM: file_path (Path) - Path to the YAML file.
# @PARAM: db_mapping (Dict[str, str]) - UUID mapping dictionary.
# @PRE: file_path must exist and be readable.
# @POST: File is modified in-place if source UUID matches mapping.
def _transform_yaml(self, file_path: Path, db_mapping: Dict[str, str]):
with open(file_path, 'r') as f:
data = yaml.safe_load(f)
@@ -74,8 +95,8 @@ class MigrationEngine:
data['database_uuid'] = db_mapping[source_uuid]
with open(file_path, 'w') as f:
yaml.dump(data, f)
# [/DEF:MigrationEngine._transform_yaml]
# [/DEF:_transform_yaml:Function]
# [/DEF:MigrationEngine]
# [/DEF:MigrationEngine:Class]
# [/DEF:backend.src.core.migration_engine]
# [/DEF:backend.src.core.migration_engine:Module]

View File

@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from typing import Dict, Any
from typing import Dict, Any, Optional
from .logger import belief_scope
from pydantic import BaseModel, Field
@@ -17,44 +18,114 @@ class PluginBase(ABC):
@property
@abstractmethod
# [DEF:id:Function]
# @PURPOSE: Returns the unique identifier for the plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string ID.
# @RETURN: str - Plugin ID.
def id(self) -> str:
"""A unique identifier for the plugin."""
pass
with belief_scope("id"):
pass
# [/DEF:id:Function]
@property
@abstractmethod
# [DEF:name:Function]
# @PURPOSE: Returns the human-readable name of the plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string name.
# @RETURN: str - Plugin name.
def name(self) -> str:
"""A human-readable name for the plugin."""
pass
with belief_scope("name"):
pass
# [/DEF:name:Function]
@property
@abstractmethod
# [DEF:description:Function]
# @PURPOSE: Returns a brief description of the plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string description.
# @RETURN: str - Plugin description.
def description(self) -> str:
"""A brief description of what the plugin does."""
pass
with belief_scope("description"):
pass
# [/DEF:description:Function]
@property
@abstractmethod
# [DEF:version:Function]
# @PURPOSE: Returns the version of the plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string version.
# @RETURN: str - Plugin version.
def version(self) -> str:
"""The version of the plugin."""
pass
with belief_scope("version"):
pass
# [/DEF:version:Function]
@property
# [DEF:required_permission:Function]
# @PURPOSE: Returns the required permission string to execute this plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string permission.
# @RETURN: str - Required permission (e.g., "plugin:backup:execute").
def required_permission(self) -> str:
"""The permission string required to execute this plugin."""
with belief_scope("required_permission"):
return f"plugin:{self.id}:execute"
# [/DEF:required_permission:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the plugin's UI, if applicable.
# @PRE: Plugin instance exists.
# @POST: Returns string route or None.
# @RETURN: Optional[str] - Frontend route.
def ui_route(self) -> Optional[str]:
"""
The frontend route for the plugin's UI.
Returns None if the plugin does not have a dedicated UI page.
"""
with belief_scope("ui_route"):
return None
# [/DEF:ui_route:Function]
@abstractmethod
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the plugin's input parameters.
# @PRE: Plugin instance exists.
# @POST: Returns dict schema.
# @RETURN: Dict[str, Any] - JSON schema.
def get_schema(self) -> Dict[str, Any]:
"""
Returns the JSON schema for the plugin's input parameters.
This schema will be used to generate the frontend form.
"""
pass
with belief_scope("get_schema"):
pass
# [/DEF:get_schema:Function]
@abstractmethod
# [DEF:execute:Function]
# @PURPOSE: Executes the plugin's core logic.
# @PARAM: params (Dict[str, Any]) - Validated input parameters.
# @PRE: params must be a dictionary.
# @POST: Plugin execution is completed.
async def execute(self, params: Dict[str, Any]):
with belief_scope("execute"):
pass
"""
Executes the plugin's logic.
The `params` argument will be validated against the schema returned by `get_schema()`.
"""
pass
# [/DEF]
# [/DEF:execute:Function]
# [/DEF:PluginBase:Class]
# [DEF:PluginConfig:Class]
# @SEMANTICS: plugin, config, schema, pydantic
@@ -67,5 +138,6 @@ class PluginConfig(BaseModel):
name: str = Field(..., description="Human-readable name for the plugin")
description: str = Field(..., description="Brief description of what the plugin does")
version: str = Field(..., description="Version of the plugin")
ui_route: Optional[str] = Field(None, description="Frontend route for the plugin UI")
input_schema: Dict[str, Any] = Field(..., description="JSON schema for input parameters", alias="schema")
# [/DEF]
# [/DEF:PluginConfig:Class]

View File

@@ -1,11 +1,12 @@
import importlib.util
import os
import sys # Added this line
from typing import Dict, Type, List, Optional
from typing import Dict, List, Optional
from .plugin_base import PluginBase, PluginConfig
from jsonschema import validate
from .logger import belief_scope
# [DEF:PluginLoader:Class]
# @TIER: STANDARD
# @SEMANTICS: plugin, loader, dynamic, import
# @PURPOSE: Scans a specified directory for Python modules, dynamically loads them, and registers any classes that are valid implementations of the PluginBase interface.
# @LAYER: Core
@@ -16,16 +17,28 @@ class PluginLoader:
that inherit from PluginBase.
"""
# [DEF:__init__:Function]
# @PURPOSE: Initializes the PluginLoader with a directory to scan.
# @PRE: plugin_dir is a valid directory path.
# @POST: Plugins are loaded and registered.
# @PARAM: plugin_dir (str) - The directory containing plugin modules.
def __init__(self, plugin_dir: str):
self.plugin_dir = plugin_dir
self._plugins: Dict[str, PluginBase] = {}
self._plugin_configs: Dict[str, PluginConfig] = {}
self._load_plugins()
with belief_scope("__init__"):
self.plugin_dir = plugin_dir
self._plugins: Dict[str, PluginBase] = {}
self._plugin_configs: Dict[str, PluginConfig] = {}
self._load_plugins()
# [/DEF:__init__:Function]
# [DEF:_load_plugins:Function]
# @PURPOSE: Scans the plugin directory and loads all valid plugins.
# @PRE: plugin_dir exists or can be created.
# @POST: _load_module is called for each .py file.
def _load_plugins(self):
"""
Scans the plugin directory, imports modules, and registers valid plugins.
"""
with belief_scope("_load_plugins"):
"""
Scans the plugin directory, imports modules, and registers valid plugins.
"""
if not os.path.exists(self.plugin_dir):
os.makedirs(self.plugin_dir)
@@ -37,22 +50,44 @@ class PluginLoader:
sys.path.insert(0, plugin_parent_dir)
for filename in os.listdir(self.plugin_dir):
file_path = os.path.join(self.plugin_dir, filename)
# Handle directory-based plugins (packages)
if os.path.isdir(file_path):
init_file = os.path.join(file_path, "__init__.py")
if os.path.exists(init_file):
self._load_module(filename, init_file)
continue
# Handle single-file plugins
if filename.endswith(".py") and filename != "__init__.py":
module_name = filename[:-3]
file_path = os.path.join(self.plugin_dir, filename)
self._load_module(module_name, file_path)
# [/DEF:_load_plugins:Function]
# [DEF:_load_module:Function]
# @PURPOSE: Loads a single Python module and discovers PluginBase implementations.
# @PRE: module_name and file_path are valid.
# @POST: Plugin classes are instantiated and registered.
# @PARAM: module_name (str) - The name of the module.
# @PARAM: file_path (str) - The path to the module file.
def _load_module(self, module_name: str, file_path: str):
"""
Loads a single Python module and extracts PluginBase subclasses.
"""
with belief_scope("_load_module"):
"""
Loads a single Python module and extracts PluginBase subclasses.
"""
# Try to determine the correct package prefix based on how the app is running
if "backend.src" in __name__:
# For standalone execution, we need to handle the import differently
if __name__ == "__main__" or "test" in __name__:
# When running as standalone or in tests, use relative import
package_name = f"plugins.{module_name}"
elif "backend.src" in __name__:
package_prefix = "backend.src.plugins"
package_name = f"{package_prefix}.{module_name}"
else:
package_prefix = "src.plugins"
package_name = f"{package_prefix}.{module_name}"
package_name = f"{package_prefix}.{module_name}"
# print(f"DEBUG: Loading plugin {module_name} as {package_name}")
spec = importlib.util.spec_from_file_location(package_name, file_path)
if spec is None or spec.loader is None:
@@ -78,11 +113,18 @@ class PluginLoader:
self._register_plugin(plugin_instance)
except Exception as e:
print(f"Error instantiating plugin {attribute_name} in {module_name}: {e}") # Replace with proper logging
# [/DEF:_load_module:Function]
# [DEF:_register_plugin:Function]
# @PURPOSE: Registers a PluginBase instance and its configuration.
# @PRE: plugin_instance is a valid implementation of PluginBase.
# @POST: Plugin is added to _plugins and _plugin_configs.
# @PARAM: plugin_instance (PluginBase) - The plugin instance to register.
def _register_plugin(self, plugin_instance: PluginBase):
"""
Registers a valid plugin instance.
"""
with belief_scope("_register_plugin"):
"""
Registers a valid plugin instance.
"""
plugin_id = plugin_instance.id
if plugin_id in self._plugins:
print(f"Warning: Duplicate plugin ID '{plugin_id}' found. Skipping.") # Replace with proper logging
@@ -99,6 +141,7 @@ class PluginLoader:
name=plugin_instance.name,
description=plugin_instance.description,
version=plugin_instance.version,
ui_route=plugin_instance.ui_route,
schema=schema,
)
# The following line is commented out because it requires a schema to be passed to validate against.
@@ -106,25 +149,53 @@ class PluginLoader:
# validate(instance={}, schema=schema)
self._plugins[plugin_id] = plugin_instance
self._plugin_configs[plugin_id] = plugin_config
print(f"Plugin '{plugin_instance.name}' (ID: {plugin_id}) loaded successfully.") # Replace with proper logging
from ..core.logger import logger
logger.info(f"Plugin '{plugin_instance.name}' (ID: {plugin_id}) loaded successfully.")
except Exception as e:
print(f"Error validating plugin '{plugin_instance.name}' (ID: {plugin_id}): {e}") # Replace with proper logging
from ..core.logger import logger
logger.error(f"Error validating plugin '{plugin_instance.name}' (ID: {plugin_id}): {e}")
# [/DEF:_register_plugin:Function]
# [DEF:get_plugin:Function]
# @PURPOSE: Retrieves a loaded plugin instance by its ID.
# @PRE: plugin_id is a string.
# @POST: Returns plugin instance or None.
# @PARAM: plugin_id (str) - The unique identifier of the plugin.
# @RETURN: Optional[PluginBase] - The plugin instance if found, otherwise None.
def get_plugin(self, plugin_id: str) -> Optional[PluginBase]:
"""
Returns a loaded plugin instance by its ID.
"""
with belief_scope("get_plugin"):
"""
Returns a loaded plugin instance by its ID.
"""
return self._plugins.get(plugin_id)
# [/DEF:get_plugin:Function]
# [DEF:get_all_plugin_configs:Function]
# @PURPOSE: Returns a list of all registered plugin configurations.
# @PRE: None.
# @POST: Returns list of all PluginConfig objects.
# @RETURN: List[PluginConfig] - A list of plugin configurations.
def get_all_plugin_configs(self) -> List[PluginConfig]:
"""
Returns a list of all loaded plugin configurations.
"""
with belief_scope("get_all_plugin_configs"):
"""
Returns a list of all loaded plugin configurations.
"""
return list(self._plugin_configs.values())
# [/DEF:get_all_plugin_configs:Function]
# [DEF:has_plugin:Function]
# @PURPOSE: Checks if a plugin with the given ID is registered.
# @PRE: plugin_id is a string.
# @POST: Returns True if plugin exists.
# @PARAM: plugin_id (str) - The unique identifier of the plugin.
# @RETURN: bool - True if the plugin is registered, False otherwise.
def has_plugin(self, plugin_id: str) -> bool:
"""
Checks if a plugin with the given ID is loaded.
"""
return plugin_id in self._plugins
with belief_scope("has_plugin"):
"""
Checks if a plugin with the given ID is loaded.
"""
return plugin_id in self._plugins
# [/DEF:has_plugin:Function]
# [/DEF:PluginLoader:Class]

View File

@@ -0,0 +1,120 @@
# [DEF:SchedulerModule:Module]
# @TIER: STANDARD
# @SEMANTICS: scheduler, apscheduler, cron, backup
# @PURPOSE: Manages scheduled tasks using APScheduler.
# @LAYER: Core
# @RELATION: Uses TaskManager to run scheduled backups.
# [SECTION: IMPORTS]
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from .logger import logger, belief_scope
from .config_manager import ConfigManager
import asyncio
# [/SECTION]
# [DEF:SchedulerService:Class]
# @TIER: STANDARD
# @SEMANTICS: scheduler, service, apscheduler
# @PURPOSE: Provides a service to manage scheduled backup tasks.
class SchedulerService:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the scheduler service with task and config managers.
# @PRE: task_manager and config_manager must be provided.
# @POST: Scheduler instance is created but not started.
def __init__(self, task_manager, config_manager: ConfigManager):
with belief_scope("SchedulerService.__init__"):
self.task_manager = task_manager
self.config_manager = config_manager
self.scheduler = BackgroundScheduler()
self.loop = asyncio.get_event_loop()
# [/DEF:__init__:Function]
# [DEF:start:Function]
# @PURPOSE: Starts the background scheduler and loads initial schedules.
# @PRE: Scheduler should be initialized.
# @POST: Scheduler is running and schedules are loaded.
def start(self):
with belief_scope("SchedulerService.start"):
if not self.scheduler.running:
self.scheduler.start()
logger.info("Scheduler started.")
self.load_schedules()
# [/DEF:start:Function]
# [DEF:stop:Function]
# @PURPOSE: Stops the background scheduler.
# @PRE: Scheduler should be running.
# @POST: Scheduler is shut down.
def stop(self):
with belief_scope("SchedulerService.stop"):
if self.scheduler.running:
self.scheduler.shutdown()
logger.info("Scheduler stopped.")
# [/DEF:stop:Function]
# [DEF:load_schedules:Function]
# @PURPOSE: Loads backup schedules from configuration and registers them.
# @PRE: config_manager must have valid configuration.
# @POST: All enabled backup jobs are added to the scheduler.
def load_schedules(self):
with belief_scope("SchedulerService.load_schedules"):
# Clear existing jobs
self.scheduler.remove_all_jobs()
config = self.config_manager.get_config()
for env in config.environments:
if env.backup_schedule and env.backup_schedule.enabled:
self.add_backup_job(env.id, env.backup_schedule.cron_expression)
# [/DEF:load_schedules:Function]
# [DEF:add_backup_job:Function]
# @PURPOSE: Adds a scheduled backup job for an environment.
# @PRE: env_id and cron_expression must be valid strings.
# @POST: A new job is added to the scheduler or replaced if it already exists.
# @PARAM: env_id (str) - The ID of the environment.
# @PARAM: cron_expression (str) - The cron expression for the schedule.
def add_backup_job(self, env_id: str, cron_expression: str):
with belief_scope("SchedulerService.add_backup_job", f"env_id={env_id}, cron={cron_expression}"):
job_id = f"backup_{env_id}"
try:
self.scheduler.add_job(
self._trigger_backup,
CronTrigger.from_crontab(cron_expression),
id=job_id,
args=[env_id],
replace_existing=True
)
logger.info(f"Scheduled backup job added for environment {env_id}: {cron_expression}")
except Exception as e:
logger.error(f"Failed to add backup job for environment {env_id}: {e}")
# [/DEF:add_backup_job:Function]
# [DEF:_trigger_backup:Function]
# @PURPOSE: Triggered by the scheduler to start a backup task.
# @PRE: env_id must be a valid environment ID.
# @POST: A new backup task is created in the task manager if not already running.
# @PARAM: env_id (str) - The ID of the environment.
def _trigger_backup(self, env_id: str):
with belief_scope("SchedulerService._trigger_backup", f"env_id={env_id}"):
logger.info(f"Triggering scheduled backup for environment {env_id}")
# Check if a backup is already running for this environment
active_tasks = self.task_manager.get_tasks(limit=100)
for task in active_tasks:
if (task.plugin_id == "superset-backup" and
task.status in ["PENDING", "RUNNING"] and
task.params.get("environment_id") == env_id):
logger.warning(f"Backup already running for environment {env_id}. Skipping scheduled run.")
return
# Run the backup task
# We need to run this in the event loop since create_task is async
asyncio.run_coroutine_threadsafe(
self.task_manager.create_task("superset-backup", {"environment_id": env_id}),
self.loop
)
# [/DEF:_trigger_backup:Function]
# [/DEF:SchedulerService:Class]
# [/DEF:SchedulerModule:Module]

View File

@@ -1,57 +1,834 @@
# [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 superset_tool.client import SupersetClient as BaseSupersetClient
from superset_tool.models import SupersetConfig
import json
import re
import zipfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union, cast
from requests import Response
from .logger import logger as app_logger, belief_scope
from .utils.network import APIClient, SupersetAPIError
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:SupersetClient.get_databases_summary:Function]
# @PURPOSE: Fetch a summary of databases including uuid, name, and engine.
# @POST: Returns a list of database dictionaries with 'engine' field.
# @RETURN: List[Dict] - Summary of databases.
def get_databases_summary(self) -> List[Dict]:
"""
Fetch a summary of databases including uuid, name, and engine.
"""
query = {
"columns": ["uuid", "database_name", "backend"]
# @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"
}
_, databases = self.get_databases(query=query)
# Map 'backend' to 'engine' for consistency with contracts
for db in databases:
db['engine'] = db.pop('backend', None)
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: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-заголовки, используемые сетевым клиентом.
# @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
# [/DEF:headers:Function]
# [SECTION: DASHBOARD OPERATIONS]
# [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"):
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"]
return databases
# [/DEF:SupersetClient.get_databases_summary]
paginated_data = self._fetch_all_pages(
endpoint="/dashboard/",
pagination_options={"base_query": validated_query, "results_field": "result"},
)
total_count = len(paginated_data)
app_logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
return total_count, paginated_data
# [/DEF:get_dashboards:Function]
# [DEF:SupersetClient.get_database_by_uuid:Function]
# [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"):
query = {
"columns": ["id", "dashboard_title", "changed_on_utc", "published"]
}
_, dashboards = self.get_dashboards(query=query)
# Map fields to DashboardMetadata schema
result = []
for dash in dashboards:
result.append({
"id": dash.get("id"),
"title": dash.get("dashboard_title"),
"last_modified": dash.get("changed_on_utc"),
"status": "published" if dash.get("published") else "draft"
})
return result
# [/DEF:get_dashboards_summary:Function]
# [DEF:get_dashboard:Function]
# @PURPOSE: Fetches a single dashboard by ID.
# @PRE: Client is authenticated and dashboard_id exists.
# @POST: Returns dashboard payload from Superset API.
# @RETURN: Dict
def get_dashboard(self, dashboard_id: int) -> Dict:
with belief_scope("SupersetClient.get_dashboard", f"id={dashboard_id}"):
response = self.network.request(method="GET", endpoint=f"/dashboard/{dashboard_id}")
return cast(Dict, response)
# [/DEF:get_dashboard:Function]
# [DEF:get_chart:Function]
# @PURPOSE: Fetches a single chart by ID.
# @PRE: Client is authenticated and chart_id exists.
# @POST: Returns chart payload from Superset API.
# @RETURN: Dict
def get_chart(self, chart_id: int) -> Dict:
with belief_scope("SupersetClient.get_chart", f"id={chart_id}"):
response = self.network.request(method="GET", endpoint=f"/chart/{chart_id}")
return cast(Dict, response)
# [/DEF:get_chart:Function]
# [DEF:get_dashboard_detail:Function]
# @PURPOSE: Fetches detailed dashboard information including related charts and datasets.
# @PRE: Client is authenticated and dashboard_id exists.
# @POST: Returns dashboard metadata with charts and datasets lists.
# @RETURN: Dict
def get_dashboard_detail(self, dashboard_id: int) -> Dict:
with belief_scope("SupersetClient.get_dashboard_detail", f"id={dashboard_id}"):
dashboard_response = self.get_dashboard(dashboard_id)
dashboard_data = dashboard_response.get("result", dashboard_response)
charts: List[Dict] = []
datasets: List[Dict] = []
def extract_dataset_id_from_form_data(form_data: Optional[Dict]) -> Optional[int]:
if not isinstance(form_data, dict):
return None
datasource = form_data.get("datasource")
if isinstance(datasource, str):
matched = re.match(r"^(\d+)__", datasource)
if matched:
try:
return int(matched.group(1))
except ValueError:
return None
if isinstance(datasource, dict):
ds_id = datasource.get("id")
try:
return int(ds_id) if ds_id is not None else None
except (TypeError, ValueError):
return None
ds_id = form_data.get("datasource_id")
try:
return int(ds_id) if ds_id is not None else None
except (TypeError, ValueError):
return None
# Canonical endpoints from Superset OpenAPI:
# /dashboard/{id_or_slug}/charts and /dashboard/{id_or_slug}/datasets.
try:
charts_response = self.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id}/charts"
)
charts_payload = charts_response.get("result", []) if isinstance(charts_response, dict) else []
for chart_obj in charts_payload:
if not isinstance(chart_obj, dict):
continue
chart_id = chart_obj.get("id")
if chart_id is None:
continue
form_data = chart_obj.get("form_data")
if isinstance(form_data, str):
try:
form_data = json.loads(form_data)
except Exception:
form_data = {}
dataset_id = extract_dataset_id_from_form_data(form_data) or chart_obj.get("datasource_id")
charts.append({
"id": int(chart_id),
"title": chart_obj.get("slice_name") or chart_obj.get("name") or f"Chart {chart_id}",
"viz_type": (form_data.get("viz_type") if isinstance(form_data, dict) else None),
"dataset_id": int(dataset_id) if dataset_id is not None else None,
"last_modified": chart_obj.get("changed_on"),
"overview": chart_obj.get("description") or (form_data.get("viz_type") if isinstance(form_data, dict) else None) or "Chart",
})
except Exception as e:
app_logger.warning("[get_dashboard_detail][Warning] Failed to fetch dashboard charts: %s", e)
try:
datasets_response = self.network.request(
method="GET",
endpoint=f"/dashboard/{dashboard_id}/datasets"
)
datasets_payload = datasets_response.get("result", []) if isinstance(datasets_response, dict) else []
for dataset_obj in datasets_payload:
if not isinstance(dataset_obj, dict):
continue
dataset_id = dataset_obj.get("id")
if dataset_id is None:
continue
db_payload = dataset_obj.get("database")
db_name = db_payload.get("database_name") if isinstance(db_payload, dict) else None
table_name = dataset_obj.get("table_name") or dataset_obj.get("datasource_name") or dataset_obj.get("name") or f"Dataset {dataset_id}"
schema = dataset_obj.get("schema")
fq_name = f"{schema}.{table_name}" if schema else table_name
datasets.append({
"id": int(dataset_id),
"table_name": table_name,
"schema": schema,
"database": db_name or dataset_obj.get("database_name") or "Unknown",
"last_modified": dataset_obj.get("changed_on"),
"overview": fq_name,
})
except Exception as e:
app_logger.warning("[get_dashboard_detail][Warning] Failed to fetch dashboard datasets: %s", e)
# Fallback: derive chart IDs from layout metadata if dashboard charts endpoint fails.
if not charts:
raw_position_json = dashboard_data.get("position_json")
chart_ids_from_position = set()
if isinstance(raw_position_json, str) and raw_position_json:
try:
parsed_position = json.loads(raw_position_json)
chart_ids_from_position.update(self._extract_chart_ids_from_layout(parsed_position))
except Exception:
pass
elif isinstance(raw_position_json, dict):
chart_ids_from_position.update(self._extract_chart_ids_from_layout(raw_position_json))
raw_json_metadata = dashboard_data.get("json_metadata")
if isinstance(raw_json_metadata, str) and raw_json_metadata:
try:
parsed_metadata = json.loads(raw_json_metadata)
chart_ids_from_position.update(self._extract_chart_ids_from_layout(parsed_metadata))
except Exception:
pass
elif isinstance(raw_json_metadata, dict):
chart_ids_from_position.update(self._extract_chart_ids_from_layout(raw_json_metadata))
app_logger.info(
"[get_dashboard_detail][State] Extracted %s fallback chart IDs from layout (dashboard_id=%s)",
len(chart_ids_from_position),
dashboard_id,
)
for chart_id in sorted(chart_ids_from_position):
try:
chart_response = self.get_chart(int(chart_id))
chart_data = chart_response.get("result", chart_response)
charts.append({
"id": int(chart_id),
"title": chart_data.get("slice_name") or chart_data.get("name") or f"Chart {chart_id}",
"viz_type": chart_data.get("viz_type"),
"dataset_id": chart_data.get("datasource_id"),
"last_modified": chart_data.get("changed_on"),
"overview": chart_data.get("description") or chart_data.get("viz_type") or "Chart",
})
except Exception as e:
app_logger.warning("[get_dashboard_detail][Warning] Failed to resolve fallback chart %s: %s", chart_id, e)
# Backfill datasets from chart datasource IDs.
dataset_ids_from_charts = {
c.get("dataset_id")
for c in charts
if c.get("dataset_id") is not None
}
known_dataset_ids = {d.get("id") for d in datasets}
missing_dataset_ids = [ds_id for ds_id in dataset_ids_from_charts if ds_id not in known_dataset_ids]
for dataset_id in missing_dataset_ids:
try:
dataset_response = self.get_dataset(int(dataset_id))
dataset_data = dataset_response.get("result", dataset_response)
db_payload = dataset_data.get("database")
db_name = db_payload.get("database_name") if isinstance(db_payload, dict) else None
table_name = dataset_data.get("table_name") or f"Dataset {dataset_id}"
schema = dataset_data.get("schema")
fq_name = f"{schema}.{table_name}" if schema else table_name
datasets.append({
"id": int(dataset_id),
"table_name": table_name,
"schema": schema,
"database": db_name or "Unknown",
"last_modified": dataset_data.get("changed_on_utc") or dataset_data.get("changed_on"),
"overview": fq_name,
})
except Exception as e:
app_logger.warning("[get_dashboard_detail][Warning] Failed to resolve dataset %s: %s", dataset_id, e)
unique_charts = {}
for chart in charts:
unique_charts[chart["id"]] = chart
unique_datasets = {}
for dataset in datasets:
unique_datasets[dataset["id"]] = dataset
return {
"id": dashboard_data.get("id", dashboard_id),
"title": dashboard_data.get("dashboard_title") or dashboard_data.get("title") or f"Dashboard {dashboard_id}",
"slug": dashboard_data.get("slug"),
"url": dashboard_data.get("url"),
"description": dashboard_data.get("description") or "",
"last_modified": dashboard_data.get("changed_on_utc") or dashboard_data.get("changed_on"),
"published": dashboard_data.get("published"),
"charts": list(unique_charts.values()),
"datasets": list(unique_datasets.values()),
"chart_count": len(unique_charts),
"dataset_count": len(unique_datasets),
}
# [/DEF:get_dashboard_detail:Function]
# [DEF:_extract_chart_ids_from_layout:Function]
# @PURPOSE: Traverses dashboard layout metadata and extracts chart IDs from common keys.
# @PRE: payload can be dict/list/scalar.
# @POST: Returns a set of chart IDs found in nested structures.
def _extract_chart_ids_from_layout(self, payload: Union[Dict, List, str, int, None]) -> set:
with belief_scope("_extract_chart_ids_from_layout"):
found = set()
def walk(node):
if isinstance(node, dict):
for key, value in node.items():
if key in ("chartId", "chart_id", "slice_id", "sliceId"):
try:
found.add(int(value))
except (TypeError, ValueError):
pass
if key == "id" and isinstance(value, str):
match = re.match(r"^CHART-(\d+)$", value)
if match:
try:
found.add(int(match.group(1)))
except ValueError:
pass
walk(value)
elif isinstance(node, list):
for item in node:
walk(item)
walk(payload)
return found
# [/DEF:_extract_chart_ids_from_layout:Function]
# [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"):
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.
# @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"):
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 дашборда.
# @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)
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]) - Дополнительные параметры запроса.
# @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"):
app_logger.info("[get_datasets][Enter] Fetching datasets.")
validated_query = self._validate_query_params(query)
paginated_data = self._fetch_all_pages(
endpoint="/dataset/",
pagination_options={"base_query": validated_query, "results_field": "result"},
)
total_count = len(paginated_data)
app_logger.info("[get_datasets][Exit] Found %d datasets.", total_count)
return total_count, paginated_data
# [/DEF:get_datasets:Function]
# [DEF:get_datasets_summary:Function]
# @PURPOSE: Fetches dataset metadata optimized for the Dataset Hub grid.
# @PRE: Client is authenticated.
# @POST: Returns a list of dataset metadata summaries.
# @RETURN: List[Dict]
def get_datasets_summary(self) -> List[Dict]:
with belief_scope("SupersetClient.get_datasets_summary"):
query = {
"columns": ["id", "table_name", "schema", "database"]
}
_, datasets = self.get_datasets(query=query)
# Map fields to match the contracts
result = []
for ds in datasets:
result.append({
"id": ds.get("id"),
"table_name": ds.get("table_name"),
"schema": ds.get("schema"),
"database": ds.get("database", {}).get("database_name", "Unknown")
})
return result
# [/DEF:get_datasets_summary:Function]
# [DEF:get_dataset_detail:Function]
# @PURPOSE: Fetches detailed dataset information including columns and linked dashboards
# @PRE: Client is authenticated and dataset_id exists.
# @POST: Returns detailed dataset info with columns and linked dashboards.
# @PARAM: dataset_id (int) - The dataset ID to fetch details for.
# @RETURN: Dict - Dataset details with columns and linked_dashboards.
# @RELATION: CALLS -> self.get_dataset
# @RELATION: CALLS -> self.network.request (for related_objects)
def get_dataset_detail(self, dataset_id: int) -> Dict:
with belief_scope("SupersetClient.get_dataset_detail", f"id={dataset_id}"):
def as_bool(value, default=False):
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "y", "on")
return bool(value)
# Get base dataset info
response = self.get_dataset(dataset_id)
# If the response is a dict and has a 'result' key, use that (standard Superset API)
if isinstance(response, dict) and 'result' in response:
dataset = response['result']
else:
dataset = response
# Extract columns information
columns = dataset.get("columns", [])
column_info = []
for col in columns:
col_id = col.get("id")
if col_id is None:
continue
column_info.append({
"id": int(col_id),
"name": col.get("column_name"),
"type": col.get("type"),
"is_dttm": as_bool(col.get("is_dttm"), default=False),
"is_active": as_bool(col.get("is_active"), default=True),
"description": col.get("description", "")
})
# Get linked dashboards using related_objects endpoint
linked_dashboards = []
try:
related_objects = self.network.request(
method="GET",
endpoint=f"/dataset/{dataset_id}/related_objects"
)
# Handle different response formats
if isinstance(related_objects, dict):
if "dashboards" in related_objects:
dashboards_data = related_objects["dashboards"]
elif "result" in related_objects and isinstance(related_objects["result"], dict):
dashboards_data = related_objects["result"].get("dashboards", [])
else:
dashboards_data = []
for dash in dashboards_data:
if isinstance(dash, dict):
dash_id = dash.get("id")
if dash_id is None:
continue
linked_dashboards.append({
"id": int(dash_id),
"title": dash.get("dashboard_title") or dash.get("title", f"Dashboard {dash_id}"),
"slug": dash.get("slug")
})
else:
try:
dash_id = int(dash)
except (TypeError, ValueError):
continue
linked_dashboards.append({
"id": dash_id,
"title": f"Dashboard {dash_id}",
"slug": None
})
except Exception as e:
app_logger.warning(f"[get_dataset_detail][Warning] Failed to fetch related dashboards: {e}")
linked_dashboards = []
# Extract SQL table information
sql = dataset.get("sql", "")
result = {
"id": dataset.get("id"),
"table_name": dataset.get("table_name"),
"schema": dataset.get("schema"),
"database": (
dataset.get("database", {}).get("database_name", "Unknown")
if isinstance(dataset.get("database"), dict)
else dataset.get("database_name") or "Unknown"
),
"description": dataset.get("description", ""),
"columns": column_info,
"column_count": len(column_info),
"sql": sql,
"linked_dashboards": linked_dashboards,
"linked_dashboard_count": len(linked_dashboards),
"is_sqllab_view": as_bool(dataset.get("is_sqllab_view"), default=False),
"created_on": dataset.get("created_on"),
"changed_on": dataset.get("changed_on")
}
app_logger.info(f"[get_dataset_detail][Exit] Got dataset {dataset_id} with {len(column_info)} columns and {len(linked_dashboards)} linked dashboards")
return result
# [/DEF:get_dataset_detail:Function]
# [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}"):
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: Обновляет данные датасета по его 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}"):
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]) - Дополнительные параметры запроса.
# @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"):
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'] = []
paginated_data = self._fetch_all_pages(
endpoint="/database/",
pagination_options={"base_query": validated_query, "results_field": "result"},
)
total_count = len(paginated_data)
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 базы данных.
# @PRE: database_id must exist.
# @POST: Returns database details.
# @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.
# @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"):
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.
# @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]:
"""
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:SupersetClient.get_database_by_uuid]
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]
# [/DEF:SupersetClient]
# [/SECTION]
# [/DEF:backend.src.core.superset_client]
# [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:
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]
# @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}")
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]
# @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", "")
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]
# @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))
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]
# @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}
return {**base_query, **(query or {})}
# [/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(
endpoint=endpoint,
query_params={"page": 0, "page_size": 1},
count_field="count",
)
# [/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)
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,203 +0,0 @@
# [DEF:TaskManagerModule:Module]
# @SEMANTICS: task, manager, lifecycle, execution, state
# @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking. It uses a thread pool to run plugins asynchronously.
# @LAYER: Core
# @RELATION: Depends on PluginLoader to get plugin instances. It is used by the API layer to create and query tasks.
import asyncio
import uuid
from datetime import datetime
from enum import Enum
from typing import Dict, Any, List, Optional
from concurrent.futures import ThreadPoolExecutor
from pydantic import BaseModel, Field
# Assuming PluginBase and PluginConfig are defined in plugin_base.py
# from .plugin_base import PluginBase, PluginConfig # Not needed here, TaskManager interacts with the PluginLoader
# [DEF:TaskStatus:Enum]
# @SEMANTICS: task, status, state, enum
# @PURPOSE: Defines the possible states a task can be in during its lifecycle.
class TaskStatus(str, Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
AWAITING_MAPPING = "AWAITING_MAPPING"
# [/DEF]
# [DEF:LogEntry:Class]
# @SEMANTICS: log, entry, record, pydantic
# @PURPOSE: A Pydantic model representing a single, structured log entry associated with a task.
class LogEntry(BaseModel):
timestamp: datetime = Field(default_factory=datetime.utcnow)
level: str
message: str
context: Optional[Dict[str, Any]] = None
# [/DEF]
# [DEF:Task:Class]
# @SEMANTICS: task, job, execution, state, pydantic
# @PURPOSE: A Pydantic model representing a single execution instance of a plugin, including its status, parameters, and logs.
class Task(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
plugin_id: str
status: TaskStatus = TaskStatus.PENDING
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
user_id: Optional[str] = None
logs: List[LogEntry] = Field(default_factory=list)
params: Dict[str, Any] = Field(default_factory=dict)
# [/DEF]
# [DEF:TaskManager:Class]
# @SEMANTICS: task, manager, lifecycle, execution, state
# @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking.
class TaskManager:
"""
Manages the lifecycle of tasks, including their creation, execution, and state tracking.
"""
def __init__(self, plugin_loader):
self.plugin_loader = plugin_loader
self.tasks: Dict[str, Task] = {}
self.subscribers: Dict[str, List[asyncio.Queue]] = {}
self.executor = ThreadPoolExecutor(max_workers=5) # For CPU-bound plugin execution
self.loop = asyncio.get_event_loop()
self.task_futures: Dict[str, asyncio.Future] = {}
# [/DEF]
async def create_task(self, plugin_id: str, params: Dict[str, Any], user_id: Optional[str] = None) -> Task:
"""
Creates and queues a new task for execution.
"""
if not self.plugin_loader.has_plugin(plugin_id):
raise ValueError(f"Plugin with ID '{plugin_id}' not found.")
plugin = self.plugin_loader.get_plugin(plugin_id)
# Validate params against plugin schema (this will be done at a higher level, e.g., API route)
# For now, a basic check
if not isinstance(params, dict):
raise ValueError("Task parameters must be a dictionary.")
task = Task(plugin_id=plugin_id, params=params, user_id=user_id)
self.tasks[task.id] = task
self.loop.create_task(self._run_task(task.id)) # Schedule task for execution
return task
async def _run_task(self, task_id: str):
"""
Internal method to execute a task.
"""
task = self.tasks[task_id]
plugin = self.plugin_loader.get_plugin(task.plugin_id)
task.status = TaskStatus.RUNNING
task.started_at = datetime.utcnow()
self._add_log(task_id, "INFO", f"Task started for plugin '{plugin.name}'")
try:
# Execute plugin in a separate thread to avoid blocking the event loop
# if the plugin's execute method is synchronous and potentially CPU-bound.
# If the plugin's execute method is already async, this can be simplified.
# Pass task_id to plugin so it can signal pause
params = {**task.params, "_task_id": task_id}
await self.loop.run_in_executor(
self.executor,
lambda: asyncio.run(plugin.execute(params)) if asyncio.iscoroutinefunction(plugin.execute) else plugin.execute(params)
)
task.status = TaskStatus.SUCCESS
self._add_log(task_id, "INFO", f"Task completed successfully for plugin '{plugin.name}'")
except Exception as e:
task.status = TaskStatus.FAILED
self._add_log(task_id, "ERROR", f"Task failed: {e}", {"error_type": type(e).__name__})
finally:
task.finished_at = datetime.utcnow()
# In a real system, you might notify clients via WebSocket here
async def resolve_task(self, task_id: str, resolution_params: Dict[str, Any]):
"""
Resumes a task that is awaiting mapping.
"""
task = self.tasks.get(task_id)
if not task or task.status != TaskStatus.AWAITING_MAPPING:
raise ValueError("Task is not awaiting mapping.")
# Update task params with resolution
task.params.update(resolution_params)
task.status = TaskStatus.RUNNING
self._add_log(task_id, "INFO", "Task resumed after mapping resolution.")
# Signal the future to continue
if task_id in self.task_futures:
self.task_futures[task_id].set_result(True)
async def wait_for_resolution(self, task_id: str):
"""
Pauses execution and waits for a resolution signal.
"""
task = self.tasks.get(task_id)
if not task: return
task.status = TaskStatus.AWAITING_MAPPING
self.task_futures[task_id] = self.loop.create_future()
try:
await self.task_futures[task_id]
finally:
del self.task_futures[task_id]
def get_task(self, task_id: str) -> Optional[Task]:
"""
Retrieves a task by its ID.
"""
return self.tasks.get(task_id)
def get_all_tasks(self) -> List[Task]:
"""
Retrieves all registered tasks.
"""
return list(self.tasks.values())
def get_task_logs(self, task_id: str) -> List[LogEntry]:
"""
Retrieves logs for a specific task.
"""
task = self.tasks.get(task_id)
return task.logs if task else []
def _add_log(self, task_id: str, level: str, message: str, context: Optional[Dict[str, Any]] = None):
"""
Adds a log entry to a task and notifies subscribers.
"""
task = self.tasks.get(task_id)
if not task:
return
log_entry = LogEntry(level=level, message=message, context=context)
task.logs.append(log_entry)
# Notify subscribers
if task_id in self.subscribers:
for queue in self.subscribers[task_id]:
self.loop.call_soon_threadsafe(queue.put_nowait, log_entry)
async def subscribe_logs(self, task_id: str) -> asyncio.Queue:
"""
Subscribes to real-time logs for a task.
"""
queue = asyncio.Queue()
if task_id not in self.subscribers:
self.subscribers[task_id] = []
self.subscribers[task_id].append(queue)
return queue
def unsubscribe_logs(self, task_id: str, queue: asyncio.Queue):
"""
Unsubscribes from real-time logs for a task.
"""
if task_id in self.subscribers:
self.subscribers[task_id].remove(queue)
if not self.subscribers[task_id]:
del self.subscribers[task_id]

View File

@@ -0,0 +1,13 @@
# [DEF:TaskManagerPackage:Module]
# @TIER: TRIVIAL
# @SEMANTICS: task, manager, package, exports
# @PURPOSE: Exports the public API of the task manager package.
# @LAYER: Core
# @RELATION: Aggregates models and manager.
from .models import Task, TaskStatus, LogEntry
from .manager import TaskManager
__all__ = ["TaskManager", "Task", "TaskStatus", "LogEntry"]
# [/DEF:TaskManagerPackage:Module]

View File

@@ -0,0 +1,75 @@
# [DEF:TaskCleanupModule:Module]
# @TIER: STANDARD
# @SEMANTICS: task, cleanup, retention, logs
# @PURPOSE: Implements task cleanup and retention policies, including associated logs.
# @LAYER: Core
# @RELATION: Uses TaskPersistenceService and TaskLogPersistenceService to delete old tasks and logs.
from typing import List
from .persistence import TaskPersistenceService, TaskLogPersistenceService
from ..logger import logger, belief_scope
from ..config_manager import ConfigManager
# [DEF:TaskCleanupService:Class]
# @PURPOSE: Provides methods to clean up old task records and their associated logs.
# @TIER: STANDARD
class TaskCleanupService:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the cleanup service with dependencies.
# @PRE: persistence_service and config_manager are valid.
# @POST: Cleanup service is ready.
def __init__(
self,
persistence_service: TaskPersistenceService,
log_persistence_service: TaskLogPersistenceService,
config_manager: ConfigManager
):
self.persistence_service = persistence_service
self.log_persistence_service = log_persistence_service
self.config_manager = config_manager
# [/DEF:__init__:Function]
# [DEF:run_cleanup:Function]
# @PURPOSE: Deletes tasks older than the configured retention period and their logs.
# @PRE: Config manager has valid settings.
# @POST: Old tasks and their logs are deleted from persistence.
def run_cleanup(self):
with belief_scope("TaskCleanupService.run_cleanup"):
settings = self.config_manager.get_config().settings
retention_days = settings.task_retention_days
logger.info(f"Cleaning up tasks older than {retention_days} days.")
# Load tasks to check for limit
tasks = self.persistence_service.load_tasks(limit=1000)
if len(tasks) > settings.task_retention_limit:
to_delete: List[str] = [t.id for t in tasks[settings.task_retention_limit:]]
# Delete logs first (before task records)
self.log_persistence_service.delete_logs_for_tasks(to_delete)
# Then delete task records
self.persistence_service.delete_tasks(to_delete)
logger.info(f"Deleted {len(to_delete)} tasks and their logs exceeding limit of {settings.task_retention_limit}")
# [/DEF:run_cleanup:Function]
# [DEF:delete_task_with_logs:Function]
# @PURPOSE: Delete a single task and all its associated logs.
# @PRE: task_id is a valid task ID.
# @POST: Task and all its logs are deleted.
# @PARAM: task_id (str) - The task ID to delete.
def delete_task_with_logs(self, task_id: str) -> None:
"""Delete a single task and all its associated logs."""
with belief_scope("TaskCleanupService.delete_task_with_logs", f"task_id={task_id}"):
# Delete logs first
self.log_persistence_service.delete_logs_for_task(task_id)
# Then delete task record
self.persistence_service.delete_tasks([task_id])
logger.info(f"Deleted task {task_id} and its associated logs")
# [/DEF:delete_task_with_logs:Function]
# [/DEF:TaskCleanupService:Class]
# [/DEF:TaskCleanupModule:Module]

View File

@@ -0,0 +1,115 @@
# [DEF:TaskContextModule:Module]
# @SEMANTICS: task, context, plugin, execution, logger
# @PURPOSE: Provides execution context passed to plugins during task execution.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> TaskLogger, USED_BY -> plugins
# @TIER: CRITICAL
# @INVARIANT: Each TaskContext is bound to a single task execution.
# [SECTION: IMPORTS]
from typing import Dict, Any, Callable
from .task_logger import TaskLogger
# [/SECTION]
# [DEF:TaskContext:Class]
# @SEMANTICS: context, task, execution, plugin
# @PURPOSE: A container passed to plugin.execute() providing the logger and other task-specific utilities.
# @TIER: CRITICAL
# @INVARIANT: logger is always a valid TaskLogger instance.
# @UX_STATE: Idle -> Active -> Complete
class TaskContext:
"""
Execution context provided to plugins during task execution.
Usage:
def execute(params: dict, context: TaskContext = None):
if context:
context.logger.info("Starting process")
context.logger.progress("Processing items", percent=50)
# ... plugin logic
"""
# [DEF:__init__:Function]
# @PURPOSE: Initialize the TaskContext with task-specific resources.
# @PRE: task_id is a valid task identifier, add_log_fn is callable.
# @POST: TaskContext is ready to be passed to plugin.execute().
# @PARAM: task_id (str) - The ID of the task.
# @PARAM: add_log_fn (Callable) - Function to add log to TaskManager.
# @PARAM: params (Dict) - Task parameters.
# @PARAM: default_source (str) - Default source for logs (default: "plugin").
def __init__(
self,
task_id: str,
add_log_fn: Callable,
params: Dict[str, Any],
default_source: str = "plugin"
):
self._task_id = task_id
self._params = params
self._logger = TaskLogger(
task_id=task_id,
add_log_fn=add_log_fn,
source=default_source
)
# [/DEF:__init__:Function]
# [DEF:task_id:Function]
# @PURPOSE: Get the task ID.
# @PRE: TaskContext must be initialized.
# @POST: Returns the task ID string.
# @RETURN: str - The task ID.
@property
def task_id(self) -> str:
return self._task_id
# [/DEF:task_id:Function]
# [DEF:logger:Function]
# @PURPOSE: Get the TaskLogger instance for this context.
# @PRE: TaskContext must be initialized.
# @POST: Returns the TaskLogger instance.
# @RETURN: TaskLogger - The logger instance.
@property
def logger(self) -> TaskLogger:
return self._logger
# [/DEF:logger:Function]
# [DEF:params:Function]
# @PURPOSE: Get the task parameters.
# @PRE: TaskContext must be initialized.
# @POST: Returns the parameters dictionary.
# @RETURN: Dict[str, Any] - The task parameters.
@property
def params(self) -> Dict[str, Any]:
return self._params
# [/DEF:params:Function]
# [DEF:get_param:Function]
# @PURPOSE: Get a specific parameter value with optional default.
# @PRE: TaskContext must be initialized.
# @POST: Returns parameter value or default.
# @PARAM: key (str) - Parameter key.
# @PARAM: default (Any) - Default value if key not found.
# @RETURN: Any - Parameter value or default.
def get_param(self, key: str, default: Any = None) -> Any:
return self._params.get(key, default)
# [/DEF:get_param:Function]
# [DEF:create_sub_context:Function]
# @PURPOSE: Create a sub-context with a different default source.
# @PRE: source is a non-empty string.
# @POST: Returns new TaskContext with different logger source.
# @PARAM: source (str) - New default source for logging.
# @RETURN: TaskContext - New context with different source.
def create_sub_context(self, source: str) -> "TaskContext":
"""Create a sub-context with a different default source for logging."""
return TaskContext(
task_id=self._task_id,
add_log_fn=self._logger._add_log,
params=self._params,
default_source=source
)
# [/DEF:create_sub_context:Function]
# [/DEF:TaskContext:Class]
# [/DEF:TaskContextModule:Module]

View File

@@ -0,0 +1,593 @@
# [DEF:TaskManagerModule:Module]
# @SEMANTICS: task, manager, lifecycle, execution, state
# @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking. It uses a thread pool to run plugins asynchronously.
# @LAYER: Core
# @RELATION: Depends on PluginLoader to get plugin instances. It is used by the API layer to create and query tasks.
# @INVARIANT: Task IDs are unique.
# @CONSTRAINT: Must use belief_scope for logging.
# [SECTION: IMPORTS]
import asyncio
import threading
import inspect
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional
from .models import Task, TaskStatus, LogEntry, LogFilter, LogStats
from .persistence import TaskPersistenceService, TaskLogPersistenceService
from .context import TaskContext
from ..logger import logger, belief_scope, should_log_task_level
# [/SECTION]
# [DEF:TaskManager:Class]
# @SEMANTICS: task, manager, lifecycle, execution, state
# @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking.
# @TIER: CRITICAL
# @INVARIANT: Task IDs are unique within the registry.
# @INVARIANT: Each task has exactly one status at any time.
# @INVARIANT: Log entries are never deleted after being added to a task.
class TaskManager:
"""
Manages the lifecycle of tasks, including their creation, execution, and state tracking.
"""
# Log flush interval in seconds
LOG_FLUSH_INTERVAL = 2.0
# [DEF:__init__:Function]
# @PURPOSE: Initialize the TaskManager with dependencies.
# @PRE: plugin_loader is initialized.
# @POST: TaskManager is ready to accept tasks.
# @PARAM: plugin_loader - The plugin loader instance.
def __init__(self, plugin_loader):
with belief_scope("TaskManager.__init__"):
self.plugin_loader = plugin_loader
self.tasks: Dict[str, Task] = {}
self.subscribers: Dict[str, List[asyncio.Queue]] = {}
self.executor = ThreadPoolExecutor(max_workers=5) # For CPU-bound plugin execution
self.persistence_service = TaskPersistenceService()
self.log_persistence_service = TaskLogPersistenceService()
# Log buffer: task_id -> List[LogEntry]
self._log_buffer: Dict[str, List[LogEntry]] = {}
self._log_buffer_lock = threading.Lock()
# Flusher thread for batch writing logs
self._flusher_stop_event = threading.Event()
self._flusher_thread = threading.Thread(target=self._flusher_loop, daemon=True)
self._flusher_thread.start()
try:
self.loop = asyncio.get_running_loop()
except RuntimeError:
self.loop = asyncio.get_event_loop()
self.task_futures: Dict[str, asyncio.Future] = {}
# Load persisted tasks on startup
self.load_persisted_tasks()
# [/DEF:__init__:Function]
# [DEF:_flusher_loop:Function]
# @PURPOSE: Background thread that periodically flushes log buffer to database.
# @PRE: TaskManager is initialized.
# @POST: Logs are batch-written to database every LOG_FLUSH_INTERVAL seconds.
def _flusher_loop(self):
"""Background thread that flushes log buffer to database."""
while not self._flusher_stop_event.is_set():
self._flush_logs()
self._flusher_stop_event.wait(self.LOG_FLUSH_INTERVAL)
# [/DEF:_flusher_loop:Function]
# [DEF:_flush_logs:Function]
# @PURPOSE: Flush all buffered logs to the database.
# @PRE: None.
# @POST: All buffered logs are written to task_logs table.
def _flush_logs(self):
"""Flush all buffered logs to the database."""
with self._log_buffer_lock:
task_ids = list(self._log_buffer.keys())
for task_id in task_ids:
with self._log_buffer_lock:
logs = self._log_buffer.pop(task_id, [])
if logs:
try:
self.log_persistence_service.add_logs(task_id, logs)
except Exception as e:
logger.error(f"Failed to flush logs for task {task_id}: {e}")
# Re-add logs to buffer on failure
with self._log_buffer_lock:
if task_id not in self._log_buffer:
self._log_buffer[task_id] = []
self._log_buffer[task_id].extend(logs)
# [/DEF:_flush_logs:Function]
# [DEF:_flush_task_logs:Function]
# @PURPOSE: Flush logs for a specific task immediately.
# @PRE: task_id exists.
# @POST: Task's buffered logs are written to database.
# @PARAM: task_id (str) - The task ID.
def _flush_task_logs(self, task_id: str):
"""Flush logs for a specific task immediately."""
with self._log_buffer_lock:
logs = self._log_buffer.pop(task_id, [])
if logs:
try:
self.log_persistence_service.add_logs(task_id, logs)
except Exception as e:
logger.error(f"Failed to flush logs for task {task_id}: {e}")
# [/DEF:_flush_task_logs:Function]
# [DEF:create_task:Function]
# @PURPOSE: Creates and queues a new task for execution.
# @PRE: Plugin with plugin_id exists. Params are valid.
# @POST: Task is created, added to registry, and scheduled for execution.
# @PARAM: plugin_id (str) - The ID of the plugin to run.
# @PARAM: params (Dict[str, Any]) - Parameters for the plugin.
# @PARAM: user_id (Optional[str]) - ID of the user requesting the task.
# @RETURN: Task - The created task instance.
# @THROWS: ValueError if plugin not found or params invalid.
async def create_task(self, plugin_id: str, params: Dict[str, Any], user_id: Optional[str] = None) -> Task:
with belief_scope("TaskManager.create_task", f"plugin_id={plugin_id}"):
if not self.plugin_loader.has_plugin(plugin_id):
logger.error(f"Plugin with ID '{plugin_id}' not found.")
raise ValueError(f"Plugin with ID '{plugin_id}' not found.")
self.plugin_loader.get_plugin(plugin_id)
if not isinstance(params, dict):
logger.error("Task parameters must be a dictionary.")
raise ValueError("Task parameters must be a dictionary.")
task = Task(plugin_id=plugin_id, params=params, user_id=user_id)
self.tasks[task.id] = task
self.persistence_service.persist_task(task)
logger.info(f"Task {task.id} created and scheduled for execution")
self.loop.create_task(self._run_task(task.id)) # Schedule task for execution
return task
# [/DEF:create_task:Function]
# [DEF:_run_task:Function]
# @PURPOSE: Internal method to execute a task with TaskContext support.
# @PRE: Task exists in registry.
# @POST: Task is executed, status updated to SUCCESS or FAILED.
# @PARAM: task_id (str) - The ID of the task to run.
async def _run_task(self, task_id: str):
with belief_scope("TaskManager._run_task", f"task_id={task_id}"):
task = self.tasks[task_id]
plugin = self.plugin_loader.get_plugin(task.plugin_id)
logger.info(f"Starting execution of task {task_id} for plugin '{plugin.name}'")
task.status = TaskStatus.RUNNING
task.started_at = datetime.utcnow()
self.persistence_service.persist_task(task)
self._add_log(task_id, "INFO", f"Task started for plugin '{plugin.name}'", source="system")
try:
# Prepare params and check if plugin supports new TaskContext
params = {**task.params, "_task_id": task_id}
# Check if plugin's execute method accepts 'context' parameter
sig = inspect.signature(plugin.execute)
accepts_context = 'context' in sig.parameters
if accepts_context:
# Create TaskContext for new-style plugins
context = TaskContext(
task_id=task_id,
add_log_fn=self._add_log,
params=params,
default_source="plugin"
)
if asyncio.iscoroutinefunction(plugin.execute):
task.result = await plugin.execute(params, context=context)
else:
task.result = await self.loop.run_in_executor(
self.executor,
lambda: plugin.execute(params, context=context)
)
else:
# Backward compatibility: old-style plugins without context
if asyncio.iscoroutinefunction(plugin.execute):
task.result = await plugin.execute(params)
else:
task.result = await self.loop.run_in_executor(
self.executor,
plugin.execute,
params
)
logger.info(f"Task {task_id} completed successfully")
task.status = TaskStatus.SUCCESS
self._add_log(task_id, "INFO", f"Task completed successfully for plugin '{plugin.name}'", source="system")
except Exception as e:
logger.error(f"Task {task_id} failed: {e}")
task.status = TaskStatus.FAILED
self._add_log(task_id, "ERROR", f"Task failed: {e}", source="system", metadata={"error_type": type(e).__name__})
finally:
task.finished_at = datetime.utcnow()
# Flush any remaining buffered logs before persisting task
self._flush_task_logs(task_id)
self.persistence_service.persist_task(task)
logger.info(f"Task {task_id} execution finished with status: {task.status}")
# [/DEF:_run_task:Function]
# [DEF:resolve_task:Function]
# @PURPOSE: Resumes a task that is awaiting mapping.
# @PRE: Task exists and is in AWAITING_MAPPING state.
# @POST: Task status updated to RUNNING, params updated, execution resumed.
# @PARAM: task_id (str) - The ID of the task.
# @PARAM: resolution_params (Dict[str, Any]) - Params to resolve the wait.
# @THROWS: ValueError if task not found or not awaiting mapping.
async def resolve_task(self, task_id: str, resolution_params: Dict[str, Any]):
with belief_scope("TaskManager.resolve_task", f"task_id={task_id}"):
task = self.tasks.get(task_id)
if not task or task.status != TaskStatus.AWAITING_MAPPING:
raise ValueError("Task is not awaiting mapping.")
# Update task params with resolution
task.params.update(resolution_params)
task.status = TaskStatus.RUNNING
self.persistence_service.persist_task(task)
self._add_log(task_id, "INFO", "Task resumed after mapping resolution.")
# Signal the future to continue
if task_id in self.task_futures:
self.task_futures[task_id].set_result(True)
# [/DEF:resolve_task:Function]
# [DEF:wait_for_resolution:Function]
# @PURPOSE: Pauses execution and waits for a resolution signal.
# @PRE: Task exists.
# @POST: Execution pauses until future is set.
# @PARAM: task_id (str) - The ID of the task.
async def wait_for_resolution(self, task_id: str):
with belief_scope("TaskManager.wait_for_resolution", f"task_id={task_id}"):
task = self.tasks.get(task_id)
if not task:
return
task.status = TaskStatus.AWAITING_MAPPING
self.persistence_service.persist_task(task)
self.task_futures[task_id] = self.loop.create_future()
try:
await self.task_futures[task_id]
finally:
if task_id in self.task_futures:
del self.task_futures[task_id]
# [/DEF:wait_for_resolution:Function]
# [DEF:wait_for_input:Function]
# @PURPOSE: Pauses execution and waits for user input.
# @PRE: Task exists.
# @POST: Execution pauses until future is set via resume_task_with_password.
# @PARAM: task_id (str) - The ID of the task.
async def wait_for_input(self, task_id: str):
with belief_scope("TaskManager.wait_for_input", f"task_id={task_id}"):
task = self.tasks.get(task_id)
if not task:
return
# Status is already set to AWAITING_INPUT by await_input()
self.task_futures[task_id] = self.loop.create_future()
try:
await self.task_futures[task_id]
finally:
if task_id in self.task_futures:
del self.task_futures[task_id]
# [/DEF:wait_for_input:Function]
# [DEF:get_task:Function]
# @PURPOSE: Retrieves a task by its ID.
# @PRE: task_id is a string.
# @POST: Returns Task object or None.
# @PARAM: task_id (str) - ID of the task.
# @RETURN: Optional[Task] - The task or None.
def get_task(self, task_id: str) -> Optional[Task]:
with belief_scope("TaskManager.get_task", f"task_id={task_id}"):
return self.tasks.get(task_id)
# [/DEF:get_task:Function]
# [DEF:get_all_tasks:Function]
# @PURPOSE: Retrieves all registered tasks.
# @PRE: None.
# @POST: Returns list of all Task objects.
# @RETURN: List[Task] - All tasks.
def get_all_tasks(self) -> List[Task]:
with belief_scope("TaskManager.get_all_tasks"):
return list(self.tasks.values())
# [/DEF:get_all_tasks:Function]
# [DEF:get_tasks:Function]
# @PURPOSE: Retrieves tasks with pagination and optional status filter.
# @PRE: limit and offset are non-negative integers.
# @POST: Returns a list of tasks sorted by start_time descending.
# @PARAM: limit (int) - Maximum number of tasks to return.
# @PARAM: offset (int) - Number of tasks to skip.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @RETURN: List[Task] - List of tasks matching criteria.
def get_tasks(
self,
limit: int = 10,
offset: int = 0,
status: Optional[TaskStatus] = None,
plugin_ids: Optional[List[str]] = None,
completed_only: bool = False
) -> List[Task]:
with belief_scope("TaskManager.get_tasks"):
tasks = list(self.tasks.values())
if status:
tasks = [t for t in tasks if t.status == status]
if plugin_ids:
plugin_id_set = set(plugin_ids)
tasks = [t for t in tasks if t.plugin_id in plugin_id_set]
if completed_only:
tasks = [t for t in tasks if t.status in [TaskStatus.SUCCESS, TaskStatus.FAILED]]
# Sort by started_at descending with tolerant handling of mixed tz-aware/naive values.
def sort_key(task: Task) -> float:
started_at = task.started_at
if started_at is None:
return float("-inf")
if not isinstance(started_at, datetime):
return float("-inf")
if started_at.tzinfo is None:
return started_at.replace(tzinfo=timezone.utc).timestamp()
return started_at.timestamp()
tasks.sort(key=sort_key, reverse=True)
return tasks[offset:offset + limit]
# [/DEF:get_tasks:Function]
# [DEF:get_task_logs:Function]
# @PURPOSE: Retrieves logs for a specific task (from memory for running, persistence for completed).
# @PRE: task_id is a string.
# @POST: Returns list of LogEntry or TaskLog objects.
# @PARAM: task_id (str) - ID of the task.
# @PARAM: log_filter (Optional[LogFilter]) - Filter parameters.
# @RETURN: List[LogEntry] - List of log entries.
def get_task_logs(self, task_id: str, log_filter: Optional[LogFilter] = None) -> List[LogEntry]:
with belief_scope("TaskManager.get_task_logs", f"task_id={task_id}"):
task = self.tasks.get(task_id)
# For completed tasks, fetch from persistence
if task and task.status in [TaskStatus.SUCCESS, TaskStatus.FAILED]:
if log_filter is None:
log_filter = LogFilter()
task_logs = self.log_persistence_service.get_logs(task_id, log_filter)
# Convert TaskLog to LogEntry for backward compatibility
return [
LogEntry(
timestamp=log.timestamp,
level=log.level,
message=log.message,
source=log.source,
metadata=log.metadata
)
for log in task_logs
]
# For running/pending tasks, return from memory
return task.logs if task else []
# [/DEF:get_task_logs:Function]
# [DEF:get_task_log_stats:Function]
# @PURPOSE: Get statistics about logs for a task.
# @PRE: task_id is a valid task ID.
# @POST: Returns LogStats with counts by level and source.
# @PARAM: task_id (str) - The task ID.
# @RETURN: LogStats - Statistics about task logs.
def get_task_log_stats(self, task_id: str) -> LogStats:
with belief_scope("TaskManager.get_task_log_stats", f"task_id={task_id}"):
return self.log_persistence_service.get_log_stats(task_id)
# [/DEF:get_task_log_stats:Function]
# [DEF:get_task_log_sources:Function]
# @PURPOSE: Get unique sources for a task's logs.
# @PRE: task_id is a valid task ID.
# @POST: Returns list of unique source strings.
# @PARAM: task_id (str) - The task ID.
# @RETURN: List[str] - Unique source names.
def get_task_log_sources(self, task_id: str) -> List[str]:
with belief_scope("TaskManager.get_task_log_sources", f"task_id={task_id}"):
return self.log_persistence_service.get_sources(task_id)
# [/DEF:get_task_log_sources:Function]
# [DEF:_add_log:Function]
# @PURPOSE: Adds a log entry to a task buffer and notifies subscribers.
# @PRE: Task exists.
# @POST: Log added to buffer and pushed to queues (if level meets task_log_level filter).
# @PARAM: task_id (str) - ID of the task.
# @PARAM: level (str) - Log level.
# @PARAM: message (str) - Log message.
# @PARAM: source (str) - Source component (default: "system").
# @PARAM: metadata (Optional[Dict]) - Additional structured data.
# @PARAM: context (Optional[Dict]) - Legacy context (for backward compatibility).
def _add_log(
self,
task_id: str,
level: str,
message: str,
source: str = "system",
metadata: Optional[Dict[str, Any]] = None,
context: Optional[Dict[str, Any]] = None
):
with belief_scope("TaskManager._add_log", f"task_id={task_id}"):
task = self.tasks.get(task_id)
if not task:
return
# Filter logs based on task_log_level configuration
if not should_log_task_level(level):
return
# Create log entry with new fields
log_entry = LogEntry(
level=level,
message=message,
source=source,
metadata=metadata,
context=context # Keep for backward compatibility
)
# Add to in-memory logs (for backward compatibility with legacy JSON field)
task.logs.append(log_entry)
# Add to buffer for batch persistence
with self._log_buffer_lock:
if task_id not in self._log_buffer:
self._log_buffer[task_id] = []
self._log_buffer[task_id].append(log_entry)
# Notify subscribers (for real-time WebSocket updates)
if task_id in self.subscribers:
for queue in self.subscribers[task_id]:
self.loop.call_soon_threadsafe(queue.put_nowait, log_entry)
# [/DEF:_add_log:Function]
# [DEF:subscribe_logs:Function]
# @PURPOSE: Subscribes to real-time logs for a task.
# @PRE: task_id is a string.
# @POST: Returns an asyncio.Queue for log entries.
# @PARAM: task_id (str) - ID of the task.
# @RETURN: asyncio.Queue - Queue for log entries.
async def subscribe_logs(self, task_id: str) -> asyncio.Queue:
with belief_scope("TaskManager.subscribe_logs", f"task_id={task_id}"):
queue = asyncio.Queue()
if task_id not in self.subscribers:
self.subscribers[task_id] = []
self.subscribers[task_id].append(queue)
return queue
# [/DEF:subscribe_logs:Function]
# [DEF:unsubscribe_logs:Function]
# @PURPOSE: Unsubscribes from real-time logs for a task.
# @PRE: task_id is a string, queue is asyncio.Queue.
# @POST: Queue removed from subscribers.
# @PARAM: task_id (str) - ID of the task.
# @PARAM: queue (asyncio.Queue) - Queue to remove.
def unsubscribe_logs(self, task_id: str, queue: asyncio.Queue):
with belief_scope("TaskManager.unsubscribe_logs", f"task_id={task_id}"):
if task_id in self.subscribers:
if queue in self.subscribers[task_id]:
self.subscribers[task_id].remove(queue)
if not self.subscribers[task_id]:
del self.subscribers[task_id]
# [/DEF:unsubscribe_logs:Function]
# [DEF:load_persisted_tasks:Function]
# @PURPOSE: Load persisted tasks using persistence service.
# @PRE: None.
# @POST: Persisted tasks loaded into self.tasks.
def load_persisted_tasks(self) -> None:
with belief_scope("TaskManager.load_persisted_tasks"):
loaded_tasks = self.persistence_service.load_tasks(limit=100)
for task in loaded_tasks:
if task.id not in self.tasks:
self.tasks[task.id] = task
# [/DEF:load_persisted_tasks:Function]
# [DEF:await_input:Function]
# @PURPOSE: Transition a task to AWAITING_INPUT state with input request.
# @PRE: Task exists and is in RUNNING state.
# @POST: Task status changed to AWAITING_INPUT, input_request set, persisted.
# @PARAM: task_id (str) - ID of the task.
# @PARAM: input_request (Dict) - Details about required input.
# @THROWS: ValueError if task not found or not RUNNING.
def await_input(self, task_id: str, input_request: Dict[str, Any]) -> None:
with belief_scope("TaskManager.await_input", f"task_id={task_id}"):
task = self.tasks.get(task_id)
if not task:
raise ValueError(f"Task {task_id} not found")
if task.status != TaskStatus.RUNNING:
raise ValueError(f"Task {task_id} is not RUNNING (current: {task.status})")
task.status = TaskStatus.AWAITING_INPUT
task.input_required = True
task.input_request = input_request
self.persistence_service.persist_task(task)
self._add_log(task_id, "INFO", "Task paused for user input", {"input_request": input_request})
# [/DEF:await_input:Function]
# [DEF:resume_task_with_password:Function]
# @PURPOSE: Resume a task that is awaiting input with provided passwords.
# @PRE: Task exists and is in AWAITING_INPUT state.
# @POST: Task status changed to RUNNING, passwords injected, task resumed.
# @PARAM: task_id (str) - ID of the task.
# @PARAM: passwords (Dict[str, str]) - Mapping of database name to password.
# @THROWS: ValueError if task not found, not awaiting input, or passwords invalid.
def resume_task_with_password(self, task_id: str, passwords: Dict[str, str]) -> None:
with belief_scope("TaskManager.resume_task_with_password", f"task_id={task_id}"):
task = self.tasks.get(task_id)
if not task:
raise ValueError(f"Task {task_id} not found")
if task.status != TaskStatus.AWAITING_INPUT:
raise ValueError(f"Task {task_id} is not AWAITING_INPUT (current: {task.status})")
if not isinstance(passwords, dict) or not passwords:
raise ValueError("Passwords must be a non-empty dictionary")
task.params["passwords"] = passwords
task.input_required = False
task.input_request = None
task.status = TaskStatus.RUNNING
self.persistence_service.persist_task(task)
self._add_log(task_id, "INFO", "Task resumed with passwords", {"databases": list(passwords.keys())})
if task_id in self.task_futures:
self.task_futures[task_id].set_result(True)
# [/DEF:resume_task_with_password:Function]
# [DEF:clear_tasks:Function]
# @PURPOSE: Clears tasks based on status filter (also deletes associated logs).
# @PRE: status is Optional[TaskStatus].
# @POST: Tasks matching filter (or all non-active) cleared from registry and database.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @RETURN: int - Number of tasks cleared.
def clear_tasks(self, status: Optional[TaskStatus] = None) -> int:
with belief_scope("TaskManager.clear_tasks"):
tasks_to_remove = []
for task_id, task in list(self.tasks.items()):
# If status is provided, match it.
# If status is None, match everything EXCEPT RUNNING (unless they are awaiting input/mapping which are technically running but paused?)
# Actually, AWAITING_INPUT and AWAITING_MAPPING are distinct statuses in TaskStatus enum.
# RUNNING is active execution.
should_remove = False
if status:
if task.status == status:
should_remove = True
else:
# Clear all non-active tasks (keep RUNNING, AWAITING_INPUT, AWAITING_MAPPING)
if task.status not in [TaskStatus.RUNNING, TaskStatus.AWAITING_INPUT, TaskStatus.AWAITING_MAPPING]:
should_remove = True
if should_remove:
tasks_to_remove.append(task_id)
for tid in tasks_to_remove:
# Cancel future if exists (e.g. for AWAITING_INPUT/MAPPING)
if tid in self.task_futures:
self.task_futures[tid].cancel()
del self.task_futures[tid]
del self.tasks[tid]
# Remove from persistence (task_records and task_logs via CASCADE)
self.persistence_service.delete_tasks(tasks_to_remove)
# Also explicitly delete logs (in case CASCADE is not set up)
if tasks_to_remove:
self.log_persistence_service.delete_logs_for_tasks(tasks_to_remove)
logger.info(f"Cleared {len(tasks_to_remove)} tasks.")
return len(tasks_to_remove)
# [/DEF:clear_tasks:Function]
# [/DEF:TaskManager:Class]
# [/DEF:TaskManagerModule:Module]

Some files were not shown because too many files have changed in this diff Show More