Compare commits
35 Commits
1042b35d1b
...
019-supers
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cf0ef25f1 | |||
| af74841765 | |||
| d7e4919d54 | |||
| fdcbe32dfa | |||
| 4de5b22d57 | |||
| c8029ed309 | |||
| c2a4c8062a | |||
| 2c820e103a | |||
| c8b84b7bd7 | |||
| fdb944f123 | |||
| d29bc511a2 | |||
| a3a9f0788d | |||
| 77147dc95b | |||
| 026239e3bf | |||
| 4a0273a604 | |||
| edb2dd5263 | |||
| 76b98fcf8f | |||
| 794cc55fe7 | |||
| 235b0e3c9f | |||
| e6087bd3c1 | |||
| 0f16bab2b8 | |||
| 7de96c17c4 | |||
| f018b97ed2 | |||
| 72846aa835 | |||
| 994c0c3e5d | |||
| 252a8601a9 | |||
| 8044f85ea4 | |||
| d4109e5a03 | |||
| b2bbd73439 | |||
| 0e0e26e2f7 | |||
| 18b42f8dd0 | |||
| e7b31accd6 | |||
| d3c3a80ed2 | |||
| cc244c2d86 | |||
| d10c23e658 |
1250
.ai/MODULE_MAP.md
Normal file
1250
.ai/MODULE_MAP.md
Normal file
File diff suppressed because it is too large
Load Diff
2914
.ai/PROJECT_MAP.md
Normal file
2914
.ai/PROJECT_MAP.md
Normal file
File diff suppressed because it is too large
Load Diff
40
.ai/ROOT.md
Normal file
40
.ai/ROOT.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# [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]`
|
||||
* **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]
|
||||
65
.ai/shots/backend_route.py
Normal file
65
.ai/shots/backend_route.py
Normal 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]
|
||||
79
.ai/shots/critical_module.py
Normal file
79
.ai/shots/critical_module.py
Normal 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]
|
||||
70
.ai/shots/frontend_component.svelte
Normal file
70
.ai/shots/frontend_component.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<!-- [DEF:FrontendComponentShot:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @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}
|
||||
*/
|
||||
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]
|
||||
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] -->
|
||||
64
.ai/shots/plugin_example.py
Normal file
64
.ai/shots/plugin_example.py
Normal 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]
|
||||
47
.ai/standards/api_design.md
Normal file
47
.ai/standards/api_design.md
Normal 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]
|
||||
25
.ai/standards/architecture.md
Normal file
25
.ai/standards/architecture.md
Normal 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]
|
||||
36
.ai/standards/constitution.md
Normal file
36
.ai/standards/constitution.md
Normal 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]
|
||||
32
.ai/standards/plugin_design.md
Normal file
32
.ai/standards/plugin_design.md
Normal 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]
|
||||
97
.ai/standards/semantics.md
Normal file
97
.ai/standards/semantics.md
Normal 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]`.
|
||||
75
.ai/standards/ui_design.md
Normal file
75
.ai/standards/ui_design.md
Normal 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]
|
||||
27
.dockerignore
Normal file
27
.dockerignore
Normal file
@@ -0,0 +1,27 @@
|
||||
.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
|
||||
backups
|
||||
semantics
|
||||
specs
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -10,8 +10,6 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -66,7 +64,7 @@ backend/mappings.db
|
||||
|
||||
|
||||
backend/tasks.db
|
||||
|
||||
# Git Integration repositories
|
||||
backend/git_repos/
|
||||
backend/backend/git_repos
|
||||
backend/logs
|
||||
backend/auth.db
|
||||
semantics/reports
|
||||
backend/tasks.db
|
||||
|
||||
@@ -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)
|
||||
@@ -31,6 +37,10 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
|
||||
- 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 Build) (001-plugin-arch-svelte-ui)
|
||||
|
||||
@@ -51,9 +61,9 @@ cd src; pytest; ruff check .
|
||||
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 015-frontend-nav-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend)
|
||||
- 014-file-storage-ui: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit (Frontend)
|
||||
- 013-unify-frontend-css: Added Node.js 18+ (Frontend Build), Svelte 5.x + SvelteKit, Tailwind CSS, `date-fns` (existing)
|
||||
- 019-superset-ux-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing)
|
||||
- 017-llm-analysis-plugin: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
|
||||
- 016-multi-user-auth: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
4
.kilocode/workflows/read_semantic.md
Normal file
4
.kilocode/workflows/read_semantic.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
description: USE SEMANTIC
|
||||
---
|
||||
Прочитай .ai/standards/semantics.md. ОБЯЗАТЕЛЬНО используй его при разработке
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
199
.kilocode/workflows/speckit.fix.md
Normal file
199
.kilocode/workflows/speckit.fix.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
178
.kilocode/workflows/speckit.test.md
Normal file
178
.kilocode/workflows/speckit.test.md
Normal 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
|
||||
@@ -1,39 +1,39 @@
|
||||
customModes:
|
||||
- slug: tester
|
||||
name: Tester
|
||||
description: QA and Plan Verification Specialist
|
||||
description: QA and Test Engineer - Full Testing Cycle
|
||||
roleDefinition: |-
|
||||
You are Kilo Code, acting as a QA and Verification Specialist. Your primary goal is to validate that the project implementation aligns strictly with the defined specifications and task plans.
|
||||
Your responsibilities include: - Reading and analyzing task plans and specifications (typically in the `specs/` directory). - Verifying that implemented code matches the requirements. - Executing tests and validating system behavior via CLI or Browser. - Updating the status of tasks in the plan files (e.g., marking checkboxes [x]) as they are verified. - Identifying and reporting missing features or bugs.
|
||||
whenToUse: Use this mode when you need to audit the progress of a project, verify completed tasks against the plan, run quality assurance checks, or update the status of task lists in specification documents.
|
||||
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. Always begin by loading the relevant plan or task list from the `specs/` directory. 2. Do not assume a task is done just because it is checked; verify the code or functionality first if asked to audit. 3. When updating task lists, ensure you only mark items as complete if you have verified them.
|
||||
- slug: product-manager
|
||||
name: Product Manager
|
||||
description: Executes SpecKit workflows for feature management
|
||||
roleDefinition: |-
|
||||
You are Kilo Code, acting as a Product Manager. 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`) - 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.
|
||||
groups:
|
||||
- read
|
||||
- edit
|
||||
- command
|
||||
- mcp
|
||||
customInstructions: 1. Always read the specific workflow file in `.kilocode/workflows/` before executing a command. 2. Adhere strictly to the "Operating Constraints" and "Execution Steps" in the workflow files.
|
||||
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 `semantic_protocol.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`, `specs/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 `semantic_protocol.md` when modifying code.
|
||||
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 `semantic_protocol.md` standards.
|
||||
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:
|
||||
@@ -43,3 +43,43 @@ customModes:
|
||||
- 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
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
Version: 1.8.0 (Frontend Unification)
|
||||
Changes:
|
||||
- Added Principle VIII: Unified Frontend Experience (Mandating Design System & i18n).
|
||||
Templates Status:
|
||||
- .specify/templates/plan-template.md: ✅ Aligned.
|
||||
- .specify/templates/spec-template.md: ✅ Aligned.
|
||||
- .specify/templates/tasks-template.md: ✅ Aligned.
|
||||
-->
|
||||
# Semantic Code Generation Constitution
|
||||
|
||||
## Core Principles
|
||||
|
||||
### I. Semantic Protocol Compliance
|
||||
The file `semantic_protocol.md` is the **authoritative technical standard** for this project. All code generation, refactoring, and architecture must strictly adhere to the standards, syntax, and workflows defined therein.
|
||||
- **Syntax**: `[DEF]` anchors, `@RELATION` tags, and metadata must match the Protocol specification.
|
||||
- **Structure**: File layouts and headers must follow the "File Structure Standard".
|
||||
- **Workflow**: The technical steps for generating code must align with the Protocol.
|
||||
|
||||
### II. Causal Validity (Contracts First)
|
||||
As defined in the Protocol, Semantic definitions (Contracts) must ALWAYS precede implementation code. Logic is downstream of definition. We define the structure and constraints (`[DEF]`, `@PRE`, `@POST`) before writing the executable logic.
|
||||
|
||||
### III. Immutability of Architecture
|
||||
Architectural decisions in the Module Header (`@LAYER`, `@INVARIANT`, `@CONSTRAINT`) are treated as immutable constraints. Changes to these require an explicit refactoring step, not ad-hoc modification during implementation.
|
||||
|
||||
### IV. Design by Contract (DbC)
|
||||
Contracts are the Source of Truth. Functions and Classes must define their purpose, specifications, and constraints in the metadata block before implementation, strictly following the **Contracts (Section IV)** standard in `semantic_protocol.md`.
|
||||
|
||||
### V. Belief State Logging
|
||||
Agents must maintain belief state logs for debugging and coherence checks, strictly following the **Logging Standard (Section V)** defined in `semantic_protocol.md`.
|
||||
|
||||
### VI. Fractal Complexity Limit
|
||||
To maintain semantic coherence, code must adhere to the complexity limits (Module/Function size) defined in the **Fractal Complexity Limit (Section VI)** of `semantic_protocol.md`.
|
||||
|
||||
### VII. Everything is a Plugin
|
||||
All functional extensions, tools, or major features must be implemented as modular Plugins inheriting from `PluginBase`. Logic should not reside in standalone services or scripts unless strictly necessary for core infrastructure. This ensures a unified execution model via the `TaskManager`, consistent logging, and modularity.
|
||||
|
||||
### VIII. Unified Frontend Experience
|
||||
To ensure a consistent and accessible user experience, all frontend implementations must strictly adhere to the unified design and localization standards.
|
||||
- **Component Reusability**: All UI elements MUST utilize the standardized Svelte component library (`src/lib/ui`) and centralized design tokens. Ad-hoc styling and hardcoded values are prohibited.
|
||||
- **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`). Hardcoded strings in the UI are prohibited.
|
||||
|
||||
## File Structure Standards
|
||||
Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of:
|
||||
- Python Module Headers (`.py`)
|
||||
- Svelte Component Headers (`.svelte`)
|
||||
|
||||
## Generation Workflow
|
||||
The development process follows a streamlined single-phase workflow:
|
||||
|
||||
### 1. Code Generation Phase (Mode: `code`)
|
||||
**Input**: `tasks.md`
|
||||
**Responsibility**:
|
||||
- Select task from `tasks.md`.
|
||||
- Generate Scaffolding (`[DEF]` anchors, Headers, Contracts) AND Implementation in one pass.
|
||||
- Ensure strict adherence to Protocol Section IV (Contracts) and Section VII (Generation Workflow).
|
||||
- **Output**: Working code with passing tests.
|
||||
|
||||
### 2. Validation
|
||||
If logic conflicts with Contract -> Stop -> Report Error.
|
||||
|
||||
## Governance
|
||||
This Constitution establishes the "Semantic Code Generation Protocol" as the supreme law of this repository.
|
||||
|
||||
- **Authoritative Source**: `semantic_protocol.md` defines the specific implementation rules for these Principles.
|
||||
- **Automated Enforcement**: Tools must validate adherence to the `semantic_protocol.md` syntax.
|
||||
- **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
|
||||
- **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
|
||||
|
||||
**Version**: 1.8.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-26
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
|
||||
description: "Architecture task list template (Contracts & Scaffolding)"
|
||||
---
|
||||
|
||||
# Architecture Tasks: [FEATURE NAME]
|
||||
|
||||
**Role**: Architect Agent
|
||||
**Goal**: Define the "What" and "Why" (Contracts, Scaffolding, Models) before implementation.
|
||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||
**Output**: Files with `[DEF]` anchors, `@PRE`/`@POST` contracts, and `@RELATION` mappings. No business logic.
|
||||
|
||||
## Phase 1: Setup & Models
|
||||
|
||||
- [ ] A001 Create/Update data models in [path] with `[DEF]` and contracts
|
||||
- [ ] A002 Define API route structure/contracts in [path]
|
||||
- [ ] A003 Define shared utilities/interfaces
|
||||
|
||||
## Phase 2: User Story 1 - [Title]
|
||||
|
||||
- [ ] A004 [US1] Define contracts for [Component/Service] in [path]
|
||||
- [ ] A005 [US1] Define contracts for [Endpoint] in [path]
|
||||
- [ ] A006 [US1] Define contracts for [Frontend Component] in [path]
|
||||
|
||||
## Phase 3: User Story 2 - [Title]
|
||||
|
||||
- [ ] A007 [US2] Define contracts for [Component/Service] in [path]
|
||||
- [ ] A008 [US2] Define contracts for [Endpoint] in [path]
|
||||
|
||||
## Handover Checklist
|
||||
|
||||
- [ ] All new files created with `[DEF]` anchors
|
||||
- [ ] All functions/classes have `@PURPOSE`, `@PRE`, `@POST` tags
|
||||
- [ ] No "naked code" (logic outside of anchors)
|
||||
- [ ] `tasks-dev.md` is ready for the Developer Agent
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
|
||||
description: "Developer task list template (Implementation Logic)"
|
||||
---
|
||||
|
||||
# Developer Tasks: [FEATURE NAME]
|
||||
|
||||
**Role**: Developer Agent
|
||||
**Goal**: Implement the "How" (Logic, State, Error Handling) inside the defined contracts.
|
||||
**Input**: `tasks-arch.md` (completed), Scaffolding files with `[DEF]` anchors.
|
||||
**Output**: Working code that satisfies `@PRE`/`@POST` conditions.
|
||||
|
||||
## Phase 1: Setup & Models
|
||||
|
||||
- [ ] D001 Implement logic for [Model] in [path]
|
||||
- [ ] D002 Implement logic for [API Route] in [path]
|
||||
- [ ] D003 Implement shared utilities
|
||||
|
||||
## Phase 2: User Story 1 - [Title]
|
||||
|
||||
- [ ] D004 [US1] Implement logic for [Component/Service] in [path]
|
||||
- [ ] D005 [US1] Implement logic for [Endpoint] in [path]
|
||||
- [ ] D006 [US1] Implement logic for [Frontend Component] in [path]
|
||||
- [ ] D007 [US1] Verify semantic compliance and belief state logging
|
||||
|
||||
## Phase 3: User Story 2 - [Title]
|
||||
|
||||
- [ ] D008 [US2] Implement logic for [Component/Service] in [path]
|
||||
- [ ] D009 [US2] Implement logic for [Endpoint] in [path]
|
||||
|
||||
## Polish & Quality Assurance
|
||||
|
||||
- [ ] DXXX Verify all tests pass
|
||||
- [ ] DXXX Check error handling and edge cases
|
||||
- [ ] DXXX Ensure code style compliance
|
||||
@@ -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
|
||||
|
||||
|
||||
152
.specify/templates/test-docs-template.md
Normal file
152
.specify/templates/test-docs-template.md
Normal 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/)
|
||||
67
.specify/templates/ux-reference-template.md
Normal file
67
.specify/templates/ux-reference-template.md
Normal 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"]
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# Stage 1: Build frontend static assets
|
||||
FROM node:20-alpine AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Stage 2: Runtime image for backend + static frontend
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV BACKEND_PORT=8000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt /app/backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
COPY --from=frontend-build /app/frontend/build /app/frontend/build
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
213
README.md
213
README.md
@@ -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/`.
|
||||
|
||||
Submodule backend/backend/git_repos/12 deleted from f46772443a
1
backend/get_full_key.py
Normal file
1
backend/get_full_key.py
Normal file
@@ -0,0 +1 @@
|
||||
{"print(f'Length": {"else": "print('Provider not found')\ndb.close()"}}
|
||||
1
backend/git_repos/12
Submodule
1
backend/git_repos/12
Submodule
Submodule backend/git_repos/12 added at 57ab7e8679
135592
backend/logs/app.log.1
135592
backend/logs/app.log.1
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -25,9 +25,13 @@ 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
|
||||
@@ -44,4 +48,9 @@ websockets==15.0.1
|
||||
pandas
|
||||
psycopg2-binary
|
||||
openpyxl
|
||||
GitPython==3.1.44
|
||||
GitPython==3.1.44
|
||||
itsdangerous
|
||||
email-validator
|
||||
openai
|
||||
playwright
|
||||
tenacity
|
||||
@@ -1,59 +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]
|
||||
|
||||
# [DEF:get_current_user:Function]
|
||||
# @PURPOSE: Dependency to get the current user from the ADFS token.
|
||||
# @PARAM: token (str) - The OAuth2 bearer token.
|
||||
# @PRE: token should be provided via Authorization header.
|
||||
# @POST: Returns user details if authenticated, else raises 401.
|
||||
# @RETURN: Dict[str, str] - User information.
|
||||
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:get_current_user:Function]
|
||||
# [/DEF:AuthModule:Module]
|
||||
# [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]
|
||||
@@ -1 +1,10 @@
|
||||
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage
|
||||
# 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']
|
||||
|
||||
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}")
|
||||
|
||||
286
backend/src/api/routes/__tests__/test_dashboards.py
Normal file
286
backend/src/api/routes/__tests__/test_dashboards.py
Normal file
@@ -0,0 +1,286 @@
|
||||
# [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_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]
|
||||
209
backend/src/api/routes/__tests__/test_datasets.py
Normal file
209
backend/src/api/routes/__tests__/test_datasets.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# [DEF:backend.src.api.routes.__tests__.test_datasets:Module]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Unit tests for Datasets API endpoints
|
||||
# @LAYER: API
|
||||
# @RELATION: TESTS -> backend.src.api.routes.datasets
|
||||
|
||||
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]
|
||||
# @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]
|
||||
310
backend/src/api/routes/admin.py
Normal file
310
backend/src/api/routes/admin.py
Normal 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]
|
||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from ...core.database import get_db
|
||||
from ...models.connection import ConnectionConfig
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from ...core.logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
327
backend/src/api/routes/dashboards.py
Normal file
327
backend/src/api/routes/dashboards.py
Normal file
@@ -0,0 +1,327 @@
|
||||
# [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
|
||||
# [/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: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: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]
|
||||
395
backend/src/api/routes/datasets.py
Normal file
395
backend/src/api/routes/datasets.py
Normal 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]
|
||||
@@ -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,15 +11,14 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List, Dict, Optional
|
||||
from ...dependencies import get_config_manager, get_scheduler_service
|
||||
from typing import List, Optional
|
||||
from ...dependencies import get_config_manager, get_scheduler_service, has_permission
|
||||
from ...core.superset_client import SupersetClient
|
||||
from pydantic import BaseModel, Field
|
||||
from ...core.config_models import Environment as EnvModel
|
||||
from ...core.logger import belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/api/environments", tags=["Environments"])
|
||||
|
||||
# [DEF:ScheduleSchema:DataClass]
|
||||
class ScheduleSchema(BaseModel):
|
||||
@@ -43,11 +43,16 @@ class DatabaseResponse(BaseModel):
|
||||
|
||||
# [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)):
|
||||
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
|
||||
@@ -68,6 +73,8 @@ async def get_environments(config_manager=Depends(get_config_manager)):
|
||||
|
||||
# [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.
|
||||
@@ -77,7 +84,8 @@ async def update_environment_schedule(
|
||||
id: str,
|
||||
schedule: ScheduleSchema,
|
||||
config_manager=Depends(get_config_manager),
|
||||
scheduler_service=Depends(get_scheduler_service)
|
||||
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()
|
||||
@@ -99,12 +107,18 @@ async def update_environment_schedule(
|
||||
|
||||
# [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)):
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# [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
|
||||
@@ -13,19 +14,19 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import typing
|
||||
from src.dependencies import get_config_manager
|
||||
from src.dependencies import get_config_manager, has_permission
|
||||
from src.core.database import get_db
|
||||
from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository
|
||||
from src.models.git import GitServerConfig, GitRepository
|
||||
from src.api.routes.git_schemas import (
|
||||
GitServerConfigSchema, GitServerConfigCreate,
|
||||
GitRepositorySchema, BranchSchema, BranchCreate,
|
||||
BranchSchema, BranchCreate,
|
||||
BranchCheckout, CommitSchema, CommitCreate,
|
||||
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest
|
||||
)
|
||||
from src.services.git_service import GitService
|
||||
from src.core.logger import logger, belief_scope
|
||||
|
||||
router = APIRouter(prefix="/api/git", tags=["git"])
|
||||
router = APIRouter(tags=["git"])
|
||||
git_service = GitService()
|
||||
|
||||
# [DEF:get_git_configs:Function]
|
||||
@@ -34,7 +35,10 @@ git_service = GitService()
|
||||
# @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)):
|
||||
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]
|
||||
@@ -46,7 +50,11 @@ async def get_git_configs(db: Session = Depends(get_db)):
|
||||
# @PARAM: config (GitServerConfigCreate)
|
||||
# @RETURN: GitServerConfigSchema
|
||||
@router.post("/config", response_model=GitServerConfigSchema)
|
||||
async def create_git_config(config: GitServerConfigCreate, db: Session = Depends(get_db)):
|
||||
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)
|
||||
@@ -61,7 +69,11 @@ async def create_git_config(config: GitServerConfigCreate, db: Session = Depends
|
||||
# @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)):
|
||||
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:
|
||||
@@ -78,7 +90,10 @@ async def delete_git_config(config_id: str, db: Session = Depends(get_db)):
|
||||
# @POST: Returns success if the connection is validated via GitService.
|
||||
# @PARAM: config (GitServerConfigCreate)
|
||||
@router.post("/config/test")
|
||||
async def test_git_config(config: GitServerConfigCreate):
|
||||
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:
|
||||
@@ -94,7 +109,12 @@ async def test_git_config(config: GitServerConfigCreate):
|
||||
# @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)):
|
||||
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()
|
||||
@@ -138,7 +158,10 @@ async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Ses
|
||||
# @PARAM: dashboard_id (int)
|
||||
# @RETURN: List[BranchSchema]
|
||||
@router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema])
|
||||
async def get_branches(dashboard_id: int):
|
||||
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)
|
||||
@@ -153,7 +176,11 @@ async def get_branches(dashboard_id: int):
|
||||
# @PARAM: dashboard_id (int)
|
||||
# @PARAM: branch_data (BranchCreate)
|
||||
@router.post("/repositories/{dashboard_id}/branches")
|
||||
async def create_branch(dashboard_id: int, branch_data: BranchCreate):
|
||||
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)
|
||||
@@ -169,7 +196,11 @@ async def create_branch(dashboard_id: int, branch_data: BranchCreate):
|
||||
# @PARAM: dashboard_id (int)
|
||||
# @PARAM: checkout_data (BranchCheckout)
|
||||
@router.post("/repositories/{dashboard_id}/checkout")
|
||||
async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout):
|
||||
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)
|
||||
@@ -185,7 +216,11 @@ async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout):
|
||||
# @PARAM: dashboard_id (int)
|
||||
# @PARAM: commit_data (CommitCreate)
|
||||
@router.post("/repositories/{dashboard_id}/commit")
|
||||
async def commit_changes(dashboard_id: int, commit_data: CommitCreate):
|
||||
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)
|
||||
@@ -200,7 +235,10 @@ async def commit_changes(dashboard_id: int, commit_data: CommitCreate):
|
||||
# @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):
|
||||
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)
|
||||
@@ -215,7 +253,10 @@ async def push_changes(dashboard_id: int):
|
||||
# @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):
|
||||
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)
|
||||
@@ -231,7 +272,11 @@ async def pull_changes(dashboard_id: int):
|
||||
# @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):
|
||||
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
|
||||
@@ -251,7 +296,10 @@ async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str]
|
||||
# @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)):
|
||||
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 [
|
||||
@@ -271,7 +319,11 @@ async def get_environments(config_manager=Depends(get_config_manager)):
|
||||
# @PARAM: dashboard_id (int)
|
||||
# @PARAM: deploy_data (DeployRequest)
|
||||
@router.post("/repositories/{dashboard_id}/deploy")
|
||||
async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest):
|
||||
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
|
||||
@@ -293,7 +345,11 @@ async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest):
|
||||
# @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):
|
||||
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)
|
||||
@@ -308,7 +364,10 @@ async def get_history(dashboard_id: int, limit: int = 50):
|
||||
# @PARAM: dashboard_id (int)
|
||||
# @RETURN: dict
|
||||
@router.get("/repositories/{dashboard_id}/status")
|
||||
async def get_repository_status(dashboard_id: int):
|
||||
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)
|
||||
@@ -325,7 +384,12 @@ async def get_repository_status(dashboard_id: int):
|
||||
# @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):
|
||||
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)
|
||||
@@ -334,4 +398,59 @@ async def get_repository_diff(dashboard_id: int, file_path: Optional[str] = None
|
||||
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]
|
||||
@@ -1,5 +1,6 @@
|
||||
# [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
|
||||
@@ -10,10 +11,10 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from src.models.git import GitProvider, GitStatus, SyncStatus
|
||||
|
||||
# [DEF:GitServerConfigBase:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Base schema for Git server configuration attributes.
|
||||
class GitServerConfigBase(BaseModel):
|
||||
name: str = Field(..., description="Display name for the Git server")
|
||||
|
||||
207
backend/src/api/routes/llm.py
Normal file
207
backend/src/api/routes/llm.py
Normal 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]
|
||||
@@ -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
|
||||
@@ -14,13 +15,13 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from ...core.logger import belief_scope
|
||||
from ...dependencies import get_config_manager
|
||||
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):
|
||||
@@ -30,6 +31,7 @@ class MappingCreate(BaseModel):
|
||||
target_db_uuid: str
|
||||
source_db_name: str
|
||||
target_db_name: str
|
||||
engine: Optional[str] = None
|
||||
# [/DEF:MappingCreate:DataClass]
|
||||
|
||||
# [DEF:MappingResponse:DataClass]
|
||||
@@ -41,6 +43,7 @@ class MappingResponse(BaseModel):
|
||||
target_db_uuid: str
|
||||
source_db_name: str
|
||||
target_db_name: str
|
||||
engine: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -60,7 +63,8 @@ class SuggestRequest(BaseModel):
|
||||
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"))
|
||||
):
|
||||
with belief_scope("get_mappings"):
|
||||
query = db.query(DatabaseMapping)
|
||||
@@ -76,7 +80,11 @@ async def get_mappings(
|
||||
# @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)):
|
||||
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(
|
||||
@@ -88,6 +96,7 @@ async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)):
|
||||
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
|
||||
@@ -106,10 +115,11 @@ async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)):
|
||||
@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"))
|
||||
):
|
||||
with belief_scope("suggest_mappings_api"):
|
||||
from backend.src.services.mapping_service import MappingService
|
||||
from ...services.mapping_service import MappingService
|
||||
service = MappingService(config_manager)
|
||||
try:
|
||||
return await service.get_suggestions(request.source_env_id, request.target_env_id)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# [DEF:backend.src.api.routes.migration:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: api, migration, dashboards
|
||||
# @PURPOSE: API endpoints for migration operations.
|
||||
# @LAYER: API
|
||||
@@ -6,8 +7,8 @@
|
||||
# @RELATION: DEPENDS_ON -> backend.src.models.dashboard
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List, Dict
|
||||
from ...dependencies import get_config_manager, get_task_manager
|
||||
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
|
||||
@@ -21,7 +22,11 @@ router = APIRouter(prefix="/api", tags=["migration"])
|
||||
# @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)):
|
||||
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)
|
||||
@@ -39,8 +44,13 @@ async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)
|
||||
# @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("/migration/execute")
|
||||
async def execute_migration(selection: DashboardSelection, config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager)):
|
||||
@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()
|
||||
|
||||
@@ -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,7 +8,7 @@ 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()
|
||||
@@ -19,7 +20,8 @@ router = APIRouter()
|
||||
# @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"))
|
||||
):
|
||||
with belief_scope("list_plugins"):
|
||||
"""
|
||||
|
||||
@@ -12,15 +12,24 @@
|
||||
# [SECTION: IMPORTS]
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List
|
||||
from ...core.config_models import AppConfig, Environment, GlobalSettings
|
||||
from pydantic import BaseModel
|
||||
from ...core.config_models import AppConfig, Environment, GlobalSettings, LoggingConfig
|
||||
from ...models.storage import StorageConfig
|
||||
from ...dependencies import get_config_manager
|
||||
from ...dependencies import get_config_manager, has_permission
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.superset_client import SupersetClient
|
||||
import os
|
||||
# [/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]
|
||||
@@ -29,7 +38,10 @@ router = APIRouter()
|
||||
# @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)):
|
||||
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)
|
||||
@@ -49,7 +61,8 @@ async def get_settings(config_manager: ConfigManager = Depends(get_config_manage
|
||||
@router.patch("/global", response_model=GlobalSettings)
|
||||
async def update_global_settings(
|
||||
settings: GlobalSettings,
|
||||
config_manager: ConfigManager = Depends(get_config_manager)
|
||||
config_manager: ConfigManager = Depends(get_config_manager),
|
||||
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||
):
|
||||
with belief_scope("update_global_settings"):
|
||||
logger.info("[update_global_settings][Entry] Updating global settings")
|
||||
@@ -62,7 +75,10 @@ async def update_global_settings(
|
||||
# @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)):
|
||||
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]
|
||||
@@ -73,7 +89,11 @@ async def get_storage_settings(config_manager: ConfigManager = Depends(get_confi
|
||||
# @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)):
|
||||
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:
|
||||
@@ -91,7 +111,10 @@ async def update_storage_settings(storage: StorageConfig, config_manager: Config
|
||||
# @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)):
|
||||
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()
|
||||
@@ -106,7 +129,8 @@ async def get_environments(config_manager: ConfigManager = Depends(get_config_ma
|
||||
@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"))
|
||||
):
|
||||
with belief_scope("add_environment"):
|
||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||
@@ -208,5 +232,145 @@ async def test_environment_connection(
|
||||
return {"status": "error", "message": str(e)}
|
||||
# [/DEF:test_environment_connection:Function]
|
||||
|
||||
# [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"))
|
||||
):
|
||||
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: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]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# [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
|
||||
@@ -8,11 +9,12 @@
|
||||
# @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
|
||||
from ...dependencies import get_plugin_loader, has_permission
|
||||
from ...plugins.storage.plugin import StoragePlugin
|
||||
from ...core.logger import belief_scope
|
||||
# [/SECTION]
|
||||
@@ -34,7 +36,8 @@ router = APIRouter(tags=["storage"])
|
||||
async def list_files(
|
||||
category: Optional[FileCategory] = None,
|
||||
path: Optional[str] = None,
|
||||
plugin_loader=Depends(get_plugin_loader)
|
||||
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")
|
||||
@@ -63,7 +66,8 @@ async def upload_file(
|
||||
category: FileCategory = Form(...),
|
||||
path: Optional[str] = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
plugin_loader=Depends(get_plugin_loader)
|
||||
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")
|
||||
@@ -89,7 +93,12 @@ async def upload_file(
|
||||
#
|
||||
# @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)):
|
||||
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:
|
||||
@@ -114,7 +123,12 @@ async def delete_file(category: FileCategory, path: str, plugin_loader=Depends(g
|
||||
#
|
||||
# @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)):
|
||||
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:
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# [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, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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, TaskStatus, LogEntry
|
||||
from ...dependencies import get_task_manager
|
||||
from ...core.task_manager.models import LogFilter, LogStats
|
||||
from ...dependencies import get_task_manager, has_permission, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -33,13 +35,31 @@ class ResumeTaskRequest(BaseModel):
|
||||
# @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.
|
||||
"""
|
||||
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()
|
||||
|
||||
task = await task_manager.create_task(
|
||||
plugin_id=request.plugin_id,
|
||||
params=request.params
|
||||
@@ -63,7 +83,8 @@ async def list_tasks(
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
status: Optional[TaskStatus] = None,
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_ = Depends(has_permission("tasks", "READ"))
|
||||
):
|
||||
"""
|
||||
Retrieve a list of tasks with pagination and optional status filter.
|
||||
@@ -82,7 +103,8 @@ async def list_tasks(
|
||||
# @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.
|
||||
@@ -96,26 +118,93 @@ async def get_task(
|
||||
|
||||
@router.get("/{task_id}/logs", response_model=List[LogEntry])
|
||||
# [DEF:get_task_logs:Function]
|
||||
# @PURPOSE: Retrieve logs for a specific task.
|
||||
# @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,
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
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.
|
||||
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")
|
||||
return task_manager.get_task_logs(task_id)
|
||||
|
||||
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.
|
||||
@@ -128,7 +217,8 @@ async def get_task_logs(
|
||||
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.
|
||||
@@ -153,7 +243,8 @@ async def resolve_task(
|
||||
async def resume_task(
|
||||
task_id: str,
|
||||
request: ResumeTaskRequest,
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
task_manager: TaskManager = Depends(get_task_manager),
|
||||
_ = Depends(has_permission("tasks", "WRITE"))
|
||||
):
|
||||
"""
|
||||
Resume a task that is awaiting input (e.g., passwords).
|
||||
@@ -175,7 +266,8 @@ async def resume_task(
|
||||
# @POST: Tasks are removed from memory/persistence.
|
||||
async def clear_tasks(
|
||||
status: Optional[TaskStatus] = None,
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
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.
|
||||
|
||||
@@ -1,25 +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
|
||||
|
||||
# project_root is used for static files mounting
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from .dependencies import get_task_manager, get_scheduler_service
|
||||
from .core.utils.network import NetworkError
|
||||
from .core.logger import logger, belief_scope
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage
|
||||
from .core.database import init_db
|
||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin, llm, dashboards, datasets
|
||||
from .api import auth
|
||||
|
||||
# [DEF:App:Global]
|
||||
# @SEMANTICS: app, fastapi, instance
|
||||
@@ -55,6 +58,10 @@ async def shutdown_event():
|
||||
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(
|
||||
CORSMiddleware,
|
||||
@@ -71,47 +78,119 @@ app.add_middleware(
|
||||
# @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):
|
||||
with belief_scope("log_requests", f"{request.method} {request.url.path}"):
|
||||
logger.info(f"[DEBUG] Incoming request: {request.method} {request.url.path}")
|
||||
# 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)
|
||||
logger.info(f"[DEBUG] Response status: {response.status_code} for {request.url.path}")
|
||||
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(connections.router, prefix="/api/settings/connections", tags=["Connections"])
|
||||
app.include_router(environments.router, prefix="/api/environments", tags=["Environments"])
|
||||
app.include_router(mappings.router)
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
# [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.
|
||||
# @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):
|
||||
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()
|
||||
logger.info(f"WebSocket connection accepted for task {task_id}")
|
||||
|
||||
# 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:
|
||||
# Stream new logs
|
||||
logger.info(f"Starting log stream for task {task_id}")
|
||||
|
||||
# Send initial logs first to build context
|
||||
# Send initial logs first to build context (apply filters)
|
||||
initial_logs = task_manager.get_task_logs(task_id)
|
||||
for log_entry in initial_logs:
|
||||
log_dict = log_entry.dict()
|
||||
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
|
||||
await websocket.send_json(log_dict)
|
||||
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.
|
||||
@@ -129,6 +208,11 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str):
|
||||
|
||||
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)
|
||||
@@ -156,25 +240,24 @@ 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
|
||||
|
||||
# [DEF:serve_spa:Function]
|
||||
# @PURPOSE: Serves frontend static files or index.html for SPA routing.
|
||||
# @PRE: file_path is requested by the client.
|
||||
# @POST: Returns the requested file or index.html as a fallback.
|
||||
@app.get("/{file_path:path}")
|
||||
# @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):
|
||||
with belief_scope("serve_spa", f"path={file_path}"):
|
||||
# Don't serve SPA for API routes that fell through
|
||||
if file_path.startswith("api/"):
|
||||
logger.info(f"[DEBUG] API route fell through to serve_spa: {file_path}")
|
||||
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
|
||||
|
||||
full_path = frontend_path / file_path
|
||||
if full_path.is_file():
|
||||
return FileResponse(str(full_path))
|
||||
# Fallback to index.html for SPA routing
|
||||
return FileResponse(str(frontend_path / "index.html"))
|
||||
# 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 file_path and full_path.is_file():
|
||||
return FileResponse(str(full_path))
|
||||
return FileResponse(str(frontend_path / "index.html"))
|
||||
# [/DEF:serve_spa:Function]
|
||||
else:
|
||||
# [DEF:read_root:Function]
|
||||
|
||||
179
backend/src/core/auth/__tests__/test_auth.py
Normal file
179
backend/src/core/auth/__tests__/test_auth.py
Normal 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]
|
||||
47
backend/src/core/auth/config.py
Normal file
47
backend/src/core/auth/config.py
Normal 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]
|
||||
55
backend/src/core/auth/jwt.py
Normal file
55
backend/src/core/auth/jwt.py
Normal 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]
|
||||
32
backend/src/core/auth/logger.py
Normal file
32
backend/src/core/auth/logger.py
Normal 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]
|
||||
51
backend/src/core/auth/oauth.py
Normal file
51
backend/src/core/auth/oauth.py
Normal 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]
|
||||
123
backend/src/core/auth/repository.py
Normal file
123
backend/src/core/auth/repository.py
Normal 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]
|
||||
42
backend/src/core/auth/security.py
Normal file
42
backend/src/core/auth/security.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# [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]
|
||||
from passlib.context import CryptContext
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:pwd_context:Variable]
|
||||
# @PURPOSE: Passlib CryptContext for password management.
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
# [/DEF:pwd_context:Variable]
|
||||
|
||||
# [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:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
# [/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 pwd_context.hash(password)
|
||||
# [/DEF:get_password_hash:Function]
|
||||
|
||||
# [/DEF:backend.src.core.auth.security:Module]
|
||||
567
backend/src/core/config_manager.py
Executable file → Normal file
567
backend/src/core/config_manager.py
Executable file → Normal file
@@ -1,284 +1,283 @@
|
||||
# [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, configure_logger, belief_scope
|
||||
# [/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"):
|
||||
with belief_scope("__init__"):
|
||||
# 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()
|
||||
|
||||
# Configure logger with loaded settings
|
||||
configure_logger(self.config.settings.logging)
|
||||
|
||||
# 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__:Function]
|
||||
|
||||
# [DEF:_load_config:Function]
|
||||
# @PURPOSE: Loads the configuration from disk or creates a default one.
|
||||
# @PRE: self.config_path is set.
|
||||
# @POST: isinstance(return, AppConfig)
|
||||
# @RETURN: AppConfig - The loaded or default configuration.
|
||||
def _load_config(self) -> AppConfig:
|
||||
with belief_scope("_load_config"):
|
||||
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()
|
||||
)
|
||||
self._save_config_to_disk(default_config)
|
||||
return default_config
|
||||
try:
|
||||
with open(self.config_path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check for deprecated field
|
||||
if "settings" in data and "backup_path" in data["settings"]:
|
||||
del data["settings"]["backup_path"]
|
||||
|
||||
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}")
|
||||
# Fallback but try to preserve existing settings if possible?
|
||||
# For now, return default to be safe, but log the error prominently.
|
||||
return AppConfig(
|
||||
environments=[],
|
||||
settings=GlobalSettings(storage=StorageConfig())
|
||||
)
|
||||
# [/DEF:_load_config:Function]
|
||||
|
||||
# [DEF:_save_config_to_disk:Function]
|
||||
# @PURPOSE: Saves the provided configuration object to disk.
|
||||
# @PRE: isinstance(config, AppConfig)
|
||||
# @POST: Configuration saved to disk.
|
||||
# @PARAM: config (AppConfig) - The configuration to save.
|
||||
def _save_config_to_disk(self, config: AppConfig):
|
||||
with belief_scope("_save_config_to_disk"):
|
||||
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:Function]
|
||||
|
||||
# [DEF:save:Function]
|
||||
# @PURPOSE: Saves the current configuration state to disk.
|
||||
# @PRE: self.config is set.
|
||||
# @POST: self._save_config_to_disk called.
|
||||
def save(self):
|
||||
with belief_scope("save"):
|
||||
self._save_config_to_disk(self.config)
|
||||
# [/DEF:save:Function]
|
||||
|
||||
# [DEF:get_config:Function]
|
||||
# @PURPOSE: Returns the current configuration.
|
||||
# @PRE: self.config is set.
|
||||
# @POST: Returns self.config.
|
||||
# @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(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()
|
||||
|
||||
# Reconfigure logger with new settings
|
||||
configure_logger(settings.logging)
|
||||
|
||||
logger.info(f"[update_global_settings][Exit] Settings updated")
|
||||
# [/DEF:update_global_settings:Function]
|
||||
|
||||
# [DEF:validate_path:Function]
|
||||
# @PURPOSE: Validates if a path exists and is writable.
|
||||
# @PRE: path is a string.
|
||||
# @POST: Returns (bool, str) status.
|
||||
# @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.
|
||||
# @PRE: self.config is set.
|
||||
# @POST: Returns list of 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.
|
||||
# @PRE: self.config is set.
|
||||
# @POST: Returns boolean indicating if environments exist.
|
||||
# @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.
|
||||
# @PRE: self.config is set and isinstance(env_id, str) and len(env_id) > 0.
|
||||
# @POST: Returns Environment object if found, None otherwise.
|
||||
# @PARAM: env_id (str) - The ID of the environment to retrieve.
|
||||
# @RETURN: Optional[Environment] - The environment with the given ID, or None.
|
||||
def get_environment(self, env_id: str) -> Optional[Environment]:
|
||||
with belief_scope("get_environment"):
|
||||
for env in self.config.environments:
|
||||
if env.id == env_id:
|
||||
return env
|
||||
return None
|
||||
# [/DEF:get_environment:Function]
|
||||
|
||||
# [DEF:add_environment:Function]
|
||||
# @PURPOSE: Adds a new environment to the configuration.
|
||||
# @PRE: isinstance(env, Environment)
|
||||
# @POST: Environment added or updated in self.config.environments.
|
||||
# @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}")
|
||||
|
||||
# 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:Function]
|
||||
|
||||
# [DEF:update_environment:Function]
|
||||
# @PURPOSE: Updates an existing environment.
|
||||
# @PRE: isinstance(env_id, str) and len(env_id) > 0 and isinstance(updated_env, Environment)
|
||||
# @POST: Returns True if environment was found and updated.
|
||||
# @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}")
|
||||
|
||||
# 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:Function]
|
||||
|
||||
# [DEF:delete_environment:Function]
|
||||
# @PURPOSE: Deletes an environment by ID.
|
||||
# @PRE: isinstance(env_id, str) and len(env_id) > 0
|
||||
# @POST: Environment removed from self.config.environments if it existed.
|
||||
# @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}")
|
||||
|
||||
# 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:Function]
|
||||
|
||||
# [/DEF:ConfigManager:Class]
|
||||
|
||||
# [/DEF:ConfigManagerModule:Module]
|
||||
# [DEF:ConfigManagerModule:Module]
|
||||
#
|
||||
# @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]
|
||||
# @PURPOSE: A class to handle application configuration persistence and management.
|
||||
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 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]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# [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
|
||||
@@ -32,9 +33,10 @@ class Environment(BaseModel):
|
||||
|
||||
# [DEF:LoggingConfig:DataClass]
|
||||
# @PURPOSE: Defines the configuration for the application's logging system.
|
||||
class LoggingConfig(BaseModel):
|
||||
level: str = "INFO"
|
||||
file_path: Optional[str] = "logs/app.log"
|
||||
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
|
||||
@@ -46,6 +48,8 @@ class GlobalSettings(BaseModel):
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
default_environment_id: Optional[str] = None
|
||||
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
|
||||
|
||||
@@ -1,59 +1,103 @@
|
||||
# [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 sqlalchemy.orm import sessionmaker
|
||||
from ..models.mapping import Base
|
||||
# Import models to ensure they're registered with Base
|
||||
from ..models.task import TaskRecord
|
||||
from ..models.connection import ConnectionConfig
|
||||
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
|
||||
from ..models 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 .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")
|
||||
# @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]
|
||||
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", "sqlite:///./tasks.db")
|
||||
# @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 _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]
|
||||
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
# @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: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 and tasks_engine are initialized.
|
||||
# @POST: Database tables created.
|
||||
# @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():
|
||||
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]
|
||||
@@ -84,4 +128,18 @@ def get_tasks_db():
|
||||
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]
|
||||
|
||||
@@ -19,6 +19,9 @@ _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):
|
||||
@@ -58,12 +61,12 @@ class LogEntry(BaseModel):
|
||||
# @SEMANTICS: logging, context, belief_state
|
||||
@contextmanager
|
||||
def belief_scope(anchor_id: str, message: str = ""):
|
||||
# Log Entry if enabled
|
||||
# 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.info(entry_msg)
|
||||
logger.debug(entry_msg)
|
||||
|
||||
# Set thread-local anchor_id
|
||||
old_anchor = getattr(_belief_state, 'anchor_id', None)
|
||||
@@ -71,13 +74,13 @@ def belief_scope(anchor_id: str, message: str = ""):
|
||||
|
||||
try:
|
||||
yield
|
||||
# Log Coherence OK and Exit
|
||||
logger.info(f"[{anchor_id}][Coherence:OK]")
|
||||
# Log Coherence OK and Exit (DEBUG level to reduce noise)
|
||||
logger.debug(f"[{anchor_id}][Coherence:OK]")
|
||||
if _enable_belief_state:
|
||||
logger.info(f"[{anchor_id}][Exit]")
|
||||
logger.debug(f"[{anchor_id}][Exit]")
|
||||
except Exception as e:
|
||||
# Log Coherence Failed
|
||||
logger.info(f"[{anchor_id}][Coherence:Failed] {str(e)}")
|
||||
# Log Coherence Failed (DEBUG level to reduce noise)
|
||||
logger.debug(f"[{anchor_id}][Coherence:Failed] {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
# Restore old anchor
|
||||
@@ -88,12 +91,13 @@ def belief_scope(anchor_id: str, message: str = ""):
|
||||
# [DEF:configure_logger:Function]
|
||||
# @PURPOSE: Configures the logger with the provided logging settings.
|
||||
# @PRE: config is a valid LoggingConfig instance.
|
||||
# @POST: Logger level, handlers, and belief state flag are updated.
|
||||
# @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
|
||||
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)
|
||||
@@ -107,7 +111,6 @@ def configure_logger(config):
|
||||
|
||||
# Add file handler if file_path is set
|
||||
if config.file_path:
|
||||
import os
|
||||
from pathlib import Path
|
||||
log_file = Path(config.file_path)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -130,6 +133,36 @@ def configure_logger(config):
|
||||
))
|
||||
# [/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
|
||||
# @PURPOSE: A custom logging handler that captures log records into a buffer. It is designed to be extended for real-time log streaming over WebSockets.
|
||||
|
||||
228
backend/src/core/logger/__tests__/test_logger.py
Normal file
228
backend/src/core/logger/__tests__/test_logger.py
Normal 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]
|
||||
@@ -11,12 +11,10 @@
|
||||
import zipfile
|
||||
import yaml
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
from .logger import logger, belief_scope
|
||||
import yaml
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:MigrationEngine:Class]
|
||||
|
||||
@@ -68,6 +68,18 @@ class PluginBase(ABC):
|
||||
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.
|
||||
|
||||
@@ -1,12 +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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# [DEF:SchedulerModule:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: scheduler, apscheduler, cron, backup
|
||||
# @PURPOSE: Manages scheduled tasks using APScheduler.
|
||||
# @LAYER: Core
|
||||
@@ -9,11 +10,11 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from .logger import logger, belief_scope
|
||||
from .config_manager import ConfigManager
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:SchedulerService:Class]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: scheduler, service, apscheduler
|
||||
# @PURPOSE: Provides a service to manage scheduled backup tasks.
|
||||
class SchedulerService:
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union, cast
|
||||
from typing import Dict, List, Optional, Tuple, Union, cast
|
||||
from requests import Response
|
||||
from .logger import logger as app_logger, belief_scope
|
||||
from .utils.network import APIClient, SupersetAPIError, AuthenticationError, DashboardNotFoundError, NetworkError
|
||||
from .utils.network import APIClient, SupersetAPIError
|
||||
from .utils.fileio import get_filename_from_headers
|
||||
from .config_models import Environment
|
||||
# [/SECTION]
|
||||
@@ -87,11 +87,11 @@ class SupersetClient:
|
||||
if 'columns' not in validated_query:
|
||||
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"]
|
||||
|
||||
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
|
||||
paginated_data = self._fetch_all_pages(
|
||||
endpoint="/dashboard/",
|
||||
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
|
||||
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]
|
||||
@@ -203,15 +203,121 @@ class SupersetClient:
|
||||
app_logger.info("[get_datasets][Enter] Fetching datasets.")
|
||||
validated_query = self._validate_query_params(query)
|
||||
|
||||
total_count = self._fetch_total_object_count(endpoint="/dataset/")
|
||||
paginated_data = self._fetch_all_pages(
|
||||
endpoint="/dataset/",
|
||||
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
|
||||
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}"):
|
||||
# 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:
|
||||
column_info.append({
|
||||
"id": col.get("id"),
|
||||
"name": col.get("column_name"),
|
||||
"type": col.get("type"),
|
||||
"is_dttm": col.get("is_dttm", False),
|
||||
"is_active": col.get("is_active", 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:
|
||||
linked_dashboards.append({
|
||||
"id": dash.get("id"),
|
||||
"title": dash.get("dashboard_title") or dash.get("title", "Unknown"),
|
||||
"slug": dash.get("slug")
|
||||
})
|
||||
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"),
|
||||
"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": dataset.get("is_sqllab_view", 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 датасета.
|
||||
@@ -264,11 +370,12 @@ class SupersetClient:
|
||||
validated_query = self._validate_query_params(query or {})
|
||||
if 'columns' not in validated_query:
|
||||
validated_query['columns'] = []
|
||||
total_count = self._fetch_total_object_count(endpoint="/database/")
|
||||
|
||||
paginated_data = self._fetch_all_pages(
|
||||
endpoint="/database/",
|
||||
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
|
||||
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]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# [DEF:TaskManagerPackage:Module]
|
||||
# @TIER: TRIVIAL
|
||||
# @SEMANTICS: task, manager, package, exports
|
||||
# @PURPOSE: Exports the public API of the task manager package.
|
||||
# @LAYER: Core
|
||||
|
||||
@@ -1,47 +1,75 @@
|
||||
# [DEF:TaskCleanupModule:Module]
|
||||
# @SEMANTICS: task, cleanup, retention
|
||||
# @PURPOSE: Implements task cleanup and retention policies.
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: task, cleanup, retention, logs
|
||||
# @PURPOSE: Implements task cleanup and retention policies, including associated logs.
|
||||
# @LAYER: Core
|
||||
# @RELATION: Uses TaskPersistenceService to delete old tasks.
|
||||
# @RELATION: Uses TaskPersistenceService and TaskLogPersistenceService to delete old tasks and logs.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from .persistence import TaskPersistenceService
|
||||
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.
|
||||
# @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, config_manager: ConfigManager):
|
||||
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.
|
||||
# @PURPOSE: Deletes tasks older than the configured retention period and their logs.
|
||||
# @PRE: Config manager has valid settings.
|
||||
# @POST: Old tasks are deleted from persistence.
|
||||
# @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
|
||||
|
||||
# This is a simplified implementation.
|
||||
# In a real scenario, we would query IDs of tasks older than retention_days.
|
||||
# For now, we'll log the action.
|
||||
logger.info(f"Cleaning up tasks older than {retention_days} days.")
|
||||
|
||||
# Re-loading tasks to check for limit
|
||||
# Load tasks to check for limit
|
||||
tasks = self.persistence_service.load_tasks(limit=1000)
|
||||
if len(tasks) > settings.task_retention_limit:
|
||||
to_delete = [t.id for t in 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 exceeding limit of {settings.task_retention_limit}")
|
||||
|
||||
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]
|
||||
115
backend/src/core/task_manager/context.py
Normal file
115
backend/src/core/task_manager/context.py
Normal 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]
|
||||
@@ -8,23 +8,33 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import asyncio
|
||||
import threading
|
||||
import inspect
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from .models import Task, TaskStatus, LogEntry
|
||||
from .persistence import TaskPersistenceService
|
||||
from ..logger import logger, belief_scope
|
||||
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.
|
||||
@@ -35,8 +45,18 @@ class TaskManager:
|
||||
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.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()
|
||||
@@ -47,6 +67,59 @@ class TaskManager:
|
||||
# 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.
|
||||
@@ -63,7 +136,7 @@ class TaskManager:
|
||||
logger.error(f"Plugin with ID '{plugin_id}' not found.")
|
||||
raise ValueError(f"Plugin with ID '{plugin_id}' not found.")
|
||||
|
||||
plugin = self.plugin_loader.get_plugin(plugin_id)
|
||||
self.plugin_loader.get_plugin(plugin_id)
|
||||
|
||||
if not isinstance(params, dict):
|
||||
logger.error("Task parameters must be a dictionary.")
|
||||
@@ -78,7 +151,7 @@ class TaskManager:
|
||||
# [/DEF:create_task:Function]
|
||||
|
||||
# [DEF:_run_task:Function]
|
||||
# @PURPOSE: Internal method to execute a task.
|
||||
# @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.
|
||||
@@ -91,30 +164,54 @@ class TaskManager:
|
||||
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}'")
|
||||
self._add_log(task_id, "INFO", f"Task started for plugin '{plugin.name}'", source="system")
|
||||
|
||||
try:
|
||||
# Execute plugin
|
||||
# Prepare params and check if plugin supports new TaskContext
|
||||
params = {**task.params, "_task_id": task_id}
|
||||
|
||||
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
|
||||
# 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}'")
|
||||
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}", {"error_type": type(e).__name__})
|
||||
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]
|
||||
@@ -151,7 +248,8 @@ class TaskManager:
|
||||
async def wait_for_resolution(self, task_id: str):
|
||||
with belief_scope("TaskManager.wait_for_resolution", f"task_id={task_id}"):
|
||||
task = self.tasks.get(task_id)
|
||||
if not task: return
|
||||
if not task:
|
||||
return
|
||||
|
||||
task.status = TaskStatus.AWAITING_MAPPING
|
||||
self.persistence_service.persist_task(task)
|
||||
@@ -172,7 +270,8 @@ class TaskManager:
|
||||
async def wait_for_input(self, task_id: str):
|
||||
with belief_scope("TaskManager.wait_for_input", f"task_id={task_id}"):
|
||||
task = self.tasks.get(task_id)
|
||||
if not task: return
|
||||
if not task:
|
||||
return
|
||||
|
||||
# Status is already set to AWAITING_INPUT by await_input()
|
||||
self.task_futures[task_id] = self.loop.create_future()
|
||||
@@ -224,36 +323,106 @@ class TaskManager:
|
||||
# [/DEF:get_tasks:Function]
|
||||
|
||||
# [DEF:get_task_logs:Function]
|
||||
# @PURPOSE: Retrieves logs for a specific task.
|
||||
# @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 objects.
|
||||
# @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) -> List[LogEntry]:
|
||||
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 and notifies subscribers.
|
||||
# @PURPOSE: Adds a log entry to a task buffer and notifies subscribers.
|
||||
# @PRE: Task exists.
|
||||
# @POST: Log added to task and pushed to queues.
|
||||
# @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: context (Optional[Dict]) - Log context.
|
||||
def _add_log(self, task_id: str, level: str, message: str, context: Optional[Dict[str, Any]] = None):
|
||||
# @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
|
||||
|
||||
log_entry = LogEntry(level=level, message=message, context=context)
|
||||
task.logs.append(log_entry)
|
||||
self.persistence_service.persist_task(task)
|
||||
# Filter logs based on task_log_level configuration
|
||||
if not should_log_task_level(level):
|
||||
return
|
||||
|
||||
# Notify subscribers
|
||||
# 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)
|
||||
@@ -353,7 +522,7 @@ class TaskManager:
|
||||
# [/DEF:resume_task_with_password:Function]
|
||||
|
||||
# [DEF:clear_tasks:Function]
|
||||
# @PURPOSE: Clears tasks based on status filter.
|
||||
# @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.
|
||||
@@ -387,9 +556,13 @@ class TaskManager:
|
||||
|
||||
del self.tasks[tid]
|
||||
|
||||
# Remove from persistence
|
||||
# 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]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# [DEF:TaskManagerModels:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: task, models, pydantic, enum, state
|
||||
# @PURPOSE: Defines the data models and enumerations used by the Task Manager.
|
||||
# @LAYER: Core
|
||||
@@ -16,6 +17,7 @@ from pydantic import BaseModel, Field
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:TaskStatus:Enum]
|
||||
# @TIER: TRIVIAL
|
||||
# @SEMANTICS: task, status, state, enum
|
||||
# @PURPOSE: Defines the possible states a task can be in during its lifecycle.
|
||||
class TaskStatus(str, Enum):
|
||||
@@ -27,17 +29,73 @@ class TaskStatus(str, Enum):
|
||||
AWAITING_INPUT = "AWAITING_INPUT"
|
||||
# [/DEF:TaskStatus:Enum]
|
||||
|
||||
# [DEF:LogLevel:Enum]
|
||||
# @SEMANTICS: log, level, severity, enum
|
||||
# @PURPOSE: Defines the possible log levels for task logging.
|
||||
# @TIER: STANDARD
|
||||
class LogLevel(str, Enum):
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
# [/DEF:LogLevel:Enum]
|
||||
|
||||
# [DEF:LogEntry:Class]
|
||||
# @SEMANTICS: log, entry, record, pydantic
|
||||
# @PURPOSE: A Pydantic model representing a single, structured log entry associated with a task.
|
||||
# @TIER: CRITICAL
|
||||
# @INVARIANT: Each log entry has a unique timestamp and source.
|
||||
class LogEntry(BaseModel):
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
level: str
|
||||
level: str = Field(default="INFO")
|
||||
message: str
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
source: str = Field(default="system") # Component attribution: plugin, superset_api, git, etc.
|
||||
context: Optional[Dict[str, Any]] = None # Legacy field, kept for backward compatibility
|
||||
metadata: Optional[Dict[str, Any]] = None # Structured metadata (e.g., dashboard_id, progress)
|
||||
# [/DEF:LogEntry:Class]
|
||||
|
||||
# [DEF:TaskLog:Class]
|
||||
# @SEMANTICS: task, log, persistent, pydantic
|
||||
# @PURPOSE: A Pydantic model representing a persisted log entry from the database.
|
||||
# @TIER: STANDARD
|
||||
# @RELATION: MAPS_TO -> TaskLogRecord
|
||||
class TaskLog(BaseModel):
|
||||
id: int
|
||||
task_id: str
|
||||
timestamp: datetime
|
||||
level: str
|
||||
source: str
|
||||
message: str
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# [/DEF:TaskLog:Class]
|
||||
|
||||
# [DEF:LogFilter:Class]
|
||||
# @SEMANTICS: log, filter, query, pydantic
|
||||
# @PURPOSE: Filter parameters for querying task logs.
|
||||
# @TIER: STANDARD
|
||||
class LogFilter(BaseModel):
|
||||
level: Optional[str] = None # Filter by log level
|
||||
source: Optional[str] = None # Filter by source component
|
||||
search: Optional[str] = None # Text search in message
|
||||
offset: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=100, ge=1, le=1000)
|
||||
# [/DEF:LogFilter:Class]
|
||||
|
||||
# [DEF:LogStats:Class]
|
||||
# @SEMANTICS: log, stats, aggregation, pydantic
|
||||
# @PURPOSE: Statistics about log entries for a task.
|
||||
# @TIER: STANDARD
|
||||
class LogStats(BaseModel):
|
||||
total_count: int
|
||||
by_level: Dict[str, int] # {"INFO": 10, "ERROR": 2}
|
||||
by_source: Dict[str, int] # {"plugin": 5, "superset_api": 7}
|
||||
# [/DEF:LogStats:Class]
|
||||
|
||||
# [DEF:Task:Class]
|
||||
# @TIER: STANDARD
|
||||
# @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):
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from typing import List, Optional
|
||||
import json
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from ...models.task import TaskRecord
|
||||
from ...models.task import TaskRecord, TaskLogRecord
|
||||
from ..database import TasksSessionLocal
|
||||
from .models import Task, TaskStatus, LogEntry
|
||||
from .models import Task, TaskStatus, LogEntry, TaskLog, LogFilter, LogStats
|
||||
from ..logger import logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
@@ -36,6 +36,7 @@ class TaskPersistenceService:
|
||||
# @PRE: isinstance(task, Task)
|
||||
# @POST: Task record created or updated in database.
|
||||
# @PARAM: task (Task) - The task object to persist.
|
||||
# @SIDE_EFFECT: Writes to task_records table in tasks.db
|
||||
def persist_task(self, task: Task) -> None:
|
||||
with belief_scope("TaskPersistenceService.persist_task", f"task_id={task.id}"):
|
||||
session: Session = TasksSessionLocal()
|
||||
@@ -50,8 +51,19 @@ class TaskPersistenceService:
|
||||
record.environment_id = task.params.get("environment_id") or task.params.get("source_env_id")
|
||||
record.started_at = task.started_at
|
||||
record.finished_at = task.finished_at
|
||||
record.params = task.params
|
||||
record.result = task.result
|
||||
|
||||
# Ensure params and result are JSON serializable
|
||||
def json_serializable(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: json_serializable(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [json_serializable(v) for v in obj]
|
||||
elif isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return obj
|
||||
|
||||
record.params = json_serializable(task.params)
|
||||
record.result = json_serializable(task.result)
|
||||
|
||||
# Store logs as JSON, converting datetime to string
|
||||
record.logs = []
|
||||
@@ -59,6 +71,9 @@ class TaskPersistenceService:
|
||||
log_dict = log.dict()
|
||||
if isinstance(log_dict.get('timestamp'), datetime):
|
||||
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
|
||||
# Also clean up any datetimes in context
|
||||
if log_dict.get('context'):
|
||||
log_dict['context'] = json_serializable(log_dict['context'])
|
||||
record.logs.append(log_dict)
|
||||
|
||||
# Extract error if failed
|
||||
@@ -155,4 +170,215 @@ class TaskPersistenceService:
|
||||
# [/DEF:delete_tasks:Function]
|
||||
|
||||
# [/DEF:TaskPersistenceService:Class]
|
||||
|
||||
# [DEF:TaskLogPersistenceService:Class]
|
||||
# @SEMANTICS: persistence, service, database, log, sqlalchemy
|
||||
# @PURPOSE: Provides methods to save and query task logs from the task_logs table.
|
||||
# @TIER: CRITICAL
|
||||
# @RELATION: DEPENDS_ON -> TaskLogRecord
|
||||
# @INVARIANT: Log entries are batch-inserted for performance.
|
||||
class TaskLogPersistenceService:
|
||||
"""
|
||||
Service for persisting and querying task logs.
|
||||
Supports batch inserts, filtering, and statistics.
|
||||
"""
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initialize the log persistence service.
|
||||
# @POST: Service is ready.
|
||||
def __init__(self):
|
||||
pass
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:add_logs:Function]
|
||||
# @PURPOSE: Batch insert log entries for a task.
|
||||
# @PRE: logs is a list of LogEntry objects.
|
||||
# @POST: All logs inserted into task_logs table.
|
||||
# @PARAM: task_id (str) - The task ID.
|
||||
# @PARAM: logs (List[LogEntry]) - Log entries to insert.
|
||||
# @SIDE_EFFECT: Writes to task_logs table.
|
||||
def add_logs(self, task_id: str, logs: List[LogEntry]) -> None:
|
||||
if not logs:
|
||||
return
|
||||
with belief_scope("TaskLogPersistenceService.add_logs", f"task_id={task_id}"):
|
||||
session: Session = TasksSessionLocal()
|
||||
try:
|
||||
for log in logs:
|
||||
record = TaskLogRecord(
|
||||
task_id=task_id,
|
||||
timestamp=log.timestamp,
|
||||
level=log.level,
|
||||
source=log.source or "system",
|
||||
message=log.message,
|
||||
metadata_json=json.dumps(log.metadata) if log.metadata else None
|
||||
)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(f"Failed to add logs for task {task_id}: {e}")
|
||||
finally:
|
||||
session.close()
|
||||
# [/DEF:add_logs:Function]
|
||||
|
||||
# [DEF:get_logs:Function]
|
||||
# @PURPOSE: Query logs for a task with filtering and pagination.
|
||||
# @PRE: task_id is a valid task ID.
|
||||
# @POST: Returns list of TaskLog objects matching filters.
|
||||
# @PARAM: task_id (str) - The task ID.
|
||||
# @PARAM: log_filter (LogFilter) - Filter parameters.
|
||||
# @RETURN: List[TaskLog] - Filtered log entries.
|
||||
def get_logs(self, task_id: str, log_filter: LogFilter) -> List[TaskLog]:
|
||||
with belief_scope("TaskLogPersistenceService.get_logs", f"task_id={task_id}"):
|
||||
session: Session = TasksSessionLocal()
|
||||
try:
|
||||
query = session.query(TaskLogRecord).filter(TaskLogRecord.task_id == task_id)
|
||||
|
||||
# Apply filters
|
||||
if log_filter.level:
|
||||
query = query.filter(TaskLogRecord.level == log_filter.level.upper())
|
||||
if log_filter.source:
|
||||
query = query.filter(TaskLogRecord.source == log_filter.source)
|
||||
if log_filter.search:
|
||||
search_pattern = f"%{log_filter.search}%"
|
||||
query = query.filter(TaskLogRecord.message.ilike(search_pattern))
|
||||
|
||||
# Order by timestamp ascending (oldest first)
|
||||
query = query.order_by(TaskLogRecord.timestamp.asc())
|
||||
|
||||
# Apply pagination
|
||||
records = query.offset(log_filter.offset).limit(log_filter.limit).all()
|
||||
|
||||
logs = []
|
||||
for record in records:
|
||||
metadata = None
|
||||
if record.metadata_json:
|
||||
try:
|
||||
metadata = json.loads(record.metadata_json)
|
||||
except json.JSONDecodeError:
|
||||
metadata = None
|
||||
|
||||
logs.append(TaskLog(
|
||||
id=record.id,
|
||||
task_id=record.task_id,
|
||||
timestamp=record.timestamp,
|
||||
level=record.level,
|
||||
source=record.source,
|
||||
message=record.message,
|
||||
metadata=metadata
|
||||
))
|
||||
|
||||
return logs
|
||||
finally:
|
||||
session.close()
|
||||
# [/DEF:get_logs:Function]
|
||||
|
||||
# [DEF:get_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_log_stats(self, task_id: str) -> LogStats:
|
||||
with belief_scope("TaskLogPersistenceService.get_log_stats", f"task_id={task_id}"):
|
||||
session: Session = TasksSessionLocal()
|
||||
try:
|
||||
# Get total count
|
||||
total_count = session.query(TaskLogRecord).filter(
|
||||
TaskLogRecord.task_id == task_id
|
||||
).count()
|
||||
|
||||
# Get counts by level
|
||||
from sqlalchemy import func
|
||||
level_counts = session.query(
|
||||
TaskLogRecord.level,
|
||||
func.count(TaskLogRecord.id)
|
||||
).filter(
|
||||
TaskLogRecord.task_id == task_id
|
||||
).group_by(TaskLogRecord.level).all()
|
||||
|
||||
by_level = {level: count for level, count in level_counts}
|
||||
|
||||
# Get counts by source
|
||||
source_counts = session.query(
|
||||
TaskLogRecord.source,
|
||||
func.count(TaskLogRecord.id)
|
||||
).filter(
|
||||
TaskLogRecord.task_id == task_id
|
||||
).group_by(TaskLogRecord.source).all()
|
||||
|
||||
by_source = {source: count for source, count in source_counts}
|
||||
|
||||
return LogStats(
|
||||
total_count=total_count,
|
||||
by_level=by_level,
|
||||
by_source=by_source
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
# [/DEF:get_log_stats:Function]
|
||||
|
||||
# [DEF:get_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_sources(self, task_id: str) -> List[str]:
|
||||
with belief_scope("TaskLogPersistenceService.get_sources", f"task_id={task_id}"):
|
||||
session: Session = TasksSessionLocal()
|
||||
try:
|
||||
from sqlalchemy import distinct
|
||||
sources = session.query(distinct(TaskLogRecord.source)).filter(
|
||||
TaskLogRecord.task_id == task_id
|
||||
).all()
|
||||
return [s[0] for s in sources]
|
||||
finally:
|
||||
session.close()
|
||||
# [/DEF:get_sources:Function]
|
||||
|
||||
# [DEF:delete_logs_for_task:Function]
|
||||
# @PURPOSE: Delete all logs for a specific task.
|
||||
# @PRE: task_id is a valid task ID.
|
||||
# @POST: All logs for the task are deleted.
|
||||
# @PARAM: task_id (str) - The task ID.
|
||||
# @SIDE_EFFECT: Deletes from task_logs table.
|
||||
def delete_logs_for_task(self, task_id: str) -> None:
|
||||
with belief_scope("TaskLogPersistenceService.delete_logs_for_task", f"task_id={task_id}"):
|
||||
session: Session = TasksSessionLocal()
|
||||
try:
|
||||
session.query(TaskLogRecord).filter(
|
||||
TaskLogRecord.task_id == task_id
|
||||
).delete(synchronize_session=False)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(f"Failed to delete logs for task {task_id}: {e}")
|
||||
finally:
|
||||
session.close()
|
||||
# [/DEF:delete_logs_for_task:Function]
|
||||
|
||||
# [DEF:delete_logs_for_tasks:Function]
|
||||
# @PURPOSE: Delete all logs for multiple tasks.
|
||||
# @PRE: task_ids is a list of task IDs.
|
||||
# @POST: All logs for the tasks are deleted.
|
||||
# @PARAM: task_ids (List[str]) - List of task IDs.
|
||||
def delete_logs_for_tasks(self, task_ids: List[str]) -> None:
|
||||
if not task_ids:
|
||||
return
|
||||
with belief_scope("TaskLogPersistenceService.delete_logs_for_tasks"):
|
||||
session: Session = TasksSessionLocal()
|
||||
try:
|
||||
session.query(TaskLogRecord).filter(
|
||||
TaskLogRecord.task_id.in_(task_ids)
|
||||
).delete(synchronize_session=False)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(f"Failed to delete logs for tasks: {e}")
|
||||
finally:
|
||||
session.close()
|
||||
# [/DEF:delete_logs_for_tasks:Function]
|
||||
|
||||
# [/DEF:TaskLogPersistenceService:Class]
|
||||
# [/DEF:TaskPersistenceModule:Module]
|
||||
167
backend/src/core/task_manager/task_logger.py
Normal file
167
backend/src/core/task_manager/task_logger.py
Normal file
@@ -0,0 +1,167 @@
|
||||
# [DEF:TaskLoggerModule:Module]
|
||||
# @SEMANTICS: task, logger, context, plugin, attribution
|
||||
# @PURPOSE: Provides a dedicated logger for tasks with automatic source attribution.
|
||||
# @LAYER: Core
|
||||
# @RELATION: DEPENDS_ON -> TaskManager, CALLS -> TaskManager._add_log
|
||||
# @TIER: CRITICAL
|
||||
# @INVARIANT: Each TaskLogger instance is bound to a specific task_id and default source.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:TaskLogger:Class]
|
||||
# @SEMANTICS: logger, task, source, attribution
|
||||
# @PURPOSE: A wrapper around TaskManager._add_log that carries task_id and source context.
|
||||
# @TIER: CRITICAL
|
||||
# @INVARIANT: All log calls include the task_id and source.
|
||||
# @UX_STATE: Idle -> Logging -> (system records log)
|
||||
class TaskLogger:
|
||||
"""
|
||||
A dedicated logger for tasks that automatically tags logs with source attribution.
|
||||
|
||||
Usage:
|
||||
logger = TaskLogger(task_id="abc123", add_log_fn=task_manager._add_log, source="plugin")
|
||||
logger.info("Starting backup process")
|
||||
logger.error("Failed to connect", metadata={"error_code": 500})
|
||||
|
||||
# Create sub-logger with different source
|
||||
api_logger = logger.with_source("superset_api")
|
||||
api_logger.info("Fetching dashboards")
|
||||
"""
|
||||
|
||||
# [DEF:__init__:Function]
|
||||
# @PURPOSE: Initialize the TaskLogger with task context.
|
||||
# @PRE: add_log_fn is a callable that accepts (task_id, level, message, context, source, metadata).
|
||||
# @POST: TaskLogger is ready to log messages.
|
||||
# @PARAM: task_id (str) - The ID of the task.
|
||||
# @PARAM: add_log_fn (Callable) - Function to add log to TaskManager.
|
||||
# @PARAM: source (str) - Default source for logs (default: "plugin").
|
||||
def __init__(
|
||||
self,
|
||||
task_id: str,
|
||||
add_log_fn: Callable,
|
||||
source: str = "plugin"
|
||||
):
|
||||
self._task_id = task_id
|
||||
self._add_log = add_log_fn
|
||||
self._default_source = source
|
||||
# [/DEF:__init__:Function]
|
||||
|
||||
# [DEF:with_source:Function]
|
||||
# @PURPOSE: Create a sub-logger with a different default source.
|
||||
# @PRE: source is a non-empty string.
|
||||
# @POST: Returns new TaskLogger with the same task_id but different source.
|
||||
# @PARAM: source (str) - New default source.
|
||||
# @RETURN: TaskLogger - New logger instance.
|
||||
def with_source(self, source: str) -> "TaskLogger":
|
||||
"""Create a sub-logger with a different source context."""
|
||||
return TaskLogger(
|
||||
task_id=self._task_id,
|
||||
add_log_fn=self._add_log,
|
||||
source=source
|
||||
)
|
||||
# [/DEF:with_source:Function]
|
||||
|
||||
# [DEF:_log:Function]
|
||||
# @PURPOSE: Internal method to log a message at a given level.
|
||||
# @PRE: level is a valid log level string.
|
||||
# @POST: Log entry added via add_log_fn.
|
||||
# @PARAM: level (str) - Log level (DEBUG, INFO, WARNING, ERROR).
|
||||
# @PARAM: message (str) - Log message.
|
||||
# @PARAM: source (Optional[str]) - Override source for this log entry.
|
||||
# @PARAM: metadata (Optional[Dict]) - Additional structured data.
|
||||
def _log(
|
||||
self,
|
||||
level: str,
|
||||
message: str,
|
||||
source: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Internal logging method."""
|
||||
self._add_log(
|
||||
task_id=self._task_id,
|
||||
level=level,
|
||||
message=message,
|
||||
source=source or self._default_source,
|
||||
metadata=metadata
|
||||
)
|
||||
# [/DEF:_log:Function]
|
||||
|
||||
# [DEF:debug:Function]
|
||||
# @PURPOSE: Log a DEBUG level message.
|
||||
# @PARAM: message (str) - Log message.
|
||||
# @PARAM: source (Optional[str]) - Override source.
|
||||
# @PARAM: metadata (Optional[Dict]) - Additional data.
|
||||
def debug(
|
||||
self,
|
||||
message: str,
|
||||
source: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
self._log("DEBUG", message, source, metadata)
|
||||
# [/DEF:debug:Function]
|
||||
|
||||
# [DEF:info:Function]
|
||||
# @PURPOSE: Log an INFO level message.
|
||||
# @PARAM: message (str) - Log message.
|
||||
# @PARAM: source (Optional[str]) - Override source.
|
||||
# @PARAM: metadata (Optional[Dict]) - Additional data.
|
||||
def info(
|
||||
self,
|
||||
message: str,
|
||||
source: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
self._log("INFO", message, source, metadata)
|
||||
# [/DEF:info:Function]
|
||||
|
||||
# [DEF:warning:Function]
|
||||
# @PURPOSE: Log a WARNING level message.
|
||||
# @PARAM: message (str) - Log message.
|
||||
# @PARAM: source (Optional[str]) - Override source.
|
||||
# @PARAM: metadata (Optional[Dict]) - Additional data.
|
||||
def warning(
|
||||
self,
|
||||
message: str,
|
||||
source: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
self._log("WARNING", message, source, metadata)
|
||||
# [/DEF:warning:Function]
|
||||
|
||||
# [DEF:error:Function]
|
||||
# @PURPOSE: Log an ERROR level message.
|
||||
# @PARAM: message (str) - Log message.
|
||||
# @PARAM: source (Optional[str]) - Override source.
|
||||
# @PARAM: metadata (Optional[Dict]) - Additional data.
|
||||
def error(
|
||||
self,
|
||||
message: str,
|
||||
source: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
self._log("ERROR", message, source, metadata)
|
||||
# [/DEF:error:Function]
|
||||
|
||||
# [DEF:progress:Function]
|
||||
# @PURPOSE: Log a progress update with percentage.
|
||||
# @PRE: percent is between 0 and 100.
|
||||
# @POST: Log entry with progress metadata added.
|
||||
# @PARAM: message (str) - Progress message.
|
||||
# @PARAM: percent (float) - Progress percentage (0-100).
|
||||
# @PARAM: source (Optional[str]) - Override source.
|
||||
def progress(
|
||||
self,
|
||||
message: str,
|
||||
percent: float,
|
||||
source: Optional[str] = None
|
||||
) -> None:
|
||||
"""Log a progress update with percentage."""
|
||||
metadata = {"progress": min(100, max(0, percent))}
|
||||
self._log("INFO", message, source, metadata)
|
||||
# [/DEF:progress:Function]
|
||||
|
||||
# [/DEF:TaskLogger:Class]
|
||||
|
||||
# [/DEF:TaskLoggerModule:Module]
|
||||
@@ -11,7 +11,7 @@
|
||||
# [SECTION: IMPORTS]
|
||||
import pandas as pd # type: ignore
|
||||
import psycopg2 # type: ignore
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Dict, Optional, Any
|
||||
from ..logger import logger as app_logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from datetime import date, datetime
|
||||
import shutil
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
import yaml
|
||||
from ..logger import logger as app_logger, belief_scope
|
||||
# [/SECTION]
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ def suggest_mappings(source_databases: List[Dict], target_databases: List[Dict],
|
||||
name, score, index = match
|
||||
if score >= threshold:
|
||||
suggestions.append({
|
||||
"source_db": s_db['database_name'],
|
||||
"target_db": target_databases[index]['database_name'],
|
||||
"source_db_uuid": s_db['uuid'],
|
||||
"target_db_uuid": target_databases[index]['uuid'],
|
||||
"confidence": score / 100.0
|
||||
|
||||
@@ -118,14 +118,41 @@ class APIClient:
|
||||
def _init_session(self) -> requests.Session:
|
||||
with belief_scope("_init_session"):
|
||||
session = requests.Session()
|
||||
|
||||
# Create a custom adapter that handles TLS issues
|
||||
class TLSAdapter(HTTPAdapter):
|
||||
def init_poolmanager(self, connections, maxsize, block=False):
|
||||
from urllib3.poolmanager import PoolManager
|
||||
import ssl
|
||||
|
||||
# Create an SSL context that ignores TLSv1 unrecognized name errors
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.set_ciphers('HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA')
|
||||
|
||||
# Ignore TLSV1_UNRECOGNIZED_NAME errors by disabling hostname verification
|
||||
# This is safe when verify_ssl is false (we're already not verifying the certificate)
|
||||
ctx.check_hostname = False
|
||||
|
||||
self.poolmanager = PoolManager(
|
||||
num_pools=connections,
|
||||
maxsize=maxsize,
|
||||
block=block,
|
||||
ssl_context=ctx
|
||||
)
|
||||
|
||||
retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
|
||||
adapter = HTTPAdapter(max_retries=retries)
|
||||
adapter = TLSAdapter(max_retries=retries)
|
||||
session.mount('http://', adapter)
|
||||
session.mount('https://', adapter)
|
||||
|
||||
if not self.request_settings["verify_ssl"]:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
app_logger.warning("[_init_session][State] SSL verification disabled.")
|
||||
session.verify = self.request_settings["verify_ssl"]
|
||||
# When verify_ssl is false, we should also disable hostname verification
|
||||
session.verify = False
|
||||
else:
|
||||
session.verify = True
|
||||
|
||||
return session
|
||||
# [/DEF:_init_session:Function]
|
||||
|
||||
@@ -140,7 +167,16 @@ class APIClient:
|
||||
app_logger.info("[authenticate][Enter] Authenticating to %s", self.base_url)
|
||||
try:
|
||||
login_url = f"{self.base_url}/security/login"
|
||||
# Log the payload keys and values (masking password)
|
||||
masked_auth = {k: ("******" if k == "password" else v) for k, v in self.auth.items()}
|
||||
app_logger.info(f"[authenticate][Debug] Login URL: {login_url}")
|
||||
app_logger.info(f"[authenticate][Debug] Auth payload: {masked_auth}")
|
||||
|
||||
response = self.session.post(login_url, json=self.auth, timeout=self.request_settings["timeout"])
|
||||
|
||||
if response.status_code != 200:
|
||||
app_logger.error(f"[authenticate][Error] Status: {response.status_code}, Response: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
access_token = response.json()["access_token"]
|
||||
|
||||
@@ -153,6 +189,9 @@ class APIClient:
|
||||
app_logger.info("[authenticate][Exit] Authenticated successfully.")
|
||||
return self._tokens
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status_code = e.response.status_code if e.response is not None else None
|
||||
if status_code in [502, 503, 504]:
|
||||
raise NetworkError(f"Environment unavailable during authentication (Status {status_code})", status_code=status_code) from e
|
||||
raise AuthenticationError(f"Authentication failed: {e}") from e
|
||||
except (requests.exceptions.RequestException, KeyError) as e:
|
||||
raise NetworkError(f"Network or parsing error during authentication: {e}") from e
|
||||
@@ -165,7 +204,8 @@ class APIClient:
|
||||
# @POST: Returns headers including auth tokens.
|
||||
def headers(self) -> Dict[str, str]:
|
||||
with belief_scope("headers"):
|
||||
if not self._authenticated: self.authenticate()
|
||||
if not self._authenticated:
|
||||
self.authenticate()
|
||||
return {
|
||||
"Authorization": f"Bearer {self._tokens['access_token']}",
|
||||
"X-CSRFToken": self._tokens.get("csrf_token", ""),
|
||||
@@ -188,7 +228,8 @@ class APIClient:
|
||||
with belief_scope("request"):
|
||||
full_url = f"{self.base_url}{endpoint}"
|
||||
_headers = self.headers.copy()
|
||||
if headers: _headers.update(headers)
|
||||
if headers:
|
||||
_headers.update(headers)
|
||||
|
||||
try:
|
||||
response = self.session.request(method, full_url, headers=_headers, **kwargs)
|
||||
@@ -209,9 +250,14 @@ class APIClient:
|
||||
def _handle_http_error(self, e: requests.exceptions.HTTPError, endpoint: str):
|
||||
with belief_scope("_handle_http_error"):
|
||||
status_code = e.response.status_code
|
||||
if status_code == 404: raise DashboardNotFoundError(endpoint) from e
|
||||
if status_code == 403: raise PermissionDeniedError() from e
|
||||
if status_code == 401: raise AuthenticationError() from e
|
||||
if status_code == 502 or status_code == 503 or status_code == 504:
|
||||
raise NetworkError(f"Environment unavailable (Status {status_code})", status_code=status_code) from e
|
||||
if status_code == 404:
|
||||
raise DashboardNotFoundError(endpoint) from e
|
||||
if status_code == 403:
|
||||
raise PermissionDeniedError() from e
|
||||
if status_code == 401:
|
||||
raise AuthenticationError() from e
|
||||
raise SupersetAPIError(f"API Error {status_code}: {e.response.text}") from e
|
||||
# [/DEF:_handle_http_error:Function]
|
||||
|
||||
@@ -223,9 +269,12 @@ class APIClient:
|
||||
# @POST: Raises a NetworkError.
|
||||
def _handle_network_error(self, e: requests.exceptions.RequestException, url: str):
|
||||
with belief_scope("_handle_network_error"):
|
||||
if isinstance(e, requests.exceptions.Timeout): msg = "Request timeout"
|
||||
elif isinstance(e, requests.exceptions.ConnectionError): msg = "Connection error"
|
||||
else: msg = f"Unknown network error: {e}"
|
||||
if isinstance(e, requests.exceptions.Timeout):
|
||||
msg = "Request timeout"
|
||||
elif isinstance(e, requests.exceptions.ConnectionError):
|
||||
msg = "Connection error"
|
||||
else:
|
||||
msg = f"Unknown network error: {e}"
|
||||
raise NetworkError(msg, url=url) from e
|
||||
# [/DEF:_handle_network_error:Function]
|
||||
|
||||
@@ -242,7 +291,9 @@ class APIClient:
|
||||
def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict:
|
||||
with belief_scope("upload_file"):
|
||||
full_url = f"{self.base_url}{endpoint}"
|
||||
_headers = self.headers.copy(); _headers.pop('Content-Type', None)
|
||||
_headers = self.headers.copy()
|
||||
_headers.pop('Content-Type', None)
|
||||
|
||||
|
||||
file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file")
|
||||
|
||||
@@ -304,20 +355,40 @@ class APIClient:
|
||||
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
|
||||
# @PARAM: endpoint (str) - Эндпоинт.
|
||||
# @PARAM: pagination_options (Dict[str, Any]) - Опции пагинации.
|
||||
# @PRE: pagination_options must contain 'base_query', 'total_count', 'results_field'.
|
||||
# @PRE: pagination_options must contain 'base_query', 'results_field'. 'total_count' is optional.
|
||||
# @POST: Returns all items across all pages.
|
||||
# @RETURN: List[Any] - Список данных.
|
||||
def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]:
|
||||
with belief_scope("fetch_paginated_data"):
|
||||
base_query, total_count = pagination_options["base_query"], pagination_options["total_count"]
|
||||
results_field, page_size = pagination_options["results_field"], base_query.get('page_size')
|
||||
assert page_size and page_size > 0, "'page_size' must be a positive number."
|
||||
base_query = pagination_options["base_query"]
|
||||
total_count = pagination_options.get("total_count")
|
||||
|
||||
results_field = pagination_options["results_field"]
|
||||
count_field = pagination_options.get("count_field", "count")
|
||||
page_size = base_query.get('page_size', 1000)
|
||||
assert page_size > 0, "'page_size' must be a positive number."
|
||||
|
||||
results = []
|
||||
for page in range((total_count + page_size - 1) // page_size):
|
||||
page = 0
|
||||
|
||||
# Fetch first page to get data and total count if not provided
|
||||
query = {**base_query, 'page': page}
|
||||
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)}))
|
||||
|
||||
first_page_results = response_json.get(results_field, [])
|
||||
results.extend(first_page_results)
|
||||
|
||||
if total_count is None:
|
||||
total_count = response_json.get(count_field, len(first_page_results))
|
||||
app_logger.debug(f"[fetch_paginated_data][State] Total count resolved from first page: {total_count}")
|
||||
|
||||
# Fetch remaining pages
|
||||
total_pages = (total_count + page_size - 1) // page_size
|
||||
for page in range(1, total_pages):
|
||||
query = {**base_query, 'page': page}
|
||||
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)}))
|
||||
results.extend(response_json.get(results_field, []))
|
||||
|
||||
return results
|
||||
# [/DEF:fetch_paginated_data:Function]
|
||||
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
# [DEF:Dependencies:Module]
|
||||
# @SEMANTICS: dependency, injection, singleton, factory
|
||||
# @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
|
||||
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt
|
||||
# @PURPOSE: Manages creation and provision of shared application dependencies, such as PluginLoader and TaskManager, to avoid circular imports.
|
||||
# @LAYER: Core
|
||||
# @RELATION: Used by the main app and API routers to get access to shared instances.
|
||||
# @RELATION: Used by main app and API routers to get access to shared instances.
|
||||
|
||||
from pathlib import Path
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
from .core.plugin_loader import PluginLoader
|
||||
from .core.task_manager import TaskManager
|
||||
from .core.config_manager import ConfigManager
|
||||
from .core.scheduler import SchedulerService
|
||||
from .core.database import init_db
|
||||
from .core.logger import logger, belief_scope
|
||||
from .services.resource_service import ResourceService
|
||||
from .services.mapping_service import MappingService
|
||||
from .core.database import init_db, get_auth_db
|
||||
from .core.logger import logger
|
||||
from .core.auth.jwt import decode_token
|
||||
from .core.auth.repository import AuthRepository
|
||||
from .models.auth import User
|
||||
|
||||
# Initialize singletons
|
||||
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
config_path = project_root / "config.json"
|
||||
config_manager = ConfigManager(config_path=str(config_path))
|
||||
|
||||
# Initialize database before any other services that might use it
|
||||
init_db()
|
||||
# Initialize singletons
|
||||
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
config_path = project_root / "config.json"
|
||||
|
||||
# Initialize database before services that use persisted configuration.
|
||||
init_db()
|
||||
config_manager = ConfigManager(config_path=str(config_path))
|
||||
|
||||
# [DEF:get_config_manager:Function]
|
||||
# @PURPOSE: Dependency injector for the ConfigManager.
|
||||
# @PURPOSE: Dependency injector for ConfigManager.
|
||||
# @PRE: Global config_manager must be initialized.
|
||||
# @POST: Returns shared ConfigManager instance.
|
||||
# @RETURN: ConfigManager - The shared config manager instance.
|
||||
def get_config_manager() -> ConfigManager:
|
||||
"""Dependency injector for the ConfigManager."""
|
||||
with belief_scope("get_config_manager"):
|
||||
return config_manager
|
||||
"""Dependency injector for ConfigManager."""
|
||||
return config_manager
|
||||
# [/DEF:get_config_manager:Function]
|
||||
|
||||
plugin_dir = Path(__file__).parent / "plugins"
|
||||
@@ -44,37 +51,121 @@ logger.info("TaskManager initialized")
|
||||
scheduler_service = SchedulerService(task_manager, config_manager)
|
||||
logger.info("SchedulerService initialized")
|
||||
|
||||
resource_service = ResourceService()
|
||||
logger.info("ResourceService initialized")
|
||||
|
||||
# [DEF:get_plugin_loader:Function]
|
||||
# @PURPOSE: Dependency injector for the PluginLoader.
|
||||
# @PURPOSE: Dependency injector for PluginLoader.
|
||||
# @PRE: Global plugin_loader must be initialized.
|
||||
# @POST: Returns shared PluginLoader instance.
|
||||
# @RETURN: PluginLoader - The shared plugin loader instance.
|
||||
def get_plugin_loader() -> PluginLoader:
|
||||
"""Dependency injector for the PluginLoader."""
|
||||
with belief_scope("get_plugin_loader"):
|
||||
return plugin_loader
|
||||
"""Dependency injector for PluginLoader."""
|
||||
return plugin_loader
|
||||
# [/DEF:get_plugin_loader:Function]
|
||||
|
||||
# [DEF:get_task_manager:Function]
|
||||
# @PURPOSE: Dependency injector for the TaskManager.
|
||||
# @PURPOSE: Dependency injector for TaskManager.
|
||||
# @PRE: Global task_manager must be initialized.
|
||||
# @POST: Returns shared TaskManager instance.
|
||||
# @RETURN: TaskManager - The shared task manager instance.
|
||||
def get_task_manager() -> TaskManager:
|
||||
"""Dependency injector for the TaskManager."""
|
||||
with belief_scope("get_task_manager"):
|
||||
return task_manager
|
||||
"""Dependency injector for TaskManager."""
|
||||
return task_manager
|
||||
# [/DEF:get_task_manager:Function]
|
||||
|
||||
# [DEF:get_scheduler_service:Function]
|
||||
# @PURPOSE: Dependency injector for the SchedulerService.
|
||||
# @PURPOSE: Dependency injector for SchedulerService.
|
||||
# @PRE: Global scheduler_service must be initialized.
|
||||
# @POST: Returns shared SchedulerService instance.
|
||||
# @RETURN: SchedulerService - The shared scheduler service instance.
|
||||
def get_scheduler_service() -> SchedulerService:
|
||||
"""Dependency injector for the SchedulerService."""
|
||||
with belief_scope("get_scheduler_service"):
|
||||
return scheduler_service
|
||||
"""Dependency injector for SchedulerService."""
|
||||
return scheduler_service
|
||||
# [/DEF:get_scheduler_service:Function]
|
||||
|
||||
# [/DEF:Dependencies:Module]
|
||||
# [DEF:get_resource_service:Function]
|
||||
# @PURPOSE: Dependency injector for ResourceService.
|
||||
# @PRE: Global resource_service must be initialized.
|
||||
# @POST: Returns shared ResourceService instance.
|
||||
# @RETURN: ResourceService - The shared resource service instance.
|
||||
def get_resource_service() -> ResourceService:
|
||||
"""Dependency injector for ResourceService."""
|
||||
return resource_service
|
||||
# [/DEF:get_resource_service:Function]
|
||||
|
||||
# [DEF:get_mapping_service:Function]
|
||||
# @PURPOSE: Dependency injector for MappingService.
|
||||
# @PRE: Global config_manager must be initialized.
|
||||
# @POST: Returns new MappingService instance.
|
||||
# @RETURN: MappingService - A new mapping service instance.
|
||||
def get_mapping_service() -> MappingService:
|
||||
"""Dependency injector for MappingService."""
|
||||
return MappingService(config_manager)
|
||||
# [/DEF:get_mapping_service:Function]
|
||||
|
||||
# [DEF:oauth2_scheme:Variable]
|
||||
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
# [/DEF:oauth2_scheme:Variable]
|
||||
|
||||
# [DEF:get_current_user:Function]
|
||||
# @PURPOSE: Dependency for retrieving currently authenticated user from a JWT.
|
||||
# @PRE: JWT token provided in Authorization header.
|
||||
# @POST: Returns User object if token is valid.
|
||||
# @THROW: HTTPException 401 if token is invalid or user not found.
|
||||
# @PARAM: token (str) - Extracted JWT token.
|
||||
# @PARAM: db (Session) - Auth database session.
|
||||
# @RETURN: User - The authenticated user.
|
||||
def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_auth_db)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
repo = AuthRepository(db)
|
||||
user = repo.get_user_by_username(username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
# [/DEF:get_current_user:Function]
|
||||
|
||||
# [DEF:has_permission:Function]
|
||||
# @PURPOSE: Dependency for checking if the current user has a specific permission.
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns True if user has permission.
|
||||
# @THROW: HTTPException 403 if permission is denied.
|
||||
# @PARAM: resource (str) - The resource identifier.
|
||||
# @PARAM: action (str) - The action identifier (READ, EXECUTE, WRITE).
|
||||
# @RETURN: User - The authenticated user if permission granted.
|
||||
def has_permission(resource: str, action: str):
|
||||
def permission_checker(current_user: User = Depends(get_current_user)):
|
||||
# Union of all permissions across all roles
|
||||
for role in current_user.roles:
|
||||
for perm in role.permissions:
|
||||
if perm.resource == resource and perm.action == action:
|
||||
return current_user
|
||||
|
||||
# Special case for Admin role (full access)
|
||||
if any(role.name == "Admin" for role in current_user.roles):
|
||||
return current_user
|
||||
|
||||
from .core.auth.logger import log_security_event
|
||||
log_security_event("PERMISSION_DENIED", current_user.username, {"resource": resource, "action": action})
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permission denied for {resource}:{action}"
|
||||
)
|
||||
return permission_checker
|
||||
# [/DEF:has_permission:Function]
|
||||
|
||||
# [/DEF:Dependencies:Module]
|
||||
|
||||
36
backend/src/models/__tests__/test_models.py
Normal file
36
backend/src/models/__tests__/test_models.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# [DEF:test_models:Module]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Unit tests for data models
|
||||
# @LAYER: Domain
|
||||
# @RELATION: VERIFIES -> src.models
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
from src.core.config_models import Environment
|
||||
from src.core.logger import belief_scope
|
||||
|
||||
|
||||
# [DEF:test_environment_model:Function]
|
||||
# @PURPOSE: Tests that Environment model correctly stores values.
|
||||
# @PRE: Environment class is available.
|
||||
# @POST: Values are verified.
|
||||
def test_environment_model():
|
||||
with belief_scope("test_environment_model"):
|
||||
env = Environment(
|
||||
id="test-id",
|
||||
name="test-env",
|
||||
url="http://localhost:8088/api/v1",
|
||||
username="admin",
|
||||
password="password"
|
||||
)
|
||||
assert env.id == "test-id"
|
||||
assert env.name == "test-env"
|
||||
assert env.url == "http://localhost:8088/api/v1"
|
||||
# [/DEF:test_environment_model:Function]
|
||||
|
||||
|
||||
# [/DEF:test_models:Module]
|
||||
105
backend/src/models/auth.py
Normal file
105
backend/src/models/auth.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# [DEF:backend.src.models.auth:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: auth, models, user, role, permission, sqlalchemy
|
||||
# @PURPOSE: SQLAlchemy models for multi-user authentication and authorization.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
|
||||
#
|
||||
# @INVARIANT: Usernames and emails must be unique.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from .mapping import Base
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:generate_uuid:Function]
|
||||
# @PURPOSE: Generates a unique UUID string.
|
||||
# @POST: Returns a string representation of a new UUID.
|
||||
def generate_uuid():
|
||||
return str(uuid.uuid4())
|
||||
# [/DEF:generate_uuid:Function]
|
||||
|
||||
# [DEF:user_roles:Table]
|
||||
# @PURPOSE: Association table for many-to-many relationship between Users and Roles.
|
||||
user_roles = Table(
|
||||
"user_roles",
|
||||
Base.metadata,
|
||||
Column("user_id", String, ForeignKey("users.id"), primary_key=True),
|
||||
Column("role_id", String, ForeignKey("roles.id"), primary_key=True),
|
||||
)
|
||||
# [/DEF:user_roles:Table]
|
||||
|
||||
# [DEF:role_permissions:Table]
|
||||
# @PURPOSE: Association table for many-to-many relationship between Roles and Permissions.
|
||||
role_permissions = Table(
|
||||
"role_permissions",
|
||||
Base.metadata,
|
||||
Column("role_id", String, ForeignKey("roles.id"), primary_key=True),
|
||||
Column("permission_id", String, ForeignKey("permissions.id"), primary_key=True),
|
||||
)
|
||||
# [/DEF:role_permissions:Table]
|
||||
|
||||
# [DEF:User:Class]
|
||||
# @PURPOSE: Represents an identity that can authenticate to the system.
|
||||
# @RELATION: HAS_MANY -> Role (via user_roles)
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
email = Column(String, unique=True, index=True, nullable=True)
|
||||
password_hash = Column(String, nullable=True)
|
||||
auth_source = Column(String, default="LOCAL") # LOCAL or ADFS
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
roles = relationship("Role", secondary=user_roles, back_populates="users")
|
||||
# [/DEF:User:Class]
|
||||
|
||||
# [DEF:Role:Class]
|
||||
# @PURPOSE: Represents a collection of permissions.
|
||||
# @RELATION: HAS_MANY -> User (via user_roles)
|
||||
# @RELATION: HAS_MANY -> Permission (via role_permissions)
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
name = Column(String, unique=True, index=True, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
|
||||
users = relationship("User", secondary=user_roles, back_populates="roles")
|
||||
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
|
||||
# [/DEF:Role:Class]
|
||||
|
||||
# [DEF:Permission:Class]
|
||||
# @PURPOSE: Represents a specific capability within the system.
|
||||
# @RELATION: HAS_MANY -> Role (via role_permissions)
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
resource = Column(String, nullable=False) # e.g. "plugin:backup"
|
||||
action = Column(String, nullable=False) # e.g. "READ", "EXECUTE", "WRITE"
|
||||
|
||||
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
|
||||
# [/DEF:Permission:Class]
|
||||
|
||||
# [DEF:ADGroupMapping:Class]
|
||||
# @PURPOSE: Maps an Active Directory group to a local System Role.
|
||||
# @RELATION: DEPENDS_ON -> Role
|
||||
class ADGroupMapping(Base):
|
||||
__tablename__ = "ad_group_mappings"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
ad_group = Column(String, unique=True, index=True, nullable=False)
|
||||
role_id = Column(String, ForeignKey("roles.id"), nullable=False)
|
||||
|
||||
role = relationship("Role")
|
||||
# [/DEF:ADGroupMapping:Class]
|
||||
|
||||
# [/DEF:backend.src.models.auth:Module]
|
||||
26
backend/src/models/config.py
Normal file
26
backend/src/models/config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# [DEF:backend.src.models.config:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: database, config, settings, sqlalchemy
|
||||
# @PURPOSE: Defines database schema for persisted application configuration.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, JSON
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from .mapping import Base
|
||||
|
||||
|
||||
# [DEF:AppConfigRecord:Class]
|
||||
# @PURPOSE: Stores the single source of truth for application configuration.
|
||||
class AppConfigRecord(Base):
|
||||
__tablename__ = "app_configurations"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
payload = Column(JSON, nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
# [/DEF:AppConfigRecord:Class]
|
||||
# [/DEF:backend.src.models.config:Module]
|
||||
@@ -1,5 +1,6 @@
|
||||
# [DEF:backend.src.models.connection:Module]
|
||||
#
|
||||
# @TIER: TRIVIAL
|
||||
# @SEMANTICS: database, connection, configuration, sqlalchemy, sqlite
|
||||
# @PURPOSE: Defines the database schema for external database connection configurations.
|
||||
# @LAYER: Domain
|
||||
@@ -15,6 +16,7 @@ import uuid
|
||||
# [/SECTION]
|
||||
|
||||
# [DEF:ConnectionConfig:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Stores credentials for external databases used for column mapping.
|
||||
class ConnectionConfig(Base):
|
||||
__tablename__ = "connection_configs"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# [DEF:backend.src.models.dashboard:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: dashboard, model, metadata, migration
|
||||
# @PURPOSE: Defines data models for dashboard metadata and selection.
|
||||
# @LAYER: Model
|
||||
@@ -8,6 +9,7 @@ from pydantic import BaseModel
|
||||
from typing import List
|
||||
|
||||
# [DEF:DashboardMetadata:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Represents a dashboard available for migration.
|
||||
class DashboardMetadata(BaseModel):
|
||||
id: int
|
||||
@@ -17,6 +19,7 @@ class DashboardMetadata(BaseModel):
|
||||
# [/DEF:DashboardMetadata:Class]
|
||||
|
||||
# [DEF:DashboardSelection:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Represents the user's selection of dashboards to migrate.
|
||||
class DashboardSelection(BaseModel):
|
||||
selected_ids: List[int]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# [DEF:GitModels:Module]
|
||||
# @TIER: TRIVIAL
|
||||
# @SEMANTICS: git, models, sqlalchemy, database, schema
|
||||
# @PURPOSE: Git-specific SQLAlchemy models for configuration and repository tracking.
|
||||
# @LAYER: Model
|
||||
@@ -7,7 +8,6 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, DateTime, Enum, ForeignKey, Boolean
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
from src.core.database import Base
|
||||
|
||||
@@ -26,11 +26,10 @@ class SyncStatus(str, enum.Enum):
|
||||
DIRTY = "DIRTY"
|
||||
CONFLICT = "CONFLICT"
|
||||
|
||||
# [DEF:GitServerConfig:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Configuration for a Git server connection.
|
||||
class GitServerConfig(Base):
|
||||
"""
|
||||
[DEF:GitServerConfig:Class]
|
||||
Configuration for a Git server connection.
|
||||
"""
|
||||
__tablename__ = "git_server_configs"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
@@ -41,12 +40,12 @@ class GitServerConfig(Base):
|
||||
default_repository = Column(String(255), nullable=True)
|
||||
status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN)
|
||||
last_validated = Column(DateTime, default=datetime.utcnow)
|
||||
# [/DEF:GitServerConfig:Class]
|
||||
|
||||
# [DEF:GitRepository:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Tracking for a local Git repository linked to a dashboard.
|
||||
class GitRepository(Base):
|
||||
"""
|
||||
[DEF:GitRepository:Class]
|
||||
Tracking for a local Git repository linked to a dashboard.
|
||||
"""
|
||||
__tablename__ = "git_repositories"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
@@ -56,12 +55,12 @@ class GitRepository(Base):
|
||||
local_path = Column(String(255), nullable=False)
|
||||
current_branch = Column(String(255), default="main")
|
||||
sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN)
|
||||
# [/DEF:GitRepository:Class]
|
||||
|
||||
# [DEF:DeploymentEnvironment:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Target Superset environments for dashboard deployment.
|
||||
class DeploymentEnvironment(Base):
|
||||
"""
|
||||
[DEF:DeploymentEnvironment:Class]
|
||||
Target Superset environments for dashboard deployment.
|
||||
"""
|
||||
__tablename__ = "deployment_environments"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
@@ -69,5 +68,6 @@ class DeploymentEnvironment(Base):
|
||||
superset_url = Column(String(255), nullable=False)
|
||||
superset_token = Column(String(255), nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
# [/DEF:DeploymentEnvironment:Class]
|
||||
|
||||
# [/DEF:GitModels:Module]
|
||||
# [/DEF:GitModels:Module]
|
||||
|
||||
46
backend/src/models/llm.py
Normal file
46
backend/src/models/llm.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# [DEF:backend.src.models.llm:Module]
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: llm, models, sqlalchemy, persistence
|
||||
# @PURPOSE: SQLAlchemy models for LLM provider configuration and validation results.
|
||||
# @LAYER: Domain
|
||||
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
|
||||
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, JSON, Text
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from .mapping import Base
|
||||
|
||||
def generate_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
# [DEF:LLMProvider:Class]
|
||||
# @PURPOSE: SQLAlchemy model for LLM provider configuration.
|
||||
class LLMProvider(Base):
|
||||
__tablename__ = "llm_providers"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
provider_type = Column(String, nullable=False) # openai, openrouter, kilo
|
||||
name = Column(String, nullable=False)
|
||||
base_url = Column(String, nullable=False)
|
||||
api_key = Column(String, nullable=False) # Should be encrypted
|
||||
default_model = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
# [/DEF:LLMProvider:Class]
|
||||
|
||||
# [DEF:ValidationRecord:Class]
|
||||
# @PURPOSE: SQLAlchemy model for dashboard validation history.
|
||||
class ValidationRecord(Base):
|
||||
__tablename__ = "llm_validation_results"
|
||||
|
||||
id = Column(String, primary_key=True, default=generate_uuid)
|
||||
dashboard_id = Column(String, nullable=False, index=True)
|
||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||
status = Column(String, nullable=False) # PASS, WARN, FAIL
|
||||
screenshot_path = Column(String, nullable=True)
|
||||
issues = Column(JSON, nullable=False)
|
||||
summary = Column(Text, nullable=False)
|
||||
raw_response = Column(Text, nullable=True)
|
||||
# [/DEF:ValidationRecord:Class]
|
||||
|
||||
# [/DEF:backend.src.models.llm:Module]
|
||||
@@ -1,5 +1,6 @@
|
||||
# [DEF:backend.src.models.mapping:Module]
|
||||
#
|
||||
# @TIER: STANDARD
|
||||
# @SEMANTICS: database, mapping, environment, migration, sqlalchemy, sqlite
|
||||
# @PURPOSE: Defines the database schema for environment metadata and database mappings using SQLAlchemy.
|
||||
# @LAYER: Domain
|
||||
@@ -19,6 +20,7 @@ import enum
|
||||
Base = declarative_base()
|
||||
|
||||
# [DEF:MigrationStatus:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Enumeration of possible migration job statuses.
|
||||
class MigrationStatus(enum.Enum):
|
||||
PENDING = "PENDING"
|
||||
@@ -29,6 +31,7 @@ class MigrationStatus(enum.Enum):
|
||||
# [/DEF:MigrationStatus:Class]
|
||||
|
||||
# [DEF:Environment:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Represents a Superset instance environment.
|
||||
class Environment(Base):
|
||||
__tablename__ = "environments"
|
||||
@@ -40,6 +43,7 @@ class Environment(Base):
|
||||
# [/DEF:Environment:Class]
|
||||
|
||||
# [DEF:DatabaseMapping:Class]
|
||||
# @TIER: STANDARD
|
||||
# @PURPOSE: Represents a mapping between source and target databases.
|
||||
class DatabaseMapping(Base):
|
||||
__tablename__ = "database_mappings"
|
||||
@@ -55,6 +59,7 @@ class DatabaseMapping(Base):
|
||||
# [/DEF:DatabaseMapping:Class]
|
||||
|
||||
# [DEF:MigrationJob:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Represents a single migration execution job.
|
||||
class MigrationJob(Base):
|
||||
__tablename__ = "migration_jobs"
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
# [DEF:backend.src.models.storage:Module]
|
||||
# @TIER: TRIVIAL
|
||||
# @SEMANTICS: storage, file, model, pydantic
|
||||
# @PURPOSE: Data models for the storage system.
|
||||
# @LAYER: Domain
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# [DEF:FileCategory:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Enumeration of supported file categories in the storage system.
|
||||
class FileCategory(str, Enum):
|
||||
BACKUP = "backups"
|
||||
@@ -11,15 +18,19 @@ class FileCategory(str, Enum):
|
||||
# [/DEF:FileCategory:Class]
|
||||
|
||||
# [DEF:StorageConfig:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Configuration model for the storage system, defining paths and naming patterns.
|
||||
class StorageConfig(BaseModel):
|
||||
root_path: str = Field(default="backups", description="Absolute path to the storage root directory.")
|
||||
backup_path: str = Field(default="backups", description="Subpath for backups.")
|
||||
repo_path: str = Field(default="repositorys", description="Subpath for repositories.")
|
||||
backup_structure_pattern: str = Field(default="{category}/", description="Pattern for backup directory structure.")
|
||||
repo_structure_pattern: str = Field(default="{category}/", description="Pattern for repository directory structure.")
|
||||
filename_pattern: str = Field(default="{name}_{timestamp}", description="Pattern for filenames.")
|
||||
# [/DEF:StorageConfig:Class]
|
||||
|
||||
# [DEF:StoredFile:Class]
|
||||
# @TIER: TRIVIAL
|
||||
# @PURPOSE: Data model representing metadata for a file stored in the system.
|
||||
class StoredFile(BaseModel):
|
||||
name: str = Field(..., description="Name of the file (including extension).")
|
||||
@@ -28,4 +39,6 @@ class StoredFile(BaseModel):
|
||||
created_at: datetime = Field(..., description="Creation timestamp.")
|
||||
category: FileCategory = Field(..., description="Category of the file.")
|
||||
mime_type: Optional[str] = Field(None, description="MIME type of the file.")
|
||||
# [/DEF:StoredFile:Class]
|
||||
# [/DEF:StoredFile:Class]
|
||||
|
||||
# [/DEF:backend.src.models.storage:Module]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user