Compare commits
53 Commits
008-migrat
...
76b98fcf8f
| Author | SHA1 | Date | |
|---|---|---|---|
| 76b98fcf8f | |||
| 794cc55fe7 | |||
| 235b0e3c9f | |||
| e6087bd3c1 | |||
| 0f16bab2b8 | |||
| 7de96c17c4 | |||
| f018b97ed2 | |||
| 72846aa835 | |||
| 994c0c3e5d | |||
| 252a8601a9 | |||
| 8044f85ea4 | |||
| d4109e5a03 | |||
| b2bbd73439 | |||
| 0e0e26e2f7 | |||
| 18b42f8dd0 | |||
| e7b31accd6 | |||
| d3c3a80ed2 | |||
| cc244c2d86 | |||
| d10c23e658 | |||
| 1042b35d1b | |||
| 16ffeb1ed6 | |||
| da34deac02 | |||
| 51e9ee3fcc | |||
| edf9286071 | |||
| a542e7d2df | |||
| a863807cf2 | |||
| e2bc68683f | |||
| 43cb82697b | |||
| 4ba28cf93e | |||
| 343f2e29f5 | |||
| c9a53578fd | |||
| 07ec2d9797 | |||
| e9d3f3c827 | |||
| 26ba015b75 | |||
| 49129d3e86 | |||
| d99a13d91f | |||
| 203ce446f4 | |||
| c96d50a3f4 | |||
| 3bbe320949 | |||
| 2d2435642d | |||
| ec8d67c956 | |||
| 76baeb1038 | |||
| 11c59fb420 | |||
| b2529973eb | |||
| ae1d630ad6 | |||
| 9a9c5879e6 | |||
| 696aac32e7 | |||
| 7a9b1a190a | |||
| a3dc1fb2b9 | |||
| 297b29986d | |||
| 4c6fc8256d | |||
| a747a163c8 | |||
| fce0941e98 |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -29,7 +29,7 @@ env/
|
|||||||
backend/backups/*
|
backend/backups/*
|
||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
frontend/node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
@@ -39,6 +39,7 @@ build/
|
|||||||
dist/
|
dist/
|
||||||
.env*
|
.env*
|
||||||
config.json
|
config.json
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
@@ -58,7 +59,13 @@ Thumbs.db
|
|||||||
*.ps1
|
*.ps1
|
||||||
keyring passwords.py
|
keyring passwords.py
|
||||||
*github*
|
*github*
|
||||||
*git*
|
|
||||||
*tech_spec*
|
*tech_spec*
|
||||||
dashboards
|
dashboards
|
||||||
backend/mappings.db
|
backend/mappings.db
|
||||||
|
|
||||||
|
|
||||||
|
backend/tasks.db
|
||||||
|
backend/logs
|
||||||
|
backend/auth.db
|
||||||
|
semantics/reports
|
||||||
|
|||||||
15
.kilocode/mcp.json
Executable file → Normal file
15
.kilocode/mcp.json
Executable file → Normal file
@@ -1,14 +1 @@
|
|||||||
{
|
{"mcpServers":{}}
|
||||||
"mcpServers": {
|
|
||||||
"tavily": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"tavily-mcp@0.2.3"
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"TAVILY_API_KEY": "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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,25 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
|
|||||||
- Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API (008-migration-ui-improvements)
|
- Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API (008-migration-ui-improvements)
|
||||||
- SQLite (optional for job history), existing database for mappings (008-migration-ui-improvements)
|
- SQLite (optional for job history), existing database for mappings (008-migration-ui-improvements)
|
||||||
- Python 3.9+, Node.js 18+ + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API (008-migration-ui-improvements)
|
- Python 3.9+, Node.js 18+ + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API (008-migration-ui-improvements)
|
||||||
|
- Python 3.9+, Node.js 18+ + FastAPI, APScheduler, SQLAlchemy, SvelteKit, Tailwind CSS (009-backup-scheduler)
|
||||||
|
- SQLite (`tasks.db`), JSON (`config.json`) (009-backup-scheduler)
|
||||||
|
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, `superset_tool` (internal lib) (010-refactor-cli-to-web)
|
||||||
|
- SQLite (for job history/results, connection configs), Filesystem (for temporary file uploads) (010-refactor-cli-to-web)
|
||||||
|
- Python 3.9+ + FastAPI, Pydantic, requests, pyyaml (migrated from superset_tool) (012-remove-superset-tool)
|
||||||
|
- SQLite (tasks.db, migrations.db), Filesystem (012-remove-superset-tool)
|
||||||
|
- Filesystem (local git repo), SQLite (for GitServerConfig, Environment) (011-git-integration-dashboard)
|
||||||
|
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API (011-git-integration-dashboard)
|
||||||
|
- SQLite (for config/history), Filesystem (local Git repositories) (011-git-integration-dashboard)
|
||||||
|
- Node.js 18+ (Frontend Build), Svelte 5.x + SvelteKit, Tailwind CSS, `date-fns` (existing) (013-unify-frontend-css)
|
||||||
|
- LocalStorage (for language preference) (013-unify-frontend-css)
|
||||||
|
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit (Frontend) (014-file-storage-ui)
|
||||||
|
- Local Filesystem (for artifacts), Config (for storage path) (014-file-storage-ui)
|
||||||
|
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend) (015-frontend-nav-redesign)
|
||||||
|
- N/A (UI reorganization and API integration) (015-frontend-nav-redesign)
|
||||||
|
- SQLite (`auth.db`) for Users, Roles, Permissions, and Mappings. (016-multi-user-auth)
|
||||||
|
- SQLite (existing `tasks.db` for results, `auth.db` for permissions, `mappings.db` or new `plugins.db` for provider config/metadata) (017-llm-analysis-plugin)
|
||||||
|
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing) (019-superset-ux-redesign)
|
||||||
|
- SQLite (tasks.db, auth.db, migrations.db) - no new database tables required (019-superset-ux-redesign)
|
||||||
|
|
||||||
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
|
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
|
||||||
|
|
||||||
@@ -36,9 +55,9 @@ cd src; pytest; ruff check .
|
|||||||
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
|
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 008-migration-ui-improvements: Added Python 3.9+, Node.js 18+ + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API
|
- 019-superset-ux-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, SQLAlchemy, WebSocket (existing)
|
||||||
- 008-migration-ui-improvements: Added Python 3.9+ (backend), Node.js 18+ (frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, SQLAlchemy, Superset API
|
- 017-llm-analysis-plugin: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
|
||||||
- 007-migration-dashboard-grid: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, Tailwind CSS, Pydantic, Superset API
|
- 016-multi-user-auth: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- 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
|
||||||
|
---
|
||||||
|
Прочитай semantic_protocol.md. ОБЯЗАТЕЛЬНО используй его при разработке
|
||||||
@@ -63,6 +63,7 @@ Load only the minimal necessary context from each artifact:
|
|||||||
**From constitution:**
|
**From constitution:**
|
||||||
|
|
||||||
- Load `.specify/memory/constitution.md` for principle validation
|
- Load `.specify/memory/constitution.md` for principle validation
|
||||||
|
- Load `semantic_protocol.md` for technical standard validation
|
||||||
|
|
||||||
### 3. Build Semantic Models
|
### 3. Build Semantic Models
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
---
|
---
|
||||||
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
|
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
|
## User Input
|
||||||
@@ -46,6 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- Automatically proceed to step 3
|
- Automatically proceed to step 3
|
||||||
|
|
||||||
3. Load and analyze the implementation context:
|
3. Load and analyze the implementation context:
|
||||||
|
- **REQUIRED**: Read `semantic_protocol.md` for strict coding standards and contract requirements
|
||||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||||
@@ -111,6 +117,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||||
|
|
||||||
7. Implementation execution rules:
|
7. Implementation execution rules:
|
||||||
|
- **Strict Adherence**: Apply `semantic_protocol.md` rules - every file must start with [DEF] header, include @TIER, and define contracts
|
||||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
- **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").
|
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 FEATURE_SPEC, `ux_reference.md`, `semantic_protocol.md` and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||||
|
|
||||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||||
@@ -64,17 +64,30 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
|
|
||||||
**Prerequisites:** `research.md` complete
|
**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`:
|
1. **Extract entities from feature spec** → `data-model.md`:
|
||||||
- Entity name, fields, relationships
|
- Entity name, fields, relationships, validation rules.
|
||||||
- Validation rules from requirements
|
|
||||||
- State transitions if applicable
|
|
||||||
|
|
||||||
2. **Generate API contracts** from functional requirements:
|
2. **Design & Verify Contracts (Semantic Protocol)**:
|
||||||
- For each user action → endpoint
|
- **Drafting**: Define [DEF] Headers and Contracts for all new modules based on `semantic_protocol.md`.
|
||||||
- Use standard REST/GraphQL patterns
|
- **Self-Review**:
|
||||||
- Output OpenAPI/GraphQL schema to `/contracts/`
|
- *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`
|
- Run `.specify/scripts/bash/update-agent-context.sh kilocode`
|
||||||
- These scripts detect which AI agent is in use
|
- These scripts detect which AI agent is in use
|
||||||
- Update the appropriate agent-specific context file
|
- 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.
|
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
|
1. Parse user description from Input
|
||||||
If empty: ERROR "No feature description provided"
|
If empty: ERROR "No feature description provided"
|
||||||
@@ -115,6 +130,12 @@ Given that feature description, do this:
|
|||||||
- [ ] Focused on user value and business needs
|
- [ ] Focused on user value and business needs
|
||||||
- [ ] Written for non-technical stakeholders
|
- [ ] Written for non-technical stakeholders
|
||||||
- [ ] All mandatory sections completed
|
- [ ] 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
|
## 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
|
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.
|
**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").
|
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:
|
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)
|
- **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.
|
- 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.
|
**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)
|
### Checklist Format (REQUIRED)
|
||||||
|
|
||||||
Every task MUST strictly follow this format:
|
Every task MUST strictly follow this format:
|
||||||
|
|||||||
116
.kilocode/workflows/speckit.test.md
Normal file
116
.kilocode/workflows/speckit.test.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
№ **speckit.tasks.md**
|
||||||
|
### Modified Workflow
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
|
||||||
|
handoffs:
|
||||||
|
- label: Analyze For Consistency
|
||||||
|
agent: speckit.analyze
|
||||||
|
prompt: Run a project analysis for consistency
|
||||||
|
send: true
|
||||||
|
- label: Implement Project
|
||||||
|
agent: speckit.implement
|
||||||
|
prompt: Start the implementation in phases
|
||||||
|
send: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
2. **Load design documents**: Read from FEATURE_DIR:
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
3. **Execute task generation workflow**:
|
||||||
|
- **Architecture Analysis (CRITICAL)**: Scan existing codebase for patterns (DI, Auth, ORM).
|
||||||
|
- Load plan.md/spec.md.
|
||||||
|
- Generate tasks organized by user story.
|
||||||
|
- **Apply Fractal Co-location**: Ensure all unit tests are mapped to `__tests__` subdirectories relative to the code.
|
||||||
|
- Validate task completeness.
|
||||||
|
|
||||||
|
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure.
|
||||||
|
- Phase 1: Context & Setup.
|
||||||
|
- Phase 2: Foundational tasks.
|
||||||
|
- Phase 3+: User Stories (Priority order).
|
||||||
|
- Final Phase: Polish.
|
||||||
|
- **Strict Constraint**: Ensure tasks follow the Co-location and Mocking rules below.
|
||||||
|
|
||||||
|
5. **Report**: Output path to generated tasks.md and summary.
|
||||||
|
|
||||||
|
Context for task generation: $ARGUMENTS
|
||||||
|
|
||||||
|
## Task Generation Rules
|
||||||
|
|
||||||
|
**CRITICAL**: Tasks MUST be actionable, specific, architecture-aware, and context-local.
|
||||||
|
|
||||||
|
### Implementation & Testing Constraints (ANTI-LOOP & CO-LOCATION)
|
||||||
|
|
||||||
|
To prevent infinite debugging loops and context fragmentation, apply these rules:
|
||||||
|
|
||||||
|
1. **Fractal Co-location Strategy (MANDATORY)**:
|
||||||
|
- **Rule**: Unit tests MUST live next to the code they verify.
|
||||||
|
- **Forbidden**: Do NOT create unit tests in root `tests/` or `backend/tests/`. Those are for E2E/Integration only.
|
||||||
|
- **Pattern (Python)**:
|
||||||
|
- Source: `src/domain/order/processing.py`
|
||||||
|
- Test Task: `Create tests in src/domain/order/__tests__/test_processing.py`
|
||||||
|
- **Pattern (Frontend)**:
|
||||||
|
- Source: `src/lib/components/UserCard.svelte`
|
||||||
|
- Test Task: `Create tests in src/lib/components/__tests__/UserCard.test.ts`
|
||||||
|
|
||||||
|
2. **Semantic Relations**:
|
||||||
|
- Test generation tasks must explicitly instruct to add the relation header: `# @RELATION: VERIFIES -> [TargetComponent]`
|
||||||
|
|
||||||
|
3. **Strict Mocking for Unit Tests**:
|
||||||
|
- Any task creating Unit Tests MUST specify: *"Use `unittest.mock.MagicMock` for heavy dependencies (DB sessions, Auth). Do NOT instantiate real service classes."*
|
||||||
|
|
||||||
|
4. **Schema/Model Separation**:
|
||||||
|
- Explicitly separate tasks for ORM Models (SQLAlchemy) and Pydantic Schemas.
|
||||||
|
|
||||||
|
### UX Preservation (CRITICAL)
|
||||||
|
|
||||||
|
- **Source of Truth**: `ux_reference.md` is the absolute standard.
|
||||||
|
- **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:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
- ✅ `- [ ] T005 [US1] Create unit tests for OrderService in src/services/__tests__/test_order.py (Mock DB)`
|
||||||
|
- ✅ `- [ ] T006 [US1] Implement OrderService in src/services/order.py`
|
||||||
|
- ❌ `- [ ] T005 [US1] Create tests in backend/tests/test_order.py` (VIOLATION: Wrong location)
|
||||||
|
|
||||||
|
### Task Organization & Phase Structure
|
||||||
|
|
||||||
|
**Phase 1: Context & Setup**
|
||||||
|
- **Goal**: Prepare environment and understand existing patterns.
|
||||||
|
- **Mandatory Task**: `- [ ] T001 Analyze existing project structure, auth patterns, and `conftest.py` location`
|
||||||
|
|
||||||
|
**Phase 2: Foundational (Data & Core)**
|
||||||
|
- Database Models (ORM).
|
||||||
|
- Pydantic Schemas (DTOs).
|
||||||
|
- Core Service interfaces.
|
||||||
|
|
||||||
|
**Phase 3+: User Stories (Iterative)**
|
||||||
|
- **Step 1: Isolation Tests (Co-located)**:
|
||||||
|
- `- [ ] Txxx [USx] Create unit tests for [Component] in [Path]/__tests__/test_[name].py`
|
||||||
|
- *Note: Specify using MagicMock for external deps.*
|
||||||
|
- **Step 2: Implementation**: Services -> Endpoints.
|
||||||
|
- **Step 3: Integration**: Wire up real dependencies (if E2E tests requested).
|
||||||
|
- **Step 4: UX Verification**.
|
||||||
|
|
||||||
|
**Final Phase: Polish**
|
||||||
|
- Linting, formatting, final manual verify.
|
||||||
@@ -2,24 +2,45 @@ customModes:
|
|||||||
- slug: tester
|
- slug: tester
|
||||||
name: Tester
|
name: Tester
|
||||||
description: QA and Plan Verification Specialist
|
description: QA and Plan Verification Specialist
|
||||||
roleDefinition: >-
|
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.
|
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.
|
||||||
Your responsibilities include:
|
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.
|
||||||
- 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.
|
|
||||||
groups:
|
groups:
|
||||||
- read
|
- read
|
||||||
- edit
|
- edit
|
||||||
- command
|
- command
|
||||||
- browser
|
- browser
|
||||||
- mcp
|
- mcp
|
||||||
customInstructions: >-
|
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.
|
||||||
1. Always begin by loading the relevant plan or task list from the `specs/` directory.
|
- slug: semantic
|
||||||
2. Do not assume a task is done just because it is checked; verify the code or functionality first if asked to audit.
|
name: Semantic Agent
|
||||||
3. When updating task lists, ensure you only mark items as complete if you have verified them.
|
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 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.
|
||||||
|
description: Codebase semantic mapping and compliance expert
|
||||||
|
customInstructions: Always check `semantics/reports/` for the latest compliance status before starting work. When fixing a file, try to fix all semantic issues in that file at once. After making a batch of fixes, run `python3 generate_semantic_map.py` to verify improvements.
|
||||||
|
groups:
|
||||||
|
- read
|
||||||
|
- edit
|
||||||
|
- command
|
||||||
|
- browser
|
||||||
|
- mcp
|
||||||
|
source: project
|
||||||
|
- slug: product-manager
|
||||||
|
name: Product Manager
|
||||||
|
roleDefinition: |-
|
||||||
|
Your purpose is to rigorously execute the workflows defined in `.kilocode/workflows/`.
|
||||||
|
You act as the orchestrator for: - Specification (`speckit.specify`, `speckit.clarify`) - Planning (`speckit.plan`) - Task Management (`speckit.tasks`, `speckit.taskstoissues`) - Quality Assurance (`speckit.analyze`, `speckit.checklist`) - 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 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.
|
||||||
|
groups:
|
||||||
|
- read
|
||||||
|
- edit
|
||||||
|
- command
|
||||||
|
- mcp
|
||||||
|
source: project
|
||||||
|
|||||||
@@ -1,99 +1,55 @@
|
|||||||
<!--
|
<!--
|
||||||
SYNC IMPACT REPORT
|
SYNC IMPACT REPORT
|
||||||
Version: 1.5.0 (Fractal Complexity Limit)
|
Version: 2.2.0 (ConfigManager Discipline)
|
||||||
Changes:
|
Changes:
|
||||||
- Added Section VI (Fractal Complexity Limit) to enforce strict module (~300 lines) and function (~30-50 lines) size limits.
|
- Updated Principle II: Added mandatory requirement for using `ConfigManager` (via dependency injection) for all configuration access to ensure consistent environment handling and avoid hardcoded values.
|
||||||
- Aims to maintain semantic coherence and avoid "Attention Sink".
|
- Updated Principle III: Refined `requestApi` requirement.
|
||||||
Templates Status:
|
Templates Status:
|
||||||
- .specify/templates/plan-template.md: ✅ Aligned.
|
- .specify/templates/plan-template.md: ✅ Aligned.
|
||||||
- .specify/templates/spec-template.md: ✅ Aligned.
|
- .specify/templates/spec-template.md: ✅ Aligned.
|
||||||
- .specify/templates/tasks-arch-template.md: ✅ Aligned (New role-based split).
|
- .specify/templates/tasks-template.md: ✅ Aligned.
|
||||||
- .specify/templates/tasks-dev-template.md: ✅ Aligned (New role-based split).
|
|
||||||
-->
|
-->
|
||||||
# Semantic Code Generation Constitution
|
# Semantic Code Generation Constitution
|
||||||
|
|
||||||
## Core Principles
|
## Core Principles
|
||||||
|
|
||||||
### I. Causal Validity (Contracts First)
|
### I. Semantic Protocol Compliance
|
||||||
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. This ensures that the "what" and "why" govern the "how".
|
The file `semantic_protocol.md` is the **sole and authoritative technical standard** for this project.
|
||||||
|
- **Law**: All code must adhere to the Axioms (Meaning First, Contract First, etc.) defined in the Protocol.
|
||||||
|
- **Syntax & Structure**: Anchors (`[DEF]`), Tags (`@KEY`), and File Structures must strictly match the Protocol.
|
||||||
|
- **Compliance**: Any deviation from `semantic_protocol.md` constitutes a build failure.
|
||||||
|
|
||||||
### II. Immutability of Architecture
|
### II. Everything is a Plugin & Centralized Config
|
||||||
Once defined, architectural decisions in the Module Header (`@LAYER`, `@INVARIANT`, `@CONSTRAINT`) are treated as immutable constraints for that module. Changes to these require an explicit refactoring step, not ad-hoc modification during implementation.
|
All functional extensions, tools, or major features must be implemented as modular Plugins inheriting from `PluginBase`.
|
||||||
|
- **Modularity**: Logic should not reside in standalone services or scripts unless strictly necessary for core infrastructure. This ensures a unified execution model via the `TaskManager`.
|
||||||
|
- **Configuration Discipline**: All configuration access (environments, settings, paths) MUST use the `ConfigManager`. In the backend, the singleton instance MUST be obtained via dependency injection (`get_config_manager()`). Hardcoding environment IDs (e.g., "1") or paths is STRICTLY FORBIDDEN.
|
||||||
|
|
||||||
### III. Semantic Format Compliance
|
### III. Unified Frontend Experience
|
||||||
All output must strictly follow the `[DEF]` / `[/DEF]` anchor syntax with specific Metadata Tags (`@KEY`) and Graph Relations (`@RELATION`). **Crucially, the closing anchor must strictly match the full content of the opening anchor (e.g., `[DEF:identifier:Type]` must close with `[/DEF:identifier:Type]`).**
|
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.
|
||||||
|
- **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`).
|
||||||
|
- **Backend Communication**: All API requests MUST use the `requestApi` wrapper (or its derivatives like `fetchApi`, `postApi`) from `src/lib/api.js`. Direct use of the native `fetch` API for backend communication is FORBIDDEN to ensure consistent authentication (JWT) and error handling.
|
||||||
|
|
||||||
**Standardized Graph Relations**
|
### IV. Security & Access Control
|
||||||
To ensure the integrity of the Semantic Graph, `@RELATION` must use a strict taxonomy:
|
To support the Role-Based Access Control (RBAC) system, all functional components must define explicit permissions.
|
||||||
- `DEPENDS_ON` (Structural dependency)
|
- **Granular Permissions**: Every Plugin MUST define a unique permission string (e.g., `plugin:name:execute`) required for its operation.
|
||||||
- `CALLS` (Flow control)
|
- **Registration**: These permissions MUST be registered in the system database (`auth.db`) during initialization.
|
||||||
- `CREATES` (Instantiation)
|
|
||||||
- `INHERITS_FROM` / `IMPLEMENTS` (OOP hierarchy)
|
|
||||||
- `READS_STATE` / `WRITES_STATE` (Data flow)
|
|
||||||
- `DISPATCHES` / `HANDLES` (Event flow)
|
|
||||||
|
|
||||||
Ad-hoc relationships are forbidden. This structure is non-negotiable as it ensures the codebase remains machine-readable, fractal-structured, and optimized for Sparse Attention navigation by AI agents.
|
### V. Independent Testability
|
||||||
|
Every feature specification MUST define "Independent Tests" that allow the feature to be verified in isolation.
|
||||||
|
- **Decoupling**: Features should be designed such that they can be tested without requiring the full application state or external dependencies where possible.
|
||||||
|
- **Verification**: A feature is not complete until its Independent Test scenarios pass.
|
||||||
|
|
||||||
### IV. Design by Contract (DbC)
|
### VI. Asynchronous Execution
|
||||||
Contracts are the Source of Truth. Functions and Classes must define their purpose, specifications, and constraints (`@PRE`, `@POST`, `@THROW`) in the metadata block before implementation. Implementation must strictly satisfy these contracts.
|
All long-running or resource-intensive operations (migrations, analysis, backups, external API calls) MUST be executed as asynchronous tasks via the `TaskManager`.
|
||||||
|
- **Non-Blocking**: HTTP API endpoints MUST NOT block on these operations; they should spawn a task and return a Task ID.
|
||||||
### V. Belief State Logging
|
- **Observability**: Tasks MUST emit real-time status updates via the WebSocket infrastructure.
|
||||||
Logs must define the agent's internal state for debugging and coherence checks. We use a strict format: `[{ANCHOR_ID}][{STATE}] {MESSAGE}`. For Python, a **Context Manager** pattern MUST be used to automatically handle `Entry`, `Exit`, and `Coherence` states, ensuring structural integrity and error capturing.
|
|
||||||
|
|
||||||
### VI. Fractal Complexity Limit
|
|
||||||
To maintain semantic coherence and avoid "Attention Sink" issues:
|
|
||||||
- **Module Size**: If a Module body exceeds ~300 lines (or logical complexity), it MUST be refactored into sub-modules or a package structure.
|
|
||||||
- **Function Size**: Functions should fit within a standard attention "chunk" (approx. 30-50 lines). If larger, logic MUST be decomposed into helper functions with their own contracts.
|
|
||||||
|
|
||||||
This ensures every vector embedding remains sharp and focused.
|
|
||||||
|
|
||||||
## File Structure Standards
|
|
||||||
|
|
||||||
### Python Modules
|
|
||||||
Every `.py` file must start with a Module definition header (`[DEF:module_name:Module]`) containing:
|
|
||||||
- `@SEMANTICS`: Keywords for vector search.
|
|
||||||
- `@PURPOSE`: Primary responsibility of the module.
|
|
||||||
- `@LAYER`: Architecture layer (Domain/Infra/UI).
|
|
||||||
- `@RELATION`: Dependencies.
|
|
||||||
- `@INVARIANT` & `@CONSTRAINT`: Immutable rules.
|
|
||||||
- `@PUBLIC_API`: Exported symbols.
|
|
||||||
|
|
||||||
### Svelte Components
|
|
||||||
Every `.svelte` file must start with a Component definition header (`[DEF:ComponentName:Component]`) wrapped in an HTML comment `<!-- ... -->` containing:
|
|
||||||
- `@SEMANTICS`: Keywords for vector search.
|
|
||||||
- `@PURPOSE`: Primary responsibility of the component.
|
|
||||||
- `@LAYER`: Architecture layer (UI/State/Layout).
|
|
||||||
- `@RELATION`: Child components, Stores used, API calls.
|
|
||||||
- `@PROPS`: Input properties.
|
|
||||||
- `@EVENTS`: Emitted events.
|
|
||||||
- `@INVARIANT`: Immutable UI/State rules.
|
|
||||||
|
|
||||||
## Generation Workflow
|
|
||||||
The development process follows a strict sequence enforced by Agent Roles:
|
|
||||||
|
|
||||||
### 1. Architecture Phase (Mode: `tech-lead`)
|
|
||||||
**Input**: `tasks-arch.md`
|
|
||||||
**Responsibility**:
|
|
||||||
- Analyze request and graph position.
|
|
||||||
- Generate `[DEF]` anchors, Headers, and Contracts (`@PRE`, `@POST`).
|
|
||||||
- **Output**: Scaffolding files with no implementation logic.
|
|
||||||
|
|
||||||
### 2. Implementation Phase (Mode: `code`)
|
|
||||||
**Input**: `tasks-dev.md` + Scaffolding files
|
|
||||||
**Responsibility**:
|
|
||||||
- Read contracts defined by Architect.
|
|
||||||
- Write implementation code that strictly satisfies contracts.
|
|
||||||
- **Output**: Working code with passing tests.
|
|
||||||
|
|
||||||
### 3. Validation
|
|
||||||
If logic conflicts with Contract -> Stop -> Report Error.
|
|
||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
This Constitution establishes the "Semantic Code Generation Protocol" as the supreme law of this repository.
|
This Constitution establishes the "Semantic Code Generation Protocol" as the supreme law of this repository.
|
||||||
|
|
||||||
- **Automated Enforcement**: All code generation tools and agents must parse and validate adherence to the `[DEF]` syntax and Contract requirements.
|
- **Authoritative Source**: `semantic_protocol.md` defines the specific implementation rules for Principle I.
|
||||||
- **Amendments**: Changes to the syntax or core principles require a formal amendment to this Constitution and a corresponding update to the constitution
|
- **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
|
||||||
- **Review**: Code reviews must verify that implementation matches the preceding contracts and that no "naked code" exists outside of semantic anchors.
|
- **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
|
||||||
- **Compliance**: Failure to adhere to the `[DEF]` / `[/DEF]` structure (including matching closing tags) constitutes a build failure.
|
|
||||||
|
|
||||||
**Version**: 1.5.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2025-12-27
|
**Version**: 2.2.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-29
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
#
|
#
|
||||||
# OPTIONS:
|
# OPTIONS:
|
||||||
# --json Output in JSON format
|
# --json Output in JSON format
|
||||||
# --require-tasks Require tasks-arch.md and tasks-dev.md to exist (for implementation phase)
|
# --require-tasks Require tasks.md to exist (for implementation phase)
|
||||||
# --include-tasks Include task files in AVAILABLE_DOCS list
|
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||||
# --paths-only Only output path variables (no validation)
|
# --paths-only Only output path variables (no validation)
|
||||||
# --help, -h Show help message
|
# --help, -h Show help message
|
||||||
#
|
#
|
||||||
@@ -49,8 +49,8 @@ Consolidated prerequisite checking for Spec-Driven Development workflow.
|
|||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
--json Output in JSON format
|
--json Output in JSON format
|
||||||
--require-tasks Require tasks-arch.md and tasks-dev.md to exist (for implementation phase)
|
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||||
--include-tasks Include task files in AVAILABLE_DOCS list
|
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||||
--paths-only Only output path variables (no prerequisite validation)
|
--paths-only Only output path variables (no prerequisite validation)
|
||||||
--help, -h Show this help message
|
--help, -h Show this help message
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ EXAMPLES:
|
|||||||
# Check task prerequisites (plan.md required)
|
# Check task prerequisites (plan.md required)
|
||||||
./check-prerequisites.sh --json
|
./check-prerequisites.sh --json
|
||||||
|
|
||||||
# Check implementation prerequisites (plan.md + task files required)
|
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||||
./check-prerequisites.sh --json --require-tasks --include-tasks
|
./check-prerequisites.sh --json --require-tasks --include-tasks
|
||||||
|
|
||||||
# Get feature paths only (no validation)
|
# Get feature paths only (no validation)
|
||||||
@@ -86,16 +86,15 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
|||||||
if $PATHS_ONLY; then
|
if $PATHS_ONLY; then
|
||||||
if $JSON_MODE; then
|
if $JSON_MODE; then
|
||||||
# Minimal JSON paths payload (no validation performed)
|
# Minimal JSON paths payload (no validation performed)
|
||||||
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS_ARCH":"%s","TASKS_DEV":"%s"}\n' \
|
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
|
||||||
"$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS_ARCH" "$TASKS_DEV"
|
"$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS"
|
||||||
else
|
else
|
||||||
echo "REPO_ROOT: $REPO_ROOT"
|
echo "REPO_ROOT: $REPO_ROOT"
|
||||||
echo "BRANCH: $CURRENT_BRANCH"
|
echo "BRANCH: $CURRENT_BRANCH"
|
||||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||||
echo "TASKS_ARCH: $TASKS_ARCH"
|
echo "TASKS: $TASKS"
|
||||||
echo "TASKS_DEV: $TASKS_DEV"
|
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -113,20 +112,11 @@ if [[ ! -f "$IMPL_PLAN" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check for task files if required
|
# Check for tasks.md if required
|
||||||
if $REQUIRE_TASKS; then
|
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
||||||
# Check for split tasks first
|
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||||
if [[ -f "$TASKS_ARCH" ]] && [[ -f "$TASKS_DEV" ]]; then
|
echo "Run /speckit.tasks first to create the task list." >&2
|
||||||
: # Split tasks exist, proceed
|
exit 1
|
||||||
# Fallback to unified tasks.md
|
|
||||||
elif [[ -f "$TASKS" ]]; then
|
|
||||||
: # Unified tasks exist, proceed
|
|
||||||
else
|
|
||||||
echo "ERROR: No valid task files found in $FEATURE_DIR" >&2
|
|
||||||
echo "Expected 'tasks-arch.md' AND 'tasks-dev.md' (split) OR 'tasks.md' (unified)" >&2
|
|
||||||
echo "Run /speckit.tasks first to create the task lists." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build list of available documents
|
# Build list of available documents
|
||||||
@@ -143,14 +133,9 @@ fi
|
|||||||
|
|
||||||
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||||
|
|
||||||
# Include task files if requested and they exist
|
# Include tasks.md if requested and it exists
|
||||||
if $INCLUDE_TASKS; then
|
if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
|
||||||
if [[ -f "$TASKS_ARCH" ]] || [[ -f "$TASKS_DEV" ]]; then
|
docs+=("tasks.md")
|
||||||
[[ -f "$TASKS_ARCH" ]] && docs+=("tasks-arch.md")
|
|
||||||
[[ -f "$TASKS_DEV" ]] && docs+=("tasks-dev.md")
|
|
||||||
elif [[ -f "$TASKS" ]]; then
|
|
||||||
docs+=("tasks.md")
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Output results
|
# Output results
|
||||||
@@ -176,11 +161,6 @@ else
|
|||||||
check_file "$QUICKSTART" "quickstart.md"
|
check_file "$QUICKSTART" "quickstart.md"
|
||||||
|
|
||||||
if $INCLUDE_TASKS; then
|
if $INCLUDE_TASKS; then
|
||||||
if [[ -f "$TASKS_ARCH" ]] || [[ -f "$TASKS_DEV" ]]; then
|
check_file "$TASKS" "tasks.md"
|
||||||
check_file "$TASKS_ARCH" "tasks-arch.md"
|
|
||||||
check_file "$TASKS_DEV" "tasks-dev.md"
|
|
||||||
else
|
|
||||||
check_file "$TASKS" "tasks.md"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -143,9 +143,7 @@ HAS_GIT='$has_git_repo'
|
|||||||
FEATURE_DIR='$feature_dir'
|
FEATURE_DIR='$feature_dir'
|
||||||
FEATURE_SPEC='$feature_dir/spec.md'
|
FEATURE_SPEC='$feature_dir/spec.md'
|
||||||
IMPL_PLAN='$feature_dir/plan.md'
|
IMPL_PLAN='$feature_dir/plan.md'
|
||||||
TASKS_ARCH='$feature_dir/tasks-arch.md'
|
TASKS='$feature_dir/tasks.md'
|
||||||
TASKS_DEV='$feature_dir/tasks-dev.md'
|
|
||||||
TASKS='$feature_dir/tasks.md' # Deprecated
|
|
||||||
RESEARCH='$feature_dir/research.md'
|
RESEARCH='$feature_dir/research.md'
|
||||||
DATA_MODEL='$feature_dir/data-model.md'
|
DATA_MODEL='$feature_dir/data-model.md'
|
||||||
QUICKSTART='$feature_dir/quickstart.md'
|
QUICKSTART='$feature_dir/quickstart.md'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Feature Specification: [FEATURE NAME]
|
# Feature Specification: [FEATURE NAME]
|
||||||
|
|
||||||
**Feature Branch**: `[###-feature-name]`
|
**Feature Branch**: `[###-feature-name]`
|
||||||
|
**Reference UX**: `[ux_reference.md]` (See specific folder)
|
||||||
**Created**: [DATE]
|
**Created**: [DATE]
|
||||||
**Status**: Draft
|
**Status**: Draft
|
||||||
**Input**: User description: "$ARGUMENTS"
|
**Input**: User description: "$ARGUMENTS"
|
||||||
|
|||||||
@@ -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
|
|
||||||
251
.specify/templates/tasks-template.md
Normal file
251
.specify/templates/tasks-template.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list template for feature implementation"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: [FEATURE NAME]
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
|
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
## Path Conventions
|
||||||
|
|
||||||
|
- **Single project**: `src/`, `tests/` at repository root
|
||||||
|
- **Web app**: `backend/src/`, `frontend/src/`
|
||||||
|
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
||||||
|
- Paths shown below assume single project - adjust based on plan.md structure
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============================================================================
|
||||||
|
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||||
|
|
||||||
|
The /speckit.tasks command MUST replace these with actual tasks based on:
|
||||||
|
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||||
|
- Feature requirements from plan.md
|
||||||
|
- Entities from data-model.md
|
||||||
|
- Endpoints from contracts/
|
||||||
|
|
||||||
|
Tasks MUST be organized by user story so each story can be:
|
||||||
|
- Implemented independently
|
||||||
|
- Tested independently
|
||||||
|
- Delivered as an MVP increment
|
||||||
|
|
||||||
|
DO NOT keep these sample tasks in the generated tasks.md file.
|
||||||
|
============================================================================
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Project initialization and basic structure
|
||||||
|
|
||||||
|
- [ ] T001 Create project structure per implementation plan
|
||||||
|
- [ ] T002 Initialize [language] project with [framework] dependencies
|
||||||
|
- [ ] T003 [P] Configure linting and formatting tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||||
|
|
||||||
|
Examples of foundational tasks (adjust based on your project):
|
||||||
|
|
||||||
|
- [ ] T004 Setup database schema and migrations framework
|
||||||
|
- [ ] T005 [P] Implement authentication/authorization framework
|
||||||
|
- [ ] T006 [P] Setup API routing and middleware structure
|
||||||
|
- [ ] T007 Create base models/entities that all stories depend on
|
||||||
|
- [ ] T008 Configure error handling and logging infrastructure
|
||||||
|
- [ ] T009 Setup environment configuration management
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: [Brief description of what this story delivers]
|
||||||
|
|
||||||
|
**Independent Test**: [How to verify this story works on its own]
|
||||||
|
|
||||||
|
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||||
|
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
|
||||||
|
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
|
||||||
|
- [ ] 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
|
||||||
|
|
||||||
|
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - [Title] (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: [Brief description of what this story delivers]
|
||||||
|
|
||||||
|
**Independent Test**: [How to verify this story works on its own]
|
||||||
|
|
||||||
|
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
|
||||||
|
|
||||||
|
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||||
|
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
|
||||||
|
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
|
||||||
|
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
|
||||||
|
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
|
||||||
|
|
||||||
|
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - [Title] (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: [Brief description of what this story delivers]
|
||||||
|
|
||||||
|
**Independent Test**: [How to verify this story works on its own]
|
||||||
|
|
||||||
|
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
|
||||||
|
|
||||||
|
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||||
|
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
|
||||||
|
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
|
||||||
|
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
|
||||||
|
|
||||||
|
**Checkpoint**: All user stories should now be independently functional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Add more user story phases as needed, following the same pattern]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase N: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Improvements that affect multiple user stories
|
||||||
|
|
||||||
|
- [ ] TXXX [P] Documentation updates in docs/
|
||||||
|
- [ ] TXXX Code cleanup and refactoring
|
||||||
|
- [ ] TXXX Performance optimization across all stories
|
||||||
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||||
|
- [ ] TXXX Security hardening
|
||||||
|
- [ ] TXXX Run quickstart.md validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||||
|
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
|
||||||
|
- User stories can then proceed in parallel (if staffed)
|
||||||
|
- Or sequentially in priority order (P1 → P2 → P3)
|
||||||
|
- **Polish (Final Phase)**: Depends on all desired user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||||
|
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
|
||||||
|
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests (if included) MUST be written and FAIL before implementation
|
||||||
|
- Models before services
|
||||||
|
- Services before endpoints
|
||||||
|
- Core implementation before integration
|
||||||
|
- Story complete before moving to next priority
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- All Setup tasks marked [P] can run in parallel
|
||||||
|
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
|
||||||
|
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
|
||||||
|
- All tests for a user story marked [P] can run in parallel
|
||||||
|
- Models within a story marked [P] can run in parallel
|
||||||
|
- Different user stories can be worked on in parallel by different team members
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch all tests for User Story 1 together (if tests requested):
|
||||||
|
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
|
||||||
|
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
|
||||||
|
|
||||||
|
# Launch all models for User Story 1 together:
|
||||||
|
Task: "Create [Entity1] model in src/models/[entity1].py"
|
||||||
|
Task: "Create [Entity2] model in src/models/[entity2].py"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup
|
||||||
|
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||||
|
3. Complete Phase 3: User Story 1
|
||||||
|
4. **STOP and VALIDATE**: Test User Story 1 independently
|
||||||
|
5. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Setup + Foundational → Foundation ready
|
||||||
|
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
|
||||||
|
3. Add User Story 2 → Test independently → Deploy/Demo
|
||||||
|
4. Add User Story 3 → Test independently → Deploy/Demo
|
||||||
|
5. Each story adds value without breaking previous stories
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
With multiple developers:
|
||||||
|
|
||||||
|
1. Team completes Setup + Foundational together
|
||||||
|
2. Once Foundational is done:
|
||||||
|
- Developer A: User Story 1
|
||||||
|
- Developer B: User Story 2
|
||||||
|
- Developer C: User Story 3
|
||||||
|
3. Stories complete and integrate independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- Each user story should be independently completable and testable
|
||||||
|
- Verify tests fail before implementing
|
||||||
|
- Commit after each task or logical group
|
||||||
|
- Stop at any checkpoint to validate story independently
|
||||||
|
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
|
||||||
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"]
|
||||||
158
README.md
158
README.md
@@ -1,119 +1,77 @@
|
|||||||
# Инструменты автоматизации Superset
|
# Инструменты автоматизации Superset (ss-tools)
|
||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
Этот репозиторий содержит Python-скрипты и библиотеку (`superset_tool`) для автоматизации задач в Apache Superset, таких как:
|
**ss-tools** — это современная платформа для автоматизации и управления экосистемой Apache Superset. Проект перешел от набора CLI-скриптов к полноценному веб-приложению с архитектурой Backend (FastAPI) + Frontend (SvelteKit), обеспечивая удобный интерфейс для сложных операций.
|
||||||
- **Резервное копирование**: Экспорт всех дашбордов из экземпляра Superset в локальное хранилище.
|
|
||||||
- **Миграция**: Перенос и преобразование дашбордов между разными средами Superset (например, Development, Sandbox, Production).
|
## Основные возможности
|
||||||
|
|
||||||
|
### 🚀 Миграция и управление дашбордами
|
||||||
|
- **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**: SQLite (для хранения метаданных, задач и настроек доступа).
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
- `backup_script.py`: Основной скрипт для выполнения запланированного резервного копирования дашбордов Superset.
|
- `backend/` — Серверная часть, API и логика плагинов.
|
||||||
- `migration_script.py`: Основной скрипт для переноса конкретных дашбордов между окружениями, включая переопределение соединений с базами данных.
|
- `frontend/` — Клиентская часть (SvelteKit приложение).
|
||||||
- `search_script.py`: Скрипт для поиска данных во всех доступных датасетах на сервере
|
- `specs/` — Спецификации функций и планы реализации.
|
||||||
- `run_mapper.py`: CLI-скрипт для маппинга метаданных датасетов.
|
- `docs/` — Дополнительная документация по маппингу и разработке плагинов.
|
||||||
- `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`: Логика маппинга метаданных датасетов.
|
|
||||||
|
|
||||||
## Настройка
|
## Быстрый старт
|
||||||
|
|
||||||
### Требования
|
### Требования
|
||||||
- Python 3.9+
|
- Python 3.9+
|
||||||
- `pip` для управления пакетами.
|
- Node.js 18+
|
||||||
- `keyring` для безопасного хранения паролей.
|
- Настроенный доступ к API Superset
|
||||||
|
|
||||||
### Установка
|
### Запуск
|
||||||
1. **Клонируйте репозиторий:**
|
Для автоматической настройки окружений и запуска обоих серверов (Backend & Frontend) используйте скрипт:
|
||||||
```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 серверов одной командой:
|
|
||||||
```bash
|
```bash
|
||||||
./run.sh
|
./run.sh
|
||||||
```
|
```
|
||||||
|
*Скрипт создаст виртуальное окружение Python, установит зависимости `pip` и `npm`, и запустит сервисы.*
|
||||||
|
|
||||||
Опции:
|
Опции:
|
||||||
- `--skip-install`: Пропустить проверку и установку зависимостей.
|
- `--skip-install`: Пропустить установку зависимостей.
|
||||||
- `--help`: Показать справку.
|
- `--help`: Показать справку.
|
||||||
|
|
||||||
Переменные окружения:
|
Переменные окружения:
|
||||||
- `BACKEND_PORT`: Порт для backend (по умолчанию 8000).
|
- `BACKEND_PORT`: Порт API (по умолчанию 8000).
|
||||||
- `FRONTEND_PORT`: Порт для frontend (по умолчанию 5173).
|
- `FRONTEND_PORT`: Порт UI (по умолчанию 5173).
|
||||||
|
|
||||||
### Скрипт резервного копирования (`backup_script.py`)
|
## Разработка
|
||||||
Для создания резервных копий дашбордов из настроенных окружений Superset:
|
Проект следует строгим правилам разработки:
|
||||||
```bash
|
1. **Semantic Code Generation**: Использование протокола `semantic_protocol.md` для обеспечения надежности кода.
|
||||||
python backup_script.py
|
2. **Design by Contract (DbC)**: Определение предусловий и постусловий для ключевых функций.
|
||||||
```
|
3. **Constitution**: Соблюдение правил, описанных в конституции проекта в папке `.specify/`.
|
||||||
Резервные копии сохраняются в `P:\Superset\010 Бекапы\` по умолчанию. Логи хранятся в `P:\Superset\010 Бекапы\Logs`.
|
|
||||||
|
|
||||||
### Скрипт миграции (`migration_script.py`)
|
### Полезные команды
|
||||||
Для переноса конкретного дашборда:
|
- **Backend**: `cd backend && .venv/bin/python3 -m uvicorn src.app:app --reload`
|
||||||
```bash
|
- **Frontend**: `cd frontend && npm run dev`
|
||||||
python migration_script.py
|
- **Тесты**: `cd backend && .venv/bin/pytest`
|
||||||
```
|
|
||||||
|
|
||||||
### Скрипт поиска (`search_script.py`)
|
## Контакты и вклад
|
||||||
Для поиска по текстовым паттернам в метаданных датасетов Superset:
|
Для добавления новых функций или исправления ошибок, пожалуйста, ознакомьтесь с `docs/plugin_dev.md` и создайте соответствующую спецификацию в `specs/`.
|
||||||
```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`-модели для валидации данных.
|
|
||||||
- Реализуйте всестороннюю обработку ошибок с помощью пользовательских исключений.
|
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
2025-12-20 19:55:11,325 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 19:55:11,325 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 19:55:11,327 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 43, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/backend/venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 21:01:49,905 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 21:01:49,906 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 21:01:49,988 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 21:01:49,990 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 22:42:32,538 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 22:42:32,538 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 22:42:32,583 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 22:42:32,587 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 22:54:29,770 - INFO - [BackupPlugin][Entry] Starting backup for .
|
|
||||||
2025-12-20 22:54:29,771 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 22:54:29,831 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 22:54:29,833 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 22:54:34,078 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 22:54:34,078 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 22:54:34,079 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 22:54:34,079 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 22:59:25,060 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 22:59:25,060 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 22:59:25,114 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 22:59:25,117 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 23:00:31,156 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 23:00:31,156 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 23:00:31,157 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 23:00:31,162 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 23:00:34,710 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 23:00:34,710 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 23:00:34,710 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 23:00:34,711 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 23:01:43,894 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 23:01:43,894 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 23:01:43,895 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 23:01:43,895 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 23:04:07,731 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 23:04:07,731 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 23:04:07,732 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 23:04:07,732 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 23:06:39,641 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 23:06:39,642 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 23:06:39,687 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 23:06:39,689 - CRITICAL - [setup_clients][Failure] Critical error during client initialization: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
Traceback (most recent call last):
|
|
||||||
File "/home/user/ss-tools/superset_tool/utils/init_clients.py", line 66, in setup_clients
|
|
||||||
config = SupersetConfig(
|
|
||||||
^^^^^^^^^^^^^^^
|
|
||||||
File "/home/user/ss-tools/backend/.venv/lib/python3.12/site-packages/pydantic/main.py", line 250, in __init__
|
|
||||||
validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for SupersetConfig
|
|
||||||
base_url
|
|
||||||
Value error, Invalid URL format: https://superset.bebesh.ru. Must include '/api/v1'. [type=value_error, input_value='https://superset.bebesh.ru', input_type=str]
|
|
||||||
For further information visit https://errors.pydantic.dev/2.12/v/value_error
|
|
||||||
2025-12-20 23:30:36,090 - INFO - [BackupPlugin][Entry] Starting backup for superset.
|
|
||||||
2025-12-20 23:30:36,093 - INFO - [setup_clients][Enter] Starting Superset clients initialization.
|
|
||||||
2025-12-20 23:30:36,128 - INFO - [setup_clients][Action] Loading environments from ConfigManager
|
|
||||||
2025-12-20 23:30:36,129 - INFO - [SupersetClient.__init__][Enter] Initializing SupersetClient.
|
|
||||||
2025-12-20 23:30:36,129 - INFO - [APIClient.__init__][Entry] Initializing APIClient.
|
|
||||||
2025-12-20 23:30:36,130 - WARNING - [_init_session][State] SSL verification disabled.
|
|
||||||
2025-12-20 23:30:36,130 - INFO - [APIClient.__init__][Exit] APIClient initialized.
|
|
||||||
2025-12-20 23:30:36,130 - INFO - [SupersetClient.__init__][Exit] SupersetClient initialized.
|
|
||||||
2025-12-20 23:30:36,130 - INFO - [get_dashboards][Enter] Fetching dashboards.
|
|
||||||
2025-12-20 23:30:36,131 - INFO - [authenticate][Enter] Authenticating to https://superset.bebesh.ru/api/v1
|
|
||||||
2025-12-20 23:30:36,897 - INFO - [authenticate][Exit] Authenticated successfully.
|
|
||||||
2025-12-20 23:30:37,527 - INFO - [get_dashboards][Exit] Found 11 dashboards.
|
|
||||||
2025-12-20 23:30:37,527 - INFO - [BackupPlugin][Progress] Found 11 dashboards to export in superset.
|
|
||||||
2025-12-20 23:30:37,529 - INFO - [export_dashboard][Enter] Exporting dashboard 11.
|
|
||||||
2025-12-20 23:30:38,224 - INFO - [export_dashboard][Exit] Exported dashboard 11 to dashboard_export_20251220T203037.zip.
|
|
||||||
2025-12-20 23:30:38,225 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:38,226 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/FCC New Coder Survey 2018/dashboard_export_20251220T203037.zip
|
|
||||||
2025-12-20 23:30:38,227 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/FCC New Coder Survey 2018
|
|
||||||
2025-12-20 23:30:38,230 - INFO - [export_dashboard][Enter] Exporting dashboard 10.
|
|
||||||
2025-12-20 23:30:38,438 - INFO - [export_dashboard][Exit] Exported dashboard 10 to dashboard_export_20251220T203038.zip.
|
|
||||||
2025-12-20 23:30:38,438 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:38,439 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/COVID Vaccine Dashboard/dashboard_export_20251220T203038.zip
|
|
||||||
2025-12-20 23:30:38,439 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/COVID Vaccine Dashboard
|
|
||||||
2025-12-20 23:30:38,440 - INFO - [export_dashboard][Enter] Exporting dashboard 9.
|
|
||||||
2025-12-20 23:30:38,853 - INFO - [export_dashboard][Exit] Exported dashboard 9 to dashboard_export_20251220T203038.zip.
|
|
||||||
2025-12-20 23:30:38,853 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:38,856 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Sales Dashboard/dashboard_export_20251220T203038.zip
|
|
||||||
2025-12-20 23:30:38,856 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Sales Dashboard
|
|
||||||
2025-12-20 23:30:38,858 - INFO - [export_dashboard][Enter] Exporting dashboard 8.
|
|
||||||
2025-12-20 23:30:38,939 - INFO - [export_dashboard][Exit] Exported dashboard 8 to dashboard_export_20251220T203038.zip.
|
|
||||||
2025-12-20 23:30:38,940 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:38,941 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Unicode Test/dashboard_export_20251220T203038.zip
|
|
||||||
2025-12-20 23:30:38,941 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Unicode Test
|
|
||||||
2025-12-20 23:30:38,942 - INFO - [export_dashboard][Enter] Exporting dashboard 7.
|
|
||||||
2025-12-20 23:30:39,148 - INFO - [export_dashboard][Exit] Exported dashboard 7 to dashboard_export_20251220T203038.zip.
|
|
||||||
2025-12-20 23:30:39,148 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:39,149 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Video Game Sales/dashboard_export_20251220T203038.zip
|
|
||||||
2025-12-20 23:30:39,149 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Video Game Sales
|
|
||||||
2025-12-20 23:30:39,150 - INFO - [export_dashboard][Enter] Exporting dashboard 6.
|
|
||||||
2025-12-20 23:30:39,689 - INFO - [export_dashboard][Exit] Exported dashboard 6 to dashboard_export_20251220T203039.zip.
|
|
||||||
2025-12-20 23:30:39,689 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:39,690 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Featured Charts/dashboard_export_20251220T203039.zip
|
|
||||||
2025-12-20 23:30:39,691 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Featured Charts
|
|
||||||
2025-12-20 23:30:39,692 - INFO - [export_dashboard][Enter] Exporting dashboard 5.
|
|
||||||
2025-12-20 23:30:39,960 - INFO - [export_dashboard][Exit] Exported dashboard 5 to dashboard_export_20251220T203039.zip.
|
|
||||||
2025-12-20 23:30:39,960 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:39,961 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Slack Dashboard/dashboard_export_20251220T203039.zip
|
|
||||||
2025-12-20 23:30:39,961 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Slack Dashboard
|
|
||||||
2025-12-20 23:30:39,962 - INFO - [export_dashboard][Enter] Exporting dashboard 4.
|
|
||||||
2025-12-20 23:30:40,196 - INFO - [export_dashboard][Exit] Exported dashboard 4 to dashboard_export_20251220T203039.zip.
|
|
||||||
2025-12-20 23:30:40,196 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:40,197 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/deck.gl Demo/dashboard_export_20251220T203039.zip
|
|
||||||
2025-12-20 23:30:40,197 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/deck.gl Demo
|
|
||||||
2025-12-20 23:30:40,198 - INFO - [export_dashboard][Enter] Exporting dashboard 3.
|
|
||||||
2025-12-20 23:30:40,745 - INFO - [export_dashboard][Exit] Exported dashboard 3 to dashboard_export_20251220T203040.zip.
|
|
||||||
2025-12-20 23:30:40,746 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:40,760 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/Misc Charts/dashboard_export_20251220T203040.zip
|
|
||||||
2025-12-20 23:30:40,761 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/Misc Charts
|
|
||||||
2025-12-20 23:30:40,762 - INFO - [export_dashboard][Enter] Exporting dashboard 2.
|
|
||||||
2025-12-20 23:30:40,928 - INFO - [export_dashboard][Exit] Exported dashboard 2 to dashboard_export_20251220T203040.zip.
|
|
||||||
2025-12-20 23:30:40,929 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:40,930 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/USA Births Names/dashboard_export_20251220T203040.zip
|
|
||||||
2025-12-20 23:30:40,931 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/USA Births Names
|
|
||||||
2025-12-20 23:30:40,932 - INFO - [export_dashboard][Enter] Exporting dashboard 1.
|
|
||||||
2025-12-20 23:30:41,582 - INFO - [export_dashboard][Exit] Exported dashboard 1 to dashboard_export_20251220T203040.zip.
|
|
||||||
2025-12-20 23:30:41,582 - INFO - [save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: False
|
|
||||||
2025-12-20 23:30:41,749 - INFO - [save_and_unpack_dashboard][State] Dashboard saved to: backups/SUPERSET/World Bank's Data/dashboard_export_20251220T203040.zip
|
|
||||||
2025-12-20 23:30:41,750 - INFO - [archive_exports][Enter] Managing archive in backups/SUPERSET/World Bank's Data
|
|
||||||
2025-12-20 23:30:41,752 - INFO - [consolidate_archive_folders][Enter] Consolidating archives in backups/SUPERSET
|
|
||||||
2025-12-20 23:30:41,753 - INFO - [remove_empty_directories][Enter] Starting cleanup of empty directories in backups/SUPERSET
|
|
||||||
2025-12-20 23:30:41,758 - INFO - [remove_empty_directories][Exit] Removed 0 empty directories.
|
|
||||||
2025-12-20 23:30:41,758 - INFO - [BackupPlugin][CoherenceCheck:Passed] Backup logic completed for superset.
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
44
backend/delete_running_tasks.py
Normal file
44
backend/delete_running_tasks.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# [DEF:backend.delete_running_tasks:Module]
|
||||||
|
# @PURPOSE: Script to delete tasks with RUNNING status from the database.
|
||||||
|
# @LAYER: Utility
|
||||||
|
# @SEMANTICS: maintenance, database, cleanup
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from src.core.database import TasksSessionLocal
|
||||||
|
from src.models.task import TaskRecord
|
||||||
|
|
||||||
|
# [DEF:delete_running_tasks:Function]
|
||||||
|
# @PURPOSE: Delete all tasks with RUNNING status from the database.
|
||||||
|
# @PRE: Database is accessible and TaskRecord model is defined.
|
||||||
|
# @POST: All tasks with status 'RUNNING' are removed from the database.
|
||||||
|
def delete_running_tasks():
|
||||||
|
"""Delete all tasks with RUNNING status from the database."""
|
||||||
|
session: Session = TasksSessionLocal()
|
||||||
|
try:
|
||||||
|
# Find all task records with RUNNING status
|
||||||
|
running_tasks = session.query(TaskRecord).filter(TaskRecord.status == "RUNNING").all()
|
||||||
|
|
||||||
|
if not running_tasks:
|
||||||
|
print("No RUNNING tasks found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Found {len(running_tasks)} RUNNING tasks:")
|
||||||
|
for task in running_tasks:
|
||||||
|
print(f"- Task ID: {task.id}, Type: {task.type}")
|
||||||
|
|
||||||
|
# Delete the found tasks
|
||||||
|
session.query(TaskRecord).filter(TaskRecord.status == "RUNNING").delete(synchronize_session=False)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
print(f"Successfully deleted {len(running_tasks)} RUNNING tasks.")
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
print(f"Error deleting tasks: {e}")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
# [/DEF:delete_running_tasks:Function]
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
delete_running_tasks()
|
||||||
|
# [/DEF:backend.delete_running_tasks:Module]
|
||||||
1
backend/get_full_key.py
Normal file
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
58903
backend/logs/app.log.1
Normal file
58903
backend/logs/app.log.1
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,14 +1,56 @@
|
|||||||
fastapi
|
annotated-doc==0.0.4
|
||||||
uvicorn
|
annotated-types==0.7.0
|
||||||
pydantic
|
anyio==4.12.0
|
||||||
authlib
|
APScheduler==3.11.2
|
||||||
python-multipart
|
attrs==25.4.0
|
||||||
starlette
|
Authlib==1.6.6
|
||||||
jsonschema
|
certifi==2025.11.12
|
||||||
requests
|
cffi==2.0.0
|
||||||
keyring
|
charset-normalizer==3.4.4
|
||||||
httpx
|
click==8.3.1
|
||||||
PyYAML
|
cryptography==46.0.3
|
||||||
websockets
|
fastapi==0.126.0
|
||||||
rapidfuzz
|
greenlet==3.3.0
|
||||||
sqlalchemy
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httpx==0.28.1
|
||||||
|
idna==3.11
|
||||||
|
jaraco.classes==3.4.0
|
||||||
|
jaraco.context==6.0.1
|
||||||
|
jaraco.functools==4.3.0
|
||||||
|
jeepney==0.9.0
|
||||||
|
jsonschema==4.25.1
|
||||||
|
jsonschema-specifications==2025.9.1
|
||||||
|
keyring==25.7.0
|
||||||
|
more-itertools==10.8.0
|
||||||
|
pycparser==2.23
|
||||||
|
pydantic==2.12.5
|
||||||
|
pydantic-settings
|
||||||
|
pydantic_core==2.41.5
|
||||||
|
python-multipart==0.0.21
|
||||||
|
PyYAML==6.0.3
|
||||||
|
passlib[bcrypt]
|
||||||
|
python-jose[cryptography]
|
||||||
|
PyJWT
|
||||||
|
RapidFuzz==3.14.3
|
||||||
|
referencing==0.37.0
|
||||||
|
requests==2.32.5
|
||||||
|
rpds-py==0.30.0
|
||||||
|
SecretStorage==3.5.0
|
||||||
|
SQLAlchemy==2.0.45
|
||||||
|
starlette==0.50.0
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
tzlocal==5.3.1
|
||||||
|
urllib3==2.6.2
|
||||||
|
uvicorn==0.38.0
|
||||||
|
websockets==15.0.1
|
||||||
|
pandas
|
||||||
|
psycopg2-binary
|
||||||
|
openpyxl
|
||||||
|
GitPython==3.1.44
|
||||||
|
itsdangerous
|
||||||
|
email-validator
|
||||||
|
openai
|
||||||
|
playwright
|
||||||
|
tenacity
|
||||||
@@ -1,52 +1,118 @@
|
|||||||
# [DEF:AuthModule:Module]
|
# [DEF:backend.src.api.auth:Module]
|
||||||
# @SEMANTICS: auth, authentication, adfs, oauth, middleware
|
#
|
||||||
# @PURPOSE: Implements ADFS authentication using Authlib for FastAPI. It provides a dependency to protect endpoints.
|
# @SEMANTICS: api, auth, routes, login, logout
|
||||||
# @LAYER: UI (API)
|
# @PURPOSE: Authentication API endpoints.
|
||||||
# @RELATION: Used by API routers to protect endpoints that require authentication.
|
# @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
|
# [SECTION: IMPORTS]
|
||||||
from fastapi.security import OAuth2AuthorizationCodeBearer
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from authlib.integrations.starlette_client import OAuth
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from starlette.config import Config
|
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.
|
# [DEF:router:Variable]
|
||||||
# Create an in-memory .env file
|
# @PURPOSE: APIRouter instance for authentication routes.
|
||||||
from io import StringIO
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
config_data = StringIO("""
|
# [/DEF:router:Variable]
|
||||||
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)
|
|
||||||
|
|
||||||
oauth.register(
|
# [DEF:login_for_access_token:Function]
|
||||||
name='adfs',
|
# @PURPOSE: Authenticates a user and returns a JWT access token.
|
||||||
server_metadata_url=config('ADFS_SERVER_METADATA_URL'),
|
# @PRE: form_data contains username and password.
|
||||||
client_kwargs={'scope': 'openid profile email'}
|
# @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(
|
# [DEF:read_users_me:Function]
|
||||||
authorizationUrl="https://your-adfs-server/adfs/oauth2/authorize",
|
# @PURPOSE: Retrieves the profile of the currently authenticated user.
|
||||||
tokenUrl="https://your-adfs-server/adfs/oauth2/token",
|
# @PRE: Valid JWT token provided.
|
||||||
)
|
# @POST: Returns the current user's data.
|
||||||
|
# @PARAM: current_user (UserSchema) - The user extracted from the token.
|
||||||
|
# @RETURN: UserSchema - The current user profile.
|
||||||
|
@router.get("/me", response_model=UserSchema)
|
||||||
|
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
|
||||||
|
with belief_scope("api.auth.me"):
|
||||||
|
return current_user
|
||||||
|
# [/DEF:read_users_me:Function]
|
||||||
|
|
||||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
# [DEF:logout:Function]
|
||||||
"""
|
# @PURPOSE: Logs out the current user (placeholder for session revocation).
|
||||||
Dependency to get the current user from the ADFS token.
|
# @PRE: Valid JWT token provided.
|
||||||
This is a placeholder and needs to be fully implemented.
|
# @POST: Returns success message.
|
||||||
"""
|
@router.post("/logout")
|
||||||
# In a real implementation, you would:
|
async def logout(current_user: UserSchema = Depends(get_current_user)):
|
||||||
# 1. Validate the token with ADFS.
|
with belief_scope("api.auth.logout"):
|
||||||
# 2. Fetch user information.
|
log_security_event("LOGOUT", current_user.username)
|
||||||
# 3. Create a user object.
|
# In a stateless JWT setup, client-side token deletion is primary.
|
||||||
# For now, we'll just check if a token exists.
|
# Server-side revocation (blacklisting) can be added here if needed.
|
||||||
if not token:
|
return {"message": "Successfully logged out"}
|
||||||
raise HTTPException(
|
# [/DEF:logout:Function]
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Not authenticated",
|
# [DEF:login_adfs:Function]
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
# @PURPOSE: Initiates the ADFS OIDC login flow.
|
||||||
)
|
# @POST: Redirects the user to ADFS.
|
||||||
# A real implementation would return a user object.
|
@router.get("/login/adfs")
|
||||||
return {"placeholder_user": "user@example.com"}
|
async def login_adfs(request: starlette.requests.Request):
|
||||||
# [/DEF]
|
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,3 @@
|
|||||||
from . import plugins, tasks, settings
|
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage, admin
|
||||||
|
|
||||||
|
__all__ = ['plugins', 'tasks', 'settings', 'connections', 'environments', 'mappings', 'migration', 'git', 'storage', 'admin']
|
||||||
|
|||||||
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]
|
||||||
100
backend/src/api/routes/connections.py
Normal file
100
backend/src/api/routes/connections.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# [DEF:ConnectionsRouter:Module]
|
||||||
|
# @SEMANTICS: api, router, connections, database
|
||||||
|
# @PURPOSE: Defines the FastAPI router for managing external database connections.
|
||||||
|
# @LAYER: UI (API)
|
||||||
|
# @RELATION: Depends on SQLAlchemy session.
|
||||||
|
# @CONSTRAINT: Must use belief_scope for logging.
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ...core.database import get_db
|
||||||
|
from ...models.connection import ConnectionConfig
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from ...core.logger import logger, belief_scope
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# [DEF:ConnectionSchema:Class]
|
||||||
|
# @PURPOSE: Pydantic model for connection response.
|
||||||
|
class ConnectionSchema(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
host: Optional[str] = None
|
||||||
|
port: Optional[int] = None
|
||||||
|
database: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
# [/DEF:ConnectionSchema:Class]
|
||||||
|
|
||||||
|
# [DEF:ConnectionCreate:Class]
|
||||||
|
# @PURPOSE: Pydantic model for creating a connection.
|
||||||
|
class ConnectionCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
host: Optional[str] = None
|
||||||
|
port: Optional[int] = None
|
||||||
|
database: Optional[str] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
# [/DEF:ConnectionCreate:Class]
|
||||||
|
|
||||||
|
# [DEF:list_connections:Function]
|
||||||
|
# @PURPOSE: Lists all saved connections.
|
||||||
|
# @PRE: Database session is active.
|
||||||
|
# @POST: Returns list of connection configs.
|
||||||
|
# @PARAM: db (Session) - Database session.
|
||||||
|
# @RETURN: List[ConnectionSchema] - List of connections.
|
||||||
|
@router.get("", response_model=List[ConnectionSchema])
|
||||||
|
async def list_connections(db: Session = Depends(get_db)):
|
||||||
|
with belief_scope("ConnectionsRouter.list_connections"):
|
||||||
|
connections = db.query(ConnectionConfig).all()
|
||||||
|
return connections
|
||||||
|
# [/DEF:list_connections:Function]
|
||||||
|
|
||||||
|
# [DEF:create_connection:Function]
|
||||||
|
# @PURPOSE: Creates a new connection configuration.
|
||||||
|
# @PRE: Connection name is unique.
|
||||||
|
# @POST: Connection is saved to DB.
|
||||||
|
# @PARAM: connection (ConnectionCreate) - Config data.
|
||||||
|
# @PARAM: db (Session) - Database session.
|
||||||
|
# @RETURN: ConnectionSchema - Created connection.
|
||||||
|
@router.post("", response_model=ConnectionSchema, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_connection(connection: ConnectionCreate, db: Session = Depends(get_db)):
|
||||||
|
with belief_scope("ConnectionsRouter.create_connection", f"name={connection.name}"):
|
||||||
|
db_connection = ConnectionConfig(**connection.dict())
|
||||||
|
db.add(db_connection)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_connection)
|
||||||
|
logger.info(f"[ConnectionsRouter.create_connection][Success] Created connection {db_connection.id}")
|
||||||
|
return db_connection
|
||||||
|
# [/DEF:create_connection:Function]
|
||||||
|
|
||||||
|
# [DEF:delete_connection:Function]
|
||||||
|
# @PURPOSE: Deletes a connection configuration.
|
||||||
|
# @PRE: Connection ID exists.
|
||||||
|
# @POST: Connection is removed from DB.
|
||||||
|
# @PARAM: connection_id (str) - ID to delete.
|
||||||
|
# @PARAM: db (Session) - Database session.
|
||||||
|
# @RETURN: None.
|
||||||
|
@router.delete("/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_connection(connection_id: str, db: Session = Depends(get_db)):
|
||||||
|
with belief_scope("ConnectionsRouter.delete_connection", f"id={connection_id}"):
|
||||||
|
db_connection = db.query(ConnectionConfig).filter(ConnectionConfig.id == connection_id).first()
|
||||||
|
if not db_connection:
|
||||||
|
logger.error(f"[ConnectionsRouter.delete_connection][State] Connection {connection_id} not found")
|
||||||
|
raise HTTPException(status_code=404, detail="Connection not found")
|
||||||
|
db.delete(db_connection)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"[ConnectionsRouter.delete_connection][Success] Deleted connection {connection_id}")
|
||||||
|
return
|
||||||
|
# [/DEF:delete_connection:Function]
|
||||||
|
|
||||||
|
# [/DEF:ConnectionsRouter:Module]
|
||||||
105
backend/src/api/routes/dashboards.py
Normal file
105
backend/src/api/routes/dashboards.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# [DEF:backend.src.api.routes.dashboards:Module]
|
||||||
|
#
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: api, dashboards, resources, hub
|
||||||
|
# @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status
|
||||||
|
# @LAYER: API
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.dependencies
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.services.resource_service
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
|
||||||
|
#
|
||||||
|
# @INVARIANT: All dashboard responses include git_status and last_task metadata
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission
|
||||||
|
from ...core.logger import logger, belief_scope
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# [DEF:GitStatus:DataClass]
|
||||||
|
class GitStatus(BaseModel):
|
||||||
|
branch: Optional[str] = None
|
||||||
|
sync_status: Optional[str] = Field(None, pattern="^OK|DIFF$")
|
||||||
|
# [/DEF:GitStatus:DataClass]
|
||||||
|
|
||||||
|
# [DEF:LastTask:DataClass]
|
||||||
|
class LastTask(BaseModel):
|
||||||
|
task_id: Optional[str] = None
|
||||||
|
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
|
||||||
|
# [/DEF:LastTask:DataClass]
|
||||||
|
|
||||||
|
# [DEF:DashboardItem:DataClass]
|
||||||
|
class DashboardItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
slug: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
last_modified: Optional[str] = None
|
||||||
|
git_status: Optional[GitStatus] = None
|
||||||
|
last_task: Optional[LastTask] = None
|
||||||
|
# [/DEF:DashboardItem:DataClass]
|
||||||
|
|
||||||
|
# [DEF:DashboardsResponse:DataClass]
|
||||||
|
class DashboardsResponse(BaseModel):
|
||||||
|
dashboards: List[DashboardItem]
|
||||||
|
total: int
|
||||||
|
# [/DEF:DashboardsResponse:DataClass]
|
||||||
|
|
||||||
|
# [DEF:get_dashboards:Function]
|
||||||
|
# @PURPOSE: Fetch list of dashboards from a specific environment with Git status and last task status
|
||||||
|
# @PRE: env_id must be a valid environment ID
|
||||||
|
# @POST: Returns a list of dashboards with enhanced metadata
|
||||||
|
# @PARAM: env_id (str) - The environment ID to fetch dashboards from
|
||||||
|
# @PARAM: search (Optional[str]) - Filter by title/slug
|
||||||
|
# @RETURN: DashboardsResponse - List of dashboards with status metadata
|
||||||
|
# @RELATION: CALLS -> ResourceService.get_dashboards_with_status
|
||||||
|
@router.get("/api/dashboards", response_model=DashboardsResponse)
|
||||||
|
async def get_dashboards(
|
||||||
|
env_id: str,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
config_manager=Depends(get_config_manager),
|
||||||
|
task_manager=Depends(get_task_manager),
|
||||||
|
resource_service=Depends(get_resource_service),
|
||||||
|
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||||
|
):
|
||||||
|
with belief_scope("get_dashboards", f"env_id={env_id}, search={search}"):
|
||||||
|
# Validate environment exists
|
||||||
|
environments = config_manager.get_environments()
|
||||||
|
env = next((e for e in environments if e.id == env_id), None)
|
||||||
|
if not env:
|
||||||
|
logger.error(f"[get_dashboards][Coherence:Failed] Environment not found: {env_id}")
|
||||||
|
raise HTTPException(status_code=404, detail="Environment not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all tasks for status lookup
|
||||||
|
all_tasks = task_manager.get_all_tasks()
|
||||||
|
|
||||||
|
# Fetch dashboards with status using ResourceService
|
||||||
|
dashboards = await resource_service.get_dashboards_with_status(env, all_tasks)
|
||||||
|
|
||||||
|
# Apply search filter if provided
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
dashboards = [
|
||||||
|
d for d in dashboards
|
||||||
|
if search_lower in d.get('title', '').lower()
|
||||||
|
or search_lower in d.get('slug', '').lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"[get_dashboards][Coherence:OK] Returning {len(dashboards)} dashboards")
|
||||||
|
|
||||||
|
return DashboardsResponse(
|
||||||
|
dashboards=dashboards,
|
||||||
|
total=len(dashboards)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[get_dashboards][Coherence:Failed] Failed to fetch dashboards: {e}")
|
||||||
|
raise HTTPException(status_code=503, detail=f"Failed to fetch dashboards: {str(e)}")
|
||||||
|
# [/DEF:get_dashboards:Function]
|
||||||
|
|
||||||
|
# [/DEF:backend.src.api.routes.dashboards:Module]
|
||||||
103
backend/src/api/routes/datasets.py
Normal file
103
backend/src/api/routes/datasets.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# [DEF:backend.src.api.routes.datasets:Module]
|
||||||
|
#
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: api, datasets, resources, hub
|
||||||
|
# @PURPOSE: API endpoints for the Dataset Hub - listing datasets with mapping progress
|
||||||
|
# @LAYER: API
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.dependencies
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.services.resource_service
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.core.superset_client
|
||||||
|
#
|
||||||
|
# @INVARIANT: All dataset responses include last_task metadata
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission
|
||||||
|
from ...core.logger import logger, belief_scope
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# [DEF:MappedFields:DataClass]
|
||||||
|
class MappedFields(BaseModel):
|
||||||
|
total: int
|
||||||
|
mapped: int
|
||||||
|
# [/DEF:MappedFields:DataClass]
|
||||||
|
|
||||||
|
# [DEF:LastTask:DataClass]
|
||||||
|
class LastTask(BaseModel):
|
||||||
|
task_id: Optional[str] = None
|
||||||
|
status: Optional[str] = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
|
||||||
|
# [/DEF:LastTask:DataClass]
|
||||||
|
|
||||||
|
# [DEF:DatasetItem:DataClass]
|
||||||
|
class DatasetItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
table_name: str
|
||||||
|
schema: str
|
||||||
|
database: str
|
||||||
|
mapped_fields: Optional[MappedFields] = None
|
||||||
|
last_task: Optional[LastTask] = None
|
||||||
|
# [/DEF:DatasetItem:DataClass]
|
||||||
|
|
||||||
|
# [DEF:DatasetsResponse:DataClass]
|
||||||
|
class DatasetsResponse(BaseModel):
|
||||||
|
datasets: List[DatasetItem]
|
||||||
|
total: int
|
||||||
|
# [/DEF:DatasetsResponse:DataClass]
|
||||||
|
|
||||||
|
# [DEF:get_datasets:Function]
|
||||||
|
# @PURPOSE: Fetch list of datasets from a specific environment with mapping progress
|
||||||
|
# @PRE: env_id must be a valid environment ID
|
||||||
|
# @POST: Returns a list of datasets with enhanced metadata
|
||||||
|
# @PARAM: env_id (str) - The environment ID to fetch datasets from
|
||||||
|
# @PARAM: search (Optional[str]) - Filter by table name
|
||||||
|
# @RETURN: DatasetsResponse - List of datasets with status metadata
|
||||||
|
# @RELATION: CALLS -> ResourceService.get_datasets_with_status
|
||||||
|
@router.get("/api/datasets", response_model=DatasetsResponse)
|
||||||
|
async def get_datasets(
|
||||||
|
env_id: str,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
config_manager=Depends(get_config_manager),
|
||||||
|
task_manager=Depends(get_task_manager),
|
||||||
|
resource_service=Depends(get_resource_service),
|
||||||
|
_ = Depends(has_permission("plugin:migration", "READ"))
|
||||||
|
):
|
||||||
|
with belief_scope("get_datasets", f"env_id={env_id}, search={search}"):
|
||||||
|
# Validate environment exists
|
||||||
|
environments = config_manager.get_environments()
|
||||||
|
env = next((e for e in environments if e.id == env_id), None)
|
||||||
|
if not env:
|
||||||
|
logger.error(f"[get_datasets][Coherence:Failed] Environment not found: {env_id}")
|
||||||
|
raise HTTPException(status_code=404, detail="Environment not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all tasks for status lookup
|
||||||
|
all_tasks = task_manager.get_all_tasks()
|
||||||
|
|
||||||
|
# Fetch datasets with status using ResourceService
|
||||||
|
datasets = await resource_service.get_datasets_with_status(env, all_tasks)
|
||||||
|
|
||||||
|
# Apply search filter if provided
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
datasets = [
|
||||||
|
d for d in datasets
|
||||||
|
if search_lower in d.get('table_name', '').lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"[get_datasets][Coherence:OK] Returning {len(datasets)} datasets")
|
||||||
|
|
||||||
|
return DatasetsResponse(
|
||||||
|
datasets=datasets,
|
||||||
|
total=len(datasets)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[get_datasets][Coherence:Failed] Failed to fetch datasets: {e}")
|
||||||
|
raise HTTPException(status_code=503, detail=f"Failed to fetch datasets: {str(e)}")
|
||||||
|
# [/DEF:get_datasets:Function]
|
||||||
|
|
||||||
|
# [/DEF:backend.src.api.routes.datasets:Module]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# [DEF:backend.src.api.routes.environments:Module]
|
# [DEF:backend.src.api.routes.environments:Module]
|
||||||
#
|
#
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: api, environments, superset, databases
|
# @SEMANTICS: api, environments, superset, databases
|
||||||
# @PURPOSE: API endpoints for listing environments and their databases.
|
# @PURPOSE: API endpoints for listing environments and their databases.
|
||||||
# @LAYER: API
|
# @LAYER: API
|
||||||
@@ -10,69 +11,120 @@
|
|||||||
|
|
||||||
# [SECTION: IMPORTS]
|
# [SECTION: IMPORTS]
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Optional
|
||||||
from backend.src.dependencies import get_config_manager
|
from ...dependencies import get_config_manager, get_scheduler_service, has_permission
|
||||||
from backend.src.core.superset_client import SupersetClient
|
from ...core.superset_client import SupersetClient
|
||||||
from superset_tool.models import SupersetConfig
|
from pydantic import BaseModel, Field
|
||||||
from pydantic import BaseModel
|
from ...core.logger import belief_scope
|
||||||
# [/SECTION]
|
# [/SECTION]
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/environments", tags=["environments"])
|
router = APIRouter()
|
||||||
|
|
||||||
|
# [DEF:ScheduleSchema:DataClass]
|
||||||
|
class ScheduleSchema(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
cron_expression: str = Field(..., pattern=r'^(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|((((\d+,)*\d+|(\d+(\/|-)\d+)|\d+|\*) ?){4,6})$')
|
||||||
|
# [/DEF:ScheduleSchema:DataClass]
|
||||||
|
|
||||||
# [DEF:EnvironmentResponse:DataClass]
|
# [DEF:EnvironmentResponse:DataClass]
|
||||||
class EnvironmentResponse(BaseModel):
|
class EnvironmentResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
# [/DEF:EnvironmentResponse]
|
backup_schedule: Optional[ScheduleSchema] = None
|
||||||
|
# [/DEF:EnvironmentResponse:DataClass]
|
||||||
|
|
||||||
# [DEF:DatabaseResponse:DataClass]
|
# [DEF:DatabaseResponse:DataClass]
|
||||||
class DatabaseResponse(BaseModel):
|
class DatabaseResponse(BaseModel):
|
||||||
uuid: str
|
uuid: str
|
||||||
database_name: str
|
database_name: str
|
||||||
engine: Optional[str]
|
engine: Optional[str]
|
||||||
# [/DEF:DatabaseResponse]
|
# [/DEF:DatabaseResponse:DataClass]
|
||||||
|
|
||||||
# [DEF:get_environments:Function]
|
# [DEF:get_environments:Function]
|
||||||
# @PURPOSE: List all configured environments.
|
# @PURPOSE: List all configured environments.
|
||||||
|
# @PRE: config_manager is injected via Depends.
|
||||||
|
# @POST: Returns a list of EnvironmentResponse objects.
|
||||||
# @RETURN: List[EnvironmentResponse]
|
# @RETURN: List[EnvironmentResponse]
|
||||||
@router.get("", response_model=List[EnvironmentResponse])
|
@router.get("", response_model=List[EnvironmentResponse])
|
||||||
async def get_environments(config_manager=Depends(get_config_manager)):
|
async def get_environments(
|
||||||
envs = config_manager.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
|
# Ensure envs is a list
|
||||||
if not isinstance(envs, list):
|
if not isinstance(envs, list):
|
||||||
envs = []
|
envs = []
|
||||||
return [EnvironmentResponse(id=e.id, name=e.name, url=e.url) for e in envs]
|
return [
|
||||||
# [/DEF:get_environments]
|
EnvironmentResponse(
|
||||||
|
id=e.id,
|
||||||
|
name=e.name,
|
||||||
|
url=e.url,
|
||||||
|
backup_schedule=ScheduleSchema(
|
||||||
|
enabled=e.backup_schedule.enabled,
|
||||||
|
cron_expression=e.backup_schedule.cron_expression
|
||||||
|
) if getattr(e, 'backup_schedule', None) else None
|
||||||
|
) for e in envs
|
||||||
|
]
|
||||||
|
# [/DEF:get_environments:Function]
|
||||||
|
|
||||||
|
# [DEF:update_environment_schedule:Function]
|
||||||
|
# @PURPOSE: Update backup schedule for an environment.
|
||||||
|
# @PRE: Environment id exists, schedule is valid ScheduleSchema.
|
||||||
|
# @POST: Backup schedule updated and scheduler reloaded.
|
||||||
|
# @PARAM: id (str) - The environment ID.
|
||||||
|
# @PARAM: schedule (ScheduleSchema) - The new schedule.
|
||||||
|
@router.put("/{id}/schedule")
|
||||||
|
async def update_environment_schedule(
|
||||||
|
id: str,
|
||||||
|
schedule: ScheduleSchema,
|
||||||
|
config_manager=Depends(get_config_manager),
|
||||||
|
scheduler_service=Depends(get_scheduler_service),
|
||||||
|
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||||
|
):
|
||||||
|
with belief_scope("update_environment_schedule", f"id={id}"):
|
||||||
|
envs = config_manager.get_environments()
|
||||||
|
env = next((e for e in envs if e.id == id), None)
|
||||||
|
if not env:
|
||||||
|
raise HTTPException(status_code=404, detail="Environment not found")
|
||||||
|
|
||||||
|
# Update environment config
|
||||||
|
env.backup_schedule.enabled = schedule.enabled
|
||||||
|
env.backup_schedule.cron_expression = schedule.cron_expression
|
||||||
|
|
||||||
|
config_manager.update_environment(id, env)
|
||||||
|
|
||||||
|
# Refresh scheduler
|
||||||
|
scheduler_service.load_schedules()
|
||||||
|
|
||||||
|
return {"message": "Schedule updated successfully"}
|
||||||
|
# [/DEF:update_environment_schedule:Function]
|
||||||
|
|
||||||
# [DEF:get_environment_databases:Function]
|
# [DEF:get_environment_databases:Function]
|
||||||
# @PURPOSE: Fetch the list of databases from a specific environment.
|
# @PURPOSE: Fetch the list of databases from a specific environment.
|
||||||
|
# @PRE: Environment id exists.
|
||||||
|
# @POST: Returns a list of database summaries from the environment.
|
||||||
# @PARAM: id (str) - The environment ID.
|
# @PARAM: id (str) - The environment ID.
|
||||||
# @RETURN: List[Dict] - List of databases.
|
# @RETURN: List[Dict] - List of databases.
|
||||||
@router.get("/{id}/databases")
|
@router.get("/{id}/databases")
|
||||||
async def get_environment_databases(id: str, config_manager=Depends(get_config_manager)):
|
async def get_environment_databases(
|
||||||
envs = config_manager.get_environments()
|
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)
|
env = next((e for e in envs if e.id == id), None)
|
||||||
if not env:
|
if not env:
|
||||||
raise HTTPException(status_code=404, detail="Environment not found")
|
raise HTTPException(status_code=404, detail="Environment not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Initialize SupersetClient from environment config
|
# Initialize SupersetClient from environment config
|
||||||
# Note: We need to map Environment model to SupersetConfig
|
client = SupersetClient(env)
|
||||||
superset_config = SupersetConfig(
|
|
||||||
env=env.name,
|
|
||||||
base_url=env.url,
|
|
||||||
auth={
|
|
||||||
"provider": "db", # Defaulting to db provider
|
|
||||||
"username": env.username,
|
|
||||||
"password": env.password,
|
|
||||||
"refresh": "false"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
client = SupersetClient(superset_config)
|
|
||||||
return client.get_databases_summary()
|
return client.get_databases_summary()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to fetch databases: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to fetch databases: {str(e)}")
|
||||||
# [/DEF:get_environment_databases]
|
# [/DEF:get_environment_databases:Function]
|
||||||
|
|
||||||
# [/DEF:backend.src.api.routes.environments]
|
# [/DEF:backend.src.api.routes.environments:Module]
|
||||||
|
|||||||
456
backend/src/api/routes/git.py
Normal file
456
backend/src/api/routes/git.py
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
# [DEF:backend.src.api.routes.git:Module]
|
||||||
|
#
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: git, routes, api, fastapi, repository, deployment
|
||||||
|
# @PURPOSE: Provides FastAPI endpoints for Git integration operations.
|
||||||
|
# @LAYER: API
|
||||||
|
# @RELATION: USES -> src.services.git_service.GitService
|
||||||
|
# @RELATION: USES -> src.api.routes.git_schemas
|
||||||
|
# @RELATION: USES -> src.models.git
|
||||||
|
#
|
||||||
|
# @INVARIANT: All Git operations must be routed through GitService.
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
import typing
|
||||||
|
from src.dependencies import get_config_manager, has_permission
|
||||||
|
from src.core.database import get_db
|
||||||
|
from src.models.git import GitServerConfig, GitRepository
|
||||||
|
from src.api.routes.git_schemas import (
|
||||||
|
GitServerConfigSchema, GitServerConfigCreate,
|
||||||
|
BranchSchema, BranchCreate,
|
||||||
|
BranchCheckout, CommitSchema, CommitCreate,
|
||||||
|
DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest
|
||||||
|
)
|
||||||
|
from src.services.git_service import GitService
|
||||||
|
from src.core.logger import logger, belief_scope
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/git", tags=["git"])
|
||||||
|
git_service = GitService()
|
||||||
|
|
||||||
|
# [DEF:get_git_configs:Function]
|
||||||
|
# @PURPOSE: List all configured Git servers.
|
||||||
|
# @PRE: Database session `db` is available.
|
||||||
|
# @POST: Returns a list of all GitServerConfig objects from the database.
|
||||||
|
# @RETURN: List[GitServerConfigSchema]
|
||||||
|
@router.get("/config", response_model=List[GitServerConfigSchema])
|
||||||
|
async def get_git_configs(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(has_permission("admin:settings", "READ"))
|
||||||
|
):
|
||||||
|
with belief_scope("get_git_configs"):
|
||||||
|
return db.query(GitServerConfig).all()
|
||||||
|
# [/DEF:get_git_configs:Function]
|
||||||
|
|
||||||
|
# [DEF:create_git_config:Function]
|
||||||
|
# @PURPOSE: Register a new Git server configuration.
|
||||||
|
# @PRE: `config` contains valid GitServerConfigCreate data.
|
||||||
|
# @POST: A new GitServerConfig record is created in the database.
|
||||||
|
# @PARAM: config (GitServerConfigCreate)
|
||||||
|
# @RETURN: GitServerConfigSchema
|
||||||
|
@router.post("/config", response_model=GitServerConfigSchema)
|
||||||
|
async def create_git_config(
|
||||||
|
config: GitServerConfigCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||||
|
):
|
||||||
|
with belief_scope("create_git_config"):
|
||||||
|
db_config = GitServerConfig(**config.dict())
|
||||||
|
db.add(db_config)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_config)
|
||||||
|
return db_config
|
||||||
|
# [/DEF:create_git_config:Function]
|
||||||
|
|
||||||
|
# [DEF:delete_git_config:Function]
|
||||||
|
# @PURPOSE: Remove a Git server configuration.
|
||||||
|
# @PRE: `config_id` corresponds to an existing configuration.
|
||||||
|
# @POST: The configuration record is removed from the database.
|
||||||
|
# @PARAM: config_id (str)
|
||||||
|
@router.delete("/config/{config_id}")
|
||||||
|
async def delete_git_config(
|
||||||
|
config_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||||
|
):
|
||||||
|
with belief_scope("delete_git_config"):
|
||||||
|
db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
|
||||||
|
if not db_config:
|
||||||
|
raise HTTPException(status_code=404, detail="Configuration not found")
|
||||||
|
|
||||||
|
db.delete(db_config)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "success", "message": "Configuration deleted"}
|
||||||
|
# [/DEF:delete_git_config:Function]
|
||||||
|
|
||||||
|
# [DEF:test_git_config:Function]
|
||||||
|
# @PURPOSE: Validate connection to a Git server using provided credentials.
|
||||||
|
# @PRE: `config` contains provider, url, and pat.
|
||||||
|
# @POST: Returns success if the connection is validated via GitService.
|
||||||
|
# @PARAM: config (GitServerConfigCreate)
|
||||||
|
@router.post("/config/test")
|
||||||
|
async def test_git_config(
|
||||||
|
config: GitServerConfigCreate,
|
||||||
|
_ = Depends(has_permission("admin:settings", "READ"))
|
||||||
|
):
|
||||||
|
with belief_scope("test_git_config"):
|
||||||
|
success = await git_service.test_connection(config.provider, config.url, config.pat)
|
||||||
|
if success:
|
||||||
|
return {"status": "success", "message": "Connection successful"}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Connection failed")
|
||||||
|
# [/DEF:test_git_config:Function]
|
||||||
|
|
||||||
|
# [DEF:init_repository:Function]
|
||||||
|
# @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init.
|
||||||
|
# @PRE: `dashboard_id` exists and `init_data` contains valid config_id and remote_url.
|
||||||
|
# @POST: Repository is initialized on disk and a GitRepository record is saved in DB.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
# @PARAM: init_data (RepoInitRequest)
|
||||||
|
@router.post("/repositories/{dashboard_id}/init")
|
||||||
|
async def init_repository(
|
||||||
|
dashboard_id: int,
|
||||||
|
init_data: RepoInitRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("init_repository"):
|
||||||
|
# 1. Get config
|
||||||
|
config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="Git configuration not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 2. Perform Git clone/init
|
||||||
|
logger.info(f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}")
|
||||||
|
git_service.init_repo(dashboard_id, init_data.remote_url, config.pat)
|
||||||
|
|
||||||
|
# 3. Save to DB
|
||||||
|
repo_path = git_service._get_repo_path(dashboard_id)
|
||||||
|
db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
|
||||||
|
if not db_repo:
|
||||||
|
db_repo = GitRepository(
|
||||||
|
dashboard_id=dashboard_id,
|
||||||
|
config_id=config.id,
|
||||||
|
remote_url=init_data.remote_url,
|
||||||
|
local_path=repo_path
|
||||||
|
)
|
||||||
|
db.add(db_repo)
|
||||||
|
else:
|
||||||
|
db_repo.config_id = config.id
|
||||||
|
db_repo.remote_url = init_data.remote_url
|
||||||
|
db_repo.local_path = repo_path
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"[init_repository][Coherence:OK] Repository initialized for dashboard {dashboard_id}")
|
||||||
|
return {"status": "success", "message": "Repository initialized"}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"[init_repository][Coherence:Failed] Failed to init repository: {e}")
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:init_repository:Function]
|
||||||
|
|
||||||
|
# [DEF:get_branches:Function]
|
||||||
|
# @PURPOSE: List all branches for a dashboard's repository.
|
||||||
|
# @PRE: Repository for `dashboard_id` is initialized.
|
||||||
|
# @POST: Returns a list of branches from the local repository.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
# @RETURN: List[BranchSchema]
|
||||||
|
@router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema])
|
||||||
|
async def get_branches(
|
||||||
|
dashboard_id: int,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("get_branches"):
|
||||||
|
try:
|
||||||
|
return git_service.list_branches(dashboard_id)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
# [/DEF:get_branches:Function]
|
||||||
|
|
||||||
|
# [DEF:create_branch:Function]
|
||||||
|
# @PURPOSE: Create a new branch in the dashboard's repository.
|
||||||
|
# @PRE: `dashboard_id` repository exists and `branch_data` has name and from_branch.
|
||||||
|
# @POST: A new branch is created in the local repository.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
# @PARAM: branch_data (BranchCreate)
|
||||||
|
@router.post("/repositories/{dashboard_id}/branches")
|
||||||
|
async def create_branch(
|
||||||
|
dashboard_id: int,
|
||||||
|
branch_data: BranchCreate,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("create_branch"):
|
||||||
|
try:
|
||||||
|
git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch)
|
||||||
|
return {"status": "success"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:create_branch:Function]
|
||||||
|
|
||||||
|
# [DEF:checkout_branch:Function]
|
||||||
|
# @PURPOSE: Switch the dashboard's repository to a specific branch.
|
||||||
|
# @PRE: `dashboard_id` repository exists and branch `checkout_data.name` exists.
|
||||||
|
# @POST: The local repository HEAD is moved to the specified branch.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
# @PARAM: checkout_data (BranchCheckout)
|
||||||
|
@router.post("/repositories/{dashboard_id}/checkout")
|
||||||
|
async def checkout_branch(
|
||||||
|
dashboard_id: int,
|
||||||
|
checkout_data: BranchCheckout,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("checkout_branch"):
|
||||||
|
try:
|
||||||
|
git_service.checkout_branch(dashboard_id, checkout_data.name)
|
||||||
|
return {"status": "success"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:checkout_branch:Function]
|
||||||
|
|
||||||
|
# [DEF:commit_changes:Function]
|
||||||
|
# @PURPOSE: Stage and commit changes in the dashboard's repository.
|
||||||
|
# @PRE: `dashboard_id` repository exists and `commit_data` has message and files.
|
||||||
|
# @POST: Specified files are staged and a new commit is created.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
# @PARAM: commit_data (CommitCreate)
|
||||||
|
@router.post("/repositories/{dashboard_id}/commit")
|
||||||
|
async def commit_changes(
|
||||||
|
dashboard_id: int,
|
||||||
|
commit_data: CommitCreate,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("commit_changes"):
|
||||||
|
try:
|
||||||
|
git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files)
|
||||||
|
return {"status": "success"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:commit_changes:Function]
|
||||||
|
|
||||||
|
# [DEF:push_changes:Function]
|
||||||
|
# @PURPOSE: Push local commits to the remote repository.
|
||||||
|
# @PRE: `dashboard_id` repository exists and has a remote configured.
|
||||||
|
# @POST: Local commits are pushed to the remote repository.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
@router.post("/repositories/{dashboard_id}/push")
|
||||||
|
async def push_changes(
|
||||||
|
dashboard_id: int,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("push_changes"):
|
||||||
|
try:
|
||||||
|
git_service.push_changes(dashboard_id)
|
||||||
|
return {"status": "success"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:push_changes:Function]
|
||||||
|
|
||||||
|
# [DEF:pull_changes:Function]
|
||||||
|
# @PURPOSE: Pull changes from the remote repository.
|
||||||
|
# @PRE: `dashboard_id` repository exists and has a remote configured.
|
||||||
|
# @POST: Remote changes are fetched and merged into the local branch.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
@router.post("/repositories/{dashboard_id}/pull")
|
||||||
|
async def pull_changes(
|
||||||
|
dashboard_id: int,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("pull_changes"):
|
||||||
|
try:
|
||||||
|
git_service.pull_changes(dashboard_id)
|
||||||
|
return {"status": "success"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:pull_changes:Function]
|
||||||
|
|
||||||
|
# [DEF:sync_dashboard:Function]
|
||||||
|
# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin.
|
||||||
|
# @PRE: `dashboard_id` is valid; GitPlugin is available.
|
||||||
|
# @POST: Dashboard YAMLs are exported from Superset and committed to Git.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
# @PARAM: source_env_id (Optional[str])
|
||||||
|
@router.post("/repositories/{dashboard_id}/sync")
|
||||||
|
async def sync_dashboard(
|
||||||
|
dashboard_id: int,
|
||||||
|
source_env_id: typing.Optional[str] = None,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("sync_dashboard"):
|
||||||
|
try:
|
||||||
|
from src.plugins.git_plugin import GitPlugin
|
||||||
|
plugin = GitPlugin()
|
||||||
|
return await plugin.execute({
|
||||||
|
"operation": "sync",
|
||||||
|
"dashboard_id": dashboard_id,
|
||||||
|
"source_env_id": source_env_id
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:sync_dashboard:Function]
|
||||||
|
|
||||||
|
# [DEF:get_environments:Function]
|
||||||
|
# @PURPOSE: List all deployment environments.
|
||||||
|
# @PRE: Config manager is accessible.
|
||||||
|
# @POST: Returns a list of DeploymentEnvironmentSchema objects.
|
||||||
|
# @RETURN: List[DeploymentEnvironmentSchema]
|
||||||
|
@router.get("/environments", response_model=List[DeploymentEnvironmentSchema])
|
||||||
|
async def get_environments(
|
||||||
|
config_manager=Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("environments", "READ"))
|
||||||
|
):
|
||||||
|
with belief_scope("get_environments"):
|
||||||
|
envs = config_manager.get_environments()
|
||||||
|
return [
|
||||||
|
DeploymentEnvironmentSchema(
|
||||||
|
id=e.id,
|
||||||
|
name=e.name,
|
||||||
|
superset_url=e.url,
|
||||||
|
is_active=True
|
||||||
|
) for e in envs
|
||||||
|
]
|
||||||
|
# [/DEF:get_environments:Function]
|
||||||
|
|
||||||
|
# [DEF:deploy_dashboard:Function]
|
||||||
|
# @PURPOSE: Deploy dashboard from Git to a target environment.
|
||||||
|
# @PRE: `dashboard_id` and `deploy_data.environment_id` are valid.
|
||||||
|
# @POST: Dashboard YAMLs are read from Git and imported into the target Superset.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
# @PARAM: deploy_data (DeployRequest)
|
||||||
|
@router.post("/repositories/{dashboard_id}/deploy")
|
||||||
|
async def deploy_dashboard(
|
||||||
|
dashboard_id: int,
|
||||||
|
deploy_data: DeployRequest,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("deploy_dashboard"):
|
||||||
|
try:
|
||||||
|
from src.plugins.git_plugin import GitPlugin
|
||||||
|
plugin = GitPlugin()
|
||||||
|
return await plugin.execute({
|
||||||
|
"operation": "deploy",
|
||||||
|
"dashboard_id": dashboard_id,
|
||||||
|
"environment_id": deploy_data.environment_id
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:deploy_dashboard:Function]
|
||||||
|
|
||||||
|
# [DEF:get_history:Function]
|
||||||
|
# @PURPOSE: View commit history for a dashboard's repository.
|
||||||
|
# @PRE: `dashboard_id` repository exists.
|
||||||
|
# @POST: Returns a list of recent commits from the repository.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
# @PARAM: limit (int)
|
||||||
|
# @RETURN: List[CommitSchema]
|
||||||
|
@router.get("/repositories/{dashboard_id}/history", response_model=List[CommitSchema])
|
||||||
|
async def get_history(
|
||||||
|
dashboard_id: int,
|
||||||
|
limit: int = 50,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("get_history"):
|
||||||
|
try:
|
||||||
|
return git_service.get_commit_history(dashboard_id, limit)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
# [/DEF:get_history:Function]
|
||||||
|
|
||||||
|
# [DEF:get_repository_status:Function]
|
||||||
|
# @PURPOSE: Get current Git status for a dashboard repository.
|
||||||
|
# @PRE: `dashboard_id` repository exists.
|
||||||
|
# @POST: Returns the status of the working directory (staged, unstaged, untracked).
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
# @RETURN: dict
|
||||||
|
@router.get("/repositories/{dashboard_id}/status")
|
||||||
|
async def get_repository_status(
|
||||||
|
dashboard_id: int,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("get_repository_status"):
|
||||||
|
try:
|
||||||
|
return git_service.get_status(dashboard_id)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:get_repository_status:Function]
|
||||||
|
|
||||||
|
# [DEF:get_repository_diff:Function]
|
||||||
|
# @PURPOSE: Get Git diff for a dashboard repository.
|
||||||
|
# @PRE: `dashboard_id` repository exists.
|
||||||
|
# @POST: Returns the diff text for the specified file or all changes.
|
||||||
|
# @PARAM: dashboard_id (int)
|
||||||
|
# @PARAM: file_path (Optional[str])
|
||||||
|
# @PARAM: staged (bool)
|
||||||
|
# @RETURN: str
|
||||||
|
@router.get("/repositories/{dashboard_id}/diff")
|
||||||
|
async def get_repository_diff(
|
||||||
|
dashboard_id: int,
|
||||||
|
file_path: Optional[str] = None,
|
||||||
|
staged: bool = False,
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("get_repository_diff"):
|
||||||
|
try:
|
||||||
|
diff_text = git_service.get_diff(dashboard_id, file_path, staged)
|
||||||
|
return diff_text
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:get_repository_diff:Function]
|
||||||
|
|
||||||
|
# [DEF:generate_commit_message:Function]
|
||||||
|
# @PURPOSE: Generate a suggested commit message using LLM.
|
||||||
|
# @PRE: Repository for `dashboard_id` is initialized.
|
||||||
|
# @POST: Returns a suggested commit message string.
|
||||||
|
@router.post("/repositories/{dashboard_id}/generate-message")
|
||||||
|
async def generate_commit_message(
|
||||||
|
dashboard_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(has_permission("plugin:git", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("generate_commit_message"):
|
||||||
|
try:
|
||||||
|
# 1. Get Diff
|
||||||
|
diff = git_service.get_diff(dashboard_id, staged=True)
|
||||||
|
if not diff:
|
||||||
|
diff = git_service.get_diff(dashboard_id, staged=False)
|
||||||
|
|
||||||
|
if not diff:
|
||||||
|
return {"message": "No changes detected"}
|
||||||
|
|
||||||
|
# 2. Get History
|
||||||
|
history_objs = git_service.get_commit_history(dashboard_id, limit=5)
|
||||||
|
history = [h.message for h in history_objs if hasattr(h, 'message')]
|
||||||
|
|
||||||
|
# 3. Get LLM Client
|
||||||
|
from ...services.llm_provider import LLMProviderService
|
||||||
|
from ...plugins.llm_analysis.service import LLMClient
|
||||||
|
from ...plugins.llm_analysis.models import LLMProviderType
|
||||||
|
|
||||||
|
llm_service = LLMProviderService(db)
|
||||||
|
providers = llm_service.get_all_providers()
|
||||||
|
provider = next((p for p in providers if p.is_active), None)
|
||||||
|
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(status_code=400, detail="No active LLM provider found")
|
||||||
|
|
||||||
|
api_key = llm_service.get_decrypted_api_key(provider.id)
|
||||||
|
client = LLMClient(
|
||||||
|
provider_type=LLMProviderType(provider.provider_type),
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=provider.base_url,
|
||||||
|
default_model=provider.default_model
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Generate Message
|
||||||
|
from ...plugins.git.llm_extension import GitLLMExtension
|
||||||
|
extension = GitLLMExtension(client)
|
||||||
|
message = await extension.suggest_commit_message(diff, history)
|
||||||
|
|
||||||
|
return {"message": message}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate commit message: {e}")
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:generate_commit_message:Function]
|
||||||
|
|
||||||
|
# [/DEF:backend.src.api.routes.git:Module]
|
||||||
144
backend/src/api/routes/git_schemas.py
Normal file
144
backend/src/api/routes/git_schemas.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# [DEF:backend.src.api.routes.git_schemas:Module]
|
||||||
|
#
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: git, schemas, pydantic, api, contracts
|
||||||
|
# @PURPOSE: Defines Pydantic models for the Git integration API layer.
|
||||||
|
# @LAYER: API
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.models.git
|
||||||
|
#
|
||||||
|
# @INVARIANT: All schemas must be compatible with the FastAPI router.
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from src.models.git import GitProvider, GitStatus, SyncStatus
|
||||||
|
|
||||||
|
# [DEF:GitServerConfigBase:Class]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
|
# @PURPOSE: Base schema for Git server configuration attributes.
|
||||||
|
class GitServerConfigBase(BaseModel):
|
||||||
|
name: str = Field(..., description="Display name for the Git server")
|
||||||
|
provider: GitProvider = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)")
|
||||||
|
url: str = Field(..., description="Server base URL")
|
||||||
|
pat: str = Field(..., description="Personal Access Token")
|
||||||
|
default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)")
|
||||||
|
# [/DEF:GitServerConfigBase:Class]
|
||||||
|
|
||||||
|
# [DEF:GitServerConfigCreate:Class]
|
||||||
|
# @PURPOSE: Schema for creating a new Git server configuration.
|
||||||
|
class GitServerConfigCreate(GitServerConfigBase):
|
||||||
|
"""Schema for creating a new Git server configuration."""
|
||||||
|
pass
|
||||||
|
# [/DEF:GitServerConfigCreate:Class]
|
||||||
|
|
||||||
|
# [DEF:GitServerConfigSchema:Class]
|
||||||
|
# @PURPOSE: Schema for representing a Git server configuration with metadata.
|
||||||
|
class GitServerConfigSchema(GitServerConfigBase):
|
||||||
|
"""Schema for representing a Git server configuration with metadata."""
|
||||||
|
id: str
|
||||||
|
status: GitStatus
|
||||||
|
last_validated: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
# [/DEF:GitServerConfigSchema:Class]
|
||||||
|
|
||||||
|
# [DEF:GitRepositorySchema:Class]
|
||||||
|
# @PURPOSE: Schema for tracking a local Git repository linked to a dashboard.
|
||||||
|
class GitRepositorySchema(BaseModel):
|
||||||
|
"""Schema for tracking a local Git repository linked to a dashboard."""
|
||||||
|
id: str
|
||||||
|
dashboard_id: int
|
||||||
|
config_id: str
|
||||||
|
remote_url: str
|
||||||
|
local_path: str
|
||||||
|
current_branch: str
|
||||||
|
sync_status: SyncStatus
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
# [/DEF:GitRepositorySchema:Class]
|
||||||
|
|
||||||
|
# [DEF:BranchSchema:Class]
|
||||||
|
# @PURPOSE: Schema for representing a Git branch metadata.
|
||||||
|
class BranchSchema(BaseModel):
|
||||||
|
"""Schema for representing a Git branch."""
|
||||||
|
name: str
|
||||||
|
commit_hash: str
|
||||||
|
is_remote: bool
|
||||||
|
last_updated: datetime
|
||||||
|
# [/DEF:BranchSchema:Class]
|
||||||
|
|
||||||
|
# [DEF:CommitSchema:Class]
|
||||||
|
# @PURPOSE: Schema for representing Git commit details.
|
||||||
|
class CommitSchema(BaseModel):
|
||||||
|
"""Schema for representing a Git commit."""
|
||||||
|
hash: str
|
||||||
|
author: str
|
||||||
|
email: str
|
||||||
|
timestamp: datetime
|
||||||
|
message: str
|
||||||
|
files_changed: List[str]
|
||||||
|
# [/DEF:CommitSchema:Class]
|
||||||
|
|
||||||
|
# [DEF:BranchCreate:Class]
|
||||||
|
# @PURPOSE: Schema for branch creation requests.
|
||||||
|
class BranchCreate(BaseModel):
|
||||||
|
"""Schema for branch creation requests."""
|
||||||
|
name: str
|
||||||
|
from_branch: str
|
||||||
|
# [/DEF:BranchCreate:Class]
|
||||||
|
|
||||||
|
# [DEF:BranchCheckout:Class]
|
||||||
|
# @PURPOSE: Schema for branch checkout requests.
|
||||||
|
class BranchCheckout(BaseModel):
|
||||||
|
"""Schema for branch checkout requests."""
|
||||||
|
name: str
|
||||||
|
# [/DEF:BranchCheckout:Class]
|
||||||
|
|
||||||
|
# [DEF:CommitCreate:Class]
|
||||||
|
# @PURPOSE: Schema for staging and committing changes.
|
||||||
|
class CommitCreate(BaseModel):
|
||||||
|
"""Schema for staging and committing changes."""
|
||||||
|
message: str
|
||||||
|
files: List[str]
|
||||||
|
# [/DEF:CommitCreate:Class]
|
||||||
|
|
||||||
|
# [DEF:ConflictResolution:Class]
|
||||||
|
# @PURPOSE: Schema for resolving merge conflicts.
|
||||||
|
class ConflictResolution(BaseModel):
|
||||||
|
"""Schema for resolving merge conflicts."""
|
||||||
|
file_path: str
|
||||||
|
resolution: str = Field(pattern="^(mine|theirs|manual)$")
|
||||||
|
content: Optional[str] = None
|
||||||
|
# [/DEF:ConflictResolution:Class]
|
||||||
|
|
||||||
|
# [DEF:DeploymentEnvironmentSchema:Class]
|
||||||
|
# @PURPOSE: Schema for representing a target deployment environment.
|
||||||
|
class DeploymentEnvironmentSchema(BaseModel):
|
||||||
|
"""Schema for representing a target deployment environment."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
superset_url: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
# [/DEF:DeploymentEnvironmentSchema:Class]
|
||||||
|
|
||||||
|
# [DEF:DeployRequest:Class]
|
||||||
|
# @PURPOSE: Schema for dashboard deployment requests.
|
||||||
|
class DeployRequest(BaseModel):
|
||||||
|
"""Schema for deployment requests."""
|
||||||
|
environment_id: str
|
||||||
|
# [/DEF:DeployRequest:Class]
|
||||||
|
|
||||||
|
# [DEF:RepoInitRequest:Class]
|
||||||
|
# @PURPOSE: Schema for repository initialization requests.
|
||||||
|
class RepoInitRequest(BaseModel):
|
||||||
|
"""Schema for repository initialization requests."""
|
||||||
|
config_id: str
|
||||||
|
remote_url: str
|
||||||
|
# [/DEF:RepoInitRequest:Class]
|
||||||
|
|
||||||
|
# [/DEF:backend.src.api.routes.git_schemas:Module]
|
||||||
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(prefix="/api/llm", 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]
|
# [DEF:backend.src.api.routes.mappings:Module]
|
||||||
#
|
#
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: api, mappings, database, fuzzy-matching
|
# @SEMANTICS: api, mappings, database, fuzzy-matching
|
||||||
# @PURPOSE: API endpoints for managing database mappings and getting suggestions.
|
# @PURPOSE: API endpoints for managing database mappings and getting suggestions.
|
||||||
# @LAYER: API
|
# @LAYER: API
|
||||||
@@ -13,9 +14,10 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from backend.src.dependencies import get_config_manager
|
from ...core.logger import belief_scope
|
||||||
from backend.src.core.database import get_db
|
from ...dependencies import get_config_manager, has_permission
|
||||||
from backend.src.models.mapping import DatabaseMapping
|
from ...core.database import get_db
|
||||||
|
from ...models.mapping import DatabaseMapping
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
# [/SECTION]
|
# [/SECTION]
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ class MappingCreate(BaseModel):
|
|||||||
target_db_uuid: str
|
target_db_uuid: str
|
||||||
source_db_name: str
|
source_db_name: str
|
||||||
target_db_name: str
|
target_db_name: str
|
||||||
# [/DEF:MappingCreate]
|
# [/DEF:MappingCreate:DataClass]
|
||||||
|
|
||||||
# [DEF:MappingResponse:DataClass]
|
# [DEF:MappingResponse:DataClass]
|
||||||
class MappingResponse(BaseModel):
|
class MappingResponse(BaseModel):
|
||||||
@@ -43,68 +45,83 @@ class MappingResponse(BaseModel):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
# [/DEF:MappingResponse]
|
# [/DEF:MappingResponse:DataClass]
|
||||||
|
|
||||||
# [DEF:SuggestRequest:DataClass]
|
# [DEF:SuggestRequest:DataClass]
|
||||||
class SuggestRequest(BaseModel):
|
class SuggestRequest(BaseModel):
|
||||||
source_env_id: str
|
source_env_id: str
|
||||||
target_env_id: str
|
target_env_id: str
|
||||||
# [/DEF:SuggestRequest]
|
# [/DEF:SuggestRequest:DataClass]
|
||||||
|
|
||||||
# [DEF:get_mappings:Function]
|
# [DEF:get_mappings:Function]
|
||||||
# @PURPOSE: List all saved database mappings.
|
# @PURPOSE: List all saved database mappings.
|
||||||
|
# @PRE: db session is injected.
|
||||||
|
# @POST: Returns filtered list of DatabaseMapping records.
|
||||||
@router.get("", response_model=List[MappingResponse])
|
@router.get("", response_model=List[MappingResponse])
|
||||||
async def get_mappings(
|
async def get_mappings(
|
||||||
source_env_id: Optional[str] = None,
|
source_env_id: Optional[str] = None,
|
||||||
target_env_id: Optional[str] = None,
|
target_env_id: Optional[str] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
|
||||||
):
|
):
|
||||||
query = db.query(DatabaseMapping)
|
with belief_scope("get_mappings"):
|
||||||
|
query = db.query(DatabaseMapping)
|
||||||
if source_env_id:
|
if source_env_id:
|
||||||
query = query.filter(DatabaseMapping.source_env_id == source_env_id)
|
query = query.filter(DatabaseMapping.source_env_id == source_env_id)
|
||||||
if target_env_id:
|
if target_env_id:
|
||||||
query = query.filter(DatabaseMapping.target_env_id == target_env_id)
|
query = query.filter(DatabaseMapping.target_env_id == target_env_id)
|
||||||
return query.all()
|
return query.all()
|
||||||
# [/DEF:get_mappings]
|
# [/DEF:get_mappings:Function]
|
||||||
|
|
||||||
# [DEF:create_mapping:Function]
|
# [DEF:create_mapping:Function]
|
||||||
# @PURPOSE: Create or update a database mapping.
|
# @PURPOSE: Create or update a database mapping.
|
||||||
|
# @PRE: mapping is valid MappingCreate, db session is injected.
|
||||||
|
# @POST: DatabaseMapping created or updated in database.
|
||||||
@router.post("", response_model=MappingResponse)
|
@router.post("", response_model=MappingResponse)
|
||||||
async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)):
|
async def create_mapping(
|
||||||
# Check if mapping already exists
|
mapping: MappingCreate,
|
||||||
existing = db.query(DatabaseMapping).filter(
|
db: Session = Depends(get_db),
|
||||||
DatabaseMapping.source_env_id == mapping.source_env_id,
|
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
|
||||||
DatabaseMapping.target_env_id == mapping.target_env_id,
|
):
|
||||||
DatabaseMapping.source_db_uuid == mapping.source_db_uuid
|
with belief_scope("create_mapping"):
|
||||||
).first()
|
# Check if mapping already exists
|
||||||
|
existing = db.query(DatabaseMapping).filter(
|
||||||
if existing:
|
DatabaseMapping.source_env_id == mapping.source_env_id,
|
||||||
existing.target_db_uuid = mapping.target_db_uuid
|
DatabaseMapping.target_env_id == mapping.target_env_id,
|
||||||
existing.target_db_name = mapping.target_db_name
|
DatabaseMapping.source_db_uuid == mapping.source_db_uuid
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.target_db_uuid = mapping.target_db_uuid
|
||||||
|
existing.target_db_name = mapping.target_db_name
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
new_mapping = DatabaseMapping(**mapping.dict())
|
||||||
|
db.add(new_mapping)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(existing)
|
db.refresh(new_mapping)
|
||||||
return existing
|
return new_mapping
|
||||||
|
# [/DEF:create_mapping:Function]
|
||||||
new_mapping = DatabaseMapping(**mapping.dict())
|
|
||||||
db.add(new_mapping)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(new_mapping)
|
|
||||||
return new_mapping
|
|
||||||
# [/DEF:create_mapping]
|
|
||||||
|
|
||||||
# [DEF:suggest_mappings_api:Function]
|
# [DEF:suggest_mappings_api:Function]
|
||||||
# @PURPOSE: Get suggested mappings based on fuzzy matching.
|
# @PURPOSE: Get suggested mappings based on fuzzy matching.
|
||||||
|
# @PRE: request is valid SuggestRequest, config_manager is injected.
|
||||||
|
# @POST: Returns mapping suggestions.
|
||||||
@router.post("/suggest")
|
@router.post("/suggest")
|
||||||
async def suggest_mappings_api(
|
async def suggest_mappings_api(
|
||||||
request: SuggestRequest,
|
request: SuggestRequest,
|
||||||
config_manager=Depends(get_config_manager)
|
config_manager=Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
|
||||||
):
|
):
|
||||||
from backend.src.services.mapping_service import MappingService
|
with belief_scope("suggest_mappings_api"):
|
||||||
service = MappingService(config_manager)
|
from ...services.mapping_service import MappingService
|
||||||
try:
|
service = MappingService(config_manager)
|
||||||
return await service.get_suggestions(request.source_env_id, request.target_env_id)
|
try:
|
||||||
except Exception as e:
|
return await service.get_suggestions(request.source_env_id, request.target_env_id)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
except Exception as e:
|
||||||
# [/DEF:suggest_mappings_api]
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
# [/DEF:suggest_mappings_api:Function]
|
||||||
|
|
||||||
# [/DEF:backend.src.api.routes.mappings]
|
# [/DEF:backend.src.api.routes.mappings:Module]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# [DEF:backend.src.api.routes.migration:Module]
|
# [DEF:backend.src.api.routes.migration:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: api, migration, dashboards
|
# @SEMANTICS: api, migration, dashboards
|
||||||
# @PURPOSE: API endpoints for migration operations.
|
# @PURPOSE: API endpoints for migration operations.
|
||||||
# @LAYER: API
|
# @LAYER: API
|
||||||
@@ -6,11 +7,11 @@
|
|||||||
# @RELATION: DEPENDS_ON -> backend.src.models.dashboard
|
# @RELATION: DEPENDS_ON -> backend.src.models.dashboard
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from typing import List, Dict
|
from typing import List
|
||||||
from backend.src.dependencies import get_config_manager, get_task_manager
|
from ...dependencies import get_config_manager, get_task_manager, has_permission
|
||||||
from backend.src.models.dashboard import DashboardMetadata, DashboardSelection
|
from ...models.dashboard import DashboardMetadata, DashboardSelection
|
||||||
from backend.src.core.superset_client import SupersetClient
|
from ...core.superset_client import SupersetClient
|
||||||
from superset_tool.models import SupersetConfig
|
from ...core.logger import belief_scope
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["migration"])
|
router = APIRouter(prefix="/api", tags=["migration"])
|
||||||
|
|
||||||
@@ -21,23 +22,21 @@ router = APIRouter(prefix="/api", tags=["migration"])
|
|||||||
# @PARAM: env_id (str) - The ID of the environment to fetch from.
|
# @PARAM: env_id (str) - The ID of the environment to fetch from.
|
||||||
# @RETURN: List[DashboardMetadata]
|
# @RETURN: List[DashboardMetadata]
|
||||||
@router.get("/environments/{env_id}/dashboards", response_model=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(
|
||||||
environments = config_manager.get_environments()
|
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)
|
env = next((e for e in environments if e.id == env_id), None)
|
||||||
if not env:
|
if not env:
|
||||||
raise HTTPException(status_code=404, detail="Environment not found")
|
raise HTTPException(status_code=404, detail="Environment not found")
|
||||||
|
|
||||||
config = SupersetConfig(
|
client = SupersetClient(env)
|
||||||
env=env.name,
|
|
||||||
base_url=env.url,
|
|
||||||
auth={'provider': 'db', 'username': env.username, 'password': env.password, 'refresh': False},
|
|
||||||
verify_ssl=True,
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
client = SupersetClient(config)
|
|
||||||
dashboards = client.get_dashboards_summary()
|
dashboards = client.get_dashboards_summary()
|
||||||
return dashboards
|
return dashboards
|
||||||
# [/DEF:get_dashboards]
|
# [/DEF:get_dashboards:Function]
|
||||||
|
|
||||||
# [DEF:execute_migration:Function]
|
# [DEF:execute_migration:Function]
|
||||||
# @PURPOSE: Execute the migration of selected dashboards.
|
# @PURPOSE: Execute the migration of selected dashboards.
|
||||||
@@ -46,9 +45,15 @@ async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)
|
|||||||
# @PARAM: selection (DashboardSelection) - The dashboards to migrate.
|
# @PARAM: selection (DashboardSelection) - The dashboards to migrate.
|
||||||
# @RETURN: Dict - {"task_id": str, "message": str}
|
# @RETURN: Dict - {"task_id": str, "message": str}
|
||||||
@router.post("/migration/execute")
|
@router.post("/migration/execute")
|
||||||
async def execute_migration(selection: DashboardSelection, config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager)):
|
async def execute_migration(
|
||||||
# Validate environments exist
|
selection: DashboardSelection,
|
||||||
environments = config_manager.get_environments()
|
config_manager=Depends(get_config_manager),
|
||||||
|
task_manager=Depends(get_task_manager),
|
||||||
|
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
|
||||||
|
):
|
||||||
|
with belief_scope("execute_migration"):
|
||||||
|
# Validate environments exist
|
||||||
|
environments = config_manager.get_environments()
|
||||||
env_ids = {e.id for e in environments}
|
env_ids = {e.id for e in environments}
|
||||||
if selection.source_env_id not in env_ids or selection.target_env_id not in env_ids:
|
if selection.source_env_id not in env_ids or selection.target_env_id not in env_ids:
|
||||||
raise HTTPException(status_code=400, detail="Invalid source or target environment")
|
raise HTTPException(status_code=400, detail="Invalid source or target environment")
|
||||||
@@ -71,6 +76,6 @@ async def execute_migration(selection: DashboardSelection, config_manager=Depend
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Task creation failed: {e}")
|
logger.error(f"Task creation failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to create migration task: {str(e)}")
|
||||||
# [/DEF:execute_migration]
|
# [/DEF:execute_migration:Function]
|
||||||
|
|
||||||
# [/DEF:backend.src.api.routes.migration]
|
# [/DEF:backend.src.api.routes.migration:Module]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
# [DEF:PluginsRouter:Module]
|
# [DEF:PluginsRouter:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: api, router, plugins, list
|
# @SEMANTICS: api, router, plugins, list
|
||||||
# @PURPOSE: Defines the FastAPI router for plugin-related endpoints, allowing clients to list available plugins.
|
# @PURPOSE: Defines the FastAPI router for plugin-related endpoints, allowing clients to list available plugins.
|
||||||
# @LAYER: UI (API)
|
# @LAYER: UI (API)
|
||||||
@@ -7,16 +8,25 @@ from typing import List
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ...core.plugin_base import PluginConfig
|
from ...core.plugin_base import PluginConfig
|
||||||
from ...dependencies import get_plugin_loader
|
from ...dependencies import get_plugin_loader, has_permission
|
||||||
|
from ...core.logger import belief_scope
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.get("/", response_model=List[PluginConfig])
|
# [DEF:list_plugins:Function]
|
||||||
|
# @PURPOSE: Retrieve a list of all available plugins.
|
||||||
|
# @PRE: plugin_loader is injected via Depends.
|
||||||
|
# @POST: Returns a list of PluginConfig objects.
|
||||||
|
# @RETURN: List[PluginConfig] - List of registered plugins.
|
||||||
|
@router.get("", response_model=List[PluginConfig])
|
||||||
async def list_plugins(
|
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"):
|
||||||
Retrieve a list of all available plugins.
|
"""
|
||||||
"""
|
Retrieve a list of all available plugins.
|
||||||
return plugin_loader.get_all_plugin_configs()
|
"""
|
||||||
# [/DEF]
|
return plugin_loader.get_all_plugin_configs()
|
||||||
|
# [/DEF:list_plugins:Function]
|
||||||
|
# [/DEF:PluginsRouter:Module]
|
||||||
@@ -12,78 +12,132 @@
|
|||||||
# [SECTION: IMPORTS]
|
# [SECTION: IMPORTS]
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from typing import List
|
from typing import List
|
||||||
from ...core.config_models import AppConfig, Environment, GlobalSettings
|
from pydantic import BaseModel
|
||||||
from ...dependencies import get_config_manager
|
from ...core.config_models import AppConfig, Environment, GlobalSettings, LoggingConfig
|
||||||
|
from ...models.storage import StorageConfig
|
||||||
|
from ...dependencies import get_config_manager, has_permission
|
||||||
from ...core.config_manager import ConfigManager
|
from ...core.config_manager import ConfigManager
|
||||||
from ...core.logger import logger
|
from ...core.logger import logger, belief_scope
|
||||||
from ...core.superset_client import SupersetClient
|
from ...core.superset_client import SupersetClient
|
||||||
from superset_tool.models import SupersetConfig
|
|
||||||
import os
|
|
||||||
# [/SECTION]
|
# [/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()
|
router = APIRouter()
|
||||||
|
|
||||||
# [DEF:get_settings:Function]
|
# [DEF:get_settings:Function]
|
||||||
# @PURPOSE: Retrieves all application settings.
|
# @PURPOSE: Retrieves all application settings.
|
||||||
|
# @PRE: Config manager is available.
|
||||||
|
# @POST: Returns masked AppConfig.
|
||||||
# @RETURN: AppConfig - The current configuration.
|
# @RETURN: AppConfig - The current configuration.
|
||||||
@router.get("/", response_model=AppConfig)
|
@router.get("", response_model=AppConfig)
|
||||||
async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)):
|
async def get_settings(
|
||||||
logger.info("[get_settings][Entry] Fetching all 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)
|
config = config_manager.get_config().copy(deep=True)
|
||||||
# Mask passwords
|
# Mask passwords
|
||||||
for env in config.environments:
|
for env in config.environments:
|
||||||
if env.password:
|
if env.password:
|
||||||
env.password = "********"
|
env.password = "********"
|
||||||
return config
|
return config
|
||||||
# [/DEF:get_settings]
|
# [/DEF:get_settings:Function]
|
||||||
|
|
||||||
# [DEF:update_global_settings:Function]
|
# [DEF:update_global_settings:Function]
|
||||||
# @PURPOSE: Updates global application settings.
|
# @PURPOSE: Updates global application settings.
|
||||||
|
# @PRE: New settings are provided.
|
||||||
|
# @POST: Global settings are updated.
|
||||||
# @PARAM: settings (GlobalSettings) - The new global settings.
|
# @PARAM: settings (GlobalSettings) - The new global settings.
|
||||||
# @RETURN: GlobalSettings - The updated settings.
|
# @RETURN: GlobalSettings - The updated settings.
|
||||||
@router.patch("/global", response_model=GlobalSettings)
|
@router.patch("/global", response_model=GlobalSettings)
|
||||||
async def update_global_settings(
|
async def update_global_settings(
|
||||||
settings: GlobalSettings,
|
settings: GlobalSettings,
|
||||||
config_manager: ConfigManager = Depends(get_config_manager)
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||||
):
|
):
|
||||||
logger.info("[update_global_settings][Entry] Updating global settings")
|
with belief_scope("update_global_settings"):
|
||||||
|
logger.info("[update_global_settings][Entry] Updating global settings")
|
||||||
|
|
||||||
config_manager.update_global_settings(settings)
|
config_manager.update_global_settings(settings)
|
||||||
return settings
|
return settings
|
||||||
# [/DEF:update_global_settings]
|
# [/DEF:update_global_settings:Function]
|
||||||
|
|
||||||
|
# [DEF:get_storage_settings:Function]
|
||||||
|
# @PURPOSE: Retrieves storage-specific settings.
|
||||||
|
# @RETURN: StorageConfig - The storage configuration.
|
||||||
|
@router.get("/storage", response_model=StorageConfig)
|
||||||
|
async def get_storage_settings(
|
||||||
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("admin:settings", "READ"))
|
||||||
|
):
|
||||||
|
with belief_scope("get_storage_settings"):
|
||||||
|
return config_manager.get_config().settings.storage
|
||||||
|
# [/DEF:get_storage_settings:Function]
|
||||||
|
|
||||||
|
# [DEF:update_storage_settings:Function]
|
||||||
|
# @PURPOSE: Updates storage-specific settings.
|
||||||
|
# @PARAM: storage (StorageConfig) - The new storage settings.
|
||||||
|
# @POST: Storage settings are updated and saved.
|
||||||
|
# @RETURN: StorageConfig - The updated storage settings.
|
||||||
|
@router.put("/storage", response_model=StorageConfig)
|
||||||
|
async def update_storage_settings(
|
||||||
|
storage: StorageConfig,
|
||||||
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||||
|
):
|
||||||
|
with belief_scope("update_storage_settings"):
|
||||||
|
is_valid, message = config_manager.validate_path(storage.root_path)
|
||||||
|
if not is_valid:
|
||||||
|
raise HTTPException(status_code=400, detail=message)
|
||||||
|
|
||||||
|
settings = config_manager.get_config().settings
|
||||||
|
settings.storage = storage
|
||||||
|
config_manager.update_global_settings(settings)
|
||||||
|
return config_manager.get_config().settings.storage
|
||||||
|
# [/DEF:update_storage_settings:Function]
|
||||||
|
|
||||||
# [DEF:get_environments:Function]
|
# [DEF:get_environments:Function]
|
||||||
# @PURPOSE: Lists all configured Superset environments.
|
# @PURPOSE: Lists all configured Superset environments.
|
||||||
|
# @PRE: Config manager is available.
|
||||||
|
# @POST: Returns list of environments.
|
||||||
# @RETURN: List[Environment] - List of environments.
|
# @RETURN: List[Environment] - List of environments.
|
||||||
@router.get("/environments", response_model=List[Environment])
|
@router.get("/environments", response_model=List[Environment])
|
||||||
async def get_environments(config_manager: ConfigManager = Depends(get_config_manager)):
|
async def get_environments(
|
||||||
logger.info("[get_environments][Entry] Fetching 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()
|
return config_manager.get_environments()
|
||||||
# [/DEF:get_environments]
|
# [/DEF:get_environments:Function]
|
||||||
|
|
||||||
# [DEF:add_environment:Function]
|
# [DEF:add_environment:Function]
|
||||||
# @PURPOSE: Adds a new Superset environment.
|
# @PURPOSE: Adds a new Superset environment.
|
||||||
|
# @PRE: Environment data is valid and reachable.
|
||||||
|
# @POST: Environment is added to config.
|
||||||
# @PARAM: env (Environment) - The environment to add.
|
# @PARAM: env (Environment) - The environment to add.
|
||||||
# @RETURN: Environment - The added environment.
|
# @RETURN: Environment - The added environment.
|
||||||
@router.post("/environments", response_model=Environment)
|
@router.post("/environments", response_model=Environment)
|
||||||
async def add_environment(
|
async def add_environment(
|
||||||
env: Environment,
|
env: Environment,
|
||||||
config_manager: ConfigManager = Depends(get_config_manager)
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||||
):
|
):
|
||||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
with belief_scope("add_environment"):
|
||||||
|
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||||
|
|
||||||
# Validate connection before adding
|
# Validate connection before adding
|
||||||
try:
|
try:
|
||||||
superset_config = SupersetConfig(
|
client = SupersetClient(env)
|
||||||
env=env.name,
|
|
||||||
base_url=env.url,
|
|
||||||
auth={
|
|
||||||
"provider": "db",
|
|
||||||
"username": env.username,
|
|
||||||
"password": env.password,
|
|
||||||
"refresh": "true"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
client = SupersetClient(config=superset_config)
|
|
||||||
client.get_dashboards(query={"page_size": 1})
|
client.get_dashboards(query={"page_size": 1})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
|
logger.error(f"[add_environment][Coherence:Failed] Connection validation failed: {e}")
|
||||||
@@ -91,20 +145,23 @@ async def add_environment(
|
|||||||
|
|
||||||
config_manager.add_environment(env)
|
config_manager.add_environment(env)
|
||||||
return env
|
return env
|
||||||
# [/DEF:add_environment]
|
# [/DEF:add_environment:Function]
|
||||||
|
|
||||||
# [DEF:update_environment:Function]
|
# [DEF:update_environment:Function]
|
||||||
# @PURPOSE: Updates an existing Superset environment.
|
# @PURPOSE: Updates an existing Superset environment.
|
||||||
|
# @PRE: ID and valid environment data are provided.
|
||||||
|
# @POST: Environment is updated in config.
|
||||||
# @PARAM: id (str) - The ID of the environment to update.
|
# @PARAM: id (str) - The ID of the environment to update.
|
||||||
# @PARAM: env (Environment) - The updated environment data.
|
# @PARAM: env (Environment) - The updated environment data.
|
||||||
# @RETURN: Environment - The updated environment.
|
# @RETURN: Environment - The updated environment.
|
||||||
@router.put("/environments/{id}", response_model=Environment)
|
@router.put("/environments/{id}", response_model=Environment)
|
||||||
async def update_environment(
|
async def update_environment(
|
||||||
id: str,
|
id: str,
|
||||||
env: Environment,
|
env: Environment,
|
||||||
config_manager: ConfigManager = Depends(get_config_manager)
|
config_manager: ConfigManager = Depends(get_config_manager)
|
||||||
):
|
):
|
||||||
logger.info(f"[update_environment][Entry] Updating environment {id}")
|
with belief_scope("update_environment"):
|
||||||
|
logger.info(f"[update_environment][Entry] Updating environment {id}")
|
||||||
|
|
||||||
# If password is masked, we need the real one for validation
|
# If password is masked, we need the real one for validation
|
||||||
env_to_validate = env.copy(deep=True)
|
env_to_validate = env.copy(deep=True)
|
||||||
@@ -115,17 +172,7 @@ async def update_environment(
|
|||||||
|
|
||||||
# Validate connection before updating
|
# Validate connection before updating
|
||||||
try:
|
try:
|
||||||
superset_config = SupersetConfig(
|
client = SupersetClient(env_to_validate)
|
||||||
env=env_to_validate.name,
|
|
||||||
base_url=env_to_validate.url,
|
|
||||||
auth={
|
|
||||||
"provider": "db",
|
|
||||||
"username": env_to_validate.username,
|
|
||||||
"password": env_to_validate.password,
|
|
||||||
"refresh": "true"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
client = SupersetClient(config=superset_config)
|
|
||||||
client.get_dashboards(query={"page_size": 1})
|
client.get_dashboards(query={"page_size": 1})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
|
logger.error(f"[update_environment][Coherence:Failed] Connection validation failed: {e}")
|
||||||
@@ -134,23 +181,28 @@ async def update_environment(
|
|||||||
if config_manager.update_environment(id, env):
|
if config_manager.update_environment(id, env):
|
||||||
return env
|
return env
|
||||||
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
|
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
|
||||||
# [/DEF:update_environment]
|
# [/DEF:update_environment:Function]
|
||||||
|
|
||||||
# [DEF:delete_environment:Function]
|
# [DEF:delete_environment:Function]
|
||||||
# @PURPOSE: Deletes a Superset environment.
|
# @PURPOSE: Deletes a Superset environment.
|
||||||
|
# @PRE: ID is provided.
|
||||||
|
# @POST: Environment is removed from config.
|
||||||
# @PARAM: id (str) - The ID of the environment to delete.
|
# @PARAM: id (str) - The ID of the environment to delete.
|
||||||
@router.delete("/environments/{id}")
|
@router.delete("/environments/{id}")
|
||||||
async def delete_environment(
|
async def delete_environment(
|
||||||
id: str,
|
id: str,
|
||||||
config_manager: ConfigManager = Depends(get_config_manager)
|
config_manager: ConfigManager = Depends(get_config_manager)
|
||||||
):
|
):
|
||||||
logger.info(f"[delete_environment][Entry] Deleting environment {id}")
|
with belief_scope("delete_environment"):
|
||||||
|
logger.info(f"[delete_environment][Entry] Deleting environment {id}")
|
||||||
config_manager.delete_environment(id)
|
config_manager.delete_environment(id)
|
||||||
return {"message": f"Environment {id} deleted"}
|
return {"message": f"Environment {id} deleted"}
|
||||||
# [/DEF:delete_environment]
|
# [/DEF:delete_environment:Function]
|
||||||
|
|
||||||
# [DEF:test_environment_connection:Function]
|
# [DEF:test_environment_connection:Function]
|
||||||
# @PURPOSE: Tests the connection to a Superset environment.
|
# @PURPOSE: Tests the connection to a Superset environment.
|
||||||
|
# @PRE: ID is provided.
|
||||||
|
# @POST: Returns success or error status.
|
||||||
# @PARAM: id (str) - The ID of the environment to test.
|
# @PARAM: id (str) - The ID of the environment to test.
|
||||||
# @RETURN: dict - Success message or error.
|
# @RETURN: dict - Success message or error.
|
||||||
@router.post("/environments/{id}/test")
|
@router.post("/environments/{id}/test")
|
||||||
@@ -158,7 +210,8 @@ async def test_environment_connection(
|
|||||||
id: str,
|
id: str,
|
||||||
config_manager: ConfigManager = Depends(get_config_manager)
|
config_manager: ConfigManager = Depends(get_config_manager)
|
||||||
):
|
):
|
||||||
logger.info(f"[test_environment_connection][Entry] Testing environment {id}")
|
with belief_scope("test_environment_connection"):
|
||||||
|
logger.info(f"[test_environment_connection][Entry] Testing environment {id}")
|
||||||
|
|
||||||
# Find environment
|
# Find environment
|
||||||
env = next((e for e in config_manager.get_environments() if e.id == id), None)
|
env = next((e for e in config_manager.get_environments() if e.id == id), None)
|
||||||
@@ -166,21 +219,8 @@ async def test_environment_connection(
|
|||||||
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
|
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create SupersetConfig
|
|
||||||
# Note: SupersetConfig expects 'auth' dict with specific keys
|
|
||||||
superset_config = SupersetConfig(
|
|
||||||
env=env.name,
|
|
||||||
base_url=env.url,
|
|
||||||
auth={
|
|
||||||
"provider": "db", # Defaulting to db for now
|
|
||||||
"username": env.username,
|
|
||||||
"password": env.password,
|
|
||||||
"refresh": "true"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize client (this will trigger authentication)
|
# Initialize client (this will trigger authentication)
|
||||||
client = SupersetClient(config=superset_config)
|
client = SupersetClient(env)
|
||||||
|
|
||||||
# Try a simple request to verify
|
# Try a simple request to verify
|
||||||
client.get_dashboards(query={"page_size": 1})
|
client.get_dashboards(query={"page_size": 1})
|
||||||
@@ -190,29 +230,85 @@ async def test_environment_connection(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}")
|
logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}")
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
# [/DEF:test_environment_connection]
|
# [/DEF:test_environment_connection:Function]
|
||||||
|
|
||||||
# [DEF:validate_backup_path:Function]
|
# [DEF:get_logging_config:Function]
|
||||||
# @PURPOSE: Validates if a backup path exists and is writable.
|
# @PURPOSE: Retrieves current logging configuration.
|
||||||
# @PARAM: path (str) - The path to validate.
|
# @PRE: Config manager is available.
|
||||||
# @RETURN: dict - Validation result.
|
# @POST: Returns logging configuration.
|
||||||
@router.post("/validate-path")
|
# @RETURN: LoggingConfigResponse - The current logging config.
|
||||||
async def validate_backup_path(
|
@router.get("/logging", response_model=LoggingConfigResponse)
|
||||||
path_data: dict,
|
async def get_logging_config(
|
||||||
config_manager: ConfigManager = Depends(get_config_manager)
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("admin:settings", "READ"))
|
||||||
):
|
):
|
||||||
path = path_data.get("path")
|
with belief_scope("get_logging_config"):
|
||||||
if not path:
|
logging_config = config_manager.get_config().settings.logging
|
||||||
raise HTTPException(status_code=400, detail="Path is required")
|
return LoggingConfigResponse(
|
||||||
|
level=logging_config.level,
|
||||||
logger.info(f"[validate_backup_path][Entry] Validating path: {path}")
|
task_log_level=logging_config.task_log_level,
|
||||||
|
enable_belief_state=logging_config.enable_belief_state
|
||||||
valid, message = config_manager.validate_path(path)
|
)
|
||||||
|
# [/DEF:get_logging_config:Function]
|
||||||
if not valid:
|
|
||||||
return {"status": "error", "message": message}
|
|
||||||
|
|
||||||
return {"status": "success", "message": message}
|
|
||||||
# [/DEF:validate_backup_path]
|
|
||||||
|
|
||||||
# [/DEF:SettingsRouter]
|
# [DEF:update_logging_config:Function]
|
||||||
|
# @PURPOSE: Updates logging configuration.
|
||||||
|
# @PRE: New logging config is provided.
|
||||||
|
# @POST: Logging configuration is updated and saved.
|
||||||
|
# @PARAM: config (LoggingConfig) - The new logging configuration.
|
||||||
|
# @RETURN: LoggingConfigResponse - The updated logging config.
|
||||||
|
@router.patch("/logging", response_model=LoggingConfigResponse)
|
||||||
|
async def update_logging_config(
|
||||||
|
config: LoggingConfig,
|
||||||
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("admin:settings", "WRITE"))
|
||||||
|
):
|
||||||
|
with belief_scope("update_logging_config"):
|
||||||
|
logger.info(f"[update_logging_config][Entry] Updating logging config: level={config.level}, task_log_level={config.task_log_level}")
|
||||||
|
|
||||||
|
# Get current settings and update logging config
|
||||||
|
settings = config_manager.get_config().settings
|
||||||
|
settings.logging = config
|
||||||
|
config_manager.update_global_settings(settings)
|
||||||
|
|
||||||
|
return LoggingConfigResponse(
|
||||||
|
level=config.level,
|
||||||
|
task_log_level=config.task_log_level,
|
||||||
|
enable_belief_state=config.enable_belief_state
|
||||||
|
)
|
||||||
|
# [/DEF:update_logging_config:Function]
|
||||||
|
|
||||||
|
# [DEF:ConsolidatedSettingsResponse:Class]
|
||||||
|
class ConsolidatedSettingsResponse(BaseModel):
|
||||||
|
environments: List[dict]
|
||||||
|
connections: List[dict]
|
||||||
|
llm: dict
|
||||||
|
logging: dict
|
||||||
|
storage: dict
|
||||||
|
# [/DEF:ConsolidatedSettingsResponse:Class]
|
||||||
|
|
||||||
|
# [DEF:get_consolidated_settings:Function]
|
||||||
|
# @PURPOSE: Retrieves all settings categories in a single call
|
||||||
|
# @PRE: Config manager is available.
|
||||||
|
# @POST: Returns all consolidated settings.
|
||||||
|
# @RETURN: ConsolidatedSettingsResponse - All settings categories.
|
||||||
|
@router.get("/consolidated", response_model=ConsolidatedSettingsResponse)
|
||||||
|
async def get_consolidated_settings(
|
||||||
|
config_manager: ConfigManager = Depends(get_config_manager),
|
||||||
|
_ = Depends(has_permission("admin:settings", "READ"))
|
||||||
|
):
|
||||||
|
with belief_scope("get_consolidated_settings"):
|
||||||
|
logger.info("[get_consolidated_settings][Entry] Fetching all consolidated settings")
|
||||||
|
|
||||||
|
config = config_manager.get_config()
|
||||||
|
|
||||||
|
return ConsolidatedSettingsResponse(
|
||||||
|
environments=config.environments,
|
||||||
|
connections=config.settings.connections,
|
||||||
|
llm=config.settings.llm,
|
||||||
|
logging=config.settings.logging,
|
||||||
|
storage=config.settings.storage
|
||||||
|
)
|
||||||
|
# [/DEF:get_consolidated_settings:Function]
|
||||||
|
|
||||||
|
# [/DEF:SettingsRouter:Module]
|
||||||
|
|||||||
146
backend/src/api/routes/storage.py
Normal file
146
backend/src/api/routes/storage.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# [DEF:storage_routes:Module]
|
||||||
|
#
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: storage, files, upload, download, backup, repository
|
||||||
|
# @PURPOSE: API endpoints for file storage management (backups and repositories).
|
||||||
|
# @LAYER: API
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.models.storage
|
||||||
|
#
|
||||||
|
# @INVARIANT: All paths must be validated against path traversal.
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from typing import List, Optional
|
||||||
|
from ...models.storage import StoredFile, FileCategory
|
||||||
|
from ...dependencies import get_plugin_loader, has_permission
|
||||||
|
from ...plugins.storage.plugin import StoragePlugin
|
||||||
|
from ...core.logger import belief_scope
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
router = APIRouter(tags=["storage"])
|
||||||
|
|
||||||
|
# [DEF:list_files:Function]
|
||||||
|
# @PURPOSE: List all files and directories in the storage system.
|
||||||
|
#
|
||||||
|
# @PRE: None.
|
||||||
|
# @POST: Returns a list of StoredFile objects.
|
||||||
|
#
|
||||||
|
# @PARAM: category (Optional[FileCategory]) - Filter by category.
|
||||||
|
# @PARAM: path (Optional[str]) - Subpath within the category.
|
||||||
|
# @RETURN: List[StoredFile] - List of files/directories.
|
||||||
|
#
|
||||||
|
# @RELATION: CALLS -> StoragePlugin.list_files
|
||||||
|
@router.get("/files", response_model=List[StoredFile])
|
||||||
|
async def list_files(
|
||||||
|
category: Optional[FileCategory] = None,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
plugin_loader=Depends(get_plugin_loader),
|
||||||
|
_ = Depends(has_permission("plugin:storage", "READ"))
|
||||||
|
):
|
||||||
|
with belief_scope("list_files"):
|
||||||
|
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||||
|
if not storage_plugin:
|
||||||
|
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||||
|
return storage_plugin.list_files(category, path)
|
||||||
|
# [/DEF:list_files:Function]
|
||||||
|
|
||||||
|
# [DEF:upload_file:Function]
|
||||||
|
# @PURPOSE: Upload a file to the storage system.
|
||||||
|
#
|
||||||
|
# @PRE: category must be a valid FileCategory.
|
||||||
|
# @PRE: file must be a valid UploadFile.
|
||||||
|
# @POST: Returns the StoredFile object of the uploaded file.
|
||||||
|
#
|
||||||
|
# @PARAM: category (FileCategory) - Target category.
|
||||||
|
# @PARAM: path (Optional[str]) - Target subpath.
|
||||||
|
# @PARAM: file (UploadFile) - The file content.
|
||||||
|
# @RETURN: StoredFile - Metadata of the uploaded file.
|
||||||
|
#
|
||||||
|
# @SIDE_EFFECT: Writes file to the filesystem.
|
||||||
|
#
|
||||||
|
# @RELATION: CALLS -> StoragePlugin.save_file
|
||||||
|
@router.post("/upload", response_model=StoredFile, status_code=201)
|
||||||
|
async def upload_file(
|
||||||
|
category: FileCategory = Form(...),
|
||||||
|
path: Optional[str] = Form(None),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
plugin_loader=Depends(get_plugin_loader),
|
||||||
|
_ = Depends(has_permission("plugin:storage", "WRITE"))
|
||||||
|
):
|
||||||
|
with belief_scope("upload_file"):
|
||||||
|
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||||
|
if not storage_plugin:
|
||||||
|
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||||
|
try:
|
||||||
|
return await storage_plugin.save_file(file, category, path)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:upload_file:Function]
|
||||||
|
|
||||||
|
# [DEF:delete_file:Function]
|
||||||
|
# @PURPOSE: Delete a specific file or directory.
|
||||||
|
#
|
||||||
|
# @PRE: category must be a valid FileCategory.
|
||||||
|
# @POST: Item is removed from storage.
|
||||||
|
#
|
||||||
|
# @PARAM: category (FileCategory) - File category.
|
||||||
|
# @PARAM: path (str) - Relative path of the item.
|
||||||
|
# @RETURN: None
|
||||||
|
#
|
||||||
|
# @SIDE_EFFECT: Deletes item from the filesystem.
|
||||||
|
#
|
||||||
|
# @RELATION: CALLS -> StoragePlugin.delete_file
|
||||||
|
@router.delete("/files/{category}/{path:path}", status_code=204)
|
||||||
|
async def delete_file(
|
||||||
|
category: FileCategory,
|
||||||
|
path: str,
|
||||||
|
plugin_loader=Depends(get_plugin_loader),
|
||||||
|
_ = Depends(has_permission("plugin:storage", "WRITE"))
|
||||||
|
):
|
||||||
|
with belief_scope("delete_file"):
|
||||||
|
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||||
|
if not storage_plugin:
|
||||||
|
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||||
|
try:
|
||||||
|
storage_plugin.delete_file(category, path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:delete_file:Function]
|
||||||
|
|
||||||
|
# [DEF:download_file:Function]
|
||||||
|
# @PURPOSE: Retrieve a file for download.
|
||||||
|
#
|
||||||
|
# @PRE: category must be a valid FileCategory.
|
||||||
|
# @POST: Returns a FileResponse.
|
||||||
|
#
|
||||||
|
# @PARAM: category (FileCategory) - File category.
|
||||||
|
# @PARAM: path (str) - Relative path of the file.
|
||||||
|
# @RETURN: FileResponse - The file content.
|
||||||
|
#
|
||||||
|
# @RELATION: CALLS -> StoragePlugin.get_file_path
|
||||||
|
@router.get("/download/{category}/{path:path}")
|
||||||
|
async def download_file(
|
||||||
|
category: FileCategory,
|
||||||
|
path: str,
|
||||||
|
plugin_loader=Depends(get_plugin_loader),
|
||||||
|
_ = Depends(has_permission("plugin:storage", "READ"))
|
||||||
|
):
|
||||||
|
with belief_scope("download_file"):
|
||||||
|
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
|
||||||
|
if not storage_plugin:
|
||||||
|
raise HTTPException(status_code=500, detail="Storage plugin not loaded")
|
||||||
|
try:
|
||||||
|
abs_path = storage_plugin.get_file_path(category, path)
|
||||||
|
filename = Path(path).name
|
||||||
|
return FileResponse(path=abs_path, filename=filename)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
# [/DEF:download_file:Function]
|
||||||
|
|
||||||
|
# [/DEF:storage_routes:Module]
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
# [DEF:TasksRouter:Module]
|
# [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.
|
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
|
||||||
# @LAYER: UI (API)
|
# @LAYER: UI (API)
|
||||||
# @RELATION: Depends on the TaskManager. It is included by the main app.
|
# @RELATION: Depends on the TaskManager. It is included by the main app.
|
||||||
from typing import List, Dict, Any, Optional
|
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 pydantic import BaseModel
|
||||||
|
from ...core.logger import belief_scope
|
||||||
|
|
||||||
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -23,98 +26,254 @@ class ResumeTaskRequest(BaseModel):
|
|||||||
passwords: Dict[str, str]
|
passwords: Dict[str, str]
|
||||||
|
|
||||||
@router.post("", response_model=Task, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=Task, status_code=status.HTTP_201_CREATED)
|
||||||
|
# [DEF:create_task:Function]
|
||||||
|
# @PURPOSE: Create and start a new task for a given plugin.
|
||||||
|
# @PARAM: request (CreateTaskRequest) - The request body containing plugin_id and params.
|
||||||
|
# @PARAM: task_manager (TaskManager) - The task manager instance.
|
||||||
|
# @PRE: plugin_id must exist and params must be valid for that plugin.
|
||||||
|
# @POST: A new task is created and started.
|
||||||
|
# @RETURN: Task - The created task instance.
|
||||||
async def create_task(
|
async def create_task(
|
||||||
request: CreateTaskRequest,
|
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.
|
Create and start a new task for a given plugin.
|
||||||
"""
|
"""
|
||||||
try:
|
with belief_scope("create_task"):
|
||||||
task = await task_manager.create_task(
|
try:
|
||||||
plugin_id=request.plugin_id,
|
# Special handling for validation task to include provider config
|
||||||
params=request.params
|
if request.plugin_id == "llm_dashboard_validation":
|
||||||
)
|
from ...core.database import SessionLocal
|
||||||
return task
|
from ...services.llm_provider import LLMProviderService
|
||||||
except ValueError as e:
|
db = SessionLocal()
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
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
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
# [/DEF:create_task:Function]
|
||||||
|
|
||||||
@router.get("", response_model=List[Task])
|
@router.get("", response_model=List[Task])
|
||||||
|
# [DEF:list_tasks:Function]
|
||||||
|
# @PURPOSE: Retrieve a list of tasks with pagination and optional status filter.
|
||||||
|
# @PARAM: limit (int) - Maximum number of tasks to return.
|
||||||
|
# @PARAM: offset (int) - Number of tasks to skip.
|
||||||
|
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
|
||||||
|
# @PARAM: task_manager (TaskManager) - The task manager instance.
|
||||||
|
# @PRE: task_manager must be available.
|
||||||
|
# @POST: Returns a list of tasks.
|
||||||
|
# @RETURN: List[Task] - List of tasks.
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
status: Optional[TaskStatus] = None,
|
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.
|
Retrieve a list of tasks with pagination and optional status filter.
|
||||||
"""
|
"""
|
||||||
return task_manager.get_tasks(limit=limit, offset=offset, status=status)
|
with belief_scope("list_tasks"):
|
||||||
|
return task_manager.get_tasks(limit=limit, offset=offset, status=status)
|
||||||
|
# [/DEF:list_tasks:Function]
|
||||||
|
|
||||||
@router.get("/{task_id}", response_model=Task)
|
@router.get("/{task_id}", response_model=Task)
|
||||||
|
# [DEF:get_task:Function]
|
||||||
|
# @PURPOSE: Retrieve the details of a specific task.
|
||||||
|
# @PARAM: task_id (str) - The unique identifier of the task.
|
||||||
|
# @PARAM: task_manager (TaskManager) - The task manager instance.
|
||||||
|
# @PRE: task_id must exist.
|
||||||
|
# @POST: Returns task details or raises 404.
|
||||||
|
# @RETURN: Task - The task details.
|
||||||
async def get_task(
|
async def get_task(
|
||||||
task_id: str,
|
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.
|
Retrieve the details of a specific task.
|
||||||
"""
|
"""
|
||||||
task = task_manager.get_task(task_id)
|
with belief_scope("get_task"):
|
||||||
if not task:
|
task = task_manager.get_task(task_id)
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
if not task:
|
||||||
return task
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||||
|
return task
|
||||||
|
# [/DEF:get_task:Function]
|
||||||
|
|
||||||
@router.get("/{task_id}/logs", response_model=List[LogEntry])
|
@router.get("/{task_id}/logs", response_model=List[LogEntry])
|
||||||
|
# [DEF:get_task_logs:Function]
|
||||||
|
# @PURPOSE: Retrieve logs for a specific task with optional filtering.
|
||||||
|
# @PARAM: task_id (str) - The unique identifier of the task.
|
||||||
|
# @PARAM: level (Optional[str]) - Filter by log level (DEBUG, INFO, WARNING, ERROR).
|
||||||
|
# @PARAM: source (Optional[str]) - Filter by source component.
|
||||||
|
# @PARAM: search (Optional[str]) - Text search in message.
|
||||||
|
# @PARAM: offset (int) - Number of logs to skip.
|
||||||
|
# @PARAM: limit (int) - Maximum number of logs to return.
|
||||||
|
# @PARAM: task_manager (TaskManager) - The task manager instance.
|
||||||
|
# @PRE: task_id must exist.
|
||||||
|
# @POST: Returns a list of log entries or raises 404.
|
||||||
|
# @RETURN: List[LogEntry] - List of log entries.
|
||||||
|
# @TIER: CRITICAL
|
||||||
async def get_task_logs(
|
async def get_task_logs(
|
||||||
task_id: str,
|
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.
|
||||||
"""
|
"""
|
||||||
task = task_manager.get_task(task_id)
|
with belief_scope("get_task_logs"):
|
||||||
if not task:
|
task = task_manager.get_task(task_id)
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
if not task:
|
||||||
return task_manager.get_task_logs(task_id)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||||
|
|
||||||
|
log_filter = LogFilter(
|
||||||
|
level=level.upper() if level else None,
|
||||||
|
source=source,
|
||||||
|
search=search,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
return task_manager.get_task_logs(task_id, log_filter)
|
||||||
|
# [/DEF:get_task_logs:Function]
|
||||||
|
|
||||||
|
@router.get("/{task_id}/logs/stats", response_model=LogStats)
|
||||||
|
# [DEF:get_task_log_stats:Function]
|
||||||
|
# @PURPOSE: Get statistics about logs for a task (counts by level and source).
|
||||||
|
# @PARAM: task_id (str) - The unique identifier of the task.
|
||||||
|
# @PARAM: task_manager (TaskManager) - The task manager instance.
|
||||||
|
# @PRE: task_id must exist.
|
||||||
|
# @POST: Returns log statistics or raises 404.
|
||||||
|
# @RETURN: LogStats - Statistics about task logs.
|
||||||
|
async def get_task_log_stats(
|
||||||
|
task_id: str,
|
||||||
|
task_manager: TaskManager = Depends(get_task_manager),
|
||||||
|
_ = Depends(has_permission("tasks", "READ"))
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get statistics about logs for a task (counts by level and source).
|
||||||
|
"""
|
||||||
|
with belief_scope("get_task_log_stats"):
|
||||||
|
task = task_manager.get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||||
|
return task_manager.get_task_log_stats(task_id)
|
||||||
|
# [/DEF:get_task_log_stats:Function]
|
||||||
|
|
||||||
|
@router.get("/{task_id}/logs/sources", response_model=List[str])
|
||||||
|
# [DEF:get_task_log_sources:Function]
|
||||||
|
# @PURPOSE: Get unique sources for a task's logs.
|
||||||
|
# @PARAM: task_id (str) - The unique identifier of the task.
|
||||||
|
# @PARAM: task_manager (TaskManager) - The task manager instance.
|
||||||
|
# @PRE: task_id must exist.
|
||||||
|
# @POST: Returns list of unique source names or raises 404.
|
||||||
|
# @RETURN: List[str] - Unique source names.
|
||||||
|
async def get_task_log_sources(
|
||||||
|
task_id: str,
|
||||||
|
task_manager: TaskManager = Depends(get_task_manager),
|
||||||
|
_ = Depends(has_permission("tasks", "READ"))
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get unique sources for a task's logs.
|
||||||
|
"""
|
||||||
|
with belief_scope("get_task_log_sources"):
|
||||||
|
task = task_manager.get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||||
|
return task_manager.get_task_log_sources(task_id)
|
||||||
|
# [/DEF:get_task_log_sources:Function]
|
||||||
|
|
||||||
@router.post("/{task_id}/resolve", response_model=Task)
|
@router.post("/{task_id}/resolve", response_model=Task)
|
||||||
|
# [DEF:resolve_task:Function]
|
||||||
|
# @PURPOSE: Resolve a task that is awaiting mapping.
|
||||||
|
# @PARAM: task_id (str) - The unique identifier of the task.
|
||||||
|
# @PARAM: request (ResolveTaskRequest) - The resolution parameters.
|
||||||
|
# @PARAM: task_manager (TaskManager) - The task manager instance.
|
||||||
|
# @PRE: task must be in AWAITING_MAPPING status.
|
||||||
|
# @POST: Task is resolved and resumes execution.
|
||||||
|
# @RETURN: Task - The updated task object.
|
||||||
async def resolve_task(
|
async def resolve_task(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
request: ResolveTaskRequest,
|
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.
|
Resolve a task that is awaiting mapping.
|
||||||
"""
|
"""
|
||||||
try:
|
with belief_scope("resolve_task"):
|
||||||
await task_manager.resolve_task(task_id, request.resolution_params)
|
try:
|
||||||
return task_manager.get_task(task_id)
|
await task_manager.resolve_task(task_id, request.resolution_params)
|
||||||
except ValueError as e:
|
return task_manager.get_task(task_id)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
# [/DEF:resolve_task:Function]
|
||||||
|
|
||||||
@router.post("/{task_id}/resume", response_model=Task)
|
@router.post("/{task_id}/resume", response_model=Task)
|
||||||
|
# [DEF:resume_task:Function]
|
||||||
|
# @PURPOSE: Resume a task that is awaiting input (e.g., passwords).
|
||||||
|
# @PARAM: task_id (str) - The unique identifier of the task.
|
||||||
|
# @PARAM: request (ResumeTaskRequest) - The input (passwords).
|
||||||
|
# @PARAM: task_manager (TaskManager) - The task manager instance.
|
||||||
|
# @PRE: task must be in AWAITING_INPUT status.
|
||||||
|
# @POST: Task resumes execution with provided input.
|
||||||
|
# @RETURN: Task - The updated task object.
|
||||||
async def resume_task(
|
async def resume_task(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
request: ResumeTaskRequest,
|
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).
|
Resume a task that is awaiting input (e.g., passwords).
|
||||||
"""
|
"""
|
||||||
try:
|
with belief_scope("resume_task"):
|
||||||
task_manager.resume_task_with_password(task_id, request.passwords)
|
try:
|
||||||
return task_manager.get_task(task_id)
|
task_manager.resume_task_with_password(task_id, request.passwords)
|
||||||
except ValueError as e:
|
return task_manager.get_task(task_id)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
# [/DEF:resume_task:Function]
|
||||||
|
|
||||||
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
# [DEF:clear_tasks:Function]
|
||||||
|
# @PURPOSE: Clear tasks matching the status filter.
|
||||||
|
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
|
||||||
|
# @PARAM: task_manager (TaskManager) - The task manager instance.
|
||||||
|
# @PRE: task_manager is available.
|
||||||
|
# @POST: Tasks are removed from memory/persistence.
|
||||||
async def clear_tasks(
|
async def clear_tasks(
|
||||||
status: Optional[TaskStatus] = None,
|
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.
|
Clear tasks matching the status filter. If no filter, clears all non-running tasks.
|
||||||
"""
|
"""
|
||||||
task_manager.clear_tasks(status)
|
with belief_scope("clear_tasks", f"status={status}"):
|
||||||
return
|
task_manager.clear_tasks(status)
|
||||||
# [/DEF]
|
return
|
||||||
|
# [/DEF:clear_tasks:Function]
|
||||||
|
# [/DEF:TasksRouter:Module]
|
||||||
@@ -1,30 +1,28 @@
|
|||||||
# [DEF:AppModule:Module]
|
# [DEF:AppModule:Module]
|
||||||
|
# @TIER: CRITICAL
|
||||||
# @SEMANTICS: app, main, entrypoint, fastapi
|
# @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.
|
# @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)
|
# @LAYER: UI (API)
|
||||||
# @RELATION: Depends on the dependency module and API route modules.
|
# @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
|
from pathlib import Path
|
||||||
|
|
||||||
# Add project root to sys.path to allow importing superset_tool
|
# project_root is used for static files mounting
|
||||||
# Assuming app.py is in backend/src/
|
|
||||||
project_root = Path(__file__).resolve().parent.parent.parent
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
sys.path.append(str(project_root))
|
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
|
|
||||||
from .dependencies import get_task_manager
|
from .dependencies import get_task_manager, get_scheduler_service
|
||||||
from .core.logger import logger
|
from .core.utils.network import NetworkError
|
||||||
from .api.routes import plugins, tasks, settings, environments, mappings, migration
|
from .core.logger import logger, belief_scope
|
||||||
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
|
||||||
# Initialize database
|
|
||||||
init_db()
|
|
||||||
|
|
||||||
# [DEF:App:Global]
|
# [DEF:App:Global]
|
||||||
# @SEMANTICS: app, fastapi, instance
|
# @SEMANTICS: app, fastapi, instance
|
||||||
@@ -34,6 +32,35 @@ app = FastAPI(
|
|||||||
description="API for managing Superset automation tools and plugins.",
|
description="API for managing Superset automation tools and plugins.",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
)
|
)
|
||||||
|
# [/DEF:App:Global]
|
||||||
|
|
||||||
|
# [DEF:startup_event:Function]
|
||||||
|
# @PURPOSE: Handles application startup tasks, such as starting the scheduler.
|
||||||
|
# @PRE: None.
|
||||||
|
# @POST: Scheduler is started.
|
||||||
|
# Startup event
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
with belief_scope("startup_event"):
|
||||||
|
scheduler = get_scheduler_service()
|
||||||
|
scheduler.start()
|
||||||
|
# [/DEF:startup_event:Function]
|
||||||
|
|
||||||
|
# [DEF:shutdown_event:Function]
|
||||||
|
# @PURPOSE: Handles application shutdown tasks, such as stopping the scheduler.
|
||||||
|
# @PRE: None.
|
||||||
|
# @POST: Scheduler is stopped.
|
||||||
|
# Shutdown event
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown_event():
|
||||||
|
with belief_scope("shutdown_event"):
|
||||||
|
scheduler = get_scheduler_service()
|
||||||
|
scheduler.stop()
|
||||||
|
# [/DEF:shutdown_event:Function]
|
||||||
|
|
||||||
|
# Configure Session Middleware (required by Authlib for OAuth2 flow)
|
||||||
|
from .core.auth.config import auth_config
|
||||||
|
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
|
||||||
|
|
||||||
# Configure CORS
|
# Configure CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -45,40 +72,118 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:log_requests:Function]
|
||||||
|
# @PURPOSE: Middleware to log incoming HTTP requests and their response status.
|
||||||
|
# @PRE: request is a FastAPI Request object.
|
||||||
|
# @POST: Logs request and response details.
|
||||||
|
# @PARAM: request (Request) - The incoming request object.
|
||||||
|
# @PARAM: call_next (Callable) - The next middleware or route handler.
|
||||||
|
@app.exception_handler(NetworkError)
|
||||||
|
async def network_error_handler(request: Request, exc: NetworkError):
|
||||||
|
with belief_scope("network_error_handler"):
|
||||||
|
logger.error(f"Network error: {exc}")
|
||||||
|
return HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Environment unavailable. Please check if the Superset instance is running."
|
||||||
|
)
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def log_requests(request: Request, call_next):
|
async def log_requests(request: Request, call_next):
|
||||||
logger.info(f"[DEBUG] Incoming request: {request.method} {request.url.path}")
|
# Avoid spamming logs for polling endpoints
|
||||||
response = await call_next(request)
|
is_polling = request.url.path.endswith("/api/tasks") and request.method == "GET"
|
||||||
logger.info(f"[DEBUG] Response status: {response.status_code} for {request.url.path}")
|
|
||||||
return response
|
if not is_polling:
|
||||||
|
logger.info(f"Incoming request: {request.method} {request.url.path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
if not is_polling:
|
||||||
|
logger.info(f"Response status: {response.status_code} for {request.url.path}")
|
||||||
|
return response
|
||||||
|
except NetworkError as e:
|
||||||
|
logger.error(f"Network error caught in middleware: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Environment unavailable. Please check if the Superset instance is running."
|
||||||
|
)
|
||||||
|
# [/DEF:log_requests:Function]
|
||||||
|
|
||||||
# Include API routes
|
# 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(plugins.router, prefix="/api/plugins", tags=["Plugins"])
|
||||||
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
|
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
|
||||||
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||||
app.include_router(environments.router)
|
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(mappings.router)
|
||||||
app.include_router(migration.router)
|
app.include_router(migration.router)
|
||||||
|
app.include_router(git.router)
|
||||||
|
app.include_router(llm.router)
|
||||||
|
app.include_router(storage.router, prefix="/api/storage", tags=["Storage"])
|
||||||
|
app.include_router(dashboards.router, tags=["Dashboards"])
|
||||||
|
app.include_router(datasets.router, tags=["Datasets"])
|
||||||
|
|
||||||
# [DEF:WebSocketEndpoint:Endpoint]
|
# [DEF:websocket_endpoint:Function]
|
||||||
# @SEMANTICS: websocket, logs, streaming, real-time
|
# @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering.
|
||||||
# @PURPOSE: Provides a WebSocket endpoint for clients to connect to and receive real-time log entries for a specific task.
|
# @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}")
|
@app.websocket("/ws/logs/{task_id}")
|
||||||
async def websocket_endpoint(websocket: WebSocket, task_id: str):
|
async def websocket_endpoint(
|
||||||
await websocket.accept()
|
websocket: WebSocket,
|
||||||
logger.info(f"WebSocket connection accepted for task {task_id}")
|
task_id: str,
|
||||||
|
source: str = None,
|
||||||
|
level: str = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
WebSocket endpoint for real-time log streaming with optional server-side filtering.
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
source: Filter logs by source component (e.g., "plugin", "superset_api")
|
||||||
|
level: Filter logs by minimum level (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
"""
|
||||||
|
with belief_scope("websocket_endpoint", f"task_id={task_id}"):
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Normalize filter parameters
|
||||||
|
source_filter = source.lower() if source else None
|
||||||
|
level_filter = level.upper() if level else None
|
||||||
|
|
||||||
|
# Level hierarchy for filtering
|
||||||
|
level_hierarchy = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
|
||||||
|
min_level = level_hierarchy.get(level_filter, 0) if level_filter else 0
|
||||||
|
|
||||||
|
logger.info(f"WebSocket connection accepted for task {task_id} (source={source_filter}, level={level_filter})")
|
||||||
task_manager = get_task_manager()
|
task_manager = get_task_manager()
|
||||||
queue = await task_manager.subscribe_logs(task_id)
|
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:
|
try:
|
||||||
# Stream new logs
|
# Stream new logs
|
||||||
logger.info(f"Starting log stream for task {task_id}")
|
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)
|
initial_logs = task_manager.get_task_logs(task_id)
|
||||||
for log_entry in initial_logs:
|
for log_entry in initial_logs:
|
||||||
log_dict = log_entry.dict()
|
if matches_filters(log_entry):
|
||||||
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
|
log_dict = log_entry.dict()
|
||||||
await websocket.send_json(log_dict)
|
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
|
||||||
|
await websocket.send_json(log_dict)
|
||||||
|
|
||||||
# Force a check for AWAITING_INPUT status immediately upon connection
|
# 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.
|
# This ensures that if the task is already waiting when the user connects, they get the prompt.
|
||||||
@@ -96,6 +201,11 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str):
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
log_entry = await queue.get()
|
log_entry = await queue.get()
|
||||||
|
|
||||||
|
# Apply server-side filtering
|
||||||
|
if not matches_filters(log_entry):
|
||||||
|
continue
|
||||||
|
|
||||||
log_dict = log_entry.dict()
|
log_dict = log_entry.dict()
|
||||||
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
|
log_dict['timestamp'] = log_dict['timestamp'].isoformat()
|
||||||
await websocket.send_json(log_dict)
|
await websocket.send_json(log_dict)
|
||||||
@@ -115,8 +225,7 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str):
|
|||||||
logger.error(f"WebSocket error for task {task_id}: {e}")
|
logger.error(f"WebSocket error for task {task_id}: {e}")
|
||||||
finally:
|
finally:
|
||||||
task_manager.unsubscribe_logs(task_id, queue)
|
task_manager.unsubscribe_logs(task_id, queue)
|
||||||
|
# [/DEF:websocket_endpoint:Function]
|
||||||
# [/DEF]
|
|
||||||
|
|
||||||
# [DEF:StaticFiles:Mount]
|
# [DEF:StaticFiles:Mount]
|
||||||
# @SEMANTICS: static, frontend, spa
|
# @SEMANTICS: static, frontend, spa
|
||||||
@@ -126,18 +235,33 @@ if frontend_path.exists():
|
|||||||
app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static")
|
app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static")
|
||||||
|
|
||||||
# Serve other static files from the root of build directory
|
# 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}")
|
@app.get("/{file_path:path}")
|
||||||
async def serve_spa(file_path: str):
|
async def serve_spa(file_path: str):
|
||||||
full_path = frontend_path / file_path
|
with belief_scope("serve_spa", f"path={file_path}"):
|
||||||
if full_path.is_file():
|
# Don't serve SPA for API routes that fell through
|
||||||
return FileResponse(str(full_path))
|
if file_path.startswith("api/"):
|
||||||
# Fallback to index.html for SPA routing
|
logger.info(f"[DEBUG] API route fell through to serve_spa: {file_path}")
|
||||||
return FileResponse(str(frontend_path / "index.html"))
|
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
|
||||||
|
|
||||||
|
full_path = frontend_path / file_path
|
||||||
|
if full_path.is_file():
|
||||||
|
return FileResponse(str(full_path))
|
||||||
|
# Fallback to index.html for SPA routing
|
||||||
|
return FileResponse(str(frontend_path / "index.html"))
|
||||||
|
# [/DEF:serve_spa:Function]
|
||||||
else:
|
else:
|
||||||
# [DEF:RootEndpoint:Endpoint]
|
# [DEF:read_root:Function]
|
||||||
# @SEMANTICS: root, healthcheck
|
# @PURPOSE: A simple root endpoint to confirm that the API is running when frontend is missing.
|
||||||
# @PURPOSE: A simple root endpoint to confirm that the API is running.
|
# @PRE: None.
|
||||||
|
# @POST: Returns a JSON message indicating API status.
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def read_root():
|
async def read_root():
|
||||||
return {"message": "Superset Tools API is running (Frontend build not found)"}
|
with belief_scope("read_root"):
|
||||||
# [/DEF]
|
return {"message": "Superset Tools API is running (Frontend build not found)"}
|
||||||
|
# [/DEF:read_root:Function]
|
||||||
|
# [/DEF:StaticFiles:Mount]
|
||||||
|
# [/DEF:AppModule:Module]
|
||||||
|
|||||||
44
backend/src/core/auth/config.py
Normal file
44
backend/src/core/auth/config.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# [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="sqlite:///./backend/auth.db", 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]
|
||||||
@@ -15,8 +15,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from .config_models import AppConfig, Environment, GlobalSettings
|
from .config_models import AppConfig, Environment, GlobalSettings, StorageConfig
|
||||||
from .logger import logger, configure_logger
|
from .logger import logger, configure_logger, belief_scope
|
||||||
# [/SECTION]
|
# [/SECTION]
|
||||||
|
|
||||||
# [DEF:ConfigManager:Class]
|
# [DEF:ConfigManager:Class]
|
||||||
@@ -30,45 +30,52 @@ class ConfigManager:
|
|||||||
# @POST: self.config is an instance of AppConfig
|
# @POST: self.config is an instance of AppConfig
|
||||||
# @PARAM: config_path (str) - Path to the configuration file.
|
# @PARAM: config_path (str) - Path to the configuration file.
|
||||||
def __init__(self, config_path: str = "config.json"):
|
def __init__(self, config_path: str = "config.json"):
|
||||||
# 1. Runtime check of @PRE
|
with belief_scope("__init__"):
|
||||||
assert isinstance(config_path, str) and config_path, "config_path must be a non-empty string"
|
# 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}")
|
|
||||||
|
logger.info(f"[ConfigManager][Entry] Initializing with {config_path}")
|
||||||
# 2. Logic implementation
|
|
||||||
self.config_path = Path(config_path)
|
# 2. Logic implementation
|
||||||
self.config: AppConfig = self._load_config()
|
self.config_path = Path(config_path)
|
||||||
|
self.config: AppConfig = self._load_config()
|
||||||
|
|
||||||
# Configure logger with loaded settings
|
# Configure logger with loaded settings
|
||||||
configure_logger(self.config.settings.logging)
|
configure_logger(self.config.settings.logging)
|
||||||
|
|
||||||
# 3. Runtime check of @POST
|
# 3. Runtime check of @POST
|
||||||
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
|
assert isinstance(self.config, AppConfig), "self.config must be an instance of AppConfig"
|
||||||
|
|
||||||
logger.info(f"[ConfigManager][Exit] Initialized")
|
logger.info("[ConfigManager][Exit] Initialized")
|
||||||
# [/DEF:__init__]
|
# [/DEF:__init__:Function]
|
||||||
|
|
||||||
# [DEF:_load_config:Function]
|
# [DEF:_load_config:Function]
|
||||||
# @PURPOSE: Loads the configuration from disk or creates a default one.
|
# @PURPOSE: Loads the configuration from disk or creates a default one.
|
||||||
|
# @PRE: self.config_path is set.
|
||||||
# @POST: isinstance(return, AppConfig)
|
# @POST: isinstance(return, AppConfig)
|
||||||
# @RETURN: AppConfig - The loaded or default configuration.
|
# @RETURN: AppConfig - The loaded or default configuration.
|
||||||
def _load_config(self) -> AppConfig:
|
def _load_config(self) -> AppConfig:
|
||||||
logger.debug(f"[_load_config][Entry] Loading from {self.config_path}")
|
with belief_scope("_load_config"):
|
||||||
|
logger.debug(f"[_load_config][Entry] Loading from {self.config_path}")
|
||||||
|
|
||||||
if not self.config_path.exists():
|
if not self.config_path.exists():
|
||||||
logger.info(f"[_load_config][Action] Config file not found. Creating default.")
|
logger.info("[_load_config][Action] Config file not found. Creating default.")
|
||||||
default_config = AppConfig(
|
default_config = AppConfig(
|
||||||
environments=[],
|
environments=[],
|
||||||
settings=GlobalSettings(backup_path="backups")
|
settings=GlobalSettings()
|
||||||
)
|
)
|
||||||
self._save_config_to_disk(default_config)
|
self._save_config_to_disk(default_config)
|
||||||
return default_config
|
return default_config
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.config_path, "r") as f:
|
with open(self.config_path, "r") as f:
|
||||||
data = json.load(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)
|
config = AppConfig(**data)
|
||||||
logger.info(f"[_load_config][Coherence:OK] Configuration loaded")
|
logger.info("[_load_config][Coherence:OK] Configuration loaded")
|
||||||
return config
|
return config
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[_load_config][Coherence:Failed] Error loading config: {e}")
|
logger.error(f"[_load_config][Coherence:Failed] Error loading config: {e}")
|
||||||
@@ -76,16 +83,18 @@ class ConfigManager:
|
|||||||
# For now, return default to be safe, but log the error prominently.
|
# For now, return default to be safe, but log the error prominently.
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
environments=[],
|
environments=[],
|
||||||
settings=GlobalSettings(backup_path="backups")
|
settings=GlobalSettings(storage=StorageConfig())
|
||||||
)
|
)
|
||||||
# [/DEF:_load_config]
|
# [/DEF:_load_config:Function]
|
||||||
|
|
||||||
# [DEF:_save_config_to_disk:Function]
|
# [DEF:_save_config_to_disk:Function]
|
||||||
# @PURPOSE: Saves the provided configuration object to disk.
|
# @PURPOSE: Saves the provided configuration object to disk.
|
||||||
# @PRE: isinstance(config, AppConfig)
|
# @PRE: isinstance(config, AppConfig)
|
||||||
|
# @POST: Configuration saved to disk.
|
||||||
# @PARAM: config (AppConfig) - The configuration to save.
|
# @PARAM: config (AppConfig) - The configuration to save.
|
||||||
def _save_config_to_disk(self, config: AppConfig):
|
def _save_config_to_disk(self, config: AppConfig):
|
||||||
logger.debug(f"[_save_config_to_disk][Entry] Saving to {self.config_path}")
|
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
|
# 1. Runtime check of @PRE
|
||||||
assert isinstance(config, AppConfig), "config must be an instance of AppConfig"
|
assert isinstance(config, AppConfig), "config must be an instance of AppConfig"
|
||||||
@@ -94,30 +103,38 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
with open(self.config_path, "w") as f:
|
with open(self.config_path, "w") as f:
|
||||||
json.dump(config.dict(), f, indent=4)
|
json.dump(config.dict(), f, indent=4)
|
||||||
logger.info(f"[_save_config_to_disk][Action] Configuration saved")
|
logger.info("[_save_config_to_disk][Action] Configuration saved")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[_save_config_to_disk][Coherence:Failed] Failed to save: {e}")
|
logger.error(f"[_save_config_to_disk][Coherence:Failed] Failed to save: {e}")
|
||||||
# [/DEF:_save_config_to_disk]
|
# [/DEF:_save_config_to_disk:Function]
|
||||||
|
|
||||||
# [DEF:save:Function]
|
# [DEF:save:Function]
|
||||||
# @PURPOSE: Saves the current configuration state to disk.
|
# @PURPOSE: Saves the current configuration state to disk.
|
||||||
|
# @PRE: self.config is set.
|
||||||
|
# @POST: self._save_config_to_disk called.
|
||||||
def save(self):
|
def save(self):
|
||||||
self._save_config_to_disk(self.config)
|
with belief_scope("save"):
|
||||||
# [/DEF:save]
|
self._save_config_to_disk(self.config)
|
||||||
|
# [/DEF:save:Function]
|
||||||
|
|
||||||
# [DEF:get_config:Function]
|
# [DEF:get_config:Function]
|
||||||
# @PURPOSE: Returns the current configuration.
|
# @PURPOSE: Returns the current configuration.
|
||||||
|
# @PRE: self.config is set.
|
||||||
|
# @POST: Returns self.config.
|
||||||
# @RETURN: AppConfig - The current configuration.
|
# @RETURN: AppConfig - The current configuration.
|
||||||
def get_config(self) -> AppConfig:
|
def get_config(self) -> AppConfig:
|
||||||
return self.config
|
with belief_scope("get_config"):
|
||||||
# [/DEF:get_config]
|
return self.config
|
||||||
|
# [/DEF:get_config:Function]
|
||||||
|
|
||||||
# [DEF:update_global_settings:Function]
|
# [DEF:update_global_settings:Function]
|
||||||
# @PURPOSE: Updates the global settings and persists the change.
|
# @PURPOSE: Updates the global settings and persists the change.
|
||||||
# @PRE: isinstance(settings, GlobalSettings)
|
# @PRE: isinstance(settings, GlobalSettings)
|
||||||
|
# @POST: self.config.settings updated and saved.
|
||||||
# @PARAM: settings (GlobalSettings) - The new global settings.
|
# @PARAM: settings (GlobalSettings) - The new global settings.
|
||||||
def update_global_settings(self, settings: GlobalSettings):
|
def update_global_settings(self, settings: GlobalSettings):
|
||||||
logger.info(f"[update_global_settings][Entry] Updating settings")
|
with belief_scope("update_global_settings"):
|
||||||
|
logger.info("[update_global_settings][Entry] Updating settings")
|
||||||
|
|
||||||
# 1. Runtime check of @PRE
|
# 1. Runtime check of @PRE
|
||||||
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
|
assert isinstance(settings, GlobalSettings), "settings must be an instance of GlobalSettings"
|
||||||
@@ -129,15 +146,18 @@ class ConfigManager:
|
|||||||
# Reconfigure logger with new settings
|
# Reconfigure logger with new settings
|
||||||
configure_logger(settings.logging)
|
configure_logger(settings.logging)
|
||||||
|
|
||||||
logger.info(f"[update_global_settings][Exit] Settings updated")
|
logger.info("[update_global_settings][Exit] Settings updated")
|
||||||
# [/DEF:update_global_settings]
|
# [/DEF:update_global_settings:Function]
|
||||||
|
|
||||||
# [DEF:validate_path:Function]
|
# [DEF:validate_path:Function]
|
||||||
# @PURPOSE: Validates if a path exists and is writable.
|
# @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.
|
# @PARAM: path (str) - The path to validate.
|
||||||
# @RETURN: tuple (bool, str) - (is_valid, message)
|
# @RETURN: tuple (bool, str) - (is_valid, message)
|
||||||
def validate_path(self, path: str) -> tuple[bool, str]:
|
def validate_path(self, path: str) -> tuple[bool, str]:
|
||||||
p = os.path.abspath(path)
|
with belief_scope("validate_path"):
|
||||||
|
p = os.path.abspath(path)
|
||||||
if not os.path.exists(p):
|
if not os.path.exists(p):
|
||||||
try:
|
try:
|
||||||
os.makedirs(p, exist_ok=True)
|
os.makedirs(p, exist_ok=True)
|
||||||
@@ -148,28 +168,50 @@ class ConfigManager:
|
|||||||
return False, "Path is not writable"
|
return False, "Path is not writable"
|
||||||
|
|
||||||
return True, "Path is valid and writable"
|
return True, "Path is valid and writable"
|
||||||
# [/DEF:validate_path]
|
# [/DEF:validate_path:Function]
|
||||||
|
|
||||||
# [DEF:get_environments:Function]
|
# [DEF:get_environments:Function]
|
||||||
# @PURPOSE: Returns the list of configured environments.
|
# @PURPOSE: Returns the list of configured environments.
|
||||||
|
# @PRE: self.config is set.
|
||||||
|
# @POST: Returns list of environments.
|
||||||
# @RETURN: List[Environment] - List of environments.
|
# @RETURN: List[Environment] - List of environments.
|
||||||
def get_environments(self) -> List[Environment]:
|
def get_environments(self) -> List[Environment]:
|
||||||
return self.config.environments
|
with belief_scope("get_environments"):
|
||||||
# [/DEF:get_environments]
|
return self.config.environments
|
||||||
|
# [/DEF:get_environments:Function]
|
||||||
|
|
||||||
# [DEF:has_environments:Function]
|
# [DEF:has_environments:Function]
|
||||||
# @PURPOSE: Checks if at least one environment is configured.
|
# @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.
|
# @RETURN: bool - True if at least one environment exists.
|
||||||
def has_environments(self) -> bool:
|
def has_environments(self) -> bool:
|
||||||
return len(self.config.environments) > 0
|
with belief_scope("has_environments"):
|
||||||
# [/DEF: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]
|
# [DEF:add_environment:Function]
|
||||||
# @PURPOSE: Adds a new environment to the configuration.
|
# @PURPOSE: Adds a new environment to the configuration.
|
||||||
# @PRE: isinstance(env, Environment)
|
# @PRE: isinstance(env, Environment)
|
||||||
|
# @POST: Environment added or updated in self.config.environments.
|
||||||
# @PARAM: env (Environment) - The environment to add.
|
# @PARAM: env (Environment) - The environment to add.
|
||||||
def add_environment(self, env: Environment):
|
def add_environment(self, env: Environment):
|
||||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
with belief_scope("add_environment"):
|
||||||
|
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||||
|
|
||||||
# 1. Runtime check of @PRE
|
# 1. Runtime check of @PRE
|
||||||
assert isinstance(env, Environment), "env must be an instance of Environment"
|
assert isinstance(env, Environment), "env must be an instance of Environment"
|
||||||
@@ -180,17 +222,19 @@ class ConfigManager:
|
|||||||
self.config.environments.append(env)
|
self.config.environments.append(env)
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
logger.info(f"[add_environment][Exit] Environment added")
|
logger.info("[add_environment][Exit] Environment added")
|
||||||
# [/DEF:add_environment]
|
# [/DEF:add_environment:Function]
|
||||||
|
|
||||||
# [DEF:update_environment:Function]
|
# [DEF:update_environment:Function]
|
||||||
# @PURPOSE: Updates an existing environment.
|
# @PURPOSE: Updates an existing environment.
|
||||||
# @PRE: isinstance(env_id, str) and len(env_id) > 0 and isinstance(updated_env, 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: env_id (str) - The ID of the environment to update.
|
||||||
# @PARAM: updated_env (Environment) - The updated environment data.
|
# @PARAM: updated_env (Environment) - The updated environment data.
|
||||||
# @RETURN: bool - True if updated, False otherwise.
|
# @RETURN: bool - True if updated, False otherwise.
|
||||||
def update_environment(self, env_id: str, updated_env: Environment) -> bool:
|
def update_environment(self, env_id: str, updated_env: Environment) -> bool:
|
||||||
logger.info(f"[update_environment][Entry] Updating {env_id}")
|
with belief_scope("update_environment"):
|
||||||
|
logger.info(f"[update_environment][Entry] Updating {env_id}")
|
||||||
|
|
||||||
# 1. Runtime check of @PRE
|
# 1. Runtime check of @PRE
|
||||||
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
|
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
|
||||||
@@ -210,14 +254,16 @@ class ConfigManager:
|
|||||||
|
|
||||||
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
|
logger.warning(f"[update_environment][Coherence:Failed] Environment {env_id} not found")
|
||||||
return False
|
return False
|
||||||
# [/DEF:update_environment]
|
# [/DEF:update_environment:Function]
|
||||||
|
|
||||||
# [DEF:delete_environment:Function]
|
# [DEF:delete_environment:Function]
|
||||||
# @PURPOSE: Deletes an environment by ID.
|
# @PURPOSE: Deletes an environment by ID.
|
||||||
# @PRE: isinstance(env_id, str) and len(env_id) > 0
|
# @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.
|
# @PARAM: env_id (str) - The ID of the environment to delete.
|
||||||
def delete_environment(self, env_id: str):
|
def delete_environment(self, env_id: str):
|
||||||
logger.info(f"[delete_environment][Entry] Deleting {env_id}")
|
with belief_scope("delete_environment"):
|
||||||
|
logger.info(f"[delete_environment][Entry] Deleting {env_id}")
|
||||||
|
|
||||||
# 1. Runtime check of @PRE
|
# 1. Runtime check of @PRE
|
||||||
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
|
assert env_id and isinstance(env_id, str), "env_id must be a non-empty string"
|
||||||
@@ -231,8 +277,8 @@ class ConfigManager:
|
|||||||
logger.info(f"[delete_environment][Action] Deleted {env_id}")
|
logger.info(f"[delete_environment][Action] Deleted {env_id}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
|
logger.warning(f"[delete_environment][Coherence:Failed] Environment {env_id} not found")
|
||||||
# [/DEF:delete_environment]
|
# [/DEF:delete_environment:Function]
|
||||||
|
|
||||||
# [/DEF:ConfigManager]
|
# [/DEF:ConfigManager:Class]
|
||||||
|
|
||||||
# [/DEF:ConfigManagerModule]
|
# [/DEF:ConfigManagerModule:Module]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# [DEF:ConfigModels:Module]
|
# [DEF:ConfigModels:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: config, models, pydantic
|
# @SEMANTICS: config, models, pydantic
|
||||||
# @PURPOSE: Defines the data models for application configuration using Pydantic.
|
# @PURPOSE: Defines the data models for application configuration using Pydantic.
|
||||||
# @LAYER: Core
|
# @LAYER: Core
|
||||||
@@ -7,6 +8,14 @@
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from ..models.storage import StorageConfig
|
||||||
|
|
||||||
|
# [DEF:Schedule:DataClass]
|
||||||
|
# @PURPOSE: Represents a backup schedule configuration.
|
||||||
|
class Schedule(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
cron_expression: str = "0 0 * * *" # Default: daily at midnight
|
||||||
|
# [/DEF:Schedule:DataClass]
|
||||||
|
|
||||||
# [DEF:Environment:DataClass]
|
# [DEF:Environment:DataClass]
|
||||||
# @PURPOSE: Represents a Superset environment configuration.
|
# @PURPOSE: Represents a Superset environment configuration.
|
||||||
@@ -16,23 +25,27 @@ class Environment(BaseModel):
|
|||||||
url: str
|
url: str
|
||||||
username: str
|
username: str
|
||||||
password: str # Will be masked in UI
|
password: str # Will be masked in UI
|
||||||
|
verify_ssl: bool = True
|
||||||
|
timeout: int = 30
|
||||||
is_default: bool = False
|
is_default: bool = False
|
||||||
# [/DEF:Environment]
|
backup_schedule: Schedule = Field(default_factory=Schedule)
|
||||||
|
# [/DEF:Environment:DataClass]
|
||||||
|
|
||||||
# [DEF:LoggingConfig:DataClass]
|
# [DEF:LoggingConfig:DataClass]
|
||||||
# @PURPOSE: Defines the configuration for the application's logging system.
|
# @PURPOSE: Defines the configuration for the application's logging system.
|
||||||
class LoggingConfig(BaseModel):
|
class LoggingConfig(BaseModel):
|
||||||
level: str = "INFO"
|
level: str = "INFO"
|
||||||
|
task_log_level: str = "INFO" # Minimum level for task-specific logs (DEBUG, INFO, WARNING, ERROR)
|
||||||
file_path: Optional[str] = "logs/app.log"
|
file_path: Optional[str] = "logs/app.log"
|
||||||
max_bytes: int = 10 * 1024 * 1024
|
max_bytes: int = 10 * 1024 * 1024
|
||||||
backup_count: int = 5
|
backup_count: int = 5
|
||||||
enable_belief_state: bool = True
|
enable_belief_state: bool = True
|
||||||
# [/DEF:LoggingConfig]
|
# [/DEF:LoggingConfig:DataClass]
|
||||||
|
|
||||||
# [DEF:GlobalSettings:DataClass]
|
# [DEF:GlobalSettings:DataClass]
|
||||||
# @PURPOSE: Represents global application settings.
|
# @PURPOSE: Represents global application settings.
|
||||||
class GlobalSettings(BaseModel):
|
class GlobalSettings(BaseModel):
|
||||||
backup_path: str
|
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||||
default_environment_id: Optional[str] = None
|
default_environment_id: Optional[str] = None
|
||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||||
|
|
||||||
@@ -40,13 +53,13 @@ class GlobalSettings(BaseModel):
|
|||||||
task_retention_days: int = 30
|
task_retention_days: int = 30
|
||||||
task_retention_limit: int = 100
|
task_retention_limit: int = 100
|
||||||
pagination_limit: int = 10
|
pagination_limit: int = 10
|
||||||
# [/DEF:GlobalSettings]
|
# [/DEF:GlobalSettings:DataClass]
|
||||||
|
|
||||||
# [DEF:AppConfig:DataClass]
|
# [DEF:AppConfig:DataClass]
|
||||||
# @PURPOSE: The root configuration model containing all application settings.
|
# @PURPOSE: The root configuration model containing all application settings.
|
||||||
class AppConfig(BaseModel):
|
class AppConfig(BaseModel):
|
||||||
environments: List[Environment] = []
|
environments: List[Environment] = []
|
||||||
settings: GlobalSettings
|
settings: GlobalSettings
|
||||||
# [/DEF:AppConfig]
|
# [/DEF:AppConfig:DataClass]
|
||||||
|
|
||||||
# [/DEF:ConfigModels]
|
# [/DEF:ConfigModels:Module]
|
||||||
|
|||||||
@@ -5,44 +5,132 @@
|
|||||||
# @LAYER: Core
|
# @LAYER: Core
|
||||||
# @RELATION: DEPENDS_ON -> sqlalchemy
|
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||||
# @RELATION: USES -> backend.src.models.mapping
|
# @RELATION: USES -> backend.src.models.mapping
|
||||||
|
# @RELATION: USES -> backend.src.core.auth.config
|
||||||
#
|
#
|
||||||
# @INVARIANT: A single engine instance is used for the entire application.
|
# @INVARIANT: A single engine instance is used for the entire application.
|
||||||
|
|
||||||
# [SECTION: IMPORTS]
|
# [SECTION: IMPORTS]
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker, Session
|
from sqlalchemy.orm import sessionmaker
|
||||||
from backend.src.models.mapping import Base
|
from ..models.mapping import Base
|
||||||
|
# Import models to ensure they're registered with Base
|
||||||
|
from .logger import belief_scope
|
||||||
|
from .auth.config import auth_config
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
# [/SECTION]
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:BASE_DIR:Variable]
|
||||||
|
# @PURPOSE: Base directory for the backend (where .db files should reside).
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
# [/DEF:BASE_DIR:Variable]
|
||||||
|
|
||||||
# [DEF:DATABASE_URL:Constant]
|
# [DEF:DATABASE_URL:Constant]
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
|
# @PURPOSE: URL for the main mappings database.
|
||||||
# [/DEF:DATABASE_URL]
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR}/mappings.db")
|
||||||
|
# [/DEF:DATABASE_URL:Constant]
|
||||||
|
|
||||||
|
# [DEF:TASKS_DATABASE_URL:Constant]
|
||||||
|
# @PURPOSE: URL for the tasks execution database.
|
||||||
|
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", f"sqlite:///{BASE_DIR}/tasks.db")
|
||||||
|
# [/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)
|
||||||
|
# If it's a relative sqlite path starting with ./backend/, fix it to be absolute or relative to BASE_DIR
|
||||||
|
if AUTH_DATABASE_URL.startswith("sqlite:///./backend/"):
|
||||||
|
AUTH_DATABASE_URL = AUTH_DATABASE_URL.replace("sqlite:///./backend/", f"sqlite:///{BASE_DIR}/")
|
||||||
|
elif AUTH_DATABASE_URL.startswith("sqlite:///./") and not AUTH_DATABASE_URL.startswith("sqlite:///./backend/"):
|
||||||
|
# If it's just ./ but we are in backend, it's fine, but let's make it absolute for robustness
|
||||||
|
AUTH_DATABASE_URL = AUTH_DATABASE_URL.replace("sqlite:///./", f"sqlite:///{BASE_DIR}/")
|
||||||
|
# [/DEF:AUTH_DATABASE_URL:Constant]
|
||||||
|
|
||||||
# [DEF:engine:Variable]
|
# [DEF:engine:Variable]
|
||||||
|
# @PURPOSE: SQLAlchemy engine for mappings database.
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
# [/DEF:engine]
|
# [/DEF:engine:Variable]
|
||||||
|
|
||||||
|
# [DEF:tasks_engine:Variable]
|
||||||
|
# @PURPOSE: SQLAlchemy engine for tasks database.
|
||||||
|
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
# [/DEF:tasks_engine:Variable]
|
||||||
|
|
||||||
|
# [DEF:auth_engine:Variable]
|
||||||
|
# @PURPOSE: SQLAlchemy engine for authentication database.
|
||||||
|
auth_engine = create_engine(AUTH_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
# [/DEF:auth_engine:Variable]
|
||||||
|
|
||||||
# [DEF:SessionLocal:Class]
|
# [DEF:SessionLocal:Class]
|
||||||
|
# @PURPOSE: A session factory for the main mappings database.
|
||||||
|
# @PRE: engine is initialized.
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
# [/DEF:SessionLocal]
|
# [/DEF:SessionLocal:Class]
|
||||||
|
|
||||||
|
# [DEF:TasksSessionLocal:Class]
|
||||||
|
# @PURPOSE: A session factory for the tasks execution database.
|
||||||
|
# @PRE: tasks_engine is initialized.
|
||||||
|
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
|
||||||
|
# [/DEF:TasksSessionLocal:Class]
|
||||||
|
|
||||||
|
# [DEF:AuthSessionLocal:Class]
|
||||||
|
# @PURPOSE: A session factory for the authentication database.
|
||||||
|
# @PRE: auth_engine is initialized.
|
||||||
|
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
|
||||||
|
# [/DEF:AuthSessionLocal:Class]
|
||||||
|
|
||||||
# [DEF:init_db:Function]
|
# [DEF:init_db:Function]
|
||||||
# @PURPOSE: Initializes the database by creating all tables.
|
# @PURPOSE: Initializes the database by creating all tables.
|
||||||
|
# @PRE: engine, tasks_engine and auth_engine are initialized.
|
||||||
|
# @POST: Database tables created in all databases.
|
||||||
|
# @SIDE_EFFECT: Creates physical database files if they don't exist.
|
||||||
def init_db():
|
def init_db():
|
||||||
Base.metadata.create_all(bind=engine)
|
with belief_scope("init_db"):
|
||||||
# [/DEF: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]
|
# [DEF:get_db:Function]
|
||||||
# @PURPOSE: Dependency for getting a database session.
|
# @PURPOSE: Dependency for getting a database session.
|
||||||
|
# @PRE: SessionLocal is initialized.
|
||||||
# @POST: Session is closed after use.
|
# @POST: Session is closed after use.
|
||||||
# @RETURN: Generator[Session, None, None]
|
# @RETURN: Generator[Session, None, None]
|
||||||
def get_db():
|
def get_db():
|
||||||
db = SessionLocal()
|
with belief_scope("get_db"):
|
||||||
try:
|
db = SessionLocal()
|
||||||
yield db
|
try:
|
||||||
finally:
|
yield db
|
||||||
db.close()
|
finally:
|
||||||
# [/DEF:get_db]
|
db.close()
|
||||||
|
# [/DEF:get_db:Function]
|
||||||
|
|
||||||
# [/DEF:backend.src.core.database]
|
# [DEF:get_tasks_db:Function]
|
||||||
|
# @PURPOSE: Dependency for getting a tasks database session.
|
||||||
|
# @PRE: TasksSessionLocal is initialized.
|
||||||
|
# @POST: Session is closed after use.
|
||||||
|
# @RETURN: Generator[Session, None, None]
|
||||||
|
def get_tasks_db():
|
||||||
|
with belief_scope("get_tasks_db"):
|
||||||
|
db = TasksSessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
# [/DEF:get_tasks_db:Function]
|
||||||
|
|
||||||
|
# [DEF:get_auth_db:Function]
|
||||||
|
# @PURPOSE: Dependency for getting an authentication database session.
|
||||||
|
# @PRE: AuthSessionLocal is initialized.
|
||||||
|
# @POST: Session is closed after use.
|
||||||
|
# @RETURN: Generator[Session, None, None]
|
||||||
|
def get_auth_db():
|
||||||
|
with belief_scope("get_auth_db"):
|
||||||
|
db = AuthSessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
# [/DEF:get_auth_db:Function]
|
||||||
|
|
||||||
|
# [/DEF:backend.src.core.database:Module]
|
||||||
|
|||||||
@@ -19,16 +19,26 @@ _belief_state = threading.local()
|
|||||||
# Global flag for belief state logging
|
# Global flag for belief state logging
|
||||||
_enable_belief_state = True
|
_enable_belief_state = True
|
||||||
|
|
||||||
|
# Global task log level filter
|
||||||
|
_task_log_level = "INFO"
|
||||||
|
|
||||||
# [DEF:BeliefFormatter:Class]
|
# [DEF:BeliefFormatter:Class]
|
||||||
# @PURPOSE: Custom logging formatter that adds belief state prefixes to log messages.
|
# @PURPOSE: Custom logging formatter that adds belief state prefixes to log messages.
|
||||||
class BeliefFormatter(logging.Formatter):
|
class BeliefFormatter(logging.Formatter):
|
||||||
|
# [DEF:format:Function]
|
||||||
|
# @PURPOSE: Formats the log record, adding belief state context if available.
|
||||||
|
# @PRE: record is a logging.LogRecord.
|
||||||
|
# @POST: Returns formatted string.
|
||||||
|
# @PARAM: record (logging.LogRecord) - The log record to format.
|
||||||
|
# @RETURN: str - The formatted log message.
|
||||||
|
# @SEMANTICS: logging, formatter, context
|
||||||
def format(self, record):
|
def format(self, record):
|
||||||
msg = super().format(record)
|
|
||||||
anchor_id = getattr(_belief_state, 'anchor_id', None)
|
anchor_id = getattr(_belief_state, 'anchor_id', None)
|
||||||
if anchor_id:
|
if anchor_id:
|
||||||
msg = f"[{anchor_id}][Action] {msg}"
|
record.msg = f"[{anchor_id}][Action] {record.msg}"
|
||||||
return msg
|
return super().format(record)
|
||||||
# [/DEF:BeliefFormatter]
|
# [/DEF:format:Function]
|
||||||
|
# [/DEF:BeliefFormatter:Class]
|
||||||
|
|
||||||
# Re-using LogEntry from task_manager for consistency
|
# Re-using LogEntry from task_manager for consistency
|
||||||
# [DEF:LogEntry:Class]
|
# [DEF:LogEntry:Class]
|
||||||
@@ -40,18 +50,23 @@ class LogEntry(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
context: Optional[Dict[str, Any]] = None
|
context: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
# [/DEF]
|
# [/DEF:LogEntry:Class]
|
||||||
|
|
||||||
# [DEF:BeliefScope:Function]
|
# [DEF:belief_scope:Function]
|
||||||
# @PURPOSE: Context manager for structured Belief State logging.
|
# @PURPOSE: Context manager for structured Belief State logging.
|
||||||
|
# @PARAM: anchor_id (str) - The identifier for the current semantic block.
|
||||||
|
# @PARAM: message (str) - Optional entry message.
|
||||||
|
# @PRE: anchor_id must be provided.
|
||||||
|
# @POST: Thread-local belief state is updated and entry/exit logs are generated.
|
||||||
|
# @SEMANTICS: logging, context, belief_state
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def belief_scope(anchor_id: str, message: str = ""):
|
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:
|
if _enable_belief_state:
|
||||||
entry_msg = f"[{anchor_id}][Entry]"
|
entry_msg = f"[{anchor_id}][Entry]"
|
||||||
if message:
|
if message:
|
||||||
entry_msg += f" {message}"
|
entry_msg += f" {message}"
|
||||||
logger.info(entry_msg)
|
logger.debug(entry_msg)
|
||||||
|
|
||||||
# Set thread-local anchor_id
|
# Set thread-local anchor_id
|
||||||
old_anchor = getattr(_belief_state, 'anchor_id', None)
|
old_anchor = getattr(_belief_state, 'anchor_id', None)
|
||||||
@@ -59,28 +74,30 @@ def belief_scope(anchor_id: str, message: str = ""):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
# Log Coherence OK and Exit
|
# Log Coherence OK and Exit (DEBUG level to reduce noise)
|
||||||
logger.info(f"[{anchor_id}][Coherence:OK]")
|
logger.debug(f"[{anchor_id}][Coherence:OK]")
|
||||||
if _enable_belief_state:
|
if _enable_belief_state:
|
||||||
logger.info(f"[{anchor_id}][Exit]")
|
logger.debug(f"[{anchor_id}][Exit]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log Coherence Failed
|
# Log Coherence Failed (DEBUG level to reduce noise)
|
||||||
logger.info(f"[{anchor_id}][Coherence:Failed] {str(e)}")
|
logger.debug(f"[{anchor_id}][Coherence:Failed] {str(e)}")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# Restore old anchor
|
# Restore old anchor
|
||||||
_belief_state.anchor_id = old_anchor
|
_belief_state.anchor_id = old_anchor
|
||||||
|
|
||||||
# [/DEF:BeliefScope]
|
# [/DEF:belief_scope:Function]
|
||||||
|
|
||||||
# [DEF:ConfigureLogger:Function]
|
# [DEF:configure_logger:Function]
|
||||||
# @PURPOSE: Configures the logger with the provided logging settings.
|
# @PURPOSE: Configures the logger with the provided logging settings.
|
||||||
# @PRE: config is a valid LoggingConfig instance.
|
# @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.
|
# @PARAM: config (LoggingConfig) - The logging configuration.
|
||||||
|
# @SEMANTICS: logging, configuration, initialization
|
||||||
def configure_logger(config):
|
def configure_logger(config):
|
||||||
global _enable_belief_state
|
global _enable_belief_state, _task_log_level
|
||||||
_enable_belief_state = config.enable_belief_state
|
_enable_belief_state = config.enable_belief_state
|
||||||
|
_task_log_level = config.task_log_level.upper()
|
||||||
|
|
||||||
# Set logger level
|
# Set logger level
|
||||||
level = getattr(logging, config.level.upper(), logging.INFO)
|
level = getattr(logging, config.level.upper(), logging.INFO)
|
||||||
@@ -94,7 +111,6 @@ def configure_logger(config):
|
|||||||
|
|
||||||
# Add file handler if file_path is set
|
# Add file handler if file_path is set
|
||||||
if config.file_path:
|
if config.file_path:
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
log_file = Path(config.file_path)
|
log_file = Path(config.file_path)
|
||||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -115,7 +131,37 @@ def configure_logger(config):
|
|||||||
handler.setFormatter(BeliefFormatter(
|
handler.setFormatter(BeliefFormatter(
|
||||||
'[%(asctime)s][%(levelname)s][%(name)s] %(message)s'
|
'[%(asctime)s][%(levelname)s][%(name)s] %(message)s'
|
||||||
))
|
))
|
||||||
# [/DEF:ConfigureLogger]
|
# [/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]
|
# [DEF:WebSocketLogHandler:Class]
|
||||||
# @SEMANTICS: logging, handler, websocket, buffer
|
# @SEMANTICS: logging, handler, websocket, buffer
|
||||||
@@ -125,12 +171,25 @@ class WebSocketLogHandler(logging.Handler):
|
|||||||
A logging handler that stores log records and can be extended to send them
|
A logging handler that stores log records and can be extended to send them
|
||||||
over WebSockets.
|
over WebSockets.
|
||||||
"""
|
"""
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the handler with a fixed-capacity buffer.
|
||||||
|
# @PRE: capacity is an integer.
|
||||||
|
# @POST: Instance initialized with empty deque.
|
||||||
|
# @PARAM: capacity (int) - Maximum number of logs to keep in memory.
|
||||||
|
# @SEMANTICS: logging, initialization, buffer
|
||||||
def __init__(self, capacity: int = 1000):
|
def __init__(self, capacity: int = 1000):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.log_buffer: deque[LogEntry] = deque(maxlen=capacity)
|
self.log_buffer: deque[LogEntry] = deque(maxlen=capacity)
|
||||||
# In a real implementation, you'd have a way to manage active WebSocket connections
|
# In a real implementation, you'd have a way to manage active WebSocket connections
|
||||||
# e.g., self.active_connections: Set[WebSocket] = set()
|
# e.g., self.active_connections: Set[WebSocket] = set()
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
|
||||||
|
# [DEF:emit:Function]
|
||||||
|
# @PURPOSE: Captures a log record, formats it, and stores it in the buffer.
|
||||||
|
# @PRE: record is a logging.LogRecord.
|
||||||
|
# @POST: Log is added to the log_buffer.
|
||||||
|
# @PARAM: record (logging.LogRecord) - The log record to emit.
|
||||||
|
# @SEMANTICS: logging, handler, buffer
|
||||||
def emit(self, record: logging.LogRecord):
|
def emit(self, record: logging.LogRecord):
|
||||||
try:
|
try:
|
||||||
log_entry = LogEntry(
|
log_entry = LogEntry(
|
||||||
@@ -151,19 +210,51 @@ class WebSocketLogHandler(logging.Handler):
|
|||||||
# Example: for ws in self.active_connections: await ws.send_json(log_entry.dict())
|
# Example: for ws in self.active_connections: await ws.send_json(log_entry.dict())
|
||||||
except Exception:
|
except Exception:
|
||||||
self.handleError(record)
|
self.handleError(record)
|
||||||
|
# [/DEF:emit:Function]
|
||||||
|
|
||||||
|
# [DEF:get_recent_logs:Function]
|
||||||
|
# @PURPOSE: Returns a list of recent log entries from the buffer.
|
||||||
|
# @PRE: None.
|
||||||
|
# @POST: Returns list of LogEntry objects.
|
||||||
|
# @RETURN: List[LogEntry] - List of buffered log entries.
|
||||||
|
# @SEMANTICS: logging, buffer, retrieval
|
||||||
def get_recent_logs(self) -> List[LogEntry]:
|
def get_recent_logs(self) -> List[LogEntry]:
|
||||||
"""
|
"""
|
||||||
Returns a list of recent log entries from the buffer.
|
Returns a list of recent log entries from the buffer.
|
||||||
"""
|
"""
|
||||||
return list(self.log_buffer)
|
return list(self.log_buffer)
|
||||||
|
# [/DEF:get_recent_logs:Function]
|
||||||
|
|
||||||
# [/DEF]
|
# [/DEF:WebSocketLogHandler:Class]
|
||||||
|
|
||||||
# [DEF:Logger:Global]
|
# [DEF:Logger:Global]
|
||||||
# @SEMANTICS: logger, global, instance
|
# @SEMANTICS: logger, global, instance
|
||||||
# @PURPOSE: The global logger instance for the application, configured with both a console handler and the custom WebSocket handler.
|
# @PURPOSE: The global logger instance for the application, configured with both a console handler and the custom WebSocket handler.
|
||||||
logger = logging.getLogger("superset_tools_app")
|
logger = logging.getLogger("superset_tools_app")
|
||||||
|
|
||||||
|
# [DEF:believed:Function]
|
||||||
|
# @PURPOSE: A decorator that wraps a function in a belief scope.
|
||||||
|
# @PARAM: anchor_id (str) - The identifier for the semantic block.
|
||||||
|
# @PRE: anchor_id must be a string.
|
||||||
|
# @POST: Returns a decorator function.
|
||||||
|
def believed(anchor_id: str):
|
||||||
|
# [DEF:decorator:Function]
|
||||||
|
# @PURPOSE: Internal decorator for belief scope.
|
||||||
|
# @PRE: func must be a callable.
|
||||||
|
# @POST: Returns the wrapped function.
|
||||||
|
def decorator(func):
|
||||||
|
# [DEF:wrapper:Function]
|
||||||
|
# @PURPOSE: Internal wrapper that enters belief scope.
|
||||||
|
# @PRE: None.
|
||||||
|
# @POST: Executes the function within a belief scope.
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
with belief_scope(anchor_id):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
# [/DEF:wrapper:Function]
|
||||||
|
return wrapper
|
||||||
|
# [/DEF:decorator:Function]
|
||||||
|
return decorator
|
||||||
|
# [/DEF:believed:Function]
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
# Create a formatter
|
# Create a formatter
|
||||||
@@ -184,4 +275,5 @@ logger.addHandler(websocket_log_handler)
|
|||||||
# Example usage:
|
# Example usage:
|
||||||
# logger.info("Application started", extra={"context_key": "context_value"})
|
# logger.info("Application started", extra={"context_key": "context_value"})
|
||||||
# logger.error("An error occurred", exc_info=True)
|
# logger.error("An error occurred", exc_info=True)
|
||||||
# [/DEF]
|
# [/DEF:Logger:Global]
|
||||||
|
# [/DEF:LoggerModule:Module]
|
||||||
@@ -11,24 +11,24 @@
|
|||||||
import zipfile
|
import zipfile
|
||||||
import yaml
|
import yaml
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from .logger import logger, belief_scope
|
from .logger import logger, belief_scope
|
||||||
import yaml
|
|
||||||
# [/SECTION]
|
# [/SECTION]
|
||||||
|
|
||||||
# [DEF:MigrationEngine:Class]
|
# [DEF:MigrationEngine:Class]
|
||||||
# @PURPOSE: Engine for transforming Superset export ZIPs.
|
# @PURPOSE: Engine for transforming Superset export ZIPs.
|
||||||
class MigrationEngine:
|
class MigrationEngine:
|
||||||
|
|
||||||
# [DEF:MigrationEngine.transform_zip:Function]
|
# [DEF:transform_zip:Function]
|
||||||
# @PURPOSE: Extracts ZIP, replaces database UUIDs in YAMLs, and re-packages.
|
# @PURPOSE: Extracts ZIP, replaces database UUIDs in YAMLs, and re-packages.
|
||||||
# @PARAM: zip_path (str) - Path to the source ZIP file.
|
# @PARAM: zip_path (str) - Path to the source ZIP file.
|
||||||
# @PARAM: output_path (str) - Path where the transformed ZIP will be saved.
|
# @PARAM: output_path (str) - Path where the transformed ZIP will be saved.
|
||||||
# @PARAM: db_mapping (Dict[str, str]) - Mapping of source UUID to target UUID.
|
# @PARAM: db_mapping (Dict[str, str]) - Mapping of source UUID to target UUID.
|
||||||
# @PARAM: strip_databases (bool) - Whether to remove the databases directory from the archive.
|
# @PARAM: strip_databases (bool) - Whether to remove the databases directory from the archive.
|
||||||
|
# @PRE: zip_path must point to a valid Superset export archive.
|
||||||
|
# @POST: Transformed archive is saved to output_path.
|
||||||
# @RETURN: bool - True if successful.
|
# @RETURN: bool - True if successful.
|
||||||
def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str], strip_databases: bool = True) -> bool:
|
def transform_zip(self, zip_path: str, output_path: str, db_mapping: Dict[str, str], strip_databases: bool = True) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -73,9 +73,14 @@ class MigrationEngine:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MigrationEngine.transform_zip][Coherence:Failed] Error transforming ZIP: {e}")
|
logger.error(f"[MigrationEngine.transform_zip][Coherence:Failed] Error transforming ZIP: {e}")
|
||||||
return False
|
return False
|
||||||
|
# [/DEF:transform_zip:Function]
|
||||||
|
|
||||||
# [DEF:MigrationEngine._transform_yaml:Function]
|
# [DEF:_transform_yaml:Function]
|
||||||
# @PURPOSE: Replaces database_uuid in a single YAML file.
|
# @PURPOSE: Replaces database_uuid in a single YAML file.
|
||||||
|
# @PARAM: file_path (Path) - Path to the YAML file.
|
||||||
|
# @PARAM: db_mapping (Dict[str, str]) - UUID mapping dictionary.
|
||||||
|
# @PRE: file_path must exist and be readable.
|
||||||
|
# @POST: File is modified in-place if source UUID matches mapping.
|
||||||
def _transform_yaml(self, file_path: Path, db_mapping: Dict[str, str]):
|
def _transform_yaml(self, file_path: Path, db_mapping: Dict[str, str]):
|
||||||
with open(file_path, 'r') as f:
|
with open(file_path, 'r') as f:
|
||||||
data = yaml.safe_load(f)
|
data = yaml.safe_load(f)
|
||||||
@@ -90,8 +95,8 @@ class MigrationEngine:
|
|||||||
data['database_uuid'] = db_mapping[source_uuid]
|
data['database_uuid'] = db_mapping[source_uuid]
|
||||||
with open(file_path, 'w') as f:
|
with open(file_path, 'w') as f:
|
||||||
yaml.dump(data, f)
|
yaml.dump(data, f)
|
||||||
# [/DEF:MigrationEngine._transform_yaml]
|
# [/DEF:_transform_yaml:Function]
|
||||||
|
|
||||||
# [/DEF:MigrationEngine]
|
# [/DEF:MigrationEngine:Class]
|
||||||
|
|
||||||
# [/DEF:backend.src.core.migration_engine]
|
# [/DEF:backend.src.core.migration_engine:Module]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, Optional
|
||||||
|
from .logger import belief_scope
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -17,44 +18,114 @@ class PluginBase(ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
# [DEF:id:Function]
|
||||||
|
# @PURPOSE: Returns the unique identifier for the plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string ID.
|
||||||
|
# @RETURN: str - Plugin ID.
|
||||||
def id(self) -> str:
|
def id(self) -> str:
|
||||||
"""A unique identifier for the plugin."""
|
"""A unique identifier for the plugin."""
|
||||||
pass
|
with belief_scope("id"):
|
||||||
|
pass
|
||||||
|
# [/DEF:id:Function]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
# [DEF:name:Function]
|
||||||
|
# @PURPOSE: Returns the human-readable name of the plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string name.
|
||||||
|
# @RETURN: str - Plugin name.
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
"""A human-readable name for the plugin."""
|
"""A human-readable name for the plugin."""
|
||||||
pass
|
with belief_scope("name"):
|
||||||
|
pass
|
||||||
|
# [/DEF:name:Function]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
# [DEF:description:Function]
|
||||||
|
# @PURPOSE: Returns a brief description of the plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string description.
|
||||||
|
# @RETURN: str - Plugin description.
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
"""A brief description of what the plugin does."""
|
"""A brief description of what the plugin does."""
|
||||||
pass
|
with belief_scope("description"):
|
||||||
|
pass
|
||||||
|
# [/DEF:description:Function]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
# [DEF:version:Function]
|
||||||
|
# @PURPOSE: Returns the version of the plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string version.
|
||||||
|
# @RETURN: str - Plugin version.
|
||||||
def version(self) -> str:
|
def version(self) -> str:
|
||||||
"""The version of the plugin."""
|
"""The version of the plugin."""
|
||||||
pass
|
with belief_scope("version"):
|
||||||
|
pass
|
||||||
|
# [/DEF:version:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:required_permission:Function]
|
||||||
|
# @PURPOSE: Returns the required permission string to execute this plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string permission.
|
||||||
|
# @RETURN: str - Required permission (e.g., "plugin:backup:execute").
|
||||||
|
def required_permission(self) -> str:
|
||||||
|
"""The permission string required to execute this plugin."""
|
||||||
|
with belief_scope("required_permission"):
|
||||||
|
return f"plugin:{self.id}:execute"
|
||||||
|
# [/DEF:required_permission:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:ui_route:Function]
|
||||||
|
# @PURPOSE: Returns the frontend route for the plugin's UI, if applicable.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string route or None.
|
||||||
|
# @RETURN: Optional[str] - Frontend route.
|
||||||
|
def ui_route(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
The frontend route for the plugin's UI.
|
||||||
|
Returns None if the plugin does not have a dedicated UI page.
|
||||||
|
"""
|
||||||
|
with belief_scope("ui_route"):
|
||||||
|
return None
|
||||||
|
# [/DEF:ui_route:Function]
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
# [DEF:get_schema:Function]
|
||||||
|
# @PURPOSE: Returns the JSON schema for the plugin's input parameters.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns dict schema.
|
||||||
|
# @RETURN: Dict[str, Any] - JSON schema.
|
||||||
def get_schema(self) -> Dict[str, Any]:
|
def get_schema(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Returns the JSON schema for the plugin's input parameters.
|
Returns the JSON schema for the plugin's input parameters.
|
||||||
This schema will be used to generate the frontend form.
|
This schema will be used to generate the frontend form.
|
||||||
"""
|
"""
|
||||||
pass
|
with belief_scope("get_schema"):
|
||||||
|
pass
|
||||||
|
# [/DEF:get_schema:Function]
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
# [DEF:execute:Function]
|
||||||
|
# @PURPOSE: Executes the plugin's core logic.
|
||||||
|
# @PARAM: params (Dict[str, Any]) - Validated input parameters.
|
||||||
|
# @PRE: params must be a dictionary.
|
||||||
|
# @POST: Plugin execution is completed.
|
||||||
async def execute(self, params: Dict[str, Any]):
|
async def execute(self, params: Dict[str, Any]):
|
||||||
|
with belief_scope("execute"):
|
||||||
|
pass
|
||||||
"""
|
"""
|
||||||
Executes the plugin's logic.
|
Executes the plugin's logic.
|
||||||
The `params` argument will be validated against the schema returned by `get_schema()`.
|
The `params` argument will be validated against the schema returned by `get_schema()`.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
# [/DEF]
|
# [/DEF:execute:Function]
|
||||||
|
# [/DEF:PluginBase:Class]
|
||||||
|
|
||||||
# [DEF:PluginConfig:Class]
|
# [DEF:PluginConfig:Class]
|
||||||
# @SEMANTICS: plugin, config, schema, pydantic
|
# @SEMANTICS: plugin, config, schema, pydantic
|
||||||
@@ -67,5 +138,6 @@ class PluginConfig(BaseModel):
|
|||||||
name: str = Field(..., description="Human-readable name for the plugin")
|
name: str = Field(..., description="Human-readable name for the plugin")
|
||||||
description: str = Field(..., description="Brief description of what the plugin does")
|
description: str = Field(..., description="Brief description of what the plugin does")
|
||||||
version: str = Field(..., description="Version of the plugin")
|
version: str = Field(..., description="Version of the plugin")
|
||||||
|
ui_route: Optional[str] = Field(None, description="Frontend route for the plugin UI")
|
||||||
input_schema: Dict[str, Any] = Field(..., description="JSON schema for input parameters", alias="schema")
|
input_schema: Dict[str, Any] = Field(..., description="JSON schema for input parameters", alias="schema")
|
||||||
# [/DEF]
|
# [/DEF:PluginConfig:Class]
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
import sys # Added this line
|
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 .plugin_base import PluginBase, PluginConfig
|
||||||
from jsonschema import validate
|
from .logger import belief_scope
|
||||||
|
|
||||||
# [DEF:PluginLoader:Class]
|
# [DEF:PluginLoader:Class]
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: plugin, loader, dynamic, import
|
# @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.
|
# @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
|
# @LAYER: Core
|
||||||
@@ -16,16 +17,28 @@ class PluginLoader:
|
|||||||
that inherit from PluginBase.
|
that inherit from PluginBase.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the PluginLoader with a directory to scan.
|
||||||
|
# @PRE: plugin_dir is a valid directory path.
|
||||||
|
# @POST: Plugins are loaded and registered.
|
||||||
|
# @PARAM: plugin_dir (str) - The directory containing plugin modules.
|
||||||
def __init__(self, plugin_dir: str):
|
def __init__(self, plugin_dir: str):
|
||||||
self.plugin_dir = plugin_dir
|
with belief_scope("__init__"):
|
||||||
self._plugins: Dict[str, PluginBase] = {}
|
self.plugin_dir = plugin_dir
|
||||||
self._plugin_configs: Dict[str, PluginConfig] = {}
|
self._plugins: Dict[str, PluginBase] = {}
|
||||||
self._load_plugins()
|
self._plugin_configs: Dict[str, PluginConfig] = {}
|
||||||
|
self._load_plugins()
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
|
||||||
|
# [DEF:_load_plugins:Function]
|
||||||
|
# @PURPOSE: Scans the plugin directory and loads all valid plugins.
|
||||||
|
# @PRE: plugin_dir exists or can be created.
|
||||||
|
# @POST: _load_module is called for each .py file.
|
||||||
def _load_plugins(self):
|
def _load_plugins(self):
|
||||||
"""
|
with belief_scope("_load_plugins"):
|
||||||
Scans the plugin directory, imports modules, and registers valid plugins.
|
"""
|
||||||
"""
|
Scans the plugin directory, imports modules, and registers valid plugins.
|
||||||
|
"""
|
||||||
if not os.path.exists(self.plugin_dir):
|
if not os.path.exists(self.plugin_dir):
|
||||||
os.makedirs(self.plugin_dir)
|
os.makedirs(self.plugin_dir)
|
||||||
|
|
||||||
@@ -37,15 +50,32 @@ class PluginLoader:
|
|||||||
sys.path.insert(0, plugin_parent_dir)
|
sys.path.insert(0, plugin_parent_dir)
|
||||||
|
|
||||||
for filename in os.listdir(self.plugin_dir):
|
for filename in os.listdir(self.plugin_dir):
|
||||||
|
file_path = os.path.join(self.plugin_dir, filename)
|
||||||
|
|
||||||
|
# Handle directory-based plugins (packages)
|
||||||
|
if os.path.isdir(file_path):
|
||||||
|
init_file = os.path.join(file_path, "__init__.py")
|
||||||
|
if os.path.exists(init_file):
|
||||||
|
self._load_module(filename, init_file)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle single-file plugins
|
||||||
if filename.endswith(".py") and filename != "__init__.py":
|
if filename.endswith(".py") and filename != "__init__.py":
|
||||||
module_name = filename[:-3]
|
module_name = filename[:-3]
|
||||||
file_path = os.path.join(self.plugin_dir, filename)
|
|
||||||
self._load_module(module_name, file_path)
|
self._load_module(module_name, file_path)
|
||||||
|
# [/DEF:_load_plugins:Function]
|
||||||
|
|
||||||
|
# [DEF:_load_module:Function]
|
||||||
|
# @PURPOSE: Loads a single Python module and discovers PluginBase implementations.
|
||||||
|
# @PRE: module_name and file_path are valid.
|
||||||
|
# @POST: Plugin classes are instantiated and registered.
|
||||||
|
# @PARAM: module_name (str) - The name of the module.
|
||||||
|
# @PARAM: file_path (str) - The path to the module file.
|
||||||
def _load_module(self, module_name: str, file_path: str):
|
def _load_module(self, module_name: str, file_path: str):
|
||||||
"""
|
with belief_scope("_load_module"):
|
||||||
Loads a single Python module and extracts PluginBase subclasses.
|
"""
|
||||||
"""
|
Loads a single Python module and extracts PluginBase subclasses.
|
||||||
|
"""
|
||||||
# Try to determine the correct package prefix based on how the app is running
|
# Try to determine the correct package prefix based on how the app is running
|
||||||
# For standalone execution, we need to handle the import differently
|
# For standalone execution, we need to handle the import differently
|
||||||
if __name__ == "__main__" or "test" in __name__:
|
if __name__ == "__main__" or "test" in __name__:
|
||||||
@@ -83,11 +113,18 @@ class PluginLoader:
|
|||||||
self._register_plugin(plugin_instance)
|
self._register_plugin(plugin_instance)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error instantiating plugin {attribute_name} in {module_name}: {e}") # Replace with proper logging
|
print(f"Error instantiating plugin {attribute_name} in {module_name}: {e}") # Replace with proper logging
|
||||||
|
# [/DEF:_load_module:Function]
|
||||||
|
|
||||||
|
# [DEF:_register_plugin:Function]
|
||||||
|
# @PURPOSE: Registers a PluginBase instance and its configuration.
|
||||||
|
# @PRE: plugin_instance is a valid implementation of PluginBase.
|
||||||
|
# @POST: Plugin is added to _plugins and _plugin_configs.
|
||||||
|
# @PARAM: plugin_instance (PluginBase) - The plugin instance to register.
|
||||||
def _register_plugin(self, plugin_instance: PluginBase):
|
def _register_plugin(self, plugin_instance: PluginBase):
|
||||||
"""
|
with belief_scope("_register_plugin"):
|
||||||
Registers a valid plugin instance.
|
"""
|
||||||
"""
|
Registers a valid plugin instance.
|
||||||
|
"""
|
||||||
plugin_id = plugin_instance.id
|
plugin_id = plugin_instance.id
|
||||||
if plugin_id in self._plugins:
|
if plugin_id in self._plugins:
|
||||||
print(f"Warning: Duplicate plugin ID '{plugin_id}' found. Skipping.") # Replace with proper logging
|
print(f"Warning: Duplicate plugin ID '{plugin_id}' found. Skipping.") # Replace with proper logging
|
||||||
@@ -104,6 +141,7 @@ class PluginLoader:
|
|||||||
name=plugin_instance.name,
|
name=plugin_instance.name,
|
||||||
description=plugin_instance.description,
|
description=plugin_instance.description,
|
||||||
version=plugin_instance.version,
|
version=plugin_instance.version,
|
||||||
|
ui_route=plugin_instance.ui_route,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
)
|
)
|
||||||
# The following line is commented out because it requires a schema to be passed to validate against.
|
# The following line is commented out because it requires a schema to be passed to validate against.
|
||||||
@@ -116,22 +154,48 @@ class PluginLoader:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
from ..core.logger import logger
|
from ..core.logger import logger
|
||||||
logger.error(f"Error validating plugin '{plugin_instance.name}' (ID: {plugin_id}): {e}")
|
logger.error(f"Error validating plugin '{plugin_instance.name}' (ID: {plugin_id}): {e}")
|
||||||
|
# [/DEF:_register_plugin:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:get_plugin:Function]
|
||||||
|
# @PURPOSE: Retrieves a loaded plugin instance by its ID.
|
||||||
|
# @PRE: plugin_id is a string.
|
||||||
|
# @POST: Returns plugin instance or None.
|
||||||
|
# @PARAM: plugin_id (str) - The unique identifier of the plugin.
|
||||||
|
# @RETURN: Optional[PluginBase] - The plugin instance if found, otherwise None.
|
||||||
def get_plugin(self, plugin_id: str) -> Optional[PluginBase]:
|
def get_plugin(self, plugin_id: str) -> Optional[PluginBase]:
|
||||||
"""
|
with belief_scope("get_plugin"):
|
||||||
Returns a loaded plugin instance by its ID.
|
"""
|
||||||
"""
|
Returns a loaded plugin instance by its ID.
|
||||||
|
"""
|
||||||
return self._plugins.get(plugin_id)
|
return self._plugins.get(plugin_id)
|
||||||
|
# [/DEF:get_plugin:Function]
|
||||||
|
|
||||||
|
# [DEF:get_all_plugin_configs:Function]
|
||||||
|
# @PURPOSE: Returns a list of all registered plugin configurations.
|
||||||
|
# @PRE: None.
|
||||||
|
# @POST: Returns list of all PluginConfig objects.
|
||||||
|
# @RETURN: List[PluginConfig] - A list of plugin configurations.
|
||||||
def get_all_plugin_configs(self) -> List[PluginConfig]:
|
def get_all_plugin_configs(self) -> List[PluginConfig]:
|
||||||
"""
|
with belief_scope("get_all_plugin_configs"):
|
||||||
Returns a list of all loaded plugin configurations.
|
"""
|
||||||
"""
|
Returns a list of all loaded plugin configurations.
|
||||||
|
"""
|
||||||
return list(self._plugin_configs.values())
|
return list(self._plugin_configs.values())
|
||||||
|
# [/DEF:get_all_plugin_configs:Function]
|
||||||
|
|
||||||
|
# [DEF:has_plugin:Function]
|
||||||
|
# @PURPOSE: Checks if a plugin with the given ID is registered.
|
||||||
|
# @PRE: plugin_id is a string.
|
||||||
|
# @POST: Returns True if plugin exists.
|
||||||
|
# @PARAM: plugin_id (str) - The unique identifier of the plugin.
|
||||||
|
# @RETURN: bool - True if the plugin is registered, False otherwise.
|
||||||
def has_plugin(self, plugin_id: str) -> bool:
|
def has_plugin(self, plugin_id: str) -> bool:
|
||||||
"""
|
with belief_scope("has_plugin"):
|
||||||
Checks if a plugin with the given ID is loaded.
|
"""
|
||||||
"""
|
Checks if a plugin with the given ID is loaded.
|
||||||
return plugin_id in self._plugins
|
"""
|
||||||
|
return plugin_id in self._plugins
|
||||||
|
# [/DEF:has_plugin:Function]
|
||||||
|
|
||||||
|
# [/DEF:PluginLoader:Class]
|
||||||
120
backend/src/core/scheduler.py
Normal file
120
backend/src/core/scheduler.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# [DEF:SchedulerModule:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: scheduler, apscheduler, cron, backup
|
||||||
|
# @PURPOSE: Manages scheduled tasks using APScheduler.
|
||||||
|
# @LAYER: Core
|
||||||
|
# @RELATION: Uses TaskManager to run scheduled backups.
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from .logger import logger, belief_scope
|
||||||
|
from .config_manager import ConfigManager
|
||||||
|
import asyncio
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:SchedulerService:Class]
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: scheduler, service, apscheduler
|
||||||
|
# @PURPOSE: Provides a service to manage scheduled backup tasks.
|
||||||
|
class SchedulerService:
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the scheduler service with task and config managers.
|
||||||
|
# @PRE: task_manager and config_manager must be provided.
|
||||||
|
# @POST: Scheduler instance is created but not started.
|
||||||
|
def __init__(self, task_manager, config_manager: ConfigManager):
|
||||||
|
with belief_scope("SchedulerService.__init__"):
|
||||||
|
self.task_manager = task_manager
|
||||||
|
self.config_manager = config_manager
|
||||||
|
self.scheduler = BackgroundScheduler()
|
||||||
|
self.loop = asyncio.get_event_loop()
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
|
||||||
|
# [DEF:start:Function]
|
||||||
|
# @PURPOSE: Starts the background scheduler and loads initial schedules.
|
||||||
|
# @PRE: Scheduler should be initialized.
|
||||||
|
# @POST: Scheduler is running and schedules are loaded.
|
||||||
|
def start(self):
|
||||||
|
with belief_scope("SchedulerService.start"):
|
||||||
|
if not self.scheduler.running:
|
||||||
|
self.scheduler.start()
|
||||||
|
logger.info("Scheduler started.")
|
||||||
|
self.load_schedules()
|
||||||
|
# [/DEF:start:Function]
|
||||||
|
|
||||||
|
# [DEF:stop:Function]
|
||||||
|
# @PURPOSE: Stops the background scheduler.
|
||||||
|
# @PRE: Scheduler should be running.
|
||||||
|
# @POST: Scheduler is shut down.
|
||||||
|
def stop(self):
|
||||||
|
with belief_scope("SchedulerService.stop"):
|
||||||
|
if self.scheduler.running:
|
||||||
|
self.scheduler.shutdown()
|
||||||
|
logger.info("Scheduler stopped.")
|
||||||
|
# [/DEF:stop:Function]
|
||||||
|
|
||||||
|
# [DEF:load_schedules:Function]
|
||||||
|
# @PURPOSE: Loads backup schedules from configuration and registers them.
|
||||||
|
# @PRE: config_manager must have valid configuration.
|
||||||
|
# @POST: All enabled backup jobs are added to the scheduler.
|
||||||
|
def load_schedules(self):
|
||||||
|
with belief_scope("SchedulerService.load_schedules"):
|
||||||
|
# Clear existing jobs
|
||||||
|
self.scheduler.remove_all_jobs()
|
||||||
|
|
||||||
|
config = self.config_manager.get_config()
|
||||||
|
for env in config.environments:
|
||||||
|
if env.backup_schedule and env.backup_schedule.enabled:
|
||||||
|
self.add_backup_job(env.id, env.backup_schedule.cron_expression)
|
||||||
|
# [/DEF:load_schedules:Function]
|
||||||
|
|
||||||
|
# [DEF:add_backup_job:Function]
|
||||||
|
# @PURPOSE: Adds a scheduled backup job for an environment.
|
||||||
|
# @PRE: env_id and cron_expression must be valid strings.
|
||||||
|
# @POST: A new job is added to the scheduler or replaced if it already exists.
|
||||||
|
# @PARAM: env_id (str) - The ID of the environment.
|
||||||
|
# @PARAM: cron_expression (str) - The cron expression for the schedule.
|
||||||
|
def add_backup_job(self, env_id: str, cron_expression: str):
|
||||||
|
with belief_scope("SchedulerService.add_backup_job", f"env_id={env_id}, cron={cron_expression}"):
|
||||||
|
job_id = f"backup_{env_id}"
|
||||||
|
try:
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._trigger_backup,
|
||||||
|
CronTrigger.from_crontab(cron_expression),
|
||||||
|
id=job_id,
|
||||||
|
args=[env_id],
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
logger.info(f"Scheduled backup job added for environment {env_id}: {cron_expression}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to add backup job for environment {env_id}: {e}")
|
||||||
|
# [/DEF:add_backup_job:Function]
|
||||||
|
|
||||||
|
# [DEF:_trigger_backup:Function]
|
||||||
|
# @PURPOSE: Triggered by the scheduler to start a backup task.
|
||||||
|
# @PRE: env_id must be a valid environment ID.
|
||||||
|
# @POST: A new backup task is created in the task manager if not already running.
|
||||||
|
# @PARAM: env_id (str) - The ID of the environment.
|
||||||
|
def _trigger_backup(self, env_id: str):
|
||||||
|
with belief_scope("SchedulerService._trigger_backup", f"env_id={env_id}"):
|
||||||
|
logger.info(f"Triggering scheduled backup for environment {env_id}")
|
||||||
|
|
||||||
|
# Check if a backup is already running for this environment
|
||||||
|
active_tasks = self.task_manager.get_tasks(limit=100)
|
||||||
|
for task in active_tasks:
|
||||||
|
if (task.plugin_id == "superset-backup" and
|
||||||
|
task.status in ["PENDING", "RUNNING"] and
|
||||||
|
task.params.get("environment_id") == env_id):
|
||||||
|
logger.warning(f"Backup already running for environment {env_id}. Skipping scheduled run.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run the backup task
|
||||||
|
# We need to run this in the event loop since create_task is async
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self.task_manager.create_task("superset-backup", {"environment_id": env_id}),
|
||||||
|
self.loop
|
||||||
|
)
|
||||||
|
# [/DEF:_trigger_backup:Function]
|
||||||
|
|
||||||
|
# [/DEF:SchedulerService:Class]
|
||||||
|
# [/DEF:SchedulerModule:Module]
|
||||||
@@ -1,83 +1,474 @@
|
|||||||
# [DEF:backend.src.core.superset_client:Module]
|
# [DEF:backend.src.core.superset_client:Module]
|
||||||
#
|
#
|
||||||
# @SEMANTICS: superset, api, client, database, metadata
|
# @SEMANTICS: superset, api, client, rest, http, dashboard, dataset, import, export
|
||||||
# @PURPOSE: Extends the base SupersetClient with database-specific metadata fetching.
|
# @PURPOSE: Предоставляет высокоуровневый клиент для взаимодействия с Superset REST API, инкапсулируя логику запросов, обработку ошибок и пагинацию.
|
||||||
# @LAYER: Core
|
# @LAYER: Core
|
||||||
# @RELATION: INHERITS_FROM -> superset_tool.client.SupersetClient
|
# @RELATION: USES -> backend.src.core.utils.network.APIClient
|
||||||
|
# @RELATION: USES -> backend.src.core.config_models.Environment
|
||||||
#
|
#
|
||||||
# @INVARIANT: All database metadata requests must include UUID and name.
|
# @INVARIANT: All network operations must use the internal APIClient instance.
|
||||||
|
# @PUBLIC_API: SupersetClient
|
||||||
|
|
||||||
# [SECTION: IMPORTS]
|
# [SECTION: IMPORTS]
|
||||||
from typing import List, Dict, Optional, Tuple
|
import json
|
||||||
from superset_tool.client import SupersetClient as BaseSupersetClient
|
import zipfile
|
||||||
from superset_tool.models import SupersetConfig
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union, cast
|
||||||
|
from requests import Response
|
||||||
|
from .logger import logger as app_logger, belief_scope
|
||||||
|
from .utils.network import APIClient, SupersetAPIError
|
||||||
|
from .utils.fileio import get_filename_from_headers
|
||||||
|
from .config_models import Environment
|
||||||
# [/SECTION]
|
# [/SECTION]
|
||||||
|
|
||||||
# [DEF:SupersetClient:Class]
|
# [DEF:SupersetClient:Class]
|
||||||
# @PURPOSE: Extended SupersetClient for migration-specific operations.
|
# @PURPOSE: Класс-обёртка над Superset REST API, предоставляющий методы для работы с дашбордами и датасетами.
|
||||||
class SupersetClient(BaseSupersetClient):
|
class SupersetClient:
|
||||||
|
# [DEF:__init__:Function]
|
||||||
# [DEF:SupersetClient.get_databases_summary:Function]
|
# @PURPOSE: Инициализирует клиент, проверяет конфигурацию и создает сетевой клиент.
|
||||||
# @PURPOSE: Fetch a summary of databases including uuid, name, and engine.
|
# @PRE: `env` должен быть валидным объектом Environment.
|
||||||
# @POST: Returns a list of database dictionaries with 'engine' field.
|
# @POST: Атрибуты `env` и `network` созданы и готовы к работе.
|
||||||
# @RETURN: List[Dict] - Summary of databases.
|
# @PARAM: env (Environment) - Конфигурация окружения.
|
||||||
def get_databases_summary(self) -> List[Dict]:
|
def __init__(self, env: Environment):
|
||||||
"""
|
with belief_scope("__init__"):
|
||||||
Fetch a summary of databases including uuid, name, and engine.
|
app_logger.info("[SupersetClient.__init__][Enter] Initializing SupersetClient for env %s.", env.name)
|
||||||
"""
|
self.env = env
|
||||||
query = {
|
# Construct auth payload expected by Superset API
|
||||||
"columns": ["uuid", "database_name", "backend"]
|
auth_payload = {
|
||||||
|
"username": env.username,
|
||||||
|
"password": env.password,
|
||||||
|
"provider": "db",
|
||||||
|
"refresh": "true"
|
||||||
}
|
}
|
||||||
_, databases = self.get_databases(query=query)
|
self.network = APIClient(
|
||||||
|
config={
|
||||||
# Map 'backend' to 'engine' for consistency with contracts
|
"base_url": env.url,
|
||||||
for db in databases:
|
"auth": auth_payload
|
||||||
db['engine'] = db.pop('backend', None)
|
},
|
||||||
|
verify_ssl=env.verify_ssl,
|
||||||
|
timeout=env.timeout
|
||||||
|
)
|
||||||
|
self.delete_before_reimport: bool = False
|
||||||
|
app_logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.")
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
|
||||||
|
# [DEF:authenticate:Function]
|
||||||
|
# @PURPOSE: Authenticates the client using the configured credentials.
|
||||||
|
# @PRE: self.network must be initialized with valid auth configuration.
|
||||||
|
# @POST: Client is authenticated and tokens are stored.
|
||||||
|
# @RETURN: Dict[str, str] - Authentication tokens.
|
||||||
|
def authenticate(self) -> Dict[str, str]:
|
||||||
|
with belief_scope("SupersetClient.authenticate"):
|
||||||
|
return self.network.authenticate()
|
||||||
|
# [/DEF:authenticate:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:headers:Function]
|
||||||
|
# @PURPOSE: Возвращает базовые HTTP-заголовки, используемые сетевым клиентом.
|
||||||
|
# @PRE: APIClient is initialized and authenticated.
|
||||||
|
# @POST: Returns a dictionary of HTTP headers.
|
||||||
|
def headers(self) -> dict:
|
||||||
|
with belief_scope("headers"):
|
||||||
|
return self.network.headers
|
||||||
|
# [/DEF:headers:Function]
|
||||||
|
|
||||||
|
# [SECTION: DASHBOARD OPERATIONS]
|
||||||
|
|
||||||
|
# [DEF:get_dashboards:Function]
|
||||||
|
# @PURPOSE: Получает полный список дашбордов, автоматически обрабатывая пагинацию.
|
||||||
|
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса для API.
|
||||||
|
# @PRE: Client is authenticated.
|
||||||
|
# @POST: Returns a tuple with total count and list of dashboards.
|
||||||
|
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список дашбордов).
|
||||||
|
def get_dashboards(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||||
|
with belief_scope("get_dashboards"):
|
||||||
|
app_logger.info("[get_dashboards][Enter] Fetching dashboards.")
|
||||||
|
validated_query = self._validate_query_params(query or {})
|
||||||
|
if 'columns' not in validated_query:
|
||||||
|
validated_query['columns'] = ["slug", "id", "changed_on_utc", "dashboard_title", "published"]
|
||||||
|
|
||||||
return databases
|
total_count = self._fetch_total_object_count(endpoint="/dashboard/")
|
||||||
# [/DEF:SupersetClient.get_databases_summary]
|
paginated_data = self._fetch_all_pages(
|
||||||
|
endpoint="/dashboard/",
|
||||||
|
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
|
||||||
|
)
|
||||||
|
app_logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
|
||||||
|
return total_count, paginated_data
|
||||||
|
# [/DEF:get_dashboards:Function]
|
||||||
|
|
||||||
# [DEF:SupersetClient.get_database_by_uuid:Function]
|
# [DEF:get_dashboards_summary:Function]
|
||||||
# @PURPOSE: Find a database by its UUID.
|
|
||||||
# @PARAM: db_uuid (str) - The UUID of the database.
|
|
||||||
# @RETURN: Optional[Dict] - Database info if found, else None.
|
|
||||||
def get_database_by_uuid(self, db_uuid: str) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Find a database by its UUID.
|
|
||||||
"""
|
|
||||||
query = {
|
|
||||||
"filters": [{"col": "uuid", "op": "eq", "value": db_uuid}]
|
|
||||||
}
|
|
||||||
_, databases = self.get_databases(query=query)
|
|
||||||
return databases[0] if databases else None
|
|
||||||
# [/DEF:SupersetClient.get_database_by_uuid]
|
|
||||||
|
|
||||||
# [DEF:SupersetClient.get_dashboards_summary:Function]
|
|
||||||
# @PURPOSE: Fetches dashboard metadata optimized for the grid.
|
# @PURPOSE: Fetches dashboard metadata optimized for the grid.
|
||||||
# @POST: Returns a list of dashboard dictionaries.
|
# @PRE: Client is authenticated.
|
||||||
# @RETURN: List[Dict]
|
# @POST: Returns a list of dashboard metadata summaries.
|
||||||
|
# @RETURN: List[Dict]
|
||||||
def get_dashboards_summary(self) -> List[Dict]:
|
def get_dashboards_summary(self) -> List[Dict]:
|
||||||
"""
|
with belief_scope("SupersetClient.get_dashboards_summary"):
|
||||||
Fetches dashboard metadata optimized for the grid.
|
query = {
|
||||||
Returns a list of dictionaries mapped to DashboardMetadata fields.
|
"columns": ["id", "dashboard_title", "changed_on_utc", "published"]
|
||||||
"""
|
}
|
||||||
query = {
|
_, dashboards = self.get_dashboards(query=query)
|
||||||
"columns": ["id", "dashboard_title", "changed_on_utc", "published"]
|
|
||||||
}
|
|
||||||
_, dashboards = self.get_dashboards(query=query)
|
|
||||||
|
|
||||||
# Map fields to DashboardMetadata schema
|
# Map fields to DashboardMetadata schema
|
||||||
result = []
|
result = []
|
||||||
for dash in dashboards:
|
for dash in dashboards:
|
||||||
result.append({
|
result.append({
|
||||||
"id": dash.get("id"),
|
"id": dash.get("id"),
|
||||||
"title": dash.get("dashboard_title"),
|
"title": dash.get("dashboard_title"),
|
||||||
"last_modified": dash.get("changed_on_utc"),
|
"last_modified": dash.get("changed_on_utc"),
|
||||||
"status": "published" if dash.get("published") else "draft"
|
"status": "published" if dash.get("published") else "draft"
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
# [/DEF:SupersetClient.get_dashboards_summary]
|
# [/DEF:get_dashboards_summary:Function]
|
||||||
|
|
||||||
# [/DEF:SupersetClient]
|
# [DEF:export_dashboard:Function]
|
||||||
|
# @PURPOSE: Экспортирует дашборд в виде ZIP-архива.
|
||||||
|
# @PARAM: dashboard_id (int) - ID дашборда для экспорта.
|
||||||
|
# @PRE: dashboard_id must exist in Superset.
|
||||||
|
# @POST: Returns ZIP content and filename.
|
||||||
|
# @RETURN: Tuple[bytes, str] - Бинарное содержимое ZIP-архива и имя файла.
|
||||||
|
def export_dashboard(self, dashboard_id: int) -> Tuple[bytes, str]:
|
||||||
|
with belief_scope("export_dashboard"):
|
||||||
|
app_logger.info("[export_dashboard][Enter] Exporting dashboard %s.", dashboard_id)
|
||||||
|
response = self.network.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/dashboard/export/",
|
||||||
|
params={"q": json.dumps([dashboard_id])},
|
||||||
|
stream=True,
|
||||||
|
raw_response=True,
|
||||||
|
)
|
||||||
|
response = cast(Response, response)
|
||||||
|
self._validate_export_response(response, dashboard_id)
|
||||||
|
filename = self._resolve_export_filename(response, dashboard_id)
|
||||||
|
app_logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename)
|
||||||
|
return response.content, filename
|
||||||
|
# [/DEF:export_dashboard:Function]
|
||||||
|
|
||||||
# [/DEF:backend.src.core.superset_client]
|
# [DEF:import_dashboard:Function]
|
||||||
|
# @PURPOSE: Импортирует дашборд из ZIP-файла.
|
||||||
|
# @PARAM: file_name (Union[str, Path]) - Путь к ZIP-архиву.
|
||||||
|
# @PARAM: dash_id (Optional[int]) - ID дашборда для удаления при сбое.
|
||||||
|
# @PARAM: dash_slug (Optional[str]) - Slug дашборда для поиска ID.
|
||||||
|
# @PRE: file_name must be a valid ZIP dashboard export.
|
||||||
|
# @POST: Dashboard is imported or re-imported after deletion.
|
||||||
|
# @RETURN: Dict - Ответ API в случае успеха.
|
||||||
|
def import_dashboard(self, file_name: Union[str, Path], dash_id: Optional[int] = None, dash_slug: Optional[str] = None) -> Dict:
|
||||||
|
with belief_scope("import_dashboard"):
|
||||||
|
file_path = str(file_name)
|
||||||
|
self._validate_import_file(file_path)
|
||||||
|
try:
|
||||||
|
return self._do_import(file_path)
|
||||||
|
except Exception as exc:
|
||||||
|
app_logger.error("[import_dashboard][Failure] First import attempt failed: %s", exc, exc_info=True)
|
||||||
|
if not self.delete_before_reimport:
|
||||||
|
raise
|
||||||
|
|
||||||
|
target_id = self._resolve_target_id_for_delete(dash_id, dash_slug)
|
||||||
|
if target_id is None:
|
||||||
|
app_logger.error("[import_dashboard][Failure] No ID available for delete-retry.")
|
||||||
|
raise
|
||||||
|
|
||||||
|
self.delete_dashboard(target_id)
|
||||||
|
app_logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id)
|
||||||
|
return self._do_import(file_path)
|
||||||
|
# [/DEF:import_dashboard:Function]
|
||||||
|
|
||||||
|
# [DEF:delete_dashboard:Function]
|
||||||
|
# @PURPOSE: Удаляет дашборд по его ID или slug.
|
||||||
|
# @PARAM: dashboard_id (Union[int, str]) - ID или slug дашборда.
|
||||||
|
# @PRE: dashboard_id must exist.
|
||||||
|
# @POST: Dashboard is removed from Superset.
|
||||||
|
def delete_dashboard(self, dashboard_id: Union[int, str]) -> None:
|
||||||
|
with belief_scope("delete_dashboard"):
|
||||||
|
app_logger.info("[delete_dashboard][Enter] Deleting dashboard %s.", dashboard_id)
|
||||||
|
response = self.network.request(method="DELETE", endpoint=f"/dashboard/{dashboard_id}")
|
||||||
|
response = cast(Dict, response)
|
||||||
|
if response.get("result", True) is not False:
|
||||||
|
app_logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id)
|
||||||
|
else:
|
||||||
|
app_logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response)
|
||||||
|
# [/DEF:delete_dashboard:Function]
|
||||||
|
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [SECTION: DATASET OPERATIONS]
|
||||||
|
|
||||||
|
# [DEF:get_datasets:Function]
|
||||||
|
# @PURPOSE: Получает полный список датасетов, автоматически обрабатывая пагинацию.
|
||||||
|
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
|
||||||
|
# @PRE: Client is authenticated.
|
||||||
|
# @POST: Returns total count and list of datasets.
|
||||||
|
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список датасетов).
|
||||||
|
def get_datasets(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||||
|
with belief_scope("get_datasets"):
|
||||||
|
app_logger.info("[get_datasets][Enter] Fetching datasets.")
|
||||||
|
validated_query = self._validate_query_params(query)
|
||||||
|
|
||||||
|
total_count = self._fetch_total_object_count(endpoint="/dataset/")
|
||||||
|
paginated_data = self._fetch_all_pages(
|
||||||
|
endpoint="/dataset/",
|
||||||
|
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
|
||||||
|
)
|
||||||
|
app_logger.info("[get_datasets][Exit] Found %d datasets.", total_count)
|
||||||
|
return total_count, paginated_data
|
||||||
|
# [/DEF:get_datasets:Function]
|
||||||
|
|
||||||
|
# [DEF:get_datasets_summary:Function]
|
||||||
|
# @PURPOSE: Fetches dataset metadata optimized for the Dataset Hub grid.
|
||||||
|
# @PRE: Client is authenticated.
|
||||||
|
# @POST: Returns a list of dataset metadata summaries.
|
||||||
|
# @RETURN: List[Dict]
|
||||||
|
def get_datasets_summary(self) -> List[Dict]:
|
||||||
|
with belief_scope("SupersetClient.get_datasets_summary"):
|
||||||
|
query = {
|
||||||
|
"columns": ["id", "table_name", "schema", "database"]
|
||||||
|
}
|
||||||
|
_, datasets = self.get_datasets(query=query)
|
||||||
|
|
||||||
|
# Map fields to match the contracts
|
||||||
|
result = []
|
||||||
|
for ds in datasets:
|
||||||
|
result.append({
|
||||||
|
"id": ds.get("id"),
|
||||||
|
"table_name": ds.get("table_name"),
|
||||||
|
"schema": ds.get("schema"),
|
||||||
|
"database": ds.get("database", {}).get("database_name", "Unknown")
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
# [/DEF:get_datasets_summary:Function]
|
||||||
|
|
||||||
|
# [DEF:get_dataset:Function]
|
||||||
|
# @PURPOSE: Получает информацию о конкретном датасете по его ID.
|
||||||
|
# @PARAM: dataset_id (int) - ID датасета.
|
||||||
|
# @PRE: dataset_id must exist.
|
||||||
|
# @POST: Returns dataset details.
|
||||||
|
# @RETURN: Dict - Информация о датасете.
|
||||||
|
def get_dataset(self, dataset_id: int) -> Dict:
|
||||||
|
with belief_scope("SupersetClient.get_dataset", f"id={dataset_id}"):
|
||||||
|
app_logger.info("[get_dataset][Enter] Fetching dataset %s.", dataset_id)
|
||||||
|
response = self.network.request(method="GET", endpoint=f"/dataset/{dataset_id}")
|
||||||
|
response = cast(Dict, response)
|
||||||
|
app_logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id)
|
||||||
|
return response
|
||||||
|
# [/DEF:get_dataset:Function]
|
||||||
|
|
||||||
|
# [DEF:update_dataset:Function]
|
||||||
|
# @PURPOSE: Обновляет данные датасета по его ID.
|
||||||
|
# @PARAM: dataset_id (int) - ID датасета.
|
||||||
|
# @PARAM: data (Dict) - Данные для обновления.
|
||||||
|
# @PRE: dataset_id must exist.
|
||||||
|
# @POST: Dataset is updated in Superset.
|
||||||
|
# @RETURN: Dict - Ответ API.
|
||||||
|
def update_dataset(self, dataset_id: int, data: Dict) -> Dict:
|
||||||
|
with belief_scope("SupersetClient.update_dataset", f"id={dataset_id}"):
|
||||||
|
app_logger.info("[update_dataset][Enter] Updating dataset %s.", dataset_id)
|
||||||
|
response = self.network.request(
|
||||||
|
method="PUT",
|
||||||
|
endpoint=f"/dataset/{dataset_id}",
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
response = cast(Dict, response)
|
||||||
|
app_logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id)
|
||||||
|
return response
|
||||||
|
# [/DEF:update_dataset:Function]
|
||||||
|
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [SECTION: DATABASE OPERATIONS]
|
||||||
|
|
||||||
|
# [DEF:get_databases:Function]
|
||||||
|
# @PURPOSE: Получает полный список баз данных.
|
||||||
|
# @PARAM: query (Optional[Dict]) - Дополнительные параметры запроса.
|
||||||
|
# @PRE: Client is authenticated.
|
||||||
|
# @POST: Returns total count and list of databases.
|
||||||
|
# @RETURN: Tuple[int, List[Dict]] - Кортеж (общее количество, список баз данных).
|
||||||
|
def get_databases(self, query: Optional[Dict] = None) -> Tuple[int, List[Dict]]:
|
||||||
|
with belief_scope("get_databases"):
|
||||||
|
app_logger.info("[get_databases][Enter] Fetching databases.")
|
||||||
|
validated_query = self._validate_query_params(query or {})
|
||||||
|
if 'columns' not in validated_query:
|
||||||
|
validated_query['columns'] = []
|
||||||
|
total_count = self._fetch_total_object_count(endpoint="/database/")
|
||||||
|
paginated_data = self._fetch_all_pages(
|
||||||
|
endpoint="/database/",
|
||||||
|
pagination_options={"base_query": validated_query, "total_count": total_count, "results_field": "result"},
|
||||||
|
)
|
||||||
|
app_logger.info("[get_databases][Exit] Found %d databases.", total_count)
|
||||||
|
return total_count, paginated_data
|
||||||
|
# [/DEF:get_databases:Function]
|
||||||
|
|
||||||
|
# [DEF:get_database:Function]
|
||||||
|
# @PURPOSE: Получает информацию о конкретной базе данных по её ID.
|
||||||
|
# @PARAM: database_id (int) - ID базы данных.
|
||||||
|
# @PRE: database_id must exist.
|
||||||
|
# @POST: Returns database details.
|
||||||
|
# @RETURN: Dict - Информация о базе данных.
|
||||||
|
def get_database(self, database_id: int) -> Dict:
|
||||||
|
with belief_scope("get_database"):
|
||||||
|
app_logger.info("[get_database][Enter] Fetching database %s.", database_id)
|
||||||
|
response = self.network.request(method="GET", endpoint=f"/database/{database_id}")
|
||||||
|
response = cast(Dict, response)
|
||||||
|
app_logger.info("[get_database][Exit] Got database %s.", database_id)
|
||||||
|
return response
|
||||||
|
# [/DEF:get_database:Function]
|
||||||
|
|
||||||
|
# [DEF:get_databases_summary:Function]
|
||||||
|
# @PURPOSE: Fetch a summary of databases including uuid, name, and engine.
|
||||||
|
# @PRE: Client is authenticated.
|
||||||
|
# @POST: Returns list of database summaries.
|
||||||
|
# @RETURN: List[Dict] - Summary of databases.
|
||||||
|
def get_databases_summary(self) -> List[Dict]:
|
||||||
|
with belief_scope("SupersetClient.get_databases_summary"):
|
||||||
|
query = {
|
||||||
|
"columns": ["uuid", "database_name", "backend"]
|
||||||
|
}
|
||||||
|
_, databases = self.get_databases(query=query)
|
||||||
|
|
||||||
|
# Map 'backend' to 'engine' for consistency with contracts
|
||||||
|
for db in databases:
|
||||||
|
db['engine'] = db.pop('backend', None)
|
||||||
|
|
||||||
|
return databases
|
||||||
|
# [/DEF:get_databases_summary:Function]
|
||||||
|
|
||||||
|
# [DEF:get_database_by_uuid:Function]
|
||||||
|
# @PURPOSE: Find a database by its UUID.
|
||||||
|
# @PARAM: db_uuid (str) - The UUID of the database.
|
||||||
|
# @PRE: db_uuid must be a valid UUID string.
|
||||||
|
# @POST: Returns database info or None.
|
||||||
|
# @RETURN: Optional[Dict] - Database info if found, else None.
|
||||||
|
def get_database_by_uuid(self, db_uuid: str) -> Optional[Dict]:
|
||||||
|
with belief_scope("SupersetClient.get_database_by_uuid", f"uuid={db_uuid}"):
|
||||||
|
query = {
|
||||||
|
"filters": [{"col": "uuid", "op": "eq", "value": db_uuid}]
|
||||||
|
}
|
||||||
|
_, databases = self.get_databases(query=query)
|
||||||
|
return databases[0] if databases else None
|
||||||
|
# [/DEF:get_database_by_uuid:Function]
|
||||||
|
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [SECTION: HELPERS]
|
||||||
|
|
||||||
|
# [DEF:_resolve_target_id_for_delete:Function]
|
||||||
|
# @PURPOSE: Resolves a dashboard ID from either an ID or a slug.
|
||||||
|
# @PRE: Either dash_id or dash_slug should be provided.
|
||||||
|
# @POST: Returns the resolved ID or None.
|
||||||
|
def _resolve_target_id_for_delete(self, dash_id: Optional[int], dash_slug: Optional[str]) -> Optional[int]:
|
||||||
|
with belief_scope("_resolve_target_id_for_delete"):
|
||||||
|
if dash_id is not None:
|
||||||
|
return dash_id
|
||||||
|
if dash_slug is not None:
|
||||||
|
app_logger.debug("[_resolve_target_id_for_delete][State] Resolving ID by slug '%s'.", dash_slug)
|
||||||
|
try:
|
||||||
|
_, candidates = self.get_dashboards(query={"filters": [{"col": "slug", "op": "eq", "value": dash_slug}]})
|
||||||
|
if candidates:
|
||||||
|
target_id = candidates[0]["id"]
|
||||||
|
app_logger.debug("[_resolve_target_id_for_delete][Success] Resolved slug to ID %s.", target_id)
|
||||||
|
return target_id
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e)
|
||||||
|
return None
|
||||||
|
# [/DEF:_resolve_target_id_for_delete:Function]
|
||||||
|
|
||||||
|
# [DEF:_do_import:Function]
|
||||||
|
# @PURPOSE: Performs the actual multipart upload for import.
|
||||||
|
# @PRE: file_name must be a path to an existing ZIP file.
|
||||||
|
# @POST: Returns the API response from the upload.
|
||||||
|
def _do_import(self, file_name: Union[str, Path]) -> Dict:
|
||||||
|
with belief_scope("_do_import"):
|
||||||
|
app_logger.debug(f"[_do_import][State] Uploading file: {file_name}")
|
||||||
|
file_path = Path(file_name)
|
||||||
|
if not file_path.exists():
|
||||||
|
app_logger.error(f"[_do_import][Failure] File does not exist: {file_name}")
|
||||||
|
raise FileNotFoundError(f"File does not exist: {file_name}")
|
||||||
|
|
||||||
|
return self.network.upload_file(
|
||||||
|
endpoint="/dashboard/import/",
|
||||||
|
file_info={"file_obj": file_path, "file_name": file_path.name, "form_field": "formData"},
|
||||||
|
extra_data={"overwrite": "true"},
|
||||||
|
timeout=self.env.timeout * 2,
|
||||||
|
)
|
||||||
|
# [/DEF:_do_import:Function]
|
||||||
|
|
||||||
|
# [DEF:_validate_export_response:Function]
|
||||||
|
# @PURPOSE: Validates that the export response is a non-empty ZIP archive.
|
||||||
|
# @PRE: response must be a valid requests.Response object.
|
||||||
|
# @POST: Raises SupersetAPIError if validation fails.
|
||||||
|
def _validate_export_response(self, response: Response, dashboard_id: int) -> None:
|
||||||
|
with belief_scope("_validate_export_response"):
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
if "application/zip" not in content_type:
|
||||||
|
raise SupersetAPIError(f"Получен не ZIP-архив (Content-Type: {content_type})")
|
||||||
|
if not response.content:
|
||||||
|
raise SupersetAPIError("Получены пустые данные при экспорте")
|
||||||
|
# [/DEF:_validate_export_response:Function]
|
||||||
|
|
||||||
|
# [DEF:_resolve_export_filename:Function]
|
||||||
|
# @PURPOSE: Determines the filename for an exported dashboard.
|
||||||
|
# @PRE: response must contain Content-Disposition header or dashboard_id must be provided.
|
||||||
|
# @POST: Returns a sanitized filename string.
|
||||||
|
def _resolve_export_filename(self, response: Response, dashboard_id: int) -> str:
|
||||||
|
with belief_scope("_resolve_export_filename"):
|
||||||
|
filename = get_filename_from_headers(dict(response.headers))
|
||||||
|
if not filename:
|
||||||
|
from datetime import datetime
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
|
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
|
||||||
|
app_logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename)
|
||||||
|
return filename
|
||||||
|
# [/DEF:_resolve_export_filename:Function]
|
||||||
|
|
||||||
|
# [DEF:_validate_query_params:Function]
|
||||||
|
# @PURPOSE: Ensures query parameters have default page and page_size.
|
||||||
|
# @PRE: query can be None or a dictionary.
|
||||||
|
# @POST: Returns a dictionary with at least page and page_size.
|
||||||
|
def _validate_query_params(self, query: Optional[Dict]) -> Dict:
|
||||||
|
with belief_scope("_validate_query_params"):
|
||||||
|
base_query = {"page": 0, "page_size": 1000}
|
||||||
|
return {**base_query, **(query or {})}
|
||||||
|
# [/DEF:_validate_query_params:Function]
|
||||||
|
|
||||||
|
# [DEF:_fetch_total_object_count:Function]
|
||||||
|
# @PURPOSE: Fetches the total number of items for a given endpoint.
|
||||||
|
# @PRE: endpoint must be a valid Superset API path.
|
||||||
|
# @POST: Returns the total count as an integer.
|
||||||
|
def _fetch_total_object_count(self, endpoint: str) -> int:
|
||||||
|
with belief_scope("_fetch_total_object_count"):
|
||||||
|
return self.network.fetch_paginated_count(
|
||||||
|
endpoint=endpoint,
|
||||||
|
query_params={"page": 0, "page_size": 1},
|
||||||
|
count_field="count",
|
||||||
|
)
|
||||||
|
# [/DEF:_fetch_total_object_count:Function]
|
||||||
|
|
||||||
|
# [DEF:_fetch_all_pages:Function]
|
||||||
|
# @PURPOSE: Iterates through all pages to collect all data items.
|
||||||
|
# @PRE: pagination_options must contain base_query, total_count, and results_field.
|
||||||
|
# @POST: Returns a combined list of all items.
|
||||||
|
def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]:
|
||||||
|
with belief_scope("_fetch_all_pages"):
|
||||||
|
return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options)
|
||||||
|
# [/DEF:_fetch_all_pages:Function]
|
||||||
|
|
||||||
|
# [DEF:_validate_import_file:Function]
|
||||||
|
# @PURPOSE: Validates that the file to be imported is a valid ZIP with metadata.yaml.
|
||||||
|
# @PRE: zip_path must be a path to a file.
|
||||||
|
# @POST: Raises error if file is missing, not a ZIP, or missing metadata.
|
||||||
|
def _validate_import_file(self, zip_path: Union[str, Path]) -> None:
|
||||||
|
with belief_scope("_validate_import_file"):
|
||||||
|
path = Path(zip_path)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"Файл {zip_path} не существует")
|
||||||
|
if not zipfile.is_zipfile(path):
|
||||||
|
raise SupersetAPIError(f"Файл {zip_path} не является ZIP-архивом")
|
||||||
|
with zipfile.ZipFile(path, "r") as zf:
|
||||||
|
if not any(n.endswith("metadata.yaml") for n in zf.namelist()):
|
||||||
|
raise SupersetAPIError(f"Архив {zip_path} не содержит 'metadata.yaml'")
|
||||||
|
# [/DEF:_validate_import_file:Function]
|
||||||
|
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [/DEF:SupersetClient:Class]
|
||||||
|
|
||||||
|
# [/DEF:backend.src.core.superset_client:Module]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# [DEF:TaskManagerPackage:Module]
|
# [DEF:TaskManagerPackage:Module]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
# @SEMANTICS: task, manager, package, exports
|
# @SEMANTICS: task, manager, package, exports
|
||||||
# @PURPOSE: Exports the public API of the task manager package.
|
# @PURPOSE: Exports the public API of the task manager package.
|
||||||
# @LAYER: Core
|
# @LAYER: Core
|
||||||
|
|||||||
75
backend/src/core/task_manager/cleanup.py
Normal file
75
backend/src/core/task_manager/cleanup.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# [DEF:TaskCleanupModule:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: task, cleanup, retention, logs
|
||||||
|
# @PURPOSE: Implements task cleanup and retention policies, including associated logs.
|
||||||
|
# @LAYER: Core
|
||||||
|
# @RELATION: Uses TaskPersistenceService and TaskLogPersistenceService to delete old tasks and logs.
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
from .persistence import TaskPersistenceService, TaskLogPersistenceService
|
||||||
|
from ..logger import logger, belief_scope
|
||||||
|
from ..config_manager import ConfigManager
|
||||||
|
|
||||||
|
# [DEF:TaskCleanupService:Class]
|
||||||
|
# @PURPOSE: Provides methods to clean up old task records and their associated logs.
|
||||||
|
# @TIER: STANDARD
|
||||||
|
class TaskCleanupService:
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the cleanup service with dependencies.
|
||||||
|
# @PRE: persistence_service and config_manager are valid.
|
||||||
|
# @POST: Cleanup service is ready.
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
persistence_service: TaskPersistenceService,
|
||||||
|
log_persistence_service: TaskLogPersistenceService,
|
||||||
|
config_manager: ConfigManager
|
||||||
|
):
|
||||||
|
self.persistence_service = persistence_service
|
||||||
|
self.log_persistence_service = log_persistence_service
|
||||||
|
self.config_manager = config_manager
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
|
||||||
|
# [DEF:run_cleanup:Function]
|
||||||
|
# @PURPOSE: Deletes tasks older than the configured retention period and their logs.
|
||||||
|
# @PRE: Config manager has valid settings.
|
||||||
|
# @POST: Old tasks and their logs are deleted from persistence.
|
||||||
|
def run_cleanup(self):
|
||||||
|
with belief_scope("TaskCleanupService.run_cleanup"):
|
||||||
|
settings = self.config_manager.get_config().settings
|
||||||
|
retention_days = settings.task_retention_days
|
||||||
|
|
||||||
|
logger.info(f"Cleaning up tasks older than {retention_days} days.")
|
||||||
|
|
||||||
|
# Load tasks to check for limit
|
||||||
|
tasks = self.persistence_service.load_tasks(limit=1000)
|
||||||
|
if len(tasks) > settings.task_retention_limit:
|
||||||
|
to_delete: List[str] = [t.id for t in tasks[settings.task_retention_limit:]]
|
||||||
|
|
||||||
|
# Delete logs first (before task records)
|
||||||
|
self.log_persistence_service.delete_logs_for_tasks(to_delete)
|
||||||
|
|
||||||
|
# Then delete task records
|
||||||
|
self.persistence_service.delete_tasks(to_delete)
|
||||||
|
|
||||||
|
logger.info(f"Deleted {len(to_delete)} tasks and their logs exceeding limit of {settings.task_retention_limit}")
|
||||||
|
# [/DEF:run_cleanup:Function]
|
||||||
|
|
||||||
|
# [DEF:delete_task_with_logs:Function]
|
||||||
|
# @PURPOSE: Delete a single task and all its associated logs.
|
||||||
|
# @PRE: task_id is a valid task ID.
|
||||||
|
# @POST: Task and all its logs are deleted.
|
||||||
|
# @PARAM: task_id (str) - The task ID to delete.
|
||||||
|
def delete_task_with_logs(self, task_id: str) -> None:
|
||||||
|
"""Delete a single task and all its associated logs."""
|
||||||
|
with belief_scope("TaskCleanupService.delete_task_with_logs", f"task_id={task_id}"):
|
||||||
|
# Delete logs first
|
||||||
|
self.log_persistence_service.delete_logs_for_task(task_id)
|
||||||
|
|
||||||
|
# Then delete task record
|
||||||
|
self.persistence_service.delete_tasks([task_id])
|
||||||
|
|
||||||
|
logger.info(f"Deleted task {task_id} and its associated logs")
|
||||||
|
# [/DEF:delete_task_with_logs:Function]
|
||||||
|
|
||||||
|
# [/DEF:TaskCleanupService:Class]
|
||||||
|
# [/DEF:TaskCleanupModule:Module]
|
||||||
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,24 +8,34 @@
|
|||||||
|
|
||||||
# [SECTION: IMPORTS]
|
# [SECTION: IMPORTS]
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import inspect
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
|
|
||||||
from .models import Task, TaskStatus, LogEntry
|
from .models import Task, TaskStatus, LogEntry, LogFilter, LogStats
|
||||||
from .persistence import TaskPersistenceService
|
from .persistence import TaskPersistenceService, TaskLogPersistenceService
|
||||||
from ..logger import logger, belief_scope
|
from .context import TaskContext
|
||||||
|
from ..logger import logger, belief_scope, should_log_task_level
|
||||||
# [/SECTION]
|
# [/SECTION]
|
||||||
|
|
||||||
# [DEF:TaskManager:Class]
|
# [DEF:TaskManager:Class]
|
||||||
# @SEMANTICS: task, manager, lifecycle, execution, state
|
# @SEMANTICS: task, manager, lifecycle, execution, state
|
||||||
# @PURPOSE: Manages the lifecycle of tasks, including their creation, execution, and state tracking.
|
# @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:
|
class TaskManager:
|
||||||
"""
|
"""
|
||||||
Manages the lifecycle of tasks, including their creation, execution, and state tracking.
|
Manages the lifecycle of tasks, including their creation, execution, and state tracking.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# [DEF:TaskManager.__init__:Function]
|
# Log flush interval in seconds
|
||||||
|
LOG_FLUSH_INTERVAL = 2.0
|
||||||
|
|
||||||
|
# [DEF:__init__:Function]
|
||||||
# @PURPOSE: Initialize the TaskManager with dependencies.
|
# @PURPOSE: Initialize the TaskManager with dependencies.
|
||||||
# @PRE: plugin_loader is initialized.
|
# @PRE: plugin_loader is initialized.
|
||||||
# @POST: TaskManager is ready to accept tasks.
|
# @POST: TaskManager is ready to accept tasks.
|
||||||
@@ -35,8 +45,18 @@ class TaskManager:
|
|||||||
self.plugin_loader = plugin_loader
|
self.plugin_loader = plugin_loader
|
||||||
self.tasks: Dict[str, Task] = {}
|
self.tasks: Dict[str, Task] = {}
|
||||||
self.subscribers: Dict[str, List[asyncio.Queue]] = {}
|
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.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:
|
try:
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
@@ -46,9 +66,62 @@ class TaskManager:
|
|||||||
|
|
||||||
# Load persisted tasks on startup
|
# Load persisted tasks on startup
|
||||||
self.load_persisted_tasks()
|
self.load_persisted_tasks()
|
||||||
# [/DEF:TaskManager.__init__:Function]
|
# [/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:TaskManager.create_task:Function]
|
# [DEF:create_task:Function]
|
||||||
# @PURPOSE: Creates and queues a new task for execution.
|
# @PURPOSE: Creates and queues a new task for execution.
|
||||||
# @PRE: Plugin with plugin_id exists. Params are valid.
|
# @PRE: Plugin with plugin_id exists. Params are valid.
|
||||||
# @POST: Task is created, added to registry, and scheduled for execution.
|
# @POST: Task is created, added to registry, and scheduled for execution.
|
||||||
@@ -63,7 +136,7 @@ class TaskManager:
|
|||||||
logger.error(f"Plugin with ID '{plugin_id}' not found.")
|
logger.error(f"Plugin with ID '{plugin_id}' not found.")
|
||||||
raise ValueError(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):
|
if not isinstance(params, dict):
|
||||||
logger.error("Task parameters must be a dictionary.")
|
logger.error("Task parameters must be a dictionary.")
|
||||||
@@ -71,13 +144,14 @@ class TaskManager:
|
|||||||
|
|
||||||
task = Task(plugin_id=plugin_id, params=params, user_id=user_id)
|
task = Task(plugin_id=plugin_id, params=params, user_id=user_id)
|
||||||
self.tasks[task.id] = task
|
self.tasks[task.id] = task
|
||||||
|
self.persistence_service.persist_task(task)
|
||||||
logger.info(f"Task {task.id} created and scheduled for execution")
|
logger.info(f"Task {task.id} created and scheduled for execution")
|
||||||
self.loop.create_task(self._run_task(task.id)) # Schedule task for execution
|
self.loop.create_task(self._run_task(task.id)) # Schedule task for execution
|
||||||
return task
|
return task
|
||||||
# [/DEF:TaskManager.create_task:Function]
|
# [/DEF:create_task:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager._run_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.
|
# @PRE: Task exists in registry.
|
||||||
# @POST: Task is executed, status updated to SUCCESS or FAILED.
|
# @POST: Task is executed, status updated to SUCCESS or FAILED.
|
||||||
# @PARAM: task_id (str) - The ID of the task to run.
|
# @PARAM: task_id (str) - The ID of the task to run.
|
||||||
@@ -89,34 +163,60 @@ class TaskManager:
|
|||||||
logger.info(f"Starting execution of task {task_id} for plugin '{plugin.name}'")
|
logger.info(f"Starting execution of task {task_id} for plugin '{plugin.name}'")
|
||||||
task.status = TaskStatus.RUNNING
|
task.status = TaskStatus.RUNNING
|
||||||
task.started_at = datetime.utcnow()
|
task.started_at = datetime.utcnow()
|
||||||
self._add_log(task_id, "INFO", f"Task started for plugin '{plugin.name}'")
|
self.persistence_service.persist_task(task)
|
||||||
|
self._add_log(task_id, "INFO", f"Task started for plugin '{plugin.name}'", source="system")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Execute plugin
|
# Prepare params and check if plugin supports new TaskContext
|
||||||
params = {**task.params, "_task_id": task_id}
|
params = {**task.params, "_task_id": task_id}
|
||||||
|
|
||||||
if asyncio.iscoroutinefunction(plugin.execute):
|
# Check if plugin's execute method accepts 'context' parameter
|
||||||
await plugin.execute(params)
|
sig = inspect.signature(plugin.execute)
|
||||||
else:
|
accepts_context = 'context' in sig.parameters
|
||||||
await self.loop.run_in_executor(
|
|
||||||
self.executor,
|
if accepts_context:
|
||||||
plugin.execute,
|
# Create TaskContext for new-style plugins
|
||||||
params
|
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")
|
logger.info(f"Task {task_id} completed successfully")
|
||||||
task.status = TaskStatus.SUCCESS
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Task {task_id} failed: {e}")
|
logger.error(f"Task {task_id} failed: {e}")
|
||||||
task.status = TaskStatus.FAILED
|
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:
|
finally:
|
||||||
task.finished_at = datetime.utcnow()
|
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}")
|
logger.info(f"Task {task_id} execution finished with status: {task.status}")
|
||||||
# [/DEF:TaskManager._run_task:Function]
|
# [/DEF:_run_task:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager.resolve_task:Function]
|
# [DEF:resolve_task:Function]
|
||||||
# @PURPOSE: Resumes a task that is awaiting mapping.
|
# @PURPOSE: Resumes a task that is awaiting mapping.
|
||||||
# @PRE: Task exists and is in AWAITING_MAPPING state.
|
# @PRE: Task exists and is in AWAITING_MAPPING state.
|
||||||
# @POST: Task status updated to RUNNING, params updated, execution resumed.
|
# @POST: Task status updated to RUNNING, params updated, execution resumed.
|
||||||
@@ -132,14 +232,15 @@ class TaskManager:
|
|||||||
# Update task params with resolution
|
# Update task params with resolution
|
||||||
task.params.update(resolution_params)
|
task.params.update(resolution_params)
|
||||||
task.status = TaskStatus.RUNNING
|
task.status = TaskStatus.RUNNING
|
||||||
|
self.persistence_service.persist_task(task)
|
||||||
self._add_log(task_id, "INFO", "Task resumed after mapping resolution.")
|
self._add_log(task_id, "INFO", "Task resumed after mapping resolution.")
|
||||||
|
|
||||||
# Signal the future to continue
|
# Signal the future to continue
|
||||||
if task_id in self.task_futures:
|
if task_id in self.task_futures:
|
||||||
self.task_futures[task_id].set_result(True)
|
self.task_futures[task_id].set_result(True)
|
||||||
# [/DEF:TaskManager.resolve_task:Function]
|
# [/DEF:resolve_task:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager.wait_for_resolution:Function]
|
# [DEF:wait_for_resolution:Function]
|
||||||
# @PURPOSE: Pauses execution and waits for a resolution signal.
|
# @PURPOSE: Pauses execution and waits for a resolution signal.
|
||||||
# @PRE: Task exists.
|
# @PRE: Task exists.
|
||||||
# @POST: Execution pauses until future is set.
|
# @POST: Execution pauses until future is set.
|
||||||
@@ -147,9 +248,11 @@ class TaskManager:
|
|||||||
async def wait_for_resolution(self, task_id: str):
|
async def wait_for_resolution(self, task_id: str):
|
||||||
with belief_scope("TaskManager.wait_for_resolution", f"task_id={task_id}"):
|
with belief_scope("TaskManager.wait_for_resolution", f"task_id={task_id}"):
|
||||||
task = self.tasks.get(task_id)
|
task = self.tasks.get(task_id)
|
||||||
if not task: return
|
if not task:
|
||||||
|
return
|
||||||
|
|
||||||
task.status = TaskStatus.AWAITING_MAPPING
|
task.status = TaskStatus.AWAITING_MAPPING
|
||||||
|
self.persistence_service.persist_task(task)
|
||||||
self.task_futures[task_id] = self.loop.create_future()
|
self.task_futures[task_id] = self.loop.create_future()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -157,9 +260,9 @@ class TaskManager:
|
|||||||
finally:
|
finally:
|
||||||
if task_id in self.task_futures:
|
if task_id in self.task_futures:
|
||||||
del self.task_futures[task_id]
|
del self.task_futures[task_id]
|
||||||
# [/DEF:TaskManager.wait_for_resolution:Function]
|
# [/DEF:wait_for_resolution:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager.wait_for_input:Function]
|
# [DEF:wait_for_input:Function]
|
||||||
# @PURPOSE: Pauses execution and waits for user input.
|
# @PURPOSE: Pauses execution and waits for user input.
|
||||||
# @PRE: Task exists.
|
# @PRE: Task exists.
|
||||||
# @POST: Execution pauses until future is set via resume_task_with_password.
|
# @POST: Execution pauses until future is set via resume_task_with_password.
|
||||||
@@ -167,7 +270,8 @@ class TaskManager:
|
|||||||
async def wait_for_input(self, task_id: str):
|
async def wait_for_input(self, task_id: str):
|
||||||
with belief_scope("TaskManager.wait_for_input", f"task_id={task_id}"):
|
with belief_scope("TaskManager.wait_for_input", f"task_id={task_id}"):
|
||||||
task = self.tasks.get(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()
|
# Status is already set to AWAITING_INPUT by await_input()
|
||||||
self.task_futures[task_id] = self.loop.create_future()
|
self.task_futures[task_id] = self.loop.create_future()
|
||||||
@@ -177,24 +281,30 @@ class TaskManager:
|
|||||||
finally:
|
finally:
|
||||||
if task_id in self.task_futures:
|
if task_id in self.task_futures:
|
||||||
del self.task_futures[task_id]
|
del self.task_futures[task_id]
|
||||||
# [/DEF:TaskManager.wait_for_input:Function]
|
# [/DEF:wait_for_input:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager.get_task:Function]
|
# [DEF:get_task:Function]
|
||||||
# @PURPOSE: Retrieves a task by its ID.
|
# @PURPOSE: Retrieves a task by its ID.
|
||||||
|
# @PRE: task_id is a string.
|
||||||
|
# @POST: Returns Task object or None.
|
||||||
# @PARAM: task_id (str) - ID of the task.
|
# @PARAM: task_id (str) - ID of the task.
|
||||||
# @RETURN: Optional[Task] - The task or None.
|
# @RETURN: Optional[Task] - The task or None.
|
||||||
def get_task(self, task_id: str) -> Optional[Task]:
|
def get_task(self, task_id: str) -> Optional[Task]:
|
||||||
return self.tasks.get(task_id)
|
with belief_scope("TaskManager.get_task", f"task_id={task_id}"):
|
||||||
# [/DEF:TaskManager.get_task:Function]
|
return self.tasks.get(task_id)
|
||||||
|
# [/DEF:get_task:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager.get_all_tasks:Function]
|
# [DEF:get_all_tasks:Function]
|
||||||
# @PURPOSE: Retrieves all registered tasks.
|
# @PURPOSE: Retrieves all registered tasks.
|
||||||
|
# @PRE: None.
|
||||||
|
# @POST: Returns list of all Task objects.
|
||||||
# @RETURN: List[Task] - All tasks.
|
# @RETURN: List[Task] - All tasks.
|
||||||
def get_all_tasks(self) -> List[Task]:
|
def get_all_tasks(self) -> List[Task]:
|
||||||
return list(self.tasks.values())
|
with belief_scope("TaskManager.get_all_tasks"):
|
||||||
# [/DEF:TaskManager.get_all_tasks:Function]
|
return list(self.tasks.values())
|
||||||
|
# [/DEF:get_all_tasks:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager.get_tasks:Function]
|
# [DEF:get_tasks:Function]
|
||||||
# @PURPOSE: Retrieves tasks with pagination and optional status filter.
|
# @PURPOSE: Retrieves tasks with pagination and optional status filter.
|
||||||
# @PRE: limit and offset are non-negative integers.
|
# @PRE: limit and offset are non-negative integers.
|
||||||
# @POST: Returns a list of tasks sorted by start_time descending.
|
# @POST: Returns a list of tasks sorted by start_time descending.
|
||||||
@@ -203,85 +313,164 @@ class TaskManager:
|
|||||||
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
|
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
|
||||||
# @RETURN: List[Task] - List of tasks matching criteria.
|
# @RETURN: List[Task] - List of tasks matching criteria.
|
||||||
def get_tasks(self, limit: int = 10, offset: int = 0, status: Optional[TaskStatus] = None) -> List[Task]:
|
def get_tasks(self, limit: int = 10, offset: int = 0, status: Optional[TaskStatus] = None) -> List[Task]:
|
||||||
tasks = list(self.tasks.values())
|
with belief_scope("TaskManager.get_tasks"):
|
||||||
|
tasks = list(self.tasks.values())
|
||||||
if status:
|
if status:
|
||||||
tasks = [t for t in tasks if t.status == status]
|
tasks = [t for t in tasks if t.status == status]
|
||||||
# Sort by start_time descending (most recent first)
|
# Sort by start_time descending (most recent first)
|
||||||
tasks.sort(key=lambda t: t.started_at or datetime.min, reverse=True)
|
tasks.sort(key=lambda t: t.started_at or datetime.min, reverse=True)
|
||||||
return tasks[offset:offset + limit]
|
return tasks[offset:offset + limit]
|
||||||
# [/DEF:TaskManager.get_tasks:Function]
|
# [/DEF:get_tasks:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager.get_task_logs: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 or TaskLog objects.
|
||||||
# @PARAM: task_id (str) - ID of the task.
|
# @PARAM: task_id (str) - ID of the task.
|
||||||
|
# @PARAM: log_filter (Optional[LogFilter]) - Filter parameters.
|
||||||
# @RETURN: List[LogEntry] - List of log entries.
|
# @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]:
|
||||||
task = self.tasks.get(task_id)
|
with belief_scope("TaskManager.get_task_logs", f"task_id={task_id}"):
|
||||||
return task.logs if task else []
|
task = self.tasks.get(task_id)
|
||||||
# [/DEF:TaskManager.get_task_logs:Function]
|
|
||||||
|
# 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:TaskManager._add_log: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.
|
# @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: task_id (str) - ID of the task.
|
||||||
# @PARAM: level (str) - Log level.
|
# @PARAM: level (str) - Log level.
|
||||||
# @PARAM: message (str) - Log message.
|
# @PARAM: message (str) - Log message.
|
||||||
# @PARAM: context (Optional[Dict]) - Log context.
|
# @PARAM: source (str) - Source component (default: "system").
|
||||||
def _add_log(self, task_id: str, level: str, message: str, context: Optional[Dict[str, Any]] = None):
|
# @PARAM: metadata (Optional[Dict]) - Additional structured data.
|
||||||
task = self.tasks.get(task_id)
|
# @PARAM: context (Optional[Dict]) - Legacy context (for backward compatibility).
|
||||||
if not task:
|
def _add_log(
|
||||||
return
|
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)
|
# Filter logs based on task_log_level configuration
|
||||||
task.logs.append(log_entry)
|
if not should_log_task_level(level):
|
||||||
|
return
|
||||||
|
|
||||||
# Notify subscribers
|
# Create log entry with new fields
|
||||||
if task_id in self.subscribers:
|
log_entry = LogEntry(
|
||||||
for queue in self.subscribers[task_id]:
|
level=level,
|
||||||
self.loop.call_soon_threadsafe(queue.put_nowait, log_entry)
|
message=message,
|
||||||
# [/DEF:TaskManager._add_log:Function]
|
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)
|
||||||
|
|
||||||
# [DEF:TaskManager.subscribe_logs:Function]
|
# Notify subscribers (for real-time WebSocket updates)
|
||||||
|
if task_id in self.subscribers:
|
||||||
|
for queue in self.subscribers[task_id]:
|
||||||
|
self.loop.call_soon_threadsafe(queue.put_nowait, log_entry)
|
||||||
|
# [/DEF:_add_log:Function]
|
||||||
|
|
||||||
|
# [DEF:subscribe_logs:Function]
|
||||||
# @PURPOSE: Subscribes to real-time logs for a task.
|
# @PURPOSE: Subscribes to real-time logs for a task.
|
||||||
|
# @PRE: task_id is a string.
|
||||||
|
# @POST: Returns an asyncio.Queue for log entries.
|
||||||
# @PARAM: task_id (str) - ID of the task.
|
# @PARAM: task_id (str) - ID of the task.
|
||||||
# @RETURN: asyncio.Queue - Queue for log entries.
|
# @RETURN: asyncio.Queue - Queue for log entries.
|
||||||
async def subscribe_logs(self, task_id: str) -> asyncio.Queue:
|
async def subscribe_logs(self, task_id: str) -> asyncio.Queue:
|
||||||
queue = asyncio.Queue()
|
with belief_scope("TaskManager.subscribe_logs", f"task_id={task_id}"):
|
||||||
if task_id not in self.subscribers:
|
queue = asyncio.Queue()
|
||||||
self.subscribers[task_id] = []
|
if task_id not in self.subscribers:
|
||||||
self.subscribers[task_id].append(queue)
|
self.subscribers[task_id] = []
|
||||||
return queue
|
self.subscribers[task_id].append(queue)
|
||||||
# [/DEF:TaskManager.subscribe_logs:Function]
|
return queue
|
||||||
|
# [/DEF:subscribe_logs:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager.unsubscribe_logs:Function]
|
# [DEF:unsubscribe_logs:Function]
|
||||||
# @PURPOSE: Unsubscribes from real-time logs for a task.
|
# @PURPOSE: Unsubscribes from real-time logs for a task.
|
||||||
|
# @PRE: task_id is a string, queue is asyncio.Queue.
|
||||||
|
# @POST: Queue removed from subscribers.
|
||||||
# @PARAM: task_id (str) - ID of the task.
|
# @PARAM: task_id (str) - ID of the task.
|
||||||
# @PARAM: queue (asyncio.Queue) - Queue to remove.
|
# @PARAM: queue (asyncio.Queue) - Queue to remove.
|
||||||
def unsubscribe_logs(self, task_id: str, queue: asyncio.Queue):
|
def unsubscribe_logs(self, task_id: str, queue: asyncio.Queue):
|
||||||
if task_id in self.subscribers:
|
with belief_scope("TaskManager.unsubscribe_logs", f"task_id={task_id}"):
|
||||||
if queue in self.subscribers[task_id]:
|
if task_id in self.subscribers:
|
||||||
self.subscribers[task_id].remove(queue)
|
if queue in self.subscribers[task_id]:
|
||||||
if not self.subscribers[task_id]:
|
self.subscribers[task_id].remove(queue)
|
||||||
del self.subscribers[task_id]
|
if not self.subscribers[task_id]:
|
||||||
# [/DEF:TaskManager.unsubscribe_logs:Function]
|
del self.subscribers[task_id]
|
||||||
|
# [/DEF:unsubscribe_logs:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager.persist_awaiting_input_tasks:Function]
|
# [DEF:load_persisted_tasks:Function]
|
||||||
# @PURPOSE: Persist tasks in AWAITING_INPUT state using persistence service.
|
|
||||||
def persist_awaiting_input_tasks(self) -> None:
|
|
||||||
self.persistence_service.persist_tasks(list(self.tasks.values()))
|
|
||||||
# [/DEF:TaskManager.persist_awaiting_input_tasks:Function]
|
|
||||||
|
|
||||||
# [DEF:TaskManager.load_persisted_tasks:Function]
|
|
||||||
# @PURPOSE: Load persisted tasks using persistence service.
|
# @PURPOSE: Load persisted tasks using persistence service.
|
||||||
|
# @PRE: None.
|
||||||
|
# @POST: Persisted tasks loaded into self.tasks.
|
||||||
def load_persisted_tasks(self) -> None:
|
def load_persisted_tasks(self) -> None:
|
||||||
loaded_tasks = self.persistence_service.load_tasks()
|
with belief_scope("TaskManager.load_persisted_tasks"):
|
||||||
for task in loaded_tasks:
|
loaded_tasks = self.persistence_service.load_tasks(limit=100)
|
||||||
if task.id not in self.tasks:
|
for task in loaded_tasks:
|
||||||
self.tasks[task.id] = task
|
if task.id not in self.tasks:
|
||||||
# [/DEF:TaskManager.load_persisted_tasks:Function]
|
self.tasks[task.id] = task
|
||||||
|
# [/DEF:load_persisted_tasks:Function]
|
||||||
|
|
||||||
# [DEF:TaskManager.await_input:Function]
|
# [DEF:await_input:Function]
|
||||||
# @PURPOSE: Transition a task to AWAITING_INPUT state with input request.
|
# @PURPOSE: Transition a task to AWAITING_INPUT state with input request.
|
||||||
# @PRE: Task exists and is in RUNNING state.
|
# @PRE: Task exists and is in RUNNING state.
|
||||||
# @POST: Task status changed to AWAITING_INPUT, input_request set, persisted.
|
# @POST: Task status changed to AWAITING_INPUT, input_request set, persisted.
|
||||||
@@ -299,12 +488,11 @@ class TaskManager:
|
|||||||
task.status = TaskStatus.AWAITING_INPUT
|
task.status = TaskStatus.AWAITING_INPUT
|
||||||
task.input_required = True
|
task.input_required = True
|
||||||
task.input_request = input_request
|
task.input_request = input_request
|
||||||
|
self.persistence_service.persist_task(task)
|
||||||
self._add_log(task_id, "INFO", "Task paused for user input", {"input_request": input_request})
|
self._add_log(task_id, "INFO", "Task paused for user input", {"input_request": input_request})
|
||||||
|
# [/DEF:await_input:Function]
|
||||||
self.persist_awaiting_input_tasks()
|
|
||||||
# [/DEF:TaskManager.await_input:Function]
|
|
||||||
|
|
||||||
# [DEF:TaskManager.resume_task_with_password:Function]
|
# [DEF:resume_task_with_password:Function]
|
||||||
# @PURPOSE: Resume a task that is awaiting input with provided passwords.
|
# @PURPOSE: Resume a task that is awaiting input with provided passwords.
|
||||||
# @PRE: Task exists and is in AWAITING_INPUT state.
|
# @PRE: Task exists and is in AWAITING_INPUT state.
|
||||||
# @POST: Task status changed to RUNNING, passwords injected, task resumed.
|
# @POST: Task status changed to RUNNING, passwords injected, task resumed.
|
||||||
@@ -326,17 +514,17 @@ class TaskManager:
|
|||||||
task.input_required = False
|
task.input_required = False
|
||||||
task.input_request = None
|
task.input_request = None
|
||||||
task.status = TaskStatus.RUNNING
|
task.status = TaskStatus.RUNNING
|
||||||
|
self.persistence_service.persist_task(task)
|
||||||
self._add_log(task_id, "INFO", "Task resumed with passwords", {"databases": list(passwords.keys())})
|
self._add_log(task_id, "INFO", "Task resumed with passwords", {"databases": list(passwords.keys())})
|
||||||
|
|
||||||
if task_id in self.task_futures:
|
if task_id in self.task_futures:
|
||||||
self.task_futures[task_id].set_result(True)
|
self.task_futures[task_id].set_result(True)
|
||||||
|
# [/DEF:resume_task_with_password:Function]
|
||||||
# Remove from persistence as it's no longer awaiting input
|
|
||||||
self.persistence_service.delete_tasks([task_id])
|
|
||||||
# [/DEF:TaskManager.resume_task_with_password:Function]
|
|
||||||
|
|
||||||
# [DEF:TaskManager.clear_tasks: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.
|
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
|
||||||
# @RETURN: int - Number of tasks cleared.
|
# @RETURN: int - Number of tasks cleared.
|
||||||
def clear_tasks(self, status: Optional[TaskStatus] = None) -> int:
|
def clear_tasks(self, status: Optional[TaskStatus] = None) -> int:
|
||||||
@@ -368,12 +556,16 @@ class TaskManager:
|
|||||||
|
|
||||||
del self.tasks[tid]
|
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)
|
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.")
|
logger.info(f"Cleared {len(tasks_to_remove)} tasks.")
|
||||||
return len(tasks_to_remove)
|
return len(tasks_to_remove)
|
||||||
# [/DEF:TaskManager.clear_tasks:Function]
|
# [/DEF:clear_tasks:Function]
|
||||||
|
|
||||||
# [/DEF:TaskManager:Class]
|
# [/DEF:TaskManager:Class]
|
||||||
# [/DEF:TaskManagerModule:Module]
|
# [/DEF:TaskManagerModule:Module]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
# [DEF:TaskManagerModels:Module]
|
# [DEF:TaskManagerModels:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: task, models, pydantic, enum, state
|
# @SEMANTICS: task, models, pydantic, enum, state
|
||||||
# @PURPOSE: Defines the data models and enumerations used by the Task Manager.
|
# @PURPOSE: Defines the data models and enumerations used by the Task Manager.
|
||||||
# @LAYER: Core
|
# @LAYER: Core
|
||||||
@@ -16,6 +17,7 @@ from pydantic import BaseModel, Field
|
|||||||
# [/SECTION]
|
# [/SECTION]
|
||||||
|
|
||||||
# [DEF:TaskStatus:Enum]
|
# [DEF:TaskStatus:Enum]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
# @SEMANTICS: task, status, state, enum
|
# @SEMANTICS: task, status, state, enum
|
||||||
# @PURPOSE: Defines the possible states a task can be in during its lifecycle.
|
# @PURPOSE: Defines the possible states a task can be in during its lifecycle.
|
||||||
class TaskStatus(str, Enum):
|
class TaskStatus(str, Enum):
|
||||||
@@ -27,17 +29,73 @@ class TaskStatus(str, Enum):
|
|||||||
AWAITING_INPUT = "AWAITING_INPUT"
|
AWAITING_INPUT = "AWAITING_INPUT"
|
||||||
# [/DEF:TaskStatus:Enum]
|
# [/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]
|
# [DEF:LogEntry:Class]
|
||||||
# @SEMANTICS: log, entry, record, pydantic
|
# @SEMANTICS: log, entry, record, pydantic
|
||||||
# @PURPOSE: A Pydantic model representing a single, structured log entry associated with a task.
|
# @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):
|
class LogEntry(BaseModel):
|
||||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||||
level: str
|
level: str = Field(default="INFO")
|
||||||
message: str
|
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: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]
|
# [DEF:Task:Class]
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: task, job, execution, state, pydantic
|
# @SEMANTICS: task, job, execution, state, pydantic
|
||||||
# @PURPOSE: A Pydantic model representing a single execution instance of a plugin, including its status, parameters, and logs.
|
# @PURPOSE: A Pydantic model representing a single execution instance of a plugin, including its status, parameters, and logs.
|
||||||
class Task(BaseModel):
|
class Task(BaseModel):
|
||||||
@@ -51,8 +109,9 @@ class Task(BaseModel):
|
|||||||
params: Dict[str, Any] = Field(default_factory=dict)
|
params: Dict[str, Any] = Field(default_factory=dict)
|
||||||
input_required: bool = False
|
input_required: bool = False
|
||||||
input_request: Optional[Dict[str, Any]] = None
|
input_request: Optional[Dict[str, Any]] = None
|
||||||
|
result: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
# [DEF:Task.__init__:Function]
|
# [DEF:__init__:Function]
|
||||||
# @PURPOSE: Initializes the Task model and validates input_request for AWAITING_INPUT status.
|
# @PURPOSE: Initializes the Task model and validates input_request for AWAITING_INPUT status.
|
||||||
# @PRE: If status is AWAITING_INPUT, input_request must be provided.
|
# @PRE: If status is AWAITING_INPUT, input_request must be provided.
|
||||||
# @POST: Task instance is created or ValueError is raised.
|
# @POST: Task instance is created or ValueError is raised.
|
||||||
@@ -61,7 +120,7 @@ class Task(BaseModel):
|
|||||||
super().__init__(**data)
|
super().__init__(**data)
|
||||||
if self.status == TaskStatus.AWAITING_INPUT and not self.input_request:
|
if self.status == TaskStatus.AWAITING_INPUT and not self.input_request:
|
||||||
raise ValueError("input_request is required when status is AWAITING_INPUT")
|
raise ValueError("input_request is required when status is AWAITING_INPUT")
|
||||||
# [/DEF:Task.__init__:Function]
|
# [/DEF:__init__:Function]
|
||||||
# [/DEF:Task:Class]
|
# [/DEF:Task:Class]
|
||||||
|
|
||||||
# [/DEF:TaskManagerModels:Module]
|
# [/DEF:TaskManagerModels:Module]
|
||||||
@@ -1,158 +1,384 @@
|
|||||||
# [DEF:TaskPersistenceModule:Module]
|
# [DEF:TaskPersistenceModule:Module]
|
||||||
# @SEMANTICS: persistence, sqlite, task, storage
|
# @SEMANTICS: persistence, sqlite, sqlalchemy, task, storage
|
||||||
# @PURPOSE: Handles the persistence of tasks, specifically those awaiting user input, to a SQLite database.
|
# @PURPOSE: Handles the persistence of tasks using SQLAlchemy and the tasks.db database.
|
||||||
# @LAYER: Core
|
# @LAYER: Core
|
||||||
# @RELATION: Used by TaskManager to save and load tasks.
|
# @RELATION: Used by TaskManager to save and load tasks.
|
||||||
# @INVARIANT: Database schema must match the Task model structure.
|
# @INVARIANT: Database schema must match the TaskRecord model structure.
|
||||||
# @CONSTRAINT: Uses synchronous SQLite operations (blocking), should be used carefully.
|
|
||||||
|
|
||||||
# [SECTION: IMPORTS]
|
# [SECTION: IMPORTS]
|
||||||
import sqlite3
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from typing import List, Optional
|
||||||
from typing import Dict, List, Optional, Any
|
import json
|
||||||
|
|
||||||
from .models import Task, TaskStatus
|
from sqlalchemy.orm import Session
|
||||||
|
from ...models.task import TaskRecord, TaskLogRecord
|
||||||
|
from ..database import TasksSessionLocal
|
||||||
|
from .models import Task, TaskStatus, LogEntry, TaskLog, LogFilter, LogStats
|
||||||
from ..logger import logger, belief_scope
|
from ..logger import logger, belief_scope
|
||||||
# [/SECTION]
|
# [/SECTION]
|
||||||
|
|
||||||
# [DEF:TaskPersistenceService:Class]
|
# [DEF:TaskPersistenceService:Class]
|
||||||
# @SEMANTICS: persistence, service, database
|
# @SEMANTICS: persistence, service, database, sqlalchemy
|
||||||
# @PURPOSE: Provides methods to save and load tasks from a local SQLite database.
|
# @PURPOSE: Provides methods to save and load tasks from the tasks.db database using SQLAlchemy.
|
||||||
class TaskPersistenceService:
|
class TaskPersistenceService:
|
||||||
def __init__(self, db_path: Optional[Path] = None):
|
# [DEF:__init__:Function]
|
||||||
if db_path is None:
|
# @PURPOSE: Initializes the persistence service.
|
||||||
self.db_path = Path(__file__).parent.parent.parent.parent / "migrations.db"
|
|
||||||
else:
|
|
||||||
self.db_path = db_path
|
|
||||||
self._ensure_db_exists()
|
|
||||||
|
|
||||||
# [DEF:TaskPersistenceService._ensure_db_exists:Function]
|
|
||||||
# @PURPOSE: Ensures the database directory and table exist.
|
|
||||||
# @PRE: None.
|
# @PRE: None.
|
||||||
# @POST: Database file and table are created if they didn't exist.
|
# @POST: Service is ready.
|
||||||
def _ensure_db_exists(self) -> None:
|
def __init__(self):
|
||||||
with belief_scope("TaskPersistenceService._ensure_db_exists"):
|
with belief_scope("TaskPersistenceService.__init__"):
|
||||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
# We use TasksSessionLocal from database.py
|
||||||
|
pass
|
||||||
conn = sqlite3.connect(str(self.db_path))
|
# [/DEF:__init__:Function]
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS persistent_tasks (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
plugin_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
input_request JSON,
|
|
||||||
context JSON
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
# [/DEF:TaskPersistenceService._ensure_db_exists:Function]
|
|
||||||
|
|
||||||
# [DEF:TaskPersistenceService.persist_tasks:Function]
|
# [DEF:persist_task:Function]
|
||||||
# @PURPOSE: Persists a list of tasks to the database.
|
# @PURPOSE: Persists or updates a single task in the database.
|
||||||
# @PRE: Tasks list contains valid Task objects.
|
# @PRE: isinstance(task, Task)
|
||||||
# @POST: Tasks matching the criteria (AWAITING_INPUT) are saved/updated in the DB.
|
# @POST: Task record created or updated in database.
|
||||||
# @PARAM: tasks (List[Task]) - The list of tasks to check and persist.
|
# @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()
|
||||||
|
try:
|
||||||
|
record = session.query(TaskRecord).filter(TaskRecord.id == task.id).first()
|
||||||
|
if not record:
|
||||||
|
record = TaskRecord(id=task.id)
|
||||||
|
session.add(record)
|
||||||
|
|
||||||
|
record.type = task.plugin_id
|
||||||
|
record.status = task.status.value
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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 = []
|
||||||
|
for log in task.logs:
|
||||||
|
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
|
||||||
|
if task.status == TaskStatus.FAILED:
|
||||||
|
for log in reversed(task.logs):
|
||||||
|
if log.level == "ERROR":
|
||||||
|
record.error = log.message
|
||||||
|
break
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"Failed to persist task {task.id}: {e}")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
# [/DEF:persist_task:Function]
|
||||||
|
|
||||||
|
# [DEF:persist_tasks:Function]
|
||||||
|
# @PURPOSE: Persists multiple tasks.
|
||||||
|
# @PRE: isinstance(tasks, list)
|
||||||
|
# @POST: All tasks in list are persisted.
|
||||||
|
# @PARAM: tasks (List[Task]) - The list of tasks to persist.
|
||||||
def persist_tasks(self, tasks: List[Task]) -> None:
|
def persist_tasks(self, tasks: List[Task]) -> None:
|
||||||
with belief_scope("TaskPersistenceService.persist_tasks"):
|
with belief_scope("TaskPersistenceService.persist_tasks"):
|
||||||
conn = sqlite3.connect(str(self.db_path))
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
if task.status == TaskStatus.AWAITING_INPUT:
|
self.persist_task(task)
|
||||||
cursor.execute("""
|
# [/DEF:persist_tasks:Function]
|
||||||
INSERT OR REPLACE INTO persistent_tasks
|
|
||||||
(id, plugin_id, status, created_at, updated_at, input_request, context)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""", (
|
|
||||||
task.id,
|
|
||||||
task.plugin_id,
|
|
||||||
task.status.value,
|
|
||||||
task.started_at.isoformat() if task.started_at else datetime.utcnow().isoformat(),
|
|
||||||
datetime.utcnow().isoformat(),
|
|
||||||
json.dumps(task.input_request) if task.input_request else None,
|
|
||||||
json.dumps(task.params)
|
|
||||||
))
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
logger.info(f"Persisted {count} tasks awaiting input.")
|
|
||||||
# [/DEF:TaskPersistenceService.persist_tasks:Function]
|
|
||||||
|
|
||||||
# [DEF:TaskPersistenceService.load_tasks:Function]
|
# [DEF:load_tasks:Function]
|
||||||
# @PURPOSE: Loads persisted tasks from the database.
|
# @PURPOSE: Loads tasks from the database.
|
||||||
# @PRE: Database exists.
|
# @PRE: limit is an integer.
|
||||||
# @POST: Returns a list of Task objects reconstructed from the DB.
|
# @POST: Returns list of Task objects.
|
||||||
|
# @PARAM: limit (int) - Max tasks to load.
|
||||||
|
# @PARAM: status (Optional[TaskStatus]) - Filter by status.
|
||||||
# @RETURN: List[Task] - The loaded tasks.
|
# @RETURN: List[Task] - The loaded tasks.
|
||||||
def load_tasks(self) -> List[Task]:
|
def load_tasks(self, limit: int = 100, status: Optional[TaskStatus] = None) -> List[Task]:
|
||||||
with belief_scope("TaskPersistenceService.load_tasks"):
|
with belief_scope("TaskPersistenceService.load_tasks"):
|
||||||
if not self.db_path.exists():
|
session: Session = TasksSessionLocal()
|
||||||
return []
|
try:
|
||||||
|
query = session.query(TaskRecord)
|
||||||
conn = sqlite3.connect(str(self.db_path))
|
if status:
|
||||||
cursor = conn.cursor()
|
query = query.filter(TaskRecord.status == status.value)
|
||||||
|
|
||||||
# Check if plugin_id column exists (migration for existing db)
|
records = query.order_by(TaskRecord.created_at.desc()).limit(limit).all()
|
||||||
cursor.execute("PRAGMA table_info(persistent_tasks)")
|
|
||||||
columns = [info[1] for info in cursor.fetchall()]
|
loaded_tasks = []
|
||||||
has_plugin_id = "plugin_id" in columns
|
for record in records:
|
||||||
|
try:
|
||||||
|
logs = []
|
||||||
|
if record.logs:
|
||||||
|
for log_data in record.logs:
|
||||||
|
# Handle timestamp conversion if it's a string
|
||||||
|
if isinstance(log_data.get('timestamp'), str):
|
||||||
|
log_data['timestamp'] = datetime.fromisoformat(log_data['timestamp'])
|
||||||
|
logs.append(LogEntry(**log_data))
|
||||||
|
|
||||||
if has_plugin_id:
|
task = Task(
|
||||||
cursor.execute("SELECT id, plugin_id, status, created_at, input_request, context FROM persistent_tasks")
|
id=record.id,
|
||||||
else:
|
plugin_id=record.type,
|
||||||
cursor.execute("SELECT id, status, created_at, input_request, context FROM persistent_tasks")
|
status=TaskStatus(record.status),
|
||||||
|
started_at=record.started_at,
|
||||||
rows = cursor.fetchall()
|
finished_at=record.finished_at,
|
||||||
|
params=record.params or {},
|
||||||
loaded_tasks = []
|
result=record.result,
|
||||||
for row in rows:
|
logs=logs
|
||||||
if has_plugin_id:
|
)
|
||||||
task_id, plugin_id, status, created_at, input_request_json, context_json = row
|
loaded_tasks.append(task)
|
||||||
else:
|
except Exception as e:
|
||||||
task_id, status, created_at, input_request_json, context_json = row
|
logger.error(f"Failed to reconstruct task {record.id}: {e}")
|
||||||
plugin_id = "superset-migration" # Default fallback
|
|
||||||
|
return loaded_tasks
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
# [/DEF:load_tasks:Function]
|
||||||
|
|
||||||
try:
|
# [DEF:delete_tasks:Function]
|
||||||
task = Task(
|
|
||||||
id=task_id,
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
status=TaskStatus(status),
|
|
||||||
started_at=datetime.fromisoformat(created_at),
|
|
||||||
input_required=True,
|
|
||||||
input_request=json.loads(input_request_json) if input_request_json else None,
|
|
||||||
params=json.loads(context_json) if context_json else {}
|
|
||||||
)
|
|
||||||
loaded_tasks.append(task)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to load task {task_id}: {e}")
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
return loaded_tasks
|
|
||||||
# [/DEF:TaskPersistenceService.load_tasks:Function]
|
|
||||||
|
|
||||||
# [DEF:TaskPersistenceService.delete_tasks:Function]
|
|
||||||
# @PURPOSE: Deletes specific tasks from the database.
|
# @PURPOSE: Deletes specific tasks from the database.
|
||||||
|
# @PRE: task_ids is a list of strings.
|
||||||
|
# @POST: Specified task records deleted from database.
|
||||||
# @PARAM: task_ids (List[str]) - List of task IDs to delete.
|
# @PARAM: task_ids (List[str]) - List of task IDs to delete.
|
||||||
def delete_tasks(self, task_ids: List[str]) -> None:
|
def delete_tasks(self, task_ids: List[str]) -> None:
|
||||||
if not task_ids:
|
if not task_ids:
|
||||||
return
|
return
|
||||||
with belief_scope("TaskPersistenceService.delete_tasks"):
|
with belief_scope("TaskPersistenceService.delete_tasks"):
|
||||||
conn = sqlite3.connect(str(self.db_path))
|
session: Session = TasksSessionLocal()
|
||||||
cursor = conn.cursor()
|
try:
|
||||||
placeholders = ', '.join('?' for _ in task_ids)
|
session.query(TaskRecord).filter(TaskRecord.id.in_(task_ids)).delete(synchronize_session=False)
|
||||||
cursor.execute(f"DELETE FROM persistent_tasks WHERE id IN ({placeholders})", task_ids)
|
session.commit()
|
||||||
conn.commit()
|
except Exception as e:
|
||||||
conn.close()
|
session.rollback()
|
||||||
# [/DEF:TaskPersistenceService.delete_tasks:Function]
|
logger.error(f"Failed to delete tasks: {e}")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
# [/DEF:delete_tasks:Function]
|
||||||
|
|
||||||
# [/DEF:TaskPersistenceService:Class]
|
# [/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]
|
# [/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]
|
||||||
237
backend/src/core/utils/dataset_mapper.py
Normal file
237
backend/src/core/utils/dataset_mapper.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# [DEF:backend.core.utils.dataset_mapper:Module]
|
||||||
|
#
|
||||||
|
# @SEMANTICS: dataset, mapping, postgresql, xlsx, superset
|
||||||
|
# @PURPOSE: Этот модуль отвечает за обновление метаданных (verbose_map) в датасетах Superset, извлекая их из PostgreSQL или XLSX-файлов.
|
||||||
|
# @LAYER: Domain
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.core.superset_client
|
||||||
|
# @RELATION: DEPENDS_ON -> pandas
|
||||||
|
# @RELATION: DEPENDS_ON -> psycopg2
|
||||||
|
# @PUBLIC_API: DatasetMapper
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
import pandas as pd # type: ignore
|
||||||
|
import psycopg2 # type: ignore
|
||||||
|
from typing import Dict, Optional, Any
|
||||||
|
from ..logger import logger as app_logger, belief_scope
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:DatasetMapper:Class]
|
||||||
|
# @PURPOSE: Класс для меппинга и обновления verbose_map в датасетах Superset.
|
||||||
|
class DatasetMapper:
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the mapper.
|
||||||
|
# @POST: Объект DatasetMapper инициализирован.
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
|
||||||
|
# [DEF:get_postgres_comments:Function]
|
||||||
|
# @PURPOSE: Извлекает комментарии к колонкам из системного каталога PostgreSQL.
|
||||||
|
# @PRE: db_config должен содержать валидные параметры подключения (host, port, user, password, dbname).
|
||||||
|
# @PRE: table_name и table_schema должны быть строками.
|
||||||
|
# @POST: Возвращается словарь, где ключи - имена колонок, значения - комментарии из БД.
|
||||||
|
# @THROW: Exception - При ошибках подключения или выполнения запроса к БД.
|
||||||
|
# @PARAM: db_config (Dict) - Конфигурация для подключения к БД.
|
||||||
|
# @PARAM: table_name (str) - Имя таблицы.
|
||||||
|
# @PARAM: table_schema (str) - Схема таблицы.
|
||||||
|
# @RETURN: Dict[str, str] - Словарь с комментариями к колонкам.
|
||||||
|
def get_postgres_comments(self, db_config: Dict, table_name: str, table_schema: str) -> Dict[str, str]:
|
||||||
|
with belief_scope("Fetch comments from PostgreSQL"):
|
||||||
|
app_logger.info("[get_postgres_comments][Enter] Fetching comments from PostgreSQL for %s.%s.", table_schema, table_name)
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
cols.column_name,
|
||||||
|
CASE
|
||||||
|
WHEN pg_catalog.col_description(
|
||||||
|
(SELECT c.oid
|
||||||
|
FROM pg_catalog.pg_class c
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relname = cols.table_name
|
||||||
|
AND n.nspname = cols.table_schema),
|
||||||
|
cols.ordinal_position::int
|
||||||
|
) LIKE '%|%' THEN
|
||||||
|
split_part(
|
||||||
|
pg_catalog.col_description(
|
||||||
|
(SELECT c.oid
|
||||||
|
FROM pg_catalog.pg_class c
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relname = cols.table_name
|
||||||
|
AND n.nspname = cols.table_schema),
|
||||||
|
cols.ordinal_position::int
|
||||||
|
),
|
||||||
|
'|',
|
||||||
|
1
|
||||||
|
)
|
||||||
|
ELSE
|
||||||
|
pg_catalog.col_description(
|
||||||
|
(SELECT c.oid
|
||||||
|
FROM pg_catalog.pg_class c
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relname = cols.table_name
|
||||||
|
AND n.nspname = cols.table_schema),
|
||||||
|
cols.ordinal_position::int
|
||||||
|
)
|
||||||
|
END AS column_comment
|
||||||
|
FROM
|
||||||
|
information_schema.columns cols
|
||||||
|
WHERE cols.table_catalog = '{db_config.get('dbname')}' AND cols.table_name = '{table_name}' AND cols.table_schema = '{table_schema}';
|
||||||
|
"""
|
||||||
|
comments = {}
|
||||||
|
try:
|
||||||
|
with psycopg2.connect(**db_config) as conn, conn.cursor() as cursor:
|
||||||
|
cursor.execute(query)
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
if row[1]:
|
||||||
|
comments[row[0]] = row[1]
|
||||||
|
app_logger.info("[get_postgres_comments][Success] Fetched %d comments.", len(comments))
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error("[get_postgres_comments][Failure] %s", e, exc_info=True)
|
||||||
|
raise
|
||||||
|
return comments
|
||||||
|
# [/DEF:get_postgres_comments:Function]
|
||||||
|
|
||||||
|
# [DEF:load_excel_mappings:Function]
|
||||||
|
# @PURPOSE: Загружает меппинги 'column_name' -> 'column_comment' из XLSX файла.
|
||||||
|
# @PRE: file_path должен указывать на существующий XLSX файл.
|
||||||
|
# @POST: Возвращается словарь с меппингами из файла.
|
||||||
|
# @THROW: Exception - При ошибках чтения файла или парсинга.
|
||||||
|
# @PARAM: file_path (str) - Путь к XLSX файлу.
|
||||||
|
# @RETURN: Dict[str, str] - Словарь с меппингами.
|
||||||
|
def load_excel_mappings(self, file_path: str) -> Dict[str, str]:
|
||||||
|
with belief_scope("Load mappings from Excel"):
|
||||||
|
app_logger.info("[load_excel_mappings][Enter] Loading mappings from %s.", file_path)
|
||||||
|
try:
|
||||||
|
df = pd.read_excel(file_path)
|
||||||
|
mappings = df.set_index('column_name')['verbose_name'].to_dict()
|
||||||
|
app_logger.info("[load_excel_mappings][Success] Loaded %d mappings.", len(mappings))
|
||||||
|
return mappings
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error("[load_excel_mappings][Failure] %s", e, exc_info=True)
|
||||||
|
raise
|
||||||
|
# [/DEF:load_excel_mappings:Function]
|
||||||
|
|
||||||
|
# [DEF:run_mapping:Function]
|
||||||
|
# @PURPOSE: Основная функция для выполнения меппинга и обновления verbose_map датасета в Superset.
|
||||||
|
# @PRE: superset_client должен быть авторизован.
|
||||||
|
# @PRE: dataset_id должен быть существующим ID в Superset.
|
||||||
|
# @POST: Если найдены изменения, датасет в Superset обновлен через API.
|
||||||
|
# @RELATION: CALLS -> self.get_postgres_comments
|
||||||
|
# @RELATION: CALLS -> self.load_excel_mappings
|
||||||
|
# @RELATION: CALLS -> superset_client.get_dataset
|
||||||
|
# @RELATION: CALLS -> superset_client.update_dataset
|
||||||
|
# @PARAM: superset_client (Any) - Клиент Superset.
|
||||||
|
# @PARAM: dataset_id (int) - ID датасета для обновления.
|
||||||
|
# @PARAM: source (str) - Источник данных ('postgres', 'excel', 'both').
|
||||||
|
# @PARAM: postgres_config (Optional[Dict]) - Конфигурация для подключения к PostgreSQL.
|
||||||
|
# @PARAM: excel_path (Optional[str]) - Путь к XLSX файлу.
|
||||||
|
# @PARAM: table_name (Optional[str]) - Имя таблицы в PostgreSQL.
|
||||||
|
# @PARAM: table_schema (Optional[str]) - Схема таблицы в PostgreSQL.
|
||||||
|
def run_mapping(self, superset_client: Any, dataset_id: int, source: str, postgres_config: Optional[Dict] = None, excel_path: Optional[str] = None, table_name: Optional[str] = None, table_schema: Optional[str] = None):
|
||||||
|
with belief_scope(f"Run dataset mapping for ID {dataset_id}"):
|
||||||
|
app_logger.info("[run_mapping][Enter] Starting dataset mapping for ID %d from source '%s'.", dataset_id, source)
|
||||||
|
mappings: Dict[str, str] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if source in ['postgres', 'both']:
|
||||||
|
assert postgres_config and table_name and table_schema, "Postgres config is required."
|
||||||
|
mappings.update(self.get_postgres_comments(postgres_config, table_name, table_schema))
|
||||||
|
if source in ['excel', 'both']:
|
||||||
|
assert excel_path, "Excel path is required."
|
||||||
|
mappings.update(self.load_excel_mappings(excel_path))
|
||||||
|
if source not in ['postgres', 'excel', 'both']:
|
||||||
|
app_logger.error("[run_mapping][Failure] Invalid source: %s.", source)
|
||||||
|
return
|
||||||
|
|
||||||
|
dataset_response = superset_client.get_dataset(dataset_id)
|
||||||
|
dataset_data = dataset_response['result']
|
||||||
|
|
||||||
|
original_columns = dataset_data.get('columns', [])
|
||||||
|
updated_columns = []
|
||||||
|
changes_made = False
|
||||||
|
|
||||||
|
for column in original_columns:
|
||||||
|
col_name = column.get('column_name')
|
||||||
|
|
||||||
|
new_column = {
|
||||||
|
"column_name": col_name,
|
||||||
|
"id": column.get("id"),
|
||||||
|
"advanced_data_type": column.get("advanced_data_type"),
|
||||||
|
"description": column.get("description"),
|
||||||
|
"expression": column.get("expression"),
|
||||||
|
"extra": column.get("extra"),
|
||||||
|
"filterable": column.get("filterable"),
|
||||||
|
"groupby": column.get("groupby"),
|
||||||
|
"is_active": column.get("is_active"),
|
||||||
|
"is_dttm": column.get("is_dttm"),
|
||||||
|
"python_date_format": column.get("python_date_format"),
|
||||||
|
"type": column.get("type"),
|
||||||
|
"uuid": column.get("uuid"),
|
||||||
|
"verbose_name": column.get("verbose_name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
new_column = {k: v for k, v in new_column.items() if v is not None}
|
||||||
|
|
||||||
|
if col_name in mappings:
|
||||||
|
mapping_value = mappings[col_name]
|
||||||
|
if isinstance(mapping_value, str) and new_column.get('verbose_name') != mapping_value:
|
||||||
|
new_column['verbose_name'] = mapping_value
|
||||||
|
changes_made = True
|
||||||
|
|
||||||
|
updated_columns.append(new_column)
|
||||||
|
|
||||||
|
updated_metrics = []
|
||||||
|
for metric in dataset_data.get("metrics", []):
|
||||||
|
new_metric = {
|
||||||
|
"id": metric.get("id"),
|
||||||
|
"metric_name": metric.get("metric_name"),
|
||||||
|
"expression": metric.get("expression"),
|
||||||
|
"verbose_name": metric.get("verbose_name"),
|
||||||
|
"description": metric.get("description"),
|
||||||
|
"d3format": metric.get("d3format"),
|
||||||
|
"currency": metric.get("currency"),
|
||||||
|
"extra": metric.get("extra"),
|
||||||
|
"warning_text": metric.get("warning_text"),
|
||||||
|
"metric_type": metric.get("metric_type"),
|
||||||
|
"uuid": metric.get("uuid"),
|
||||||
|
}
|
||||||
|
updated_metrics.append({k: v for k, v in new_metric.items() if v is not None})
|
||||||
|
|
||||||
|
if changes_made:
|
||||||
|
payload_for_update = {
|
||||||
|
"database_id": dataset_data.get("database", {}).get("id"),
|
||||||
|
"table_name": dataset_data.get("table_name"),
|
||||||
|
"schema": dataset_data.get("schema"),
|
||||||
|
"columns": updated_columns,
|
||||||
|
"owners": [owner["id"] for owner in dataset_data.get("owners", [])],
|
||||||
|
"metrics": updated_metrics,
|
||||||
|
"extra": dataset_data.get("extra"),
|
||||||
|
"description": dataset_data.get("description"),
|
||||||
|
"sql": dataset_data.get("sql"),
|
||||||
|
"cache_timeout": dataset_data.get("cache_timeout"),
|
||||||
|
"catalog": dataset_data.get("catalog"),
|
||||||
|
"default_endpoint": dataset_data.get("default_endpoint"),
|
||||||
|
"external_url": dataset_data.get("external_url"),
|
||||||
|
"fetch_values_predicate": dataset_data.get("fetch_values_predicate"),
|
||||||
|
"filter_select_enabled": dataset_data.get("filter_select_enabled"),
|
||||||
|
"is_managed_externally": dataset_data.get("is_managed_externally"),
|
||||||
|
"is_sqllab_view": dataset_data.get("is_sqllab_view"),
|
||||||
|
"main_dttm_col": dataset_data.get("main_dttm_col"),
|
||||||
|
"normalize_columns": dataset_data.get("normalize_columns"),
|
||||||
|
"offset": dataset_data.get("offset"),
|
||||||
|
"template_params": dataset_data.get("template_params"),
|
||||||
|
}
|
||||||
|
|
||||||
|
payload_for_update = {k: v for k, v in payload_for_update.items() if v is not None}
|
||||||
|
|
||||||
|
superset_client.update_dataset(dataset_id, payload_for_update)
|
||||||
|
app_logger.info("[run_mapping][Success] Dataset %d columns' verbose_name updated.", dataset_id)
|
||||||
|
else:
|
||||||
|
app_logger.info("[run_mapping][State] No changes in columns' verbose_name, skipping update.")
|
||||||
|
|
||||||
|
except (AssertionError, FileNotFoundError, Exception) as e:
|
||||||
|
app_logger.error("[run_mapping][Failure] %s", e, exc_info=True)
|
||||||
|
return
|
||||||
|
# [/DEF:run_mapping:Function]
|
||||||
|
# [/DEF:DatasetMapper:Class]
|
||||||
|
|
||||||
|
# [/DEF:backend.core.utils.dataset_mapper:Module]
|
||||||
487
backend/src/core/utils/fileio.py
Normal file
487
backend/src/core/utils/fileio.py
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
# [DEF:backend.core.utils.fileio:Module]
|
||||||
|
#
|
||||||
|
# @SEMANTICS: file, io, zip, yaml, temp, archive, utility
|
||||||
|
# @PURPOSE: Предоставляет набор утилит для управления файловыми операциями, включая работу с временными файлами, архивами ZIP, файлами YAML и очистку директорий.
|
||||||
|
# @LAYER: Infra
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.core.logger
|
||||||
|
# @RELATION: DEPENDS_ON -> pyyaml
|
||||||
|
# @PUBLIC_API: create_temp_file, remove_empty_directories, read_dashboard_from_disk, calculate_crc32, RetentionPolicy, archive_exports, save_and_unpack_dashboard, update_yamls, create_dashboard_export, sanitize_filename, get_filename_from_headers, consolidate_archive_folders
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional, Tuple, Dict, List, Union, LiteralString, Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import tempfile
|
||||||
|
from datetime import date, datetime
|
||||||
|
import shutil
|
||||||
|
import zlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from ..logger import logger as app_logger, belief_scope
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:InvalidZipFormatError:Class]
|
||||||
|
# @PURPOSE: Exception raised when a file is not a valid ZIP archive.
|
||||||
|
class InvalidZipFormatError(Exception):
|
||||||
|
pass
|
||||||
|
# [/DEF:InvalidZipFormatError:Class]
|
||||||
|
|
||||||
|
# [DEF:create_temp_file:Function]
|
||||||
|
# @PURPOSE: Контекстный менеджер для создания временного файла или директории с гарантированным удалением.
|
||||||
|
# @PRE: suffix должен быть строкой, определяющей тип ресурса.
|
||||||
|
# @POST: Временный ресурс создан и путь к нему возвращен; ресурс удален после выхода из контекста.
|
||||||
|
# @PARAM: content (Optional[bytes]) - Бинарное содержимое для записи во временный файл.
|
||||||
|
# @PARAM: suffix (str) - Суффикс ресурса. Если `.dir`, создается директория.
|
||||||
|
# @PARAM: mode (str) - Режим записи в файл (e.g., 'wb').
|
||||||
|
# @YIELDS: Path - Путь к временному ресурсу.
|
||||||
|
# @THROW: IOError - При ошибках создания ресурса.
|
||||||
|
@contextmanager
|
||||||
|
def create_temp_file(content: Optional[bytes] = None, suffix: str = ".zip", mode: str = 'wb', dry_run = False) -> Generator[Path, None, None]:
|
||||||
|
with belief_scope("Create temporary resource"):
|
||||||
|
resource_path = None
|
||||||
|
is_dir = suffix.startswith('.dir')
|
||||||
|
try:
|
||||||
|
if is_dir:
|
||||||
|
with tempfile.TemporaryDirectory(suffix=suffix) as temp_dir:
|
||||||
|
resource_path = Path(temp_dir)
|
||||||
|
app_logger.debug("[create_temp_file][State] Created temporary directory: %s", resource_path)
|
||||||
|
yield resource_path
|
||||||
|
else:
|
||||||
|
fd, temp_path_str = tempfile.mkstemp(suffix=suffix)
|
||||||
|
resource_path = Path(temp_path_str)
|
||||||
|
os.close(fd)
|
||||||
|
if content:
|
||||||
|
resource_path.write_bytes(content)
|
||||||
|
app_logger.debug("[create_temp_file][State] Created temporary file: %s", resource_path)
|
||||||
|
yield resource_path
|
||||||
|
finally:
|
||||||
|
if resource_path and resource_path.exists() and not dry_run:
|
||||||
|
try:
|
||||||
|
if resource_path.is_dir():
|
||||||
|
shutil.rmtree(resource_path)
|
||||||
|
app_logger.debug("[create_temp_file][Cleanup] Removed temporary directory: %s", resource_path)
|
||||||
|
else:
|
||||||
|
resource_path.unlink()
|
||||||
|
app_logger.debug("[create_temp_file][Cleanup] Removed temporary file: %s", resource_path)
|
||||||
|
except OSError as e:
|
||||||
|
app_logger.error("[create_temp_file][Failure] Error during cleanup of %s: %s", resource_path, e)
|
||||||
|
# [/DEF:create_temp_file:Function]
|
||||||
|
|
||||||
|
# [DEF:remove_empty_directories:Function]
|
||||||
|
# @PURPOSE: Рекурсивно удаляет все пустые поддиректории, начиная с указанного пути.
|
||||||
|
# @PRE: root_dir должен быть путем к существующей директории.
|
||||||
|
# @POST: Все пустые поддиректории удалены, возвращено их количество.
|
||||||
|
# @PARAM: root_dir (str) - Путь к корневой директории для очистки.
|
||||||
|
# @RETURN: int - Количество удаленных директорий.
|
||||||
|
def remove_empty_directories(root_dir: str) -> int:
|
||||||
|
with belief_scope(f"Remove empty directories in {root_dir}"):
|
||||||
|
app_logger.info("[remove_empty_directories][Enter] Starting cleanup of empty directories in %s", root_dir)
|
||||||
|
removed_count = 0
|
||||||
|
if not os.path.isdir(root_dir):
|
||||||
|
app_logger.error("[remove_empty_directories][Failure] Directory not found: %s", root_dir)
|
||||||
|
return 0
|
||||||
|
for current_dir, _, _ in os.walk(root_dir, topdown=False):
|
||||||
|
if not os.listdir(current_dir):
|
||||||
|
try:
|
||||||
|
os.rmdir(current_dir)
|
||||||
|
removed_count += 1
|
||||||
|
app_logger.info("[remove_empty_directories][State] Removed empty directory: %s", current_dir)
|
||||||
|
except OSError as e:
|
||||||
|
app_logger.error("[remove_empty_directories][Failure] Failed to remove %s: %s", current_dir, e)
|
||||||
|
app_logger.info("[remove_empty_directories][Exit] Removed %d empty directories.", removed_count)
|
||||||
|
return removed_count
|
||||||
|
# [/DEF:remove_empty_directories:Function]
|
||||||
|
|
||||||
|
# [DEF:read_dashboard_from_disk:Function]
|
||||||
|
# @PURPOSE: Читает бинарное содержимое файла с диска.
|
||||||
|
# @PRE: file_path должен указывать на существующий файл.
|
||||||
|
# @POST: Возвращает байты содержимого и имя файла.
|
||||||
|
# @PARAM: file_path (str) - Путь к файлу.
|
||||||
|
# @RETURN: Tuple[bytes, str] - Кортеж (содержимое, имя файла).
|
||||||
|
# @THROW: FileNotFoundError - Если файл не найден.
|
||||||
|
def read_dashboard_from_disk(file_path: str) -> Tuple[bytes, str]:
|
||||||
|
with belief_scope(f"Read dashboard from {file_path}"):
|
||||||
|
path = Path(file_path)
|
||||||
|
assert path.is_file(), f"Файл дашборда не найден: {file_path}"
|
||||||
|
app_logger.info("[read_dashboard_from_disk][Enter] Reading file: %s", file_path)
|
||||||
|
content = path.read_bytes()
|
||||||
|
if not content:
|
||||||
|
app_logger.warning("[read_dashboard_from_disk][Warning] File is empty: %s", file_path)
|
||||||
|
return content, path.name
|
||||||
|
# [/DEF:read_dashboard_from_disk:Function]
|
||||||
|
|
||||||
|
# [DEF:calculate_crc32:Function]
|
||||||
|
# @PURPOSE: Вычисляет контрольную сумму CRC32 для файла.
|
||||||
|
# @PRE: file_path должен быть объектом Path к существующему файлу.
|
||||||
|
# @POST: Возвращает 8-значную hex-строку CRC32.
|
||||||
|
# @PARAM: file_path (Path) - Путь к файлу.
|
||||||
|
# @RETURN: str - 8-значное шестнадцатеричное представление CRC32.
|
||||||
|
# @THROW: IOError - При ошибках чтения файла.
|
||||||
|
def calculate_crc32(file_path: Path) -> str:
|
||||||
|
with belief_scope(f"Calculate CRC32 for {file_path}"):
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
crc32_value = zlib.crc32(f.read())
|
||||||
|
return f"{crc32_value:08x}"
|
||||||
|
# [/DEF:calculate_crc32:Function]
|
||||||
|
|
||||||
|
# [SECTION: DATA_CLASSES]
|
||||||
|
# [DEF:RetentionPolicy:DataClass]
|
||||||
|
# @PURPOSE: Определяет политику хранения для архивов (ежедневные, еженедельные, ежемесячные).
|
||||||
|
@dataclass
|
||||||
|
class RetentionPolicy:
|
||||||
|
daily: int = 7
|
||||||
|
weekly: int = 4
|
||||||
|
monthly: int = 12
|
||||||
|
# [/DEF:RetentionPolicy:DataClass]
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:archive_exports:Function]
|
||||||
|
# @PURPOSE: Управляет архивом экспортированных файлов, применяя политику хранения и дедупликацию.
|
||||||
|
# @PRE: output_dir должен быть путем к существующей директории.
|
||||||
|
# @POST: Старые или дублирующиеся архивы удалены согласно политике.
|
||||||
|
# @RELATION: CALLS -> apply_retention_policy
|
||||||
|
# @RELATION: CALLS -> calculate_crc32
|
||||||
|
# @PARAM: output_dir (str) - Директория с архивами.
|
||||||
|
# @PARAM: policy (RetentionPolicy) - Политика хранения.
|
||||||
|
# @PARAM: deduplicate (bool) - Флаг для включения удаления дубликатов по CRC32.
|
||||||
|
def archive_exports(output_dir: str, policy: RetentionPolicy, deduplicate: bool = False) -> None:
|
||||||
|
with belief_scope(f"Archive exports in {output_dir}"):
|
||||||
|
output_path = Path(output_dir)
|
||||||
|
if not output_path.is_dir():
|
||||||
|
app_logger.warning("[archive_exports][Skip] Archive directory not found: %s", output_dir)
|
||||||
|
return
|
||||||
|
|
||||||
|
app_logger.info("[archive_exports][Enter] Managing archive in %s", output_dir)
|
||||||
|
|
||||||
|
# 1. Collect all zip files
|
||||||
|
zip_files = list(output_path.glob("*.zip"))
|
||||||
|
if not zip_files:
|
||||||
|
app_logger.info("[archive_exports][State] No zip files found in %s", output_dir)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Deduplication
|
||||||
|
if deduplicate:
|
||||||
|
app_logger.info("[archive_exports][State] Starting deduplication...")
|
||||||
|
checksums = {}
|
||||||
|
files_to_remove = []
|
||||||
|
|
||||||
|
# Sort by modification time (newest first) to keep the latest version
|
||||||
|
zip_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
for file_path in zip_files:
|
||||||
|
try:
|
||||||
|
crc = calculate_crc32(file_path)
|
||||||
|
if crc in checksums:
|
||||||
|
files_to_remove.append(file_path)
|
||||||
|
app_logger.debug("[archive_exports][State] Duplicate found: %s (same as %s)", file_path.name, checksums[crc].name)
|
||||||
|
else:
|
||||||
|
checksums[crc] = file_path
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error("[archive_exports][Failure] Failed to calculate CRC32 for %s: %s", file_path, e)
|
||||||
|
|
||||||
|
for f in files_to_remove:
|
||||||
|
try:
|
||||||
|
f.unlink()
|
||||||
|
zip_files.remove(f)
|
||||||
|
app_logger.info("[archive_exports][State] Removed duplicate: %s", f.name)
|
||||||
|
except OSError as e:
|
||||||
|
app_logger.error("[archive_exports][Failure] Failed to remove duplicate %s: %s", f, e)
|
||||||
|
|
||||||
|
# 3. Retention Policy
|
||||||
|
files_with_dates = []
|
||||||
|
for file_path in zip_files:
|
||||||
|
# Try to extract date from filename
|
||||||
|
# Pattern: ..._YYYYMMDD_HHMMSS.zip or ..._YYYYMMDD.zip
|
||||||
|
match = re.search(r'_(\d{8})_', file_path.name)
|
||||||
|
file_date = None
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
date_str = match.group(1)
|
||||||
|
file_date = datetime.strptime(date_str, "%Y%m%d").date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not file_date:
|
||||||
|
# Fallback to modification time
|
||||||
|
file_date = datetime.fromtimestamp(file_path.stat().st_mtime).date()
|
||||||
|
|
||||||
|
files_with_dates.append((file_path, file_date))
|
||||||
|
|
||||||
|
files_to_keep = apply_retention_policy(files_with_dates, policy)
|
||||||
|
|
||||||
|
for file_path, _ in files_with_dates:
|
||||||
|
if file_path not in files_to_keep:
|
||||||
|
try:
|
||||||
|
file_path.unlink()
|
||||||
|
app_logger.info("[archive_exports][State] Removed by retention policy: %s", file_path.name)
|
||||||
|
except OSError as e:
|
||||||
|
app_logger.error("[archive_exports][Failure] Failed to remove %s: %s", file_path, e)
|
||||||
|
# [/DEF:archive_exports:Function]
|
||||||
|
|
||||||
|
# [DEF:apply_retention_policy:Function]
|
||||||
|
# @PURPOSE: (Helper) Применяет политику хранения к списку файлов, возвращая те, что нужно сохранить.
|
||||||
|
# @PRE: files_with_dates is a list of (Path, date) tuples.
|
||||||
|
# @POST: Returns a set of files to keep.
|
||||||
|
# @PARAM: files_with_dates (List[Tuple[Path, date]]) - Список файлов с датами.
|
||||||
|
# @PARAM: policy (RetentionPolicy) - Политика хранения.
|
||||||
|
# @RETURN: set - Множество путей к файлам, которые должны быть сохранены.
|
||||||
|
def apply_retention_policy(files_with_dates: List[Tuple[Path, date]], policy: RetentionPolicy) -> set:
|
||||||
|
with belief_scope("Apply retention policy"):
|
||||||
|
# Сортируем по дате (от новой к старой)
|
||||||
|
sorted_files = sorted(files_with_dates, key=lambda x: x[1], reverse=True)
|
||||||
|
# Словарь для хранения файлов по категориям
|
||||||
|
daily_files = []
|
||||||
|
weekly_files = []
|
||||||
|
monthly_files = []
|
||||||
|
today = date.today()
|
||||||
|
for file_path, file_date in sorted_files:
|
||||||
|
# Ежедневные
|
||||||
|
if (today - file_date).days < policy.daily:
|
||||||
|
daily_files.append(file_path)
|
||||||
|
# Еженедельные
|
||||||
|
elif (today - file_date).days < policy.weekly * 7:
|
||||||
|
weekly_files.append(file_path)
|
||||||
|
# Ежемесячные
|
||||||
|
elif (today - file_date).days < policy.monthly * 30:
|
||||||
|
monthly_files.append(file_path)
|
||||||
|
# Возвращаем множество файлов, которые нужно сохранить
|
||||||
|
files_to_keep = set()
|
||||||
|
files_to_keep.update(daily_files)
|
||||||
|
files_to_keep.update(weekly_files[:policy.weekly])
|
||||||
|
files_to_keep.update(monthly_files[:policy.monthly])
|
||||||
|
app_logger.debug("[apply_retention_policy][State] Keeping %d files according to retention policy", len(files_to_keep))
|
||||||
|
return files_to_keep
|
||||||
|
# [/DEF:apply_retention_policy:Function]
|
||||||
|
|
||||||
|
# [DEF:save_and_unpack_dashboard:Function]
|
||||||
|
# @PURPOSE: Сохраняет бинарное содержимое ZIP-архива на диск и опционально распаковывает его.
|
||||||
|
# @PRE: zip_content должен быть байтами валидного ZIP-архива.
|
||||||
|
# @POST: ZIP-файл сохранен, и если unpack=True, он распакован в output_dir.
|
||||||
|
# @PARAM: zip_content (bytes) - Содержимое ZIP-архива.
|
||||||
|
# @PARAM: output_dir (Union[str, Path]) - Директория для сохранения.
|
||||||
|
# @PARAM: unpack (bool) - Флаг, нужно ли распаковывать архив.
|
||||||
|
# @PARAM: original_filename (Optional[str]) - Исходное имя файла для сохранения.
|
||||||
|
# @RETURN: Tuple[Path, Optional[Path]] - Путь к ZIP-файлу и, если применимо, путь к директории с распаковкой.
|
||||||
|
# @THROW: InvalidZipFormatError - При ошибке формата ZIP.
|
||||||
|
def save_and_unpack_dashboard(zip_content: bytes, output_dir: Union[str, Path], unpack: bool = False, original_filename: Optional[str] = None) -> Tuple[Path, Optional[Path]]:
|
||||||
|
with belief_scope("Save and unpack dashboard"):
|
||||||
|
app_logger.info("[save_and_unpack_dashboard][Enter] Processing dashboard. Unpack: %s", unpack)
|
||||||
|
try:
|
||||||
|
output_path = Path(output_dir)
|
||||||
|
output_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
zip_name = sanitize_filename(original_filename) if original_filename else f"dashboard_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
|
||||||
|
zip_path = output_path / zip_name
|
||||||
|
zip_path.write_bytes(zip_content)
|
||||||
|
app_logger.info("[save_and_unpack_dashboard][State] Dashboard saved to: %s", zip_path)
|
||||||
|
if unpack:
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(output_path)
|
||||||
|
app_logger.info("[save_and_unpack_dashboard][State] Dashboard unpacked to: %s", output_path)
|
||||||
|
return zip_path, output_path
|
||||||
|
return zip_path, None
|
||||||
|
except zipfile.BadZipFile as e:
|
||||||
|
app_logger.error("[save_and_unpack_dashboard][Failure] Invalid ZIP archive: %s", e)
|
||||||
|
raise InvalidZipFormatError(f"Invalid ZIP file: {e}") from e
|
||||||
|
# [/DEF:save_and_unpack_dashboard:Function]
|
||||||
|
|
||||||
|
# [DEF:update_yamls:Function]
|
||||||
|
# @PURPOSE: Обновляет конфигурации в YAML-файлах, заменяя значения или применяя regex.
|
||||||
|
# @PRE: path должен быть существующей директорией.
|
||||||
|
# @POST: Все YAML файлы в директории обновлены согласно переданным параметрам.
|
||||||
|
# @RELATION: CALLS -> _update_yaml_file
|
||||||
|
# @THROW: FileNotFoundError - Если `path` не существует.
|
||||||
|
# @PARAM: db_configs (Optional[List[Dict]]) - Список конфигураций для замены.
|
||||||
|
# @PARAM: path (str) - Путь к директории с YAML файлами.
|
||||||
|
# @PARAM: regexp_pattern (Optional[LiteralString]) - Паттерн для поиска.
|
||||||
|
# @PARAM: replace_string (Optional[LiteralString]) - Строка для замены.
|
||||||
|
def update_yamls(db_configs: Optional[List[Dict[str, Any]]] = None, path: str = "dashboards", regexp_pattern: Optional[LiteralString] = None, replace_string: Optional[LiteralString] = None) -> None:
|
||||||
|
with belief_scope("Update YAML configurations"):
|
||||||
|
app_logger.info("[update_yamls][Enter] Starting YAML configuration update.")
|
||||||
|
dir_path = Path(path)
|
||||||
|
assert dir_path.is_dir(), f"Путь {path} не существует или не является директорией"
|
||||||
|
|
||||||
|
configs: List[Dict[str, Any]] = db_configs or []
|
||||||
|
|
||||||
|
for file_path in dir_path.rglob("*.yaml"):
|
||||||
|
_update_yaml_file(file_path, configs, regexp_pattern, replace_string)
|
||||||
|
# [/DEF:update_yamls:Function]
|
||||||
|
|
||||||
|
# [DEF:_update_yaml_file:Function]
|
||||||
|
# @PURPOSE: (Helper) Обновляет один YAML файл.
|
||||||
|
# @PRE: file_path должен быть объектом Path к существующему YAML файлу.
|
||||||
|
# @POST: Файл обновлен согласно переданным конфигурациям или регулярному выражению.
|
||||||
|
# @PARAM: file_path (Path) - Путь к файлу.
|
||||||
|
# @PARAM: db_configs (List[Dict]) - Конфигурации.
|
||||||
|
# @PARAM: regexp_pattern (Optional[str]) - Паттерн.
|
||||||
|
# @PARAM: replace_string (Optional[str]) - Замена.
|
||||||
|
def _update_yaml_file(file_path: Path, db_configs: List[Dict[str, Any]], regexp_pattern: Optional[str], replace_string: Optional[str]) -> None:
|
||||||
|
with belief_scope(f"Update YAML file: {file_path}"):
|
||||||
|
# Читаем содержимое файла
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error("[_update_yaml_file][Failure] Failed to read %s: %s", file_path, e)
|
||||||
|
return
|
||||||
|
# Если задан pattern и replace_string, применяем замену по регулярному выражению
|
||||||
|
if regexp_pattern and replace_string:
|
||||||
|
try:
|
||||||
|
new_content = re.sub(regexp_pattern, replace_string, content)
|
||||||
|
if new_content != content:
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
app_logger.info("[_update_yaml_file][State] Updated %s using regex pattern", file_path)
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error("[_update_yaml_file][Failure] Error applying regex to %s: %s", file_path, e)
|
||||||
|
# Если заданы конфигурации, заменяем значения (поддержка old/new)
|
||||||
|
if db_configs:
|
||||||
|
try:
|
||||||
|
# Прямой текстовый заменитель для старых/новых значений, чтобы сохранить структуру файла
|
||||||
|
modified_content = content
|
||||||
|
for cfg in db_configs:
|
||||||
|
# Ожидаем структуру: {'old': {...}, 'new': {...}}
|
||||||
|
old_cfg = cfg.get('old', {})
|
||||||
|
new_cfg = cfg.get('new', {})
|
||||||
|
for key, old_val in old_cfg.items():
|
||||||
|
if key in new_cfg:
|
||||||
|
new_val = new_cfg[key]
|
||||||
|
# Заменяем только точные совпадения старого значения в тексте YAML, используя ключ для контекста
|
||||||
|
if isinstance(old_val, str):
|
||||||
|
# Ищем паттерн: key: "value" или key: value
|
||||||
|
key_pattern = re.escape(key)
|
||||||
|
val_pattern = re.escape(old_val)
|
||||||
|
# Группы: 1=ключ+разделитель, 2=открывающая кавычка (опц), 3=значение, 4=закрывающая кавычка (опц)
|
||||||
|
pattern = rf'({key_pattern}\s*:\s*)(["\']?)({val_pattern})(["\']?)'
|
||||||
|
|
||||||
|
# [DEF:replacer:Function]
|
||||||
|
# @PURPOSE: Функция замены, сохраняющая кавычки если они были.
|
||||||
|
# @PRE: match должен быть объектом совпадения регулярного выражения.
|
||||||
|
# @POST: Возвращает строку с новым значением, сохраняя префикс и кавычки.
|
||||||
|
def replacer(match):
|
||||||
|
prefix = match.group(1)
|
||||||
|
quote_open = match.group(2)
|
||||||
|
quote_close = match.group(4)
|
||||||
|
return f"{prefix}{quote_open}{new_val}{quote_close}"
|
||||||
|
# [/DEF:replacer:Function]
|
||||||
|
|
||||||
|
modified_content = re.sub(pattern, replacer, modified_content)
|
||||||
|
app_logger.info("[_update_yaml_file][State] Replaced '%s' with '%s' for key %s in %s", old_val, new_val, key, file_path)
|
||||||
|
# Записываем обратно изменённый контент без парсинга YAML, сохраняем оригинальное форматирование
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(modified_content)
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error("[_update_yaml_file][Failure] Error performing raw replacement in %s: %s", file_path, e)
|
||||||
|
# [/DEF:_update_yaml_file:Function]
|
||||||
|
|
||||||
|
# [DEF:create_dashboard_export:Function]
|
||||||
|
# @PURPOSE: Создает ZIP-архив из указанных исходных путей.
|
||||||
|
# @PRE: source_paths должен содержать существующие пути.
|
||||||
|
# @POST: ZIP-архив создан по пути zip_path.
|
||||||
|
# @PARAM: zip_path (Union[str, Path]) - Путь для сохранения ZIP архива.
|
||||||
|
# @PARAM: source_paths (List[Union[str, Path]]) - Список исходных путей для архивации.
|
||||||
|
# @PARAM: exclude_extensions (Optional[List[str]]) - Список расширений для исключения.
|
||||||
|
# @RETURN: bool - `True` при успехе, `False` при ошибке.
|
||||||
|
def create_dashboard_export(zip_path: Union[str, Path], source_paths: List[Union[str, Path]], exclude_extensions: Optional[List[str]] = None) -> bool:
|
||||||
|
with belief_scope(f"Create dashboard export: {zip_path}"):
|
||||||
|
app_logger.info("[create_dashboard_export][Enter] Packing dashboard: %s -> %s", source_paths, zip_path)
|
||||||
|
try:
|
||||||
|
exclude_ext = [ext.lower() for ext in exclude_extensions or []]
|
||||||
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||||
|
for src_path_str in source_paths:
|
||||||
|
src_path = Path(src_path_str)
|
||||||
|
assert src_path.exists(), f"Путь не найден: {src_path}"
|
||||||
|
for item in src_path.rglob('*'):
|
||||||
|
if item.is_file() and item.suffix.lower() not in exclude_ext:
|
||||||
|
arcname = item.relative_to(src_path.parent)
|
||||||
|
zipf.write(item, arcname)
|
||||||
|
app_logger.info("[create_dashboard_export][Exit] Archive created: %s", zip_path)
|
||||||
|
return True
|
||||||
|
except (IOError, zipfile.BadZipFile, AssertionError) as e:
|
||||||
|
app_logger.error("[create_dashboard_export][Failure] Error: %s", e, exc_info=True)
|
||||||
|
return False
|
||||||
|
# [/DEF:create_dashboard_export:Function]
|
||||||
|
|
||||||
|
# [DEF:sanitize_filename:Function]
|
||||||
|
# @PURPOSE: Очищает строку от символов, недопустимых в именах файлов.
|
||||||
|
# @PRE: filename должен быть строкой.
|
||||||
|
# @POST: Возвращает строку без спецсимволов.
|
||||||
|
# @PARAM: filename (str) - Исходное имя файла.
|
||||||
|
# @RETURN: str - Очищенная строка.
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
with belief_scope(f"Sanitize filename: {filename}"):
|
||||||
|
return re.sub(r'[\\/*?:"<>|]', "_", filename).strip()
|
||||||
|
# [/DEF:sanitize_filename:Function]
|
||||||
|
|
||||||
|
# [DEF:get_filename_from_headers:Function]
|
||||||
|
# @PURPOSE: Извлекает имя файла из HTTP заголовка 'Content-Disposition'.
|
||||||
|
# @PRE: headers должен быть словарем заголовков.
|
||||||
|
# @POST: Возвращает имя файла или None, если заголовок отсутствует.
|
||||||
|
# @PARAM: headers (dict) - Словарь HTTP заголовков.
|
||||||
|
# @RETURN: Optional[str] - Имя файла or `None`.
|
||||||
|
def get_filename_from_headers(headers: dict) -> Optional[str]:
|
||||||
|
with belief_scope("Get filename from headers"):
|
||||||
|
content_disposition = headers.get("Content-Disposition", "")
|
||||||
|
if match := re.search(r'filename="?([^"]+)"?', content_disposition):
|
||||||
|
return match.group(1).strip()
|
||||||
|
return None
|
||||||
|
# [/DEF:get_filename_from_headers:Function]
|
||||||
|
|
||||||
|
# [DEF:consolidate_archive_folders:Function]
|
||||||
|
# @PURPOSE: Консолидирует директории архивов на основе общего слага в имени.
|
||||||
|
# @PRE: root_directory должен быть объектом Path к существующей директории.
|
||||||
|
# @POST: Директории с одинаковым префиксом объединены в одну.
|
||||||
|
# @THROW: TypeError, ValueError - Если `root_directory` невалиден.
|
||||||
|
# @PARAM: root_directory (Path) - Корневая директория для консолидации.
|
||||||
|
def consolidate_archive_folders(root_directory: Path) -> None:
|
||||||
|
with belief_scope(f"Consolidate archives in {root_directory}"):
|
||||||
|
assert isinstance(root_directory, Path), "root_directory must be a Path object."
|
||||||
|
assert root_directory.is_dir(), "root_directory must be an existing directory."
|
||||||
|
|
||||||
|
app_logger.info("[consolidate_archive_folders][Enter] Consolidating archives in %s", root_directory)
|
||||||
|
# Собираем все директории с архивами
|
||||||
|
archive_dirs = []
|
||||||
|
for item in root_directory.iterdir():
|
||||||
|
if item.is_dir():
|
||||||
|
# Проверяем, есть ли в директории ZIP-архивы
|
||||||
|
if any(item.glob("*.zip")):
|
||||||
|
archive_dirs.append(item)
|
||||||
|
# Группируем по слагу (части имени до первого '_')
|
||||||
|
slug_groups = {}
|
||||||
|
for dir_path in archive_dirs:
|
||||||
|
dir_name = dir_path.name
|
||||||
|
slug = dir_name.split('_')[0] if '_' in dir_name else dir_name
|
||||||
|
if slug not in slug_groups:
|
||||||
|
slug_groups[slug] = []
|
||||||
|
slug_groups[slug].append(dir_path)
|
||||||
|
# Для каждой группы консолидируем
|
||||||
|
for slug, dirs in slug_groups.items():
|
||||||
|
if len(dirs) <= 1:
|
||||||
|
continue
|
||||||
|
# Создаем целевую директорию
|
||||||
|
target_dir = root_directory / slug
|
||||||
|
target_dir.mkdir(exist_ok=True)
|
||||||
|
app_logger.info("[consolidate_archive_folders][State] Consolidating %d directories under %s", len(dirs), target_dir)
|
||||||
|
# Перемещаем содержимое
|
||||||
|
for source_dir in dirs:
|
||||||
|
if source_dir == target_dir:
|
||||||
|
continue
|
||||||
|
for item in source_dir.iterdir():
|
||||||
|
dest_item = target_dir / item.name
|
||||||
|
try:
|
||||||
|
if item.is_dir():
|
||||||
|
shutil.move(str(item), str(dest_item))
|
||||||
|
else:
|
||||||
|
shutil.move(str(item), str(dest_item))
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error("[consolidate_archive_folders][Failure] Failed to move %s to %s: %s", item, dest_item, e)
|
||||||
|
# Удаляем исходную директорию
|
||||||
|
try:
|
||||||
|
source_dir.rmdir()
|
||||||
|
app_logger.info("[consolidate_archive_folders][State] Removed source directory: %s", source_dir)
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error("[consolidate_archive_folders][Failure] Failed to remove source directory %s: %s", source_dir, e)
|
||||||
|
# [/DEF:consolidate_archive_folders:Function]
|
||||||
|
|
||||||
|
# [/DEF:backend.core.utils.fileio:Module]
|
||||||
@@ -48,6 +48,6 @@ def suggest_mappings(source_databases: List[Dict], target_databases: List[Dict],
|
|||||||
})
|
})
|
||||||
|
|
||||||
return suggestions
|
return suggestions
|
||||||
# [/DEF:suggest_mappings]
|
# [/DEF:suggest_mappings:Function]
|
||||||
|
|
||||||
# [/DEF:backend.src.core.utils.matching]
|
# [/DEF:backend.src.core.utils.matching:Module]
|
||||||
|
|||||||
350
backend/src/core/utils/network.py
Normal file
350
backend/src/core/utils/network.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# [DEF:backend.core.utils.network:Module]
|
||||||
|
#
|
||||||
|
# @SEMANTICS: network, http, client, api, requests, session, authentication
|
||||||
|
# @PURPOSE: Инкапсулирует низкоуровневую HTTP-логику для взаимодействия с Superset API, включая аутентификацию, управление сессией, retry-логику и обработку ошибок.
|
||||||
|
# @LAYER: Infra
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.core.logger
|
||||||
|
# @RELATION: DEPENDS_ON -> requests
|
||||||
|
# @PUBLIC_API: APIClient
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from typing import Optional, Dict, Any, List, Union, cast
|
||||||
|
import json
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
import urllib3
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
from ..logger import logger as app_logger, belief_scope
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:SupersetAPIError:Class]
|
||||||
|
# @PURPOSE: Base exception for all Superset API related errors.
|
||||||
|
class SupersetAPIError(Exception):
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the exception with a message and context.
|
||||||
|
# @PRE: message is a string, context is a dict.
|
||||||
|
# @POST: Exception is initialized with context.
|
||||||
|
def __init__(self, message: str = "Superset API error", **context: Any):
|
||||||
|
with belief_scope("SupersetAPIError.__init__"):
|
||||||
|
self.context = context
|
||||||
|
super().__init__(f"[API_FAILURE] {message} | Context: {self.context}")
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
# [/DEF:SupersetAPIError:Class]
|
||||||
|
|
||||||
|
# [DEF:AuthenticationError:Class]
|
||||||
|
# @PURPOSE: Exception raised when authentication fails.
|
||||||
|
class AuthenticationError(SupersetAPIError):
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the authentication error.
|
||||||
|
# @PRE: message is a string, context is a dict.
|
||||||
|
# @POST: AuthenticationError is initialized.
|
||||||
|
def __init__(self, message: str = "Authentication failed", **context: Any):
|
||||||
|
with belief_scope("AuthenticationError.__init__"):
|
||||||
|
super().__init__(message, type="authentication", **context)
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
# [/DEF:AuthenticationError:Class]
|
||||||
|
|
||||||
|
# [DEF:PermissionDeniedError:Class]
|
||||||
|
# @PURPOSE: Exception raised when access is denied.
|
||||||
|
class PermissionDeniedError(AuthenticationError):
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the permission denied error.
|
||||||
|
# @PRE: message is a string, context is a dict.
|
||||||
|
# @POST: PermissionDeniedError is initialized.
|
||||||
|
def __init__(self, message: str = "Permission denied", **context: Any):
|
||||||
|
with belief_scope("PermissionDeniedError.__init__"):
|
||||||
|
super().__init__(message, **context)
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
# [/DEF:PermissionDeniedError:Class]
|
||||||
|
|
||||||
|
# [DEF:DashboardNotFoundError:Class]
|
||||||
|
# @PURPOSE: Exception raised when a dashboard cannot be found.
|
||||||
|
class DashboardNotFoundError(SupersetAPIError):
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the not found error with resource ID.
|
||||||
|
# @PRE: resource_id is provided.
|
||||||
|
# @POST: DashboardNotFoundError is initialized.
|
||||||
|
def __init__(self, resource_id: Union[int, str], message: str = "Dashboard not found", **context: Any):
|
||||||
|
with belief_scope("DashboardNotFoundError.__init__"):
|
||||||
|
super().__init__(f"Dashboard '{resource_id}' {message}", subtype="not_found", resource_id=resource_id, **context)
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
# [/DEF:DashboardNotFoundError:Class]
|
||||||
|
|
||||||
|
# [DEF:NetworkError:Class]
|
||||||
|
# @PURPOSE: Exception raised when a network level error occurs.
|
||||||
|
class NetworkError(Exception):
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the network error.
|
||||||
|
# @PRE: message is a string.
|
||||||
|
# @POST: NetworkError is initialized.
|
||||||
|
def __init__(self, message: str = "Network connection failed", **context: Any):
|
||||||
|
with belief_scope("NetworkError.__init__"):
|
||||||
|
self.context = context
|
||||||
|
super().__init__(f"[NETWORK_FAILURE] {message} | Context: {self.context}")
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
# [/DEF:NetworkError:Class]
|
||||||
|
|
||||||
|
# [DEF:APIClient:Class]
|
||||||
|
# @PURPOSE: Инкапсулирует HTTP-логику для работы с API, включая сессии, аутентификацию, и обработку запросов.
|
||||||
|
class APIClient:
|
||||||
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Инициализирует API клиент с конфигурацией, сессией и логгером.
|
||||||
|
# @PARAM: config (Dict[str, Any]) - Конфигурация.
|
||||||
|
# @PARAM: verify_ssl (bool) - Проверять ли SSL.
|
||||||
|
# @PARAM: timeout (int) - Таймаут запросов.
|
||||||
|
# @PRE: config must contain 'base_url' and 'auth'.
|
||||||
|
# @POST: APIClient instance is initialized with a session.
|
||||||
|
def __init__(self, config: Dict[str, Any], verify_ssl: bool = True, timeout: int = DEFAULT_TIMEOUT):
|
||||||
|
with belief_scope("__init__"):
|
||||||
|
app_logger.info("[APIClient.__init__][Entry] Initializing APIClient.")
|
||||||
|
self.base_url: str = config.get("base_url", "")
|
||||||
|
self.auth = config.get("auth")
|
||||||
|
self.request_settings = {"verify_ssl": verify_ssl, "timeout": timeout}
|
||||||
|
self.session = self._init_session()
|
||||||
|
self._tokens: Dict[str, str] = {}
|
||||||
|
self._authenticated = False
|
||||||
|
app_logger.info("[APIClient.__init__][Exit] APIClient initialized.")
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
|
||||||
|
# [DEF:_init_session:Function]
|
||||||
|
# @PURPOSE: Создает и настраивает `requests.Session` с retry-логикой.
|
||||||
|
# @PRE: self.request_settings must be initialized.
|
||||||
|
# @POST: Returns a configured requests.Session instance.
|
||||||
|
# @RETURN: requests.Session - Настроенная сессия.
|
||||||
|
def _init_session(self) -> requests.Session:
|
||||||
|
with belief_scope("_init_session"):
|
||||||
|
session = requests.Session()
|
||||||
|
retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
|
||||||
|
adapter = HTTPAdapter(max_retries=retries)
|
||||||
|
session.mount('http://', adapter)
|
||||||
|
session.mount('https://', adapter)
|
||||||
|
if not self.request_settings["verify_ssl"]:
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
app_logger.warning("[_init_session][State] SSL verification disabled.")
|
||||||
|
session.verify = self.request_settings["verify_ssl"]
|
||||||
|
return session
|
||||||
|
# [/DEF:_init_session:Function]
|
||||||
|
|
||||||
|
# [DEF:authenticate:Function]
|
||||||
|
# @PURPOSE: Выполняет аутентификацию в Superset API и получает access и CSRF токены.
|
||||||
|
# @PRE: self.auth and self.base_url must be valid.
|
||||||
|
# @POST: `self._tokens` заполнен, `self._authenticated` установлен в `True`.
|
||||||
|
# @RETURN: Dict[str, str] - Словарь с токенами.
|
||||||
|
# @THROW: AuthenticationError, NetworkError - при ошибках.
|
||||||
|
def authenticate(self) -> Dict[str, str]:
|
||||||
|
with belief_scope("authenticate"):
|
||||||
|
app_logger.info("[authenticate][Enter] Authenticating to %s", self.base_url)
|
||||||
|
try:
|
||||||
|
login_url = f"{self.base_url}/security/login"
|
||||||
|
# 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"]
|
||||||
|
|
||||||
|
csrf_url = f"{self.base_url}/security/csrf_token/"
|
||||||
|
csrf_response = self.session.get(csrf_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=self.request_settings["timeout"])
|
||||||
|
csrf_response.raise_for_status()
|
||||||
|
|
||||||
|
self._tokens = {"access_token": access_token, "csrf_token": csrf_response.json()["result"]}
|
||||||
|
self._authenticated = True
|
||||||
|
app_logger.info("[authenticate][Exit] Authenticated successfully.")
|
||||||
|
return self._tokens
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
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
|
||||||
|
# [/DEF:authenticate:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:headers:Function]
|
||||||
|
# @PURPOSE: Возвращает HTTP-заголовки для аутентифицированных запросов.
|
||||||
|
# @PRE: APIClient is initialized and authenticated or can be authenticated.
|
||||||
|
# @POST: Returns headers including auth tokens.
|
||||||
|
def headers(self) -> Dict[str, str]:
|
||||||
|
with belief_scope("headers"):
|
||||||
|
if not self._authenticated:
|
||||||
|
self.authenticate()
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self._tokens['access_token']}",
|
||||||
|
"X-CSRFToken": self._tokens.get("csrf_token", ""),
|
||||||
|
"Referer": self.base_url,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
# [/DEF:headers:Function]
|
||||||
|
|
||||||
|
# [DEF:request:Function]
|
||||||
|
# @PURPOSE: Выполняет универсальный HTTP-запрос к API.
|
||||||
|
# @PARAM: method (str) - HTTP метод.
|
||||||
|
# @PARAM: endpoint (str) - API эндпоинт.
|
||||||
|
# @PARAM: headers (Optional[Dict]) - Дополнительные заголовки.
|
||||||
|
# @PARAM: raw_response (bool) - Возвращать ли сырой ответ.
|
||||||
|
# @PRE: method and endpoint must be strings.
|
||||||
|
# @POST: Returns response content or raw Response object.
|
||||||
|
# @RETURN: `requests.Response` если `raw_response=True`, иначе `dict`.
|
||||||
|
# @THROW: SupersetAPIError, NetworkError и их подклассы.
|
||||||
|
def request(self, method: str, endpoint: str, headers: Optional[Dict] = None, raw_response: bool = False, **kwargs) -> Union[requests.Response, Dict[str, Any]]:
|
||||||
|
with belief_scope("request"):
|
||||||
|
full_url = f"{self.base_url}{endpoint}"
|
||||||
|
_headers = self.headers.copy()
|
||||||
|
if headers:
|
||||||
|
_headers.update(headers)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.request(method, full_url, headers=_headers, **kwargs)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response if raw_response else response.json()
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
self._handle_http_error(e, endpoint)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
self._handle_network_error(e, full_url)
|
||||||
|
# [/DEF:request:Function]
|
||||||
|
|
||||||
|
# [DEF:_handle_http_error:Function]
|
||||||
|
# @PURPOSE: (Helper) Преобразует HTTP ошибки в кастомные исключения.
|
||||||
|
# @PARAM: e (requests.exceptions.HTTPError) - Ошибка.
|
||||||
|
# @PARAM: endpoint (str) - Эндпоинт.
|
||||||
|
# @PRE: e must be a valid HTTPError with a response.
|
||||||
|
# @POST: Raises a specific SupersetAPIError or subclass.
|
||||||
|
def _handle_http_error(self, e: requests.exceptions.HTTPError, endpoint: str):
|
||||||
|
with belief_scope("_handle_http_error"):
|
||||||
|
status_code = e.response.status_code
|
||||||
|
if status_code == 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]
|
||||||
|
|
||||||
|
# [DEF:_handle_network_error:Function]
|
||||||
|
# @PURPOSE: (Helper) Преобразует сетевые ошибки в `NetworkError`.
|
||||||
|
# @PARAM: e (requests.exceptions.RequestException) - Ошибка.
|
||||||
|
# @PARAM: url (str) - URL.
|
||||||
|
# @PRE: e must be a RequestException.
|
||||||
|
# @POST: Raises a NetworkError.
|
||||||
|
def _handle_network_error(self, e: requests.exceptions.RequestException, url: str):
|
||||||
|
with belief_scope("_handle_network_error"):
|
||||||
|
if isinstance(e, requests.exceptions.Timeout):
|
||||||
|
msg = "Request timeout"
|
||||||
|
elif isinstance(e, requests.exceptions.ConnectionError):
|
||||||
|
msg = "Connection error"
|
||||||
|
else:
|
||||||
|
msg = f"Unknown network error: {e}"
|
||||||
|
raise NetworkError(msg, url=url) from e
|
||||||
|
# [/DEF:_handle_network_error:Function]
|
||||||
|
|
||||||
|
# [DEF:upload_file:Function]
|
||||||
|
# @PURPOSE: Загружает файл на сервер через multipart/form-data.
|
||||||
|
# @PARAM: endpoint (str) - Эндпоинт.
|
||||||
|
# @PARAM: file_info (Dict[str, Any]) - Информация о файле.
|
||||||
|
# @PARAM: extra_data (Optional[Dict]) - Дополнительные данные.
|
||||||
|
# @PARAM: timeout (Optional[int]) - Таймаут.
|
||||||
|
# @PRE: file_info must contain 'file_obj' and 'file_name'.
|
||||||
|
# @POST: File is uploaded and response returned.
|
||||||
|
# @RETURN: Ответ API в виде словаря.
|
||||||
|
# @THROW: SupersetAPIError, NetworkError, TypeError.
|
||||||
|
def upload_file(self, endpoint: str, file_info: Dict[str, Any], extra_data: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict:
|
||||||
|
with belief_scope("upload_file"):
|
||||||
|
full_url = f"{self.base_url}{endpoint}"
|
||||||
|
_headers = self.headers.copy()
|
||||||
|
_headers.pop('Content-Type', None)
|
||||||
|
|
||||||
|
|
||||||
|
file_obj, file_name, form_field = file_info.get("file_obj"), file_info.get("file_name"), file_info.get("form_field", "file")
|
||||||
|
|
||||||
|
files_payload = {}
|
||||||
|
if isinstance(file_obj, (str, Path)):
|
||||||
|
with open(file_obj, 'rb') as f:
|
||||||
|
files_payload = {form_field: (file_name, f.read(), 'application/x-zip-compressed')}
|
||||||
|
elif isinstance(file_obj, io.BytesIO):
|
||||||
|
files_payload = {form_field: (file_name, file_obj.getvalue(), 'application/x-zip-compressed')}
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Unsupported file_obj type: {type(file_obj)}")
|
||||||
|
|
||||||
|
return self._perform_upload(full_url, files_payload, extra_data, _headers, timeout)
|
||||||
|
# [/DEF:upload_file:Function]
|
||||||
|
|
||||||
|
# [DEF:_perform_upload:Function]
|
||||||
|
# @PURPOSE: (Helper) Выполняет POST запрос с файлом.
|
||||||
|
# @PARAM: url (str) - URL.
|
||||||
|
# @PARAM: files (Dict) - Файлы.
|
||||||
|
# @PARAM: data (Optional[Dict]) - Данные.
|
||||||
|
# @PARAM: headers (Dict) - Заголовки.
|
||||||
|
# @PARAM: timeout (Optional[int]) - Таймаут.
|
||||||
|
# @PRE: url, files, and headers must be provided.
|
||||||
|
# @POST: POST request is performed and JSON response returned.
|
||||||
|
# @RETURN: Dict - Ответ.
|
||||||
|
def _perform_upload(self, url: str, files: Dict, data: Optional[Dict], headers: Dict, timeout: Optional[int]) -> Dict:
|
||||||
|
with belief_scope("_perform_upload"):
|
||||||
|
try:
|
||||||
|
response = self.session.post(url, files=files, data=data or {}, headers=headers, timeout=timeout or self.request_settings["timeout"])
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.status_code == 200:
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except Exception as json_e:
|
||||||
|
app_logger.debug(f"[_perform_upload][Debug] Response is not valid JSON: {response.text[:200]}...")
|
||||||
|
raise SupersetAPIError(f"API error during upload: Response is not valid JSON: {json_e}") from json_e
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
raise SupersetAPIError(f"API error during upload: {e.response.text}") from e
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise NetworkError(f"Network error during upload: {e}", url=url) from e
|
||||||
|
# [/DEF:_perform_upload:Function]
|
||||||
|
|
||||||
|
# [DEF:fetch_paginated_count:Function]
|
||||||
|
# @PURPOSE: Получает общее количество элементов для пагинации.
|
||||||
|
# @PARAM: endpoint (str) - Эндпоинт.
|
||||||
|
# @PARAM: query_params (Dict) - Параметры запроса.
|
||||||
|
# @PARAM: count_field (str) - Поле с количеством.
|
||||||
|
# @PRE: query_params must be a dictionary.
|
||||||
|
# @POST: Returns total count of items.
|
||||||
|
# @RETURN: int - Количество.
|
||||||
|
def fetch_paginated_count(self, endpoint: str, query_params: Dict, count_field: str = "count") -> int:
|
||||||
|
with belief_scope("fetch_paginated_count"):
|
||||||
|
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query_params)}))
|
||||||
|
return response_json.get(count_field, 0)
|
||||||
|
# [/DEF:fetch_paginated_count:Function]
|
||||||
|
|
||||||
|
# [DEF:fetch_paginated_data:Function]
|
||||||
|
# @PURPOSE: Автоматически собирает данные со всех страниц пагинированного эндпоинта.
|
||||||
|
# @PARAM: endpoint (str) - Эндпоинт.
|
||||||
|
# @PARAM: pagination_options (Dict[str, Any]) - Опции пагинации.
|
||||||
|
# @PRE: pagination_options must contain 'base_query', 'total_count', 'results_field'.
|
||||||
|
# @POST: Returns all items across all pages.
|
||||||
|
# @RETURN: List[Any] - Список данных.
|
||||||
|
def fetch_paginated_data(self, endpoint: str, pagination_options: Dict[str, Any]) -> List[Any]:
|
||||||
|
with belief_scope("fetch_paginated_data"):
|
||||||
|
base_query, total_count = pagination_options["base_query"], pagination_options["total_count"]
|
||||||
|
results_field, page_size = pagination_options["results_field"], base_query.get('page_size')
|
||||||
|
assert page_size and page_size > 0, "'page_size' must be a positive number."
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for page in range((total_count + page_size - 1) // page_size):
|
||||||
|
query = {**base_query, 'page': page}
|
||||||
|
response_json = cast(Dict[str, Any], self.request("GET", endpoint, params={"q": json.dumps(query)}))
|
||||||
|
results.extend(response_json.get(results_field, []))
|
||||||
|
return results
|
||||||
|
# [/DEF:fetch_paginated_data:Function]
|
||||||
|
|
||||||
|
# [/DEF:APIClient:Class]
|
||||||
|
|
||||||
|
# [/DEF:backend.core.utils.network:Module]
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
# [DEF:Dependencies:Module]
|
# [DEF:Dependencies:Module]
|
||||||
# @SEMANTICS: dependency, injection, singleton, factory
|
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt
|
||||||
# @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
|
# @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
|
||||||
# @LAYER: Core
|
# @LAYER: Core
|
||||||
# @RELATION: Used by the main app and API routers to get access to shared instances.
|
# @RELATION: Used by the main app and API routers to get access to shared instances.
|
||||||
|
|
||||||
from pathlib import Path
|
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.plugin_loader import PluginLoader
|
||||||
from .core.task_manager import TaskManager
|
from .core.task_manager import TaskManager
|
||||||
from .core.config_manager import ConfigManager
|
from .core.config_manager import ConfigManager
|
||||||
|
from .core.scheduler import SchedulerService
|
||||||
|
from .services.resource_service import ResourceService
|
||||||
|
from .core.database import init_db, get_auth_db
|
||||||
|
from .core.logger import logger
|
||||||
|
from .core.auth.jwt import decode_token
|
||||||
|
from .core.auth.repository import AuthRepository
|
||||||
|
from .models.auth import User
|
||||||
|
|
||||||
# Initialize singletons
|
# Initialize singletons
|
||||||
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
|
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
|
||||||
@@ -15,24 +25,136 @@ project_root = Path(__file__).parent.parent.parent
|
|||||||
config_path = project_root / "config.json"
|
config_path = project_root / "config.json"
|
||||||
config_manager = ConfigManager(config_path=str(config_path))
|
config_manager = ConfigManager(config_path=str(config_path))
|
||||||
|
|
||||||
|
# Initialize database before any other services that might use it
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# [DEF:get_config_manager:Function]
|
||||||
|
# @PURPOSE: Dependency injector for the 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:
|
def get_config_manager() -> ConfigManager:
|
||||||
"""Dependency injector for the ConfigManager."""
|
"""Dependency injector for the ConfigManager."""
|
||||||
return config_manager
|
return config_manager
|
||||||
|
# [/DEF:get_config_manager:Function]
|
||||||
|
|
||||||
plugin_dir = Path(__file__).parent / "plugins"
|
plugin_dir = Path(__file__).parent / "plugins"
|
||||||
|
|
||||||
plugin_loader = PluginLoader(plugin_dir=str(plugin_dir))
|
plugin_loader = PluginLoader(plugin_dir=str(plugin_dir))
|
||||||
from .core.logger import logger
|
|
||||||
logger.info(f"PluginLoader initialized with directory: {plugin_dir}")
|
logger.info(f"PluginLoader initialized with directory: {plugin_dir}")
|
||||||
logger.info(f"Available plugins: {[config.name for config in plugin_loader.get_all_plugin_configs()]}")
|
logger.info(f"Available plugins: {[config.name for config in plugin_loader.get_all_plugin_configs()]}")
|
||||||
|
|
||||||
task_manager = TaskManager(plugin_loader)
|
task_manager = TaskManager(plugin_loader)
|
||||||
logger.info("TaskManager initialized")
|
logger.info("TaskManager initialized")
|
||||||
|
|
||||||
|
scheduler_service = SchedulerService(task_manager, config_manager)
|
||||||
|
logger.info("SchedulerService initialized")
|
||||||
|
|
||||||
|
resource_service = ResourceService()
|
||||||
|
logger.info("ResourceService initialized")
|
||||||
|
|
||||||
|
# [DEF:get_plugin_loader:Function]
|
||||||
|
# @PURPOSE: Dependency injector for the PluginLoader.
|
||||||
|
# @PRE: Global plugin_loader must be initialized.
|
||||||
|
# @POST: Returns shared PluginLoader instance.
|
||||||
|
# @RETURN: PluginLoader - The shared plugin loader instance.
|
||||||
def get_plugin_loader() -> PluginLoader:
|
def get_plugin_loader() -> PluginLoader:
|
||||||
"""Dependency injector for the PluginLoader."""
|
"""Dependency injector for the PluginLoader."""
|
||||||
return plugin_loader
|
return plugin_loader
|
||||||
|
# [/DEF:get_plugin_loader:Function]
|
||||||
|
|
||||||
|
# [DEF:get_task_manager:Function]
|
||||||
|
# @PURPOSE: Dependency injector for the 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:
|
def get_task_manager() -> TaskManager:
|
||||||
"""Dependency injector for the TaskManager."""
|
"""Dependency injector for the TaskManager."""
|
||||||
return task_manager
|
return task_manager
|
||||||
# [/DEF]
|
# [/DEF:get_task_manager:Function]
|
||||||
|
|
||||||
|
# [DEF:get_scheduler_service:Function]
|
||||||
|
# @PURPOSE: Dependency injector for the 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."""
|
||||||
|
return scheduler_service
|
||||||
|
# [/DEF:get_scheduler_service:Function]
|
||||||
|
|
||||||
|
# [DEF:get_resource_service:Function]
|
||||||
|
# @PURPOSE: Dependency injector for the ResourceService.
|
||||||
|
# @PRE: Global resource_service must be initialized.
|
||||||
|
# @POST: Returns shared ResourceService instance.
|
||||||
|
# @RETURN: ResourceService - The shared resource service instance.
|
||||||
|
def get_resource_service() -> ResourceService:
|
||||||
|
"""Dependency injector for the ResourceService."""
|
||||||
|
return resource_service
|
||||||
|
# [/DEF:get_resource_service:Function]
|
||||||
|
|
||||||
|
# [DEF:oauth2_scheme:Variable]
|
||||||
|
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
# [/DEF:oauth2_scheme:Variable]
|
||||||
|
|
||||||
|
# [DEF:get_current_user:Function]
|
||||||
|
# @PURPOSE: Dependency for retrieving the currently authenticated user from a JWT.
|
||||||
|
# @PRE: JWT token provided in Authorization header.
|
||||||
|
# @POST: Returns the 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]
|
||||||
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]
|
||||||
36
backend/src/models/connection.py
Normal file
36
backend/src/models/connection.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# [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
|
||||||
|
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||||
|
#
|
||||||
|
# @INVARIANT: All primary keys are UUID strings.
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from sqlalchemy import Column, String, Integer, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from .mapping import Base
|
||||||
|
import uuid
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:ConnectionConfig:Class]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
|
# @PURPOSE: Stores credentials for external databases used for column mapping.
|
||||||
|
class ConnectionConfig(Base):
|
||||||
|
__tablename__ = "connection_configs"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
type = Column(String, nullable=False) # e.g., "postgres"
|
||||||
|
host = Column(String, nullable=True)
|
||||||
|
port = Column(Integer, nullable=True)
|
||||||
|
database = Column(String, nullable=True)
|
||||||
|
username = Column(String, nullable=True)
|
||||||
|
password = Column(String, nullable=True) # Encrypted/Obfuscated password
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
# [/DEF:ConnectionConfig:Class]
|
||||||
|
|
||||||
|
# [/DEF:backend.src.models.connection:Module]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
# [DEF:backend.src.models.dashboard:Module]
|
# [DEF:backend.src.models.dashboard:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: dashboard, model, metadata, migration
|
# @SEMANTICS: dashboard, model, metadata, migration
|
||||||
# @PURPOSE: Defines data models for dashboard metadata and selection.
|
# @PURPOSE: Defines data models for dashboard metadata and selection.
|
||||||
# @LAYER: Model
|
# @LAYER: Model
|
||||||
@@ -8,21 +9,23 @@ from pydantic import BaseModel
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
# [DEF:DashboardMetadata:Class]
|
# [DEF:DashboardMetadata:Class]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
# @PURPOSE: Represents a dashboard available for migration.
|
# @PURPOSE: Represents a dashboard available for migration.
|
||||||
class DashboardMetadata(BaseModel):
|
class DashboardMetadata(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
last_modified: str
|
last_modified: str
|
||||||
status: str
|
status: str
|
||||||
# [/DEF:DashboardMetadata]
|
# [/DEF:DashboardMetadata:Class]
|
||||||
|
|
||||||
# [DEF:DashboardSelection:Class]
|
# [DEF:DashboardSelection:Class]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
# @PURPOSE: Represents the user's selection of dashboards to migrate.
|
# @PURPOSE: Represents the user's selection of dashboards to migrate.
|
||||||
class DashboardSelection(BaseModel):
|
class DashboardSelection(BaseModel):
|
||||||
selected_ids: List[int]
|
selected_ids: List[int]
|
||||||
source_env_id: str
|
source_env_id: str
|
||||||
target_env_id: str
|
target_env_id: str
|
||||||
replace_db_config: bool = False
|
replace_db_config: bool = False
|
||||||
# [/DEF:DashboardSelection]
|
# [/DEF:DashboardSelection:Class]
|
||||||
|
|
||||||
# [/DEF:backend.src.models.dashboard]
|
# [/DEF:backend.src.models.dashboard:Module]
|
||||||
73
backend/src/models/git.py
Normal file
73
backend/src/models/git.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# [DEF:GitModels:Module]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
|
# @SEMANTICS: git, models, sqlalchemy, database, schema
|
||||||
|
# @PURPOSE: Git-specific SQLAlchemy models for configuration and repository tracking.
|
||||||
|
# @LAYER: Model
|
||||||
|
# @RELATION: specs/011-git-integration-dashboard/data-model.md
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, String, Integer, DateTime, Enum, ForeignKey, Boolean
|
||||||
|
import uuid
|
||||||
|
from src.core.database import Base
|
||||||
|
|
||||||
|
class GitProvider(str, enum.Enum):
|
||||||
|
GITHUB = "GITHUB"
|
||||||
|
GITLAB = "GITLAB"
|
||||||
|
GITEA = "GITEA"
|
||||||
|
|
||||||
|
class GitStatus(str, enum.Enum):
|
||||||
|
CONNECTED = "CONNECTED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
UNKNOWN = "UNKNOWN"
|
||||||
|
|
||||||
|
class SyncStatus(str, enum.Enum):
|
||||||
|
CLEAN = "CLEAN"
|
||||||
|
DIRTY = "DIRTY"
|
||||||
|
CONFLICT = "CONFLICT"
|
||||||
|
|
||||||
|
# [DEF:GitServerConfig:Class]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
|
# @PURPOSE: Configuration for a Git server connection.
|
||||||
|
class GitServerConfig(Base):
|
||||||
|
__tablename__ = "git_server_configs"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
provider = Column(Enum(GitProvider), nullable=False)
|
||||||
|
url = Column(String(255), nullable=False)
|
||||||
|
pat = Column(String(255), nullable=False) # PERSONAL ACCESS TOKEN
|
||||||
|
default_repository = Column(String(255), nullable=True)
|
||||||
|
status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN)
|
||||||
|
last_validated = Column(DateTime, default=datetime.utcnow)
|
||||||
|
# [/DEF:GitServerConfig:Class]
|
||||||
|
|
||||||
|
# [DEF:GitRepository:Class]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
|
# @PURPOSE: Tracking for a local Git repository linked to a dashboard.
|
||||||
|
class GitRepository(Base):
|
||||||
|
__tablename__ = "git_repositories"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
dashboard_id = Column(Integer, nullable=False, unique=True)
|
||||||
|
config_id = Column(String(36), ForeignKey("git_server_configs.id"), nullable=False)
|
||||||
|
remote_url = Column(String(255), nullable=False)
|
||||||
|
local_path = Column(String(255), nullable=False)
|
||||||
|
current_branch = Column(String(255), default="main")
|
||||||
|
sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN)
|
||||||
|
# [/DEF:GitRepository:Class]
|
||||||
|
|
||||||
|
# [DEF:DeploymentEnvironment:Class]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
|
# @PURPOSE: Target Superset environments for dashboard deployment.
|
||||||
|
class DeploymentEnvironment(Base):
|
||||||
|
__tablename__ = "deployment_environments"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
superset_url = Column(String(255), nullable=False)
|
||||||
|
superset_token = Column(String(255), nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
# [/DEF:DeploymentEnvironment:Class]
|
||||||
|
|
||||||
|
# [/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]
|
# [DEF:backend.src.models.mapping:Module]
|
||||||
#
|
#
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: database, mapping, environment, migration, sqlalchemy, sqlite
|
# @SEMANTICS: database, mapping, environment, migration, sqlalchemy, sqlite
|
||||||
# @PURPOSE: Defines the database schema for environment metadata and database mappings using SQLAlchemy.
|
# @PURPOSE: Defines the database schema for environment metadata and database mappings using SQLAlchemy.
|
||||||
# @LAYER: Domain
|
# @LAYER: Domain
|
||||||
@@ -19,6 +20,7 @@ import enum
|
|||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
# [DEF:MigrationStatus:Class]
|
# [DEF:MigrationStatus:Class]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
# @PURPOSE: Enumeration of possible migration job statuses.
|
# @PURPOSE: Enumeration of possible migration job statuses.
|
||||||
class MigrationStatus(enum.Enum):
|
class MigrationStatus(enum.Enum):
|
||||||
PENDING = "PENDING"
|
PENDING = "PENDING"
|
||||||
@@ -26,9 +28,10 @@ class MigrationStatus(enum.Enum):
|
|||||||
COMPLETED = "COMPLETED"
|
COMPLETED = "COMPLETED"
|
||||||
FAILED = "FAILED"
|
FAILED = "FAILED"
|
||||||
AWAITING_MAPPING = "AWAITING_MAPPING"
|
AWAITING_MAPPING = "AWAITING_MAPPING"
|
||||||
# [/DEF:MigrationStatus]
|
# [/DEF:MigrationStatus:Class]
|
||||||
|
|
||||||
# [DEF:Environment:Class]
|
# [DEF:Environment:Class]
|
||||||
|
# @TIER: STANDARD
|
||||||
# @PURPOSE: Represents a Superset instance environment.
|
# @PURPOSE: Represents a Superset instance environment.
|
||||||
class Environment(Base):
|
class Environment(Base):
|
||||||
__tablename__ = "environments"
|
__tablename__ = "environments"
|
||||||
@@ -37,9 +40,10 @@ class Environment(Base):
|
|||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
url = Column(String, nullable=False)
|
url = Column(String, nullable=False)
|
||||||
credentials_id = Column(String, nullable=False)
|
credentials_id = Column(String, nullable=False)
|
||||||
# [/DEF:Environment]
|
# [/DEF:Environment:Class]
|
||||||
|
|
||||||
# [DEF:DatabaseMapping:Class]
|
# [DEF:DatabaseMapping:Class]
|
||||||
|
# @TIER: STANDARD
|
||||||
# @PURPOSE: Represents a mapping between source and target databases.
|
# @PURPOSE: Represents a mapping between source and target databases.
|
||||||
class DatabaseMapping(Base):
|
class DatabaseMapping(Base):
|
||||||
__tablename__ = "database_mappings"
|
__tablename__ = "database_mappings"
|
||||||
@@ -52,9 +56,10 @@ class DatabaseMapping(Base):
|
|||||||
source_db_name = Column(String, nullable=False)
|
source_db_name = Column(String, nullable=False)
|
||||||
target_db_name = Column(String, nullable=False)
|
target_db_name = Column(String, nullable=False)
|
||||||
engine = Column(String, nullable=True)
|
engine = Column(String, nullable=True)
|
||||||
# [/DEF:DatabaseMapping]
|
# [/DEF:DatabaseMapping:Class]
|
||||||
|
|
||||||
# [DEF:MigrationJob:Class]
|
# [DEF:MigrationJob:Class]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
# @PURPOSE: Represents a single migration execution job.
|
# @PURPOSE: Represents a single migration execution job.
|
||||||
class MigrationJob(Base):
|
class MigrationJob(Base):
|
||||||
__tablename__ = "migration_jobs"
|
__tablename__ = "migration_jobs"
|
||||||
@@ -65,6 +70,6 @@ class MigrationJob(Base):
|
|||||||
status = Column(SQLEnum(MigrationStatus), default=MigrationStatus.PENDING)
|
status = Column(SQLEnum(MigrationStatus), default=MigrationStatus.PENDING)
|
||||||
replace_db = Column(Boolean, default=False)
|
replace_db = Column(Boolean, default=False)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
# [/DEF:MigrationJob]
|
# [/DEF:MigrationJob:Class]
|
||||||
|
|
||||||
# [/DEF:backend.src.models.mapping]
|
# [/DEF:backend.src.models.mapping:Module]
|
||||||
|
|||||||
42
backend/src/models/storage.py
Normal file
42
backend/src/models/storage.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# [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"
|
||||||
|
REPOSITORY = "repositorys"
|
||||||
|
# [/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_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).")
|
||||||
|
path: str = Field(..., description="Relative path from storage root.")
|
||||||
|
size: int = Field(..., ge=0, description="Size of the file in bytes.")
|
||||||
|
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:backend.src.models.storage:Module]
|
||||||
61
backend/src/models/task.py
Normal file
61
backend/src/models/task.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# [DEF:backend.src.models.task:Module]
|
||||||
|
#
|
||||||
|
# @TIER: TRIVIAL
|
||||||
|
# @SEMANTICS: database, task, record, sqlalchemy, sqlite
|
||||||
|
# @PURPOSE: Defines the database schema for task execution records.
|
||||||
|
# @LAYER: Domain
|
||||||
|
# @RELATION: DEPENDS_ON -> sqlalchemy
|
||||||
|
#
|
||||||
|
# @INVARIANT: All primary keys are UUID strings.
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from sqlalchemy import Column, String, DateTime, JSON, ForeignKey, Text, Integer, Index
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from .mapping import Base
|
||||||
|
import uuid
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:TaskRecord:Class]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
|
# @PURPOSE: Represents a persistent record of a task execution.
|
||||||
|
class TaskRecord(Base):
|
||||||
|
__tablename__ = "task_records"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
type = Column(String, nullable=False) # e.g., "backup", "migration"
|
||||||
|
status = Column(String, nullable=False) # Enum: "PENDING", "RUNNING", "SUCCESS", "FAILED"
|
||||||
|
environment_id = Column(String, ForeignKey("environments.id"), nullable=True)
|
||||||
|
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
logs = Column(JSON, nullable=True) # Store structured logs as JSON (legacy, kept for backward compatibility)
|
||||||
|
error = Column(String, nullable=True)
|
||||||
|
result = Column(JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
params = Column(JSON, nullable=True)
|
||||||
|
# [/DEF:TaskRecord:Class]
|
||||||
|
|
||||||
|
# [DEF:TaskLogRecord:Class]
|
||||||
|
# @PURPOSE: Represents a single persistent log entry for a task.
|
||||||
|
# @TIER: CRITICAL
|
||||||
|
# @RELATION: DEPENDS_ON -> TaskRecord
|
||||||
|
# @INVARIANT: Each log entry belongs to exactly one task.
|
||||||
|
class TaskLogRecord(Base):
|
||||||
|
__tablename__ = "task_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id = Column(String, ForeignKey("task_records.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
timestamp = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
level = Column(String(16), nullable=False) # INFO, WARNING, ERROR, DEBUG
|
||||||
|
source = Column(String(64), nullable=False, default="system") # plugin, superset_api, git, etc.
|
||||||
|
message = Column(Text, nullable=False)
|
||||||
|
metadata_json = Column(Text, nullable=True) # JSON string for additional metadata
|
||||||
|
|
||||||
|
# Composite indexes for efficient filtering
|
||||||
|
__table_args__ = (
|
||||||
|
Index('ix_task_logs_task_timestamp', 'task_id', 'timestamp'),
|
||||||
|
Index('ix_task_logs_task_level', 'task_id', 'level'),
|
||||||
|
Index('ix_task_logs_task_source', 'task_id', 'source'),
|
||||||
|
)
|
||||||
|
# [/DEF:TaskLogRecord:Class]
|
||||||
|
|
||||||
|
# [/DEF:backend.src.models.task:Module]
|
||||||
@@ -5,16 +5,17 @@
|
|||||||
# @RELATION: IMPLEMENTS -> PluginBase
|
# @RELATION: IMPLEMENTS -> PluginBase
|
||||||
# @RELATION: DEPENDS_ON -> superset_tool.client
|
# @RELATION: DEPENDS_ON -> superset_tool.client
|
||||||
# @RELATION: DEPENDS_ON -> superset_tool.utils
|
# @RELATION: DEPENDS_ON -> superset_tool.utils
|
||||||
|
# @RELATION: USES -> TaskContext
|
||||||
|
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from requests.exceptions import RequestException
|
from requests.exceptions import RequestException
|
||||||
|
|
||||||
from ..core.plugin_base import PluginBase
|
from ..core.plugin_base import PluginBase
|
||||||
from superset_tool.client import SupersetClient
|
from ..core.logger import belief_scope, logger as app_logger
|
||||||
from superset_tool.exceptions import SupersetAPIError
|
from ..core.superset_client import SupersetClient
|
||||||
from superset_tool.utils.logger import SupersetLogger
|
from ..core.utils.network import SupersetAPIError
|
||||||
from superset_tool.utils.fileio import (
|
from ..core.utils.fileio import (
|
||||||
save_and_unpack_dashboard,
|
save_and_unpack_dashboard,
|
||||||
archive_exports,
|
archive_exports,
|
||||||
sanitize_filename,
|
sanitize_filename,
|
||||||
@@ -22,34 +23,79 @@ from superset_tool.utils.fileio import (
|
|||||||
remove_empty_directories,
|
remove_empty_directories,
|
||||||
RetentionPolicy
|
RetentionPolicy
|
||||||
)
|
)
|
||||||
from superset_tool.utils.init_clients import setup_clients
|
|
||||||
from ..dependencies import get_config_manager
|
from ..dependencies import get_config_manager
|
||||||
|
from ..core.task_manager.context import TaskContext
|
||||||
|
|
||||||
|
# [DEF:BackupPlugin:Class]
|
||||||
|
# @PURPOSE: Implementation of the backup plugin logic.
|
||||||
class BackupPlugin(PluginBase):
|
class BackupPlugin(PluginBase):
|
||||||
"""
|
"""
|
||||||
A plugin to back up Superset dashboards.
|
A plugin to back up Superset dashboards.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
# [DEF:id:Function]
|
||||||
|
# @PURPOSE: Returns the unique identifier for the backup plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string ID.
|
||||||
|
# @RETURN: str - "superset-backup"
|
||||||
def id(self) -> str:
|
def id(self) -> str:
|
||||||
return "superset-backup"
|
with belief_scope("id"):
|
||||||
|
return "superset-backup"
|
||||||
|
# [/DEF:id:Function]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
# [DEF:name:Function]
|
||||||
|
# @PURPOSE: Returns the human-readable name of the backup plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string name.
|
||||||
|
# @RETURN: str - Plugin name.
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "Superset Dashboard Backup"
|
with belief_scope("name"):
|
||||||
|
return "Superset Dashboard Backup"
|
||||||
|
# [/DEF:name:Function]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
# [DEF:description:Function]
|
||||||
|
# @PURPOSE: Returns a description of the backup plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string description.
|
||||||
|
# @RETURN: str - Plugin description.
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return "Backs up all dashboards from a Superset instance."
|
with belief_scope("description"):
|
||||||
|
return "Backs up all dashboards from a Superset instance."
|
||||||
|
# [/DEF:description:Function]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
# [DEF:version:Function]
|
||||||
|
# @PURPOSE: Returns the version of the backup plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string version.
|
||||||
|
# @RETURN: str - "1.0.0"
|
||||||
def version(self) -> str:
|
def version(self) -> str:
|
||||||
return "1.0.0"
|
with belief_scope("version"):
|
||||||
|
return "1.0.0"
|
||||||
|
# [/DEF:version:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:ui_route:Function]
|
||||||
|
# @PURPOSE: Returns the frontend route for the backup plugin.
|
||||||
|
# @RETURN: str - "/tools/backups"
|
||||||
|
def ui_route(self) -> str:
|
||||||
|
with belief_scope("ui_route"):
|
||||||
|
return "/tools/backups"
|
||||||
|
# [/DEF:ui_route:Function]
|
||||||
|
|
||||||
|
# [DEF:get_schema:Function]
|
||||||
|
# @PURPOSE: Returns the JSON schema for backup plugin parameters.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns dictionary schema.
|
||||||
|
# @RETURN: Dict[str, Any] - JSON schema.
|
||||||
def get_schema(self) -> Dict[str, Any]:
|
def get_schema(self) -> Dict[str, Any]:
|
||||||
config_manager = get_config_manager()
|
with belief_scope("get_schema"):
|
||||||
envs = [e.name for e in config_manager.get_environments()]
|
config_manager = get_config_manager()
|
||||||
default_path = config_manager.get_config().settings.backup_path
|
envs = [e.name for e in config_manager.get_environments()]
|
||||||
|
config_manager.get_config().settings.storage.root_path
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -60,74 +106,104 @@ class BackupPlugin(PluginBase):
|
|||||||
"description": "The Superset environment to back up.",
|
"description": "The Superset environment to back up.",
|
||||||
"enum": envs if envs else [],
|
"enum": envs if envs else [],
|
||||||
},
|
},
|
||||||
"backup_path": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Backup Path",
|
|
||||||
"description": "The root directory to save backups to.",
|
|
||||||
"default": default_path
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"required": ["env", "backup_path"],
|
"required": ["env"],
|
||||||
}
|
}
|
||||||
|
# [/DEF:get_schema:Function]
|
||||||
|
|
||||||
async def execute(self, params: Dict[str, Any]):
|
# [DEF:execute:Function]
|
||||||
env = params["env"]
|
# @PURPOSE: Executes the dashboard backup logic with TaskContext support.
|
||||||
backup_path = Path(params["backup_path"])
|
# @PARAM: params (Dict[str, Any]) - Backup parameters (env, backup_path).
|
||||||
|
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||||
logger = SupersetLogger(log_dir=backup_path / "Logs", console=True)
|
# @PRE: Target environment must be configured. params must be a dictionary.
|
||||||
logger.info(f"[BackupPlugin][Entry] Starting backup for {env}.")
|
# @POST: All dashboards are exported and archived.
|
||||||
|
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
|
||||||
try:
|
with belief_scope("execute"):
|
||||||
config_manager = get_config_manager()
|
config_manager = get_config_manager()
|
||||||
if not config_manager.has_environments():
|
env_id = params.get("environment_id")
|
||||||
raise ValueError("No Superset environments configured. Please add an environment in Settings.")
|
|
||||||
|
# Resolve environment name if environment_id is provided
|
||||||
|
if env_id:
|
||||||
|
env_config = next((e for e in config_manager.get_environments() if e.id == env_id), None)
|
||||||
|
if env_config:
|
||||||
|
params["env"] = env_config.name
|
||||||
|
|
||||||
|
env = params.get("env")
|
||||||
|
if not env:
|
||||||
|
raise KeyError("env")
|
||||||
|
|
||||||
|
storage_settings = config_manager.get_config().settings.storage
|
||||||
|
# Use 'backups' subfolder within the storage root
|
||||||
|
backup_path = Path(storage_settings.root_path) / "backups"
|
||||||
|
|
||||||
|
# Use TaskContext logger if available, otherwise fall back to app_logger
|
||||||
|
log = context.logger if context else app_logger
|
||||||
|
|
||||||
|
# Create sub-loggers for different components
|
||||||
|
superset_log = log.with_source("superset_api") if context else log
|
||||||
|
storage_log = log.with_source("storage") if context else log
|
||||||
|
|
||||||
|
log.info(f"Starting backup for environment: {env}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_manager = get_config_manager()
|
||||||
|
if not config_manager.has_environments():
|
||||||
|
raise ValueError("No Superset environments configured. Please add an environment in Settings.")
|
||||||
|
|
||||||
|
env_config = config_manager.get_environment(env)
|
||||||
|
if not env_config:
|
||||||
|
raise ValueError(f"Environment '{env}' not found in configuration.")
|
||||||
|
|
||||||
clients = setup_clients(logger, custom_envs=config_manager.get_environments())
|
client = SupersetClient(env_config)
|
||||||
client = clients.get(env)
|
|
||||||
|
dashboard_count, dashboard_meta = client.get_dashboards()
|
||||||
if not client:
|
superset_log.info(f"Found {dashboard_count} dashboards to export")
|
||||||
raise ValueError(f"Environment '{env}' not found in configuration.")
|
|
||||||
|
|
||||||
dashboard_count, dashboard_meta = client.get_dashboards()
|
|
||||||
logger.info(f"[BackupPlugin][Progress] Found {dashboard_count} dashboards to export in {env}.")
|
|
||||||
|
|
||||||
if dashboard_count == 0:
|
if dashboard_count == 0:
|
||||||
logger.info("[BackupPlugin][Exit] No dashboards to back up.")
|
log.info("No dashboards to back up")
|
||||||
return
|
return
|
||||||
|
|
||||||
for db in dashboard_meta:
|
total = len(dashboard_meta)
|
||||||
dashboard_id = db.get('id')
|
for idx, db in enumerate(dashboard_meta, 1):
|
||||||
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
|
dashboard_id = db.get('id')
|
||||||
if not dashboard_id:
|
dashboard_title = db.get('dashboard_title', 'Unknown Dashboard')
|
||||||
continue
|
if not dashboard_id:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
# Report progress
|
||||||
dashboard_base_dir_name = sanitize_filename(f"{dashboard_title}")
|
progress_pct = (idx / total) * 100
|
||||||
dashboard_dir = backup_path / env.upper() / dashboard_base_dir_name
|
log.progress(f"Backing up dashboard: {dashboard_title}", percent=progress_pct)
|
||||||
dashboard_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
zip_content, filename = client.export_dashboard(dashboard_id)
|
try:
|
||||||
|
dashboard_base_dir_name = sanitize_filename(f"{dashboard_title}")
|
||||||
|
dashboard_dir = backup_path / env.upper() / dashboard_base_dir_name
|
||||||
|
dashboard_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
save_and_unpack_dashboard(
|
zip_content, filename = client.export_dashboard(dashboard_id)
|
||||||
zip_content=zip_content,
|
superset_log.debug(f"Exported dashboard: {dashboard_title}")
|
||||||
original_filename=filename,
|
|
||||||
output_dir=dashboard_dir,
|
|
||||||
unpack=False,
|
|
||||||
logger=logger
|
|
||||||
)
|
|
||||||
|
|
||||||
archive_exports(str(dashboard_dir), policy=RetentionPolicy(), logger=logger)
|
save_and_unpack_dashboard(
|
||||||
|
zip_content=zip_content,
|
||||||
|
original_filename=filename,
|
||||||
|
output_dir=dashboard_dir,
|
||||||
|
unpack=False
|
||||||
|
)
|
||||||
|
|
||||||
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
archive_exports(str(dashboard_dir), policy=RetentionPolicy())
|
||||||
logger.error(f"[BackupPlugin][Failure] Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}", exc_info=True)
|
storage_log.debug(f"Archived dashboard: {dashboard_title}")
|
||||||
continue
|
|
||||||
|
|
||||||
consolidate_archive_folders(backup_path / env.upper(), logger=logger)
|
|
||||||
remove_empty_directories(str(backup_path / env.upper()), logger=logger)
|
|
||||||
|
|
||||||
logger.info(f"[BackupPlugin][CoherenceCheck:Passed] Backup logic completed for {env}.")
|
except (SupersetAPIError, RequestException, IOError, OSError) as db_error:
|
||||||
|
log.error(f"Failed to export dashboard {dashboard_title} (ID: {dashboard_id}): {db_error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
consolidate_archive_folders(backup_path / env.upper())
|
||||||
|
remove_empty_directories(str(backup_path / env.upper()))
|
||||||
|
|
||||||
except (RequestException, IOError, KeyError) as e:
|
log.info(f"Backup completed successfully for {env}")
|
||||||
logger.critical(f"[BackupPlugin][Failure] Fatal error during backup for {env}: {e}", exc_info=True)
|
|
||||||
raise e
|
except (RequestException, IOError, KeyError) as e:
|
||||||
# [/DEF:BackupPlugin]
|
log.error(f"Fatal error during backup for {env}: {e}")
|
||||||
|
raise e
|
||||||
|
# [/DEF:execute:Function]
|
||||||
|
# [/DEF:BackupPlugin:Class]
|
||||||
|
# [/DEF:BackupPlugin:Module]
|
||||||
216
backend/src/plugins/debug.py
Normal file
216
backend/src/plugins/debug.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# [DEF:DebugPluginModule:Module]
|
||||||
|
# @SEMANTICS: plugin, debug, api, database, superset
|
||||||
|
# @PURPOSE: Implements a plugin for system diagnostics and debugging Superset API responses.
|
||||||
|
# @LAYER: Plugins
|
||||||
|
# @RELATION: Inherits from PluginBase. Uses SupersetClient from core.
|
||||||
|
# @RELATION: USES -> TaskContext
|
||||||
|
# @CONSTRAINT: Must use belief_scope for logging.
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from ..core.plugin_base import PluginBase
|
||||||
|
from ..core.superset_client import SupersetClient
|
||||||
|
from ..core.logger import logger, belief_scope
|
||||||
|
from ..core.task_manager.context import TaskContext
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:DebugPlugin:Class]
|
||||||
|
# @PURPOSE: Plugin for system diagnostics and debugging.
|
||||||
|
class DebugPlugin(PluginBase):
|
||||||
|
"""
|
||||||
|
Plugin for system diagnostics and debugging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:id:Function]
|
||||||
|
# @PURPOSE: Returns the unique identifier for the debug plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string ID.
|
||||||
|
# @RETURN: str - "system-debug"
|
||||||
|
def id(self) -> str:
|
||||||
|
with belief_scope("id"):
|
||||||
|
return "system-debug"
|
||||||
|
# [/DEF:id:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:name:Function]
|
||||||
|
# @PURPOSE: Returns the human-readable name of the debug plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string name.
|
||||||
|
# @RETURN: str - Plugin name.
|
||||||
|
def name(self) -> str:
|
||||||
|
with belief_scope("name"):
|
||||||
|
return "System Debug"
|
||||||
|
# [/DEF:name:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:description:Function]
|
||||||
|
# @PURPOSE: Returns a description of the debug plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string description.
|
||||||
|
# @RETURN: str - Plugin description.
|
||||||
|
def description(self) -> str:
|
||||||
|
with belief_scope("description"):
|
||||||
|
return "Run system diagnostics and debug Superset API responses."
|
||||||
|
# [/DEF:description:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:version:Function]
|
||||||
|
# @PURPOSE: Returns the version of the debug plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string version.
|
||||||
|
# @RETURN: str - "1.0.0"
|
||||||
|
def version(self) -> str:
|
||||||
|
with belief_scope("version"):
|
||||||
|
return "1.0.0"
|
||||||
|
# [/DEF:version:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:ui_route:Function]
|
||||||
|
# @PURPOSE: Returns the frontend route for the debug plugin.
|
||||||
|
# @RETURN: str - "/tools/debug"
|
||||||
|
def ui_route(self) -> str:
|
||||||
|
with belief_scope("ui_route"):
|
||||||
|
return "/tools/debug"
|
||||||
|
# [/DEF:ui_route:Function]
|
||||||
|
|
||||||
|
# [DEF:get_schema:Function]
|
||||||
|
# @PURPOSE: Returns the JSON schema for the debug plugin parameters.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns dictionary schema.
|
||||||
|
# @RETURN: Dict[str, Any] - JSON schema.
|
||||||
|
def get_schema(self) -> Dict[str, Any]:
|
||||||
|
with belief_scope("get_schema"):
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Action",
|
||||||
|
"enum": ["test-db-api", "get-dataset-structure"],
|
||||||
|
"default": "test-db-api"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Environment",
|
||||||
|
"description": "The Superset environment (for dataset structure)."
|
||||||
|
},
|
||||||
|
"dataset_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Dataset ID",
|
||||||
|
"description": "The ID of the dataset (for dataset structure)."
|
||||||
|
},
|
||||||
|
"source_env": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Source Environment",
|
||||||
|
"description": "Source env for DB API test."
|
||||||
|
},
|
||||||
|
"target_env": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Target Environment",
|
||||||
|
"description": "Target env for DB API test."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["action"]
|
||||||
|
}
|
||||||
|
# [/DEF:get_schema:Function]
|
||||||
|
|
||||||
|
# [DEF:execute:Function]
|
||||||
|
# @PURPOSE: Executes the debug logic with TaskContext support.
|
||||||
|
# @PARAM: params (Dict[str, Any]) - Debug parameters.
|
||||||
|
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||||
|
# @PRE: action must be provided in params.
|
||||||
|
# @POST: Debug action is executed and results returned.
|
||||||
|
# @RETURN: Dict[str, Any] - Execution results.
|
||||||
|
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None) -> Dict[str, Any]:
|
||||||
|
with belief_scope("execute"):
|
||||||
|
action = params.get("action")
|
||||||
|
|
||||||
|
# Use TaskContext logger if available, otherwise fall back to app logger
|
||||||
|
log = context.logger if context else logger
|
||||||
|
debug_log = log.with_source("debug") if context else log
|
||||||
|
superset_log = log.with_source("superset_api") if context else log
|
||||||
|
|
||||||
|
debug_log.info(f"Executing debug action: {action}")
|
||||||
|
|
||||||
|
if action == "test-db-api":
|
||||||
|
return await self._test_db_api(params, superset_log)
|
||||||
|
elif action == "get-dataset-structure":
|
||||||
|
return await self._get_dataset_structure(params, superset_log)
|
||||||
|
else:
|
||||||
|
debug_log.error(f"Unknown action: {action}")
|
||||||
|
raise ValueError(f"Unknown action: {action}")
|
||||||
|
# [/DEF:execute:Function]
|
||||||
|
|
||||||
|
# [DEF:_test_db_api:Function]
|
||||||
|
# @PURPOSE: Tests database API connectivity for source and target environments.
|
||||||
|
# @PRE: source_env and target_env params exist in params.
|
||||||
|
# @POST: Returns DB counts for both envs.
|
||||||
|
# @PARAM: params (Dict) - Plugin parameters.
|
||||||
|
# @PARAM: log - Logger instance for superset_api source.
|
||||||
|
# @RETURN: Dict - Comparison results.
|
||||||
|
async def _test_db_api(self, params: Dict[str, Any], log) -> Dict[str, Any]:
|
||||||
|
with belief_scope("_test_db_api"):
|
||||||
|
source_env_name = params.get("source_env")
|
||||||
|
target_env_name = params.get("target_env")
|
||||||
|
|
||||||
|
if not source_env_name or not target_env_name:
|
||||||
|
raise ValueError("source_env and target_env are required for test-db-api")
|
||||||
|
|
||||||
|
from ..dependencies import get_config_manager
|
||||||
|
config_manager = get_config_manager()
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for name in [source_env_name, target_env_name]:
|
||||||
|
log.info(f"Testing database API for environment: {name}")
|
||||||
|
env_config = config_manager.get_environment(name)
|
||||||
|
if not env_config:
|
||||||
|
log.error(f"Environment '{name}' not found.")
|
||||||
|
raise ValueError(f"Environment '{name}' not found.")
|
||||||
|
|
||||||
|
client = SupersetClient(env_config)
|
||||||
|
client.authenticate()
|
||||||
|
count, dbs = client.get_databases()
|
||||||
|
log.debug(f"Found {count} databases in {name}")
|
||||||
|
results[name] = {
|
||||||
|
"count": count,
|
||||||
|
"databases": dbs
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
# [/DEF:_test_db_api:Function]
|
||||||
|
|
||||||
|
# [DEF:_get_dataset_structure:Function]
|
||||||
|
# @PURPOSE: Retrieves the structure of a dataset.
|
||||||
|
# @PRE: env and dataset_id params exist in params.
|
||||||
|
# @POST: Returns dataset JSON structure.
|
||||||
|
# @PARAM: params (Dict) - Plugin parameters.
|
||||||
|
# @PARAM: log - Logger instance for superset_api source.
|
||||||
|
# @RETURN: Dict - Dataset structure.
|
||||||
|
async def _get_dataset_structure(self, params: Dict[str, Any], log) -> Dict[str, Any]:
|
||||||
|
with belief_scope("_get_dataset_structure"):
|
||||||
|
env_name = params.get("env")
|
||||||
|
dataset_id = params.get("dataset_id")
|
||||||
|
|
||||||
|
if not env_name or dataset_id is None:
|
||||||
|
raise ValueError("env and dataset_id are required for get-dataset-structure")
|
||||||
|
|
||||||
|
log.info(f"Fetching structure for dataset {dataset_id} in {env_name}")
|
||||||
|
|
||||||
|
from ..dependencies import get_config_manager
|
||||||
|
config_manager = get_config_manager()
|
||||||
|
env_config = config_manager.get_environment(env_name)
|
||||||
|
if not env_config:
|
||||||
|
log.error(f"Environment '{env_name}' not found.")
|
||||||
|
raise ValueError(f"Environment '{env_name}' not found.")
|
||||||
|
|
||||||
|
client = SupersetClient(env_config)
|
||||||
|
client.authenticate()
|
||||||
|
|
||||||
|
dataset_response = client.get_dataset(dataset_id)
|
||||||
|
log.debug(f"Retrieved dataset structure for {dataset_id}")
|
||||||
|
return dataset_response.get('result') or {}
|
||||||
|
# [/DEF:_get_dataset_structure:Function]
|
||||||
|
|
||||||
|
# [/DEF:DebugPlugin:Class]
|
||||||
|
# [/DEF:DebugPluginModule:Module]
|
||||||
66
backend/src/plugins/git/llm_extension.py
Normal file
66
backend/src/plugins/git/llm_extension.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# [DEF:backend/src/plugins/git/llm_extension:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: git, llm, commit
|
||||||
|
# @PURPOSE: LLM-based extensions for the Git plugin, specifically for commit message generation.
|
||||||
|
# @LAYER: Domain
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.plugins.llm_analysis.service.LLMClient
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||||
|
from ..llm_analysis.service import LLMClient
|
||||||
|
from ...core.logger import belief_scope, logger
|
||||||
|
|
||||||
|
# [DEF:GitLLMExtension:Class]
|
||||||
|
# @PURPOSE: Provides LLM capabilities to the Git plugin.
|
||||||
|
class GitLLMExtension:
|
||||||
|
def __init__(self, client: LLMClient):
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
# [DEF:suggest_commit_message:Function]
|
||||||
|
# @PURPOSE: Generates a suggested commit message based on a diff and history.
|
||||||
|
# @PARAM: diff (str) - The git diff of staged changes.
|
||||||
|
# @PARAM: history (List[str]) - Recent commit messages for context.
|
||||||
|
# @RETURN: str - The suggested commit message.
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(2),
|
||||||
|
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||||
|
reraise=True
|
||||||
|
)
|
||||||
|
async def suggest_commit_message(self, diff: str, history: List[str]) -> str:
|
||||||
|
with belief_scope("suggest_commit_message"):
|
||||||
|
history_text = "\n".join(history)
|
||||||
|
prompt = f"""
|
||||||
|
Generate a concise and professional git commit message based on the following diff and recent history.
|
||||||
|
Use Conventional Commits format (e.g., feat: ..., fix: ..., docs: ...).
|
||||||
|
|
||||||
|
Recent History:
|
||||||
|
{history_text}
|
||||||
|
|
||||||
|
Diff:
|
||||||
|
{diff}
|
||||||
|
|
||||||
|
Commit Message:
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.debug(f"[suggest_commit_message] Calling LLM with model: {self.client.default_model}")
|
||||||
|
response = await self.client.client.chat.completions.create(
|
||||||
|
model=self.client.default_model,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"[suggest_commit_message] LLM Response: {response}")
|
||||||
|
|
||||||
|
if not response or not hasattr(response, 'choices') or not response.choices:
|
||||||
|
error_info = getattr(response, 'error', 'No choices in response')
|
||||||
|
logger.error(f"[suggest_commit_message] Invalid LLM response. Error info: {error_info}")
|
||||||
|
|
||||||
|
# If it's a timeout/provider error, we might want to throw to trigger retry if decorated
|
||||||
|
# but for now we return a safe fallback to avoid UI crash
|
||||||
|
return "Update dashboard configurations (LLM generation failed)"
|
||||||
|
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
# [/DEF:suggest_commit_message:Function]
|
||||||
|
# [/DEF:GitLLMExtension:Class]
|
||||||
|
|
||||||
|
# [/DEF:backend/src/plugins/git/llm_extension:Module]
|
||||||
399
backend/src/plugins/git_plugin.py
Normal file
399
backend/src/plugins/git_plugin.py
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# [DEF:backend.src.plugins.git_plugin:Module]
|
||||||
|
#
|
||||||
|
# @SEMANTICS: git, plugin, dashboard, version_control, sync, deploy
|
||||||
|
# @PURPOSE: Предоставляет плагин для версионирования и развертывания дашбордов Superset.
|
||||||
|
# @LAYER: Plugin
|
||||||
|
# @RELATION: INHERITS_FROM -> src.core.plugin_base.PluginBase
|
||||||
|
# @RELATION: USES -> src.services.git_service.GitService
|
||||||
|
# @RELATION: USES -> src.core.superset_client.SupersetClient
|
||||||
|
# @RELATION: USES -> src.core.config_manager.ConfigManager
|
||||||
|
# @RELATION: USES -> TaskContext
|
||||||
|
#
|
||||||
|
# @INVARIANT: Все операции с Git должны выполняться через GitService.
|
||||||
|
# @CONSTRAINT: Плагин работает только с распакованными YAML-экспортами Superset.
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from src.core.plugin_base import PluginBase
|
||||||
|
from src.services.git_service import GitService
|
||||||
|
from src.core.logger import logger as app_logger, belief_scope
|
||||||
|
from src.core.config_manager import ConfigManager
|
||||||
|
from src.core.superset_client import SupersetClient
|
||||||
|
from src.core.task_manager.context import TaskContext
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:GitPlugin:Class]
|
||||||
|
# @PURPOSE: Реализация плагина Git Integration для управления версиями дашбордов.
|
||||||
|
class GitPlugin(PluginBase):
|
||||||
|
|
||||||
|
# [DEF:__init__:Function]
|
||||||
|
# @PURPOSE: Инициализирует плагин и его зависимости.
|
||||||
|
# @PRE: config.json exists or shared config_manager is available.
|
||||||
|
# @POST: Инициализированы git_service и config_manager.
|
||||||
|
def __init__(self):
|
||||||
|
with belief_scope("GitPlugin.__init__"):
|
||||||
|
app_logger.info("Initializing GitPlugin.")
|
||||||
|
self.git_service = GitService()
|
||||||
|
|
||||||
|
# Robust config path resolution:
|
||||||
|
# 1. Try absolute path from src/dependencies.py style if possible
|
||||||
|
# 2. Try relative paths based on common execution patterns
|
||||||
|
if os.path.exists("../config.json"):
|
||||||
|
config_path = "../config.json"
|
||||||
|
elif os.path.exists("config.json"):
|
||||||
|
config_path = "config.json"
|
||||||
|
else:
|
||||||
|
# Fallback to the one initialized in dependencies if we can import it
|
||||||
|
try:
|
||||||
|
from src.dependencies import config_manager
|
||||||
|
self.config_manager = config_manager
|
||||||
|
app_logger.info("GitPlugin initialized using shared config_manager.")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
config_path = "config.json"
|
||||||
|
|
||||||
|
self.config_manager = ConfigManager(config_path)
|
||||||
|
app_logger.info(f"GitPlugin initialized with {config_path}")
|
||||||
|
# [/DEF:__init__:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:id:Function]
|
||||||
|
# @PURPOSE: Returns the plugin identifier.
|
||||||
|
# @PRE: GitPlugin is initialized.
|
||||||
|
# @POST: Returns 'git-integration'.
|
||||||
|
def id(self) -> str:
|
||||||
|
with belief_scope("GitPlugin.id"):
|
||||||
|
return "git-integration"
|
||||||
|
# [/DEF:id:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:name:Function]
|
||||||
|
# @PURPOSE: Returns the plugin name.
|
||||||
|
# @PRE: GitPlugin is initialized.
|
||||||
|
# @POST: Returns the human-readable name.
|
||||||
|
def name(self) -> str:
|
||||||
|
with belief_scope("GitPlugin.name"):
|
||||||
|
return "Git Integration"
|
||||||
|
# [/DEF:name:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:description:Function]
|
||||||
|
# @PURPOSE: Returns the plugin description.
|
||||||
|
# @PRE: GitPlugin is initialized.
|
||||||
|
# @POST: Returns the plugin's purpose description.
|
||||||
|
def description(self) -> str:
|
||||||
|
with belief_scope("GitPlugin.description"):
|
||||||
|
return "Version control for Superset dashboards"
|
||||||
|
# [/DEF:description:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:version:Function]
|
||||||
|
# @PURPOSE: Returns the plugin version.
|
||||||
|
# @PRE: GitPlugin is initialized.
|
||||||
|
# @POST: Returns the version string.
|
||||||
|
def version(self) -> str:
|
||||||
|
with belief_scope("GitPlugin.version"):
|
||||||
|
return "0.1.0"
|
||||||
|
# [/DEF:version:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:ui_route:Function]
|
||||||
|
# @PURPOSE: Returns the frontend route for the git plugin.
|
||||||
|
# @RETURN: str - "/git"
|
||||||
|
def ui_route(self) -> str:
|
||||||
|
with belief_scope("GitPlugin.ui_route"):
|
||||||
|
return "/git"
|
||||||
|
# [/DEF:ui_route:Function]
|
||||||
|
|
||||||
|
# [DEF:get_schema:Function]
|
||||||
|
# @PURPOSE: Возвращает JSON-схему параметров для выполнения задач плагина.
|
||||||
|
# @PRE: GitPlugin is initialized.
|
||||||
|
# @POST: Returns a JSON schema dictionary.
|
||||||
|
# @RETURN: Dict[str, Any] - Схема параметров.
|
||||||
|
def get_schema(self) -> Dict[str, Any]:
|
||||||
|
with belief_scope("GitPlugin.get_schema"):
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"operation": {"type": "string", "enum": ["sync", "deploy", "history"]},
|
||||||
|
"dashboard_id": {"type": "integer"},
|
||||||
|
"environment_id": {"type": "string"},
|
||||||
|
"source_env_id": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["operation", "dashboard_id"]
|
||||||
|
}
|
||||||
|
# [/DEF:get_schema:Function]
|
||||||
|
|
||||||
|
# [DEF:initialize:Function]
|
||||||
|
# @PURPOSE: Выполняет начальную настройку плагина.
|
||||||
|
# @PRE: GitPlugin is initialized.
|
||||||
|
# @POST: Плагин готов к выполнению задач.
|
||||||
|
async def initialize(self):
|
||||||
|
with belief_scope("GitPlugin.initialize"):
|
||||||
|
app_logger.info("[GitPlugin.initialize][Action] Initializing Git Integration Plugin logic.")
|
||||||
|
|
||||||
|
# [DEF:execute:Function]
|
||||||
|
# @PURPOSE: Основной метод выполнения задач плагина с поддержкой TaskContext.
|
||||||
|
# @PRE: task_data содержит 'operation' и 'dashboard_id'.
|
||||||
|
# @POST: Возвращает результат выполнения операции.
|
||||||
|
# @PARAM: task_data (Dict[str, Any]) - Данные задачи.
|
||||||
|
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||||
|
# @RETURN: Dict[str, Any] - Статус и сообщение.
|
||||||
|
# @RELATION: CALLS -> self._handle_sync
|
||||||
|
# @RELATION: CALLS -> self._handle_deploy
|
||||||
|
async def execute(self, task_data: Dict[str, Any], context: Optional[TaskContext] = None) -> Dict[str, Any]:
|
||||||
|
with belief_scope("GitPlugin.execute"):
|
||||||
|
operation = task_data.get("operation")
|
||||||
|
dashboard_id = task_data.get("dashboard_id")
|
||||||
|
|
||||||
|
# Use TaskContext logger if available, otherwise fall back to app_logger
|
||||||
|
log = context.logger if context else app_logger
|
||||||
|
|
||||||
|
# Create sub-loggers for different components
|
||||||
|
git_log = log.with_source("git") if context else log
|
||||||
|
superset_log = log.with_source("superset_api") if context else log
|
||||||
|
|
||||||
|
log.info(f"Executing operation: {operation} for dashboard {dashboard_id}")
|
||||||
|
|
||||||
|
if operation == "sync":
|
||||||
|
source_env_id = task_data.get("source_env_id")
|
||||||
|
result = await self._handle_sync(dashboard_id, source_env_id, log, git_log, superset_log)
|
||||||
|
elif operation == "deploy":
|
||||||
|
env_id = task_data.get("environment_id")
|
||||||
|
result = await self._handle_deploy(dashboard_id, env_id, log, git_log, superset_log)
|
||||||
|
elif operation == "history":
|
||||||
|
result = {"status": "success", "message": "History available via API"}
|
||||||
|
else:
|
||||||
|
log.error(f"Unknown operation: {operation}")
|
||||||
|
raise ValueError(f"Unknown operation: {operation}")
|
||||||
|
|
||||||
|
log.info(f"Operation {operation} completed.")
|
||||||
|
return result
|
||||||
|
# [/DEF:execute:Function]
|
||||||
|
|
||||||
|
# [DEF:_handle_sync:Function]
|
||||||
|
# @PURPOSE: Экспортирует дашборд из Superset и распаковывает в Git-репозиторий.
|
||||||
|
# @PRE: Репозиторий для дашборда должен существовать.
|
||||||
|
# @POST: Файлы в репозитории обновлены до текущего состояния в Superset.
|
||||||
|
# @PARAM: dashboard_id (int) - ID дашборда.
|
||||||
|
# @PARAM: source_env_id (Optional[str]) - ID исходного окружения.
|
||||||
|
# @RETURN: Dict[str, str] - Результат синхронизации.
|
||||||
|
# @SIDE_EFFECT: Изменяет файлы в локальной рабочей директории репозитория.
|
||||||
|
# @RELATION: CALLS -> src.services.git_service.GitService.get_repo
|
||||||
|
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.export_dashboard
|
||||||
|
async def _handle_sync(self, dashboard_id: int, source_env_id: Optional[str] = None, log=None, git_log=None, superset_log=None) -> Dict[str, str]:
|
||||||
|
with belief_scope("GitPlugin._handle_sync"):
|
||||||
|
try:
|
||||||
|
# 1. Получение репозитория
|
||||||
|
repo = self.git_service.get_repo(dashboard_id)
|
||||||
|
repo_path = Path(repo.working_dir)
|
||||||
|
git_log.info(f"Target repo path: {repo_path}")
|
||||||
|
|
||||||
|
# 2. Настройка клиента Superset
|
||||||
|
env = self._get_env(source_env_id)
|
||||||
|
client = SupersetClient(env)
|
||||||
|
client.authenticate()
|
||||||
|
|
||||||
|
# 3. Экспорт дашборда
|
||||||
|
superset_log.info(f"Exporting dashboard {dashboard_id} from {env.name}")
|
||||||
|
zip_bytes, _ = client.export_dashboard(dashboard_id)
|
||||||
|
|
||||||
|
# 4. Распаковка с выравниванием структуры (flattening)
|
||||||
|
git_log.info(f"Unpacking export to {repo_path}")
|
||||||
|
|
||||||
|
# Список папок/файлов, которые мы ожидаем от Superset
|
||||||
|
managed_dirs = ["dashboards", "charts", "datasets", "databases"]
|
||||||
|
managed_files = ["metadata.yaml"]
|
||||||
|
|
||||||
|
# Очистка старых данных перед распаковкой, чтобы не оставалось "призраков"
|
||||||
|
for d in managed_dirs:
|
||||||
|
d_path = repo_path / d
|
||||||
|
if d_path.exists() and d_path.is_dir():
|
||||||
|
shutil.rmtree(d_path)
|
||||||
|
for f in managed_files:
|
||||||
|
f_path = repo_path / f
|
||||||
|
if f_path.exists():
|
||||||
|
f_path.unlink()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||||||
|
# Superset экспортирует всё в подпапку dashboard_export_timestamp/
|
||||||
|
# Нам нужно найти это имя папки
|
||||||
|
namelist = zf.namelist()
|
||||||
|
if not namelist:
|
||||||
|
raise ValueError("Export ZIP is empty")
|
||||||
|
|
||||||
|
root_folder = namelist[0].split('/')[0]
|
||||||
|
git_log.info(f"Detected root folder in ZIP: {root_folder}")
|
||||||
|
|
||||||
|
for member in zf.infolist():
|
||||||
|
if member.filename.startswith(root_folder + "/") and len(member.filename) > len(root_folder) + 1:
|
||||||
|
# Убираем префикс папки
|
||||||
|
relative_path = member.filename[len(root_folder)+1:]
|
||||||
|
target_path = repo_path / relative_path
|
||||||
|
|
||||||
|
if member.is_dir():
|
||||||
|
target_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zf.open(member) as source, open(target_path, "wb") as target:
|
||||||
|
shutil.copyfileobj(source, target)
|
||||||
|
|
||||||
|
# 5. Автоматический staging изменений (не коммит, чтобы юзер мог проверить diff)
|
||||||
|
try:
|
||||||
|
repo.git.add(A=True)
|
||||||
|
app_logger.info("[_handle_sync][Action] Changes staged in git")
|
||||||
|
except Exception as ge:
|
||||||
|
app_logger.warning(f"[_handle_sync][Action] Failed to stage changes: {ge}")
|
||||||
|
|
||||||
|
app_logger.info(f"[_handle_sync][Coherence:OK] Dashboard {dashboard_id} synced successfully.")
|
||||||
|
return {"status": "success", "message": "Dashboard synced and flattened in local repository"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(f"[_handle_sync][Coherence:Failed] Sync failed: {e}")
|
||||||
|
raise
|
||||||
|
# [/DEF:_handle_sync:Function]
|
||||||
|
|
||||||
|
# [DEF:_handle_deploy:Function]
|
||||||
|
# @PURPOSE: Упаковывает репозиторий в ZIP и импортирует в целевое окружение Superset.
|
||||||
|
# @PRE: environment_id должен соответствовать настроенному окружению.
|
||||||
|
# @POST: Дашборд импортирован в целевой Superset.
|
||||||
|
# @PARAM: dashboard_id (int) - ID дашборда.
|
||||||
|
# @PARAM: env_id (str) - ID целевого окружения.
|
||||||
|
# @PARAM: log - Main logger instance.
|
||||||
|
# @PARAM: git_log - Git-specific logger instance.
|
||||||
|
# @PARAM: superset_log - Superset API-specific logger instance.
|
||||||
|
# @RETURN: Dict[str, Any] - Результат деплоя.
|
||||||
|
# @SIDE_EFFECT: Создает и удаляет временный ZIP-файл.
|
||||||
|
# @RELATION: CALLS -> src.core.superset_client.SupersetClient.import_dashboard
|
||||||
|
async def _handle_deploy(self, dashboard_id: int, env_id: str, log=None, git_log=None, superset_log=None) -> Dict[str, Any]:
|
||||||
|
with belief_scope("GitPlugin._handle_deploy"):
|
||||||
|
try:
|
||||||
|
if not env_id:
|
||||||
|
raise ValueError("Target environment ID required for deployment")
|
||||||
|
|
||||||
|
# 1. Получение репозитория
|
||||||
|
repo = self.git_service.get_repo(dashboard_id)
|
||||||
|
repo_path = Path(repo.working_dir)
|
||||||
|
|
||||||
|
# 2. Упаковка в ZIP
|
||||||
|
git_log.info(f"Packing repository {repo_path} for deployment.")
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
|
||||||
|
# Superset expects a root directory in the ZIP (e.g., dashboard_export_20240101T000000/)
|
||||||
|
root_dir_name = f"dashboard_export_{dashboard_id}"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for root, dirs, files in os.walk(repo_path):
|
||||||
|
if ".git" in dirs:
|
||||||
|
dirs.remove(".git")
|
||||||
|
for file in files:
|
||||||
|
if file == ".git" or file.endswith(".zip"):
|
||||||
|
continue
|
||||||
|
file_path = Path(root) / file
|
||||||
|
# Prepend the root directory name to the archive path
|
||||||
|
arcname = Path(root_dir_name) / file_path.relative_to(repo_path)
|
||||||
|
zf.write(file_path, arcname)
|
||||||
|
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
|
||||||
|
# 3. Настройка клиента Superset
|
||||||
|
env = self.config_manager.get_environment(env_id)
|
||||||
|
if not env:
|
||||||
|
raise ValueError(f"Environment {env_id} not found")
|
||||||
|
|
||||||
|
client = SupersetClient(env)
|
||||||
|
client.authenticate()
|
||||||
|
|
||||||
|
# 4. Импорт
|
||||||
|
temp_zip_path = repo_path / f"deploy_{dashboard_id}.zip"
|
||||||
|
git_log.info(f"Saving temporary zip to {temp_zip_path}")
|
||||||
|
with open(temp_zip_path, "wb") as f:
|
||||||
|
f.write(zip_buffer.getvalue())
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_logger.info(f"[_handle_deploy][Action] Importing dashboard to {env.name}")
|
||||||
|
result = client.import_dashboard(temp_zip_path)
|
||||||
|
app_logger.info(f"[_handle_deploy][Coherence:OK] Deployment successful for dashboard {dashboard_id}.")
|
||||||
|
return {"status": "success", "message": f"Dashboard deployed to {env.name}", "details": result}
|
||||||
|
finally:
|
||||||
|
if temp_zip_path.exists():
|
||||||
|
os.remove(temp_zip_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app_logger.error(f"[_handle_deploy][Coherence:Failed] Deployment failed: {e}")
|
||||||
|
raise
|
||||||
|
# [/DEF:_handle_deploy:Function]
|
||||||
|
|
||||||
|
# [DEF:_get_env:Function]
|
||||||
|
# @PURPOSE: Вспомогательный метод для получения конфигурации окружения.
|
||||||
|
# @PARAM: env_id (Optional[str]) - ID окружения.
|
||||||
|
# @PRE: env_id is a string or None.
|
||||||
|
# @POST: Returns an Environment object from config or DB.
|
||||||
|
# @RETURN: Environment - Объект конфигурации окружения.
|
||||||
|
def _get_env(self, env_id: Optional[str] = None):
|
||||||
|
with belief_scope("GitPlugin._get_env"):
|
||||||
|
app_logger.info(f"[_get_env][Entry] Fetching environment for ID: {env_id}")
|
||||||
|
|
||||||
|
# Priority 1: ConfigManager (config.json)
|
||||||
|
if env_id:
|
||||||
|
env = self.config_manager.get_environment(env_id)
|
||||||
|
if env:
|
||||||
|
app_logger.info(f"[_get_env][Exit] Found environment by ID in ConfigManager: {env.name}")
|
||||||
|
return env
|
||||||
|
|
||||||
|
# Priority 2: Database (DeploymentEnvironment)
|
||||||
|
from src.core.database import SessionLocal
|
||||||
|
from src.models.git import DeploymentEnvironment
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if env_id:
|
||||||
|
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.id == env_id).first()
|
||||||
|
else:
|
||||||
|
# If no ID, try to find active or any environment in DB
|
||||||
|
db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.is_active).first()
|
||||||
|
if not db_env:
|
||||||
|
db_env = db.query(DeploymentEnvironment).first()
|
||||||
|
|
||||||
|
if db_env:
|
||||||
|
app_logger.info(f"[_get_env][Exit] Found environment in DB: {db_env.name}")
|
||||||
|
from src.core.config_models import Environment
|
||||||
|
# Use token as password for SupersetClient
|
||||||
|
return Environment(
|
||||||
|
id=db_env.id,
|
||||||
|
name=db_env.name,
|
||||||
|
url=db_env.superset_url,
|
||||||
|
username="admin",
|
||||||
|
password=db_env.superset_token,
|
||||||
|
verify_ssl=True
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Priority 3: ConfigManager Default (if no env_id provided)
|
||||||
|
envs = self.config_manager.get_environments()
|
||||||
|
if envs:
|
||||||
|
if env_id:
|
||||||
|
# If env_id was provided but not found in DB or specifically by ID in config,
|
||||||
|
# but we have other envs, maybe it's one of them?
|
||||||
|
env = next((e for e in envs if e.id == env_id), None)
|
||||||
|
if env:
|
||||||
|
app_logger.info(f"[_get_env][Exit] Found environment {env_id} in ConfigManager list")
|
||||||
|
return env
|
||||||
|
|
||||||
|
if not env_id:
|
||||||
|
app_logger.info(f"[_get_env][Exit] Using first environment from ConfigManager: {envs[0].name}")
|
||||||
|
return envs[0]
|
||||||
|
|
||||||
|
app_logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}")
|
||||||
|
raise ValueError("No environments configured. Please add a Superset Environment in Settings.")
|
||||||
|
# [/DEF:_get_env:Function]
|
||||||
|
|
||||||
|
# [/DEF:initialize:Function]
|
||||||
|
# [/DEF:GitPlugin:Class]
|
||||||
|
# [/DEF:backend.src.plugins.git_plugin:Module]
|
||||||
14
backend/src/plugins/llm_analysis/__init__.py
Normal file
14
backend/src/plugins/llm_analysis/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# [DEF:backend/src/plugins/llm_analysis/__init__.py:Module]
|
||||||
|
# @TIER: TRIVIAL
|
||||||
|
# @PURPOSE: Initialize the LLM Analysis plugin package.
|
||||||
|
# @LAYER: Domain
|
||||||
|
|
||||||
|
"""
|
||||||
|
LLM Analysis Plugin for automated dashboard validation and dataset documentation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .plugin import DashboardValidationPlugin, DocumentationPlugin
|
||||||
|
|
||||||
|
__all__ = ['DashboardValidationPlugin', 'DocumentationPlugin']
|
||||||
|
|
||||||
|
# [/DEF:backend/src/plugins/llm_analysis/__init__.py:Module]
|
||||||
61
backend/src/plugins/llm_analysis/models.py
Normal file
61
backend/src/plugins/llm_analysis/models.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# [DEF:backend/src/plugins/llm_analysis/models.py:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: pydantic, models, llm
|
||||||
|
# @PURPOSE: Define Pydantic models for LLM Analysis plugin.
|
||||||
|
# @LAYER: Domain
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
# [DEF:LLMProviderType:Class]
|
||||||
|
# @PURPOSE: Enum for supported LLM providers.
|
||||||
|
class LLMProviderType(str, Enum):
|
||||||
|
OPENAI = "openai"
|
||||||
|
OPENROUTER = "openrouter"
|
||||||
|
KILO = "kilo"
|
||||||
|
# [/DEF:LLMProviderType:Class]
|
||||||
|
|
||||||
|
# [DEF:LLMProviderConfig:Class]
|
||||||
|
# @PURPOSE: Configuration for an LLM provider.
|
||||||
|
class LLMProviderConfig(BaseModel):
|
||||||
|
id: Optional[str] = None
|
||||||
|
provider_type: LLMProviderType
|
||||||
|
name: str
|
||||||
|
base_url: str
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
default_model: str
|
||||||
|
is_active: bool = True
|
||||||
|
# [/DEF:LLMProviderConfig:Class]
|
||||||
|
|
||||||
|
# [DEF:ValidationStatus:Class]
|
||||||
|
# @PURPOSE: Enum for dashboard validation status.
|
||||||
|
class ValidationStatus(str, Enum):
|
||||||
|
PASS = "PASS"
|
||||||
|
WARN = "WARN"
|
||||||
|
FAIL = "FAIL"
|
||||||
|
# [/DEF:ValidationStatus:Class]
|
||||||
|
|
||||||
|
# [DEF:DetectedIssue:Class]
|
||||||
|
# @PURPOSE: Model for a single issue detected during validation.
|
||||||
|
class DetectedIssue(BaseModel):
|
||||||
|
severity: ValidationStatus
|
||||||
|
message: str
|
||||||
|
location: Optional[str] = None
|
||||||
|
# [/DEF:DetectedIssue:Class]
|
||||||
|
|
||||||
|
# [DEF:ValidationResult:Class]
|
||||||
|
# @PURPOSE: Model for dashboard validation result.
|
||||||
|
class ValidationResult(BaseModel):
|
||||||
|
id: Optional[str] = None
|
||||||
|
dashboard_id: str
|
||||||
|
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
status: ValidationStatus
|
||||||
|
screenshot_path: Optional[str] = None
|
||||||
|
issues: List[DetectedIssue]
|
||||||
|
summary: str
|
||||||
|
raw_response: Optional[str] = None
|
||||||
|
# [/DEF:ValidationResult:Class]
|
||||||
|
|
||||||
|
# [/DEF:backend/src/plugins/llm_analysis/models.py:Module]
|
||||||
391
backend/src/plugins/llm_analysis/plugin.py
Normal file
391
backend/src/plugins/llm_analysis/plugin.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
# [DEF:backend/src/plugins/llm_analysis/plugin.py:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: plugin, llm, analysis, documentation
|
||||||
|
# @PURPOSE: Implements DashboardValidationPlugin and DocumentationPlugin.
|
||||||
|
# @LAYER: Domain
|
||||||
|
# @RELATION: INHERITS -> backend.src.core.plugin_base.PluginBase
|
||||||
|
# @RELATION: CALLS -> backend.src.plugins.llm_analysis.service.ScreenshotService
|
||||||
|
# @RELATION: CALLS -> backend.src.plugins.llm_analysis.service.LLMClient
|
||||||
|
# @RELATION: CALLS -> backend.src.services.llm_provider.LLMProviderService
|
||||||
|
# @RELATION: USES -> TaskContext
|
||||||
|
# @INVARIANT: All LLM interactions must be executed as asynchronous tasks.
|
||||||
|
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from ...core.plugin_base import PluginBase
|
||||||
|
from ...core.logger import belief_scope, logger
|
||||||
|
from ...core.database import SessionLocal
|
||||||
|
from ...services.llm_provider import LLMProviderService
|
||||||
|
from ...core.superset_client import SupersetClient
|
||||||
|
from .service import ScreenshotService, LLMClient
|
||||||
|
from .models import LLMProviderType, ValidationStatus, ValidationResult, DetectedIssue
|
||||||
|
from ...models.llm import ValidationRecord
|
||||||
|
from ...core.task_manager.context import TaskContext
|
||||||
|
|
||||||
|
# [DEF:DashboardValidationPlugin:Class]
|
||||||
|
# @PURPOSE: Plugin for automated dashboard health analysis using LLMs.
|
||||||
|
# @RELATION: IMPLEMENTS -> backend.src.core.plugin_base.PluginBase
|
||||||
|
class DashboardValidationPlugin(PluginBase):
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return "llm_dashboard_validation"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "Dashboard LLM Validation"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return "Automated dashboard health analysis using multimodal LLMs."
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self) -> str:
|
||||||
|
return "1.0.0"
|
||||||
|
|
||||||
|
def get_schema(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dashboard_id": {"type": "string", "title": "Dashboard ID"},
|
||||||
|
"environment_id": {"type": "string", "title": "Environment ID"},
|
||||||
|
"provider_id": {"type": "string", "title": "LLM Provider ID"}
|
||||||
|
},
|
||||||
|
"required": ["dashboard_id", "environment_id", "provider_id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# [DEF:DashboardValidationPlugin.execute:Function]
|
||||||
|
# @PURPOSE: Executes the dashboard validation task with TaskContext support.
|
||||||
|
# @PARAM: params (Dict[str, Any]) - Validation parameters.
|
||||||
|
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||||
|
# @PRE: params contains dashboard_id, environment_id, and provider_id.
|
||||||
|
# @POST: Returns a dictionary with validation results and persists them to the database.
|
||||||
|
# @SIDE_EFFECT: Captures a screenshot, calls LLM API, and writes to the database.
|
||||||
|
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
|
||||||
|
with belief_scope("execute", f"plugin_id={self.id}"):
|
||||||
|
# Use TaskContext logger if available, otherwise fall back to app logger
|
||||||
|
log = context.logger if context else logger
|
||||||
|
|
||||||
|
# Create sub-loggers for different components
|
||||||
|
llm_log = log.with_source("llm") if context else log
|
||||||
|
screenshot_log = log.with_source("screenshot") if context else log
|
||||||
|
superset_log = log.with_source("superset_api") if context else log
|
||||||
|
|
||||||
|
log.info(f"Executing {self.name} with params: {params}")
|
||||||
|
|
||||||
|
dashboard_id = params.get("dashboard_id")
|
||||||
|
env_id = params.get("environment_id")
|
||||||
|
provider_id = params.get("provider_id")
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# 1. Get Environment
|
||||||
|
from ...dependencies import get_config_manager
|
||||||
|
config_mgr = get_config_manager()
|
||||||
|
env = config_mgr.get_environment(env_id)
|
||||||
|
if not env:
|
||||||
|
log.error(f"Environment {env_id} not found")
|
||||||
|
raise ValueError(f"Environment {env_id} not found")
|
||||||
|
|
||||||
|
# 2. Get LLM Provider
|
||||||
|
llm_service = LLMProviderService(db)
|
||||||
|
db_provider = llm_service.get_provider(provider_id)
|
||||||
|
if not db_provider:
|
||||||
|
log.error(f"LLM Provider {provider_id} not found")
|
||||||
|
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||||
|
|
||||||
|
llm_log.debug("Retrieved provider config:")
|
||||||
|
llm_log.debug(f" Provider ID: {db_provider.id}")
|
||||||
|
llm_log.debug(f" Provider Name: {db_provider.name}")
|
||||||
|
llm_log.debug(f" Provider Type: {db_provider.provider_type}")
|
||||||
|
llm_log.debug(f" Base URL: {db_provider.base_url}")
|
||||||
|
llm_log.debug(f" Default Model: {db_provider.default_model}")
|
||||||
|
llm_log.debug(f" Is Active: {db_provider.is_active}")
|
||||||
|
|
||||||
|
api_key = llm_service.get_decrypted_api_key(provider_id)
|
||||||
|
llm_log.debug(f"API Key decrypted (first 8 chars): {api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}...")
|
||||||
|
|
||||||
|
# Check if API key was successfully decrypted
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError(
|
||||||
|
f"Failed to decrypt API key for provider {provider_id}. "
|
||||||
|
f"The provider may have been encrypted with a different encryption key. "
|
||||||
|
f"Please update the provider with a new API key through the UI."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Capture Screenshot
|
||||||
|
screenshot_service = ScreenshotService(env)
|
||||||
|
|
||||||
|
storage_root = config_mgr.get_config().settings.storage.root_path
|
||||||
|
screenshots_dir = os.path.join(storage_root, "screenshots")
|
||||||
|
os.makedirs(screenshots_dir, exist_ok=True)
|
||||||
|
|
||||||
|
filename = f"{dashboard_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||||
|
screenshot_path = os.path.join(screenshots_dir, filename)
|
||||||
|
|
||||||
|
screenshot_log.info(f"Capturing screenshot for dashboard {dashboard_id}")
|
||||||
|
await screenshot_service.capture_dashboard(dashboard_id, screenshot_path)
|
||||||
|
screenshot_log.debug(f"Screenshot saved to: {screenshot_path}")
|
||||||
|
|
||||||
|
# 4. Fetch Logs (from Environment /api/v1/log/)
|
||||||
|
logs = []
|
||||||
|
try:
|
||||||
|
client = SupersetClient(env)
|
||||||
|
|
||||||
|
# Calculate time window (last 24 hours)
|
||||||
|
start_time = (datetime.now() - timedelta(hours=24)).isoformat()
|
||||||
|
|
||||||
|
# Construct filter for logs
|
||||||
|
# Note: We filter by dashboard_id matching the object
|
||||||
|
query_params = {
|
||||||
|
"filters": [
|
||||||
|
{"col": "dashboard_id", "opr": "eq", "value": dashboard_id},
|
||||||
|
{"col": "dttm", "opr": "gt", "value": start_time}
|
||||||
|
],
|
||||||
|
"order_column": "dttm",
|
||||||
|
"order_direction": "desc",
|
||||||
|
"page": 0,
|
||||||
|
"page_size": 100
|
||||||
|
}
|
||||||
|
|
||||||
|
superset_log.debug(f"Fetching logs for dashboard {dashboard_id}")
|
||||||
|
response = client.network.request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/log/",
|
||||||
|
params={"q": json.dumps(query_params)}
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(response, dict) and "result" in response:
|
||||||
|
for item in response["result"]:
|
||||||
|
action = item.get("action", "unknown")
|
||||||
|
dttm = item.get("dttm", "")
|
||||||
|
details = item.get("json", "")
|
||||||
|
logs.append(f"[{dttm}] {action}: {details}")
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
logs = ["No recent logs found for this dashboard."]
|
||||||
|
superset_log.debug("No recent logs found for this dashboard")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
superset_log.warning(f"Failed to fetch logs from environment: {e}")
|
||||||
|
logs = [f"Error fetching remote logs: {str(e)}"]
|
||||||
|
|
||||||
|
# 5. Analyze with LLM
|
||||||
|
llm_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
|
||||||
|
)
|
||||||
|
|
||||||
|
llm_log.info(f"Analyzing dashboard {dashboard_id} with LLM")
|
||||||
|
analysis = await llm_client.analyze_dashboard(screenshot_path, logs)
|
||||||
|
|
||||||
|
# Log analysis summary to task logs for better visibility
|
||||||
|
llm_log.info(f"[ANALYSIS_SUMMARY] Status: {analysis['status']}")
|
||||||
|
llm_log.info(f"[ANALYSIS_SUMMARY] Summary: {analysis['summary']}")
|
||||||
|
if analysis.get("issues"):
|
||||||
|
for i, issue in enumerate(analysis["issues"]):
|
||||||
|
llm_log.info(f"[ANALYSIS_ISSUE][{i+1}] {issue.get('severity')}: {issue.get('message')} (Location: {issue.get('location', 'N/A')})")
|
||||||
|
|
||||||
|
# 6. Persist Result
|
||||||
|
validation_result = ValidationResult(
|
||||||
|
dashboard_id=dashboard_id,
|
||||||
|
status=ValidationStatus(analysis["status"]),
|
||||||
|
summary=analysis["summary"],
|
||||||
|
issues=[DetectedIssue(**issue) for issue in analysis["issues"]],
|
||||||
|
screenshot_path=screenshot_path,
|
||||||
|
raw_response=str(analysis)
|
||||||
|
)
|
||||||
|
|
||||||
|
db_record = ValidationRecord(
|
||||||
|
dashboard_id=validation_result.dashboard_id,
|
||||||
|
status=validation_result.status.value,
|
||||||
|
summary=validation_result.summary,
|
||||||
|
issues=[issue.dict() for issue in validation_result.issues],
|
||||||
|
screenshot_path=validation_result.screenshot_path,
|
||||||
|
raw_response=validation_result.raw_response
|
||||||
|
)
|
||||||
|
db.add(db_record)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 7. Notification on failure (US1 / FR-015)
|
||||||
|
if validation_result.status == ValidationStatus.FAIL:
|
||||||
|
log.warning(f"Dashboard {dashboard_id} validation FAILED. Summary: {validation_result.summary}")
|
||||||
|
# Placeholder for Email/Pulse notification dispatch
|
||||||
|
# In a real implementation, we would call a NotificationService here
|
||||||
|
# with a payload containing the summary and a link to the report.
|
||||||
|
|
||||||
|
# Final log to ensure all analysis is visible in task logs
|
||||||
|
log.info(f"Validation completed for dashboard {dashboard_id}. Status: {validation_result.status.value}")
|
||||||
|
|
||||||
|
return validation_result.dict()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
# [/DEF:DashboardValidationPlugin.execute:Function]
|
||||||
|
# [/DEF:DashboardValidationPlugin:Class]
|
||||||
|
|
||||||
|
# [DEF:DocumentationPlugin:Class]
|
||||||
|
# @PURPOSE: Plugin for automated dataset documentation using LLMs.
|
||||||
|
# @RELATION: IMPLEMENTS -> backend.src.core.plugin_base.PluginBase
|
||||||
|
class DocumentationPlugin(PluginBase):
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return "llm_documentation"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "Dataset LLM Documentation"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return "Automated dataset and column documentation using LLMs."
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self) -> str:
|
||||||
|
return "1.0.0"
|
||||||
|
|
||||||
|
def get_schema(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dataset_id": {"type": "string", "title": "Dataset ID"},
|
||||||
|
"environment_id": {"type": "string", "title": "Environment ID"},
|
||||||
|
"provider_id": {"type": "string", "title": "LLM Provider ID"}
|
||||||
|
},
|
||||||
|
"required": ["dataset_id", "environment_id", "provider_id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# [DEF:DocumentationPlugin.execute:Function]
|
||||||
|
# @PURPOSE: Executes the dataset documentation task with TaskContext support.
|
||||||
|
# @PARAM: params (Dict[str, Any]) - Documentation parameters.
|
||||||
|
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||||
|
# @PRE: params contains dataset_id, environment_id, and provider_id.
|
||||||
|
# @POST: Returns generated documentation and updates the dataset in Superset.
|
||||||
|
# @SIDE_EFFECT: Calls LLM API and updates dataset metadata in Superset.
|
||||||
|
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None):
|
||||||
|
with belief_scope("execute", f"plugin_id={self.id}"):
|
||||||
|
# Use TaskContext logger if available, otherwise fall back to app logger
|
||||||
|
log = context.logger if context else logger
|
||||||
|
|
||||||
|
# Create sub-loggers for different components
|
||||||
|
llm_log = log.with_source("llm") if context else log
|
||||||
|
superset_log = log.with_source("superset_api") if context else log
|
||||||
|
|
||||||
|
log.info(f"Executing {self.name} with params: {params}")
|
||||||
|
|
||||||
|
dataset_id = params.get("dataset_id")
|
||||||
|
env_id = params.get("environment_id")
|
||||||
|
provider_id = params.get("provider_id")
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# 1. Get Environment
|
||||||
|
from ...dependencies import get_config_manager
|
||||||
|
config_mgr = get_config_manager()
|
||||||
|
env = config_mgr.get_environment(env_id)
|
||||||
|
if not env:
|
||||||
|
log.error(f"Environment {env_id} not found")
|
||||||
|
raise ValueError(f"Environment {env_id} not found")
|
||||||
|
|
||||||
|
# 2. Get LLM Provider
|
||||||
|
llm_service = LLMProviderService(db)
|
||||||
|
db_provider = llm_service.get_provider(provider_id)
|
||||||
|
if not db_provider:
|
||||||
|
log.error(f"LLM Provider {provider_id} not found")
|
||||||
|
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||||
|
|
||||||
|
llm_log.debug("Retrieved provider config:")
|
||||||
|
llm_log.debug(f" Provider ID: {db_provider.id}")
|
||||||
|
llm_log.debug(f" Provider Name: {db_provider.name}")
|
||||||
|
llm_log.debug(f" Provider Type: {db_provider.provider_type}")
|
||||||
|
llm_log.debug(f" Base URL: {db_provider.base_url}")
|
||||||
|
llm_log.debug(f" Default Model: {db_provider.default_model}")
|
||||||
|
|
||||||
|
api_key = llm_service.get_decrypted_api_key(provider_id)
|
||||||
|
llm_log.debug(f"API Key decrypted (first 8 chars): {api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}...")
|
||||||
|
|
||||||
|
# Check if API key was successfully decrypted
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError(
|
||||||
|
f"Failed to decrypt API key for provider {provider_id}. "
|
||||||
|
f"The provider may have been encrypted with a different encryption key. "
|
||||||
|
f"Please update the provider with a new API key through the UI."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Fetch Metadata (US2 / T024)
|
||||||
|
from ...core.superset_client import SupersetClient
|
||||||
|
client = SupersetClient(env)
|
||||||
|
|
||||||
|
superset_log.debug(f"Fetching dataset {dataset_id}")
|
||||||
|
dataset = client.get_dataset(int(dataset_id))
|
||||||
|
|
||||||
|
# Extract columns and existing descriptions
|
||||||
|
columns_data = []
|
||||||
|
for col in dataset.get("columns", []):
|
||||||
|
columns_data.append({
|
||||||
|
"name": col.get("column_name"),
|
||||||
|
"type": col.get("type"),
|
||||||
|
"description": col.get("description")
|
||||||
|
})
|
||||||
|
superset_log.debug(f"Extracted {len(columns_data)} columns from dataset")
|
||||||
|
|
||||||
|
# 4. Construct Prompt & Analyze (US2 / T025)
|
||||||
|
llm_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
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
Generate professional documentation for the following dataset and its columns.
|
||||||
|
Dataset: {dataset.get('table_name')}
|
||||||
|
Columns: {columns_data}
|
||||||
|
|
||||||
|
Provide the documentation in JSON format:
|
||||||
|
{{
|
||||||
|
"dataset_description": "General description of the dataset",
|
||||||
|
"column_descriptions": [
|
||||||
|
{{
|
||||||
|
"name": "column_name",
|
||||||
|
"description": "Generated description"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Using a generic chat completion for text-only US2
|
||||||
|
llm_log.info(f"Generating documentation for dataset {dataset_id}")
|
||||||
|
doc_result = await llm_client.get_json_completion([{"role": "user", "content": prompt}])
|
||||||
|
|
||||||
|
# 5. Update Metadata (US2 / T026)
|
||||||
|
update_payload = {
|
||||||
|
"description": doc_result["dataset_description"],
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map generated descriptions back to column IDs
|
||||||
|
for col_doc in doc_result["column_descriptions"]:
|
||||||
|
for col in dataset.get("columns", []):
|
||||||
|
if col.get("column_name") == col_doc["name"]:
|
||||||
|
update_payload["columns"].append({
|
||||||
|
"id": col.get("id"),
|
||||||
|
"description": col_doc["description"]
|
||||||
|
})
|
||||||
|
|
||||||
|
superset_log.info(f"Updating dataset {dataset_id} with generated documentation")
|
||||||
|
client.update_dataset(int(dataset_id), update_payload)
|
||||||
|
|
||||||
|
log.info(f"Documentation completed for dataset {dataset_id}")
|
||||||
|
|
||||||
|
return doc_result
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
# [/DEF:DocumentationPlugin.execute:Function]
|
||||||
|
# [/DEF:DocumentationPlugin:Class]
|
||||||
|
|
||||||
|
# [/DEF:backend/src/plugins/llm_analysis/plugin.py:Module]
|
||||||
62
backend/src/plugins/llm_analysis/scheduler.py
Normal file
62
backend/src/plugins/llm_analysis/scheduler.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# [DEF:backend/src/plugins/llm_analysis/scheduler.py:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: scheduler, task, automation
|
||||||
|
# @PURPOSE: Provides helper functions to schedule LLM-based validation tasks.
|
||||||
|
# @LAYER: Domain
|
||||||
|
# @RELATION: DEPENDS_ON -> backend.src.core.scheduler
|
||||||
|
|
||||||
|
from typing import Dict, Any
|
||||||
|
from ...dependencies import get_task_manager, get_scheduler_service
|
||||||
|
from ...core.logger import belief_scope, logger
|
||||||
|
|
||||||
|
# [DEF:schedule_dashboard_validation:Function]
|
||||||
|
# @PURPOSE: Schedules a recurring dashboard validation task.
|
||||||
|
# @PARAM: dashboard_id (str) - ID of the dashboard to validate.
|
||||||
|
# @PARAM: cron_expression (str) - Standard cron expression for scheduling.
|
||||||
|
# @PARAM: params (Dict[str, Any]) - Task parameters (environment_id, provider_id).
|
||||||
|
# @SIDE_EFFECT: Adds a job to the scheduler service.
|
||||||
|
def schedule_dashboard_validation(dashboard_id: str, cron_expression: str, params: Dict[str, Any]):
|
||||||
|
with belief_scope("schedule_dashboard_validation", f"dashboard_id={dashboard_id}"):
|
||||||
|
scheduler = get_scheduler_service()
|
||||||
|
task_manager = get_task_manager()
|
||||||
|
|
||||||
|
job_id = f"llm_val_{dashboard_id}"
|
||||||
|
|
||||||
|
async def job_func():
|
||||||
|
await task_manager.create_task(
|
||||||
|
plugin_id="llm_dashboard_validation",
|
||||||
|
params={
|
||||||
|
"dashboard_id": dashboard_id,
|
||||||
|
**params
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
job_func,
|
||||||
|
"cron",
|
||||||
|
id=job_id,
|
||||||
|
replace_existing=True,
|
||||||
|
**_parse_cron(cron_expression)
|
||||||
|
)
|
||||||
|
logger.info(f"Scheduled validation for dashboard {dashboard_id} with cron {cron_expression}")
|
||||||
|
# [/DEF:schedule_dashboard_validation:Function]
|
||||||
|
|
||||||
|
# [DEF:_parse_cron:Function]
|
||||||
|
# @PURPOSE: Basic cron parser placeholder.
|
||||||
|
# @PARAM: cron (str) - Cron expression.
|
||||||
|
# @RETURN: Dict[str, str] - Parsed cron parts.
|
||||||
|
def _parse_cron(cron: str) -> Dict[str, str]:
|
||||||
|
# Basic cron parser placeholder
|
||||||
|
parts = cron.split()
|
||||||
|
if len(parts) != 5:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"minute": parts[0],
|
||||||
|
"hour": parts[1],
|
||||||
|
"day": parts[2],
|
||||||
|
"month": parts[3],
|
||||||
|
"day_of_week": parts[4]
|
||||||
|
}
|
||||||
|
# [/DEF:_parse_cron:Function]
|
||||||
|
|
||||||
|
# [/DEF:backend/src/plugins/llm_analysis/scheduler.py:Module]
|
||||||
632
backend/src/plugins/llm_analysis/service.py
Normal file
632
backend/src/plugins/llm_analysis/service.py
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
# [DEF:backend/src/plugins/llm_analysis/service.py:Module]
|
||||||
|
# @TIER: STANDARD
|
||||||
|
# @SEMANTICS: service, llm, screenshot, playwright, openai
|
||||||
|
# @PURPOSE: Services for LLM interaction and dashboard screenshots.
|
||||||
|
# @LAYER: Domain
|
||||||
|
# @RELATION: DEPENDS_ON -> playwright
|
||||||
|
# @RELATION: DEPENDS_ON -> openai
|
||||||
|
# @RELATION: DEPENDS_ON -> tenacity
|
||||||
|
# @INVARIANT: Screenshots must be 1920px width and capture full page height.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import io
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from PIL import Image
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
from openai import AsyncOpenAI, RateLimitError, AuthenticationError as OpenAIAuthenticationError
|
||||||
|
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
|
||||||
|
from .models import LLMProviderType
|
||||||
|
from ...core.logger import belief_scope, logger
|
||||||
|
from ...core.config_models import Environment
|
||||||
|
|
||||||
|
# [DEF:ScreenshotService:Class]
|
||||||
|
# @PURPOSE: Handles capturing screenshots of Superset dashboards.
|
||||||
|
class ScreenshotService:
|
||||||
|
# [DEF:ScreenshotService.__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the ScreenshotService with environment configuration.
|
||||||
|
# @PRE: env is a valid Environment object.
|
||||||
|
def __init__(self, env: Environment):
|
||||||
|
self.env = env
|
||||||
|
# [/DEF:ScreenshotService.__init__:Function]
|
||||||
|
|
||||||
|
# [DEF:ScreenshotService.capture_dashboard:Function]
|
||||||
|
# @PURPOSE: Captures a full-page screenshot of a dashboard using Playwright and CDP.
|
||||||
|
# @PRE: dashboard_id is a valid string, output_path is a writable path.
|
||||||
|
# @POST: Returns True if screenshot is saved successfully.
|
||||||
|
# @SIDE_EFFECT: Launches a browser, performs UI login, switches tabs, and writes a PNG file.
|
||||||
|
# @UX_STATE: [Navigating] -> Loading dashboard UI
|
||||||
|
# @UX_STATE: [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
|
||||||
|
# @UX_STATE: [CalculatingHeight] -> Determining dashboard dimensions
|
||||||
|
# @UX_STATE: [Capturing] -> Executing CDP screenshot
|
||||||
|
async def capture_dashboard(self, dashboard_id: str, output_path: str) -> bool:
|
||||||
|
with belief_scope("capture_dashboard", f"dashboard_id={dashboard_id}"):
|
||||||
|
logger.info(f"Capturing screenshot for dashboard {dashboard_id}")
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(
|
||||||
|
headless=True,
|
||||||
|
args=[
|
||||||
|
"--disable-blink-features=AutomationControlled",
|
||||||
|
"--disable-infobars",
|
||||||
|
"--no-sandbox"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# Set a realistic user agent to avoid 403 Forbidden from OpenResty/WAF
|
||||||
|
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
# Construct base UI URL from environment (strip /api/v1 suffix)
|
||||||
|
base_ui_url = self.env.url.rstrip("/")
|
||||||
|
if base_ui_url.endswith("/api/v1"):
|
||||||
|
base_ui_url = base_ui_url[:-len("/api/v1")]
|
||||||
|
|
||||||
|
# Create browser context with realistic headers
|
||||||
|
context = await browser.new_context(
|
||||||
|
viewport={'width': 1280, 'height': 720},
|
||||||
|
user_agent=user_agent,
|
||||||
|
extra_http_headers={
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "none",
|
||||||
|
"Sec-Fetch-User": "?1"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info("Browser context created successfully")
|
||||||
|
|
||||||
|
page = await context.new_page()
|
||||||
|
# Bypass navigator.webdriver detection
|
||||||
|
await page.add_init_script("delete Object.getPrototypeOf(navigator).webdriver")
|
||||||
|
|
||||||
|
# 1. Navigate to login page and authenticate
|
||||||
|
login_url = f"{base_ui_url.rstrip('/')}/login/"
|
||||||
|
logger.info(f"[DEBUG] Navigating to login page: {login_url}")
|
||||||
|
|
||||||
|
response = await page.goto(login_url, wait_until="networkidle", timeout=60000)
|
||||||
|
if response:
|
||||||
|
logger.info(f"[DEBUG] Login page response status: {response.status}")
|
||||||
|
|
||||||
|
# Wait for login form to be ready
|
||||||
|
await page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
# More exhaustive list of selectors for various Superset versions/themes
|
||||||
|
selectors = {
|
||||||
|
"username": ['input[name="username"]', 'input#username', 'input[placeholder*="Username"]', 'input[type="text"]'],
|
||||||
|
"password": ['input[name="password"]', 'input#password', 'input[placeholder*="Password"]', 'input[type="password"]'],
|
||||||
|
"submit": ['button[type="submit"]', 'button#submit', '.btn-primary', 'input[type="submit"]']
|
||||||
|
}
|
||||||
|
logger.info("[DEBUG] Attempting to find login form elements...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find and fill username
|
||||||
|
u_selector = None
|
||||||
|
for s in selectors["username"]:
|
||||||
|
count = await page.locator(s).count()
|
||||||
|
logger.info(f"[DEBUG] Selector '{s}': {count} elements found")
|
||||||
|
if count > 0:
|
||||||
|
u_selector = s
|
||||||
|
break
|
||||||
|
|
||||||
|
if not u_selector:
|
||||||
|
# Log all input fields on the page for debugging
|
||||||
|
all_inputs = await page.locator('input').all()
|
||||||
|
logger.info(f"[DEBUG] Found {len(all_inputs)} input fields on page")
|
||||||
|
for i, inp in enumerate(all_inputs[:5]): # Log first 5
|
||||||
|
inp_type = await inp.get_attribute('type')
|
||||||
|
inp_name = await inp.get_attribute('name')
|
||||||
|
inp_id = await inp.get_attribute('id')
|
||||||
|
logger.info(f"[DEBUG] Input {i}: type={inp_type}, name={inp_name}, id={inp_id}")
|
||||||
|
raise RuntimeError("Could not find username input field on login page")
|
||||||
|
|
||||||
|
logger.info(f"[DEBUG] Filling username field with selector: {u_selector}")
|
||||||
|
await page.fill(u_selector, self.env.username)
|
||||||
|
|
||||||
|
# Find and fill password
|
||||||
|
p_selector = None
|
||||||
|
for s in selectors["password"]:
|
||||||
|
if await page.locator(s).count() > 0:
|
||||||
|
p_selector = s
|
||||||
|
break
|
||||||
|
|
||||||
|
if not p_selector:
|
||||||
|
raise RuntimeError("Could not find password input field on login page")
|
||||||
|
|
||||||
|
logger.info(f"[DEBUG] Filling password field with selector: {p_selector}")
|
||||||
|
await page.fill(p_selector, self.env.password)
|
||||||
|
|
||||||
|
# Click submit
|
||||||
|
s_selector = selectors["submit"][0]
|
||||||
|
for s in selectors["submit"]:
|
||||||
|
if await page.locator(s).count() > 0:
|
||||||
|
s_selector = s
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(f"[DEBUG] Clicking submit button with selector: {s_selector}")
|
||||||
|
await page.click(s_selector)
|
||||||
|
|
||||||
|
# Wait for navigation after login
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||||
|
|
||||||
|
# Check if login was successful
|
||||||
|
if "/login" in page.url:
|
||||||
|
# Check for error messages on page
|
||||||
|
error_msg = await page.locator(".alert-danger, .error-message").text_content() if await page.locator(".alert-danger, .error-message").count() > 0 else "Unknown error"
|
||||||
|
logger.error(f"[DEBUG] Login failed. Still on login page. Error: {error_msg}")
|
||||||
|
debug_path = output_path.replace(".png", "_debug_failed_login.png")
|
||||||
|
await page.screenshot(path=debug_path)
|
||||||
|
raise RuntimeError(f"Login failed: {error_msg}. Debug screenshot saved to {debug_path}")
|
||||||
|
|
||||||
|
logger.info(f"[DEBUG] Login successful. Current URL: {page.url}")
|
||||||
|
|
||||||
|
# Check cookies after successful login
|
||||||
|
page_cookies = await context.cookies()
|
||||||
|
logger.info(f"[DEBUG] Cookies after login: {len(page_cookies)}")
|
||||||
|
for c in page_cookies:
|
||||||
|
logger.info(f"[DEBUG] Cookie: name={c['name']}, domain={c['domain']}, value={c.get('value', '')[:20]}...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
page_title = await page.title()
|
||||||
|
logger.error(f"UI Login failed. Page title: {page_title}, URL: {page.url}, Error: {str(e)}")
|
||||||
|
debug_path = output_path.replace(".png", "_debug_failed_login.png")
|
||||||
|
await page.screenshot(path=debug_path)
|
||||||
|
raise RuntimeError(f"Login failed: {str(e)}. Debug screenshot saved to {debug_path}")
|
||||||
|
|
||||||
|
# 2. Navigate to dashboard
|
||||||
|
# @UX_STATE: [Navigating] -> Loading dashboard UI
|
||||||
|
dashboard_url = f"{base_ui_url.rstrip('/')}/superset/dashboard/{dashboard_id}/?standalone=true"
|
||||||
|
|
||||||
|
if base_ui_url.startswith("https://") and dashboard_url.startswith("http://"):
|
||||||
|
dashboard_url = dashboard_url.replace("http://", "https://")
|
||||||
|
|
||||||
|
logger.info(f"[DEBUG] Navigating to dashboard: {dashboard_url}")
|
||||||
|
|
||||||
|
# Use networkidle to ensure all initial assets are loaded
|
||||||
|
response = await page.goto(dashboard_url, wait_until="networkidle", timeout=60000)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
logger.info(f"[DEBUG] Dashboard navigation response status: {response.status}, URL: {response.url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Wait for the dashboard grid to be present
|
||||||
|
await page.wait_for_selector('.dashboard-component, .dashboard-header, [data-test="dashboard-grid"]', timeout=30000)
|
||||||
|
logger.info("[DEBUG] Dashboard container loaded")
|
||||||
|
|
||||||
|
# Wait for charts to finish loading (Superset uses loading spinners/skeletons)
|
||||||
|
# We wait until loading indicators disappear or a timeout occurs
|
||||||
|
try:
|
||||||
|
# Wait for loading indicators to disappear
|
||||||
|
await page.wait_for_selector('.loading, .ant-skeleton, .spinner', state="hidden", timeout=60000)
|
||||||
|
logger.info("[DEBUG] Loading indicators hidden")
|
||||||
|
except Exception:
|
||||||
|
logger.warning("[DEBUG] Timeout waiting for loading indicators to hide")
|
||||||
|
|
||||||
|
# Wait for charts to actually render their content (e.g., ECharts, NVD3)
|
||||||
|
# We look for common chart containers that should have content
|
||||||
|
try:
|
||||||
|
await page.wait_for_selector('.chart-container canvas, .slice_container svg, .superset-chart-canvas, .grid-content .chart-container', timeout=60000)
|
||||||
|
logger.info("[DEBUG] Chart content detected")
|
||||||
|
except Exception:
|
||||||
|
logger.warning("[DEBUG] Timeout waiting for chart content")
|
||||||
|
|
||||||
|
# Additional check: wait for all chart containers to have non-empty content
|
||||||
|
logger.info("[DEBUG] Waiting for all charts to have rendered content...")
|
||||||
|
await page.wait_for_function("""() => {
|
||||||
|
const charts = document.querySelectorAll('.chart-container, .slice_container');
|
||||||
|
if (charts.length === 0) return true; // No charts to wait for
|
||||||
|
|
||||||
|
// Check if all charts have rendered content (canvas, svg, or non-empty div)
|
||||||
|
return Array.from(charts).every(chart => {
|
||||||
|
const hasCanvas = chart.querySelector('canvas') !== null;
|
||||||
|
const hasSvg = chart.querySelector('svg') !== null;
|
||||||
|
const hasContent = chart.innerText.trim().length > 0 || chart.children.length > 0;
|
||||||
|
return hasCanvas || hasSvg || hasContent;
|
||||||
|
});
|
||||||
|
}""", timeout=60000)
|
||||||
|
logger.info("[DEBUG] All charts have rendered content")
|
||||||
|
|
||||||
|
# Scroll to bottom and back to top to trigger lazy loading of all charts
|
||||||
|
logger.info("[DEBUG] Scrolling to trigger lazy loading...")
|
||||||
|
await page.evaluate("""async () => {
|
||||||
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
for (let i = 0; i < document.body.scrollHeight; i += 500) {
|
||||||
|
window.scrollTo(0, i);
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
await delay(500);
|
||||||
|
}""")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[DEBUG] Dashboard content wait failed: {e}, proceeding anyway after delay")
|
||||||
|
|
||||||
|
# Final stabilization delay - increased for complex dashboards
|
||||||
|
logger.info("[DEBUG] Final stabilization delay...")
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
|
||||||
|
# Logic to handle tabs and full-page capture
|
||||||
|
try:
|
||||||
|
# 1. Handle Tabs (Recursive switching)
|
||||||
|
# @UX_STATE: [TabSwitching] -> Iterating through dashboard tabs to trigger lazy loading
|
||||||
|
processed_tabs = set()
|
||||||
|
|
||||||
|
async def switch_tabs(depth=0):
|
||||||
|
if depth > 3:
|
||||||
|
return # Limit recursion depth
|
||||||
|
|
||||||
|
tab_selectors = [
|
||||||
|
'.ant-tabs-nav-list .ant-tabs-tab',
|
||||||
|
'.dashboard-component-tabs .ant-tabs-tab',
|
||||||
|
'[data-test="dashboard-component-tabs"] .ant-tabs-tab'
|
||||||
|
]
|
||||||
|
|
||||||
|
found_tabs = []
|
||||||
|
for selector in tab_selectors:
|
||||||
|
found_tabs = await page.locator(selector).all()
|
||||||
|
if found_tabs:
|
||||||
|
break
|
||||||
|
|
||||||
|
if found_tabs:
|
||||||
|
logger.info(f"[DEBUG][TabSwitching] Found {len(found_tabs)} tabs at depth {depth}")
|
||||||
|
for i, tab in enumerate(found_tabs):
|
||||||
|
try:
|
||||||
|
tab_text = (await tab.inner_text()).strip()
|
||||||
|
tab_id = f"{depth}_{i}_{tab_text}"
|
||||||
|
|
||||||
|
if tab_id in processed_tabs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if await tab.is_visible():
|
||||||
|
logger.info(f"[DEBUG][TabSwitching] Switching to tab: {tab_text}")
|
||||||
|
processed_tabs.add(tab_id)
|
||||||
|
|
||||||
|
is_active = "ant-tabs-tab-active" in (await tab.get_attribute("class") or "")
|
||||||
|
if not is_active:
|
||||||
|
await tab.click()
|
||||||
|
await asyncio.sleep(2) # Wait for content to render
|
||||||
|
|
||||||
|
await switch_tabs(depth + 1)
|
||||||
|
except Exception as tab_e:
|
||||||
|
logger.warning(f"[DEBUG][TabSwitching] Failed to process tab {i}: {tab_e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
first_tab = found_tabs[0]
|
||||||
|
if "ant-tabs-tab-active" not in (await first_tab.get_attribute("class") or ""):
|
||||||
|
await first_tab.click()
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await switch_tabs()
|
||||||
|
|
||||||
|
# 2. Calculate full height for screenshot
|
||||||
|
# @UX_STATE: [CalculatingHeight] -> Determining dashboard dimensions
|
||||||
|
full_height = await page.evaluate("""() => {
|
||||||
|
const body = document.body;
|
||||||
|
const html = document.documentElement;
|
||||||
|
const dashboardContent = document.querySelector('.dashboard-content');
|
||||||
|
|
||||||
|
return Math.max(
|
||||||
|
body.scrollHeight, body.offsetHeight,
|
||||||
|
html.clientHeight, html.scrollHeight, html.offsetHeight,
|
||||||
|
dashboardContent ? dashboardContent.scrollHeight + 100 : 0
|
||||||
|
);
|
||||||
|
}""")
|
||||||
|
logger.info(f"[DEBUG] Calculated full height: {full_height}")
|
||||||
|
|
||||||
|
# DIAGNOSTIC: Count chart elements before resize
|
||||||
|
chart_count_before = await page.evaluate("""() => {
|
||||||
|
return {
|
||||||
|
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
|
||||||
|
canvasElements: document.querySelectorAll('canvas').length,
|
||||||
|
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
|
||||||
|
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
|
||||||
|
};
|
||||||
|
}""")
|
||||||
|
logger.info(f"[DIAGNOSTIC] Chart elements BEFORE viewport resize: {chart_count_before}")
|
||||||
|
|
||||||
|
# DIAGNOSTIC: Capture pre-resize screenshot for comparison
|
||||||
|
pre_resize_path = output_path.replace(".png", "_preresize.png")
|
||||||
|
try:
|
||||||
|
await page.screenshot(path=pre_resize_path, full_page=False, timeout=10000)
|
||||||
|
import os
|
||||||
|
pre_resize_size = os.path.getsize(pre_resize_path) if os.path.exists(pre_resize_path) else 0
|
||||||
|
logger.info(f"[DIAGNOSTIC] Pre-resize screenshot saved: {pre_resize_path} ({pre_resize_size} bytes)")
|
||||||
|
except Exception as pre_e:
|
||||||
|
logger.warning(f"[DIAGNOSTIC] Failed to capture pre-resize screenshot: {pre_e}")
|
||||||
|
|
||||||
|
logger.info(f"[DIAGNOSTIC] Resizing viewport from current to 1920x{int(full_height)}")
|
||||||
|
await page.set_viewport_size({"width": 1920, "height": int(full_height)})
|
||||||
|
|
||||||
|
# DIAGNOSTIC: Increased wait time and log timing
|
||||||
|
logger.info("[DIAGNOSTIC] Waiting 10 seconds after viewport resize for re-render...")
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
logger.info("[DIAGNOSTIC] Wait completed")
|
||||||
|
|
||||||
|
# DIAGNOSTIC: Count chart elements after resize and wait
|
||||||
|
chart_count_after = await page.evaluate("""() => {
|
||||||
|
return {
|
||||||
|
chartContainers: document.querySelectorAll('.chart-container, .slice_container').length,
|
||||||
|
canvasElements: document.querySelectorAll('canvas').length,
|
||||||
|
svgElements: document.querySelectorAll('.chart-container svg, .slice_container svg').length,
|
||||||
|
visibleCharts: document.querySelectorAll('.chart-container:visible, .slice_container:visible').length
|
||||||
|
};
|
||||||
|
}""")
|
||||||
|
logger.info(f"[DIAGNOSTIC] Chart elements AFTER viewport resize + wait: {chart_count_after}")
|
||||||
|
|
||||||
|
# DIAGNOSTIC: Check if any charts have error states
|
||||||
|
chart_errors = await page.evaluate("""() => {
|
||||||
|
const errors = [];
|
||||||
|
document.querySelectorAll('.chart-container, .slice_container').forEach((chart, i) => {
|
||||||
|
const errorEl = chart.querySelector('.error, .alert-danger, .ant-alert-error');
|
||||||
|
if (errorEl) {
|
||||||
|
errors.push({index: i, text: errorEl.innerText.substring(0, 100)});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}""")
|
||||||
|
if chart_errors:
|
||||||
|
logger.warning(f"[DIAGNOSTIC] Charts with error states detected: {chart_errors}")
|
||||||
|
else:
|
||||||
|
logger.info("[DIAGNOSTIC] No chart error states detected")
|
||||||
|
|
||||||
|
# 3. Take screenshot using CDP to bypass Playwright's font loading wait
|
||||||
|
# @UX_STATE: [Capturing] -> Executing CDP screenshot
|
||||||
|
logger.info("[DEBUG] Attempting full-page screenshot via CDP...")
|
||||||
|
cdp = await page.context.new_cdp_session(page)
|
||||||
|
|
||||||
|
screenshot_data = await cdp.send("Page.captureScreenshot", {
|
||||||
|
"format": "png",
|
||||||
|
"fromSurface": True,
|
||||||
|
"captureBeyondViewport": True
|
||||||
|
})
|
||||||
|
|
||||||
|
image_data = base64.b64decode(screenshot_data["data"])
|
||||||
|
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(image_data)
|
||||||
|
|
||||||
|
# DIAGNOSTIC: Verify screenshot file
|
||||||
|
import os
|
||||||
|
final_size = os.path.getsize(output_path) if os.path.exists(output_path) else 0
|
||||||
|
logger.info(f"[DIAGNOSTIC] Final screenshot saved: {output_path}")
|
||||||
|
logger.info(f"[DIAGNOSTIC] Final screenshot size: {final_size} bytes ({final_size / 1024:.2f} KB)")
|
||||||
|
|
||||||
|
# DIAGNOSTIC: Get image dimensions
|
||||||
|
try:
|
||||||
|
with Image.open(output_path) as final_img:
|
||||||
|
logger.info(f"[DIAGNOSTIC] Final screenshot dimensions: {final_img.width}x{final_img.height}")
|
||||||
|
except Exception as img_err:
|
||||||
|
logger.warning(f"[DIAGNOSTIC] Could not read final image dimensions: {img_err}")
|
||||||
|
|
||||||
|
logger.info(f"Full-page screenshot saved to {output_path} (via CDP)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DEBUG] Full-page/Tab capture failed: {e}")
|
||||||
|
try:
|
||||||
|
await page.screenshot(path=output_path, full_page=True, timeout=10000)
|
||||||
|
except Exception as e2:
|
||||||
|
logger.error(f"[DEBUG] Fallback screenshot also failed: {e2}")
|
||||||
|
await page.screenshot(path=output_path, timeout=5000)
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
return True
|
||||||
|
# [/DEF:ScreenshotService.capture_dashboard:Function]
|
||||||
|
# [/DEF:ScreenshotService:Class]
|
||||||
|
|
||||||
|
# [DEF:LLMClient:Class]
|
||||||
|
# @PURPOSE: Wrapper for LLM provider APIs.
|
||||||
|
class LLMClient:
|
||||||
|
# [DEF:LLMClient.__init__:Function]
|
||||||
|
# @PURPOSE: Initializes the LLMClient with provider settings.
|
||||||
|
# @PRE: api_key, base_url, and default_model are non-empty strings.
|
||||||
|
def __init__(self, provider_type: LLMProviderType, api_key: str, base_url: str, default_model: str):
|
||||||
|
self.provider_type = provider_type
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base_url = base_url
|
||||||
|
self.default_model = default_model
|
||||||
|
|
||||||
|
# DEBUG: Log initialization parameters (without exposing full API key)
|
||||||
|
logger.info("[LLMClient.__init__] Initializing LLM client:")
|
||||||
|
logger.info(f"[LLMClient.__init__] Provider Type: {provider_type}")
|
||||||
|
logger.info(f"[LLMClient.__init__] Base URL: {base_url}")
|
||||||
|
logger.info(f"[LLMClient.__init__] Default Model: {default_model}")
|
||||||
|
logger.info(f"[LLMClient.__init__] API Key (first 8 chars): {api_key[:8] if api_key and len(api_key) > 8 else 'EMPTY_OR_NONE'}...")
|
||||||
|
logger.info(f"[LLMClient.__init__] API Key Length: {len(api_key) if api_key else 0}")
|
||||||
|
|
||||||
|
self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||||
|
# [/DEF:LLMClient.__init__:Function]
|
||||||
|
|
||||||
|
# [DEF:LLMClient.get_json_completion:Function]
|
||||||
|
# @PURPOSE: Helper to handle LLM calls with JSON mode and fallback parsing.
|
||||||
|
# @PRE: messages is a list of valid message dictionaries.
|
||||||
|
# @POST: Returns a parsed JSON dictionary.
|
||||||
|
# @SIDE_EFFECT: Calls external LLM API.
|
||||||
|
def _should_retry(exception: Exception) -> bool:
|
||||||
|
"""Custom retry predicate that excludes authentication errors."""
|
||||||
|
# Don't retry on authentication errors
|
||||||
|
if isinstance(exception, OpenAIAuthenticationError):
|
||||||
|
return False
|
||||||
|
# Retry on rate limit errors and other exceptions
|
||||||
|
return isinstance(exception, (RateLimitError, Exception))
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(5),
|
||||||
|
wait=wait_exponential(multiplier=2, min=5, max=60),
|
||||||
|
retry=retry_if_exception(_should_retry),
|
||||||
|
reraise=True
|
||||||
|
)
|
||||||
|
async def get_json_completion(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
with belief_scope("get_json_completion"):
|
||||||
|
response = None
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
logger.info(f"[get_json_completion] Attempting LLM call with JSON mode for model: {self.default_model}")
|
||||||
|
logger.info(f"[get_json_completion] Base URL being used: {self.base_url}")
|
||||||
|
logger.info(f"[get_json_completion] Number of messages: {len(messages)}")
|
||||||
|
logger.info(f"[get_json_completion] API Key present: {bool(self.api_key and len(self.api_key) > 0)}")
|
||||||
|
|
||||||
|
response = await self.client.chat.completions.create(
|
||||||
|
model=self.default_model,
|
||||||
|
messages=messages,
|
||||||
|
response_format={"type": "json_object"}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if "JSON mode is not enabled" in str(e) or "400" in str(e):
|
||||||
|
logger.warning(f"[get_json_completion] JSON mode failed or not supported: {str(e)}. Falling back to plain text response.")
|
||||||
|
response = await self.client.chat.completions.create(
|
||||||
|
model=self.default_model,
|
||||||
|
messages=messages
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
logger.debug(f"[get_json_completion] LLM Response: {response}")
|
||||||
|
except OpenAIAuthenticationError as e:
|
||||||
|
logger.error(f"[get_json_completion] Authentication error: {str(e)}")
|
||||||
|
# Do not retry on auth errors - re-raise to stop retry
|
||||||
|
raise
|
||||||
|
except RateLimitError as e:
|
||||||
|
logger.warning(f"[get_json_completion] Rate limit hit: {str(e)}")
|
||||||
|
|
||||||
|
# Extract retry_delay from error metadata if available
|
||||||
|
retry_delay = 5.0 # Default fallback
|
||||||
|
try:
|
||||||
|
# Based on logs, the raw response is in e.body or e.response.json()
|
||||||
|
# The logs show 'metadata': {'raw': '...'} which suggests a proxy or specific client wrapper
|
||||||
|
# Let's try to find the 'retryDelay' in the error message or response
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Try to find "retryDelay": "XXs" in the string representation of the error
|
||||||
|
error_str = str(e)
|
||||||
|
match = re.search(r'"retryDelay":\s*"(\d+)s"', error_str)
|
||||||
|
if match:
|
||||||
|
retry_delay = float(match.group(1))
|
||||||
|
else:
|
||||||
|
# Try to parse from response if it's a standard OpenAI-like error with body
|
||||||
|
if hasattr(e, 'body') and isinstance(e.body, dict):
|
||||||
|
# Some providers put it in details
|
||||||
|
details = e.body.get('error', {}).get('details', [])
|
||||||
|
for detail in details:
|
||||||
|
if detail.get('@type') == 'type.googleapis.com/google.rpc.RetryInfo':
|
||||||
|
delay_str = detail.get('retryDelay', '5s')
|
||||||
|
retry_delay = float(delay_str.rstrip('s'))
|
||||||
|
break
|
||||||
|
except Exception as parse_e:
|
||||||
|
logger.debug(f"[get_json_completion] Failed to parse retry delay: {parse_e}")
|
||||||
|
|
||||||
|
# Add a small safety margin (0.5s) as requested
|
||||||
|
wait_time = retry_delay + 0.5
|
||||||
|
logger.info(f"[get_json_completion] Waiting for {wait_time}s before retry...")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[get_json_completion] LLM call failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not response or not hasattr(response, 'choices') or not response.choices:
|
||||||
|
raise RuntimeError(f"Invalid LLM response: {response}")
|
||||||
|
|
||||||
|
content = response.choices[0].message.content
|
||||||
|
logger.debug(f"[get_json_completion] Raw content to parse: {content}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("[get_json_completion] Failed to parse JSON directly, attempting to extract from code blocks")
|
||||||
|
if "```json" in content:
|
||||||
|
json_str = content.split("```json")[1].split("```")[0].strip()
|
||||||
|
return json.loads(json_str)
|
||||||
|
elif "```" in content:
|
||||||
|
json_str = content.split("```")[1].split("```")[0].strip()
|
||||||
|
return json.loads(json_str)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
# [/DEF:LLMClient.get_json_completion:Function]
|
||||||
|
|
||||||
|
# [DEF:LLMClient.analyze_dashboard:Function]
|
||||||
|
# @PURPOSE: Sends dashboard data (screenshot + logs) to LLM for health analysis.
|
||||||
|
# @PRE: screenshot_path exists, logs is a list of strings.
|
||||||
|
# @POST: Returns a structured analysis dictionary (status, summary, issues).
|
||||||
|
# @SIDE_EFFECT: Reads screenshot file and calls external LLM API.
|
||||||
|
async def analyze_dashboard(self, screenshot_path: str, logs: List[str]) -> Dict[str, Any]:
|
||||||
|
with belief_scope("analyze_dashboard"):
|
||||||
|
# Optimize image to reduce token count (US1 / T023)
|
||||||
|
# Gemini/Gemma models have limits on input tokens, and large images contribute significantly.
|
||||||
|
try:
|
||||||
|
with Image.open(screenshot_path) as img:
|
||||||
|
# Convert to RGB if necessary
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
# Resize if too large (max 1024px width while maintaining aspect ratio)
|
||||||
|
# We reduce width further to 1024px to stay within token limits for long dashboards
|
||||||
|
max_width = 1024
|
||||||
|
if img.width > max_width or img.height > 2048:
|
||||||
|
# Calculate scaling factor to fit within 1024x2048
|
||||||
|
scale = min(max_width / img.width, 2048 / img.height)
|
||||||
|
if scale < 1.0:
|
||||||
|
new_width = int(img.width * scale)
|
||||||
|
new_height = int(img.height * scale)
|
||||||
|
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
logger.info(f"[analyze_dashboard] Resized image from {img.width}x{img.height} to {new_width}x{new_height}")
|
||||||
|
|
||||||
|
# Compress and convert to base64
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
# Lower quality to 60% to further reduce payload size
|
||||||
|
img.save(buffer, format="JPEG", quality=60, optimize=True)
|
||||||
|
base_64_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
logger.info(f"[analyze_dashboard] Optimized image size: {len(buffer.getvalue()) / 1024:.2f} KB")
|
||||||
|
except Exception as img_e:
|
||||||
|
logger.warning(f"[analyze_dashboard] Image optimization failed: {img_e}. Using raw image.")
|
||||||
|
with open(screenshot_path, "rb") as image_file:
|
||||||
|
base_64_image = base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
log_text = "\n".join(logs)
|
||||||
|
prompt = f"""
|
||||||
|
Analyze the attached dashboard screenshot and the following execution logs for health and visual issues.
|
||||||
|
|
||||||
|
Logs:
|
||||||
|
{log_text}
|
||||||
|
|
||||||
|
Provide the analysis in JSON format with the following structure:
|
||||||
|
{{
|
||||||
|
"status": "PASS" | "WARN" | "FAIL",
|
||||||
|
"summary": "Short summary of findings",
|
||||||
|
"issues": [
|
||||||
|
{{
|
||||||
|
"severity": "WARN" | "FAIL",
|
||||||
|
"message": "Description of the issue",
|
||||||
|
"location": "Optional location info (e.g. chart name)"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/jpeg;base64,{base_64_image}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self.get_json_completion(messages)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[analyze_dashboard] Failed to get analysis: {str(e)}")
|
||||||
|
return {
|
||||||
|
"status": "FAIL",
|
||||||
|
"summary": f"Failed to get response from LLM: {str(e)}",
|
||||||
|
"issues": [{"severity": "FAIL", "message": "LLM provider returned empty or invalid response"}]
|
||||||
|
}
|
||||||
|
# [/DEF:LLMClient.analyze_dashboard:Function]
|
||||||
|
# [/DEF:LLMClient:Class]
|
||||||
|
|
||||||
|
# [/DEF:backend/src/plugins/llm_analysis/service.py:Module]
|
||||||
215
backend/src/plugins/mapper.py
Normal file
215
backend/src/plugins/mapper.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# [DEF:MapperPluginModule:Module]
|
||||||
|
# @SEMANTICS: plugin, mapper, datasets, postgresql, excel
|
||||||
|
# @PURPOSE: Implements a plugin for mapping dataset columns using external database connections or Excel files.
|
||||||
|
# @LAYER: Plugins
|
||||||
|
# @RELATION: Inherits from PluginBase. Uses DatasetMapper from superset_tool.
|
||||||
|
# @RELATION: USES -> TaskContext
|
||||||
|
# @CONSTRAINT: Must use belief_scope for logging.
|
||||||
|
|
||||||
|
# [SECTION: IMPORTS]
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from ..core.plugin_base import PluginBase
|
||||||
|
from ..core.superset_client import SupersetClient
|
||||||
|
from ..core.logger import logger, belief_scope
|
||||||
|
from ..core.database import SessionLocal
|
||||||
|
from ..models.connection import ConnectionConfig
|
||||||
|
from ..core.utils.dataset_mapper import DatasetMapper
|
||||||
|
from ..core.task_manager.context import TaskContext
|
||||||
|
# [/SECTION]
|
||||||
|
|
||||||
|
# [DEF:MapperPlugin:Class]
|
||||||
|
# @PURPOSE: Plugin for mapping dataset columns verbose names.
|
||||||
|
class MapperPlugin(PluginBase):
|
||||||
|
"""
|
||||||
|
Plugin for mapping dataset columns verbose names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:id:Function]
|
||||||
|
# @PURPOSE: Returns the unique identifier for the mapper plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string ID.
|
||||||
|
# @RETURN: str - "dataset-mapper"
|
||||||
|
def id(self) -> str:
|
||||||
|
with belief_scope("id"):
|
||||||
|
return "dataset-mapper"
|
||||||
|
# [/DEF:id:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:name:Function]
|
||||||
|
# @PURPOSE: Returns the human-readable name of the mapper plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string name.
|
||||||
|
# @RETURN: str - Plugin name.
|
||||||
|
def name(self) -> str:
|
||||||
|
with belief_scope("name"):
|
||||||
|
return "Dataset Mapper"
|
||||||
|
# [/DEF:name:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:description:Function]
|
||||||
|
# @PURPOSE: Returns a description of the mapper plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string description.
|
||||||
|
# @RETURN: str - Plugin description.
|
||||||
|
def description(self) -> str:
|
||||||
|
with belief_scope("description"):
|
||||||
|
return "Map dataset column verbose names using PostgreSQL comments or Excel files."
|
||||||
|
# [/DEF:description:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:version:Function]
|
||||||
|
# @PURPOSE: Returns the version of the mapper plugin.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns string version.
|
||||||
|
# @RETURN: str - "1.0.0"
|
||||||
|
def version(self) -> str:
|
||||||
|
with belief_scope("version"):
|
||||||
|
return "1.0.0"
|
||||||
|
# [/DEF:version:Function]
|
||||||
|
|
||||||
|
@property
|
||||||
|
# [DEF:ui_route:Function]
|
||||||
|
# @PURPOSE: Returns the frontend route for the mapper plugin.
|
||||||
|
# @RETURN: str - "/tools/mapper"
|
||||||
|
def ui_route(self) -> str:
|
||||||
|
with belief_scope("ui_route"):
|
||||||
|
return "/tools/mapper"
|
||||||
|
# [/DEF:ui_route:Function]
|
||||||
|
|
||||||
|
# [DEF:get_schema:Function]
|
||||||
|
# @PURPOSE: Returns the JSON schema for the mapper plugin parameters.
|
||||||
|
# @PRE: Plugin instance exists.
|
||||||
|
# @POST: Returns dictionary schema.
|
||||||
|
# @RETURN: Dict[str, Any] - JSON schema.
|
||||||
|
def get_schema(self) -> Dict[str, Any]:
|
||||||
|
with belief_scope("get_schema"):
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"env": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Environment",
|
||||||
|
"description": "The Superset environment (e.g., 'dev')."
|
||||||
|
},
|
||||||
|
"dataset_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Dataset ID",
|
||||||
|
"description": "The ID of the dataset to update."
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Mapping Source",
|
||||||
|
"enum": ["postgres", "excel"],
|
||||||
|
"default": "postgres"
|
||||||
|
},
|
||||||
|
"connection_id": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Saved Connection",
|
||||||
|
"description": "The ID of a saved database connection (for postgres source)."
|
||||||
|
},
|
||||||
|
"table_name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Table Name",
|
||||||
|
"description": "Target table name in PostgreSQL."
|
||||||
|
},
|
||||||
|
"table_schema": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Table Schema",
|
||||||
|
"description": "Target table schema in PostgreSQL.",
|
||||||
|
"default": "public"
|
||||||
|
},
|
||||||
|
"excel_path": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Excel Path",
|
||||||
|
"description": "Path to the Excel file (for excel source)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["env", "dataset_id", "source"]
|
||||||
|
}
|
||||||
|
# [/DEF:get_schema:Function]
|
||||||
|
|
||||||
|
# [DEF:execute:Function]
|
||||||
|
# @PURPOSE: Executes the dataset mapping logic with TaskContext support.
|
||||||
|
# @PARAM: params (Dict[str, Any]) - Mapping parameters.
|
||||||
|
# @PARAM: context (Optional[TaskContext]) - Task context for logging with source attribution.
|
||||||
|
# @PRE: Params contain valid 'env', 'dataset_id', and 'source'. params must be a dictionary.
|
||||||
|
# @POST: Updates the dataset in Superset.
|
||||||
|
# @RETURN: Dict[str, Any] - Execution status.
|
||||||
|
async def execute(self, params: Dict[str, Any], context: Optional[TaskContext] = None) -> Dict[str, Any]:
|
||||||
|
with belief_scope("execute"):
|
||||||
|
env_name = params.get("env")
|
||||||
|
dataset_id = params.get("dataset_id")
|
||||||
|
source = params.get("source")
|
||||||
|
|
||||||
|
# Use TaskContext logger if available, otherwise fall back to app logger
|
||||||
|
log = context.logger if context else logger
|
||||||
|
|
||||||
|
# Create sub-loggers for different components
|
||||||
|
superset_log = log.with_source("superset_api") if context else log
|
||||||
|
db_log = log.with_source("postgres") if context else log
|
||||||
|
|
||||||
|
if not env_name or dataset_id is None or not source:
|
||||||
|
log.error("Missing required parameters: env, dataset_id, source")
|
||||||
|
raise ValueError("Missing required parameters: env, dataset_id, source")
|
||||||
|
|
||||||
|
# Get config and initialize client
|
||||||
|
from ..dependencies import get_config_manager
|
||||||
|
config_manager = get_config_manager()
|
||||||
|
env_config = config_manager.get_environment(env_name)
|
||||||
|
if not env_config:
|
||||||
|
log.error(f"Environment '{env_name}' not found in configuration.")
|
||||||
|
raise ValueError(f"Environment '{env_name}' not found in configuration.")
|
||||||
|
|
||||||
|
client = SupersetClient(env_config)
|
||||||
|
client.authenticate()
|
||||||
|
|
||||||
|
postgres_config = None
|
||||||
|
if source == "postgres":
|
||||||
|
connection_id = params.get("connection_id")
|
||||||
|
if not connection_id:
|
||||||
|
log.error("connection_id is required for postgres source.")
|
||||||
|
raise ValueError("connection_id is required for postgres source.")
|
||||||
|
|
||||||
|
# Load connection from DB
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
conn_config = db.query(ConnectionConfig).filter(ConnectionConfig.id == connection_id).first()
|
||||||
|
if not conn_config:
|
||||||
|
db_log.error(f"Connection {connection_id} not found.")
|
||||||
|
raise ValueError(f"Connection {connection_id} not found.")
|
||||||
|
|
||||||
|
postgres_config = {
|
||||||
|
'dbname': conn_config.database,
|
||||||
|
'user': conn_config.username,
|
||||||
|
'password': conn_config.password,
|
||||||
|
'host': conn_config.host,
|
||||||
|
'port': str(conn_config.port) if conn_config.port else '5432'
|
||||||
|
}
|
||||||
|
db_log.debug(f"Loaded connection config for {conn_config.host}:{conn_config.port}/{conn_config.database}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
log.info(f"Starting mapping for dataset {dataset_id} in {env_name}")
|
||||||
|
|
||||||
|
mapper = DatasetMapper()
|
||||||
|
|
||||||
|
try:
|
||||||
|
mapper.run_mapping(
|
||||||
|
superset_client=client,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
source=source,
|
||||||
|
postgres_config=postgres_config,
|
||||||
|
excel_path=params.get("excel_path"),
|
||||||
|
table_name=params.get("table_name"),
|
||||||
|
table_schema=params.get("table_schema") or "public"
|
||||||
|
)
|
||||||
|
superset_log.info(f"Mapping completed for dataset {dataset_id}")
|
||||||
|
return {"status": "success", "dataset_id": dataset_id}
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Mapping failed: {e}")
|
||||||
|
raise
|
||||||
|
# [/DEF:execute:Function]
|
||||||
|
|
||||||
|
# [/DEF:MapperPlugin:Class]
|
||||||
|
# [/DEF:MapperPluginModule:Module]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user